commit cd591c14e368bbd0c59dbd92afb6bf3d1942bc0b Author: Omni Date: Fri Sep 5 20:46:24 2025 +0100 Initial public release v0.1.0 - Linux Wabbajack Modlist Application Jackify provides native Linux support for Wabbajack modlist installation and management with automated Steam integration and Proton configuration. Key Features: - Almost Native Linux implementation (texconv.exe run via proton) - Automated Steam shortcut creation and Proton prefix management - Both CLI and GUI interfaces, with Steam Deck optimization Supported Games: - Skyrim Special Edition - Fallout 4 - Fallout New Vegas - Oblivion, Starfield, Enderal, and diverse other games Technical Architecture: - Clean separation between frontend and backend services - Powered by jackify-engine 0.3.x for Wabbajack-matching modlist installation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..448e5cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environment +venv/ +.env/ + +# Logs +*.log +jackify-install-engine/logs/ +logs/ + +# Build artifacts +build/ +dist/ +core/__pycache__/ +core/modules/__pycache__/ + +# Jackify engine temp and output files +jackify-install-engine/temp/ +jackify/engine/temp/ +jackify-install-engine/*.log +jackify-install-engine/*.dds + +# Editor/OS temp files +*.tmp +*.bak +*.swp +.DS_Store +Thumbs.db + +# NOTE: .dll, .json, .deps.json, .runtimeconfig.json in jackify-install-engine/ +# are currently tracked because it's unclear if they are required for runtime/distribution +# or are build artifacts. Do not ignore until this is confirmed. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c4e4215 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,450 @@ +# Jackify Changelog + +## v0.0.30 - FNV/Enderal Support and better Modlist Selection +**Release Date:** September 5, 2025 + +### Major Features +- **FNV and Enderal Modlist Support**: Complete implementation for Fallout New Vegas and Enderal modlists + - Automatic detection via nvse_loader.exe and Enderal Launcher.exe + - Wine components routing to vanilla game compatdata (AppID 22380 for FNV, 933480 for Enderal) + - Proper launch options with STEAM_COMPAT_DATA_PATH before Steam restart + - Skip DXVK.conf creation for special games using vanilla compatdata +- **Enhanced Configuration Output**: Improved visual consistency with proper section headers and timing phases + +### Bug Fixes +- **Process Cleanup**: Fixed critical bug where jackify-engine processes weren't terminated when GUI window closes unexpectedly + - Added cleanup() method to ModlistOperations class for graceful process termination + - Enhanced cleanup_processes() methods across all GUI screens + - Integrated with existing main GUI cleanup infrastructure +- **Enderal Support**: Fixed Enderal modlists incorrectly showing "unsupported game" dialog + - Added Enderal to supported games lists across codebase + - Updated WabbajackParser with proper Enderal game type mappings +- **Configuration Formatting**: Resolved output formatting inconsistencies between phases + +### Improved Modlist Selection Interface +- **Table Layout**: Replaced simple list with organized table showing Modlist Name, Download Size, Install Size, and Total Size in separate columns +- **Server-Side Filtering**: Improved performance by filtering modlists at the engine level instead of client-side +- **NSFW Checkbox**: Added "Show NSFW" checkbox in modlist selection (defaults to hidden) +- **Enhanced Status Indicators**: Clear indicators for unavailable modlists ([DOWN] with strikethrough) and adult content ([NSFW] in red) +- **Download Size Information**: Display all three size metrics (Download | Install | Total) to help users plan storage requirements +- **This is the first step towards a vastly improved Modlist Selection, with more to come soon.** +- **Making use of the updated jackify-engine features, such as --game and --show-all-sizes flags** + +### Technical Improvements +- **Special Game Detection**: Detection system using multiple fallback mechanisms +`- **Timing System**: Implemented phase separation with proper timing resets between Installation and Configuration phases +- **Thread Management**: Improved cleanup of ConfigThread instances across configure screens + +--- + +## v0.0.29 - STL tidy-up, jackify-engine 0.3.10 and bug fixes +**Release Date:** August 31, 2025 + +### Major Features +- **STL Dependency Completely Removed**: Removed the remaining steamtinkerlaunch traces. +- **Cross-Distribution Compatibility**: Fixed settings menu and API link compatibility issues + +### Engine Updates +- **jackify-engine 0.3.10**: Improvements to manual download handling and error clarity +- **Manual Download Detection**: Phase 1 system for detecting files requiring manual download with user-friendly summaries +- **Enhanced Error Handling**: Clear distinction between corrupted files, download failures, and hash mismatches +- **Automatic Cleanup**: Corrupted files automatically deleted with clear guidance on root cause +- **Better User Experience**: Numbered download instructions with exact URLs - I will be improving manual downloads in future, but this is a first step. + +### Technical Improvements +- **Compatibility Fixes**: Resolved UnboundLocalError in settings menu and Qt library conflicts +- **Steam Shortcut Fix**: Fixed regression with Steam shortcut creation + + +--- + +## v0.0.28 - Conditional Path Manipulation and Engine Update +**Release Date:** August 30, 2025 + +### Major Features +- **Conditional Path Manipulation**: Install a Modlist and Tuxborn Auto workflows now skip redundant path manipulation since jackify-engine 0.3.7 outputs correct paths directly +- **Workflow Optimization**: Configure New/Existing modlists retain path manipulation for manual installations +- **Engine Architecture**: Leverages jackify-engine's improved ModOrganizer.ini path handling + +### Engine Updates +- **jackify-engine 0.3.8**: Enhanced ModOrganizer.ini path generation eliminates need for post-processing in engine-based workflows + +### Technical Improvements +- **Selective Path Processing**: Added `engine_installed` flag to ModlistContext for workflow differentiation +- **Build System**: AppImage builds now use dynamic version extraction from source + +### Bug Fixes +- **Path Corruption Prevention**: Eliminates redundant path manipulation that could introduce corruption +- **Version Consistency**: Fixed AppImage builds to use correct version numbers automatically +- **Steam Restart Reliability**: Improved Steam restart success rate by using aggressive pkill approach instead of unreliable steam -shutdown command +- **Settings Menu Compatibility**: Fixed UnboundLocalError for 'row' variable when resource_settings is empty +- **API Link Compatibility**: Replaced QDesktopServices with subprocess-based URL opening to resolve Qt library conflicts in PyInstaller environments + +--- + +## v0.0.27 - Workflow Architecture Cleanup and Bug Fixes +**Release Date:** August 27, 2025 + +### Bug Fixes +- **Duplicate Shortcut Creation**: Fixed automated workflows creating multiple Steam shortcuts for the same modlist +- **GUI Workflow Optimization**: Removed manual shortcut creation from Tuxborn Installer and Configure New Modlist workflows +- **Workflow Consistency**: All three main workflows (Install Modlist, Configure New Modlist, Tuxborn Installer) now use unified automated approach + +### Code Architecture Improvements +- **Legacy Code Removal**: Eliminated unused ModlistGUIService (42KB) that was creating maintenance overhead +- **Simplified Navigation**: ModlistTasksScreen now functions as pure navigation menu to existing workflows +- **Clean Architecture**: Removed obsolete service imports, initializations, and cleanup methods +- **Code Quality**: Eliminated "tombstone comments" and unused service references + +### Technical Details +- **Single Shortcut Creation Path**: All workflows now use `run_working_workflow()` → `create_shortcut_with_native_service()` +- **Service Layer Cleanup**: Removed dual codepath architecture in favor of proven automated workflows +- **Import Optimization**: Cleaned up unused service imports across GUI components + +## v0.0.26 - Distribution Optimization and STL Integration Polish +**Release Date:** August 20, 2025 + +### Major Improvements +- **AppImage Size Optimization**: Implemented PyInstaller-style pre-filtering for PySide6 components, reducing AppImage size from 246M to 93M (62% reduction) +- **STL Distribution Integration**: Fixed SteamTinkerLaunch bundling and path detection for both PyInstaller and AppImage builds +- **Build Process Optimization**: Replaced inefficient "install everything then delete" approach with selective component installation + +### Technical Improvements +- **Pre-filtering Architecture**: Only install essential PySide6 modules (QtCore, QtGui, QtWidgets, QtNetwork, QtConcurrent, QtOpenGL) and their corresponding Qt libraries +- **Unified STL Path Detection**: Created `get_stl_path()` function for consistent STL location across all environments +- **AppImage Build Optimization**: Selective copying of Qt libraries, plugins, and data files instead of full installation +- **PyInstaller Integration**: Fixed STL bundling using `binaries` instead of `datas` for proper execute permissions + +### Bug Fixes +- **AppImage STL Path Resolution**: Fixed STL not found errors in AppImage runtime environment +- **PyInstaller STL Permissions**: Resolved permission denied errors for bundled STL binary +- **Build Script Paths**: Corrected STL source path in AppImage build script +- **Icon Display**: Re-added PyInstaller icon configuration for proper logo display + +### Performance Improvements +- **AppImage Size**: Reduced from 246M to 93M (smaller than PyInstaller's 120M) +- **Build Efficiency**: Eliminated wasteful post-deletion operations in favor of pre-filtering +- **Dependency Management**: Streamlined PySide6 component selection for optimal size + +--- + +## v0.0.25 - Shortcut Creation and Configuration Automation +**Release Date:** August 19, 2025 + +### Major Features +- **Fully Automated Shortcut Creation**: Complete automated prefix creation workflow using SteamTinkerLaunch. Jackify can now create the required new shortcut, set it's proton version, create the prefix and set Launch Options automatically. No more Manual Steps required. + +### Technical Improvements +- **STL-based Prefix Creation**: Replace manual prefix setup with automated STL workflow +- **Compatibility Tool Setting**: Direct VDF manipulation for Proton version configuration +- **Cancellation Process Management**: Enhanced Jackify-related process detection and termination - still more to do on this during the Modlist Configuration phase. +- **Conflict Resolution**: Added handling of shortcut conflicts and existing installations + +### Bug Fixes +- **Shortcut Installation Flag**: Fix Steam shortcuts not appearing in "Installed Locally" section +- **Indentation Errors**: Fix syntax errors in modlist parsing logic + +--- + +## v0.0.24 - Engine Performance & Stability +**Release Date:** August 16, 2025 + +### Engine Updates +- **jackify-engine 0.3.2**: Performance improvements regarding concurrency, and a few minor bug fixes +- **Enhanced Modlist Parsing**: Improved parsing logic for better compatibility +- **Resource Management**: Better memory and resource handling + +### Bug Fixes +- **Modlist Operations**: Fix parsing errors and improve reliability +- **GUI Stability**: Resolve various UI-related issues + +--- + +## v0.0.22 - SteamTinkerLaunch/Remove Manual Steps Investigation (Dev build only) +**Release Date:** August 13, 2025 + +### Research & Development +- **STL Integration Research**: Investigation into SteamTinkerLaunch integration possibilities, with the aim of removing the required Manual Steps with a fully automated process flow. +- **Proton Version Setting**: Exploration of automated Proton compatibility tool configuration for new shortcuts +- **Shortcut Creation Methods**: Analysis of different Steam shortcut creation approaches + +--- + +## v0.0.21 - Major Engine Update & UX Overhaul +**Release Date:** August 3, 2025 + +### Major Features +- **jackify-engine 0.3.0**: Complete rework of the texture conversion tools, increased performance and improved compatibility +- **Texture Conversion Tools**: Now using texconv.exe via Proton for texture processing, entirely invisible to the user. + +### User Experience +- **Streamlined API Key Management**: Implement silent validation +- **Interface Changes**: Cleaned up some UI elements +- **Error Handling**: Improved error dialogs and user feedback + +### Technical Improvements +- **Tool Integration**: New texture processing and diagnostic tools +- **Performance Optimization**: Significant speed improvements in modlist (7zz, texconv.exe) + +--- + +## v0.0.20 - GUI Regression Fixes +**Release Date:** July 23, 2025 + +### Bug Fixes +- **Fixed console scroll behavior during error output** + - Resolved race condition in `_safe_append_text()` where scroll position was checked before text append + - Added scroll position tolerance (±1px) to handle rounding issues + - Implemented auto-recovery when user manually scrolls back to bottom + - Applied fixes consistently across all GUI screens + +- **Enhanced API key save functionality** + - Added immediate visual feedback when save checkbox is toggled + - Implemented success/failure messages with color-coded tooltips + - Added automatic checkbox unchecking when save operations fail + - Improved error handling with comprehensive config write permission checks + +- **Added live API key validation** + - New "Validate" button with threaded validation against Nexus API endpoint + - Visual feedback for validation results (success/error states) + - Enhanced security with masked logging and no plain text API key exposure + - Maintains existing base64 encoding for stored API keys + +### Engine Updates +- **jackify-engine 0.2.11**: Performance improvements and bug fixes + +#### Fixed +- **Accurate DDS Texture Format Detection and Skip Logic** + - Replaced manual DDS header parsing with BCnEncoder-based format detection for improved accuracy and parity with upstream Wabbajack. + - Added logic to skip recompression of B8G8R8X8_UNORM textures, copying them unchanged instead (hopefully matching upstream behavior). + - Massive performance improvement: files that previously took 15+ minutes to process now copy in seconds. + - Fixes major texture processing performance bottleneck in ESP-embedded textures. + +##### Technical Details +- B8G8R8X8_UNORM format (88) is not supported by upstream Wabbajack's ToCompressionFormat; upstream appears to skip these files entirely. +- BCnEncoder-based format detection now used for all DDS files, ensuring correct handling and skipping of unsupported formats. +- Files detected as B8G8R8X8_UNORM now trigger copy logic instead of recompression, preventing unnecessary CPU-intensive work. +- Root cause: Previous logic attempted BC7 recompression on unsupported texture formats, causing major slowdowns. + +--- + +## v0.0.19 - Resource Management +**Release Date:** 2025-07-20 + +### New Features +- **Resource Management System**: Resource tracking and management +- **jackify-engine 0.2.11**: Performance and stability improvements + +### Technical Improvements +- **Memory Management**: Better resource allocation and cleanup +- **Process Monitoring**: Enhanced process tracking and management + +## v0.0.18 - Build System Improvements +**Release Date:** July 17, 2025 + +### Technical Improvements +- **Fixed PyInstaller temp directory inclusion issue** + - Added custom PyInstaller hook to exclude temporary files from build + - Prevents build failures when Jackify is running during build process + - Added automatic temp directory cleanup in build script + - Updated .gitignore to exclude temp directory from version control + +## v0.0.17 - Settings Dialog & UI Improvements +**Release Date:** July 17, 2025 + +### User Experience +- **Streamlined Resource Limits Interface** + - Removed "Max Throughput" column + - Added inline "Multithreading (Experimental)" checkbox for File Extractor resource +- **Multithreading Configuration** + - Added experimental multithreading option for 7-Zip file extraction + - Saves `_7zzMultiThread: "on"` to resource_settings.json when enabled + - Default state is disabled (off) + +### Technical Improvements +- **UI Scaling Implementation** + - Fixed vertical scaling issues on Steam Deck (1280x800) and low-resolution displays + - Implemented form-priority dynamic scaling across all 4 GUI screens + - Form elements now maintain minimum 280px height to ensure full visibility + - Console now dynamically shrinks to accommodate form needs instead of vice versa + - Added resize event handling for real-time scaling adjustments +- **API Key URL Regression Fix** + - Fixed API key acquisition URLs not opening browser on Linux systems + - Replaced unreliable automatic external link handling with manual QDesktopServices integration + - Affects both Install Modlist and Tuxborn Auto workflows + +### Engine Updates +- **jackify-engine 0.2.7**: Performance improvements and bug fixes +#### Fixed +- **Excessive logging when resuming aborted installations** + - Suppressed `DirectoryNotFoundException` warnings when re-running on previously aborted install directories + - Moved these warnings to debug level while preserving retry behavior + - Reduces noise when resuming installations without affecting functionality + +#### Changed +- **Texture compression performance optimization** + - Reduced BC7, BC6H, and BC5 compression quality settings from aggressive max quality to balanced levels + - Disabled channel weighting and adjusted compression speed settings + - Matches upstream Wabbajack's balanced compression approach for significantly faster texture processing + - Addresses extremely long compression times for large texture files + +## v0.0.16 - Steam Restart & User Experience +**Release Date:** July 16, 2025 + +### Bug Fixes +- **Fixed Steam interface not opening after restart in PyInstaller DIST mode** + - Added comprehensive environment cleaning to `steam_restart_service.py` + - Prevents PyInstaller environment variables from contaminating Steam subprocess calls + - Resolves issue where Steam interface wouldn't open after restart in three workflows requiring steam restarts + +### User Experience +- **Reduced popup timeout from 5 seconds to 3 seconds** + - Updated success dialogs and message service for faster user interaction + - Affects OK/Cancel buttons on confirmation popups +- **Fixed Install Modlist form reset issue** + - Form no longer resets when users select game type/modlist after filling out fields + - Preserves user input during modlist selection workflow + +### Workflow Improvements +- **Fixed misleading cancellation messages** + - Users who cancel workflows now see proper cancellation message instead of "Install Failed" + - Added cancellation detection logic similar to existing Tuxborn installer + +### Security +- **Added SSL certificate verification to all HTTP requests** + - All `requests.get()` calls now include `verify=True` parameter + - Improves security of downloads from GitHub APIs and other external sources + - Zero impact on functionality, pure security hardening +- **Removed hardcoded test paths** + - Cleaned up development test paths from `wabbajack_handler.py` + - Improved code hygiene and security posture + +### Technical Improvements +- Enhanced environment variable cleaning in steam restart service +- Improved error handling and user feedback in workflow cancellation +- Consolidated timeout handling across GUI components + +--- + +## v0.0.15 - GUI Workflow Logging Refactor +**Release Date:** July 15, 2025 + +### Major Fixes +- **GUI Workflow Logging Refactor**: Complete overhaul of logging behavior across all 4 GUI workflows + - Fixed premature log rotation that was creating .1 files before workflows started + - Moved log rotation from screen initialization to workflow execution start + - Eliminated early log file creation in Install Modlist and Configure Existing workflows + - All workflows now have proper log rotation timing and clean startup behavior + +### Technical Improvements +- **Backend Service Integration**: Removed remaining CLI subprocess calls from Configure New Modlist workflow + - Replaced CLI-based configuration with direct backend service calls + - Unified manual steps validation across all workflows using backend services + - Improved consistency between Tuxborn Automatic and Configure New Modlist workflows + +### Technical Details +- **Thread Safety**: Preserved thread cleanup improvements in all workflows +- **Error Handling**: Improved error handling and user feedback during workflow failures +- **Code Consistency**: Unified patterns across all 4 workflows for maintainability + +This release completes the logging refactor that was blocking development workflow. + +## v0.0.14 - User Experience & Steam Restart +**Release Date:** July 9, 2025 + +### User Experience +- Introduced protection from accidental confirmations etc due to focus-stealing popups: All user-facing dialogs (info, warnings, confirmations) now use the new MessageService with safety levels (LOW, MEDIUM, HIGH) to prevent focus-stealing and accidental confirmation. +- Steam restart workflow improvements: Unified and hardened the logic for restarting Steam and handling post-restart manual steps in all workflows (Tuxborn Installer, Install Modlist, Configure New/Existing Modlist). + +## v0.0.13 - Directory Safety & Configuration +**Release Date:** July 8, 2025 + +### New Features +- **Directory Safety System:** Prevents installation to dangerous system directories; adds install directory markers for validation. +- **Warning Dialogs:** Custom Jackify-themed warning dialogs for unsafe operations. + +### Bug Fixes +- Fixed 'TuxbornInstallerScreen' object has no attribute 'context' errors. + +### Technical Improvements +- **Configuration Persistence:** Debug mode and other settings persist across sessions. +- **Upgraded jackify-engine to 0.2.6, which includes:** + +### Engine Updates +- **jackify-engine 0.2.6**: Performance improvements and enhanced user feedback + +#### Added +- **Enhanced user feedback during long-running operations** + - Single-line progress updates for extraction, texture conversion, and BSA building phases + - Real-time progress counters showing current/total items (e.g., "Converting Textures (123/456): filename.dds") + - Smart filename truncation to prevent line wrapping in narrow console windows + - Carriage return-based progress display for cleaner console output + +#### Fixed +- **Temp directory cleanup after installation** + - Added explicit disposal of temporary file manager to ensure `__temp__` directory is properly cleaned up + - Prevents accumulation of temporary files in modlist install directories + - Cleanup occurs whether installation succeeds or fails + +#### Changed +- **Console output improvements** + - Progress updates now use single-line format with carriage returns for better user experience + - Maintains compatibility with Jackify's output parsing system + - Preserves all existing logging and error reporting functionality + +## v0.0.12 - Success Dialog & UI Improvements +**Release Date:** July 7, 2025 + +### New Features +* Redesigned the workflow completion (“Success”) dialog. +* Added an application icon, bundled with the PyInstaller build. Assets are now stored in a dedicated assets/ directory. +* Added fallback to pkill for instances where `steam -shutdown` wasn't working. + +### User Experience +* All main workflows (Install, Tuxborn Auto, Configure New, Configure Existing) now use the updated SuccessDialog and display the correct game type. +* Improved field validation and error handling before starting installs. +* Changed text on pop up when user cancels workflow, was previously reusing Failed Install dialog. +* Upgraded jackify-engine to latest build (v.0.2.5) +* Temporarily hid the non-primary workflow functions from both GUI and CLI FE's. + +### Bug Fixes +* Fixed missing app icon in PyInstaller builds by updating the spec file and asset paths. +* Scroll Bar behaviour - should be much better now + +## v0.0.11 - Configurable Directories & Game Support +**Release Date:** July 4, 2025 + +### New Features +- **Configurable Base Directories**: Users can now customize default install and download base directories via `~/.config/jackify/config.json` + - `modlist_install_base_dir`: Default `/home/user/Games` + - `modlist_downloads_base_dir`: Default `/home/user/Games/Modlist_Downloads` +- **Enhanced Game Type Support**: Added support for new game types + - Starfield + - Oblivion Remastered + - Improved game type detection and categorization +- **Unsupported Game Handling**: Clear warnings for unsupported games (e.g., Cyberpunk 2077) + - GUI: Pop-up alert with user confirmation + - CLI: Matching warning message with user confirmation +- **Simplified Directory Autofill**: + - Clean default paths without guessing or appending modlist names + - Consistent behavior across all configuration-based screens + +### Technical Improvements +- **DXVK Configuration**: fixed malformed dxvk.conf contents + - Now generates: `dxvk.enableGraphicsPipelineLibrary = False` +- **UI/UX Improvements**: + - Removed redundant "Return to Main Menu" buttons + - Improved dialog spacing, button order, and color consistency + +### Bug Fixes +- **Game Type Filtering**: Fixed modlists appearing in multiple categories by improving matching logic +- **CLI/GUI Parity**: Unified backend service usage for consistent behavior across interfaces + +## v0.0.10 - Previous Development Version +**Release Date:** Early Development +- Core CLI features implemented for running Wabbajack modlists on Linux. +- Initial support for Steam Deck and native Linux environments. +- Modular handler architecture for extensibility. + +## v0.0.09 and Earlier +See commit history for previous versions. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..706750b --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Jackify + +**Native Linux modlist installer and manager for Wabbajack modlists** + +Jackify enables seamless installation and configuration of Wabbajack modlists on Linux systems, providing automated Steam integration and Proton prefix management without requiring Windows dependencies. + +## Features + +- **Native Linux Support**: Pure Linux implementation with no Wine/Windows dependencies for core operations +- **Automated Steam Integration**: Automatic Steam shortcut creation with proper Proton configuration +- **Comprehensive Modlist Support**: Support for Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and more +- **Professional Interface**: Both CLI and GUI interfaces with enhanced modlist selection and metadata display +- **Steam Deck Optimized**: Full Steam Deck support with controller-friendly interface +- **Advanced Filtering**: Smart categorization with NSFW filtering and game-specific organization + +## Quick Start + +### Requirements + +- Linux system (Steam Deck supported) +- Steam installed and configured +- Python 3.8+ (for source installation) + +### Installation + +#### AppImage (Recommended) +```bash +# Download latest release +wget https://github.com/your-repo/jackify/releases/latest/jackify.AppImage +chmod +x jackify.AppImage +./jackify.AppImage +``` + +#### From Source +```bash +git clone https://github.com/your-repo/jackify.git +cd jackify/src +pip install -r requirements.txt +python -m jackify.frontends.gui # GUI mode +python -m jackify.frontends.cli # CLI mode +``` + +## Usage + +### GUI Mode +Launch the GUI and navigate through the intuitive interface: +1. Select "Modlist Tasks" → "Install a Modlist" +2. Choose your game type and modlist +3. Configure installation and download directories +4. Enter your Nexus API key +5. Let Jackify handle the rest + +### CLI Mode +```bash +python -m jackify.frontends.cli +``` +Follow the interactive prompts to configure and install modlists. + +## Supported Games + +- **Skyrim Special Edition** (88+ modlists) +- **Fallout 4** (22+ modlists) +- **Fallout New Vegas** (13+ modlists) +- **Oblivion** +- **Starfield** +- **Enderal** +- **Other Games** (Cyberpunk 2077, Baldur's Gate 3, and more) + +## Architecture + +Jackify follows a clean separation between frontend and backend: + +- **Backend Services**: Pure business logic with no UI dependencies +- **Frontend Interfaces**: CLI and GUI implementations using shared backend +- **Native Engine**: Powered by jackify-engine for optimal performance +- **Steam Integration**: Direct Steam shortcuts.vdf manipulation + +## Configuration + +Configuration files are stored in: +- **Linux**: `~/.config/jackify/` +- **Steam Deck**: `~/.config/jackify/` + +## Development + +### Building from Source +```bash +cd src +pip install -r requirements-packaging.txt +pyinstaller jackify.spec +``` + +### Running Tests +```bash +python -m pytest tests/ +``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Contributing + +Contributions are welcome! Please read our contributing guidelines and submit pull requests for any improvements. + +## Support + +- **Issues**: Report bugs and request features via GitHub Issues +- **Documentation**: See the Wiki for detailed guides +- **Community**: Join our community discussions + +## Acknowledgments + +- Wabbajack team for the modlist ecosystem +- jackify-engine developers +- Steam Deck and Linux gaming community + +--- + +**Jackify** - Bringing professional modlist management to Linux \ No newline at end of file diff --git a/assets/JackifyLogo_256.png b/assets/JackifyLogo_256.png new file mode 100644 index 0000000..f9589ee Binary files /dev/null and b/assets/JackifyLogo_256.png differ diff --git a/docs/SessionEnd.md b/docs/SessionEnd.md new file mode 100644 index 0000000..957c968 --- /dev/null +++ b/docs/SessionEnd.md @@ -0,0 +1,6 @@ +Session End: 2025-07-06 + +- v0.0.12 released: SuccessDialog redesign, accurate workflow timing, robust app icon, game type display, non-modal dialog, asset best practices, and more. +- All changes merged to master and pushed to remote. +- See CHANGELOG.md for full details. + diff --git a/hook-PySide6.py b/hook-PySide6.py new file mode 100644 index 0000000..1e5891b --- /dev/null +++ b/hook-PySide6.py @@ -0,0 +1,106 @@ +# Custom PyInstaller hook to optimize PySide6 by removing unused components +# This significantly reduces build size by excluding unnecessary Qt modules and tools + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules +import os +import shutil +from pathlib import Path + +def hook(hook_api): + """ + PySide6 optimization hook - removes unused Qt modules and development tools + to reduce build size and improve startup performance. + """ + + # Get the PySide6 data files + pyside_datas = collect_data_files('PySide6') + + # Filter out unnecessary components + filtered_datas = [] + + for src, dst in pyside_datas: + # Skip development tools and scripts + if any(skip in src for skip in [ + '/scripts/', + '/assistant/', + '/designer/', + '/linguist/', + '/lupdate', + '/lrelease', + '/qmllint', + '/qmlformat', + '/qmlls', + '/qsb', + '/svgtoqml', + '/balsam', + '/balsamui' + ]): + continue + + # Skip unused Qt modules (keep only what Jackify uses) + if any(skip in src for skip in [ + 'Qt3D', + 'QtBluetooth', + 'QtCharts', + 'QtConcurrent', # Keep this one - might be needed + 'QtDataVisualization', + 'QtDBus', + 'QtDesigner', + 'QtGraphs', + 'QtHelp', + 'QtHttpServer', + 'QtLocation', + 'QtMultimedia', + 'QtNfc', + 'QtOpenGL', # Keep this one - might be needed by QtWidgets + 'QtPdf', + 'QtPositioning', + 'QtPrintSupport', + 'QtQml', + 'QtQuick', + 'QtRemoteObjects', + 'QtScxml', + 'QtSensors', + 'QtSerial', + 'QtSpatialAudio', + 'QtSql', + 'QtStateMachine', + 'QtSvg', + 'QtTest', + 'QtTextToSpeech', + 'QtWeb', + 'QtXml', + 'QtNetworkAuth', + 'QtUiTools' + ]): + continue + + # Keep core modules that Jackify uses + if any(keep in src for keep in [ + 'QtCore', + 'QtGui', + 'QtWidgets', + 'QtNetwork' + ]): + filtered_datas.append((src, dst)) + continue + + # Add the filtered data files + hook_api.add_datas(filtered_datas) + + # Also filter submodules to exclude unused ones + pyside_modules = collect_submodules('PySide6') + filtered_modules = [] + + for module in pyside_modules: + # Keep only core modules + if any(keep in module for keep in [ + 'PySide6.QtCore', + 'PySide6.QtGui', + 'PySide6.QtWidgets', + 'PySide6.QtNetwork' + ]): + filtered_modules.append(module) + + # Add the filtered modules + hook_api.add_imports(*filtered_modules) diff --git a/hook-jackify.py b/hook-jackify.py new file mode 100644 index 0000000..bd91eea --- /dev/null +++ b/hook-jackify.py @@ -0,0 +1,17 @@ +# Custom hook to exclude temp directory from Jackify engine data collection +from PyInstaller.utils.hooks import collect_data_files +import os + +def hook(hook_api): + # Get the original data files for jackify.engine + datas = collect_data_files('jackify.engine') + + # Filter out any files in the temp directory + filtered_datas = [] + for src, dst in datas: + # Skip any files that contain 'temp' in their path + if 'temp' not in src: + filtered_datas.append((src, dst)) + + # Set the filtered data files + hook_api.add_datas(filtered_datas) \ No newline at end of file diff --git a/jackify.spec b/jackify.spec new file mode 100644 index 0000000..e733ee2 --- /dev/null +++ b/jackify.spec @@ -0,0 +1,49 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['jackify/frontends/gui/__main__.py'], + pathex=[], + binaries=[], + datas=[('jackify/engine', 'jackify/engine'), ('jackify/shared', 'jackify/shared'), ('assets/JackifyLogo_256.png', 'assets')], + hiddenimports=[ + 'PySide6.QtCore', 'PySide6.QtGui', 'PySide6.QtWidgets', + 'jackify.backend.core', 'jackify.backend.handlers', 'jackify.backend.services', 'jackify.backend.models', + 'jackify.backend.handlers.resolution_handler', 'jackify.backend.handlers.modlist_handler', + 'jackify.backend.handlers.menu_handler', 'jackify.backend.handlers.path_handler', + 'jackify.frontends.cli', 'jackify.frontends.cli.main', + 'jackify.frontends.cli.menus', 'jackify.frontends.cli.menus.main_menu', + 'jackify.frontends.cli.menus.tuxborn_menu', 'jackify.frontends.cli.menus.wabbajack_menu', + 'jackify.frontends.gui.widgets.unsupported_game_dialog', + 'jackify.shared.paths', 'jackify.shared.ui_utils' + ], + hookspath=['.'], + hooksconfig={}, + runtime_hooks=[], + excludes=['tkinter', 'matplotlib', 'numpy', 'scipy', 'pandas', 'IPython', 'jupyter', 'test', 'tests', 'unittest'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='jackify', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='assets/JackifyLogo_256.png', +) diff --git a/jackify/__init__.py b/jackify/__init__.py new file mode 100644 index 0000000..81e53c5 --- /dev/null +++ b/jackify/__init__.py @@ -0,0 +1,8 @@ +""" +Jackify - A tool for running Wabbajack modlists on Linux + +This package provides both CLI and GUI interfaces for managing +Wabbajack modlists natively on Linux systems. +""" + +__version__ = "0.0.30" diff --git a/jackify/__main__.py b/jackify/__main__.py new file mode 100644 index 0000000..8e4705c --- /dev/null +++ b/jackify/__main__.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +""" +Main entry point for Jackify package. +Launches the GUI by default. +""" + +import sys +import os + +# Add the src directory to the Python path +src_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if src_dir not in sys.path: + sys.path.insert(0, src_dir) + +def main(): + """Main entry point - launch GUI by default""" + from jackify.frontends.gui.main import main as gui_main + return gui_main() + +if __name__ == "__main__": + main() diff --git a/jackify/backend/__init__.py b/jackify/backend/__init__.py new file mode 100644 index 0000000..56709c3 --- /dev/null +++ b/jackify/backend/__init__.py @@ -0,0 +1,6 @@ +""" +Jackify Backend + +Pure business logic layer with no user interaction. +Provides services and handlers for modlist management. +""" \ No newline at end of file diff --git a/jackify/backend/core/__init__.py b/jackify/backend/core/__init__.py new file mode 100644 index 0000000..281786a --- /dev/null +++ b/jackify/backend/core/__init__.py @@ -0,0 +1,5 @@ +""" +Backend Core Operations + +High-level business logic operations. +""" \ No newline at end of file diff --git a/jackify/backend/core/modlist_operations.py b/jackify/backend/core/modlist_operations.py new file mode 100644 index 0000000..6e6bc0b --- /dev/null +++ b/jackify/backend/core/modlist_operations.py @@ -0,0 +1,1479 @@ +import os +from pathlib import Path +from typing import Optional, Dict, List, Any, Union +from ..handlers.protontricks_handler import ProtontricksHandler +from ..handlers.shortcut_handler import ShortcutHandler +from ..handlers.menu_handler import MenuHandler, ModlistMenuHandler +from ..handlers.ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION +# Standard logging (no file handler) - LoggingHandler import removed +import logging +from ..handlers.wabbajack_parser import WabbajackParser +import re +import subprocess +import logging +import sys +import json +import shlex +import time +import pty +# from src.core.compressonator import run_compressonatorcli # TODO: Implement compressonator integration +from jackify.backend.services.modlist_service import ModlistService +from jackify.backend.models.configuration import SystemInfo +from jackify.backend.handlers.config_handler import ConfigHandler + +# UI Colors already imported above + +# Attempt to import readline for tab completion +READLINE_AVAILABLE = False +try: + import readline + READLINE_AVAILABLE = True + # Check if running in a non-interactive environment (e.g., some CI) + if 'libedit' in readline.__doc__: + # libedit doesn't support set_completion_display_matches_hook + pass + # Add other potential checks if needed +except ImportError: + # readline not available on Windows or potentially minimal environments + pass +except Exception as e: + # Catch other potential errors during readline import/setup + logging.warning(f"Readline import failed: {e}") # Use standard logging before our handler + pass + +# Initialize logger for the module +logger = logging.getLogger(__name__) # Standard logger init + +# Helper function to get path to jackify-install-engine +def get_jackify_engine_path(): + # Priority 1: Environment variable override (for AppImage writable engine copy) + env_engine_path = os.environ.get('JACKIFY_ENGINE_PATH') + if env_engine_path and os.path.exists(env_engine_path): + logger.debug(f"Using engine from environment variable: {env_engine_path}") + return env_engine_path + + # Priority 2: PyInstaller bundle (most specific detection) + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + # Running in a PyInstaller bundle + # Engine is expected at /jackify/engine/jackify-engine + engine_path = os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine') + if os.path.exists(engine_path): + return engine_path + # Fallback: log warning but continue to other detection methods + logger.warning(f"PyInstaller engine not found at expected path: {engine_path}") + + # Priority 3: Check if THIS process is actually running from Jackify AppImage + # (not just inheriting APPDIR from another AppImage like Cursor) + appdir = os.environ.get('APPDIR') + if appdir and sys.argv[0] and 'jackify' in sys.argv[0].lower() and '/tmp/.mount_' in sys.argv[0]: + # Only use AppImage path if we're actually running a Jackify AppImage + engine_path = os.path.join(appdir, 'opt', 'jackify', 'engine', 'jackify-engine') + if os.path.exists(engine_path): + return engine_path + # Log if AppImage engine is missing + logger.warning(f"AppImage engine not found at expected path: {engine_path}") + + # Priority 3: Source execution (development/normal Python environment) + # Current file is in src/jackify/backend/core/modlist_operations.py + # Engine is at src/jackify/engine/jackify-engine + current_file_dir = os.path.dirname(os.path.abspath(__file__)) + # Navigate up from src/jackify/backend/core/ to src/jackify/ + jackify_dir = os.path.dirname(os.path.dirname(current_file_dir)) + engine_path = os.path.join(jackify_dir, 'engine', 'jackify-engine') + if os.path.exists(engine_path): + return engine_path + + # If all else fails, log error and return the source path anyway + logger.error(f"jackify-engine not found in any expected location. Tried:") + logger.error(f" PyInstaller: {getattr(sys, '_MEIPASS', 'N/A')}/jackify/engine/jackify-engine") + logger.error(f" AppImage: {appdir or 'N/A'}/opt/jackify/engine/jackify-engine") + logger.error(f" Source: {engine_path}") + logger.error("This will likely cause installation failures.") + + # Return source path as final fallback + return engine_path + +class ModlistInstallCLI: + """CLI interface for modlist installation operations.""" + def __init__(self, menu_handler_or_system_info, steamdeck: bool = False): + # Support both initialization patterns: + # 1. ModlistInstallCLI(menu_handler, steamdeck) - CLI frontend pattern + # 2. ModlistInstallCLI(system_info) - GUI frontend pattern + + from ..models.configuration import SystemInfo + + 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 + + # Initialize menu_handler for GUI mode + from ..handlers.menu_handler import MenuHandler + self.menu_handler = MenuHandler() + else: + # CLI frontend initialization pattern + self.menu_handler = menu_handler_or_system_info + self.steamdeck = steamdeck + + self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck) + self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck) + self.context = {} + # Use standard logging (no file handler) + self.logger = logging.getLogger(__name__) + self.logger.propagate = False # Prevent duplicate logs if root logger is also configured + + # Initialize Wabbajack parser for game detection + self.wabbajack_parser = WabbajackParser() + + # Initialize process tracking for cleanup + self._current_process = None + + def cleanup(self): + """Clean up any running jackify-engine process""" + if self._current_process and self._current_process.poll() is None: + try: + self._current_process.terminate() + self._current_process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._current_process.kill() + except Exception: + # Process may have already died + pass + finally: + self._current_process = None + + def detect_game_type(self, modlist_info: Optional[Dict] = None, wabbajack_file_path: Optional[Path] = None) -> Optional[str]: + """ + Detect the game type for a modlist installation. + + Args: + modlist_info: Dictionary containing modlist information (for online modlists) + wabbajack_file_path: Path to .wabbajack file (for local files) + + Returns: + Jackify game type string or None if detection fails + """ + if wabbajack_file_path: + # Parse .wabbajack file to get game type + self.logger.info(f"Detecting game type from .wabbajack file: {wabbajack_file_path}") + game_type = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_file_path) + if game_type: + self.logger.info(f"Detected game type from .wabbajack file: {game_type}") + return game_type + else: + self.logger.warning(f"Could not detect game type from .wabbajack file: {wabbajack_file_path}") + return None + elif modlist_info and 'game' in modlist_info: + # Use game type from modlist info + game_name = modlist_info['game'].lower() + self.logger.info(f"Detecting game type from modlist info: {game_name}") + + # Map common game names to Jackify game types + game_mapping = { + 'skyrim special edition': 'skyrim', + 'skyrim': 'skyrim', + 'fallout 4': 'fallout4', + 'fallout new vegas': 'falloutnv', + 'oblivion': 'oblivion', + 'starfield': 'starfield', + 'oblivion remastered': 'oblivion_remastered' + } + + game_type = game_mapping.get(game_name) + if game_type: + self.logger.info(f"Mapped game name '{game_name}' to game type: {game_type}") + return game_type + else: + self.logger.warning(f"Unknown game name in modlist info: {game_name}") + return None + else: + self.logger.warning("No modlist info or .wabbajack file path provided for game detection") + return None + + def check_game_support(self, game_type: str) -> bool: + """ + Check if a game type is supported by Jackify's post-install configuration. + + Args: + game_type: Jackify game type string + + Returns: + True if the game is supported, False otherwise + """ + return self.wabbajack_parser.is_supported_game(game_type) + + def run_discovery_phase(self, context_override=None) -> Optional[Dict]: + """ + Run the discovery phase: prompt for all required info, and validate inputs. + Returns a context dict with all collected info, or None if cancelled. + Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow). + """ + self.logger.info("Starting modlist discovery phase (restored logic).") + print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}") + + if context_override: + self.context.update(context_override) + if 'resolution' in context_override: + self.context['resolution'] = context_override['resolution'] + else: + self.context = {} + + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + # Only require game_type for non-Tuxborn workflows + if self.context.get('machineid'): + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key'] + else: + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type'] + has_modlist = self.context.get('modlist_value') or self.context.get('machineid') + missing = [k for k in required_keys if not self.context.get(k)] + if is_gui_mode: + if missing or not has_modlist: + print(f"ERROR: Missing required arguments for GUI workflow: {', '.join(missing)}") + if not has_modlist: + print("ERROR: Missing modlist_value or machineid for GUI workflow.") + print("This workflow must be fully non-interactive. Please report this as a bug if you see this message.") + return None + self.logger.info("All required context present in GUI mode, skipping prompts.") + return self.context + + # Get engine path using the helper + engine_executable = get_jackify_engine_path() + self.logger.debug(f"Engine executable path: {engine_executable}") + + if not os.path.exists(engine_executable): + self.logger.error(f"jackify-install-engine not found at {engine_executable}") + print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") + print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") + return None + + engine_dir = os.path.dirname(engine_executable) + + # 1. Prompt for modlist source (unless using machineid from context_override) + if 'machineid' not in self.context: + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu") + source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() + self.logger.debug(f"User selected modlist source option: {source_choice}") + + if source_choice == '1': + self.context['modlist_source_type'] = 'online_list' + print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}") + try: + # Use the same backend service as the GUI + 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 + system_info = SystemInfo(is_steamdeck=is_steamdeck) + modlist_service = ModlistService(system_info) + + # Define categories and their backend keys + categories = [ + ("Skyrim", "skyrim"), + ("Fallout 4", "fallout4"), + ("Fallout New Vegas", "falloutnv"), + ("Oblivion", "oblivion"), + ("Starfield", "starfield"), + ("Oblivion Remastered", "oblivion_remastered"), + ("Other Games", "other") + ] + grouped_modlists = {} + for label, key in categories: + grouped_modlists[label] = modlist_service.list_modlists(game_type=key) + + selected_modlist_info = None + while not selected_modlist_info: + print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}") + category_display_map = {} + display_idx = 1 + for label, _ in categories: + modlists = grouped_modlists[label] + # Always show Oblivion Remastered, even if empty + if label == "Oblivion Remastered" or modlists: + print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {label} ({len(modlists)} modlists)") + category_display_map[str(display_idx)] = label + display_idx += 1 + if display_idx == 1: + print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}") + return None + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel") + game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip() + if game_cat_choice == '0': + self.logger.info("User cancelled game category selection.") + return None + actual_label = category_display_map.get(game_cat_choice) + if not actual_label: + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + continue + modlist_group_for_game = sorted(grouped_modlists[actual_label], key=lambda x: x.id.lower()) + print(f"\n{COLOR_SUCCESS}Available Modlists for {actual_label}:{COLOR_RESET}") + for idx, m_detail in enumerate(modlist_group_for_game, 1): + # Show game name for Other Games + if actual_label == "Other Games": + print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail.id} ({m_detail.game})") + else: + print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail.id}") + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories") + while True: + mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip() + if mod_choice_idx_str == '0': + break + if mod_choice_idx_str.isdigit(): + mod_idx = int(mod_choice_idx_str) - 1 + if 0 <= mod_idx < len(modlist_group_for_game): + selected_modlist_info = { + 'id': modlist_group_for_game[mod_idx].id, + 'game': modlist_group_for_game[mod_idx].game, + 'machine_url': getattr(modlist_group_for_game[mod_idx], 'machine_url', modlist_group_for_game[mod_idx].id) + } + self.context['modlist_source'] = 'identifier' + # Use machine_url for installation, display name for suggestions + self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id']) + self.context['modlist_game'] = selected_modlist_info['game'] + self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1] + self.logger.info(f"User selected online modlist: {selected_modlist_info}") + break + else: + print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") + if selected_modlist_info: + break + except Exception as e: + self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) + print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}") + return None + + elif source_choice == '2': + self.context['modlist_source_type'] = 'local_file' + print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}") + modlist_path = self.menu_handler.get_existing_file_path( + prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", + extension_filter=".wabbajack", # Ensure this is the exact filter used by the method + no_header=True # To avoid re-printing a header if get_existing_file_path has one + ) + if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q' + self.logger.info("User cancelled .wabbajack file selection.") + print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}") + return None + + self.context['modlist_source'] = 'path' # For install command + self.context['modlist_value'] = str(modlist_path) + # Suggest a name based on the file + self.context['modlist_name_suggestion'] = Path(modlist_path).stem + self.logger.info(f"User selected local .wabbajack file: {modlist_path}") + + elif source_choice == '0': + self.logger.info("User cancelled modlist source selection.") + print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}") + return None + else: + self.logger.warning(f"Invalid modlist source choice: {source_choice}") + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + return self.run_discovery_phase() # Re-prompt + + # --- Prompts for install_dir, download_dir, modlist_name, api_key --- + # (This part is largely similar to the restored version, adapt as needed) + # It will use self.context['modlist_name_suggestion'] if available. + + # 2. Prompt for modlist name (skip if 'modlist_name' already in context from override) + if 'modlist_name' not in self.context or not self.context['modlist_name']: + default_name = self.context.get('modlist_name_suggestion', 'MyModlist') + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}") + print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}") + modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() + if not modlist_name_input: # User hit enter for default + modlist_name = default_name + elif modlist_name_input.lower() == 'q': + self.logger.info("User cancelled at modlist name prompt.") + return None + else: + modlist_name = modlist_name_input + self.context['modlist_name'] = modlist_name + self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}") + + # 3. Prompt for install directory + if 'install_dir' not in self.context: + # Use configurable base directory + config_handler = ConfigHandler() + base_install_dir = Path(config_handler.get_modlist_install_base_dir()) + default_install_dir = base_install_dir / self.context['modlist_name'] + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}") + print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}") + install_dir_path = self.menu_handler.get_directory_path( + prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", + default_path=default_install_dir, + create_if_missing=True, + no_header=True + ) + if install_dir_path is None: + self.logger.info("User cancelled at install directory prompt.") + return None + self.context['install_dir'] = install_dir_path + self.logger.debug(f"Install directory context set to: {self.context['install_dir']}") + + # 4. Prompt for download directory + if 'download_dir' not in self.context: + # Use configurable base directory for downloads + config_handler = ConfigHandler() + base_download_dir = Path(config_handler.get_modlist_downloads_base_dir()) + default_download_dir = base_download_dir / self.context['modlist_name'] + + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}") + print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}") + download_dir_path = self.menu_handler.get_directory_path( + prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", + default_path=default_download_dir, + create_if_missing=True, + no_header=True + ) + if download_dir_path is None: + self.logger.info("User cancelled at download directory prompt.") + return None + self.context['download_dir'] = download_dir_path + self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") + + # 5. Prompt for Nexus API key (skip if in context and valid) + if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'): + 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("\n" + "-" * 28) + 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_SUCCESS}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("\n" + "-" * 28) + print(f"{COLOR_INFO}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': + self.logger.info("User cancelled or provided no API key.") + return None + 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_SUCCESS}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}") + + # Set the API key in context regardless of which path was taken + self.context['nexus_api_key'] = api_key + self.logger.debug(f"NEXUS_API_KEY is set in environment for engine (presence check).") + + # Display summary and confirm + self._display_summary() # Ensure this method exists or implement it + + # --- Unsupported game warning and Enter-to-continue prompt --- + # Determine the game type and name + game_type = None + game_name = None + if self.context.get('modlist_source_type') == 'online_list': + game_name = self.context.get('modlist_game', '') + game_mapping = { + 'skyrim special edition': 'skyrim', + 'skyrim': 'skyrim', + 'fallout 4': 'fallout4', + 'fallout new vegas': 'falloutnv', + 'oblivion': 'oblivion', + 'starfield': 'starfield', + 'oblivion remastered': 'oblivion_remastered' + } + game_type = game_mapping.get(game_name.lower()) + if not game_type: + game_type = 'unknown' + elif self.context.get('modlist_source_type') == 'local_file': + # Use the parser to get the game type from the .wabbajack file + wabbajack_path = self.context.get('modlist_value') + if wabbajack_path: + result = self.wabbajack_parser.parse_wabbajack_game_type(Path(wabbajack_path)) + if result: + if isinstance(result, tuple): + game_type, raw_game_type = result + game_name = raw_game_type if game_type == 'unknown' else game_type + else: + game_type = result + game_name = game_type + # If unsupported, show warning and require Enter + if game_type and not self.wabbajack_parser.is_supported_game(game_type): + print("\n" + "─"*46) + print("\u26A0\uFE0F Game Support Notice\n") + print(f"You are about to install a modlist for: {game_name or 'Unknown'}\n") + print("Jackify does not provide post-install configuration for this game.") + print("You can still install and use the modlist, but you will need to manually set up Steam shortcuts and other steps after installation.\n") + print("Press [Enter] to continue, or [Ctrl+C] to cancel.") + print("─"*46 + "\n") + try: + input() + except KeyboardInterrupt: + print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") + return None + + if self.context.get('skip_confirmation'): + confirm = 'y' + else: + confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower() + if confirm != 'y': + self.logger.info("User cancelled at final confirmation.") + print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") + return None + + self.logger.info("Discovery phase complete.") # Log completion first + + # Create a copy of the context for logging, so we don't alter the original + context_for_logging = self.context.copy() + if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None: + context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging + + self.logger.info(f"Context: {context_for_logging}") # Log the redacted context + return self.context + + def _display_summary(self): + """ + Display a summary of the collected context (excluding API key). + """ + print(f"\n{COLOR_INFO}--- Summary of Collected Information ---{COLOR_RESET}") + if self.context.get('modlist_source_type') == 'online_list': + print(f"Modlist Source: Selected from online list") + print(f"Modlist Identifier: {self.context.get('modlist_value')}") + print(f"Detected Game: {self.context.get('modlist_game', 'N/A')}") + elif self.context.get('modlist_source_type') == 'local_file': + print(f"Modlist Source: Local .wabbajack file") + print(f"File Path: {self.context.get('modlist_value')}") + elif 'machineid' in self.context: # For Tuxborn/override flow + print(f"Modlist Identifier (Tuxborn/MachineID): {self.context.get('machineid')}") + + print(f"Steam Shortcut Name: {self.context.get('modlist_name', 'N/A')}") + + install_dir_display = self.context.get('install_dir') + if isinstance(install_dir_display, tuple): + install_dir_display = install_dir_display[0] # Get the Path object from (Path, bool) + print(f"Install Directory: {install_dir_display}") + + download_dir_display = self.context.get('download_dir') + if isinstance(download_dir_display, tuple): + download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool) + print(f"Download Directory: {download_dir_display}") + + if self.context.get('nexus_api_key'): + print(f"Nexus API Key: [SET]") + else: + print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") + print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") + + def configuration_phase(self): + """ + Run the configuration phase: execute the Linux-native Jackify Install Engine. + """ + import subprocess + import time + import sys + from pathlib import Path + # UI Colors already imported at module level + print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}") + start_time = time.time() + + # --- BEGIN: TEE LOGGING SETUP & LOG ROTATION --- + log_dir = Path.home() / "Jackify" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + workflow_log_path = log_dir / "Modlist_Install_workflow.log" + # Log rotation: keep last 3 logs, 1MB each (adjust as needed) + max_logs = 3 + max_size = 1024 * 1024 # 1MB + 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"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path + dest = log_dir / f"Modlist_Install_workflow.log.{i}" + if prev.exists(): + if dest.exists(): + dest.unlink() + prev.rename(dest) + workflow_log = open(workflow_log_path, 'a') + 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() + orig_stdout, orig_stderr = sys.stdout, sys.stderr + sys.stdout = TeeStdout(sys.stdout, workflow_log) + sys.stderr = TeeStdout(sys.stderr, workflow_log) + # --- END: TEE LOGGING SETUP & LOG ROTATION --- + try: + # --- Process Paths from context --- + install_dir_context = self.context['install_dir'] + if isinstance(install_dir_context, tuple): + actual_install_path = Path(install_dir_context[0]) + if install_dir_context[1]: # Second element is True if creation was intended + self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}") + actual_install_path.mkdir(parents=True, exist_ok=True) + else: # Should be a Path object or string already + actual_install_path = Path(install_dir_context) + install_dir_str = str(actual_install_path) + self.logger.debug(f"Processed install directory for engine: {install_dir_str}") + + download_dir_context = self.context['download_dir'] + if isinstance(download_dir_context, tuple): + actual_download_path = Path(download_dir_context[0]) + if download_dir_context[1]: # Second element is True if creation was intended + self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}") + actual_download_path.mkdir(parents=True, exist_ok=True) + else: # Should be a Path object or string already + actual_download_path = Path(download_dir_context) + download_dir_str = str(actual_download_path) + self.logger.debug(f"Processed download directory for engine: {download_dir_str}") + # --- End Process Paths --- + + modlist_arg = self.context.get('modlist_value') or self.context.get('machineid') + machineid = self.context.get('machineid') + api_key = self.context.get('nexus_api_key') + + # Path to the engine binary + engine_path = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_path) + if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): + print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}") + return + + # --- Patch for GUI/auto: always set modlist_source to 'identifier' if not set, and ensure modlist_value is present --- + if os.environ.get('JACKIFY_GUI_MODE') == '1': + if not self.context.get('modlist_source'): + self.context['modlist_source'] = 'identifier' + if not self.context.get('modlist_value'): + print(f"{COLOR_ERROR}ERROR: modlist_value is missing in context for GUI workflow!{COLOR_RESET}") + self.logger.error("modlist_value is missing in context for GUI workflow!") + return + # --- End Patch --- + + # Build command + cmd = [engine_path, 'install'] + # Determine if this is a local .wabbajack file or an online modlist + modlist_value = self.context.get('modlist_value') + if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): + cmd += ['-w', modlist_value] + elif modlist_value: + cmd += ['-m', modlist_value] + elif self.context.get('machineid'): + cmd += ['-m', self.context['machineid']] + cmd += ['-o', install_dir_str, '-d', download_dir_str] + + # Store original environment values to restore later + original_env_values = { + 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), + 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') + } + + try: + # Temporarily modify current process's environment + if api_key: + os.environ['NEXUS_API_KEY'] = api_key + self.logger.debug(f"Temporarily set os.environ['NEXUS_API_KEY'] for engine call using session-provided key.") + elif 'NEXUS_API_KEY' in os.environ: # api_key is None/empty, but a system key might exist + self.logger.debug(f"Session API key not provided. Temporarily removing inherited NEXUS_API_KEY ('{'[REDACTED]' if os.environ.get('NEXUS_API_KEY') else 'None'}') from os.environ for engine call to ensure it is not used.") + del os.environ['NEXUS_API_KEY'] + # If api_key is None and NEXUS_API_KEY was not in os.environ, it remains unset, which is correct. + + os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" + self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.") + + self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.") + self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}") + + pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) + print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}") + + # Temporarily increase file descriptor limit for engine process + from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit + success, old_limit, new_limit, message = increase_file_descriptor_limit() + if success: + self.logger.debug(f"File descriptor limit: {message}") + else: + self.logger.warning(f"File descriptor limit: {message}") + + # Popen now inherits the modified os.environ because env=None + # Store process reference for cleanup + self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=None, cwd=engine_dir) + proc = self._current_process + + # Read output in binary mode to properly handle carriage returns + buffer = b'' + while True: + chunk = proc.stdout.read(1) + if not chunk: + break + buffer += chunk + + # Process complete lines or carriage return updates + if chunk == b'\n': + # Complete line - decode and print + line = buffer.decode('utf-8', errors='replace') + print(line, end='') + buffer = b'' + elif chunk == b'\r': + # Carriage return - decode and print without newline + line = buffer.decode('utf-8', errors='replace') + print(line, end='') + sys.stdout.flush() + buffer = b'' + + # Print any remaining buffer content + if buffer: + line = buffer.decode('utf-8', errors='replace') + print(line, end='') + + proc.wait() + # Clear process reference after completion + self._current_process = None + if proc.returncode != 0: + print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") + self.logger.error(f"Engine exited with code {proc.returncode}.") + return # Configuration phase failed + self.logger.info(f"Engine completed with code {proc.returncode}.") + except Exception as e: + error_message = str(e) + print(f"{COLOR_ERROR}Error running Jackify Install Engine: {error_message}{COLOR_RESET}\n") + self.logger.error(f"Exception running engine: {error_message}", exc_info=True) + + # Check for file descriptor limit issues and attempt to handle them + try: + from jackify.backend.services.resource_manager import handle_file_descriptor_error + if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']): + result = handle_file_descriptor_error(error_message, "Jackify Install Engine execution") + if result['auto_fix_success']: + print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}") + self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") + elif result['error_detected']: + print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}") + self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}") + if result['manual_instructions']: + distro = result['manual_instructions']['distribution'] + print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}") + self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution") + except Exception as resource_error: + self.logger.debug(f"Error checking for resource limit issues: {resource_error}") + + return # Configuration phase failed + finally: + # Restore original environment state + for key, original_value in original_env_values.items(): + current_value_in_os_environ = os.environ.get(key) # Value after Popen and before our restoration for this key + + # Determine display values for logging, redacting NEXUS_API_KEY + display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'" + # display_current_value_before_restore = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{current_value_in_os_environ}'" + + if original_value is not None: + # Original value existed. We must restore it. + if current_value_in_os_environ != original_value: + os.environ[key] = original_value + self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.") + else: + # If current value is already the original, ensure it's correctly set (os.environ[key] = original_value is harmless) + os.environ[key] = original_value # Ensure it is set + self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.") + else: + # Original value was None (key was not in os.environ initially). + if key in os.environ: # If it's in os.environ now, it means we must have set it or it was set by other means. + self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.") + del os.environ[key] + # If original_value was None and key is not in os.environ now, nothing to do. + + except Exception as e: + error_message = str(e) + print(f"{COLOR_ERROR}Error during Tuxborn installation workflow: {error_message}{COLOR_RESET}\n") + self.logger.error(f"Exception in Tuxborn workflow: {error_message}", exc_info=True) + + # Check for file descriptor limit issues and attempt to handle them + try: + from jackify.backend.services.resource_manager import handle_file_descriptor_error + if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']): + result = handle_file_descriptor_error(error_message, "Tuxborn installation workflow") + if result['auto_fix_success']: + print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}") + self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") + elif result['error_detected']: + print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}") + self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}") + if result['manual_instructions']: + distro = result['manual_instructions']['distribution'] + print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}") + self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution") + except Exception as resource_error: + self.logger.debug(f"Error checking for resource limit issues: {resource_error}") + + return + finally: + # --- BEGIN: RESTORE STDOUT/STDERR --- + sys.stdout = orig_stdout + sys.stderr = orig_stderr + workflow_log.close() + # --- END: RESTORE STDOUT/STDERR --- + + elapsed = int(time.time() - start_time) + print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n") + print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n") + if self.context.get('machineid') != 'Tuxborn/Tuxborn': + print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}") + + self.logger.debug("configuration_phase: Starting post-install game detection...") + + # After install, use self.context['modlist_game'] to determine if configuration should be offered + # After install, detect game type from ModOrganizer.ini + modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini") + detected_game = None + self.logger.debug(f"configuration_phase: Looking for ModOrganizer.ini at: {modorganizer_ini}") + if os.path.isfile(modorganizer_ini): + self.logger.debug("configuration_phase: Found ModOrganizer.ini, detecting game...") + from ..handlers.modlist_handler import ModlistHandler + handler = ModlistHandler({}, steamdeck=self.steamdeck) + handler.modlist_ini = modorganizer_ini + handler.modlist_dir = install_dir_str + if handler._detect_game_variables(): + detected_game = handler.game_var_full + self.logger.debug(f"configuration_phase: Detected game: {detected_game}") + else: + self.logger.debug("configuration_phase: Failed to detect game variables") + else: + self.logger.debug("configuration_phase: ModOrganizer.ini not found") + + supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"] + is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn' + self.logger.debug(f"configuration_phase: detected_game='{detected_game}', is_tuxborn={is_tuxborn}") + self.logger.debug(f"configuration_phase: Checking condition: (detected_game in supported_games) or is_tuxborn") + self.logger.debug(f"configuration_phase: Result: {(detected_game in supported_games) or is_tuxborn}") + + if (detected_game in supported_games) or is_tuxborn: + self.logger.debug("configuration_phase: Entering Steam configuration workflow...") + shortcut_name = self.context.get('modlist_name') + self.logger.debug(f"configuration_phase: shortcut_name from context: '{shortcut_name}'") + + if is_tuxborn and not shortcut_name: + self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'") + shortcut_name = "Tuxborn Automatic Installer" # Provide a fallback default + elif not shortcut_name: # For non-Tuxborn, prompt if missing + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}") + raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip() + if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name: + self.logger.debug("configuration_phase: User cancelled shortcut name input") + return + shortcut_name = raw_shortcut_name + + self.logger.debug(f"configuration_phase: Final shortcut_name: '{shortcut_name}'") + + # Check if GUI mode to skip interactive prompts + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + self.logger.debug(f"configuration_phase: is_gui_mode={is_gui_mode}") + + if not is_gui_mode: + self.logger.debug("configuration_phase: Not in GUI mode, prompting user for configuration...") + # Prompt user if they want to configure Steam shortcut now + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now?{COLOR_RESET}") + configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() + self.logger.debug(f"configuration_phase: User choice: '{configure_choice}'") + + if configure_choice == 'n': + print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}") + self.logger.debug("configuration_phase: User chose to skip Steam configuration") + return + else: + self.logger.debug("configuration_phase: In GUI mode, proceeding automatically...") + + self.logger.debug("configuration_phase: Proceeding with Steam configuration...") + + # Proceed with Steam configuration + self.logger.info(f"Starting Steam configuration for '{shortcut_name}'") + + # Step 1: Create Steam shortcut first + mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe') + + # Check if we should use automated prefix creation + use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1' + + if use_automated_prefix: + # Use automated prefix service for modern workflow + print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}") + + from ..services.automated_prefix_service import AutomatedPrefixService + prefix_service = AutomatedPrefixService() + + # Define progress callback for CLI with jackify-engine style timestamps + import time + start_time = time.time() + + def progress_callback(message): + elapsed = time.time() - start_time + hours = int(elapsed // 3600) + minutes = int((elapsed % 3600) // 60) + seconds = int(elapsed % 60) + timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]" + print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}") + + # Run the automated workflow + # Detect Steam Deck once and pass through to workflow + 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 + result = prefix_service.run_working_workflow( + shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck + ) + + # Handle the result + if isinstance(result, tuple) and len(result) == 3: + if result[0] == "CONFLICT": + # Handle conflict + 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 + 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 + success, prefix_path, app_id = result + else: + success, prefix_path, app_id = False, None, None + + if success: + print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}") + if prefix_path: + 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 + else: + print(f"{COLOR_WARNING}Automated Steam setup failed. Falling back to manual setup...{COLOR_RESET}") + + # Fallback to manual shortcut creation process + print(f"\n{COLOR_INFO}Using manual Steam setup workflow...{COLOR_RESET}") + + # 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" + ) + + 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 + + # 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) + + # 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 ===") + + self.logger.info("Running post-installation configuration phase") + configuration_success = modlist_menu.run_modlist_configuration_phase(config_context) + + if configuration_success: + self.logger.info("Post-installation configuration completed successfully") + else: + self.logger.warning("Post-installation configuration had issues") + else: + # Game not supported for automated configuration + print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}") + if detected_game: + print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}") + else: + print(f"{COLOR_WARNING}Could not detect game type from ModOrganizer.ini for automated configuration.{COLOR_RESET}") + print(f"{COLOR_INFO}You may need to manually configure the modlist for Steam/Proton.{COLOR_RESET}") + + def configuration_phase_gui_mode(self, context, + progress_callback=None, + manual_steps_callback=None, + completion_callback=None): + """ + GUI-friendly configuration phase that uses callbacks instead of prompts. + + This method provides the same functionality as configuration_phase() but + integrates with GUI frontends using Qt callbacks instead of CLI prompts. + + Args: + context: Configuration context dict with modlist details + progress_callback: Called with progress messages (str) + manual_steps_callback: Called when manual steps needed (modlist_name, retry_count) + completion_callback: Called when configuration completes (success, message, modlist_name) + """ + # Section header now provided by GUI layer to avoid duplication + + try: + # Set GUI mode for backend operations + import os + original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') + os.environ['JACKIFY_GUI_MODE'] = '1' + + try: + # Build context for configuration + config_context = { + 'name': context.get('modlist_name', ''), + 'path': context.get('install_dir', ''), + 'mo2_exe_path': context.get('mo2_exe_path', ''), + 'modlist_value': context.get('modlist_value'), + 'modlist_source': context.get('modlist_source'), + 'resolution': context.get('resolution'), + 'skip_confirmation': True, # GUI mode is non-interactive + 'manual_steps_completed': False + } + + # Handle existing modlist configuration with app_id + existing_app_id = context.get('app_id') + if existing_app_id: + # This is an existing modlist configuration + config_context['appid'] = existing_app_id + + if progress_callback: + progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...") + + # Get the modlist menu handler + from jackify.backend.handlers.menu_handler import ModlistMenuHandler + from jackify.backend.handlers.config_handler import ConfigHandler + + config_handler = ConfigHandler() + modlist_menu = ModlistMenuHandler(config_handler) + + # Run configuration phase with GUI callbacks for existing modlist + retry_count = 0 + max_retries = 3 + + while retry_count < max_retries: + if progress_callback: + progress_callback("Running modlist configuration...") + + # Run the actual configuration + result = modlist_menu.run_modlist_configuration_phase(config_context) + + if progress_callback: + progress_callback(f"Configuration attempt {retry_count}: {'Success' if result else 'Failed'}") + + if result: + # Configuration successful + if completion_callback: + completion_callback(True, "Configuration completed successfully!", config_context['name']) + return True + else: + # Configuration failed - might need manual steps + retry_count += 1 + + if retry_count < max_retries: + # Show manual steps dialog + if progress_callback: + progress_callback(f"Configuration failed on attempt {retry_count}, showing manual steps dialog...") + if manual_steps_callback: + if progress_callback: + progress_callback(f"Calling manual_steps_callback for {config_context['name']}, retry {retry_count}") + manual_steps_callback(config_context['name'], retry_count) + + # Update context to indicate manual steps were attempted + config_context['manual_steps_completed'] = True + else: + # Max retries reached + if completion_callback: + completion_callback(False, "Manual steps failed after multiple attempts", config_context['name']) + return False + + # Should not reach here + if completion_callback: + completion_callback(False, "Configuration failed", config_context['name']) + return False + + # NEW modlist configuration - create Steam shortcut first + else: + # Get the modlist menu handler + from jackify.backend.handlers.menu_handler import ModlistMenuHandler + from jackify.backend.handlers.config_handler import ConfigHandler + + config_handler = ConfigHandler() + modlist_menu = ModlistMenuHandler(config_handler) + + # Create Steam shortcut first + if progress_callback: + progress_callback("Creating Steam shortcut...") + + # Create shortcut with working NativeSteamService + from jackify.backend.services.native_steam_service import NativeSteamService + steam_service = NativeSteamService() + + 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" + ) + + if not success or not app_id: + if completion_callback: + completion_callback(False, "Failed to create Steam shortcut", config_context['name']) + return False + + # Add the new app_id to context + config_context['appid'] = app_id + + if progress_callback: + # Import here to avoid circular imports + from jackify.shared.timing import get_timestamp + progress_callback(f"{get_timestamp()} Steam shortcut created successfully") + + # For GUI mode, run configuration once and let GUI handle manual steps retry + if progress_callback: + progress_callback("Running modlist configuration...") + + # Run the actual configuration + if progress_callback: + progress_callback(f"About to call run_modlist_configuration_phase with context: {config_context}") + + result = modlist_menu.run_modlist_configuration_phase(config_context) + + if progress_callback: + progress_callback(f"run_modlist_configuration_phase returned: {result}") + + if result: + # Configuration successful + if completion_callback: + completion_callback(True, "Configuration completed successfully!", config_context['name']) + return True + else: + # Configuration failed - need manual steps + if progress_callback: + progress_callback("Configuration failed, manual Steam/Proton setup required") + if manual_steps_callback: + if progress_callback: + progress_callback(f"About to call manual_steps_callback for {config_context['name']}, retry 1") + # Call manual steps callback - GUI will handle validation and retry logic + manual_steps_callback(config_context['name'], 1) + if progress_callback: + progress_callback("manual_steps_callback completed") + + # Don't complete here - let GUI handle retry when user is done + return True + + # Should not reach here + if completion_callback: + completion_callback(False, "Configuration failed", config_context['name']) + return False + + finally: + # Restore original GUI mode + if original_gui_mode is not None: + os.environ['JACKIFY_GUI_MODE'] = original_gui_mode + else: + os.environ.pop('JACKIFY_GUI_MODE', None) + + except Exception as e: + error_msg = f"Configuration failed: {str(e)}" + if completion_callback: + completion_callback(False, error_msg, context.get('modlist_name', 'Unknown')) + return False + + def install_modlist(self, selected_modlist_info: Optional[Dict[str, Any]] = None, wabbajack_file_path: Optional[Union[str, Path]] = None): + # This is where we would get the engine path for the actual installation + engine_path = get_jackify_engine_path() # Use the helper + self.logger.info(f"Using engine path for installation: {engine_path}") + + # --- The rest of your install_modlist logic --- + # ... + # When constructing the subprocess command for install, use `engine_path` + # For example: + # install_command = [engine_path, 'install', '--modlist-url', modlist_url, ...] + # ... + self.logger.info("Placeholder for actual modlist installation logic using the engine.") + print("Modlist installation logic would run here.") + return True # Placeholder + + def _get_nexus_api_key(self) -> Optional[str]: + # This method is not provided in the original file or the code block + # It's assumed to exist as it's called in the _display_summary method + # Implement the logic to retrieve the Nexus API key from the context + return self.context.get('nexus_api_key') + + def get_all_modlists_from_engine(self, game_type=None): + """ + Call the Jackify engine with 'list-modlists' and return a list of modlist dicts. + Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags. + + Args: + game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas") + """ + import subprocess + import re + from pathlib import Path + # COLOR_ERROR already imported at module level + engine_executable = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_executable) + if not os.path.exists(engine_executable): + self.logger.error(f"jackify-install-engine not found at {engine_executable}") + print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_ERROR}") + return [] + env = os.environ.copy() + env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" + command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url'] + + # Add game filter if specified + if game_type: + command.extend(['--game', game_type]) + try: + result = subprocess.run( + command, + capture_output=True, text=True, check=True, + env=env, cwd=engine_dir + ) + lines = result.stdout.splitlines() + modlists = [] + for line in lines: + line = line.strip() + if not line or line.startswith('Loading') or line.startswith('Loaded'): + continue + + # Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL + # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] + + # Extract status indicators + status_down = '[DOWN]' in line + status_nsfw = '[NSFW]' in line + + # 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(' - ') + if len(parts) != 4: + continue # Skip malformed lines + + modlist_name = parts[0].strip() + game_name = parts[1].strip() + sizes_str = parts[2].strip() + machine_url = parts[3].strip() + + # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") + size_parts = sizes_str.split('|') + if len(size_parts) != 3: + continue # Skip if sizes don't match expected format + + download_size = size_parts[0].strip() + install_size = size_parts[1].strip() + total_size = size_parts[2].strip() + + # Skip if any required data is missing + if not modlist_name or not game_name or not machine_url: + continue + + modlists.append({ + 'id': modlist_name, # Use modlist name as ID for compatibility + 'name': modlist_name, + 'game': game_name, + 'download_size': download_size, + 'install_size': install_size, + 'total_size': total_size, + 'machine_url': machine_url, # Store machine URL for installation + 'status_down': status_down, + 'status_nsfw': status_nsfw + }) + return modlists + except subprocess.CalledProcessError as e: + self.logger.error(f"list-modlists failed. Code: {e.returncode}") + if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") + if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") + print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}") + return [] + except Exception as e: + self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) + print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}") + return [] + + def _display_summary(self): + # REMOVE pass AND RESTORE THE METHOD BODY + # print(f"{COLOR_WARNING}DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}{COLOR_RESET}") # Keep commented + # self.logger.info(f"DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}") # Keep commented + print(f"\n{COLOR_INFO}--- Summary of Collected Information ---{COLOR_RESET}") + if self.context.get('modlist_source_type') == 'online_list': + print(f"Modlist Source: Selected from online list") + print(f"Modlist Identifier: {self.context.get('modlist_value')}") + print(f"Detected Game: {self.context.get('modlist_game', 'N/A')}") + elif self.context.get('modlist_source_type') == 'local_file': + print(f"Modlist Source: Local .wabbajack file") + print(f"File Path: {self.context.get('modlist_value')}") + elif 'machineid' in self.context: # For Tuxborn/override flow + print(f"Modlist Identifier (Tuxborn/MachineID): {self.context.get('machineid')}") + + print(f"Steam Shortcut Name: {self.context.get('modlist_name', 'N/A')}") + + install_dir_display = self.context.get('install_dir') + if isinstance(install_dir_display, tuple): + install_dir_display = install_dir_display[0] # Get the Path object from (Path, bool) + print(f"Install Directory: {install_dir_display}") + + download_dir_display = self.context.get('download_dir') + if isinstance(download_dir_display, tuple): + download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool) + print(f"Download Directory: {download_dir_display}") + + if self.context.get('nexus_api_key'): + print(f"Nexus API Key: [SET]") + else: + print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") + print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") \ No newline at end of file diff --git a/jackify/backend/handlers/__init__.py b/jackify/backend/handlers/__init__.py new file mode 100644 index 0000000..c6980df --- /dev/null +++ b/jackify/backend/handlers/__init__.py @@ -0,0 +1,5 @@ +""" +Backend Handlers + +Business logic handlers with UI interactions removed. +""" \ No newline at end of file diff --git a/jackify/backend/handlers/completers.py b/jackify/backend/handlers/completers.py new file mode 100644 index 0000000..a31bd28 --- /dev/null +++ b/jackify/backend/handlers/completers.py @@ -0,0 +1,94 @@ +""" +completers.py +Reusable tab completion functions for Jackify CLI, including bash-like path completion. +""" + +import os +import readline +import logging # Added for debugging + +# Get a logger for this module +completer_logger = logging.getLogger(__name__) # Logger will be named src.modules.completers + +# Set level to DEBUG for this logger to ensure all debug messages are generated. +# These messages will be handled by handlers configured in the main application (e.g., via LoggingHandler). +completer_logger.setLevel(logging.INFO) + +# Ensure messages DO NOT propagate to the root logger's console handler by default. +# A dedicated file handler will be added in jackify-cli.py. +completer_logger.propagate = False + +# IMPORTANT: Do NOT include '/' in the completer delimiters! +# Use: readline.set_completer_delims(' \t\n;') + +def path_completer(text, state): + """ + Bash-like pathname completer for readline. + Args: + text: The text to complete (provided by readline, e.g., "/foo/b" or "b" or "") + state: The state index (0 for first match, 1 for second, etc.) + Returns: + The matching completion string that should replace 'text', or None. + """ + line_buffer = readline.get_line_buffer() + begidx = readline.get_begidx() + endidx = readline.get_endidx() + + effective_text_for_completion = line_buffer[:endidx] + expanded_effective_text = os.path.expanduser(os.path.expandvars(effective_text_for_completion)) + + # Special case: if text is an exact directory (no trailing slash), complete to text + '/' + if os.path.isdir(text) and not text.endswith(os.sep): + if state == 0: + return text + os.sep + else: + return None + + # Normal completion logic + if os.path.isdir(expanded_effective_text): + disk_basedir = expanded_effective_text + disk_item_prefix = "" + else: + disk_basedir = os.path.dirname(expanded_effective_text) + disk_item_prefix = os.path.basename(expanded_effective_text) + if not disk_basedir: + disk_basedir = "." + + matched_item_names_on_disk = [] + try: + if not os.path.exists(disk_basedir) or not os.path.isdir(disk_basedir): + completer_logger.warning(f" Disk basedir '{disk_basedir}' non-existent or not a dir. No disk matches.") + else: + dir_contents = os.listdir(disk_basedir) + for item_name in dir_contents: + if item_name.startswith(disk_item_prefix): + matched_item_names_on_disk.append(item_name) + except OSError as e: + completer_logger.error(f" OSError listing '{disk_basedir}': {e}") + + final_match_strings_for_readline = [] + text_dir_part = os.path.dirname(text) + # If text is a directory with trailing slash, use it as the base for completions + if os.path.isdir(text) and text.endswith(os.sep): + base_path = text + elif os.path.isdir(text): + base_path = text + os.sep + else: + base_path = text_dir_part + os.sep if text_dir_part else "" + + for item_name in matched_item_names_on_disk: + result_str_for_readline = os.path.join(base_path, item_name) + actual_disk_path_of_item = os.path.join(disk_basedir, item_name) + if os.path.isdir(actual_disk_path_of_item): + result_str_for_readline += os.sep + final_match_strings_for_readline.append(result_str_for_readline) + final_match_strings_for_readline.sort() + try: + match = final_match_strings_for_readline[state] + completer_logger.debug(f" Returning match for state {state}: '{match}'") + return match + except IndexError: + return None + except Exception as e: + completer_logger.exception(f" Unexpected error retrieving match for state {state}: {e}") + return None \ No newline at end of file diff --git a/jackify/backend/handlers/config_handler.py b/jackify/backend/handlers/config_handler.py new file mode 100644 index 0000000..b0eb731 --- /dev/null +++ b/jackify/backend/handlers/config_handler.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Configuration Handler Module +Handles application settings and configuration +""" + +import os +import json +import logging +import shutil +import re +import base64 +from pathlib import Path + +# Initialize logger +logger = logging.getLogger(__name__) + + +class ConfigHandler: + """ + Handles application configuration and settings + """ + + def __init__(self): + """Initialize configuration handler with default settings""" + self.config_dir = os.path.expanduser("~/.config/jackify") + self.config_file = os.path.join(self.config_dir, "config.json") + self.settings = { + "version": "0.0.5", + "last_selected_modlist": None, + "steam_libraries": [], + "resolution": None, + "protontricks_path": None, + "steam_path": None, + "nexus_api_key": None, # Base64 encoded API key + "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 + } + + # Load configuration if exists + self._load_config() + + # If steam_path is not set, detect it + if not self.settings["steam_path"]: + self.settings["steam_path"] = self._detect_steam_path() + # Save the updated settings + self.save_config() + + def _detect_steam_path(self): + """ + Detect the Steam installation path + + Returns: + str: Path to the Steam installation or None if not found + """ + logger.info("Detecting Steam installation path...") + + # Common Steam installation paths + steam_paths = [ + os.path.expanduser("~/.steam/steam"), + os.path.expanduser("~/.local/share/Steam"), + os.path.expanduser("~/.steam/root") + ] + + # Check each path + for path in steam_paths: + if os.path.exists(path): + logger.info(f"Found Steam installation at: {path}") + return path + + # If not found in common locations, try to find using libraryfolders.vdf + libraryfolders_vdf_paths = [ + os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"), + os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"), + os.path.expanduser("~/.steam/root/config/libraryfolders.vdf") + ] + + for vdf_path in libraryfolders_vdf_paths: + if os.path.exists(vdf_path): + # Extract the Steam path from the libraryfolders.vdf path + steam_path = os.path.dirname(os.path.dirname(vdf_path)) + logger.info(f"Found Steam installation at: {steam_path}") + return steam_path + + logger.error("Steam installation not found") + return None + + def _load_config(self): + """Load configuration from file""" + try: + if os.path.exists(self.config_file): + with open(self.config_file, 'r') as f: + saved_config = json.load(f) + # Update settings with saved values while preserving defaults + self.settings.update(saved_config) + logger.debug("Loaded configuration from file") + else: + logger.debug("No configuration file found, using defaults") + self._create_config_dir() + except Exception as e: + logger.error(f"Error loading configuration: {e}") + + def _create_config_dir(self): + """Create configuration directory if it doesn't exist""" + try: + os.makedirs(self.config_dir, exist_ok=True) + logger.debug(f"Created configuration directory: {self.config_dir}") + except Exception as e: + logger.error(f"Error creating configuration directory: {e}") + + def save_config(self): + """Save current configuration to file""" + try: + self._create_config_dir() + with open(self.config_file, 'w') as f: + json.dump(self.settings, f, indent=2) + logger.debug("Saved configuration to file") + return True + except Exception as e: + logger.error(f"Error saving configuration: {e}") + return False + + def get(self, key, default=None): + """Get a configuration value by key""" + return self.settings.get(key, default) + + def set(self, key, value): + """Set a configuration value""" + self.settings[key] = value + return True + + def update(self, settings_dict): + """Update multiple configuration values""" + self.settings.update(settings_dict) + return True + + def add_steam_library(self, path): + """Add a Steam library path to configuration""" + if path not in self.settings["steam_libraries"]: + self.settings["steam_libraries"].append(path) + logger.debug(f"Added Steam library: {path}") + return True + return False + + def remove_steam_library(self, path): + """Remove a Steam library path from configuration""" + if path in self.settings["steam_libraries"]: + self.settings["steam_libraries"].remove(path) + logger.debug(f"Removed Steam library: {path}") + return True + return False + + def set_resolution(self, width, height): + """Set preferred resolution""" + resolution = f"{width}x{height}" + self.settings["resolution"] = resolution + logger.debug(f"Set resolution to: {resolution}") + return True + + def get_resolution(self): + """Get preferred resolution""" + return self.settings.get("resolution") + + def set_last_modlist(self, modlist_name): + """Save the last selected modlist""" + self.settings["last_selected_modlist"] = modlist_name + logger.debug(f"Set last selected modlist to: {modlist_name}") + return True + + def get_last_modlist(self): + """Get the last selected modlist""" + return self.settings.get("last_selected_modlist") + + def set_protontricks_path(self, path): + """Set the path to protontricks executable""" + self.settings["protontricks_path"] = path + logger.debug(f"Set protontricks path to: {path}") + return True + + def get_protontricks_path(self): + """Get the path to protontricks executable""" + return self.settings.get("protontricks_path") + + def save_api_key(self, api_key): + """ + Save Nexus API key with base64 encoding + + Args: + api_key (str): Plain text API key + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + if api_key: + # Encode the API key using base64 + encoded_key = base64.b64encode(api_key.encode('utf-8')).decode('utf-8') + self.settings["nexus_api_key"] = encoded_key + logger.debug("API key saved successfully") + else: + # Clear the API key if empty + self.settings["nexus_api_key"] = None + logger.debug("API key cleared") + + return self.save_config() + except Exception as e: + logger.error(f"Error saving API key: {e}") + return False + + def get_api_key(self): + """ + Retrieve and decode the saved Nexus API key + + Returns: + str: Decoded API key or None if not saved + """ + try: + encoded_key = self.settings.get("nexus_api_key") + if encoded_key: + # Decode the base64 encoded key + decoded_key = base64.b64decode(encoded_key.encode('utf-8')).decode('utf-8') + return decoded_key + return None + except Exception as e: + logger.error(f"Error retrieving API key: {e}") + return None + + def has_saved_api_key(self): + """ + Check if an API key is saved in configuration + + Returns: + bool: True if API key exists, False otherwise + """ + return self.settings.get("nexus_api_key") is not None + + def clear_api_key(self): + """ + Clear the saved API key from configuration + + Returns: + bool: True if cleared successfully, False otherwise + """ + try: + self.settings["nexus_api_key"] = None + logger.debug("API key cleared from configuration") + return self.save_config() + except Exception as e: + logger.error(f"Error clearing API key: {e}") + return False + def save_resolution(self, resolution): + """ + Save resolution setting to configuration + + Args: + resolution (str): Resolution string (e.g., '1920x1080') + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + if resolution and resolution != 'Leave unchanged': + self.settings["resolution"] = resolution + logger.debug(f"Resolution saved: {resolution}") + else: + # Clear resolution if 'Leave unchanged' or empty + self.settings["resolution"] = None + logger.debug("Resolution cleared") + + return self.save_config() + except Exception as e: + logger.error(f"Error saving resolution: {e}") + return False + + def get_saved_resolution(self): + """ + Retrieve the saved resolution from configuration + + Returns: + str: Saved resolution or None if not saved + """ + try: + resolution = self.settings.get("resolution") + if resolution: + logger.debug(f"Retrieved saved resolution: {resolution}") + else: + logger.debug("No saved resolution found") + return resolution + except Exception as e: + logger.error(f"Error retrieving resolution: {e}") + return None + + def has_saved_resolution(self): + """ + Check if a resolution is saved in configuration + + Returns: + bool: True if resolution exists, False otherwise + """ + return self.settings.get("resolution") is not None + + def clear_saved_resolution(self): + """ + Clear the saved resolution from configuration + + Returns: + bool: True if cleared successfully, False otherwise + """ + try: + self.settings["resolution"] = None + logger.debug("Resolution cleared from configuration") + return self.save_config() + except Exception as e: + logger.error(f"Error clearing resolution: {e}") + return False + + def set_default_install_parent_dir(self, path): + """ + Save the parent directory for modlist installations + + Args: + path (str): Parent directory path to save + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + if path and os.path.exists(path): + self.settings["default_install_parent_dir"] = path + logger.debug(f"Default install parent directory saved: {path}") + return self.save_config() + else: + logger.warning(f"Invalid or non-existent path for install parent directory: {path}") + return False + except Exception as e: + logger.error(f"Error saving install parent directory: {e}") + return False + + def get_default_install_parent_dir(self): + """ + Retrieve the saved parent directory for modlist installations + + Returns: + str: Saved parent directory path or None if not saved + """ + try: + path = self.settings.get("default_install_parent_dir") + if path and os.path.exists(path): + logger.debug(f"Retrieved default install parent directory: {path}") + return path + else: + logger.debug("No valid default install parent directory found") + return None + except Exception as e: + logger.error(f"Error retrieving install parent directory: {e}") + return None + + def set_default_download_parent_dir(self, path): + """ + Save the parent directory for downloads + + Args: + path (str): Parent directory path to save + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + if path and os.path.exists(path): + self.settings["default_download_parent_dir"] = path + logger.debug(f"Default download parent directory saved: {path}") + return self.save_config() + else: + logger.warning(f"Invalid or non-existent path for download parent directory: {path}") + return False + except Exception as e: + logger.error(f"Error saving download parent directory: {e}") + return False + + def get_default_download_parent_dir(self): + """ + Retrieve the saved parent directory for downloads + + Returns: + str: Saved parent directory path or None if not saved + """ + try: + path = self.settings.get("default_download_parent_dir") + if path and os.path.exists(path): + logger.debug(f"Retrieved default download parent directory: {path}") + return path + else: + logger.debug("No valid default download parent directory found") + return None + except Exception as e: + logger.error(f"Error retrieving download parent directory: {e}") + return None + + def has_saved_install_parent_dir(self): + """ + Check if a default install parent directory is saved in configuration + + Returns: + bool: True if directory exists and is valid, False otherwise + """ + path = self.settings.get("default_install_parent_dir") + return path is not None and os.path.exists(path) + + def has_saved_download_parent_dir(self): + """ + Check if a default download parent directory is saved in configuration + + Returns: + bool: True if directory exists and is valid, False otherwise + """ + path = self.settings.get("default_download_parent_dir") + return path is not None and os.path.exists(path) + + def get_modlist_install_base_dir(self): + """ + Get the configurable base directory for modlist installations + + Returns: + str: Base directory path for modlist installations + """ + return self.settings.get("modlist_install_base_dir", os.path.expanduser("~/Games")) + + def set_modlist_install_base_dir(self, path): + """ + Set the configurable base directory for modlist installations + + Args: + path (str): Base directory path to save + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + if path: + self.settings["modlist_install_base_dir"] = path + logger.debug(f"Modlist install base directory saved: {path}") + return self.save_config() + else: + logger.warning("Invalid path for modlist install base directory") + return False + except Exception as e: + logger.error(f"Error saving modlist install base directory: {e}") + return False + + def get_modlist_downloads_base_dir(self): + """ + Get the configurable base directory for modlist downloads + + Returns: + str: Base directory path for modlist downloads + """ + return self.settings.get("modlist_downloads_base_dir", os.path.expanduser("~/Games/Modlist_Downloads")) + + def set_modlist_downloads_base_dir(self, path): + """ + Set the configurable base directory for modlist downloads + + Args: + path (str): Base directory path to save + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + if path: + self.settings["modlist_downloads_base_dir"] = path + logger.debug(f"Modlist downloads base directory saved: {path}") + return self.save_config() + else: + logger.warning("Invalid path for modlist downloads base directory") + return False + except Exception as e: + logger.error(f"Error saving modlist downloads base directory: {e}") + return False + + \ No newline at end of file diff --git a/jackify/backend/handlers/diagnostic_helper.py b/jackify/backend/handlers/diagnostic_helper.py new file mode 100644 index 0000000..5ee955a --- /dev/null +++ b/jackify/backend/handlers/diagnostic_helper.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Jackify Performance Diagnostic Helper + +This utility helps diagnose whether performance issues are in: +1. jackify-engine (.NET binary) - stalls, memory leaks, etc. +2. jackify (Python wrapper) - subprocess handling, threading issues + +Usage: python -m jackify.backend.handlers.diagnostic_helper +""" + +import time +import psutil +import subprocess +import logging +import sys +from pathlib import Path +from typing import List, Dict, Any + + +def find_jackify_engine_processes() -> List[Dict[str, Any]]: + """Find all running jackify-engine and magick (ImageMagick) processes.""" + processes = [] + for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'create_time', 'cpu_percent', 'memory_info']): + try: + if ( + 'jackify-engine' in proc.info['name'] or + any('jackify-engine' in arg for arg in (proc.info['cmdline'] or [])) or + proc.info['name'] == 'magick' or + any('magick' in arg for arg in (proc.info['cmdline'] or [])) + ): + processes.append({ + 'pid': proc.info['pid'], + 'name': proc.info['name'], + 'cmdline': ' '.join(proc.info['cmdline'] or []), + 'age_seconds': time.time() - proc.info['create_time'], + 'cpu_percent': proc.info['cpu_percent'], + 'memory_mb': proc.info['memory_info'].rss / (1024 * 1024) if proc.info['memory_info'] else 0, + 'process': proc + }) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + return processes + + +def diagnose_stalled_engine(pid: int, duration: int = 60) -> Dict[str, Any]: + """Monitor a specific jackify-engine process for stalls.""" + try: + proc = psutil.Process(pid) + except psutil.NoSuchProcess: + return {"error": f"Process {pid} not found"} + + print(f"Monitoring jackify-engine PID {pid} for {duration} seconds...") + + samples = [] + start_time = time.time() + + while time.time() - start_time < duration: + try: + sample = { + 'timestamp': time.time(), + 'cpu_percent': proc.cpu_percent(), + 'memory_mb': proc.memory_info().rss / (1024 * 1024), + 'thread_count': proc.num_threads(), + 'status': proc.status() + } + + try: + sample['fd_count'] = proc.num_fds() + except (psutil.AccessDenied, AttributeError): + sample['fd_count'] = 0 + + samples.append(sample) + + # Real-time status + status_icon = "🟢" if sample['cpu_percent'] > 10 else "🟡" if sample['cpu_percent'] > 2 else "🔴" + print(f"{status_icon} CPU: {sample['cpu_percent']:5.1f}% | Memory: {sample['memory_mb']:6.1f}MB | " + f"Threads: {sample['thread_count']:2d} | Status: {sample['status']}") + + time.sleep(2) + + except psutil.NoSuchProcess: + print("Process terminated during monitoring") + break + except Exception as e: + print(f"Error monitoring process: {e}") + break + + if not samples: + return {"error": "No samples collected"} + + # Analyze results + cpu_values = [s['cpu_percent'] for s in samples] + memory_values = [s['memory_mb'] for s in samples] + + low_cpu_samples = [s for s in samples if s['cpu_percent'] < 5] + stall_duration = len(low_cpu_samples) * 2 # 2 second intervals + + diagnosis = { + 'samples': len(samples), + 'avg_cpu': sum(cpu_values) / len(cpu_values), + 'max_cpu': max(cpu_values), + 'min_cpu': min(cpu_values), + 'avg_memory_mb': sum(memory_values) / len(memory_values), + 'max_memory_mb': max(memory_values), + 'low_cpu_samples': len(low_cpu_samples), + 'stall_duration_seconds': stall_duration, + 'thread_count_final': samples[-1]['thread_count'] if samples else 0, + 'likely_stalled': stall_duration > 30 and sum(cpu_values[-5:]) / 5 < 5, # Last 10 seconds low CPU + } + + return diagnosis + + +def check_system_resources() -> Dict[str, Any]: + """Check overall system resources that might affect performance.""" + return { + 'total_memory_gb': psutil.virtual_memory().total / (1024**3), + 'available_memory_gb': psutil.virtual_memory().available / (1024**3), + 'memory_percent': psutil.virtual_memory().percent, + 'cpu_count': psutil.cpu_count(), + 'cpu_percent_overall': psutil.cpu_percent(interval=1), + 'disk_usage_percent': psutil.disk_usage('/').percent, + 'load_average': psutil.getloadavg() if hasattr(psutil, 'getloadavg') else None, + } + + +def main(): + """Main diagnostic routine.""" + print("Jackify Performance Diagnostic Tool") + print("=" * 50) + + # Check for running engines and magick processes + engines = find_jackify_engine_processes() + + if not engines: + print("No jackify-engine or magick processes found running") + print("\nTo use this tool:") + print("1. Start a modlist installation in Jackify") + print("2. Run this diagnostic while the installation is active") + return + + print(f"Found {len(engines)} relevant process(es):") + for engine in engines: + age_min = engine['age_seconds'] / 60 + print(f" PID {engine['pid']}: {engine['name']} {engine['cpu_percent']:.1f}% CPU, " + f"{engine['memory_mb']:.1f}MB RAM, running {age_min:.1f} minutes, CMD: {engine['cmdline']}") + + # Check system resources + print("\nSystem Resources:") + sys_info = check_system_resources() + print(f" Memory: {sys_info['memory_percent']:.1f}% used " + f"({sys_info['available_memory_gb']:.1f}GB / {sys_info['total_memory_gb']:.1f}GB available)") + print(f" CPU: {sys_info['cpu_percent_overall']:.1f}% overall, {sys_info['cpu_count']} cores") + print(f" Disk: {sys_info['disk_usage_percent']:.1f}% used") + if sys_info['load_average']: + print(f" Load average: {sys_info['load_average']}") + + # Focus on the engine with highest CPU usage (likely active) + active_engine = max(engines, key=lambda x: x['cpu_percent']) + + print(f"\nMonitoring most active engine (PID {active_engine['pid']}) for stalls...") + + try: + diagnosis = diagnose_stalled_engine(active_engine['pid'], duration=60) + + if 'error' in diagnosis: + print(f"Error: {diagnosis['error']}") + return + + print(f"\n📊 Diagnosis 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']} " + f"(stalled for {diagnosis['stall_duration_seconds']}s)") + print(f" Thread count: {diagnosis['thread_count_final']}") + + # Provide diagnosis + print(f"\n[DIAGNOSIS]:") + if diagnosis['likely_stalled']: + print("[ERROR] ENGINE STALL DETECTED") + print(" - jackify-engine process shows sustained low CPU usage") + print(" - This indicates an issue in the .NET Wabbajack engine, not the Python wrapper") + print(" - Recommendation: Report this to the Wabbajack team as a jackify-engine issue") + elif diagnosis['avg_cpu'] > 50: + print("[OK] Engine appears to be working normally (high CPU activity)") + elif diagnosis['avg_cpu'] > 10: + print("[WARNING] Engine showing moderate activity - may be normal for current operation") + else: + print("[WARNING] Engine showing low activity - monitor for longer or check if installation completed") + + # System-level issues + if sys_info['memory_percent'] > 90: + print("[WARNING] System memory critically low - may cause stalls") + elif sys_info['memory_percent'] > 80: + print("[CAUTION] System memory usage high") + + if sys_info['cpu_percent_overall'] > 90: + print("[WARNING] System CPU usage very high - may indicate system-wide issue") + + except KeyboardInterrupt: + print("\n\n[STOPPED] Monitoring interrupted by user") + except Exception as e: + print(f"\n[ERROR] Error during diagnosis: {e}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/jackify/backend/handlers/engine_monitor.py b/jackify/backend/handlers/engine_monitor.py new file mode 100644 index 0000000..2396ade --- /dev/null +++ b/jackify/backend/handlers/engine_monitor.py @@ -0,0 +1,338 @@ +""" +Engine Performance Monitor + +Monitors the jackify-engine process for performance issues like CPU stalls, +memory problems, and excessive I/O wait times. +""" + +import time +import threading +import psutil +import logging +import os +from typing import Optional, Dict, Any, Callable +from dataclasses import dataclass +from enum import Enum + + +class PerformanceState(Enum): + NORMAL = "normal" + STALLED = "stalled" + HIGH_MEMORY = "high_memory" + HIGH_IO_WAIT = "high_io_wait" + ZOMBIE = "zombie" + + +@dataclass +class PerformanceMetrics: + timestamp: float + cpu_percent: float + memory_percent: float + memory_mb: float + io_read_mb: float + io_write_mb: float + thread_count: int + fd_count: int + state: PerformanceState + + # Additional diagnostics for engine vs wrapper distinction + parent_cpu_percent: Optional[float] = None + parent_memory_mb: Optional[float] = None + engine_responsive: bool = True + + # New: ImageMagick resource usage + magick_cpu_percent: float = 0.0 + magick_memory_mb: float = 0.0 + + +class EnginePerformanceMonitor: + """ + Monitors jackify-engine process performance and detects common stall patterns. + + This is designed to help diagnose the issue where extraction starts at 80-100% CPU + but drops to 2% after ~5 minutes and requires manual kills. + + Also monitors parent Python process to distinguish between engine vs wrapper issues. + """ + + def __init__(self, + logger: Optional[logging.Logger] = None, + stall_threshold: float = 5.0, # CPU below this % for stall_duration = stall + stall_duration: float = 120.0, # seconds of low CPU = stall + memory_threshold: float = 85.0, # % memory usage threshold + sample_interval: float = 5.0): # seconds between samples + + self.logger = logger or logging.getLogger(__name__) + self.stall_threshold = stall_threshold + self.stall_duration = stall_duration + self.memory_threshold = memory_threshold + self.sample_interval = sample_interval + + self._process: Optional[psutil.Process] = None + self._parent_process: Optional[psutil.Process] = None + self._monitoring = False + self._monitor_thread: Optional[threading.Thread] = None + self._metrics_history: list[PerformanceMetrics] = [] + self._callbacks: list[Callable[[PerformanceMetrics], None]] = [] + + # Performance state tracking + self._low_cpu_start_time: Optional[float] = None + self._last_io_read = 0 + self._last_io_write = 0 + + def add_callback(self, callback: Callable[[PerformanceMetrics], None]): + """Add a callback to receive performance metrics updates.""" + self._callbacks.append(callback) + + def start_monitoring(self, pid: int) -> bool: + """Start monitoring the given process ID.""" + try: + self._process = psutil.Process(pid) + + # Also monitor the parent Python process for comparison + try: + self._parent_process = psutil.Process(os.getpid()) + except: + self._parent_process = None + + self._monitoring = True + self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) + self._monitor_thread.start() + + process_name = self._process.name() if self._process else "unknown" + self.logger.info(f"Started performance monitoring for PID {pid} ({process_name}) " + f"(stall threshold: {self.stall_threshold}% CPU for {self.stall_duration}s)") + return True + + except psutil.NoSuchProcess: + self.logger.error(f"Process {pid} not found") + return False + except Exception as e: + self.logger.error(f"Failed to start monitoring PID {pid}: {e}") + return False + + def stop_monitoring(self): + """Stop monitoring the process.""" + self._monitoring = False + if self._monitor_thread and self._monitor_thread.is_alive(): + self._monitor_thread.join(timeout=10) + + def get_metrics_summary(self) -> Dict[str, Any]: + """Get a summary of collected metrics.""" + if not self._metrics_history: + return {} + + cpu_values = [m.cpu_percent for m in self._metrics_history] + memory_values = [m.memory_mb for m in self._metrics_history] + + stalled_count = sum(1 for m in self._metrics_history if m.state == PerformanceState.STALLED) + + # Engine vs wrapper analysis + engine_avg_cpu = sum(cpu_values) / len(cpu_values) + parent_cpu_values = [m.parent_cpu_percent for m in self._metrics_history if m.parent_cpu_percent is not None] + parent_avg_cpu = sum(parent_cpu_values) / len(parent_cpu_values) if parent_cpu_values else 0 + + return { + "total_samples": len(self._metrics_history), + "monitoring_duration": self._metrics_history[-1].timestamp - self._metrics_history[0].timestamp, + + # Engine process metrics + "engine_avg_cpu_percent": engine_avg_cpu, + "engine_max_cpu_percent": max(cpu_values), + "engine_min_cpu_percent": min(cpu_values), + "engine_avg_memory_mb": sum(memory_values) / len(memory_values), + "engine_max_memory_mb": max(memory_values), + + # Parent process metrics (for comparison) + "parent_avg_cpu_percent": parent_avg_cpu, + + # Stall analysis + "stalled_samples": stalled_count, + "stall_percentage": (stalled_count / len(self._metrics_history)) * 100, + + # Diagnosis hints + "likely_engine_issue": engine_avg_cpu < 10 and parent_avg_cpu < 5, + "likely_wrapper_issue": engine_avg_cpu > 20 and parent_avg_cpu > 50, + } + + def _monitor_loop(self): + """Main monitoring loop.""" + while self._monitoring: + try: + if not self._process or not self._process.is_running(): + self.logger.warning("Monitored engine process is no longer running") + break + + metrics = self._collect_metrics() + self._metrics_history.append(metrics) + + # Notify callbacks + for callback in self._callbacks: + try: + callback(metrics) + except Exception as e: + self.logger.error(f"Error in performance callback: {e}") + + # Log significant events with engine vs wrapper context + if metrics.state == PerformanceState.STALLED: + parent_info = "" + if metrics.parent_cpu_percent is not None: + parent_info = f", Python wrapper: {metrics.parent_cpu_percent:.1f}% CPU" + + self.logger.warning(f"🚨 ENGINE STALL DETECTED: jackify-engine CPU at {metrics.cpu_percent:.1f}% " + f"for {self.stall_duration}s+ (Memory: {metrics.memory_mb:.1f}MB, " + f"Threads: {metrics.thread_count}, FDs: {metrics.fd_count}{parent_info})") + + # Provide diagnosis hint + if metrics.parent_cpu_percent and metrics.parent_cpu_percent > 10: + self.logger.warning("Warning: Python wrapper still active - likely jackify-engine (.NET) issue") + else: + self.logger.warning("Warning: Both processes low CPU - possible system-wide issue") + + elif metrics.state == PerformanceState.HIGH_MEMORY: + self.logger.warning(f"HIGH MEMORY USAGE in jackify-engine: {metrics.memory_percent:.1f}% " + f"({metrics.memory_mb:.1f}MB)") + + time.sleep(self.sample_interval) + + except psutil.NoSuchProcess: + self.logger.info("Monitored engine process terminated") + break + except Exception as e: + self.logger.error(f"Error in monitoring loop: {e}") + time.sleep(self.sample_interval) + + def _collect_metrics(self) -> PerformanceMetrics: + """Collect current performance metrics.""" + now = time.time() + + # Get basic process info for engine + cpu_percent = self._process.cpu_percent() + memory_info = self._process.memory_info() + memory_mb = memory_info.rss / (1024 * 1024) + memory_percent = self._process.memory_percent() + + # Get parent process info for comparison + parent_cpu_percent = None + parent_memory_mb = None + if self._parent_process: + try: + parent_cpu_percent = self._parent_process.cpu_percent() + parent_memory_info = self._parent_process.memory_info() + parent_memory_mb = parent_memory_info.rss / (1024 * 1024) + except: + pass + + # Get I/O info + try: + io_counters = self._process.io_counters() + io_read_mb = io_counters.read_bytes / (1024 * 1024) + io_write_mb = io_counters.write_bytes / (1024 * 1024) + except (psutil.AccessDenied, AttributeError): + io_read_mb = 0 + io_write_mb = 0 + + # Get thread and file descriptor counts + try: + thread_count = self._process.num_threads() + except (psutil.AccessDenied, AttributeError): + thread_count = 0 + + try: + fd_count = self._process.num_fds() + except (psutil.AccessDenied, AttributeError): + fd_count = 0 + + # Determine performance state + state = self._determine_state(cpu_percent, memory_percent, now) + + # New: Aggregate ImageMagick ('magick') child process usage + magick_cpu = 0.0 + magick_mem = 0.0 + try: + for child in self._process.children(recursive=True): + try: + if child.name() == 'magick' or 'magick' in ' '.join(child.cmdline()): + magick_cpu += child.cpu_percent() + magick_mem += child.memory_info().rss / (1024 * 1024) + except Exception: + continue + except Exception: + pass + + return PerformanceMetrics( + timestamp=now, + cpu_percent=cpu_percent, + memory_percent=memory_percent, + memory_mb=memory_mb, + io_read_mb=io_read_mb, + io_write_mb=io_write_mb, + thread_count=thread_count, + fd_count=fd_count, + state=state, + parent_cpu_percent=parent_cpu_percent, + parent_memory_mb=parent_memory_mb, + engine_responsive=cpu_percent > self.stall_threshold or (now - self._low_cpu_start_time if self._low_cpu_start_time else 0) < self.stall_duration, + magick_cpu_percent=magick_cpu, + magick_memory_mb=magick_mem + ) + + def _determine_state(self, cpu_percent: float, memory_percent: float, timestamp: float) -> PerformanceState: + """Determine the current performance state.""" + + # Check for high memory usage + if memory_percent > self.memory_threshold: + return PerformanceState.HIGH_MEMORY + + # Check for CPU stall + if cpu_percent < self.stall_threshold: + if self._low_cpu_start_time is None: + self._low_cpu_start_time = timestamp + elif timestamp - self._low_cpu_start_time >= self.stall_duration: + return PerformanceState.STALLED + else: + # CPU is above threshold, reset stall timer + self._low_cpu_start_time = None + + return PerformanceState.NORMAL + + +def create_debug_callback(logger: logging.Logger) -> Callable[[PerformanceMetrics], None]: + """Create a callback that logs detailed performance metrics for debugging.""" + + def debug_callback(metrics: PerformanceMetrics): + parent_info = f", Python: {metrics.parent_cpu_percent:.1f}%" if metrics.parent_cpu_percent else "" + magick_info = f", Magick: {metrics.magick_cpu_percent:.1f}% CPU, {metrics.magick_memory_mb:.1f}MB RAM" if metrics.magick_cpu_percent or metrics.magick_memory_mb else "" + logger.debug(f"Engine Performance: jackify-engine CPU={metrics.cpu_percent:.1f}%, " + f"Memory={metrics.memory_mb:.1f}MB ({metrics.memory_percent:.1f}%), " + f"Threads={metrics.thread_count}, FDs={metrics.fd_count}, " + f"State={metrics.state.value}{parent_info}{magick_info}") + + return debug_callback + + +def create_stall_alert_callback(logger: logging.Logger, + alert_func: Optional[Callable[[str], None]] = None + ) -> Callable[[PerformanceMetrics], None]: + """Create a callback that alerts when performance issues are detected.""" + + def alert_callback(metrics: PerformanceMetrics): + if metrics.state in [PerformanceState.STALLED, PerformanceState.HIGH_MEMORY]: + + # Provide context about engine vs wrapper + if metrics.state == PerformanceState.STALLED: + if metrics.parent_cpu_percent and metrics.parent_cpu_percent > 10: + issue_type = "jackify-engine (.NET binary) stalled" + else: + issue_type = "system-wide performance issue" + else: + issue_type = metrics.state.value.upper() + + message = (f"{issue_type} - Engine CPU: {metrics.cpu_percent:.1f}%, " + f"Memory: {metrics.memory_mb:.1f}MB") + + logger.warning(message) + if alert_func: + alert_func(message) + + return alert_callback \ No newline at end of file diff --git a/jackify/backend/handlers/filesystem_handler.py b/jackify/backend/handlers/filesystem_handler.py new file mode 100644 index 0000000..f629d7d --- /dev/null +++ b/jackify/backend/handlers/filesystem_handler.py @@ -0,0 +1,900 @@ +""" +FileSystemHandler module for managing file system operations. +This module handles path normalization, validation, and file operations. +""" + +import os +import shutil +import logging +from pathlib import Path +from typing import Optional, List, Dict, Tuple +from datetime import datetime +import re +import time +import subprocess # Needed for running sudo commands +import pwd # To get user name +import grp # To get group name +import requests # Import requests +import vdf # Import VDF library at the top level +from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET + +# Initialize logger for the module +logger = logging.getLogger(__name__) + +class FileSystemHandler: + def __init__(self): + # Keep instance logger if needed, but static methods use module logger + self.logger = logging.getLogger(__name__) + + @staticmethod + def normalize_path(path: str) -> Path: + """Normalize a path string to a Path object.""" + try: + if path.startswith('~'): + path = os.path.expanduser(path) + path = os.path.abspath(path) + return Path(path) + except Exception as e: + logger.error(f"Failed to normalize path {path}: {e}") + return Path(path) # Return original path as Path object on error + + @staticmethod + def validate_path(path: Path) -> bool: + """Validate if a path exists and is accessible.""" + try: + if not path.exists(): + logger.warning(f"Validation failed: Path does not exist - {path}") + return False + # Check read access + if not os.access(path, os.R_OK): + logger.warning(f"Validation failed: No read access - {path}") + return False + # Check write access (important for many operations) + # For directories, check write on parent; for files, check write on file itself + if path.is_dir(): + if not os.access(path, os.W_OK): + logger.warning(f"Validation failed: No write access to directory - {path}") + return False + elif path.is_file(): + # Check write access to the parent directory for file creation/modification + if not os.access(path.parent, os.W_OK): + logger.warning(f"Validation failed: No write access to parent dir of file - {path.parent}") + return False + return True # Passed existence and access checks + except Exception as e: + logger.error(f"Failed to validate path {path}: {e}") + return False + + @staticmethod + def ensure_directory(path: Path) -> bool: + """Ensure a directory exists, create if it doesn't.""" + try: + path.mkdir(parents=True, exist_ok=True) + logger.debug(f"Ensured directory exists: {path}") + return True + except Exception as e: + logger.error(f"Failed to ensure directory {path}: {e}") + return False + + @staticmethod + def backup_file(file_path: Path, backup_dir: Optional[Path] = None) -> Optional[Path]: + """Create a backup of a file with timestamp.""" + try: + if not file_path.is_file(): + logger.error(f"Backup failed: Source is not a file - {file_path}") + return None + + if backup_dir is None: + backup_dir = file_path.parent / "backups" + + FileSystemHandler.ensure_directory(backup_dir) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = backup_dir / f"{file_path.stem}_{timestamp}{file_path.suffix}" + + shutil.copy2(file_path, backup_path) + logger.info(f"File backed up to: {backup_path}") + return backup_path + except Exception as e: + logger.error(f"Failed to backup file {file_path}: {e}") + return None + + @staticmethod + def restore_backup(backup_path: Path, target_path: Path) -> bool: + """Restore a file from backup, backing up the current target first.""" + try: + if not backup_path.is_file(): + logger.error(f"Restore failed: Backup source is not a file - {backup_path}") + return False + + if target_path.exists(): + logger.warning(f"Target file exists, creating backup before restore: {target_path}") + FileSystemHandler.backup_file(target_path) + + # Ensure target directory exists + FileSystemHandler.ensure_directory(target_path.parent) + + shutil.copy2(backup_path, target_path) + logger.info(f"Restored {backup_path} to {target_path}") + return True + except Exception as e: + logger.error(f"Failed to restore backup {backup_path} to {target_path}: {e}") + return False + + @staticmethod + def find_latest_backup(original_file_path: Path) -> Optional[Path]: + """Finds the most recent backup file for a given original file path.""" + if not original_file_path.exists(): + logger.warning(f"Cannot find backups for non-existent file: {original_file_path}") + return None + + backup_dir = original_file_path.parent / "backups" + if not backup_dir.is_dir(): + logger.debug(f"Backup directory not found: {backup_dir}") + return None + + file_stem = original_file_path.stem + file_suffix = original_file_path.suffix + + # Look for timestamped backups first (e.g., shortcuts_20230101_120000.vdf) + # Adjusted glob pattern to match the format used in backup_file + timestamp_pattern = f"{file_stem}_*_*{file_suffix}" + timestamped_backups = list(backup_dir.glob(timestamp_pattern)) + + latest_backup_path = None + latest_timestamp = 0 + + if timestamped_backups: + logger.debug(f"Found potential timestamped backups: {timestamped_backups}") + for backup_path in timestamped_backups: + # Extract timestamp from filename (e.g., stem_YYYYMMDD_HHMMSS.suffix) + try: + name_parts = backup_path.stem.split('_') + if len(name_parts) >= 3: + # Combine date and time parts for parsing + timestamp_str = f"{name_parts[-2]}_{name_parts[-1]}" + backup_time = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S").timestamp() + if backup_time > latest_timestamp: + latest_timestamp = backup_time + latest_backup_path = backup_path + else: + logger.warning(f"Could not parse timestamp from backup filename: {backup_path.name}") + except (ValueError, IndexError) as e: + logger.warning(f"Error parsing timestamp from {backup_path.name}: {e}") + + if latest_backup_path: + logger.info(f"Latest timestamped backup found: {latest_backup_path}") + return latest_backup_path + + # If no timestamped backup found, check for simple .bak file + simple_backup_path = backup_dir / f"{original_file_path.name}.bak" + # Correction: Simple backup might be in the *same* directory, not backup_dir + simple_backup_path_alt = original_file_path.with_suffix(f"{file_suffix}.bak") + + if simple_backup_path_alt.is_file(): + logger.info(f"Found simple backup file: {simple_backup_path_alt}") + return simple_backup_path_alt + elif simple_backup_path.is_file(): # Check in backup dir as fallback + logger.info(f"Found simple backup file in backup dir: {simple_backup_path}") + return simple_backup_path + + logger.warning(f"No suitable backup found for {original_file_path} in {backup_dir} or adjacent.") + return None + + @staticmethod + def set_permissions(path: Path, permissions: int = 0o755, recursive: bool = True) -> bool: + """Set file or directory permissions (non-sudo).""" + try: + if not path.exists(): + logger.error(f"Cannot set permissions: Path does not exist - {path}") + return False + + if recursive and path.is_dir(): + for root, dirs, files in os.walk(path): + try: + os.chmod(root, 0o755) # Dirs typically 755 + except Exception as dir_e: + logger.warning(f"Failed to chmod dir {root}: {dir_e}") + for file in files: + try: + os.chmod(os.path.join(root, file), 0o644) # Files typically 644 + except Exception as file_e: + logger.warning(f"Failed to chmod file {os.path.join(root, file)}: {file_e}") + elif path.is_file(): + os.chmod(path, 0o644 if permissions == 0o755 else permissions) # Default file perms 644 + elif path.is_dir(): + os.chmod(path, permissions) # Set specific perm for top-level dir if not recursive + logger.debug(f"Set permissions for {path} (recursive={recursive})") + return True + except Exception as e: + logger.error(f"Failed to set permissions for {path}: {e}") + return False + + @staticmethod + def get_permissions(path: Path) -> Optional[int]: + """Get file or directory permissions (last 3 octal digits).""" + try: + return os.stat(path).st_mode & 0o777 + except Exception as e: + logger.error(f"Failed to get permissions for {path}: {e}") + return None + + @staticmethod + def is_sd_card(path: Path) -> bool: + """Check if a path likely resides on an SD card based on common mount points.""" + try: + # Get the absolute path to resolve symlinks etc. + abs_path_str = str(path.resolve()) + + # Common SD card mount patterns/devices on Linux/Steam Deck + sd_patterns = [ + "/run/media/mmcblk", + "/media/mmcblk", + "/dev/mmcblk" + ] + + # Check if path starts with known mount points + for pattern in sd_patterns: + if abs_path_str.startswith(pattern): + logger.debug(f"Path {path} matches SD card pattern: {pattern}") + return True + + # Less reliable: Check mount point info (can be slow/complex) + # try: + # # ... (logic using /proc/mounts or df command) ... + # except Exception as mount_e: + # logger.warning(f"Could not reliably check mount point for {path}: {mount_e}") + + logger.debug(f"Path {path} does not appear to be on a standard SD card mount.") + return False + + except Exception as e: + logger.error(f"Error checking if path is on SD card: {e}") + return False # Default to False on error + + @staticmethod + def get_directory_size(path: Path) -> Optional[int]: + """Get the total size of a directory in bytes.""" + try: + total_size = 0 + for entry in os.scandir(path): + if entry.is_dir(follow_symlinks=False): + total_size += FileSystemHandler.get_directory_size(Path(entry.path)) or 0 + elif entry.is_file(follow_symlinks=False): + total_size += entry.stat().st_size + return total_size + except Exception as e: + logger.error(f"Failed to get directory size for {path}: {e}") + return None + + @staticmethod + def cleanup_directory(path: Path, age_days: int) -> bool: + """Delete files in a directory older than age_days.""" + try: + if not path.is_dir(): + logger.error(f"Cleanup failed: Not a directory - {path}") + return False + + current_time = time.time() + age_seconds = age_days * 86400 + deleted_count = 0 + + for item in path.iterdir(): + if item.is_file(): + try: + file_age = current_time - item.stat().st_mtime + if file_age > age_seconds: + item.unlink() + logger.debug(f"Deleted old file: {item}") + deleted_count += 1 + except Exception as item_e: + logger.warning(f"Could not process/delete file {item}: {item_e}") + + logger.info(f"Cleanup complete for {path}. Deleted {deleted_count} files older than {age_days} days.") + return True + except Exception as e: + logger.error(f"Failed to clean up directory {path}: {e}") + return False + + @staticmethod + def move_directory(source: Path, destination: Path) -> bool: + """Move a directory and its contents.""" + try: + if not source.is_dir(): + logger.error(f"Move failed: Source is not a directory - {source}") + return False + + FileSystemHandler.ensure_directory(destination.parent) + + shutil.move(str(source), str(destination)) # shutil.move needs strings + logger.info(f"Moved directory {source} to {destination}") + return True + except Exception as e: + logger.error(f"Failed to move directory {source} to {destination}: {e}") + return False + + @staticmethod + def copy_directory(source: Path, destination: Path, dirs_exist_ok=True) -> bool: + """Copy a directory and its contents.""" + try: + if not source.is_dir(): + logger.error(f"Copy failed: Source is not a directory - {source}") + return False + + # shutil.copytree needs destination to NOT exist unless dirs_exist_ok=True (Py 3.8+) + # Ensure parent exists + FileSystemHandler.ensure_directory(destination.parent) + + shutil.copytree(source, destination, dirs_exist_ok=dirs_exist_ok) + logger.info(f"Copied directory {source} to {destination}") + return True + except Exception as e: + logger.error(f"Failed to copy directory {source} to {destination}: {e}") + return False + + @staticmethod + def list_directory(path: Path, pattern: Optional[str] = None) -> List[Path]: + """List contents of a directory, optionally filtering by pattern.""" + try: + if not path.is_dir(): + logger.error(f"Cannot list: Not a directory - {path}") + return [] + + if pattern: + return list(path.glob(pattern)) + else: + return list(path.iterdir()) + except Exception as e: + logger.error(f"Failed to list directory {path}: {e}") + return [] + + @staticmethod + def backup_modorganizer(modlist_ini: Path) -> bool: + """Backs up ModOrganizer.ini and adds a backupPath entry.""" + logger.info(f"Backing up {modlist_ini}...") + backup_path = FileSystemHandler.backup_file(modlist_ini) + if not backup_path: + return False + + try: + # Add backupPath entry (read, find gamePath, duplicate/rename, write) + content = modlist_ini.read_text().splitlines() + new_content = [] + gamepath_line = None + backupath_exists = False + + for line in content: + new_content.append(line) + if line.strip().startswith("gamePath="): + gamepath_line = line + if line.strip().startswith("backupPath="): + backupath_exists = True + + if gamepath_line and not backupath_exists: + backupath_line = gamepath_line.replace("gamePath=", "backupPath=", 1) + # Find the index of gamepath_line to insert backupath after it + try: + gamepath_index = new_content.index(gamepath_line) + new_content.insert(gamepath_index + 1, backupath_line) + logger.debug("Added backupPath entry to ModOrganizer.ini") + except ValueError: + logger.warning("Could not find gamePath line index to insert backupPath.") + new_content.append(backupath_line) # Append at end as fallback + + modlist_ini.write_text("\n".join(new_content) + "\n") + elif backupath_exists: + logger.debug("backupPath already exists in ModOrganizer.ini") + else: + logger.warning("gamePath not found, cannot add backupPath entry.") + + return True + except Exception as e: + logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}") + return False # Backup succeeded, but adding entry failed + + @staticmethod + def blank_downloads_dir(modlist_ini: Path) -> bool: + """Blanks the download_directory line in ModOrganizer.ini.""" + logger.info(f"Blanking download_directory in {modlist_ini}...") + try: + content = modlist_ini.read_text().splitlines() + new_content = [] + found = False + for line in content: + if line.strip().startswith("download_directory="): + new_content.append("download_directory=") + found = True + else: + new_content.append(line) + + if found: + modlist_ini.write_text("\n".join(new_content) + "\n") + logger.debug("download_directory line blanked.") + else: + logger.warning("download_directory line not found.") + # Consider if we should add it blank? + + return True + except Exception as e: + logger.error(f"Failed to blank download_directory in {modlist_ini}: {e}") + return False + + @staticmethod + def copy_file(src: Path, dst: Path, overwrite: bool = False) -> bool: + """Copy a single file.""" + try: + if not src.is_file(): + logger.error(f"Copy failed: Source is not a file - {src}") + return False + if dst.exists() and not overwrite: + logger.warning(f"Copy skipped: Destination exists and overwrite=False - {dst}") + return False # Or True, depending on desired behavior for skip + + FileSystemHandler.ensure_directory(dst.parent) + shutil.copy2(src, dst) + logger.debug(f"Copied file {src} to {dst}") + return True + except Exception as e: + logger.error(f"Failed to copy file {src} to {dst}: {e}") + return False + + @staticmethod + def move_file(src: Path, dst: Path, overwrite: bool = False) -> bool: + """Move a single file.""" + try: + if not src.is_file(): + logger.error(f"Move failed: Source is not a file - {src}") + return False + if dst.exists() and not overwrite: + logger.warning(f"Move skipped: Destination exists and overwrite=False - {dst}") + return False + + FileSystemHandler.ensure_directory(dst.parent) + shutil.move(str(src), str(dst)) # shutil.move needs strings + # Create backup with timestamp + timestamp = os.path.getmtime(modlist_ini) + backup_path = modlist_ini.with_suffix(f'.{timestamp:.0f}.bak') + + # Copy file to backup + shutil.copy2(modlist_ini, backup_path) + + # Copy game path to backup path + with open(modlist_ini, 'r') as f: + lines = f.readlines() + + game_path_line = None + for line in lines: + if line.startswith('gamePath'): + game_path_line = line + break + + if game_path_line: + # Create backup path entry + backup_path_line = game_path_line.replace('gamePath', 'backupPath') + + # Append to file if not already present + with open(modlist_ini, 'a') as f: + f.write(backup_path_line) + + self.logger.debug(f"Backed up ModOrganizer.ini and created backupPath entry") + return True + else: + self.logger.error("No gamePath found in ModOrganizer.ini") + return False + + except Exception as e: + self.logger.error(f"Error backing up ModOrganizer.ini: {e}") + return False + + def blank_downloads_dir(self, modlist_ini: Path) -> bool: + """ + Blank or reset the MO2 Downloads Directory + Returns True on success, False on failure + """ + try: + self.logger.info("Editing download_directory...") + + # Read the file + with open(modlist_ini, 'r') as f: + content = f.read() + + # Replace the download_directory line + modified_content = re.sub(r'download_directory[^\n]*', 'download_directory =', content) + + # Write back to the file + with open(modlist_ini, 'w') as f: + f.write(modified_content) + + self.logger.debug("Download directory cleared successfully") + return True + + except Exception as e: + self.logger.error(f"Error blanking downloads directory: {e}") + return False + + def copy_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool: + """ + Copy a file from source to destination. + + Args: + src: Source file path + dst: Destination file path + overwrite: Whether to overwrite existing file + + Returns: + bool: True if file was copied successfully, False otherwise + """ + try: + if not overwrite and os.path.exists(dst): + self.logger.info(f"Destination file already exists: {dst}") + return False + + shutil.copy2(src, dst) + return True + except Exception as e: + self.logger.error(f"Error copying file: {e}") + return False + + def move_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool: + """ + Move a file from source to destination. + + Args: + src: Source file path + dst: Destination file path + overwrite: Whether to overwrite existing file + + Returns: + bool: True if file was moved successfully, False otherwise + """ + try: + if not overwrite and os.path.exists(dst): + self.logger.info(f"Destination file already exists: {dst}") + return False + + shutil.move(src, dst) + return True + except Exception as e: + self.logger.error(f"Error moving file: {e}") + return False + + def delete_file(self, path: Path) -> bool: + """ + Delete a file. + + Args: + path: Path to the file to delete + + Returns: + bool: True if file was deleted successfully, False otherwise + """ + try: + if os.path.exists(path): + os.remove(path) + return True + return False + except Exception as e: + self.logger.error(f"Error deleting file: {e}") + return False + + def delete_directory(self, path: Path, recursive: bool = True) -> bool: + """ + Delete a directory. + + Args: + path: Path to the directory to delete + recursive: Whether to delete directory recursively + + Returns: + bool: True if directory was deleted successfully, False otherwise + """ + try: + if os.path.exists(path): + if recursive: + shutil.rmtree(path) + else: + os.rmdir(path) + return True + return False + except Exception as e: + self.logger.error(f"Error deleting directory: {e}") + return False + + def create_required_dirs(self, game_name: str, appid: str) -> bool: + """ + Create required directories for a game modlist + + Args: + game_name: Name of the game (e.g., skyrimse, fallout4) + appid: Steam AppID of the modlist + + Returns: + bool: True if directories were created successfully, False otherwise + """ + try: + # Define base paths + home_dir = os.path.expanduser("~") + game_dirs = { + # Common directories needed across all games + "common": [ + os.path.join(home_dir, ".local", "share", "Steam", "steamapps", "compatdata", appid, "pfx"), + os.path.join(home_dir, ".steam", "steam", "steamapps", "compatdata", appid, "pfx") + ], + # Game-specific directories + "skyrimse": [ + os.path.join(home_dir, "Documents", "My Games", "Skyrim Special Edition"), + ], + "fallout4": [ + os.path.join(home_dir, "Documents", "My Games", "Fallout4"), + ], + "falloutnv": [ + os.path.join(home_dir, "Documents", "My Games", "FalloutNV"), + ], + "oblivion": [ + os.path.join(home_dir, "Documents", "My Games", "Oblivion"), + ] + } + + # Create common directories + for dir_path in game_dirs["common"]: + if dir_path and os.path.exists(os.path.dirname(dir_path)): + os.makedirs(dir_path, exist_ok=True) + self.logger.debug(f"Created directory: {dir_path}") + + # Create game-specific directories + if game_name in game_dirs: + for dir_path in game_dirs[game_name]: + os.makedirs(dir_path, exist_ok=True) + self.logger.debug(f"Created game-specific directory: {dir_path}") + + return True + except Exception as e: + self.logger.error(f"Error creating required directories: {e}") + return False + + @staticmethod + def all_owned_by_user(path: Path) -> bool: + """ + Returns True if all files and directories under 'path' are owned by the current user. + """ + uid = os.getuid() + gid = os.getgid() + for root, dirs, files in os.walk(path): + for name in dirs + files: + full_path = os.path.join(root, name) + try: + stat = os.stat(full_path) + if stat.st_uid != uid or stat.st_gid != gid: + return False + except Exception: + return False + return True + + @staticmethod + def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool: + """Change ownership and permissions using sudo (robust, with timeout and re-prompt).""" + if not path.exists(): + logger.error(f"Path does not exist: {path}") + return False + # Check if all files/dirs are already owned by the user + if FileSystemHandler.all_owned_by_user(path): + logger.info(f"All files in {path} are already owned by the current user. Skipping sudo chown/chmod.") + return True + try: + user_name = pwd.getpwuid(os.geteuid()).pw_name + group_name = grp.getgrgid(os.geteuid()).gr_name + except KeyError: + logger.error("Could not determine current user or group name.") + return False + + log_msg = f"Applying ownership/permissions for {path} (user: {user_name}, group: {group_name}) via sudo." + logger.info(log_msg) + if status_callback: + status_callback(f"Setting ownership/permissions for {os.path.basename(str(path))}...") + else: + print(f'\n{COLOR_PROMPT}Adjusting permissions for {path} (may require sudo password)...{COLOR_RESET}') + + def run_sudo_with_retries(cmd, desc, max_retries=3, timeout=300): + for attempt in range(max_retries): + try: + logger.info(f"Running sudo command (attempt {attempt+1}/{max_retries}): {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout) + if result.returncode == 0: + return True + else: + logger.error(f"sudo {desc} failed. Error: {result.stderr.strip()}") + print(f"Error: Failed to {desc}. Check logs.") + return False + except subprocess.TimeoutExpired: + logger.error(f"sudo {desc} timed out (attempt {attempt+1}/{max_retries}).") + print(f"\nSudo prompt timed out after {timeout} seconds. Please try again.") + # Flush input if possible, then retry + print(f"Failed to {desc} after {max_retries} attempts. Aborting.") + return False + + # Run chown with retries + chown_command = ['sudo', 'chown', '-R', f'{user_name}:{group_name}', str(path)] + if not run_sudo_with_retries(chown_command, "change ownership"): + return False + print() + # Run chmod with retries + chmod_command = ['sudo', 'chmod', '-R', '755', str(path)] + if not run_sudo_with_retries(chmod_command, "set permissions"): + return False + print() + logger.info("Permissions set successfully.") + return True + + def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool: + """Downloads a file from a URL to a destination path.""" + self.logger.info(f"Downloading {url} to {destination_path}...") + + if not overwrite and destination_path.exists(): + self.logger.info(f"File already exists, skipping download: {destination_path}") + # Only print if not quiet + if not quiet: + print(f"File {destination_path.name} already exists, skipping download.") + return True # Consider existing file as success + + try: + # Ensure destination directory exists + destination_path.parent.mkdir(parents=True, exist_ok=True) + + # Perform the download with streaming + with requests.get(url, stream=True, timeout=300, verify=True) as r: + r.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) + with open(destination_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + + self.logger.info("Download complete.") + # Only print if not quiet + if not quiet: + print("Download complete.") + return True + + except requests.exceptions.RequestException as e: + self.logger.error(f"Download failed: {e}") + print(f"Error: Download failed for {url}. Check network connection and URL.") + # Clean up potentially incomplete file + if destination_path.exists(): + try: destination_path.unlink() + except OSError: pass + return False + except Exception as e: + self.logger.error(f"Error during download or file writing: {e}", exc_info=True) + print("Error: An unexpected error occurred during download.") + # Clean up potentially incomplete file + if destination_path.exists(): + try: destination_path.unlink() + except OSError: pass + return False + + @staticmethod + def find_steam_library() -> Optional[Path]: + """ + Find the Steam library containing game installations, prioritizing vdf. + + Returns: + Optional[Path]: Path object to the Steam library's steamapps/common dir, or None if not found + """ + logger.info("Detecting Steam library location...") + + # Try finding libraryfolders.vdf in common Steam paths + possible_vdf_paths = [ + Path.home() / ".steam/steam/config/libraryfolders.vdf", + Path.home() / ".local/share/Steam/config/libraryfolders.vdf", + Path.home() / ".steam/root/config/libraryfolders.vdf" + ] + + libraryfolders_vdf_path: Optional[Path] = None + for path_obj in possible_vdf_paths: + # Explicitly ensure path_obj is Path before checking is_file + current_path = Path(path_obj) + if current_path.is_file(): + libraryfolders_vdf_path = current_path # Assign the confirmed Path object + logger.debug(f"Found libraryfolders.vdf at: {libraryfolders_vdf_path}") + break + + # Check AFTER loop - libraryfolders_vdf_path is now definitely Path or None + if not libraryfolders_vdf_path: + logger.warning("libraryfolders.vdf not found...") + # Proceed to default check below if vdf not found + else: + # Parse the VDF file to extract library paths + try: + # Try importing vdf here if not done globally + with open(libraryfolders_vdf_path, 'r') as f: + data = vdf.load(f) + + # Look for library folders (indices are strings '0', '1', etc.) + libraries = data.get('libraryfolders', {}) + + for key in libraries: + if isinstance(libraries[key], dict) and 'path' in libraries[key]: + lib_path_str = libraries[key]['path'] + if lib_path_str: + # Check if this library path is valid + potential_lib_path = Path(lib_path_str) / "steamapps/common" + if potential_lib_path.is_dir(): + logger.info(f"Using Steam library path from vdf: {potential_lib_path}") + return potential_lib_path # Return first valid Path object found + + logger.warning("No valid library paths found within libraryfolders.vdf.") + # Proceed to default check below if vdf parsing fails to find a valid path + + except ImportError: + logger.error("Python 'vdf' library not found. Cannot parse libraryfolders.vdf.") + # Proceed to default check below + except Exception as e: + logger.error(f"Error parsing libraryfolders.vdf: {e}") + # Proceed to default check below + + # Fallback: Check default location if VDF parsing didn't yield a result + default_path = Path.home() / ".steam/steam/steamapps/common" + if default_path.is_dir(): + logger.warning(f"Using default Steam library path: {default_path}") + return default_path + + logger.error("No valid Steam library found via vdf or at default location.") + return None + + @staticmethod + def find_compat_data(appid: str) -> Optional[Path]: + """Find the compatdata directory for a given AppID.""" + if not appid or not appid.isdigit(): + logger.error(f"Invalid AppID provided for compatdata search: {appid}") + return None + + logger.debug(f"Searching for compatdata directory for AppID: {appid}") + + # Standard Steam locations + possible_bases = [ + Path.home() / ".steam/steam/steamapps/compatdata", + Path.home() / ".local/share/Steam/steamapps/compatdata", + ] + + # Try to get library path from vdf to check there too + # Use type hint for clarity + steam_lib_common_path: Optional[Path] = FileSystemHandler.find_steam_library() + if steam_lib_common_path: + # find_steam_library returns steamapps/common, go up two levels for library root + library_root = steam_lib_common_path.parent.parent + vdf_compat_path = library_root / "steamapps/compatdata" + if vdf_compat_path.is_dir() and vdf_compat_path not in possible_bases: + possible_bases.insert(0, vdf_compat_path) # Prioritize library path from vdf + + for base_path in possible_bases: + if not base_path.is_dir(): + logger.debug(f"Compatdata base path does not exist or is not a directory: {base_path}") + continue + + potential_path = base_path / appid + if potential_path.is_dir(): + logger.info(f"Found compatdata directory: {potential_path}") + return potential_path # Return Path object + else: + logger.debug(f"Compatdata for {appid} not found in {base_path}") + + logger.warning(f"Compatdata directory for AppID {appid} not found in standard or detected library locations.") + return None + + @staticmethod + def find_steam_config_vdf() -> Optional[Path]: + """Finds the active Steam config.vdf file.""" + logger.debug("Searching for Steam config.vdf...") + possible_steam_paths = [ + Path.home() / ".steam/steam", + Path.home() / ".local/share/Steam", + Path.home() / ".steam/root" + ] + for steam_path in possible_steam_paths: + potential_path = steam_path / "config/config.vdf" + if potential_path.is_file(): + logger.info(f"Found config.vdf at: {potential_path}") + return potential_path # Return Path object + + logger.warning("Could not locate Steam's config.vdf file in standard locations.") + return None + + # ... (rest of the class) ... \ No newline at end of file diff --git a/jackify/backend/handlers/game_detector.py b/jackify/backend/handlers/game_detector.py new file mode 100644 index 0000000..9c4f234 --- /dev/null +++ b/jackify/backend/handlers/game_detector.py @@ -0,0 +1,260 @@ +""" +GameDetector module for detecting and managing game-related information. +This module handles game type detection, version detection, and game-specific requirements. +""" + +import os +import logging +from pathlib import Path +from typing import Optional, Dict, List, Tuple + +class GameDetector: + def __init__(self): + self.logger = logging.getLogger(__name__) + self.supported_games = { + 'skyrim': ['Skyrim Special Edition', 'Skyrim'], + 'fallout4': ['Fallout 4'], + 'falloutnv': ['Fallout New Vegas'], + 'oblivion': ['Oblivion'], + 'starfield': ['Starfield'], + 'oblivion_remastered': ['Oblivion Remastered'] + } + + def detect_game_type(self, modlist_name: str) -> Optional[str]: + """Detect the game type from a modlist name.""" + modlist_lower = modlist_name.lower() + + # Check for game-specific keywords in modlist name + # Check for Oblivion Remastered first since "oblivion" is a substring + if any(keyword in modlist_lower for keyword in ['oblivion remastered', 'oblivionremastered', 'oblivion_remastered']): + return 'oblivion_remastered' + elif any(keyword in modlist_lower for keyword in ['skyrim', 'sse', 'skse', 'dragonborn', 'dawnguard']): + return 'skyrim' + elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']): + return 'fallout4' + elif any(keyword in modlist_lower for keyword in ['fallout new vegas', 'fonv', 'fnv', 'new vegas', 'nvse']): + return 'falloutnv' + elif any(keyword in modlist_lower for keyword in ['oblivion', 'obse', 'shivering isles']): + return 'oblivion' + elif any(keyword in modlist_lower for keyword in ['starfield', 'sf', 'starfieldse']): + return 'starfield' + + self.logger.debug(f"Could not detect game type from modlist name: {modlist_name}") + return None + + def detect_game_version(self, game_type: str, modlist_path: Path) -> Optional[str]: + """Detect the game version from the modlist path.""" + try: + # Look for ModOrganizer.ini to get game info + mo_ini = modlist_path / "ModOrganizer.ini" + if mo_ini.exists(): + with open(mo_ini, 'r', encoding='utf-8') as f: + content = f.read() + + # Extract game version info from MO2 config + if 'gameName=' in content: + for line in content.splitlines(): + if line.startswith('gameName='): + game_name = line.split('=', 1)[1].strip() + return game_name + + self.logger.debug(f"Could not detect game version for {game_type} at {modlist_path}") + return None + + except Exception as e: + self.logger.error(f"Error detecting game version: {e}") + return None + + def detect_game_path(self, game_type: str, modlist_path: Path) -> Optional[Path]: + """Detect the game installation path.""" + try: + # Look for ModOrganizer.ini to get game path + mo_ini = modlist_path / "ModOrganizer.ini" + if mo_ini.exists(): + with open(mo_ini, 'r', encoding='utf-8') as f: + content = f.read() + + # Extract game path from MO2 config + for line in content.splitlines(): + if line.startswith('gamePath='): + game_path = line.split('=', 1)[1].strip() + return Path(game_path) if game_path else None + + self.logger.debug(f"Could not detect game path for {game_type} at {modlist_path}") + return None + + except Exception as e: + self.logger.error(f"Error detecting game path: {e}") + return None + + def get_game_requirements(self, game_type: str) -> Dict: + """Get the requirements for a specific game type.""" + requirements = { + 'skyrim': { + 'launcher': 'SKSE', + 'min_proton_version': '6.0', + 'required_dlc': ['Dawnguard', 'Hearthfire', 'Dragonborn'], + 'compatibility_tools': ['protontricks', 'winetricks'] + }, + 'fallout4': { + 'launcher': 'F4SE', + 'min_proton_version': '6.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'] + }, + 'falloutnv': { + 'launcher': 'NVSE', + 'min_proton_version': '5.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'] + }, + 'oblivion': { + 'launcher': 'OBSE', + 'min_proton_version': '5.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'] + }, + 'starfield': { + 'launcher': 'SFSE', + 'min_proton_version': '8.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'] + }, + 'oblivion_remastered': { + 'launcher': 'OBSE', + 'min_proton_version': '8.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'] + } + } + + return requirements.get(game_type, {}) + + def detect_mods(self, modlist_path: Path) -> List[Dict]: + """Detect installed mods in a modlist.""" + mods = [] + try: + # Look for mods directory in MO2 structure + mods_dir = modlist_path / "mods" + if mods_dir.exists() and mods_dir.is_dir(): + for mod_dir in mods_dir.iterdir(): + if mod_dir.is_dir(): + mod_info = { + 'name': mod_dir.name, + 'path': str(mod_dir), + 'enabled': True # Assume enabled by default + } + + # Check for meta.ini for more details + meta_ini = mod_dir / "meta.ini" + if meta_ini.exists(): + try: + with open(meta_ini, 'r', encoding='utf-8') as f: + meta_content = f.read() + # Parse basic mod info from meta.ini + for line in meta_content.splitlines(): + if line.startswith('modid='): + mod_info['nexus_id'] = line.split('=', 1)[1].strip() + elif line.startswith('version='): + mod_info['version'] = line.split('=', 1)[1].strip() + except Exception: + pass # Continue without meta info + + mods.append(mod_info) + + except Exception as e: + self.logger.error(f"Error detecting mods: {e}") + + return mods + + def detect_launcher(self, game_type: str, modlist_path: Path) -> Optional[str]: + """Detect the game launcher type (SKSE, F4SE, etc).""" + launcher_map = { + 'skyrim': 'SKSE', + 'fallout4': 'F4SE', + 'falloutnv': 'NVSE', + 'oblivion': 'OBSE', + 'starfield': 'SFSE', + 'oblivion_remastered': 'OBSE' + } + + expected_launcher = launcher_map.get(game_type) + if not expected_launcher: + return None + + # Check if launcher executable exists + launcher_exe = f"{expected_launcher.lower()}_loader.exe" + if (modlist_path / launcher_exe).exists(): + return expected_launcher + + return expected_launcher # Return expected even if not found + + def get_launcher_path(self, launcher_type: str, modlist_path: Path) -> Optional[Path]: + """Get the path to the game launcher.""" + launcher_exe = f"{launcher_type.lower()}_loader.exe" + launcher_path = modlist_path / launcher_exe + + if launcher_path.exists(): + return launcher_path + + return None + + def detect_compatibility_requirements(self, game_type: str) -> List[str]: + """Detect compatibility requirements for a game type.""" + requirements = { + 'skyrim': ['vcrun2019', 'dotnet48', 'dxvk'], + 'fallout4': ['vcrun2019', 'dotnet48', 'dxvk'], + 'falloutnv': ['vcrun2019', 'dotnet48'], + 'oblivion': ['vcrun2019', 'dotnet48'], + 'starfield': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'], + 'oblivion_remastered': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'] + } + + return requirements.get(game_type, []) + + def validate_game_installation(self, game_type: str, game_path: Path) -> bool: + """Validate a game installation.""" + if not game_path or not game_path.exists(): + return False + + # Check for game-specific executables + game_executables = { + 'skyrim': ['SkyrimSE.exe', 'Skyrim.exe'], + 'fallout4': ['Fallout4.exe'], + 'falloutnv': ['FalloutNV.exe'], + 'oblivion': ['Oblivion.exe'] + } + + executables = game_executables.get(game_type, []) + for exe in executables: + if (game_path / exe).exists(): + return True + + return False + + def get_game_specific_config(self, game_type: str) -> Dict: + """Get game-specific configuration requirements.""" + configs = { + 'skyrim': { + 'ini_files': ['Skyrim.ini', 'SkyrimPrefs.ini', 'SkyrimCustom.ini'], + 'config_dirs': ['Data', 'Saves'], + 'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\Skyrim Special Edition'] + }, + 'fallout4': { + 'ini_files': ['Fallout4.ini', 'Fallout4Prefs.ini', 'Fallout4Custom.ini'], + 'config_dirs': ['Data', 'Saves'], + 'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\Fallout 4'] + }, + 'falloutnv': { + 'ini_files': ['Fallout.ini', 'FalloutPrefs.ini'], + 'config_dirs': ['Data', 'Saves'], + 'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\FalloutNV'] + }, + 'oblivion': { + 'ini_files': ['Oblivion.ini'], + 'config_dirs': ['Data', 'Saves'], + 'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\Oblivion'] + } + } + + return configs.get(game_type, {}) \ No newline at end of file diff --git a/jackify/backend/handlers/hoolamike_handler.py b/jackify/backend/handlers/hoolamike_handler.py new file mode 100644 index 0000000..342077b --- /dev/null +++ b/jackify/backend/handlers/hoolamike_handler.py @@ -0,0 +1,994 @@ +import logging +import os +import subprocess +import zipfile +import tarfile +from pathlib import Path +import yaml # Assuming PyYAML is installed +from typing import Dict, Optional, List +import requests + +# Import necessary handlers from the current Jackify structure +from .path_handler import PathHandler +from .vdf_handler import VDFHandler # Keeping just in case +from .filesystem_handler import FileSystemHandler +from .config_handler import ConfigHandler +# Import color constants needed for print statements in this module +from .ui_colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_SELECTION +# Standard logging (no file handler) - LoggingHandler import removed +from .status_utils import show_status, clear_status +from .subprocess_utils import get_clean_subprocess_env + +logger = logging.getLogger(__name__) + +# Define default Hoolamike AppIDs for relevant games +TARGET_GAME_APPIDS = { + 'Fallout 3': '22370', # GOTY Edition + 'Fallout New Vegas': '22380', # Base game + 'Skyrim Special Edition': '489830', + 'Oblivion': '22330', # GOTY Edition + 'Fallout 4': '377160' +} + +# Define the expected name of the native Hoolamike executable +HOOLAMIKE_EXECUTABLE_NAME = "hoolamike" # Assuming this is the binary name +# Keep consistent with logs directory - use ~/Jackify/ for user-visible managed components +JACKIFY_BASE_DIR = Path.home() / "Jackify" +# Use Jackify base directory for ALL Hoolamike-related files to centralize management +DEFAULT_HOOLAMIKE_APP_INSTALL_DIR = JACKIFY_BASE_DIR / "Hoolamike" +HOOLAMIKE_CONFIG_DIR = DEFAULT_HOOLAMIKE_APP_INSTALL_DIR +HOOLAMIKE_CONFIG_FILENAME = "hoolamike.yaml" +# Default dirs for other components +DEFAULT_HOOLAMIKE_DOWNLOADS_DIR = JACKIFY_BASE_DIR / "Mod_Downloads" +DEFAULT_MODLIST_INSTALL_BASE_DIR = Path.home() / "ModdedGames" + +class HoolamikeHandler: + """Handles discovery, configuration, and execution of Hoolamike tasks. + Assumes Hoolamike is a native Linux CLI application. + """ + + def __init__(self, steamdeck: bool, verbose: bool, filesystem_handler: FileSystemHandler, config_handler: ConfigHandler, menu_handler=None): + """Initialize the handler and perform initial discovery.""" + self.steamdeck = steamdeck + self.verbose = verbose + self.path_handler = PathHandler() + self.filesystem_handler = filesystem_handler + self.config_handler = config_handler + self.menu_handler = menu_handler + # Use standard logging (no file handler) + self.logger = logging.getLogger(__name__) + + # --- Discovered/Managed State --- + self.game_install_paths: Dict[str, Path] = {} + # Allow user override for Hoolamike app install path later + self.hoolamike_app_install_path: Path = DEFAULT_HOOLAMIKE_APP_INSTALL_DIR + self.hoolamike_executable_path: Optional[Path] = None # Path to the binary + self.hoolamike_installed: bool = False + self.hoolamike_config_path: Path = HOOLAMIKE_CONFIG_DIR / HOOLAMIKE_CONFIG_FILENAME + self.hoolamike_config: Optional[Dict] = None + + # Load Hoolamike install path from Jackify config if it exists + saved_path_str = self.config_handler.get('hoolamike_install_path') + if saved_path_str and Path(saved_path_str).is_dir(): # Basic check if path exists + self.hoolamike_app_install_path = Path(saved_path_str) + self.logger.info(f"Loaded Hoolamike install path from Jackify config: {self.hoolamike_app_install_path}") + + self._load_hoolamike_config() + self._run_discovery() + + def _ensure_hoolamike_dirs_exist(self): + """Ensure base directories for Hoolamike exist.""" + try: + HOOLAMIKE_CONFIG_DIR.mkdir(parents=True, exist_ok=True) # Separate Hoolamike config + self.hoolamike_app_install_path.mkdir(parents=True, exist_ok=True) # Install dir (~/Jackify/Hoolamike) + # Default downloads dir also needs to exist if we reference it + DEFAULT_HOOLAMIKE_DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) + except OSError as e: + self.logger.error(f"Error creating Hoolamike directories: {e}", exc_info=True) + # Decide how to handle this - maybe raise an exception? + + def _check_hoolamike_installation(self): + """Check if Hoolamike executable exists at the expected location. + Prioritizes path stored in config if available. + """ + potential_exe_path = self.hoolamike_app_install_path / HOOLAMIKE_EXECUTABLE_NAME + check_path = None + if potential_exe_path.is_file() and os.access(potential_exe_path, os.X_OK): + check_path = potential_exe_path + self.logger.info(f"Found Hoolamike at current path: {check_path}") + else: + self.logger.info(f"Hoolamike executable ({HOOLAMIKE_EXECUTABLE_NAME}) not found or not executable at current path {self.hoolamike_app_install_path}.") + + # Update state based on whether we found a valid path + if check_path: + self.hoolamike_installed = True + self.hoolamike_executable_path = check_path + else: + self.hoolamike_installed = False + self.hoolamike_executable_path = None + + def _generate_default_config(self) -> Dict: + """Generates the default configuration dictionary.""" + self.logger.info("Generating default Hoolamike config structure.") + # Detection is now handled separately after loading config + detected_paths = self.path_handler.find_game_install_paths(TARGET_GAME_APPIDS) + + config = { + "downloaders": { + "downloads_directory": str(DEFAULT_HOOLAMIKE_DOWNLOADS_DIR), + "nexus": {"api_key": "YOUR_API_KEY_HERE"} + }, + "installation": { + "wabbajack_file_path": "", # Placeholder, set per-run + "installation_path": "" # Placeholder, set per-run + }, + "games": { # Only include detected games with consistent formatting (no spaces) + self._format_game_name(game_name): {"root_directory": str(path)} + for game_name, path in detected_paths.items() + }, + "fixup": { + "game_resolution": "1920x1080" + }, + "extras": { + "tale_of_two_wastelands": { + "path_to_ttw_mpi_file": "", # Placeholder + "variables": { + "DESTINATION": "" # Placeholder + } + } + } + } + # Add comment if no games detected + if not detected_paths: + # This won't appear in YAML, logic adjusted below + pass + return config + + def _format_game_name(self, game_name: str) -> str: + """Formats game name for Hoolamike configuration (removes spaces). + + Hoolamike expects game names without spaces like: Fallout3, FalloutNewVegas, SkyrimSpecialEdition + """ + # Handle specific game name formats that Hoolamike expects + game_name_map = { + "Fallout 3": "Fallout3", + "Fallout New Vegas": "FalloutNewVegas", + "Skyrim Special Edition": "SkyrimSpecialEdition", + "Fallout 4": "Fallout4", + "Oblivion": "Oblivion" # No change needed + } + + # Use predefined mapping if available + if game_name in game_name_map: + return game_name_map[game_name] + + # Otherwise, just remove spaces as fallback + return game_name.replace(" ", "") + + def _load_hoolamike_config(self): + """Load hoolamike.yaml if it exists, or generate a default one.""" + self._ensure_hoolamike_dirs_exist() # Ensure parent dir exists + + if self.hoolamike_config_path.is_file(): + self.logger.info(f"Found existing hoolamike.yaml at {self.hoolamike_config_path}. Loading...") + try: + with open(self.hoolamike_config_path, 'r', encoding='utf-8') as f: + self.hoolamike_config = yaml.safe_load(f) + if not isinstance(self.hoolamike_config, dict): + self.logger.warning(f"Failed to parse hoolamike.yaml as a dictionary. Generating default.") + self.hoolamike_config = self._generate_default_config() + self.save_hoolamike_config() # Save the newly generated default + else: + self.logger.info("Successfully loaded hoolamike.yaml configuration.") + # Game path merging is handled in _run_discovery now + except yaml.YAMLError as e: + self.logger.error(f"Error parsing hoolamike.yaml: {e}. The file may be corrupted.") + # Don't automatically overwrite - let user decide + self.hoolamike_config = None + return False + except Exception as e: + self.logger.error(f"Error reading hoolamike.yaml: {e}.", exc_info=True) + # Don't automatically overwrite - let user decide + self.hoolamike_config = None + return False + else: + self.logger.info(f"hoolamike.yaml not found at {self.hoolamike_config_path}. Generating default configuration.") + self.hoolamike_config = self._generate_default_config() + self.save_hoolamike_config() + + return True + + def save_hoolamike_config(self): + """Saves the current configuration dictionary to hoolamike.yaml.""" + if self.hoolamike_config is None: + self.logger.error("Cannot save config, internal config dictionary is None.") + return False + + self._ensure_hoolamike_dirs_exist() # Ensure parent dir exists + self.logger.info(f"Saving configuration to {self.hoolamike_config_path}") + try: + with open(self.hoolamike_config_path, 'w', encoding='utf-8') as f: + # Add comments conditionally + f.write("# Configuration file created or updated by Jackify\n") + if not self.hoolamike_config.get("games"): + f.write("# No games were detected by Jackify. Add game paths manually if needed.\n") + # Dump the actual YAML + yaml.dump(self.hoolamike_config, f, default_flow_style=False, sort_keys=False) + self.logger.info("Configuration saved successfully.") + return True + except Exception as e: + self.logger.error(f"Error saving hoolamike.yaml: {e}", exc_info=True) + return False + + def _run_discovery(self): + """Execute all discovery steps.""" + self.logger.info("Starting Hoolamike feature discovery phase...") + + # Detect game paths and update internal state + config + self._detect_and_update_game_paths() + + self.logger.info("Hoolamike discovery phase complete.") + + def _detect_and_update_game_paths(self): + """Detect game install paths and update state and config.""" + self.logger.info("Detecting game install paths...") + # Always run detection + detected_paths = self.path_handler.find_game_install_paths(TARGET_GAME_APPIDS) + self.game_install_paths = detected_paths # Update internal state + self.logger.info(f"Detected game paths: {detected_paths}") + + # Update the loaded config if it exists + if self.hoolamike_config is not None: + self.logger.debug("Updating loaded hoolamike.yaml with detected game paths.") + if "games" not in self.hoolamike_config or not isinstance(self.hoolamike_config.get("games"), dict): + self.hoolamike_config["games"] = {} # Ensure games section exists + + # Define a unified format for game names in config - no spaces + # Clear existing entries first to avoid duplicates + self.hoolamike_config["games"] = {} + + # Add detected paths with proper formatting - no spaces + for game_name, detected_path in detected_paths.items(): + formatted_name = self._format_game_name(game_name) + self.hoolamike_config["games"][formatted_name] = {"root_directory": str(detected_path)} + + self.logger.info(f"Updated config with {len(detected_paths)} game paths using correct naming format (no spaces)") + else: + self.logger.warning("Cannot update game paths in config because config is not loaded.") + + # --- Methods for Hoolamike Tasks (To be implemented later) --- + # TODO: Update these methods to accept necessary parameters and update/save config + + def install_update_hoolamike(self, context=None) -> bool: + """Install or update Hoolamike application. + + Returns: + bool: True if installation/update was successful or process was properly cancelled, + False if a critical error occurred. + """ + self.logger.info("Starting Hoolamike Installation/Update...") + print("\nStarting Hoolamike Installation/Update...") + + # 1. Prompt user to install/reinstall/update + try: + # Check if Hoolamike is already installed at the expected path + self._check_hoolamike_installation() + if self.hoolamike_installed: + self.logger.info(f"Hoolamike appears to be installed at: {self.hoolamike_executable_path}") + print(f"{COLOR_INFO}Hoolamike is already installed at:{COLOR_RESET}") + print(f" {self.hoolamike_executable_path}") + # Use a menu-style prompt for reinstall/update + print(f"\n{COLOR_PROMPT}Choose an action for Hoolamike:{COLOR_RESET}") + print(f" 1. Reinstall/Update Hoolamike") + print(f" 2. Keep existing installation (return to menu)") + while True: + choice = input(f"Select an option [1-2]: ").strip() + if choice == '1': + self.logger.info("User chose to reinstall/update Hoolamike.") + break + elif choice == '2' or choice.lower() == 'q': + self.logger.info("User chose to keep existing Hoolamike installation.") + print("Skipping Hoolamike installation/update.") + return True + else: + print(f"{COLOR_WARNING}Invalid choice. Please enter 1 or 2.{COLOR_RESET}") + # 2. Get installation directory from user (allow override) + self.logger.info(f"Default install path: {self.hoolamike_app_install_path}") + print("\nHoolamike Installation Directory:") + print(f"Default: {self.hoolamike_app_install_path}") + install_dir = self.menu_handler.get_directory_path( + prompt_message=f"Specify where to install Hoolamike (or press Enter for default)", + default_path=self.hoolamike_app_install_path, + create_if_missing=True, + no_header=True + ) + if install_dir is None: + self.logger.warning("User cancelled Hoolamike installation path selection.") + print("Installation cancelled.") + return True + # Check if hoolamike already exists at this specific path + potential_existing_exe = install_dir / HOOLAMIKE_EXECUTABLE_NAME + if potential_existing_exe.is_file() and os.access(potential_existing_exe, os.X_OK): + self.logger.info(f"Hoolamike executable found at the chosen path: {potential_existing_exe}") + print(f"{COLOR_INFO}Hoolamike appears to already be installed at:{COLOR_RESET}") + print(f" {install_dir}") + # Use menu-style prompt for overwrite + print(f"{COLOR_PROMPT}Choose an action for the existing installation:{COLOR_RESET}") + print(f" 1. Download and overwrite (update)") + print(f" 2. Keep existing installation (return to menu)") + while True: + overwrite_choice = input(f"Select an option [1-2]: ").strip() + if overwrite_choice == '1': + self.logger.info("User chose to update (overwrite) existing Hoolamike installation.") + break + elif overwrite_choice == '2' or overwrite_choice.lower() == 'q': + self.logger.info("User chose to keep existing Hoolamike installation at chosen path.") + print("Update cancelled. Using existing installation for this session.") + self.hoolamike_app_install_path = install_dir + self.hoolamike_executable_path = potential_existing_exe + self.hoolamike_installed = True + return True + else: + print(f"{COLOR_WARNING}Invalid choice. Please enter 1 or 2.{COLOR_RESET}") + # Proceed with install/update + self.logger.info(f"Proceeding with installation to directory: {install_dir}") + self.hoolamike_app_install_path = install_dir + # Get latest release info from GitHub + release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest" + download_url = None + asset_name = None + try: + self.logger.info(f"Fetching latest release info from {release_url}") + show_status("Fetching latest Hoolamike release info...") + response = requests.get(release_url, timeout=15, verify=True) + response.raise_for_status() + release_data = response.json() + self.logger.debug(f"GitHub Release Data: {release_data}") + linux_tar_asset = None + linux_zip_asset = None + for asset in release_data.get('assets', []): + name = asset.get('name', '').lower() + self.logger.debug(f"Checking asset: {name}") + is_linux = 'linux' in name + is_x64 = 'x86_64' in name or 'amd64' in name + is_incompatible_arch = 'arm' in name or 'aarch64' in name or 'darwin' in name + if is_linux and is_x64 and not is_incompatible_arch: + if name.endswith(('.tar.gz', '.tgz')): + linux_tar_asset = asset + self.logger.debug(f"Found potential tar asset: {name}") + break + elif name.endswith('.zip') and not linux_tar_asset: + linux_zip_asset = asset + self.logger.debug(f"Found potential zip asset: {name}") + chosen_asset = linux_tar_asset or linux_zip_asset + if not chosen_asset: + clear_status() + self.logger.error("Could not find a suitable Linux x86_64 download asset (tar.gz/zip) in the latest release.") + print(f"{COLOR_ERROR}Error: Could not find a linux x86_64 download asset in the latest Hoolamike release.{COLOR_RESET}") + return False + download_url = chosen_asset.get('browser_download_url') + asset_name = chosen_asset.get('name') + if not download_url or not asset_name: + clear_status() + self.logger.error(f"Chosen asset is missing URL or name: {chosen_asset}") + print(f"{COLOR_ERROR}Error: Found asset but could not get download details.{COLOR_RESET}") + return False + self.logger.info(f"Found asset '{asset_name}' for download: {download_url}") + clear_status() + except requests.exceptions.RequestException as e: + clear_status() + self.logger.error(f"Failed to fetch release info from GitHub: {e}") + print(f"Error: Failed to contact GitHub to check for Hoolamike updates: {e}") + return False + except Exception as e: + clear_status() + self.logger.error(f"Error parsing release info: {e}", exc_info=True) + print("Error: Failed to understand release information from GitHub.") + return False + # Download the asset + show_status(f"Downloading {asset_name}...") + temp_download_path = self.hoolamike_app_install_path / asset_name + if not self.filesystem_handler.download_file(download_url, temp_download_path, overwrite=True, quiet=True): + clear_status() + self.logger.error(f"Failed to download {asset_name} from {download_url}") + print(f"{COLOR_ERROR}Error: Failed to download Hoolamike asset.{COLOR_RESET}") + return False + clear_status() + self.logger.info(f"Downloaded {asset_name} successfully to {temp_download_path}") + show_status("Extracting Hoolamike archive...") + # Extract the asset + try: + if asset_name.lower().endswith(('.tar.gz', '.tgz')): + self.logger.debug(f"Extracting tar file: {temp_download_path}") + with tarfile.open(temp_download_path, 'r:*') as tar: + tar.extractall(path=self.hoolamike_app_install_path) + self.logger.info("Extracted tar file successfully.") + elif asset_name.lower().endswith('.zip'): + self.logger.debug(f"Extracting zip file: {temp_download_path}") + with zipfile.ZipFile(temp_download_path, 'r') as zip_ref: + zip_ref.extractall(self.hoolamike_app_install_path) + self.logger.info("Extracted zip file successfully.") + else: + clear_status() + self.logger.error(f"Unknown archive format for asset: {asset_name}") + print(f"{COLOR_ERROR}Error: Unknown file type '{asset_name}'. Cannot extract.{COLOR_RESET}") + return False + clear_status() + print("Extraction complete. Setting permissions...") + except (tarfile.TarError, zipfile.BadZipFile, EOFError) as e: + clear_status() + self.logger.error(f"Failed to extract archive {temp_download_path}: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error: Failed to extract downloaded file: {e}{COLOR_RESET}") + return False + except Exception as e: + clear_status() + self.logger.error(f"An unexpected error occurred during extraction: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred during extraction.{COLOR_RESET}") + return False + finally: + # Clean up downloaded archive + if temp_download_path.exists(): + try: + temp_download_path.unlink() + self.logger.debug(f"Removed temporary download file: {temp_download_path}") + except OSError as e: + self.logger.warning(f"Could not remove temporary download file {temp_download_path}: {e}") + # Set execute permissions on the binary + executable_path = self.hoolamike_app_install_path / HOOLAMIKE_EXECUTABLE_NAME + if executable_path.is_file(): + try: + show_status("Setting permissions on Hoolamike executable...") + os.chmod(executable_path, 0o755) + self.logger.info(f"Set execute permissions (+x) on {executable_path}") + clear_status() + print("Permissions set successfully.") + except OSError as e: + clear_status() + self.logger.error(f"Failed to set execute permission on {executable_path}: {e}") + print(f"{COLOR_ERROR}Error: Could not set execute permission on Hoolamike executable.{COLOR_RESET}") + else: + clear_status() + self.logger.error(f"Hoolamike executable not found after extraction at {executable_path}") + print(f"{COLOR_ERROR}Error: Hoolamike executable missing after extraction!{COLOR_RESET}") + return False + # Update self.hoolamike_installed and self.hoolamike_executable_path state + self.logger.info("Refreshing Hoolamike installation status...") + self._check_hoolamike_installation() + if not self.hoolamike_installed: + self.logger.error("Hoolamike check failed after apparent successful install/extract.") + print(f"{COLOR_ERROR}Error: Installation completed, but failed final verification check.{COLOR_RESET}") + return False + # Save install path to Jackify config + self.logger.info(f"Saving Hoolamike install path to Jackify config: {self.hoolamike_app_install_path}") + self.config_handler.set('hoolamike_install_path', str(self.hoolamike_app_install_path)) + if not self.config_handler.save_config(): + self.logger.warning("Failed to save Jackify config file after updating Hoolamike path.") + # Non-fatal, but warn user? + print(f"{COLOR_WARNING}Warning: Could not save installation path to main Jackify config file.{COLOR_RESET}") + print(f"{COLOR_SUCCESS}Hoolamike installation/update successful!{COLOR_RESET}") + self.logger.info("Hoolamike install/update process completed successfully.") + return True + except Exception as e: + self.logger.error(f"Error during Hoolamike installation/update: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error: An unexpected error occurred during Hoolamike installation/update: {e}{COLOR_RESET}") + return False + + def install_modlist(self, wabbajack_path=None, install_path=None, downloads_path=None, premium=False, api_key=None, game_resolution=None, context=None): + """ + Install a Wabbajack modlist using Hoolamike, following Jackify's Discovery/Configuration/Confirmation pattern. + """ + self.logger.info("Starting Hoolamike modlist install (Discovery Phase)") + self._check_hoolamike_installation() + menu = self.menu_handler + print(f"\n{'='*60}") + print(f"{COLOR_INFO}Hoolamike Modlist Installation{COLOR_RESET}") + print(f"{'='*60}\n") + + # --- Discovery Phase --- + # 1. Auto-detect games (robust, multi-library) + detected_games = self.path_handler.find_vanilla_game_paths() + # 2. Prompt for .wabbajack file (custom prompt, only accept .wabbajack, q to exit, with tab-completion) + print() + while not wabbajack_path: + print(f"{COLOR_WARNING}This option requires a Nexus Mods Premium account for automatic downloads.{COLOR_RESET}") + print(f"If you don't have a premium account, please use the '{COLOR_SELECTION}Non-Premium Installation{COLOR_RESET}' option from the previous menu instead.\n") + print(f"Before continuing, you'll need a .wabbajack file. You can usually find these at:") + print(f" 1. {COLOR_INFO}https://build.wabbajack.org/authored_files{COLOR_RESET} - Official Wabbajack modlist repository") + print(f" 2. {COLOR_INFO}https://www.nexusmods.com/{COLOR_RESET} - Some modlist authors publish on Nexus Mods") + print(f" 3. Various Discord communities for specific modlists\n") + print(f"{COLOR_WARNING}NOTE: Download the .wabbajack file first, then continue. Enter 'q' to exit.{COLOR_RESET}\n") + # Use menu.get_existing_file_path for tab-completion + candidate = menu.get_existing_file_path( + prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", + extension_filter=".wabbajack", + no_header=True + ) + if candidate is None: + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + # If user literally typed 'q', treat as cancel + if str(candidate).strip().lower() == 'q': + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + wabbajack_path = candidate + # 3. Prompt for install directory + print() + while True: + install_path_result = menu.get_directory_path( + prompt_message="Select the directory where the modlist should be installed:", + default_path=DEFAULT_MODLIST_INSTALL_BASE_DIR / wabbajack_path.stem, + create_if_missing=True, + no_header=False + ) + if not install_path_result: + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + # Handle tuple (path, should_create) + if isinstance(install_path_result, tuple): + install_path, install_should_create = install_path_result + else: + install_path, install_should_create = install_path_result, False + # Check if directory exists and is not empty + if install_path.exists() and any(install_path.iterdir()): + print(f"{COLOR_WARNING}Warning: The selected directory '{install_path}' already exists and is not empty. Its contents may be overwritten!{COLOR_RESET}") + confirm = input(f"{COLOR_PROMPT}This directory is not empty and may be overwritten. Proceed? (y/N): {COLOR_RESET}").strip().lower() + if not confirm.startswith('y'): + print(f"{COLOR_INFO}Please select a different directory.\n{COLOR_RESET}") + continue + break + # 4. Prompt for downloads directory + print() + if not downloads_path: + downloads_path_result = menu.get_directory_path( + prompt_message="Select the directory for mod downloads:", + default_path=DEFAULT_HOOLAMIKE_DOWNLOADS_DIR, + create_if_missing=True, + no_header=False + ) + if not downloads_path_result: + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + # Handle tuple (path, should_create) + if isinstance(downloads_path_result, tuple): + downloads_path, downloads_should_create = downloads_path_result + else: + downloads_path, downloads_should_create = downloads_path_result, False + else: + downloads_should_create = False + # 5. Nexus API key + print() + current_api_key = self.hoolamike_config.get('downloaders', {}).get('nexus', {}).get('api_key') if self.hoolamike_config else None + if not current_api_key or current_api_key == 'YOUR_API_KEY_HERE': + api_key = menu.get_nexus_api_key(current_api_key) + if not api_key: + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + else: + api_key = current_api_key + + # --- Summary & Confirmation --- + print(f"\n{'-'*60}") + print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}") + print(f"- Wabbajack file: {wabbajack_path}") + print(f"- Install directory: {install_path}") + print(f"- Downloads directory: {downloads_path}") + print(f"- Nexus API key: [{'Set' if api_key else 'Not Set'}]") + print("- Games:") + for game in ["Fallout 3", "Fallout New Vegas", "Skyrim Special Edition", "Oblivion", "Fallout 4"]: + found = detected_games.get(game) + print(f" {game}: {found if found else 'Not Found'}") + print(f"{'-'*60}") + print(f"{COLOR_WARNING}Proceed with these settings and start Hoolamike install? (Warning: This can take MANY HOURS){COLOR_RESET}") + confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower() + if confirm and not confirm.startswith('y'): + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + # --- Actually create directories if needed --- + if install_should_create and not install_path.exists(): + try: + install_path.mkdir(parents=True, exist_ok=True) + print(f"{COLOR_SUCCESS}Install directory created: {install_path}{COLOR_RESET}") + except Exception as e: + print(f"{COLOR_ERROR}Failed to create install directory: {e}{COLOR_RESET}") + return False + if downloads_should_create and not downloads_path.exists(): + try: + downloads_path.mkdir(parents=True, exist_ok=True) + print(f"{COLOR_SUCCESS}Downloads directory created: {downloads_path}{COLOR_RESET}") + except Exception as e: + print(f"{COLOR_ERROR}Failed to create downloads directory: {e}{COLOR_RESET}") + return False + + # --- Configuration Phase --- + # Prepare config dict + config = { + "downloaders": { + "downloads_directory": str(downloads_path), + "nexus": {"api_key": api_key} + }, + "installation": { + "wabbajack_file_path": str(wabbajack_path), + "installation_path": str(install_path) + }, + "games": { + self._format_game_name(game): {"root_directory": str(path)} + for game, path in detected_games.items() + }, + "fixup": { + "game_resolution": "1920x1080" + }, + # Resolution intentionally omitted + # "extras": {}, + # No 'jackify_managed' key here + } + self.hoolamike_config = config + if not self.save_hoolamike_config(): + print(f"{COLOR_ERROR}Failed to save hoolamike.yaml. Aborting.{COLOR_RESET}") + return False + + # --- Run Hoolamike --- + print(f"\n{COLOR_INFO}Starting Hoolamike...{COLOR_RESET}") + print(f"{COLOR_INFO}Streaming output below. Press Ctrl+C to cancel and return to Jackify menu.{COLOR_RESET}\n") + # Defensive: Ensure executable path is set and valid + if not self.hoolamike_executable_path or not Path(self.hoolamike_executable_path).is_file(): + print(f"{COLOR_ERROR}Error: Hoolamike executable not found or not set. Please (re)install Hoolamike from the menu before continuing.{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") + return False + try: + cmd = [str(self.hoolamike_executable_path), "install"] + ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env()) + if ret == 0: + print(f"\n{COLOR_SUCCESS}Hoolamike completed successfully!{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") + return True + else: + print(f"\n{COLOR_ERROR}Hoolamike process failed with exit code {ret}.{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") + return False + except KeyboardInterrupt: + print(f"\n{COLOR_WARNING}Hoolamike install interrupted by user. Returning to menu.{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") + return False + except Exception as e: + print(f"\n{COLOR_ERROR}Error running Hoolamike: {e}{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") + return False + + def install_ttw(self, ttw_mpi_path=None, ttw_output_path=None, context=None): + """Install Tale of Two Wastelands (TTW) using Hoolamike. + + Args: + ttw_mpi_path: Path to the TTW installer .mpi file + ttw_output_path: Target installation directory for TTW + + Returns: + bool: True if successful, False otherwise + """ + self.logger.info(f"Starting Tale of Two Wastelands installation via Hoolamike") + self._check_hoolamike_installation() + menu = self.menu_handler + print(f"\n{'='*60}") + print(f"{COLOR_INFO}Hoolamike: Tale of Two Wastelands Installation{COLOR_RESET}") + print(f"{'='*60}\n") + print(f"This feature will install Tale of Two Wastelands (TTW) using Hoolamike.") + print(f"Requirements:") + print(f" • Fallout 3 and Fallout New Vegas must be installed and detected.") + print(f" • You must provide the path to your TTW .mpi installer file.") + print(f" • You must select an output directory for the TTW install.\n") + + # Ensure config is loaded + if self.hoolamike_config is None: + loaded = self._load_hoolamike_config() + if not loaded or self.hoolamike_config is None: + self.logger.error("Failed to load or generate hoolamike.yaml configuration.") + print(f"{COLOR_ERROR}Error: Could not load or generate Hoolamike configuration. Aborting TTW install.{COLOR_RESET}") + return False + + # Verify required games are in configuration + required_games = ['Fallout 3', 'Fallout New Vegas'] + detected_games = self.path_handler.find_vanilla_game_paths() + missing_games = [game for game in required_games if game not in detected_games] + if missing_games: + self.logger.error(f"Missing required games for TTW installation: {', '.join(missing_games)}") + print(f"{COLOR_ERROR}Error: The following required games were not found: {', '.join(missing_games)}{COLOR_RESET}") + print("TTW requires both Fallout 3 and Fallout New Vegas to be installed.") + return False + + # Prompt for TTW .mpi file + print(f"{COLOR_INFO}Please provide the path to your TTW .mpi installer file.{COLOR_RESET}") + print(f"You can download this from: {COLOR_INFO}https://mod.pub/ttw/133/files{COLOR_RESET}") + print(f"(Extract the .mpi file from the downloaded archive.)\n") + while not ttw_mpi_path: + candidate = menu.get_existing_file_path( + prompt_message="Enter the path to your TTW .mpi file (or 'q' to cancel):", + extension_filter=".mpi", + no_header=True + ) + if candidate is None: + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + if str(candidate).strip().lower() == 'q': + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + ttw_mpi_path = candidate + + # Prompt for output directory + print(f"\n{COLOR_INFO}Please select the output directory where TTW will be installed.{COLOR_RESET}") + print(f"(This should be an empty or new directory.)\n") + while not ttw_output_path: + ttw_output_path = menu.get_directory_path( + prompt_message="Select the TTW output directory:", + default_path=self.hoolamike_app_install_path / "TTW_Output", + create_if_missing=True, + no_header=False + ) + if not ttw_output_path: + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + if ttw_output_path.exists() and any(ttw_output_path.iterdir()): + print(f"{COLOR_WARNING}Warning: The selected directory '{ttw_output_path}' already exists and is not empty. Its contents may be overwritten!{COLOR_RESET}") + confirm = input(f"{COLOR_PROMPT}This directory is not empty and may be overwritten. Proceed? (y/N): {COLOR_RESET}").strip().lower() + if not confirm.startswith('y'): + print(f"{COLOR_INFO}Please select a different directory.\n{COLOR_RESET}") + ttw_output_path = None + continue + + # --- Summary & Confirmation --- + print(f"\n{'-'*60}") + print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}") + print(f"- TTW .mpi file: {ttw_mpi_path}") + print(f"- Output directory: {ttw_output_path}") + print("- Games:") + for game in required_games: + found = detected_games.get(game) + print(f" {game}: {found if found else 'Not Found'}") + print(f"{'-'*60}") + print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}") + confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower() + if confirm and not confirm.startswith('y'): + print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") + return False + + # --- Always re-detect games before updating config --- + detected_games = self.path_handler.find_vanilla_game_paths() + if not detected_games: + print(f"{COLOR_ERROR}No supported games were detected on your system. TTW requires Fallout 3 and Fallout New Vegas to be installed.{COLOR_RESET}") + return False + # Update the games section with correct keys + if self.hoolamike_config is None: + self.hoolamike_config = {} + self.hoolamike_config['games'] = { + self._format_game_name(game): {"root_directory": str(path)} + for game, path in detected_games.items() + } + + # Update TTW configuration + self._update_hoolamike_config_for_ttw(ttw_mpi_path, ttw_output_path) + if not self.save_hoolamike_config(): + self.logger.error("Failed to save hoolamike.yaml configuration.") + print(f"{COLOR_ERROR}Error: Failed to save Hoolamike configuration.{COLOR_RESET}") + print("Attempting to continue anyway...") + + # Construct command to execute + cmd = [ + str(self.hoolamike_executable_path), + "tale-of-two-wastelands" + ] + self.logger.info(f"Executing Hoolamike command: {' '.join(cmd)}") + print(f"\n{COLOR_INFO}Executing Hoolamike for TTW Installation...{COLOR_RESET}") + print(f"Command: {' '.join(cmd)}") + print(f"{COLOR_INFO}Streaming output below. Press Ctrl+C to cancel and return to Jackify menu.{COLOR_RESET}\n") + try: + ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env()) + if ret == 0: + self.logger.info("TTW installation completed successfully.") + print(f"\n{COLOR_SUCCESS}TTW installation completed successfully!{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") + return True + else: + self.logger.error(f"TTW installation process returned non-zero exit code: {ret}") + print(f"\n{COLOR_ERROR}Error: TTW installation failed with exit code {ret}.{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Error executing Hoolamike TTW installation: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Error executing Hoolamike TTW installation: {e}{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") + return False + + def _update_hoolamike_config_for_ttw(self, ttw_mpi_path: Path, ttw_output_path: Path): + """Update the Hoolamike configuration with settings for TTW installation.""" + # Ensure extras and TTW sections exist + if "extras" not in self.hoolamike_config: + self.hoolamike_config["extras"] = {} + + if "tale_of_two_wastelands" not in self.hoolamike_config["extras"]: + self.hoolamike_config["extras"]["tale_of_two_wastelands"] = { + "variables": {} + } + + # Update TTW configuration + ttw_config = self.hoolamike_config["extras"]["tale_of_two_wastelands"] + ttw_config["path_to_ttw_mpi_file"] = str(ttw_mpi_path) + + # Ensure variables section exists + if "variables" not in ttw_config: + ttw_config["variables"] = {} + + # Set destination variable + ttw_config["variables"]["DESTINATION"] = str(ttw_output_path) + + # Set USERPROFILE to a Jackify-managed directory for TTW + userprofile_path = str(self.hoolamike_app_install_path / "USERPROFILE") + if "variables" not in self.hoolamike_config["extras"]["tale_of_two_wastelands"]: + self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"] = {} + self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"]["USERPROFILE"] = userprofile_path + + # Make sure game paths are set correctly + for game in ['Fallout 3', 'Fallout New Vegas']: + if game in self.game_install_paths: + game_key = game.replace(' ', '').lower() + + if "games" not in self.hoolamike_config: + self.hoolamike_config["games"] = {} + + if game not in self.hoolamike_config["games"]: + self.hoolamike_config["games"][game] = {} + + self.hoolamike_config["games"][game]["root_directory"] = str(self.game_install_paths[game]) + + self.logger.info("Updated Hoolamike configuration with TTW settings.") + + def reset_config(self): + """Resets the hoolamike.yaml to default settings, backing up any existing file.""" + if self.hoolamike_config_path.is_file(): + # Create a backup with timestamp + import datetime + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = self.hoolamike_config_path.with_suffix(f".{timestamp}.bak") + try: + import shutil + shutil.copy2(self.hoolamike_config_path, backup_path) + self.logger.info(f"Created backup of existing config at {backup_path}") + print(f"{COLOR_INFO}Created backup of existing config at {backup_path}{COLOR_RESET}") + except Exception as e: + self.logger.error(f"Failed to create backup of config: {e}") + print(f"{COLOR_WARNING}Warning: Failed to create backup of config: {e}{COLOR_RESET}") + + # Generate and save a fresh default config + self.logger.info("Generating new default configuration") + self.hoolamike_config = self._generate_default_config() + if self.save_hoolamike_config(): + self.logger.info("Successfully reset config to defaults") + print(f"{COLOR_SUCCESS}Successfully reset configuration to defaults.{COLOR_RESET}") + return True + else: + self.logger.error("Failed to save new default config") + print(f"{COLOR_ERROR}Failed to save new default configuration.{COLOR_RESET}") + return False + + def edit_hoolamike_config(self): + """Opens the hoolamike.yaml file in a chosen editor, with a 0 option to return to menu.""" + self.logger.info("Task: Edit Hoolamike Config started...") + self._check_hoolamike_installation() + if not self.hoolamike_installed: + self.logger.warning("Cannot edit config - Hoolamike not installed") + print(f"\n{COLOR_WARNING}Hoolamike is not installed through Jackify yet.{COLOR_RESET}") + print(f"Please use option 1 from the Hoolamike menu to install Hoolamike first.") + print(f"This will ensure that Jackify can properly manage the Hoolamike configuration.") + return False + if self.hoolamike_config is None: + self.logger.warning("Config is not loaded properly. Will attempt to fix or create.") + print(f"\n{COLOR_WARNING}Configuration file may be corrupted or not accessible.{COLOR_RESET}") + print("Options:") + print("1. Reset to default configuration (backup will be created)") + print("2. Try to edit the file anyway (may be corrupted)") + print("0. Cancel and return to menu") + choice = input("\nEnter your choice (0-2): ").strip() + if choice == "1": + if not self.reset_config(): + self.logger.error("Failed to reset configuration") + print(f"{COLOR_ERROR}Failed to reset configuration. See logs for details.{COLOR_RESET}") + return + elif choice == "2": + self.logger.warning("User chose to edit potentially corrupted config") + # Continue to editing + elif choice == "0": + self.logger.info("User cancelled editing corrupted config") + print("Edit cancelled.") + return + else: + self.logger.info("User cancelled editing corrupted config") + print("Edit cancelled.") + return + if not self.hoolamike_config_path.exists(): + self.logger.warning(f"Hoolamike config file does not exist at {self.hoolamike_config_path}. Generating default before editing.") + self.hoolamike_config = self._generate_default_config() + self.save_hoolamike_config() + if not self.hoolamike_config_path.exists(): + self.logger.error("Failed to create config file for editing.") + print("Error: Could not create configuration file.") + return + available_editors = ["nano", "vim", "vi", "gedit", "kate", "micro"] + preferred_editor = os.environ.get("EDITOR") + found_editors = {} + import shutil + for editor_name in available_editors: + editor_path = shutil.which(editor_name) + if editor_path and editor_path not in found_editors.values(): + found_editors[editor_name] = editor_path + if preferred_editor: + preferred_editor_path = shutil.which(preferred_editor) + if preferred_editor_path and preferred_editor_path not in found_editors.values(): + display_name = os.path.basename(preferred_editor) if '/' in preferred_editor else preferred_editor + if display_name not in found_editors: + found_editors[display_name] = preferred_editor_path + if not found_editors: + self.logger.error("No suitable text editors found on the system.") + print(f"{COLOR_ERROR}Error: No common text editors (nano, vim, gedit, kate, micro) found.{COLOR_RESET}") + return + sorted_editor_names = sorted(found_editors.keys()) + print("\nSelect an editor to open the configuration file:") + print(f"(System default EDITOR is: {preferred_editor if preferred_editor else 'Not set'})") + for i, name in enumerate(sorted_editor_names): + print(f" {i + 1}. {name}") + print(f" 0. Return to Hoolamike Menu") + while True: + try: + choice = input(f"Enter choice (0-{len(sorted_editor_names)}): ").strip() + if choice == "0": + print("Edit cancelled.") + return + choice_index = int(choice) - 1 + if 0 <= choice_index < len(sorted_editor_names): + chosen_name = sorted_editor_names[choice_index] + editor_to_use_path = found_editors[chosen_name] + break + else: + print("Invalid choice.") + except ValueError: + print("Invalid input. Please enter a number.") + except KeyboardInterrupt: + print("\nEdit cancelled.") + return + if editor_to_use_path: + self.logger.info(f"Launching editor '{editor_to_use_path}' for {self.hoolamike_config_path}") + try: + process = subprocess.Popen([editor_to_use_path, str(self.hoolamike_config_path)]) + process.wait() + self.logger.info(f"Editor '{editor_to_use_path}' closed. Reloading config...") + if not self._load_hoolamike_config(): + self.logger.error("Failed to load config after editing. It may still be corrupted.") + print(f"{COLOR_ERROR}Warning: The configuration file could not be parsed after editing.{COLOR_RESET}") + print("You may need to fix it manually or reset it to defaults.") + return False + else: + self.logger.info("Successfully reloaded config after editing.") + print(f"{COLOR_SUCCESS}Configuration file successfully updated.{COLOR_RESET}") + return True + except FileNotFoundError: + self.logger.error(f"Editor '{editor_to_use_path}' not found unexpectedly.") + print(f"{COLOR_ERROR}Error: Editor command '{editor_to_use_path}' not found.{COLOR_RESET}") + except Exception as e: + self.logger.error(f"Error launching or waiting for editor: {e}") + print(f"{COLOR_ERROR}An error occurred while launching the editor: {e}{COLOR_RESET}") + +# Example usage (for testing, remove later) +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + print("Running HoolamikeHandler discovery...") + handler = HoolamikeHandler(steamdeck=False, verbose=True) + print("\n--- Discovery Results ---") + print(f"Game Paths: {handler.game_install_paths}") + print(f"Hoolamike App Install Path: {handler.hoolamike_app_install_path}") + print(f"Hoolamike Executable: {handler.hoolamike_executable_path}") + print(f"Hoolamike Installed: {handler.hoolamike_installed}") + print(f"Hoolamike Config Path: {handler.hoolamike_config_path}") + config_loaded = isinstance(handler.hoolamike_config, dict) + print(f"Hoolamike Config Loaded: {config_loaded}") + if config_loaded: + print(f" Downloads Dir: {handler.hoolamike_config.get('downloaders', {}).get('downloads_directory')}") + print(f" API Key Set: {'Yes' if handler.hoolamike_config.get('downloaders', {}).get('nexus', {}).get('api_key') != 'YOUR_API_KEY_HERE' else 'No'}") + print("-------------------------") + # Test edit config (example) + # handler.edit_hoolamike_config() \ No newline at end of file diff --git a/jackify/backend/handlers/install_wabbajack_handler.py b/jackify/backend/handlers/install_wabbajack_handler.py new file mode 100644 index 0000000..3de05fb --- /dev/null +++ b/jackify/backend/handlers/install_wabbajack_handler.py @@ -0,0 +1,1663 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Install Wabbajack Handler Module +Handles the installation and updating of Wabbajack +""" + +import os +import logging +from pathlib import Path +from typing import Optional, Tuple +import shutil +import subprocess +import pwd +import requests +from tqdm import tqdm +import tempfile +import time +import re + +# Attempt to import readline for tab completion +READLINE_AVAILABLE = False +try: + import readline + READLINE_AVAILABLE = True + # Check if running in a non-interactive environment (e.g., some CI) + if 'libedit' in readline.__doc__: + # libedit doesn't support set_completion_display_matches_hook + pass + # Add other potential checks if needed +except ImportError: + # readline not available on Windows or potentially minimal environments + pass +except Exception as e: + # Catch other potential errors during readline import/setup + logging.warning(f"Readline import failed: {e}") + pass + +# Import UI Colors first - these should always be available +from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR + +# Import necessary components from other modules +try: + from .path_handler import PathHandler + from .protontricks_handler import ProtontricksHandler + from .shortcut_handler import ShortcutHandler + from .vdf_handler import VDFHandler + from .modlist_handler import ModlistHandler + from .filesystem_handler import FileSystemHandler + from .menu_handler import MenuHandler, simple_path_completer + # Standard logging (no file handler) - LoggingHandler import removed + from .status_utils import show_status, clear_status + from jackify.shared.ui_utils import print_section_header +except ImportError as e: + logging.error(f"Import error in InstallWabbajackHandler: {e}") + logging.error("Could not import FileSystemHandler or simple_path_completer. Ensure structure is correct.") + +# Default locations +WABBAJACK_DEFAULT_DIR = os.path.expanduser("~/.config/Jackify/Wabbajack") + +# Initialize logger for the module +logger = logging.getLogger(__name__) + +DEFAULT_WABBAJACK_PATH = "~/Wabbajack" +DEFAULT_WABBAJACK_NAME = "Wabbajack" + +class InstallWabbajackHandler: + """Handles the workflow for installing Wabbajack via Jackify.""" + + def __init__(self, steamdeck: bool, protontricks_handler: ProtontricksHandler, shortcut_handler: ShortcutHandler, path_handler: PathHandler, vdf_handler: VDFHandler, modlist_handler: ModlistHandler, filesystem_handler: FileSystemHandler, menu_handler=None): + """ + Initializes the handler. + + Args: + steamdeck (bool): True if running on a Steam Deck, False otherwise. + protontricks_handler (ProtontricksHandler): An initialized instance. + shortcut_handler (ShortcutHandler): An initialized instance. + path_handler (PathHandler): An initialized instance. + vdf_handler (VDFHandler): An initialized instance. + modlist_handler (ModlistHandler): An initialized instance. + filesystem_handler (FileSystemHandler): An initialized instance. + menu_handler: An optional MenuHandler instance for improved UI interactions. + """ + # Use standard logging (no file handler) + self.logger = logging.getLogger(__name__) + self.logger.propagate = False + self.steamdeck = steamdeck + self.protontricks_handler = protontricks_handler # Store the handler + self.shortcut_handler = shortcut_handler # Store the handler + self.path_handler = path_handler # Store the handler + self.vdf_handler = vdf_handler # Store the handler + self.modlist_handler = modlist_handler # Store the handler + self.filesystem_handler = filesystem_handler # Store the handler + self.menu_handler = menu_handler # Store the menu handler + self.logger.info(f"InstallWabbajackHandler initialized. Steam Deck status: {self.steamdeck}") + self.install_path: Optional[Path] = None + self.shortcut_name: Optional[str] = None + self.initial_appid: Optional[str] = None # To store the AppID from shortcut creation + self.final_appid: Optional[str] = None # To store the AppID after verification + self.compatdata_path: Optional[Path] = None # To store the compatdata path + # Add other state variables as needed + + def _print_default_status(self, message: str): + """Prints overwriting status line, ONLY if not in verbose/debug mode.""" + verbose_console = False + for handler in logging.getLogger().handlers: + if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): + if handler.level <= logging.INFO: + verbose_console = True + break + + if not verbose_console: + # Use \r to return to start, \033[K to clear line, then print message + # Prepend "Current Task: " to the message + status_text = f"Current Task: {message}" + # Use a fixed-width field for consistent display and proper line clearing + status_width = 80 # Ensure sufficient width to cover previous text + # Pad with spaces and use \r to stay on the same line + print(f"\r\033[K{COLOR_INFO}{status_text:<{status_width}}{COLOR_RESET}", end="", flush=True) + + def _clear_default_status(self): + """Clears the status line, ONLY if not in verbose/debug mode.""" + verbose_console = False + for handler in logging.getLogger().handlers: + if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): + if handler.level <= logging.INFO: + verbose_console = True + break + if not verbose_console: + print("\r\033[K", end="", flush=True) + + def _download_file(self, url: str, destination_path: Path) -> bool: + """Downloads a file from a URL to a destination path. + Handles temporary file and overwrites destination if download succeeds. + + Args: + url (str): The URL to download from. + destination_path (Path): The path to save the downloaded file. + + Returns: + bool: True if download succeeds, False otherwise. + """ + self.logger.info(f"Downloading {destination_path.name} from {url}") + + # Ensure parent directory exists + destination_path.parent.mkdir(parents=True, exist_ok=True) + + # --- Download --- + temp_path = destination_path.with_suffix(destination_path.suffix + ".part") + self.logger.debug(f"Downloading to temporary path: {temp_path}") + + try: + with requests.get(url, stream=True, timeout=30, verify=True) as r: + r.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) + # total_size_in_bytes = int(r.headers.get('content-length', 0)) + block_size = 8192 # 8KB chunks + + with open(temp_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=block_size): + if chunk: # filter out keep-alive new chunks + f.write(chunk) + + # --- Post-Download Actions --- + actual_downloaded_size = temp_path.stat().st_size + self.logger.debug(f"Download finished. Actual size: {actual_downloaded_size} bytes.") + + # Overwrite final destination with temp file + # Use shutil.move for better cross-filesystem compatibility if needed + # temp_path.rename(destination_path) # Simple rename + shutil.move(str(temp_path), str(destination_path)) + self.logger.info(f"Successfully downloaded and moved to {destination_path}") + return True + + except requests.exceptions.RequestException as e: + self.logger.error(f"Download failed for {url}: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Error downloading {destination_path.name}: {e}{COLOR_RESET}") + # Clean up partial file if download fails + if temp_path.exists(): + try: + temp_path.unlink() + except OSError as unlink_err: + self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") + return False + except Exception as e: + self.logger.error(f"An unexpected error occurred during download: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}An unexpected error occurred during download: {e}{COLOR_RESET}") + if temp_path.exists(): + try: + temp_path.unlink() + except OSError as unlink_err: + self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") + return False + + def _prepare_install_directory(self) -> bool: + """ + Ensures the target installation directory exists and is accessible. + Handles directory creation, prompting the user if outside $HOME. + + Returns: + bool: True if the directory exists and is ready, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot prepare directory: install_path is not set.") + return False + + self.logger.info(f"Preparing installation directory: {self.install_path}") + + if self.install_path.exists(): + if self.install_path.is_dir(): + self.logger.info(f"Directory already exists: {self.install_path}") + # Check write permissions + if not os.access(self.install_path, os.W_OK | os.X_OK): + self.logger.error(f"Directory exists but lacks write/execute permissions: {self.install_path}") + print(f"\n{COLOR_ERROR}Error: Directory exists but lacks necessary write/execute permissions.{COLOR_RESET}") + return False + return True + else: + self.logger.error(f"Path exists but is not a directory: {self.install_path}") + print(f"\n{COLOR_ERROR}Error: The specified path exists but is a file, not a directory.{COLOR_RESET}") + return False + else: + # Directory does not exist, attempt creation + self.logger.info("Directory does not exist. Attempting creation...") + try: + home_dir = Path.home() + is_outside_home = not str(self.install_path.resolve()).startswith(str(home_dir.resolve())) + + if is_outside_home: + self.logger.warning(f"Install path {self.install_path} is outside home directory {home_dir}.") + print(f"\n{COLOR_PROMPT}The chosen path is outside your home directory and may require manual creation.{COLOR_RESET}") + while True: + response = input(f"{COLOR_PROMPT}Please create the directory \"{self.install_path}\" manually,\nensure you have write permissions, and then press Enter to continue (or 'q' to quit): {COLOR_RESET}").lower() + if response == 'q': + self.logger.warning("User aborted manual directory creation.") + return False + # Re-check after user presses Enter + if self.install_path.exists(): + if self.install_path.is_dir(): + self.logger.info("Directory created manually by user.") + if not os.access(self.install_path, os.W_OK | os.X_OK): + self.logger.warning(f"Directory created but may lack write/execute permissions: {self.install_path}") + print(f"\n{COLOR_ERROR}Warning: Directory created, but write/execute permissions might be missing.{COLOR_RESET}") + # Decide whether to proceed or fail here - let's proceed but warn + return True + else: + self.logger.error("User indicated directory created, but path is not a directory.") + print(f"\n{COLOR_ERROR}Error: Path exists now, but it is not a directory. Please fix and try again.{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}Directory still not found. Please create it or enter 'q' to quit.{COLOR_RESET}") + else: + # Inside home directory, attempt direct creation + self.logger.info("Path is inside home directory. Creating...") + os.makedirs(self.install_path) + self.logger.info(f"Successfully created directory: {self.install_path}") + # Verify permissions after creation + if not os.access(self.install_path, os.W_OK | os.X_OK): + self.logger.warning(f"Directory created but lacks write/execute permissions: {self.install_path}") + print(f"\n{COLOR_ERROR}Warning: Directory created, but lacks write/execute permissions. Subsequent steps might fail.{COLOR_RESET}") + # Proceed anyway? + return True + + except PermissionError: + self.logger.error(f"Permission denied when trying to create directory: {self.install_path}", exc_info=True) + print(f"\n{COLOR_ERROR}Error: Permission denied creating directory.{COLOR_RESET}") + print(f"{COLOR_INFO}Please check permissions for the parent directory or choose a different location.{COLOR_RESET}") + return False + except OSError as e: + self.logger.error(f"Failed to create directory {self.install_path}: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Error creating directory: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"An unexpected error occurred during directory preparation: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _get_wabbajack_install_path(self) -> Optional[Path]: + """ + Prompts the user for the Wabbajack installation path with tab completion. + Uses the FileSystemHandler for path validation and completion. + + Returns: + Optional[Path]: The chosen installation path as a Path object, or None if cancelled. + """ + self.logger.info("Prompting for Wabbajack installation path.") + # Use default path if set, otherwise prompt with suggestion + current_path = self.install_path if self.install_path else Path(DEFAULT_WABBAJACK_PATH).expanduser() + + # Enable tab completion if readline is available + if READLINE_AVAILABLE: + readline.set_completer_delims(' \t\n;') + readline.parse_and_bind("tab: complete") + # Use the simple_path_completer from FileSystemHandler for directory completion + readline.set_completer(simple_path_completer) + + while True: + try: + prompt_text = f"{COLOR_PROMPT}Enter Wabbajack installation path (default: {current_path}): {COLOR_RESET}" + user_input = input(prompt_text).strip() + + if not user_input: # User pressed Enter for default + chosen_path_str = str(current_path) + else: + chosen_path_str = user_input + + # Expand ~ and make absolute + chosen_path = Path(chosen_path_str).expanduser().resolve() + + # Basic validation (is it a plausible path format?) + if not chosen_path.name: # e.g. if user entered just "/" + print(f"{COLOR_ERROR}Invalid path. Please enter a valid directory path.{COLOR_RESET}") + continue + + # Check if path exists and is a directory, or can be created + if chosen_path.exists() and not chosen_path.is_dir(): + print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}") + continue + + # Confirm with user + confirm_prompt = f"{COLOR_PROMPT}Install Wabbajack to {chosen_path}? (Y/n/c to cancel): {COLOR_RESET}" + confirmation = input(confirm_prompt).lower() + + if confirmation == 'c': + self.logger.info("Wabbajack installation path selection cancelled by user.") + return None # User cancelled + elif confirmation != 'n': + self.install_path = chosen_path # Store the confirmed path + self.logger.info(f"Wabbajack installation path set to: {self.install_path}") + return self.install_path + # If 'n', loop again to ask for path + except KeyboardInterrupt: + self.logger.info("Wabbajack installation path selection cancelled by user (Ctrl+C).") + print("\nPath selection cancelled.") + return None + except Exception as e: + self.logger.error(f"Error during path input: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + # Decide if we should return None or retry on general exception + return None + finally: + # Restore default completer if it was changed + if READLINE_AVAILABLE: + readline.set_completer(None) + + def _get_wabbajack_shortcut_name(self) -> Optional[str]: + """ + Prompts the user for the Wabbajack shortcut name. + + Returns: + Optional[str]: The name chosen by the user, or None if cancelled. + """ + self.logger.debug("Getting Wabbajack shortcut name.") + + # Return pre-configured shortcut name if already set + if self.shortcut_name: + self.logger.info(f"Using pre-configured shortcut name: {self.shortcut_name}") + return self.shortcut_name + + chosen_name = DEFAULT_WABBAJACK_NAME + + # Use menu_handler if available for consistent UI + if self.menu_handler: + self.logger.debug("Using menu_handler for shortcut name input") + print(f"\nWabbajack Shortcut Name:") + name_input = self.menu_handler.get_input_with_default( + prompt=f"Enter the desired name for the Wabbajack Steam shortcut (default: {chosen_name})", + default=chosen_name + ) + + if name_input is not None: + self.logger.info(f"User provided shortcut name: {name_input}") + return name_input + else: + self.logger.info("User cancelled shortcut name input") + return None + + # Fallback to direct input if no menu_handler + try: + print(f"\n{COLOR_PROMPT}Enter the desired name for the Wabbajack Steam shortcut.{COLOR_RESET}") + name_input = input(f"{COLOR_PROMPT}Name [{chosen_name}]: {COLOR_RESET}").strip() + + if not name_input: + self.logger.info(f"User did not provide input, using default name: {chosen_name}") + else: + chosen_name = name_input + self.logger.info(f"User provided name: {chosen_name}") + + return chosen_name + + except KeyboardInterrupt: + print(f"\n{COLOR_ERROR}Input cancelled by user.{COLOR_RESET}") + self.logger.warning("User cancelled name input.") + return None + except Exception as e: + self.logger.error(f"An unexpected error occurred while getting name input: {e}", exc_info=True) + return None + + def run_install_workflow(self, context: dict = None) -> bool: + """ + Main entry point for the Wabbajack installation workflow. + """ + os.system('cls' if os.name == 'nt' else 'clear') + # Banner display handled by frontend + print_section_header('Wabbajack Installation') + # Standard logging (no file handler) - LoggingHandler calls removed + + self.logger.info("Starting Wabbajack installation workflow...") + # Remove legacy divider + # print(f"\n{COLOR_INFO}--- Wabbajack Installation ---{COLOR_RESET}") + # 1. Get Installation Path + if self.menu_handler: + print("\nWabbajack Installation Location:") + default_path = Path.home() / 'Wabbajackify' + install_path_result = self.menu_handler.get_directory_path( + prompt_message=f"Enter path (Default: {default_path}):", + default_path=default_path, + create_if_missing=True, + no_header=True + ) + if not install_path_result: + self.logger.info("User cancelled path input via menu_handler") + return True # Return to menu to allow user to retry or exit gracefully + # Handle the result from get_directory_path (could be Path or tuple) + if isinstance(install_path_result, tuple): + self.install_path = install_path_result[0] # Path object + self.logger.info(f"Install path set to {self.install_path}, user confirmed creation if new.") + else: + self.install_path = install_path_result # Already a Path object + self.logger.info(f"Install path set to {self.install_path}.") + else: # Fallback if no menu_handler (should ideally not happen in normal flow) + default_path = Path.home() / 'Wabbajackify' + print(f"\n{COLOR_PROMPT}Enter the full path where Wabbajack should be installed.{COLOR_RESET}") + print(f"Default: {default_path}") + try: + user_input = input(f"{COLOR_PROMPT}Enter path (or press Enter for default: {default_path}): {COLOR_RESET}").strip() + if not user_input: + install_path = default_path + else: + install_path = Path(user_input).expanduser().resolve() + self.install_path = install_path + except KeyboardInterrupt: + print("\nOperation cancelled by user.") + self.logger.info("User cancelled path input.") + return True + + # 2. Get Shortcut Name + self.shortcut_name = self._get_wabbajack_shortcut_name() + if not self.shortcut_name: + self.logger.warning("Workflow aborted: Failed to get shortcut name.") + return True # Return to menu + + # 3. Steam Deck status is already known (self.steamdeck) + self.logger.info(f"Proceeding with Steam Deck status: {self.steamdeck}") + + # 4. Check Prerequisite: Protontricks + self.logger.info("Checking Protontricks prerequisite...") + protontricks_ok = self.protontricks_handler.check_and_setup_protontricks() + if not protontricks_ok: + self.logger.error("Workflow aborted: Protontricks requirement not met or setup failed.") + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + self.logger.info("Protontricks check successful.") + + # --- Show summary (no input required) --- + self._display_summary() # Show the summary only, no input here + # --- Single confirmation prompt before making changes/restarting Steam --- + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_PROMPT}Important:{COLOR_RESET} Steam will now restart so Jackify can create the Wabbajack shortcut.\n\nPlease do not manually start or close Steam until Jackify is finished.") + print("───────────────────────────────────────────────────────────────────") + confirm = input(f"{COLOR_PROMPT}Do you wish to continue? (y/N): {COLOR_RESET}").strip().lower() + if confirm not in ('y', ''): + print("Installation cancelled by user.") + return True + + # --- Phase 2: All changes happen after confirmation --- + + # 5. Prepare Install Directory + show_status("Preparing install directory") + if not self._prepare_install_directory(): + self.logger.error("Workflow aborted: Failed to prepare installation directory.") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + self.logger.info("Installation directory prepared successfully.") + + # 6. Download Wabbajack.exe + show_status("Downloading Wabbajack.exe") + if not self._download_wabbajack_executable(): + self.logger.error("Workflow aborted: Failed to download Wabbajack.exe.") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + clear_status() + + # 7. Create Steam Shortcut + show_status("Creating Steam shortcut") + shortcut_created = self._create_steam_shortcut() + clear_status() + if not shortcut_created: + self.logger.error("Workflow aborted: Failed to create Steam shortcut.") + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + + # Print the AppID immediately after shortcut creation, before any other output + print("\n==================== Steam Shortcut Created ====================") + if self.initial_appid: + print(f"{COLOR_INFO}Initial Steam AppID (before Steam restart): {self.initial_appid}{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Warning: Could not determine initial AppID after shortcut creation.{COLOR_RESET}") + print("==============================================================\n") + + # 8. Handle Steam Restart & Manual Steps (Calls _print_default_status internally) + if not self._handle_steam_restart_and_manual_steps(): + # Status already cleared by the function if needed + self.logger.info("Workflow aborted: Steam restart/manual steps issue or user needs to re-run.") + return True # Return to menu, user needs to act + + # 9. Verify Manual Steps + # Move cursor up, return to start, clear line - attempt to overwrite input prompt line + print("\033[A\r\033[K", end="", flush=True) + show_status("Verifying Proton Setup") + while True: + if self._verify_manual_steps(): + show_status("Manual Steps Successful") + # Print the AppID after Steam restart and re-detection + if self.final_appid: + print(f"\n{COLOR_INFO}Final Steam AppID (after Steam restart): {self.final_appid}{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}Warning: Could not determine AppID after Steam restart.{COLOR_RESET}") + break # Verification successful + else: + self.logger.warning("Manual steps verification failed.") + clear_status() # Clear status before printing error/prompt + print(f"\n{COLOR_ERROR}Verification failed. Please ensure you have completed all manual steps correctly.{COLOR_RESET}") + self._display_manual_proton_steps() # Re-display steps + try: + # Add a newline before the input prompt for clarity + response = input(f"\n{COLOR_PROMPT}Press Enter to retry verification, or 'q' to quit: {COLOR_RESET}").lower() + if response == 'q': + self.logger.warning("User quit during verification loop.") + return True # Return to menu, aborting config + show_status("Retrying Verification") + except KeyboardInterrupt: + clear_status() + print("\nOperation cancelled by user.") + self.logger.warning("User cancelled during verification loop.") + return True # Return to menu + + # --- Start Actual Configuration --- + self.logger.info(f"Starting final configuration for AppID {self.final_appid}...") + # logger.info("--- Configuration --- Applying final configurations...") # Keep this log for file + + # Check console level for verbose output + verbose_console = False + for handler in logging.getLogger().handlers: + if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): + if handler.level <= logging.INFO: # Check if INFO or DEBUG + verbose_console = True + break + + if verbose_console: + print(f"{COLOR_INFO}Applying final configurations...{COLOR_RESET}") + + # 10. Set Protontricks Permissions (Flatpak) + show_status("Setting Protontricks permissions") + if not self.protontricks_handler.set_protontricks_permissions(str(self.install_path), self.steamdeck): + self.logger.warning("Failed to set Flatpak Protontricks permissions. Continuing, but subsequent steps might fail if Flatpak Protontricks is used.") + clear_status() # Clear status before printing warning + print(f"\n{COLOR_ERROR}Warning: Could not set Flatpak permissions automatically.{COLOR_RESET}") + + # 12. Download WebView Installer (Check happens BEFORE setting prefix) + show_status("Checking WebView Installer") + if not self._download_webview_installer(): + self.logger.error("Workflow aborted: Failed to download WebView installer.") + # Error message printed by the download function + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + + # 13. Configure Prefix (Set to Win7 for WebView install) + show_status("Applying Initial Win7 Registry Settings (for WebView install)") + try: + import requests + # Download minimal Win7 system.reg (corrected URL) + system_reg_win7_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.wj.win7" + system_reg_dest = self.compatdata_path / 'pfx' / 'system.reg' + system_reg_dest.parent.mkdir(parents=True, exist_ok=True) + self.logger.info(f"Downloading system.reg.wj.win7 from {system_reg_win7_url} to {system_reg_dest}") + response = requests.get(system_reg_win7_url, verify=True) + response.raise_for_status() + with open(system_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"system.reg.wj.win7 downloaded and applied to {system_reg_dest}") + except Exception as e: + self.logger.error(f"Failed to download or apply initial Win7 system.reg: {e}") + print(f"{COLOR_ERROR}Error: Failed to download or apply initial Win7 system.reg. {e}{COLOR_RESET}") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True + + # 14. Install WebView (using protontricks-launch) + show_status("Installing WebView (Edge)") + webview_installer_path = self.install_path / "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + webview_result = self.protontricks_handler.run_protontricks_launch( + self.final_appid, webview_installer_path, "/silent", "/install" + ) + self.logger.debug(f"WebView install result: {webview_result}") + if not webview_result or webview_result.returncode != 0: + self.logger.error("WebView installation failed via protontricks-launch.") + print(f"{COLOR_ERROR}Error: WebView installation failed via protontricks-launch.{COLOR_RESET}") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True + show_status("WebView installation Complete") + + # 15. Configure Prefix (Part 2 - Final Settings) + show_status("Applying Final Registry Settings") + try: + # Download final system.reg + system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.wj" + system_reg_dest = self.compatdata_path / 'pfx' / 'system.reg' + self.logger.info(f"Downloading final system.reg from {system_reg_url} to {system_reg_dest}") + response = requests.get(system_reg_url, verify=True) + response.raise_for_status() + with open(system_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"Final system.reg downloaded and applied to {system_reg_dest}") + # Download final user.reg + user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.wj" + user_reg_dest = self.compatdata_path / 'pfx' / 'user.reg' + self.logger.info(f"Downloading final user.reg from {user_reg_url} to {user_reg_dest}") + response = requests.get(user_reg_url, verify=True) + response.raise_for_status() + with open(user_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"Final user.reg downloaded and applied to {user_reg_dest}") + except Exception as e: + self.logger.error(f"Failed to download or apply final user.reg/system.reg: {e}") + print(f"{COLOR_ERROR}Error: Failed to download or apply final user.reg/system.reg. {e}{COLOR_RESET}") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True + + # 16. Configure Prefix Steam Library VDF + show_status("Configuring Steam Library in Prefix") + if not self._create_prefix_library_vdf(): return False + + # 17. Create Dotnet Bundle Cache Directory + show_status("Creating .NET Cache Directory") + if not self._create_dotnet_cache_dir(): + self.logger.error("Workflow aborted: Failed to create dotnet cache directory.") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + + # --- Final Steps --- + # Check for and optionally apply Flatpak overrides *before* final cleanup/completion + self._check_and_prompt_flatpak_overrides() + + # Attempt to clean up any stray Wine/Protontricks processes as a final measure + self.logger.info("Performing final Wine process cleanup...") + try: + # Ensure the ProtontricksHandler instance exists and has the method + if hasattr(self, 'protontricks_handler') and hasattr(self.protontricks_handler, '_cleanup_wine_processes'): + self.protontricks_handler._cleanup_wine_processes() + self.logger.info("Wine process cleanup command executed.") + else: + self.logger.warning("Protontricks handler or cleanup method not available, skipping cleanup.") + except Exception as cleanup_e: + self.logger.error(f"Error during final Wine process cleanup: {cleanup_e}", exc_info=True) + # Don't abort the whole workflow for a cleanup failure, just log it. + + # 18b. Display Completion Message + clear_status() + self._display_completion_message() + + # End of successful workflow + self.logger.info("Wabbajack installation workflow completed successfully.") + clear_status() # Clear status before final prompt + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + + def _display_summary(self): + """Displays a summary of settings (no confirmation prompt).""" + if not self.install_path or not self.shortcut_name: + self.logger.error("Cannot display summary: Install path or shortcut name missing.") + return False # Should not happen if called at the right time + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_PROMPT}--- Installation Summary ---{COLOR_RESET}") + print(f" Install Path: {self.install_path}") + print(f" Shortcut Name: {self.shortcut_name}") + print(f" Environment: {'Steam Deck' if self.steamdeck else 'Desktop Linux'}") + print(f" Protontricks: {self.protontricks_handler.which_protontricks or 'Unknown'}") + print("───────────────────────────────────────────────────────────────────") + return True + + def _backup_and_replace_final_reg_files(self) -> bool: + """Backs up current reg files and replaces them with the final downloaded versions.""" + if not self.compatdata_path: + self.logger.error("Cannot backup/replace reg files: compatdata_path not set.") + return False + + pfx_path = self.compatdata_path / 'pfx' + system_reg = pfx_path / 'system.reg' + user_reg = pfx_path / 'user.reg' + system_reg_bak = pfx_path / 'system.reg.orig' + user_reg_bak = pfx_path / 'user.reg.orig' + + # Backup existing files + self.logger.info("Backing up existing registry files...") + logger.info("Backing up current registry files...") + try: + if system_reg.exists(): + shutil.copy2(system_reg, system_reg_bak) + self.logger.debug(f"Backed up {system_reg} to {system_reg_bak}") + else: + self.logger.warning(f"Original {system_reg} not found for backup.") + + if user_reg.exists(): + shutil.copy2(user_reg, user_reg_bak) + self.logger.debug(f"Backed up {user_reg} to {user_reg_bak}") + else: + self.logger.warning(f"Original {user_reg} not found for backup.") + + except Exception as e: + self.logger.error(f"Error backing up registry files: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error backing up registry files: {e}{COLOR_RESET}") + return False # Treat backup failure as critical? + + # Define final registry file URLs + final_system_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/system.reg.github" + final_user_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/user.reg.github" + + # Download and replace + logger.info("Downloading and applying final registry settings...") + system_ok = self._download_and_replace_reg_file(final_system_reg_url, system_reg) + user_ok = self._download_and_replace_reg_file(final_user_reg_url, user_reg) + + if system_ok and user_ok: + self.logger.info("Successfully applied final registry files.") + return True + else: + self.logger.error("Failed to download or replace one or both final registry files.") + print(f"{COLOR_ERROR}Error: Failed to apply final registry settings.{COLOR_RESET}") + # Should we attempt to restore backups here? + return False + + def _install_webview(self) -> bool: + """Installs the WebView2 runtime using protontricks-launch.""" + if not self.final_appid or not self.install_path: + self.logger.error("Cannot install WebView: final_appid or install_path not set.") + return False + + installer_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + installer_path = self.install_path / installer_name + + if not installer_path.is_file(): + self.logger.error(f"WebView installer not found at {installer_path}. Cannot install.") + print(f"{COLOR_ERROR}Error: WebView installer file missing. Please ensure step 12 completed.{COLOR_RESET}") + return False + + self.logger.info(f"Starting WebView installation for AppID {self.final_appid}...") + # Remove print, handled by caller + # print("\nInstalling WebView (this can take a while, please be patient)...") + + cmd_prefix = [] + if self.protontricks_handler.which_protontricks == 'flatpak': + # Using full command path is safer than relying on alias being sourced + cmd_prefix = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks"] + else: + launch_path = shutil.which("protontricks-launch") + if not launch_path: + self.logger.error("protontricks-launch command not found in PATH.") + print(f"{COLOR_ERROR}Error: protontricks-launch command not found.{COLOR_RESET}") + return False + cmd_prefix = [launch_path] + + # Arguments for protontricks-launch + args = ["--appid", self.final_appid, str(installer_path), "/silent", "/install"] + full_cmd = cmd_prefix + args + + self.logger.debug(f"Executing WebView install command: {' '.join(full_cmd)}") + + try: + # Use check=True to raise CalledProcessError on non-zero exit + # Set a longer timeout as this can take time. + result = subprocess.run(full_cmd, check=True, capture_output=True, text=True, timeout=600) # 10 minute timeout + self.logger.info("WebView installation command completed successfully.") + # Do NOT log result.stdout or result.stderr here + return True + except FileNotFoundError: + self.logger.error(f"Command not found: {cmd_prefix[0]}") + print(f"{COLOR_ERROR}Error: Could not execute {cmd_prefix[0]}. Is it installed correctly?{COLOR_RESET}") + return False + except subprocess.TimeoutExpired: + self.logger.error("WebView installation timed out after 10 minutes.") + print(f"{COLOR_ERROR}Error: WebView installation took too long and timed out.{COLOR_RESET}") + return False + except subprocess.CalledProcessError as e: + self.logger.error(f"WebView installation failed with return code {e.returncode}") + # Only log a short snippet of output for debugging + self.logger.error(f"STDERR (truncated):\n{e.stderr[:500] if e.stderr else ''}") + print(f"{COLOR_ERROR}Error: WebView installation failed (Return Code: {e.returncode}). Check logs for details.{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Unexpected error during WebView installation: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred during WebView installation: {e}{COLOR_RESET}") + return False + + def _find_steam_library_and_vdf_path(self) -> Tuple[Optional[Path], Optional[Path]]: + """Finds the Steam library root and the path to the real libraryfolders.vdf.""" + self.logger.info("Attempting to find Steam library and libraryfolders.vdf...") + try: + # Check if PathHandler uses static methods or needs instantiation + if isinstance(self.path_handler, type): + common_path = self.path_handler.find_steam_library() + else: + common_path = self.path_handler.find_steam_library() + + if not common_path or not common_path.is_dir(): + self.logger.error("Could not find Steam library common path.") + return None, None + + # Navigate up to find the library root + library_root = common_path.parent.parent # steamapps/common -> steamapps -> library_root + self.logger.debug(f"Deduced library root: {library_root}") + + # Construct path to the real libraryfolders.vdf + # Common locations relative to library root + vdf_path_candidates = [ + library_root / 'config/libraryfolders.vdf', # For non-Flatpak? ~/.steam/steam/config + library_root / '../config/libraryfolders.vdf' # Flatpak? ~/.var/app/../Steam/config + ] + + real_vdf_path = None + for candidate in vdf_path_candidates: + resolved_candidate = candidate.resolve() # Resolve symlinks/.. parts + if resolved_candidate.is_file(): + real_vdf_path = resolved_candidate + self.logger.info(f"Found real libraryfolders.vdf at: {real_vdf_path}") + break + + if not real_vdf_path: + self.logger.error(f"Could not find libraryfolders.vdf within library root: {library_root}") + return None, None + + return library_root, real_vdf_path + + except Exception as e: + self.logger.error(f"Error finding Steam library/VDF: {e}", exc_info=True) + return None, None + + def _link_steam_library_config(self) -> bool: + """Creates the necessary directory structure and symlinks libraryfolders.vdf.""" + if not self.compatdata_path: + self.logger.error("Cannot link Steam library: compatdata_path not set.") + return False + + self.logger.info("Linking Steam library configuration (libraryfolders.vdf)...") + + library_root, real_vdf_path = self._find_steam_library_and_vdf_path() + if not library_root or not real_vdf_path: + print(f"{COLOR_ERROR}Error: Could not locate Steam library or libraryfolders.vdf.{COLOR_RESET}") + return False + + target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' + link_path = target_dir / 'libraryfolders.vdf' + + try: + # Backup the original libraryfolders.vdf before doing anything else + # Use FileSystemHandler for consistency - NOW USE INSTANCE + self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") + if not self.filesystem_handler.backup_file(real_vdf_path): + self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") + # Optionally, prompt user or fail here? For now, just warn. + print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") + + # Create the target directory + self.logger.debug(f"Creating directory: {target_dir}") + os.makedirs(target_dir, exist_ok=True) + + # Remove existing symlink if it exists + if link_path.is_symlink(): + self.logger.debug(f"Removing existing symlink at {link_path}") + link_path.unlink() + elif link_path.exists(): + # It exists but isn't a symlink - this is unexpected + self.logger.warning(f"Path {link_path} exists but is not a symlink. Removing it.") + if link_path.is_dir(): + shutil.rmtree(link_path) + else: + link_path.unlink() + + # Create the symlink + self.logger.info(f"Creating symlink from {real_vdf_path} to {link_path}") + os.symlink(real_vdf_path, link_path) + + # Verification (optional but good) + if link_path.is_symlink() and link_path.resolve() == real_vdf_path.resolve(): + self.logger.info("Symlink created and verified successfully.") + return True + else: + self.logger.error("Symlink creation failed or verification failed.") + return False + + except OSError as e: + self.logger.error(f"OSError during symlink creation: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error creating Steam library link: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Unexpected error during symlink creation: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _create_prefix_library_vdf(self) -> bool: + """Creates the necessary directory structure and copies a modified libraryfolders.vdf.""" + if not self.compatdata_path: + self.logger.error("Cannot create prefix VDF: compatdata_path not set.") + return False + + self.logger.info("Creating modified libraryfolders.vdf in prefix...") + + # 1. Find the real host VDF file + library_root, real_vdf_path = self._find_steam_library_and_vdf_path() + if not real_vdf_path: + # Error logged by _find_steam_library_and_vdf_path + print(f"{COLOR_ERROR}Error: Could not locate real libraryfolders.vdf.{COLOR_RESET}") + return False + + # 2. Backup the real VDF file + self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") + if not self.filesystem_handler.backup_file(real_vdf_path): + self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") + print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") + + # 3. Define target location in prefix + target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' + target_vdf_path = target_dir / 'libraryfolders.vdf' + + try: + # 4. Read the content of the real VDF + self.logger.debug(f"Reading content from {real_vdf_path}") + vdf_content = real_vdf_path.read_text(encoding='utf-8') + + # 5. Convert Linux paths to Wine paths within the content string + modified_content = vdf_content + # Regex to find "path" "/linux/path" entries reliably + path_pattern = re.compile(r'("path"\s*")([^"]+)(")') + + # Use a function for replacement logic to handle potential errors + def replace_path(match): + prefix, linux_path_str, suffix = match.groups() + self.logger.debug(f"Found path entry to convert: {linux_path_str}") + try: + linux_path = Path(linux_path_str) + # Check if it's an SD card path + if self.filesystem_handler.is_sd_card(linux_path): + # Assuming SD card maps to D: + # Remove prefix like /run/media/mmcblk0p1/ + relative_sd_path_str = self.filesystem_handler._strip_sdcard_path_prefix(linux_path) + wine_path = "D:\\" + relative_sd_path_str.replace('/', '\\') + self.logger.debug(f" Converted SD card path: {linux_path_str} -> {wine_path}") + else: + # Assume non-SD maps relative to Z: + # Need the full path prefixed with Z: + wine_path = "Z:\\" + linux_path_str.strip('/').replace('/', '\\') + self.logger.debug(f" Converted standard path: {linux_path_str} -> {wine_path}") + + # Ensure backslashes are doubled for VDF format + wine_path_vdf_escaped = wine_path.replace('\\', '\\\\') + return f'{prefix}{wine_path_vdf_escaped}{suffix}' + except Exception as e: + self.logger.error(f"Error converting path '{linux_path_str}': {e}. Keeping original.") + return match.group(0) # Return original match on error + + # Perform the replacement using re.sub with the function + modified_content = path_pattern.sub(replace_path, vdf_content) + + # Log comparison if content changed (optional) + if modified_content != vdf_content: + self.logger.info("Successfully converted Linux paths to Wine paths in VDF content.") + else: + self.logger.warning("VDF content unchanged after conversion attempt. Did it contain Linux paths?") + + # 6. Ensure target directory exists + self.logger.debug(f"Ensuring target directory exists: {target_dir}") + os.makedirs(target_dir, exist_ok=True) + + # 7. Write the modified content to the target file in the prefix + self.logger.info(f"Writing modified VDF content to {target_vdf_path}") + target_vdf_path.write_text(modified_content, encoding='utf-8') + + # 8. Verification (optional: check file exists and content) + if target_vdf_path.is_file(): + self.logger.info("Prefix libraryfolders.vdf created successfully.") + return True + else: + self.logger.error("Failed to create prefix libraryfolders.vdf.") + return False + + except Exception as e: + self.logger.error(f"Error processing or writing prefix libraryfolders.vdf: {e}", exc_info=True) + print(f"{COLOR_ERROR}An error occurred configuring the Steam library in the prefix: {e}{COLOR_RESET}") + return False + + def _create_dotnet_cache_dir(self) -> bool: + """Creates the dotnet_bundle_extract cache directory.""" + if not self.install_path: + self.logger.error("Cannot create dotnet cache dir: install_path not set.") + return False + + try: + # Get username reliably + username = pwd.getpwuid(os.getuid()).pw_name + # Fallback if pwd fails for some reason? + # username = os.getlogin() # Can fail in some environments + except Exception as e: + self.logger.error(f"Could not determine username: {e}") + print(f"{COLOR_ERROR}Error: Could not determine username to create cache directory.{COLOR_RESET}") + return False + + cache_dir = self.install_path / 'home' / username / '.cache' / 'dotnet_bundle_extract' + self.logger.info(f"Creating dotnet bundle cache directory: {cache_dir}") + + try: + os.makedirs(cache_dir, exist_ok=True) + # Optionally set permissions? The bash script didn't explicitly. + self.logger.info("dotnet cache directory created successfully.") + return True + except OSError as e: + self.logger.error(f"Failed to create dotnet cache directory {cache_dir}: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error creating dotnet cache directory: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Unexpected error creating dotnet cache directory: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _check_and_prompt_flatpak_overrides(self): + """Checks if Flatpak Steam needs filesystem overrides and prompts the user to apply them.""" + self.logger.info("Checking for necessary Flatpak Steam filesystem overrides...") + is_flatpak_steam = False + # Use compatdata_path as indicator + if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): + is_flatpak_steam = True + self.logger.debug("Flatpak Steam detected based on compatdata path.") + # Add other checks if needed (e.g., check if `flatpak info com.valvesoftware.Steam` runs) + + if not is_flatpak_steam: + self.logger.info("Flatpak Steam not detected, skipping override check.") + return + + paths_to_check = [] + if self.install_path: + paths_to_check.append(self.install_path) + + # Get all library paths from libraryfolders.vdf + try: + all_libs = self.path_handler.get_all_steam_libraries() + paths_to_check.extend(all_libs) + except Exception as e: + self.logger.warning(f"Could not get all Steam libraries to check for overrides: {e}") + + needed_overrides = set() # Use a set to store unique parent paths needing override + home_dir = Path.home() + flatpak_steam_data_dir = home_dir / ".var/app/com.valvesoftware.Steam" + + for path in paths_to_check: + if not path: + continue + resolved_path = path.resolve() + # Check if path is outside $HOME AND outside the Flatpak data dir + is_outside_home = not str(resolved_path).startswith(str(home_dir)) + is_outside_flatpak_data = not str(resolved_path).startswith(str(flatpak_steam_data_dir)) + + if is_outside_home and is_outside_flatpak_data: + # Need override for the parent directory containing this path + # Go up levels until we find a reasonable base (e.g., /mnt/Games, /data/Steam) + # Avoid adding /, /home, etc. + parent_to_add = resolved_path.parent + while parent_to_add != parent_to_add.parent and len(str(parent_to_add)) > 1 and parent_to_add.name != 'home': + # Check if adding this parent makes sense (e.g., it exists, not too high up) + if parent_to_add.is_dir(): # Simple check for existence + # Further heuristics could be added here + needed_overrides.add(str(parent_to_add)) + self.logger.debug(f"Path {resolved_path} is outside sandbox. Adding parent {parent_to_add} to needed overrides.") + break # Add the first reasonable parent found + parent_to_add = parent_to_add.parent + + if not needed_overrides: + self.logger.info("No external paths requiring Flatpak overrides detected.") + return + + # Construct the command string(s) + override_commands = [] + for path_str in sorted(list(needed_overrides)): + # Add specific path override + override_commands.append(f"flatpak override --user --filesystem=\"{path_str}\" com.valvesoftware.Steam") + + # Combine into a single string for display, but keep list for execution + command_display = "\n".join([f" {cmd}" for cmd in override_commands]) + + print(f"\n{COLOR_PROMPT}--- Flatpak Steam Permissions ---{COLOR_RESET}") + print("Jackify has detected that you are using Flatpak Steam and have paths") + print("(e.g., Wabbajack install location or other Steam libraries) outside") + print("the standard Flatpak sandbox. For Wabbajack to access these locations,") + print("Steam needs the following filesystem permissions:") + print(f"{COLOR_INFO}{command_display}{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + + try: + confirm = input(f"{COLOR_PROMPT}Do you want Jackify to apply these permissions now? (y/N): {COLOR_RESET}").lower().strip() + if confirm == 'y': + self.logger.info("User confirmed applying Flatpak overrides.") + success_count = 0 + for cmd_str in override_commands: + self.logger.info(f"Executing: {cmd_str}") + try: + # Split command string for subprocess + cmd_list = cmd_str.split() + result = subprocess.run(cmd_list, check=True, capture_output=True, text=True, timeout=30) + self.logger.debug(f"Override command successful: {result.stdout}") + success_count += 1 + except FileNotFoundError: + self.logger.error(f"'flatpak' command not found. Cannot apply override: {cmd_str}") + print(f"{COLOR_ERROR}Error: 'flatpak' command not found.{COLOR_RESET}") + break # Stop trying if flatpak isn't found + except subprocess.TimeoutExpired: + self.logger.error(f"Flatpak override command timed out: {cmd_str}") + print(f"{COLOR_ERROR}Error: Command timed out: {cmd_str}{COLOR_RESET}") + except subprocess.CalledProcessError as e: + self.logger.error(f"Flatpak override failed: {cmd_str}. Error: {e.stderr}") + print(f"{COLOR_ERROR}Error applying override: {cmd_str}\n{e.stderr}{COLOR_RESET}") + except Exception as e: + self.logger.error(f"Unexpected error applying override {cmd_str}: {e}") + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + + if success_count == len(override_commands): + print(f"{COLOR_INFO}Successfully applied necessary Flatpak permissions.{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Applied {success_count}/{len(override_commands)} permissions. Some overrides may have failed. Check logs.{COLOR_RESET}") + else: + self.logger.info("User declined applying Flatpak overrides.") + print("Permissions not applied. You may need to run the override command(s) manually") + print("if Wabbajack has issues accessing files or game installations.") + + except KeyboardInterrupt: + print("\nOperation cancelled by user.") + self.logger.warning("User cancelled during Flatpak override prompt.") + except Exception as e: + self.logger.error(f"Error during Flatpak override prompt/execution: {e}") + + def _disable_prefix_decoration(self) -> bool: + """Disables window manager decoration in the Wine prefix using protontricks -c.""" + if not self.final_appid: + self.logger.error("Cannot disable decoration: final_appid not set.") + return False + + self.logger.info(f"Disabling window manager decoration for AppID {self.final_appid} via -c 'wine reg add...'") + # Original command string + command = 'wine reg add "HKCU\\Software\\Wine\\X11 Driver" /v Decorated /t REG_SZ /d N /f' + + try: + # Ensure ProtontricksHandler is available + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized!") + print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") + return False + + # Use the original -c method + result = self.protontricks_handler.run_protontricks( + '-c', + command, + self.final_appid # AppID comes last for -c commands + ) + + # Check the return code + if result and result.returncode == 0: + self.logger.info("Successfully disabled window decoration (command returned 0).") + # Add a small delay just in case there's a write lag? + time.sleep(1) + return True + else: + err_msg = result.stderr if result else "Command execution failed or returned non-zero" + # Add stdout to error message if stderr is empty + if result and not result.stderr and result.stdout: + err_msg += f"\nSTDOUT: {result.stdout}" + self.logger.error(f"Failed to disable window decoration via -c. Error: {err_msg}") + print(f"{COLOR_ERROR}Error: Failed to disable window decoration via protontricks -c.{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Exception disabling window decoration: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error disabling window decoration: {e}.{COLOR_RESET}") + return False + + def _display_completion_message(self): + """Displays the final success message and next steps.""" + # Basic log file path (assuming standard location) + # TODO: Get log file path more reliably if needed + log_path = Path.home() / "Jackify" / "logs" / "jackify-cli.log" + + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Wabbajack Installation Completed Successfully!{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + print("Next Steps:") + print(f" • Launch '{COLOR_INFO}{self.shortcut_name or 'Wabbajack'}{COLOR_RESET}' through Steam.") + print(f" • When Wabbajack opens, log in to Nexus using the Settings button (cog icon).") + print(f" • Once logged in, you can browse and install modlists as usual!") + + # Check for Flatpak Steam (Placeholder check) + # A more robust check might involve inspecting self.path_handler findings or config + # For now, check if compatdata path hints at flatpak + is_flatpak_steam = False + if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): + is_flatpak_steam = True + + if is_flatpak_steam: + self.logger.info("Detected Flatpak Steam usage.") + print(f"\n{COLOR_PROMPT}Note: Flatpak Steam Detected:{COLOR_RESET}") + print(f" You may need to grant Wabbajack filesystem access for modlist downloads/installations.") + print(f" Example: If installing to \"/home/{os.getlogin()}/Games/SkyrimSEModlist\", run:") + print(f" {COLOR_INFO}flatpak override --user --filesystem=/home/{os.getlogin()}/Games com.valvesoftware.Steam{COLOR_RESET}") + + print(f"\nDetailed log available at: {log_path}") + print("───────────────────────────────────────────────────────────────────") + + def _download_wabbajack_executable(self) -> bool: + """ + Downloads the latest Wabbajack.exe to the install directory. + Checks existence first. + + Returns: + bool: True on success or if file exists, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot download Wabbajack.exe: install_path is not set.") + return False + + url = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe" + destination = self.install_path / "Wabbajack.exe" + + # Check if file exists first + if destination.is_file(): + self.logger.info(f"Wabbajack.exe already exists at {destination}. Skipping download.") + # print("Wabbajack.exe already present.") # Replaced by logger + return True + + # print(f"\nDownloading latest Wabbajack.exe...") # Replaced by logger + self.logger.info("Wabbajack.exe not found. Downloading...") + if self._download_file(url, destination): + # print("Wabbajack.exe downloaded successfully.") # Replaced by logger + # Set executable permissions + try: + os.chmod(destination, 0o755) + self.logger.info(f"Set execute permissions on {destination}") + except Exception as e: + self.logger.warning(f"Could not set execute permission on {destination}: {e}") + print(f"{COLOR_ERROR}Warning: Could not set execute permission on Wabbajack.exe.{COLOR_RESET}") + return True + else: + self.logger.error("Failed to download Wabbajack.exe.") + # Error message printed by _download_file + return False + + def _create_steam_shortcut(self) -> bool: + """ + Creates the Steam shortcut for Wabbajack using the ShortcutHandler. + + Returns: + bool: True on success, False otherwise. + """ + if not self.shortcut_name or not self.install_path: + self.logger.error("Cannot create shortcut: Missing shortcut name or install path.") + return False + + self.logger.info(f"Creating Steam shortcut '{self.shortcut_name}'...") + executable_path = str(self.install_path / "Wabbajack.exe") + + # Ensure the ShortcutHandler instance exists + # 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=self.shortcut_name, + exe_path=executable_path, + start_dir=os.path.dirname(executable_path), + launch_options="PROTON_USE_WINED3D=1 %command%", + tags=["Jackify", "Wabbajack"], + proton_version="proton_experimental" + ) + + if success and app_id: + self.initial_appid = app_id # Store the initially generated AppID + self.logger.info(f"Shortcut created successfully with initial AppID: {self.initial_appid}") + # Remove direct print, rely on status indicator from caller + # print(f"Steam shortcut '{self.shortcut_name}' created.") + return True + else: + self.logger.error("Failed to create Steam shortcut via ShortcutHandler.") + print(f"{COLOR_ERROR}Error: Failed to create the Steam shortcut for Wabbajack.{COLOR_RESET}") + # Further error details should be logged by the ShortcutHandler + return False + + # --- Helper Methods for Workflow Steps --- + + def _display_manual_proton_steps(self): + """Displays the detailed manual steps required for Proton setup.""" + if not self.shortcut_name: + self.logger.error("Cannot display manual steps: shortcut_name not set.") + print(f"{COLOR_ERROR}Internal Error: Shortcut name missing.{COLOR_RESET}") + return + + print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}") + print("Please complete the following steps in Steam:") + print(f" 1. Locate the '{COLOR_INFO}{self.shortcut_name}{COLOR_RESET}' entry in your Steam Library") + print(" 2. Right-click and select 'Properties'") + print(" 3. Switch to the 'Compatibility' tab") + print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'") + print(" 5. Select 'Proton - Experimental' from the dropdown menu") + print(" 6. Close the Properties window") + print(f" 7. Launch '{COLOR_INFO}{self.shortcut_name}{COLOR_RESET}' from your Steam Library") + print(" 8. Wait for Wabbajack to download its files and fully load") + print(" 9. Once Wabbajack has fully loaded, CLOSE IT completely and return here") + print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}") + + def _handle_steam_restart_and_manual_steps(self) -> bool: + """ + Handles Steam restart and manual steps prompt, with GUI mode support. + """ + self.logger.info("Handling Steam restart and manual steps prompt.") + clear_status() + + if os.environ.get('JACKIFY_GUI_MODE'): + # GUI mode: emit prompt markers like ModlistMenuHandler does + print('[PROMPT:RESTART_STEAM]') + input() # Wait for GUI to send confirmation + print('[PROMPT:MANUAL_STEPS]') + input() # Wait for GUI to send confirmation + # Continue with verification as before + return True + else: + # CLI mode: original behavior + # Condensed message: only show essential manual steps guidance + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} After Steam restarts, follow the on-screen instructions to set Proton Experimental.") + print("───────────────────────────────────────────────────────────────────") + self.logger.info("Attempting secure Steam restart...") + show_status("Restarting Steam") + if not hasattr(self, 'shortcut_handler') or not self.shortcut_handler: + self.logger.critical("ShortcutHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Shortcut handler not available for restart.{COLOR_RESET}") + return False + if self.shortcut_handler.secure_steam_restart(): + self.logger.info("Secure Steam restart successful.") + clear_status() + self._display_manual_proton_steps() + print() + input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") + self.logger.info("User confirmed completion of manual steps.") + return True + else: + self.logger.error("Secure Steam restart failed.") + clear_status() + print(f"\n{COLOR_ERROR}Error: Steam restart failed.{COLOR_RESET}") + print("Please try restarting Steam manually:") + print("1. Exit Steam completely (Steam -> Exit or right-click tray icon -> Exit)") + print("2. Wait a few seconds") + print("3. Start Steam again") + print("\nAfter restarting, you MUST perform the manual Proton setup steps:") + self._display_manual_proton_steps() + print(f"\n{COLOR_ERROR}You will need to re-run this Jackify option after completing these steps.{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + return False + + def _redetect_appid(self) -> bool: + """ + Re-detects the AppID for the shortcut after Steam restart. + + Returns: + bool: True if AppID is found, False otherwise. + """ + if not self.shortcut_name: + self.logger.error("Cannot redetect AppID: shortcut_name not set.") + return False + + self.logger.info(f"Re-detecting AppID for shortcut '{self.shortcut_name}'...") + try: + # Ensure the ProtontricksHandler instance exists + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") + return False + + all_shortcuts = self.protontricks_handler.list_non_steam_shortcuts() + + if not all_shortcuts: + self.logger.error("Protontricks listed no non-Steam shortcuts.") + return False + + found_appid = None + for name, appid in all_shortcuts.items(): + if name.lower() == self.shortcut_name.lower(): + found_appid = appid + break + + if found_appid: + self.final_appid = found_appid + self.logger.info(f"Successfully re-detected AppID: {self.final_appid}") + if self.initial_appid and self.initial_appid != self.final_appid: + # Change Warning to Info - this is expected behavior + self.logger.info(f"AppID changed after Steam restart: {self.initial_appid} -> {self.final_appid}") + elif not self.initial_appid: + self.logger.warning("Initial AppID was not set, cannot compare.") + return True + else: + self.logger.error(f"Shortcut '{self.shortcut_name}' not found in protontricks list after restart.") + return False + + except Exception as e: + self.logger.error(f"Error re-detecting AppID: {e}", exc_info=True) + return False + + def _find_steam_config_vdf(self) -> Optional[Path]: + """Finds the path to the primary Steam config.vdf file.""" + self.logger.debug("Searching for Steam config.vdf...") + # Use PathHandler if it has this logic? For now, check common paths. + common_paths = [ + Path.home() / ".steam/steam/config/config.vdf", + Path.home() / ".local/share/Steam/config/config.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.config/Valve Corporation/Steam/config/config.vdf" # Check Flatpak path + ] + for path in common_paths: + if path.is_file(): + self.logger.info(f"Found config.vdf at: {path}") + return path + self.logger.error("Could not find Steam config.vdf in common locations.") + return None + + def _verify_manual_steps(self) -> bool: + """ + Verifies that the user has performed the manual steps using ModlistHandler. + Checks AppID, Proton version set, and prefix existence. + + Returns: + bool: True if verification passes AND compatdata_path is set, False otherwise. + """ + self.logger.info("Verifying manual Proton setup steps...") + self.compatdata_path = None # Explicitly reset before verification + + # 1. Re-detect AppID + # Clear status BEFORE potentially failing here + clear_status() + if not self._redetect_appid(): + print(f"{COLOR_ERROR}Error: Could not find the Steam shortcut '{self.shortcut_name}' using protontricks.{COLOR_RESET}") + print(f"{COLOR_INFO}Ensure Steam has restarted and the shortcut is visible.{COLOR_RESET}") + return False # Indicate failure + + self.logger.debug(f"Verification using final AppID: {self.final_appid}") + + # Add padding after user confirmation before the next status update + # Removed print() call - padding should come AFTER status clear + + # Print status JUST before calling the verification logic + show_status("Verifying Proton Setup") + + # Ensure ModlistHandler is available + if not hasattr(self, 'modlist_handler') or not self.modlist_handler: + self.logger.critical("ModlistHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Modlist handler not available for verification.{COLOR_RESET}") + return False + + # 2. Call the existing verification logic from ModlistHandler + verified, status_code = self.modlist_handler.verify_proton_setup(self.final_appid) + + if not verified: + # Handle Verification Failure Messages based on status_code + if status_code == 'wrong_proton_version': + proton_ver = getattr(self.modlist_handler, 'proton_ver', 'Unknown') + print(f"{COLOR_ERROR}\nVerification Failed: Incorrect Proton version detected ('{proton_ver}'). Expected 'Proton Experimental' (or similar).{COLOR_RESET}") + print(f"{COLOR_INFO}Please ensure you selected the correct Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") + elif status_code == 'proton_check_failed': + print(f"{COLOR_ERROR}\nVerification Failed: Compatibility tool not detected as set for '{self.shortcut_name}' in Steam config.{COLOR_RESET}") + print(f"{COLOR_INFO}Please ensure you forced a Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") + elif status_code == 'compatdata_missing': + print(f"{COLOR_ERROR}\nVerification Failed: Steam compatdata directory for AppID {self.final_appid} not found.{COLOR_RESET}") + print(f"{COLOR_INFO}Have you launched the shortcut '{self.shortcut_name}' at least once after setting Proton?{COLOR_RESET}") + elif status_code == 'prefix_missing': + print(f"{COLOR_ERROR}\nVerification Failed: Wine prefix directory (pfx) not found inside compatdata.{COLOR_RESET}") + print(f"{COLOR_INFO}This usually means the shortcut hasn't been launched successfully after setting Proton.{COLOR_RESET}") + elif status_code == 'config_vdf_missing' or status_code == 'config_vdf_error': + print(f"{COLOR_ERROR}\nVerification Failed: Could not read or parse Steam's config.vdf file ({status_code}).{COLOR_RESET}") + print(f"{COLOR_INFO}Check file permissions or integrity. Check logs for details.{COLOR_RESET}") + else: # General/unknown error + print(f"{COLOR_ERROR}\nVerification Failed: An unexpected error occurred ({status_code}). Check logs.{COLOR_RESET}") + return False # Indicate verification failure + + # If we reach here, basic verification passed (proton set, prefix exists) + # Now, ensure we have the compatdata path. + self.logger.info("Basic verification checks passed. Confirming compatdata path...") + + modlist_handler_compat_path = getattr(self.modlist_handler, 'compat_data_path', None) + if modlist_handler_compat_path: + self.compatdata_path = modlist_handler_compat_path + self.logger.info(f"Compatdata path obtained from ModlistHandler: {self.compatdata_path}") + else: + # If modlist_handler didn't set it, try path_handler + # Change Warning to Info - Fallback is acceptable + self.logger.info("ModlistHandler did not set compat_data_path. Attempting manual lookup via PathHandler.") + # Ensure path_handler is available + if not hasattr(self, 'path_handler') or not self.path_handler: + self.logger.critical("PathHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Path handler not available for verification.{COLOR_RESET}") + return False + + self.compatdata_path = self.path_handler.find_compat_data(self.final_appid) + if self.compatdata_path: + self.logger.info(f"Manually found compatdata path via PathHandler: {self.compatdata_path}") + else: + self.logger.error("Verification checks passed, but COULD NOT FIND compatdata path via ModlistHandler or PathHandler.") + print(f"{COLOR_ERROR}\nVerification Error: Basic checks passed, but failed to locate the compatdata directory for AppID {self.final_appid}.{COLOR_RESET}") + print(f"{COLOR_INFO}This is unexpected. Check Steam filesystem structure and logs.{COLOR_RESET}") + return False # CRITICAL: Return False if path is unobtainable + + # If we get here, verification passed AND we have the compatdata_path + self.logger.info("Manual steps verification successful (including path confirmation).") + logger.info(f"Verification successful! (AppID: {self.final_appid}, Path: {self.compatdata_path})") + return True + + def _download_webview_installer(self) -> bool: + """ + Downloads the specific WebView2 installer needed by Wabbajack. + Checks existence first. + + Returns: + bool: True on success or if file already exists correctly, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot download WebView installer: install_path is not set.") + return False + + url = "https://node10.sokloud.com/filebrowser/api/public/dl/yqVTbUT8/rwatch/WebView/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + file_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + destination = self.install_path / file_name + + self.logger.info(f"Checking WebView installer: {destination}") + # print(f"\nChecking required WebView installer ({file_name})...") # Replaced by logger + + if destination.is_file(): + self.logger.info(f"WebView installer {destination.name} already exists. Skipping download.") + # Consider adding a message here if verbose/debug? + return True + + # File doesn't exist, attempt download + self.logger.info(f"WebView installer not found locally. Downloading {file_name}...") + # Update status before starting download - Use a more user-friendly message + show_status("Downloading WebView Installer") + + if self._download_file(url, destination): + # Status will be cleared by caller or next step + return True + else: + self.logger.error(f"Failed to download WebView installer from {url}.") + # Error message already printed by _download_file + return False + + def _set_prefix_renderer(self, renderer: str = 'vulkan') -> bool: + """Sets the prefix renderer using protontricks.""" + if not self.final_appid: + self.logger.error("Cannot set renderer: final_appid not set.") + return False + + self.logger.info(f"Setting prefix renderer to {renderer} for AppID {self.final_appid}...") + try: + # Ensure the ProtontricksHandler instance exists + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") + return False + + result = self.protontricks_handler.run_protontricks( + self.final_appid, + 'settings', + f'renderer={renderer}' + ) + if result and result.returncode == 0: + self.logger.info(f"Successfully set renderer to {renderer}.") + return True + else: + err_msg = result.stderr if result else "Command execution failed" + self.logger.error(f"Failed to set renderer to {renderer}. Error: {err_msg}") + print(f"{COLOR_ERROR}Error: Failed to set prefix renderer to {renderer}.{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Exception setting renderer: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error setting prefix renderer: {e}.{COLOR_RESET}") + return False + + def _download_and_replace_reg_file(self, url: str, target_reg_path: Path) -> bool: + """Downloads a .reg file and replaces the target file. + Always downloads and overwrites. + """ + self.logger.info(f"Downloading registry file from {url} to replace {target_reg_path}") + + # Always download and replace for registry files + if self._download_file(url, target_reg_path): + self.logger.info(f"Successfully downloaded and replaced {target_reg_path}") + return True + else: + self.logger.error(f"Failed to download/replace {target_reg_path} from {url}") + return False + +# Example usage (for testing - keep this section for easy module testing) +if __name__ == '__main__': + # Configure logging for standalone testing + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + print("Testing Wabbajack Install Handler...") + # Simulate running on or off deck + test_on_deck = False + print(f"Simulating run with steamdeck={test_on_deck}") + + # Need dummy handlers for direct testing + class DummyProton: + which_protontricks = 'native' + def check_and_setup_protontricks(self): return True + def set_protontricks_permissions(self, path, steamdeck): return True + def enable_dotfiles(self, appid): return True + def _cleanup_wine_processes(self): pass + def run_protontricks(self, *args, **kwargs): return subprocess.CompletedProcess(args=[], returncode=0) + def list_non_steam_shortcuts(self): return {"Wabbajack": "12345"} + + class DummyShortcut: + def create_shortcut(self, *args, **kwargs): return True, "12345" + def secure_steam_restart(self): return True + + class DummyPath: + def find_compat_data(self, appid): return Path(f"/tmp/jackify_test/compatdata/{appid}") + def find_steam_library(self): return Path("/tmp/jackify_test/steam/steamapps/common") + + class DummyVDF: + @staticmethod + def load(path): + if "config.vdf" in str(path): + # Simulate structure needed for proton check + return {'UserLocalConfigStore': {'Software': {'Valve': {'Steam': {'apps': {'12345': {'CompatTool': 'proton_experimental'}}}}}}} + return {} + + handler = InstallWabbajackHandler( + steamdeck=test_on_deck, + protontricks_handler=DummyProton(), + shortcut_handler=DummyShortcut(), + path_handler=DummyPath(), + vdf_handler=DummyVDF(), + modlist_handler=ModlistHandler(), + filesystem_handler=FileSystemHandler() + ) + # Pre-create dummy compatdata dir for verification step + if not Path("/tmp/jackify_test/compatdata/12345/pfx").exists(): + os.makedirs("/tmp/jackify_test/compatdata/12345/pfx", exist_ok=True) + + handler.run_install_workflow() + + print("\nTesting completed.") \ No newline at end of file diff --git a/jackify/backend/handlers/logging_handler.py b/jackify/backend/handlers/logging_handler.py new file mode 100644 index 0000000..5210e9e --- /dev/null +++ b/jackify/backend/handlers/logging_handler.py @@ -0,0 +1,189 @@ +""" +LoggingHandler module for managing logging operations. +This module handles log file creation, rotation, and management. +""" + +import os +import logging +import logging.handlers +from pathlib import Path +from typing import Optional, Dict, List +from datetime import datetime +import shutil + +class LoggingHandler: + """ + Central logging handler for Jackify. + - Uses ~/Jackify/logs/ as the log directory. + - 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" + self.ensure_log_directory() + + def ensure_log_directory(self) -> None: + """Ensure the log directory exists.""" + try: + self.log_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + print(f"Failed to create log directory: {e}") + + def rotate_log_file_per_run(self, log_file_path: Path, backup_count: int = 5): + """Rotate the log file on every run, keeping up to backup_count backups.""" + if log_file_path.exists(): + # Remove the oldest backup if it exists + oldest = log_file_path.with_suffix(log_file_path.suffix + f'.{backup_count}') + if oldest.exists(): + oldest.unlink() + # Shift backups + for i in range(backup_count - 1, 0, -1): + src = log_file_path.with_suffix(log_file_path.suffix + f'.{i}') + dst = log_file_path.with_suffix(log_file_path.suffix + f'.{i+1}') + if src.exists(): + src.rename(dst) + # Move current log to .1 + log_file_path.rename(log_file_path.with_suffix(log_file_path.suffix + '.1')) + + def rotate_log_for_logger(self, name: str, log_file: Optional[str] = None, backup_count: int = 5): + """ + Rotate the log file for a logger before any logging occurs. + Must be called BEFORE any log is written or file handler is attached. + """ + file_path = self.log_dir / (log_file if log_file else "jackify-cli.log") + self.rotate_log_file_per_run(file_path, backup_count=backup_count) + + def setup_logger(self, name: str, log_file: Optional[str] = None, is_general: bool = False) -> logging.Logger: + """Set up a logger with file and console handlers. Call rotate_log_for_logger before this if you want per-run rotation.""" + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + logger.propagate = False + + # Create formatters + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + console_formatter = logging.Formatter( + '%(levelname)s: %(message)s' + ) + + # Add console handler (ERROR and above only) + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.ERROR) + console_handler.setFormatter(console_formatter) + if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers): + logger.addHandler(console_handler) + + # Add file handler if log_file is specified, or use default for general + if log_file or is_general: + file_path = self.log_dir / (log_file if log_file else "jackify-cli.log") + file_handler = logging.handlers.RotatingFileHandler( + file_path, mode='a', encoding='utf-8', maxBytes=1024*1024, backupCount=5 + ) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(file_formatter) + if not any(isinstance(h, logging.handlers.RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(file_path) for h in logger.handlers): + logger.addHandler(file_handler) + + return logger + + def rotate_logs(self, max_bytes: int = 1024 * 1024, backup_count: int = 5) -> None: + """Rotate log files based on size.""" + for log_file in self.get_log_files(): + try: + if log_file.stat().st_size > max_bytes: + # Create backup + backup_path = log_file.with_suffix(f'.{datetime.now().strftime("%Y%m%d_%H%M%S")}.log') + log_file.rename(backup_path) + + # Clean up old backups + backups = sorted(log_file.parent.glob(f"{log_file.stem}.*.log")) + if len(backups) > backup_count: + for old_backup in backups[:-backup_count]: + old_backup.unlink() + except Exception as e: + print(f"Failed to rotate log file {log_file}: {e}") + + def cleanup_old_logs(self, days: int = 30) -> None: + """Clean up log files older than specified days.""" + cutoff = datetime.now().timestamp() - (days * 24 * 60 * 60) + for log_file in self.get_log_files(): + try: + if log_file.stat().st_mtime < cutoff: + log_file.unlink() + except Exception as e: + print(f"Failed to clean up log file {log_file}: {e}") + + def get_log_files(self) -> List[Path]: + """Get a list of all log files.""" + return list(self.log_dir.glob("*.log")) + + def get_log_content(self, log_file: Path, lines: int = 100) -> List[str]: + """Get the last N lines of a log file.""" + try: + with open(log_file, 'r') as f: + return f.readlines()[-lines:] + except Exception as e: + print(f"Failed to read log file {log_file}: {e}") + return [] + + def search_logs(self, pattern: str) -> Dict[Path, List[str]]: + """Search all log files for a pattern.""" + results = {} + for log_file in self.get_log_files(): + try: + with open(log_file, 'r') as f: + matches = [line for line in f if pattern in line] + if matches: + results[log_file] = matches + except Exception as e: + print(f"Failed to search log file {log_file}: {e}") + return results + + def export_logs(self, output_dir: Path) -> bool: + """Export all logs to a directory.""" + try: + output_dir.mkdir(parents=True, exist_ok=True) + for log_file in self.get_log_files(): + shutil.copy2(log_file, output_dir / log_file.name) + return True + except Exception as e: + print(f"Failed to export logs: {e}") + return False + + def set_log_level(self, level: int) -> None: + """Set the logging level for all loggers.""" + for logger_name in logging.root.manager.loggerDict: + logger = logging.getLogger(logger_name) + logger.setLevel(level) + + def get_log_stats(self) -> Dict: + """Get statistics about log files.""" + stats = { + 'total_files': 0, + 'total_size': 0, + 'largest_file': None, + 'oldest_file': None, + 'newest_file': None + } + + try: + log_files = self.get_log_files() + stats['total_files'] = len(log_files) + + if log_files: + stats['total_size'] = sum(f.stat().st_size for f in log_files) + stats['largest_file'] = max(log_files, key=lambda x: x.stat().st_size) + stats['oldest_file'] = min(log_files, key=lambda x: x.stat().st_mtime) + stats['newest_file'] = max(log_files, key=lambda x: x.stat().st_mtime) + + except Exception as e: + print(f"Failed to get log stats: {e}") + + return stats + + def get_general_logger(self): + """Get the general CLI logger (~/Jackify/logs/jackify-cli.log).""" + return self.setup_logger('jackify_cli', is_general=True) \ No newline at end of file diff --git a/jackify/backend/handlers/menu_handler.py b/jackify/backend/handlers/menu_handler.py new file mode 100644 index 0000000..5ca21be --- /dev/null +++ b/jackify/backend/handlers/menu_handler.py @@ -0,0 +1,1106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Menu Handler Module +Handles CLI menu system for Jackify +""" + +import os +import sys +import logging +import time +import subprocess # Add subprocess import +# from datetime import datetime # Not used currently +import argparse +import re +from typing import List, Dict, Optional +from pathlib import Path +import glob # Add for the simpler tab completion + +# Import colors from the new central location +from .ui_colors import ( + COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR, + COLOR_SUCCESS, COLOR_WARNING, COLOR_DISABLED, COLOR_ACTION, COLOR_INPUT +) + +# Import our modules +# Ensure these imports are correct based on your project structure +from .modlist_handler import ModlistHandler +from .shortcut_handler import ShortcutHandler +from .config_handler import ConfigHandler +from .filesystem_handler import FileSystemHandler +from .resolution_handler import ResolutionHandler +from .protontricks_handler import ProtontricksHandler +from .path_handler import PathHandler +from .vdf_handler import VDFHandler +from .mo2_handler import MO2Handler +from jackify.shared.ui_utils import print_section_header +from .completers import path_completer + +# Define exports for this module +__all__ = [ + 'MenuHandler', + 'ModlistMenuHandler', + 'simple_path_completer' # Export the function without underscore +] + +# Initialize logger +logger = logging.getLogger(__name__) + +# --- Input Handling with Readline Tab Completion --- +# Simple function for basic input +def basic_input_prompt(message, **kwargs): + return input(message) + +# --- Readline for tab completion --- +READLINE_AVAILABLE = False +READLINE_HAS_PROMPT = False +READLINE_HAS_DISPLAY_HOOK = False +try: + import readline + READLINE_AVAILABLE = True + logging.debug("Readline imported for tab completion") + + # Check for the specific features we want to use + if hasattr(readline, 'set_prompt'): + READLINE_HAS_PROMPT = True + logging.debug("Readline has set_prompt capability") + else: + logging.debug("Readline does not have set_prompt capability, will use fallback") + + # Test readline tab completion functionality + try: + # Try to parse tab configuration to confirm readline is properly configured + readline.parse_and_bind('tab: complete') + logging.debug("Readline tab completion successfully configured") + except Exception as e: + logging.warning(f"Error configuring readline tab completion: {e}. Tab completion may be limited.") + + # Set better readline behavior for displaying completions if available + if hasattr(readline, 'set_completion_display_matches_hook'): + READLINE_HAS_DISPLAY_HOOK = True + logging.debug("Readline has completion display hook capability") + + def custom_display_completions(substitution, matches, longest_match_length): + """Custom function to display completions with better formatting""" + # Print a newline to avoid overwriting the prompt + print() + # Get terminal width + try: + import shutil + term_width = shutil.get_terminal_size().columns + except (ImportError, AttributeError): + term_width = 80 # Default fallback + + # Calculate how many completions to display per line + items_per_line = max(1, term_width // (longest_match_length + 2)) + + # Print completions in columns + for i, match in enumerate(matches): + print(f"{match:<{longest_match_length + 2}}", end='' if (i + 1) % items_per_line else '\n') + + if len(matches) % items_per_line != 0: + print() # Ensure we end with a newline + + # Re-display the prompt with the current input - use the safe approach + current_input = readline.get_line_buffer() + # Use the visual prompt string which may not be exactly what readline knows as the prompt + print(f"{COLOR_PROMPT}> {COLOR_RESET}{current_input}", end='', flush=True) + + try: + # Set the custom display function + readline.set_completion_display_matches_hook(custom_display_completions) + logging.debug("Custom completion display hook successfully set") + except Exception as e: + logging.warning(f"Error setting completion display hook: {e}. Using default display behavior.") + READLINE_HAS_DISPLAY_HOOK = False + else: + logging.debug("Readline doesn't have completion display hook capability, using default") +except ImportError: + READLINE_AVAILABLE = False + READLINE_HAS_PROMPT = False + READLINE_HAS_DISPLAY_HOOK = False + logging.warning("readline not available. Tab completion for paths will be disabled.") +except Exception as e: + READLINE_AVAILABLE = False + READLINE_HAS_PROMPT = False + READLINE_HAS_DISPLAY_HOOK = False + logging.warning(f"Error initializing readline: {e}. Tab completion for paths will be disabled.") + +# --- DEBUG PRINT --- +# --- END DEBUG PRINT --- + +class ModlistMenuHandler: + """ + Handles modlist-specific menu operations + """ + + def __init__(self, config_handler, test_mode=False): + """Initialize the ModlistMenuHandler with configuration""" + + self.config_handler = config_handler + self.test_mode = test_mode + self.exit_flag = False + self.logger = logging.getLogger(__name__) + + # Initialize handlers + try: + # Initialize filesystem handler first, others may depend on it + self.filesystem_handler = FileSystemHandler() + + # Initialize basic handlers + 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) + + # Create the resolution handler + self.resolution_handler = ResolutionHandler() + + # Initialize menu handler for consistent UI + self.menu_handler = MenuHandler() + + # Initialize modlist handler + self.modlist_handler = ModlistHandler( + self.config_handler.settings, + steamdeck=self.steamdeck, + verbose=False, + filesystem_handler=self.filesystem_handler + ) + + self.shortcut_handler = self.modlist_handler.shortcut_handler + + # Initialize the wabbajack installation handler + self.install_wabbajack_handler = None + + except Exception as e: + self.logger.error(f"Error initializing ModlistMenuHandler: {e}") + # Initialize with defaults/empty to prevent errors + self.filesystem_handler = FileSystemHandler() + self.steamdeck = False + self.modlist_handler = None + + def show_modlist_menu(self): + while True: + os.system('cls' if os.name == 'nt' else 'clear') + # Banner display handled by frontend + print_section_header('Modlist Configuration') + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Configure a New modlist not yet in Steam") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure a modlist already in Steam") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") + choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}") + if choice == "1": + if not self._configure_new_modlist(): + return False + elif choice == "2": + if not self._configure_existing_modlist(): + return False + elif choice == "0": + logger.info("Returning to main menu from Modlist Configuration menu.") + return False + else: + logger.warning(f"Invalid menu selection: {choice}") + print("\nInvalid selection. Please try again.") + input("\nPress Enter to continue...") + + def _display_manual_proton_steps(self, modlist_name): + """Displays the detailed manual steps required for Proton setup.""" + # Keep these as print for clear user instructions + print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}") + print("Please complete the following steps in Steam:") + print(f" 1. Locate the '{COLOR_INFO}{modlist_name}{COLOR_RESET}' entry in your Steam Library") + print(" 2. Right-click and select 'Properties'") + print(" 3. Switch to the 'Compatibility' tab") + print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'") + print(" 5. Select 'Proton - Experimental' from the dropdown menu") + print(" 6. Close the Properties window") + print(f" 7. Launch '{COLOR_INFO}{modlist_name}{COLOR_RESET}' from your Steam Library") + print(" 8. If Mod Organizer opens or produces any error message, that's normal") + print(" 9. No matter what,CLOSE Mod Organizer completely and return here") + print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}") + + def _get_mo2_path(self) -> Optional[str]: + """ + Get the path to ModOrganizer.exe from user input. + Returns the validated path or None if cancelled/invalid. + """ + self.logger.info("Prompting for ModOrganizer.exe path...") + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}Please provide the path to ModOrganizer.exe for your modlist.{COLOR_RESET}") + print(f"{COLOR_INFO}This is typically found in the modlist's installation directory.") + print(f"{COLOR_INFO}Example: ~/Games/MyModlist/ModOrganizer.exe") + print(f"{COLOR_INFO}You can also provide the path to the directory containing ModOrganizer.exe.") + + # Use the menu_handler's get_existing_file_path for consistency if self.menu_handler is available + # Note: self.menu_handler here is an instance of MenuHandler, not ModlistMenuHandler + if hasattr(self, 'menu_handler') and self.menu_handler is not None: + # get_existing_file_path will use its own standard prompting style internally + # We pass no_header=False so it shows its full prompt. + # The prompt_message here becomes the main instruction for get_existing_file_path. + path_result = self.menu_handler.get_existing_file_path( + prompt_message=f"Path to ModOrganizer.exe or its directory", + extension_filter=".exe", + no_header=False # Let get_existing_file_path handle its full prompt including separator + ) + if path_result is None: # User cancelled + self.logger.info("User cancelled ModOrganizer.exe path input via get_existing_file_path.") + return None + + path_str = str(path_result) + if os.path.isdir(path_str): + potential_mo2_path = os.path.join(path_str, "ModOrganizer.exe") + if os.path.isfile(potential_mo2_path): + self.logger.info(f"Found ModOrganizer.exe in directory: {potential_mo2_path}") + return potential_mo2_path + else: + print(f"\n{COLOR_ERROR}Error: ModOrganizer.exe not found in directory: {path_str}{COLOR_RESET}") + # Allow to try again - this might need a loop or rely on get_existing_file_path loop + return self._get_mo2_path() # Recursive call to try again, simple loop better + elif os.path.isfile(path_str) and os.path.basename(path_str).lower() == "modorganizer.exe": + self.logger.info(f"ModOrganizer.exe path validated: {path_str}") + return path_str + else: + print(f"\n{COLOR_ERROR}Error: Path is not ModOrganizer.exe or a directory containing it.{COLOR_RESET}") + return self._get_mo2_path() # Recursive call + + # Fallback to basic input if self.menu_handler is not available (should ideally not happen) + self.logger.warning("_get_mo2_path: self.menu_handler not available, using basic input as fallback.") + while True: + try: + # Basic input prompt if menu_handler isn't used + mo2_path_input = input(f"{COLOR_PROMPT}Enter path to ModOrganizer.exe (or 'q' to cancel): {COLOR_RESET}").strip() + if mo2_path_input.lower() == 'q': + self.logger.info("User cancelled ModOrganizer.exe path input (fallback).") + return None + + expanded_path = os.path.expanduser(mo2_path_input) + normalized_path = os.path.normpath(expanded_path) + + if os.path.isdir(normalized_path): + potential_mo2_path = os.path.join(normalized_path, "ModOrganizer.exe") + if os.path.isfile(potential_mo2_path): + self.logger.info(f"Found ModOrganizer.exe in directory (fallback): {potential_mo2_path}") + return potential_mo2_path + else: + print(f"{COLOR_ERROR}Error: ModOrganizer.exe not found in directory: {normalized_path}{COLOR_RESET}") + continue + + if not normalized_path.lower().endswith('modorganizer.exe'): + print(f"{COLOR_ERROR}Error: Path must be ModOrganizer.exe or a directory containing it.{COLOR_RESET}") + continue + if not os.path.isfile(normalized_path): + print(f"{COLOR_ERROR}Error: File does not exist: {normalized_path}{COLOR_RESET}") + continue + + self.logger.info(f"ModOrganizer.exe path validated (fallback): {normalized_path}") + return normalized_path + except KeyboardInterrupt: + print("\nOperation cancelled.") + self.logger.info("User cancelled ModOrganizer.exe path input via Ctrl+C (fallback).") + return None + except Exception as e: + self.logger.error(f"Error processing ModOrganizer.exe path (fallback): {e}") + print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}") + return None + + def _get_modlist_name(self) -> Optional[str]: + """ + Get the modlist name from user input. + Returns the validated name or None if cancelled. + """ + self.logger.info("Prompting for modlist name...") + + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}Please provide a name for your modlist.{COLOR_RESET}") + print(f"{COLOR_INFO}(This will be the name used for the Steam shortcut.){COLOR_RESET}") + + while True: + try: + modlist_name = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() + + if modlist_name.lower() == 'q': + self.logger.info("User cancelled modlist name input.") + return None + + if not modlist_name: + print(f"{COLOR_ERROR}Error: Name cannot be empty.{COLOR_RESET}") + continue + + if len(modlist_name) > 100: + print(f"{COLOR_ERROR}Error: Name is too long (max 100 characters).{COLOR_RESET}") + continue + + invalid_chars = '< > : " / \\ | ? *' # String of invalid chars for message + if any(char in modlist_name for char in invalid_chars.replace(' ','')): + print(f"{COLOR_ERROR}Error: Name contains invalid characters (e.g., {invalid_chars}).{COLOR_RESET}") + continue + + self.logger.info(f"Modlist name validated: {modlist_name}") + return modlist_name + + except KeyboardInterrupt: + print("\nOperation cancelled.") + self.logger.info("User cancelled modlist name input via Ctrl+C.") + return None + except Exception as e: + self.logger.error(f"Error processing modlist name: {e}") + print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}") + return None + + def _configure_new_modlist(self, default_modlist_dir=None, default_modlist_name=None): + """Handle configuration of a new modlist. Returns True to continue menu, False to exit.""" + # --- Get ModOrganizer.exe Path --- + if default_modlist_dir: + # Try to infer ModOrganizer.exe path + mo2_path = os.path.join(default_modlist_dir, "ModOrganizer.exe") + if not os.path.isfile(mo2_path): + print(f"{COLOR_ERROR}Could not find ModOrganizer.exe in {default_modlist_dir}{COLOR_RESET}") + mo2_path = self._get_mo2_path() + else: + mo2_path = self._get_mo2_path() + if not mo2_path: + return True + # --- Get Modlist Name --- + if default_modlist_name: + modlist_name = default_modlist_name + else: + modlist_name = self._get_modlist_name() + if not modlist_name: + return True + # Add a blank line for padding + print("") + try: + # --- Ensure SteamIcons directory is normalized before icon selection --- + mo2_dir = os.path.dirname(mo2_path) + # --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) --- + self.shortcut_handler.write_nxmhandler_ini(mo2_dir, mo2_path) + steam_icons_path = os.path.join(mo2_dir, "Steam Icons") + steamicons_path = os.path.join(mo2_dir, "SteamIcons") + if os.path.isdir(steam_icons_path) and not os.path.isdir(steamicons_path): + try: + os.rename(steam_icons_path, steamicons_path) + self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {mo2_dir}") + except Exception as e: + self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}") + self.logger.debug(f"[DEBUG] After normalization, SteamIcons exists: {os.path.isdir(steamicons_path)}") + # --- Create shortcut with working NativeSteamService --- + try: + from ..services.native_steam_service import NativeSteamService + steam_service = NativeSteamService() + + success, app_id = steam_service.create_shortcut_with_proton( + app_name=modlist_name, + exe_path=mo2_path, + start_dir=os.path.dirname(mo2_path), + launch_options="%command%", + tags=["Jackify"], + proton_version="proton_experimental" + ) + if not success or not app_id: + self.logger.error("Failed to create Steam shortcut.") + print(f"\n{COLOR_ERROR}Failed to create Steam shortcut. Check the logs for details.{COLOR_RESET}") + return True + mo2_dir = os.path.dirname(mo2_path) + if os.environ.get('JACKIFY_GUI_MODE'): + print('[PROMPT:RESTART_STEAM]') + input() # Wait for GUI to send confirmation + print('[PROMPT:MANUAL_STEPS]') + input() # Wait for GUI to send confirmation + # Continue as before + else: + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Important:{COLOR_RESET} Steam needs to restart to detect the new shortcut.") + print("This process involves several manual steps after the restart.") + restart_choice = input("\nRestart Steam automatically now? (Y/n): ").strip().lower() + if restart_choice == 'n': + self.logger.info("User opted out of automatic Steam restart.") + print("\nPlease restart Steam manually to see your new shortcut:") + print("1. Exit Steam completely (Steam -> Exit or right-click tray icon -> Exit)") + print("2. Wait a few seconds") + print("3. Start Steam again") + print("\nAfter restarting, you MUST perform the manual Proton setup steps:") + self._display_manual_proton_steps(modlist_name) + print(f"\n{COLOR_ERROR}You will need to re-run this configuration option after completing these steps.{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + return True + self.logger.info("Attempting secure Steam restart...") + print() + status_line = "" + def update_status(msg): + nonlocal status_line + if status_line: + print("\r" + " " * len(status_line), end="\r") + status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}" + print(status_line, end="", flush=True) + # Actually restart Steam and wait for completion + if self.shortcut_handler.secure_steam_restart(status_callback=update_status): + print() + self.logger.info("Secure Steam restart successful.") + self._display_manual_proton_steps(modlist_name) + print() + input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") + self.logger.info("User confirmed completion of manual steps.") + # Re-detect the shortcut and get the new, positive AppID + new_app_id = self.shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_path) + self.logger.info(f"Pre-launch AppID: {app_id}, Post-launch AppID: {new_app_id}") + if not new_app_id or not new_app_id.isdigit() or int(new_app_id) < 0: + print(f"{COLOR_ERROR}Could not find a valid AppID for '{modlist_name}' after launch. Please ensure you launched the shortcut from Steam at least once, then try again.{COLOR_RESET}") + return True + context = { + "name": modlist_name, + "appid": new_app_id, + "path": mo2_dir, + "manual_steps_completed": True, + "resolution": None + } + self.logger.debug(f"[DEBUG] New Modlist Context (post-launch): {context}") + return self.run_modlist_configuration_phase(context) + except Exception as e: + self.logger.error(f"Error creating Steam shortcut: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Failed to create Steam shortcut: {e}{COLOR_RESET}") + return True + except Exception as e: + self.logger.error(f"Error in _configure_new_modlist: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Unexpected error in new modlist configuration: {e}{COLOR_RESET}") + return True + + def _configure_existing_modlist(self): + """Handle configuration of an existing modlist. Returns True to continue menu, False to exit.""" + logger.info("Detecting installed modlists...") + try: + if not self.modlist_handler: + print("Internal Error: Modlist handler not available.") + input("\nPress Enter to continue...") + return True + configurable_modlists = self.modlist_handler.discover_executable_shortcuts("ModOrganizer.exe") + if not configurable_modlists: + logger.warning("No configurable ModOrganizer modlists found.") + print(f"{COLOR_ERROR}\nCould not find any recognized ModOrganizer modlists.{COLOR_RESET}") + print("Ensure the shortcut exists in Steam, points to ModOrganizer.exe, and has been run once.") + input(f"\n{COLOR_PROMPT}Press Enter to return to menu...{COLOR_RESET}") + return True + selected_modlist_dict = self.select_from_list(configurable_modlists, f"{COLOR_PROMPT}Select Modlist to Configure:{COLOR_RESET}") + if not selected_modlist_dict: + logger.info("Modlist selection cancelled by user.") + return True + logger.info(f"Setting context for selected modlist: {selected_modlist_dict.get('name')}") + context = { + "name": selected_modlist_dict.get("name"), + "appid": selected_modlist_dict.get("appid"), + "path": selected_modlist_dict.get("path"), + "resolution": selected_modlist_dict.get("resolution") if selected_modlist_dict.get("resolution") else None + } + self.logger.debug(f"[DEBUG] Existing Modlist Context: {context}") + return self.run_modlist_configuration_phase(context) + except KeyboardInterrupt: + print("\nConfiguration cancelled by user.") + return True + except Exception as e: + logger.exception(f"Error configuring existing modlist: {e}", exc_info=True) + print(f"{COLOR_ERROR}\nAn unexpected error occurred: {str(e)}{COLOR_RESET}") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return True + + def select_from_list(self, items: List[Dict], prompt="Select an option") -> Optional[Dict]: + """ + Display a list of items (dictionaries) and let the user select one. + + Args: + items: A list of dictionaries, each expected to have at least 'name' and 'appid'. + prompt: The message to display before the list. + + Returns: + The selected dictionary item or None if cancelled. + """ + if not items: + print(f"{COLOR_WARNING}No items available to select from.{COLOR_RESET}") + return None + + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}{prompt}{COLOR_RESET}") # Main prompt message (e.g., "Select Modlist to Configure:") + + for i, item_dict in enumerate(items, 1): + display_name = item_dict.get('name', 'Unknown Item') + # Optionally display other relevant info if available, e.g., AppID or path + # For now, keeping it simple with just the name for selection clarity. + print(f" {COLOR_SELECTION}{i}.{COLOR_RESET} {display_name}") + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel selection") # Added cancel option + + while True: + try: + choice_input = input(f"{COLOR_PROMPT}Enter your choice (0-{len(items)}): {COLOR_RESET}").strip() + if choice_input.lower() == 'q' or choice_input == '0': # Allow 'q' or '0' for cancel + self.logger.info("User cancelled selection from list.") + print(f"{COLOR_INFO}Selection cancelled.{COLOR_RESET}") + return None + if choice_input.isdigit(): + choice_int = int(choice_input) + if 1 <= choice_int <= len(items): + return items[choice_int - 1] + + print(f"{COLOR_ERROR}Invalid choice. Please enter a number between 0 and {len(items)}.{COLOR_RESET}") + except ValueError: + print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") + except KeyboardInterrupt: + print("\nSelection cancelled (Ctrl+C).") + self.logger.info("User cancelled selection from list via Ctrl+C.") + return None + + def run_modlist_configuration_phase(self, context: dict) -> bool: + """ + Shared configuration phase for both new and existing modlists. + Expects context dict with keys: name, appid, path (at minimum). + """ + self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}") + # Robust AppID lookup for GUI/CLI: if appid missing but mo2_exe_path present, look it up + if 'appid' not in context or not context.get('appid'): + if 'mo2_exe_path' in context and context['mo2_exe_path']: + appid = self.shortcut_handler.get_appid_for_shortcut(context['name'], context['mo2_exe_path']) + if appid: + context['appid'] = appid + else: + self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}") + set_modlist_result = self.modlist_handler.set_modlist(context) + self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}") + if not set_modlist_result: + print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}") + self.logger.error(f"set_modlist failed for {context.get('name')}") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return False + + # --- Resolution selection logic for GUI mode --- + import os + gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + selected_resolution = context.get('resolution', None) + if gui_mode: + # If resolution is provided, set it and do not prompt + if selected_resolution: + self.modlist_handler.selected_resolution = selected_resolution + self.logger.info(f"[GUI MODE] Resolution set from GUI: {selected_resolution}") + else: + # If on Steam Deck, set to 1280x800; else leave unchanged + if self.steamdeck: + self.modlist_handler.selected_resolution = "1280x800" + self.logger.info("[GUI MODE] Steam Deck detected, setting resolution to 1280x800.") + else: + self.logger.info("[GUI MODE] No resolution set, leaving unchanged.") + else: + # CLI mode: prompt as before + print() # Add padding before resolution prompt + selected_res = self.resolution_handler.select_resolution(steamdeck=self.steamdeck) + if selected_res: + self.modlist_handler.selected_resolution = selected_res + self.logger.info(f"Resolution preference set to: {selected_res}") + elif self.steamdeck: + self.modlist_handler.selected_resolution = "1280x800" + self.logger.info(f"Using default Steam Deck resolution: {self.modlist_handler.selected_resolution}") + else: + self.logger.info("User cancelled resolution selection or not applicable.") + + skip_confirmation = context.get('skip_confirmation', False) + if gui_mode: + skip_confirmation = True + if not self.modlist_handler.display_modlist_summary(skip_confirmation=skip_confirmation): + self.logger.info("User chose not to proceed with configuration after summary.") + return True + + self.logger.info(f"Starting configuration steps for {context.get('name')}") + print() # Add padding before status line + status_line = "" + import os + gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + def update_status(msg): + nonlocal status_line + if status_line: + print("\r" + " " * len(status_line), end="\r") + if gui_mode: + print(msg, flush=True) + else: + status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}" + print(status_line, end="", flush=True) + manual_steps_completed = context.get("manual_steps_completed", False) + if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed): + if status_line: + print() + self.logger.error(f"Core configuration steps failed for {context.get('name')}") + print(f"{COLOR_ERROR}\nModlist configuration failed. Check logs for details.{COLOR_RESET}") + # Only wait for input in CLI mode, not GUI mode + if not gui_mode: + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return False + if status_line: + print() + + print("") + print("") + print("") # Extra blank line before completion + print("=" * 35) + print("= Configuration phase complete =") + print("=" * 35) + print("") + print("Modlist Install and Configuration complete!") + print(f"• You should now be able to Launch '{context.get('name')}' through Steam") + print("• Congratulations and enjoy the game!") + print("Detailed log available at: ~/Jackify/logs/Configure_New_Modlist_workflow.log") + # Only wait for input in CLI mode, not GUI mode + if not gui_mode: + input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}") + return True + +class MenuHandler: + """ + Handles CLI menu display and interaction + """ + + def __init__(self, logger_instance=None): + if logger_instance: + self.logger = logger_instance + else: + self.logger = logging.getLogger(__name__) + self.config_handler = ConfigHandler() + self.shortcut_handler = ShortcutHandler( + steamdeck=self.config_handler.settings.get('steamdeck', False), + verbose=False + ) + self.mo2_handler = MO2Handler(self) + + def display_banner(self): + """Display the application banner - DEPRECATED: Banner display should be handled by frontend""" + os.system('cls' if os.name == 'nt' else 'clear') + # Banner display handled by frontend + + + + + + def _show_recovery_menu(self, cli_instance): + """Show the recovery tools menu.""" + while True: + self._clear_screen() + # Banner display handled by frontend + print_section_header('Recovery Tools') + print(f"{COLOR_INFO}This allows restoring original Steam configuration files from backups created by Jackify.{COLOR_RESET}") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Restore all backups") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Restore config.vdf only") + print(f"{COLOR_SELECTION}3.{COLOR_RESET} Restore libraryfolders.vdf only") + print(f"{COLOR_SELECTION}4.{COLOR_RESET} Restore shortcuts.vdf only") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") + + choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip() + + if choice == "1": + logger.info("Recovery selected: Restore all Steam config files") + print("\nAttempting to restore all supported Steam config files...") + # Logic to find and restore backups for all three files + paths_to_check = { + "libraryfolders": cli_instance.path_handler.find_steam_library_vdf_path(), # Need method to find vdf itself + "config": cli_instance.path_handler.find_steam_config_vdf(), + "shortcuts": cli_instance.shortcut_handler._find_shortcuts_vdf() # Assumes this returns the path + } + restored_count = 0 + for file_type, file_path in paths_to_check.items(): + if file_path: + print(f"Restoring {file_type} ({file_path})...") + # Find latest backup (needs helper function) + latest_backup = cli_instance.filesystem_handler.find_latest_backup(Path(file_path)) + if latest_backup: + if cli_instance.filesystem_handler.restore_backup(latest_backup, Path(file_path)): + print(f"Successfully restored {file_type}.") + restored_count += 1 + else: + print(f"{COLOR_ERROR}Failed to restore {file_type} from {latest_backup}.{COLOR_RESET}") + else: + print(f"No backup found for {file_type}.") + else: + print(f"Could not locate original file for {file_type} to restore.") + print(f"\nRestore process completed. {restored_count}/{len(paths_to_check)} files potentially restored.") + input("\nPress Enter to continue...") + elif choice == "2": + logger.info("Recovery selected: Restore config.vdf only") + print("\nAttempting to restore config.vdf...") + # Logic for config.vdf + file_path = cli_instance.path_handler.find_steam_config_vdf() + if file_path: + latest_backup = cli_instance.filesystem_handler.find_latest_backup(Path(file_path)) + if latest_backup: + if cli_instance.filesystem_handler.restore_backup(latest_backup, Path(file_path)): + print(f"Successfully restored config.vdf from {latest_backup}.") + else: + print(f"{COLOR_ERROR}Failed to restore config.vdf from {latest_backup}.{COLOR_RESET}") + else: + print("No backup found for config.vdf.") + else: + print("Could not locate config.vdf.") + input("\nPress Enter to continue...") + elif choice == "3": + logger.info("Recovery selected: Restore libraryfolders.vdf only") + print("\nAttempting to restore libraryfolders.vdf...") + # Logic for libraryfolders.vdf + file_path = cli_instance.path_handler.find_steam_library_vdf_path() + if file_path: + latest_backup = cli_instance.filesystem_handler.find_latest_backup(Path(file_path)) + if latest_backup: + if cli_instance.filesystem_handler.restore_backup(latest_backup, Path(file_path)): + print(f"Successfully restored libraryfolders.vdf from {latest_backup}.") + else: + print(f"{COLOR_ERROR}Failed to restore libraryfolders.vdf from {latest_backup}.{COLOR_RESET}") + else: + print("No backup found for libraryfolders.vdf.") + else: + print("Could not locate libraryfolders.vdf.") + input("\nPress Enter to continue...") + elif choice == "4": + logger.info("Recovery selected: Restore shortcuts.vdf only") + print("\nAttempting to restore shortcuts.vdf...") + # Logic for shortcuts.vdf + file_path = cli_instance.shortcut_handler._find_shortcuts_vdf() + if file_path: + latest_backup = cli_instance.filesystem_handler.find_latest_backup(Path(file_path)) + if latest_backup: + if cli_instance.filesystem_handler.restore_backup(latest_backup, Path(file_path)): + print(f"Successfully restored shortcuts.vdf from {latest_backup}.") + else: + print(f"{COLOR_ERROR}Failed to restore shortcuts.vdf from {latest_backup}.{COLOR_RESET}") + else: + print("No backup found for shortcuts.vdf.") + else: + print("Could not locate shortcuts.vdf.") + input("\nPress Enter to continue...") + elif choice == "0": + logger.info("Returning to main menu from recovery.") + break # Exit recovery menu loop + else: + logger.warning(f"Invalid recovery menu selection: {choice}") + print("\nInvalid selection. Please try again.") + time.sleep(1) + + def get_input_with_default(self, prompt, default=None): + """ + Get user input with an optional default value. + Returns the user input or the default value, or None if cancelled by 'q'. + """ + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}{prompt}{COLOR_RESET}") # Main prompt message + if default is not None: + print(f"{COLOR_INFO}(Default: {default}){COLOR_RESET}") + + try: + # Consistent input line + user_input = input(f"{COLOR_PROMPT}Enter value (or 'q' to cancel, Enter for default): {COLOR_RESET}").strip() + if user_input.lower() == 'q': + self.logger.info(f"User cancelled input for prompt: '{prompt}'") + print(f"{COLOR_INFO}Input cancelled by user.{COLOR_RESET}") + return None # Explicit None for cancellation + return user_input if user_input else default + except KeyboardInterrupt: + self.logger.info(f"User cancelled input via Ctrl+C for prompt: '{prompt}'") + print("\nInput cancelled.") + return None # Explicit None for cancellation + + def show_progress(self, step, percent, message): + """ + Display a progress bar with the current step and message + """ + # Ensure percent is within bounds + percent = max(0, min(100, int(percent))) + bar_length = 50 + filled_length = int(bar_length * percent / 100) + bar = '=' * filled_length + ' ' * (bar_length - filled_length) + + # Use \r to return to the beginning of the line, \033[K to clear the rest + print(f"\r\033[K[{bar}] {percent}% - {step}: {message}", end='') + if percent == 100: + print() # Add a newline when complete + sys.stdout.flush() + + def _clear_screen(self): + """Clears the terminal screen with fallbacks.""" + self.logger.debug(f"_clear_screen: Detected os.name: {os.name}") + if os.name == 'nt': + self.logger.debug("_clear_screen: Clearing screen for NT by attempting command: cls via os.system") + os.system('cls') + else: + try: + # Attempt 1: Specific path to clear + self.logger.debug("_clear_screen: Attempting /usr/bin/clear") + subprocess.run(['/usr/bin/clear'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.logger.debug("_clear_screen: /usr/bin/clear succeeded") + return + except FileNotFoundError: + self.logger.warning("_clear_screen: /usr/bin/clear not found.") + except subprocess.CalledProcessError as e: + self.logger.warning(f"_clear_screen: /usr/bin/clear failed: {e}") + except Exception as e: + self.logger.error(f"_clear_screen: Unexpected error with /usr/bin/clear: {e}") + + try: + # Attempt 2: 'clear' command (relies on PATH) + self.logger.debug("_clear_screen: Attempting 'clear' from PATH") + subprocess.run(['clear'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.logger.debug("_clear_screen: 'clear' from PATH succeeded") + return + except FileNotFoundError: + self.logger.warning("_clear_screen: 'clear' not found in PATH.") + except subprocess.CalledProcessError as e: + self.logger.warning(f"_clear_screen: 'clear' from PATH failed: {e}") + except Exception as e: + self.logger.error(f"_clear_screen: Unexpected error with 'clear' from PATH: {e}") + + # Attempt 3: Fallback to printing newlines (guaranteed) + self.logger.debug("_clear_screen: Clearing screen for POSIX by printing 100 newlines.") + print("\n" * 100, flush=True) + + def show_hoolamike_menu(self, cli_instance): + """Show the Hoolamike Modlist Management menu""" + if not hasattr(cli_instance, 'hoolamike_handler') or cli_instance.hoolamike_handler is None: + try: + from .hoolamike_handler import HoolamikeHandler + cli_instance.hoolamike_handler = HoolamikeHandler( + steamdeck=getattr(cli_instance, 'steamdeck', False), + verbose=getattr(cli_instance, 'verbose', False), + filesystem_handler=getattr(cli_instance, 'filesystem_handler', None), + config_handler=getattr(cli_instance, 'config_handler', None), + menu_handler=self + ) + except Exception as e: + self.logger.error(f"Failed to initialize Hoolamike features: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error: Failed to initialize Hoolamike features. Check logs.{COLOR_RESET}") + input("\nPress Enter to return to the main menu...") + return # Exit this menu if handler fails + + while True: + self._clear_screen() + # Banner display handled by frontend + # Use print_section_header for consistency if available, otherwise manual with COLOR_SELECTION + if hasattr(self, 'print_section_header'): # Check if method exists (it's from ui_utils) + print_section_header("Hoolamike Modlist Management") + else: # Fallback if not imported or available directly on self + print(f"{COLOR_SELECTION}Hoolamike Modlist Management{COLOR_RESET}") + print(f"{COLOR_SELECTION}{'-'*30}{COLOR_RESET}") + + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install or Update Hoolamike App") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Install Modlist (Nexus Premium)") + print(f"{COLOR_SELECTION}3.{COLOR_RESET} Install Modlist (Non-Premium) {COLOR_DISABLED}(Not Implemented){COLOR_RESET}") + print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Tale of Two Wastelands (TTW)") + print(f"{COLOR_SELECTION}5.{COLOR_RESET} Edit Hoolamike Configuration") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") + selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip() + + if selection.lower() == 'q': # Allow 'q' to re-display menu + continue + if selection == "1": + cli_instance.hoolamike_handler.install_update_hoolamike() + elif selection == "2": + cli_instance.hoolamike_handler.install_modlist(premium=True) + elif selection == "3": + print(f"{COLOR_INFO}Install Modlist (Non-Premium) is not yet implemented.{COLOR_RESET}") + input("\nPress Enter to return to the Hoolamike menu...") + elif selection == "4": + cli_instance.hoolamike_handler.install_ttw() + elif selection == "5": + cli_instance.hoolamike_handler.edit_hoolamike_config() + elif selection == "0": + break + else: + print("Invalid selection. Please try again.") + time.sleep(1) + + + + def _ask_try_again(self): + """Prompt the user to try again or cancel. Returns True to retry, False to cancel.""" + while True: + choice = input(f"{COLOR_PROMPT}Try again? (Y/n/q): {COLOR_RESET}").strip().lower() + if choice == '' or choice.startswith('y'): + return True + elif choice == 'n' or choice == 'q': + return False + else: + print(f"{COLOR_ERROR}Invalid input. Please enter 'y', 'n', or 'q'.{COLOR_RESET}") + + def get_directory_path(self, prompt_message: str, default_path: Optional[Path], create_if_missing: bool = True, no_header: bool = False) -> Optional[Path]: + """ + Prompts the user for a directory path. If the directory does not exist, asks if it should be created. + Returns a tuple (chosen_path, should_create) if creation is needed, or just the path if it exists. + The actual directory creation should be performed after summary confirmation. + """ + if not no_header: + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}{prompt_message}{COLOR_RESET}") + if default_path is not None: # Explicit check + print(f"{COLOR_INFO}(Default: {default_path}){COLOR_RESET}") + print(f"{COLOR_PROMPT}Enter path (or 'q' to cancel, Enter for default):{COLOR_RESET}") + else: + print(f"{COLOR_PROMPT}{prompt_message}{COLOR_RESET}") + if READLINE_AVAILABLE: + readline.set_completer_delims(' \t\n;') + readline.set_completer(path_completer) + readline.parse_and_bind('tab: complete') + elif not no_header: + print(f"{COLOR_INFO}Note: Tab completion is not available in this environment.{COLOR_RESET}") + try: + while True: + chosen_path: Optional[Path] = None + try: + user_input = input("Path: ").strip() + if user_input.lower() == 'q': + self.logger.info("User cancelled path input with 'q'.") + print(f"{COLOR_INFO}Input cancelled by user.{COLOR_RESET}") + return None + if not user_input: # User pressed Enter (empty input) + if default_path is not None: # Explicitly check if a default_path object was provided + self.logger.debug(f"User pressed Enter, using default_path: {default_path}") + chosen_path = default_path.expanduser().resolve() + else: + self.logger.warning("User pressed Enter, but no default_path was available.") + print(f"{COLOR_ERROR}No path entered and no default path was available.{COLOR_RESET}") + if not self._ask_try_again(): return None + continue + else: + self.logger.debug(f"User entered path: {user_input}") + chosen_path = Path(os.path.expanduser(user_input)).resolve() + if chosen_path.exists(): + if chosen_path.is_dir(): + self.logger.info(f"Selected directory (exists): {chosen_path}") + return chosen_path + else: + self.logger.warning(f"Path exists but is not a directory: {chosen_path}") + print(f"{COLOR_ERROR}Error: Path exists but is not a directory: {chosen_path}{COLOR_RESET}") + if not self._ask_try_again(): return None + continue + elif create_if_missing: + self.logger.info(f"Directory does not exist: {chosen_path}. Prompting to create.") + print(f"{COLOR_WARNING}Directory does not exist: {chosen_path}{COLOR_RESET}") + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Create this directory?{COLOR_RESET}") + create_choice = input(f"{COLOR_PROMPT}(Y/n/q): {COLOR_RESET}").strip().lower() + print("-" * 28) + if create_choice == '' or create_choice.startswith('y'): + self.logger.info(f"User chose to create directory: {chosen_path}") + return (chosen_path, True) + elif create_choice.startswith('n') or create_choice.startswith('q'): + self.logger.info(f"User chose not to create directory: {chosen_path}") + print("Directory creation skipped by user.") + if create_choice.startswith('q') or not self._ask_try_again(): return None + continue + else: + print(f"{COLOR_ERROR}Invalid input. Please enter 'y', 'n', or 'q'.{COLOR_RESET}") + if not self._ask_try_again(): return None + continue + except EOFError: + print("\nInput cancelled (EOF).") + return None + except KeyboardInterrupt: + print("\nInput cancelled (Ctrl+C).") + return None + finally: + if READLINE_AVAILABLE: + readline.set_completer(None) + + def get_existing_file_path(self, prompt_message: str, extension_filter: str = ".wabbajack", no_header: bool = False) -> Optional[Path]: + if not no_header: + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}{prompt_message}{COLOR_RESET}") + print(f"Looking for files with extension: {extension_filter}") + print("You can also select a directory containing the file.") + print("") + print(f"{COLOR_PROMPT}Enter file path (or 'q' to cancel):{COLOR_RESET}") + if READLINE_AVAILABLE: + readline.set_completer_delims(' \t\n;') + readline.set_completer(path_completer) + readline.parse_and_bind('tab: complete') + else: + print(f"{COLOR_INFO}Note: Tab completion is not available in this environment.{COLOR_RESET}") + print(f"{COLOR_INFO}You'll need to manually type the full path to the file.{COLOR_RESET}") + try: + while True: + raw_path = input("File: ").strip() + if raw_path.lower() == 'q': + print(f"{COLOR_INFO}Input cancelled by user.{COLOR_RESET}") + print("") + return None + if not raw_path: + print("Input cancelled.") + print("") + return None + file_path = Path(os.path.expanduser(raw_path)).resolve() + if file_path.is_dir(): + print("") + return file_path + if file_path.is_file() and file_path.name.lower().endswith(extension_filter.lower()): + print("") + return file_path + else: + print(f"{COLOR_ERROR}Error: Path is not a valid '{extension_filter}' file or a directory: {file_path}{COLOR_RESET}") + print("Please check the path and try again, or press Ctrl+C or 'q' to cancel.") + if not self._ask_try_again(): + print("") + return None + except KeyboardInterrupt: + print("\nInput cancelled.") + print("") + return None + finally: + if READLINE_AVAILABLE: + readline.set_completer(None) + +# Basic input prompt function for use throughout the application +input_prompt = basic_input_prompt + +# --- Robust shell-like path completer function --- +def _shell_path_completer(text, state): + """ + Shell-like pathname completer for readline. + Expands ~, handles absolute/relative paths, and completes inside directories. + """ + import os + import glob + # Expand ~ and environment variables + expanded = os.path.expanduser(os.path.expandvars(text)) + # If the expanded path is a directory, list its contents + if os.path.isdir(expanded): + pattern = os.path.join(expanded, '*') + else: + # Complete the last component + pattern = expanded + '*' + matches = glob.glob(pattern) + # Add trailing slash to directories + matches = [m + ('/' if os.path.isdir(m) else '') for m in matches] + # If the user hasn't typed anything, show current dir + if not text: + matches = glob.glob('*') + matches = [m + ('/' if os.path.isdir(m) else '') for m in matches] + # Return the state-th match or None + try: + return matches[state] + except IndexError: + return None + +# Create a public reference to the robust completer +simple_path_completer = _shell_path_completer + +# --- Simple path completer function --- +def _simple_path_completer(text, state): + """ + Simple pathname completer for readline. + Logic: + - If text is empty (at beginning of line), returns options for current dir + - If text has content, does prefix matching on path components + - Tab completion will fill up to next / or complete the filename + - State is an integer index representing which match to return + Args: + text: The text to complete + state: The state index (0 for first match, 1 for second, etc.) + Returns: + The matching completion or None if no more matches + """ + import glob, os + matches = glob.glob(text + '*') + matches = [f + ('/' if os.path.isdir(f) else '') for f in matches] + try: + return matches[state] + except IndexError: + return None + +simple_path_completer = _simple_path_completer \ No newline at end of file diff --git a/jackify/backend/handlers/mo2_handler.py b/jackify/backend/handlers/mo2_handler.py new file mode 100644 index 0000000..679df9e --- /dev/null +++ b/jackify/backend/handlers/mo2_handler.py @@ -0,0 +1,184 @@ +import shutil +import subprocess +import requests +from pathlib import Path +import re +import time +import os +from .ui_colors import COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING +from .status_utils import show_status, clear_status +from jackify.shared.ui_utils import print_section_header, print_subsection_header + +class MO2Handler: + """ + Handles downloading and installing Mod Organizer 2 (MO2) using system 7z. + """ + def __init__(self, menu_handler): + self.menu_handler = menu_handler + # Import shortcut handler from menu_handler if available + self.shortcut_handler = getattr(menu_handler, 'shortcut_handler', None) + + def _is_dangerous_path(self, path: Path) -> bool: + # Block /, /home, /root, and the user's home directory + home = Path.home().resolve() + dangerous = [Path('/'), Path('/home'), Path('/root'), home] + return any(path.resolve() == d for d in dangerous) + + def install_mo2(self): + os.system('cls' if os.name == 'nt' else 'clear') + # Banner display handled by frontend + print_section_header('Mod Organizer 2 Installation') + # 1. Check for 7z + if not shutil.which('7z'): + print(f"{COLOR_ERROR}[ERROR] 7z is not installed. Please install it (e.g., sudo apt install p7zip-full).{COLOR_RESET}\n") + return False + # 2. Prompt for install location + default_dir = Path.home() / "ModOrganizer2" + prompt = f"Enter the full path where Mod Organizer 2 should be installed (default: {default_dir}, enter 'q' to cancel)" + install_dir = self.menu_handler.get_directory_path( + prompt_message=prompt, + default_path=default_dir, + create_if_missing=False, + no_header=True + ) + if not install_dir: + print(f"\n{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}\n") + return False + # Safety: Block dangerous paths + if self._is_dangerous_path(install_dir): + print(f"\n{COLOR_ERROR}Refusing to install to a dangerous directory: {install_dir}{COLOR_RESET}\n") + return False + # 3. Ask if user wants to add MO2 to Steam + add_to_steam = input(f"Add Mod Organizer 2 as a custom Steam shortcut for Proton? (Y/n): ").strip().lower() + add_to_steam = (add_to_steam == '' or add_to_steam.startswith('y')) + shortcut_name = None + if add_to_steam: + shortcut_name = input(f"Enter a name for your new Steam shortcut (default: Mod Organizer 2): ").strip() + if not shortcut_name: + shortcut_name = "Mod Organizer 2" + print_subsection_header('Configuration Phase') + time.sleep(0.5) + # 4. Create directory if needed, handle existing contents + if not install_dir.exists(): + try: + install_dir.mkdir(parents=True, exist_ok=True) + show_status(f"Created directory: {install_dir}") + except Exception as e: + print(f"{COLOR_ERROR}[ERROR] Could not create directory: {e}{COLOR_RESET}\n") + return False + else: + files = list(install_dir.iterdir()) + if files: + print(f"Warning: The directory '{install_dir}' is not empty.") + print("Warning: This will permanently delete all files in the folder. Type 'DELETE' to confirm:") + confirm = input("").strip() + if confirm != 'DELETE': + print(f"{COLOR_INFO}Cancelled by user. Please choose a different directory if you want to keep existing files.{COLOR_RESET}\n") + return False + for f in files: + try: + if f.is_dir(): + shutil.rmtree(f) + else: + f.unlink() + except Exception as e: + print(f"{COLOR_ERROR}Failed to delete {f}: {e}{COLOR_RESET}") + show_status(f"Deleted all contents of {install_dir}") + + # 5. Fetch latest MO2 release info from GitHub + show_status("Fetching latest Mod Organizer 2 release info...") + try: + response = requests.get("https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest", timeout=15, verify=True) + response.raise_for_status() + release = response.json() + except Exception as e: + print(f"{COLOR_ERROR}[ERROR] Failed to fetch MO2 release info: {e}{COLOR_RESET}\n") + return False + + # 6. Find the correct .7z asset (exclude -pdbs, -src, etc) + asset = None + for a in release.get('assets', []): + name = a['name'] + if re.match(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$", name): + asset = a + break + if not asset: + print(f"{COLOR_ERROR}[ERROR] Could not find main MO2 .7z asset in latest release.{COLOR_RESET}\n") + return False + + # 7. Download the archive + show_status(f"Downloading {asset['name']}...") + archive_path = install_dir / asset['name'] + try: + with requests.get(asset['browser_download_url'], stream=True, timeout=60, verify=True) as r: + r.raise_for_status() + with open(archive_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + except Exception as e: + print(f"{COLOR_ERROR}[ERROR] Failed to download MO2 archive: {e}{COLOR_RESET}\n") + return False + + # 8. Extract using 7z (suppress noisy output) + show_status(f"Extracting to {install_dir}...") + try: + result = subprocess.run(['7z', 'x', str(archive_path), f'-o{install_dir}'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode != 0: + print(f"{COLOR_ERROR}[ERROR] Extraction failed: {result.stderr.decode(errors='ignore')}{COLOR_RESET}\n") + return False + except Exception as e: + print(f"{COLOR_ERROR}[ERROR] Extraction failed: {e}{COLOR_RESET}\n") + return False + + # 9. Validate extraction + mo2_exe = next(install_dir.glob('**/ModOrganizer.exe'), None) + if not mo2_exe: + print(f"{COLOR_ERROR}[ERROR] ModOrganizer.exe not found after extraction. Please check extraction.{COLOR_RESET}\n") + return False + else: + show_status(f"MO2 installed at: {mo2_exe.parent}") + + # 10. Add to Steam if requested + if add_to_steam and self.shortcut_handler: + show_status("Creating Steam shortcut...") + try: + 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=str(mo2_exe), + start_dir=str(mo2_exe.parent), + launch_options="%command%", + tags=["Jackify"], + proton_version="proton_experimental" + ) + if not success or not app_id: + print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut.{COLOR_RESET}\n") + else: + show_status(f"Steam shortcut created for '{COLOR_INFO}{shortcut_name}{COLOR_RESET}'.") + # Restart Steam and show manual steps (reuse logic from Configure Modlist) + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Important:{COLOR_RESET} Steam needs to restart to detect the new shortcut.") + print("This process involves several manual steps after the restart.") + restart_choice = input(f"\n{COLOR_PROMPT}Restart Steam automatically now? (Y/n): {COLOR_RESET}").strip().lower() + if restart_choice != 'n': + if hasattr(self.shortcut_handler, 'secure_steam_restart'): + print("Restarting Steam...") + self.shortcut_handler.secure_steam_restart() + print("\nAfter restarting, you MUST perform the manual Proton setup steps:") + print(f" 1. Locate '{COLOR_INFO}{shortcut_name}{COLOR_RESET}' in your Steam Library") + print(" 2. Right-click and select 'Properties'") + print(" 3. Switch to the 'Compatibility' tab") + print(" 4. Check 'Force the use of a specific Steam Play compatibility tool'") + print(" 5. Select 'Proton - Experimental' from the dropdown menu") + print(" 6. Close the Properties window") + print(f" 7. Launch '{COLOR_INFO}{shortcut_name}{COLOR_RESET}' from your Steam Library") + print(" 8. If Mod Organizer opens or produces any error message, that's normal") + print(" 9. CLOSE Mod Organizer completely and return here") + print("───────────────────────────────────────────────────────────────────\n") + except Exception as e: + print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut: {e}{COLOR_RESET}\n") + + print(f"{COLOR_SUCCESS}Mod Organizer 2 has been installed successfully!{COLOR_RESET}\n") + return True \ No newline at end of file diff --git a/jackify/backend/handlers/modlist_handler.py b/jackify/backend/handlers/modlist_handler.py new file mode 100644 index 0000000..060a08a --- /dev/null +++ b/jackify/backend/handlers/modlist_handler.py @@ -0,0 +1,1277 @@ +from pathlib import Path +import json +import logging +from typing import Union, Dict, Optional, List, Tuple +import re +import time +import vdf +import os +import subprocess +import shutil +import requests +import atexit +import signal +import sys + +# Import our modules +from .path_handler import PathHandler +# from .wine_utils import WineUtils # Removed unused import +from .filesystem_handler import FileSystemHandler +from .protontricks_handler import ProtontricksHandler +from .shortcut_handler import ShortcutHandler +from .resolution_handler import ResolutionHandler + +# Import our safe VDF handler +from .vdf_handler import VDFHandler + +# Import colors from the new central location +from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SELECTION, COLOR_ERROR + +# Standard logging (no file handler) +import logging + +# Initialize logger +logger = logging.getLogger(__name__) + +# Ensure terminal state is restored on exit, error, or interrupt +def _restore_terminal(): + try: + # Skip stty in GUI mode to prevent "Inappropriate ioctl for device" error + if os.environ.get('JACKIFY_GUI_MODE') == '1': + return + os.system('stty sane') + except Exception: + pass + +# Only register signal handlers if we're in the main thread +try: + import threading + if threading.current_thread() is threading.main_thread(): + atexit.register(_restore_terminal) + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, lambda signum, frame: (_restore_terminal(), sys.exit(1))) +except Exception: + # If signal handling fails, just continue without it + pass + +class ModlistHandler: + """ + Handles operations related to modlist detection and configuration + """ + + # Dictionary mapping modlist name patterns (lowercase, spaces optional) + # to lists of additional Wine components or special actions. + MODLIST_SPECIFIC_COMPONENTS = { + # Pattern: [component1, component2, ... or special_action_string] + "wildlander": ["dotnet48"], # Example from bash script + "licentia": ["dotnet8"], # Example from bash script (needs special handling) + "nolvus": ["dotnet6", "dotnet7"], # Example + # Add other modlists and their specific needs here + # e.g., "fallout4_anotherlife": ["some_component"] + } + + # Canonical mapping of modlist-specific Wine components (from omni-guides.sh) + MODLIST_WINE_COMPONENTS = { + "wildlander": ["dotnet472"], + "librum": ["dotnet40", "dotnet8"], + "apostasy": ["dotnet40", "dotnet8"], + "nordicsouls": ["dotnet40"], + "livingskyrim": ["dotnet40"], + "lsiv": ["dotnet40"], + "ls4": ["dotnet40"], + "lostlegacy": ["dotnet48"], + } + + def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None, + mo2_path: Optional[Union[str, Path]] = None, + steamdeck: bool = False, + verbose: bool = False, # Add verbose flag + filesystem_handler: Optional['FileSystemHandler'] = None): + """ + Initialize the ModlistHandler. + Can be initialized with: + 1. A config dictionary: ModlistHandler(config_dict, steamdeck=True) + 2. Explicit paths: ModlistHandler(steam_path="/path/to/steam", mo2_path="/path/to/mo2", steamdeck=False) + 3. Default (will try to find paths if needed later): ModlistHandler() + + Args: + steam_path_or_config: Config dict or path to Steam installation. + mo2_path: Path to ModOrganizer installation (needed if steam_path_or_config is a path). + steamdeck: Boolean indicating if running on Steam Deck. + verbose: Boolean indicating if verbose output is desired. + filesystem_handler: Optional FileSystemHandler instance to use instead of creating a new one. + """ + # Use standard logging (no file handler) + self.logger = logging.getLogger(__name__) + self.logger.propagate = False + self.steamdeck = steamdeck + self.steam_path: Optional[Path] = None + self.verbose = verbose # Store verbose flag + self.mo2_path: Optional[Path] = None + + if isinstance(steam_path_or_config, dict): + # Scenario 1: Init with config dict + self.logger.debug("Initializing ModlistHandler with config dict") + steam_path_str = steam_path_or_config.get('steam_path') + self.steam_path = Path(steam_path_str) if steam_path_str else None + mo2_path_str = steam_path_or_config.get('mo2_path') + self.mo2_path = Path(mo2_path_str) if mo2_path_str else None + elif steam_path_or_config: + # Scenario 2: Init with explicit paths + self.logger.debug("Initializing ModlistHandler with explicit paths") + self.steam_path = Path(steam_path_or_config) + if mo2_path: + self.mo2_path = Path(mo2_path) + else: + # Decide if mo2_path is strictly required here + self.logger.warning("MO2 path not provided during path-based initialization") + # If MO2 path is essential, raise ValueError + # raise ValueError("mo2_path is required when providing steam_path directly") + else: + # Scenario 3: Default init, paths might be found later if needed + self.logger.debug("Initializing ModlistHandler with default settings") + # Paths remain None for now + + self.modlists: Dict[str, Dict] = {} + self.launch_options = [ + "--no-sandbox", + "--disable-gpu-sandbox", + "--disable-software-rasterizer", + "--disable-dev-shm-usage" + ] + # Initialize state reset variables first + self.modlist = None + self.appid = None + self.game_var = None + self.game_var_full = None + self.modlist_dir = None + self.modlist_ini = None + self.steam_library = None + self.basegame_sdcard = False + self.modlist_sdcard = False + self.compat_data_path = None + self.proton_ver = None + self.game_name = None + self.selected_resolution = None + self.which_protontricks = None + self.steamdeck = steamdeck + 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.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=self.verbose) + self.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler() + self.resolution_handler = ResolutionHandler() + self.path_handler = PathHandler() # Assuming PathHandler is needed + + # Use shared timing for consistency across services + + # Load modlists if steam_path is known + if self.steam_path: + self._load_modlists() + else: + self.logger.debug("Steam path not known during init, skipping initial modlist load.") + + # Use static methods from VDFHandler + self.vdf_handler = VDFHandler + + def _get_progress_timestamp(self): + """Get consistent progress timestamp""" + from jackify.shared.timing import get_timestamp + return get_timestamp() + + # --- Original methods continue below --- + def _load_modlists(self) -> None: + """Load modlists from local configuration or detect from Steam shortcuts.""" + try: + # Try to load from local config first + if not self.steam_path or not self.steam_path.exists(): + self.logger.warning("Steam path not valid in __init__, cannot load modlists.json") + self._detect_modlists_from_shortcuts() + return + + config_path = self.steam_path.parent / 'modlists.json' + if config_path.exists(): + with open(config_path, 'r') as f: + self.modlists = json.load(f) + self.logger.info("Loaded modlists from local configuration") + return + + self._detect_modlists_from_shortcuts() + except Exception as e: + self.logger.error(f"Error loading modlists: {e}") + + def _detect_modlists_from_shortcuts(self) -> bool: + """ + Detect modlists from Steam shortcuts.vdf entries + """ + self.logger.info("Detecting modlists from Steam shortcuts") + return False # Placeholder return + + def discover_executable_shortcuts(self, executable_name: str) -> List[Dict]: + """Discovers non-Steam shortcuts pointing to a specific executable. + + Args: + executable_name: The name of the executable (e.g., "ModOrganizer.exe") + to look for in the shortcut's 'Exe' path. + + Returns: + A list of dictionaries, each containing validated shortcut info: + {'name': AppName, 'appid': AppID, 'path': StartDir} + Returns an empty list if none are found or an error occurs. + """ + self.logger.info(f"Discovering non-Steam shortcuts for executable: {executable_name}") + 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 + matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name) + if not matching_vdf_shortcuts: + self.logger.warning(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 + for vdf_shortcut in matching_vdf_shortcuts: + app_name = vdf_shortcut.get('AppName') + start_dir = vdf_shortcut.get('StartDir') + + 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})") + else: + # Downgraded from WARNING to INFO + self.logger.info(f"Shortcut '{app_name}' found in VDF but not listed by protontricks. Skipping.") + + except Exception as e: + self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True) + return [] + + if not discovered_modlists_info: + self.logger.warning("No validated shortcuts found after correlation.") + + return discovered_modlists_info + + def set_modlist(self, modlist_info: Dict) -> bool: + """Sets the internal context based on the selected modlist dictionary. + + Extracts AppName, AppID, and StartDir from the input dictionary + and sets internal state variables like self.game_name, self.appid, + self.modlist_dir, self.modlist_ini. + + Args: + modlist_info: Dictionary containing {'name', 'appid', 'path'}. + + Returns: + True if the context was successfully set, False otherwise. + """ + self.logger.info(f"Setting context for selected modlist: {modlist_info.get('name')}") + + # 1. Extract info from dictionary + app_name = modlist_info.get('name') + app_id = modlist_info.get('appid') + modlist_dir_path_str = modlist_info.get('path') + + if not all([app_name, app_id, modlist_dir_path_str]): + self.logger.error(f"Incomplete modlist info provided: {modlist_info}") + return False + + self.logger.debug(f"Using AppName: {app_name}, AppID: {app_id}, Path: {modlist_dir_path_str}") + modlist_dir_path = Path(modlist_dir_path_str) + + # 2. Validate paths and set internal state + if not modlist_dir_path.is_dir(): + self.logger.error(f"Modlist directory does not exist: {modlist_dir_path}") + return False + + modlist_ini_path = modlist_dir_path / "ModOrganizer.ini" + if not modlist_ini_path.is_file(): + self.logger.error(f"ModOrganizer.ini not found in directory: {modlist_dir_path}") + return False + + # Set state variables + self.game_name = app_name + self.appid = str(app_id) # Ensure AppID is always stored as string + 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"): + self.modlist_sdcard = True + self.logger.info("Modlist appears to be on an SD card.") + else: + self.modlist_sdcard = False + + # Find and set compatdata path now that we have appid + # Ensure PathHandler is available (should be initialized in __init__) + if hasattr(self, 'path_handler'): + # Convert appid to string since find_compat_data expects a string + appid_str = str(self.appid) + self.compat_data_path = self.path_handler.find_compat_data(appid_str) + if self.compat_data_path: + self.logger.debug(f"Found compatdata path: {self.compat_data_path}") + else: + self.logger.warning(f"Could not find compatdata path for AppID {self.appid}") + else: + self.logger.error("PathHandler not initialized, cannot find compatdata path.") + self.compat_data_path = None # Ensure it's None if handler missing + + self.logger.info(f"Modlist context set successfully for '{self.game_name}' (AppID: {self.appid})") + self.logger.debug(f" Directory: {self.modlist_dir}") + self.logger.debug(f" INI Path: {self.modlist_ini}") + self.logger.debug(f" On SD Card: {self.modlist_sdcard}") + + # 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.") + # Decide if failure to detect game should make set_modlist return False + # return False + + # TODO: Add calls here or later to detect_steam_library, + # detect_compatdata_path, detect_proton_version based on the now-known AppID/paths + # to fully populate the handler's state before configuration phase. + + return True + + def _detect_game_variables(self): + """Detect game_var and game_var_full based on ModOrganizer.ini content.""" + if not self.modlist_ini or not Path(self.modlist_ini).is_file(): + self.logger.error("Cannot detect game variables: ModOrganizer.ini path not set or file not found.") + self.game_var = "Unknown" + self.game_var_full = "Unknown" + return False + + # Define mapping from loader executable to full game name + loader_to_game = { + "skse64_loader.exe": "Skyrim Special Edition", + "f4se_loader.exe": "Fallout 4", + "nvse_loader.exe": "Fallout New Vegas", + "obse_loader.exe": "Oblivion" + # Add others if needed + } + + # Short name lookup (can derive from full name later) + short_name_lookup = { + "Skyrim Special Edition": "Skyrim", + "Fallout 4": "Fallout", + "Fallout New Vegas": "FNV", # Or "Fallout" + "Oblivion": "Oblivion" + } + + try: + with open(self.modlist_ini, 'r', encoding='utf-8', errors='ignore') as f: + ini_content = f.read().lower() # Read entire file, lowercase for easier matching + except Exception as e: + self.logger.error(f"Error reading ModOrganizer.ini ({self.modlist_ini}): {e}") + self.game_var = "Unknown" + self.game_var_full = "Unknown" + return False + + found_game = None + for loader, game_name in loader_to_game.items(): + # Look for the loader name within the INI content + # A simple check might be enough, or use regex for more specific context + # (e.g., in a binary= line) + if loader in ini_content: + found_game = game_name + self.logger.info(f"Detected game type '{found_game}' based on finding '{loader}' in ModOrganizer.ini") + break + + if found_game: + self.game_var_full = found_game + self.game_var = short_name_lookup.get(found_game, found_game.split()[0]) # Fallback short name + return True + else: + # Fallback: Could try checking self.game_name keywords as a last resort? + self.logger.warning(f"Could not detect game type from ModOrganizer.ini content. Check INI for known loaders (skse64, f4se, nvse, obse).") + # Optionally, ask the user here? + self.game_var = "Unknown" + self.game_var_full = "Unknown" + return False # Indicate detection failed + + def _detect_proton_version(self): + """Detect the Proton version used for the modlist prefix.""" + self.logger.info(f"Detecting Proton version for AppID {self.appid}...") + self.proton_ver = "Unknown" + + if not self.appid: + self.logger.error("Cannot detect Proton version without a valid AppID.") + return False + + # --- Check config.vdf first for user-selected tool name --- + try: + # Reuse PathHandler's method to find config.vdf + config_vdf_path = self.path_handler.find_steam_config_vdf() + if config_vdf_path and config_vdf_path.exists(): + import vdf # Assuming vdf library is available + with open(config_vdf_path, 'r') as f: + data = vdf.load(f) + + # Navigate the VDF structure (adjust path as needed based on vdf library usage) + mapping = data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}) + app_mapping = mapping.get(str(self.appid), {}) + tool_name = app_mapping.get('name', '') + + if tool_name and 'experimental' in tool_name.lower(): + self.proton_ver = tool_name # Use the name from config.vdf (e.g., proton_experimental) + self.logger.info(f"Detected Proton tool from config.vdf: {self.proton_ver}") + return True + elif tool_name: # If found but not experimental, log it but proceed to reg check + self.logger.debug(f"Proton tool from config.vdf: {tool_name}. Checking registry for runtime version.") + else: + self.logger.debug(f"No specific Proton tool mapping found for AppID {self.appid} in config.vdf.") + else: + self.logger.debug("config.vdf not found, proceeding with registry check.") + + except ImportError: + self.logger.warning("Python 'vdf' library not found. Cannot check config.vdf for Proton version. Skipping.") + except Exception as e: + self.logger.warning(f"Error reading config.vdf: {e}. Proceeding with registry check.") + # --- End config.vdf check --- + + # --- If config.vdf didn't yield 'Experimental', check prefix files --- + if not self.compat_data_path or not self.compat_data_path.exists(): + self.logger.warning(f"Compatdata path '{self.compat_data_path}' not found or invalid for AppID {self.appid}. Cannot detect Proton version via prefix files.") + # Keep self.proton_ver as "Unknown" if config.vdf also failed + return False + + # Method 1: Check system.reg (Primary runtime check) + system_reg_path = self.compat_data_path / "pfx" / "system.reg" + if system_reg_path.exists(): + try: + with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + # Use regex to find the version string + match = re.search(r'"SteamClientProtonVersion"="([^"]+)"\r?', content) + if match: + version_str = match.group(1).strip() + if version_str: + # Check if it's a GE version + if "GE" in version_str.upper(): + self.proton_ver = version_str + else: + self.proton_ver = f"Proton {version_str}" + self.logger.info(f"Detected Proton runtime version from system.reg: {self.proton_ver}") + return True + else: + self.logger.debug("'SteamClientProtonVersion' not found in system.reg.") + + except Exception as e: + self.logger.warning(f"Error reading system.reg: {e}") + else: + self.logger.debug("system.reg not found.") + + # Method 2: Check config_info (Fallback runtime check) + config_info_path = self.compat_data_path / "config_info" + if config_info_path.exists(): + try: + with open(config_info_path, 'r') as f: + version_str = f.readline().strip() + if version_str: + # Check if it's a GE version + if "GE" in version_str.upper(): + self.proton_ver = version_str + else: + self.proton_ver = f"Proton {version_str}" + self.logger.info(f"Detected Proton runtime version from config_info: {self.proton_ver}") + return True + except Exception as e: + self.logger.warning(f"Error reading config_info: {e}") + else: + self.logger.debug("config_info file not found.") + + # If neither method worked + self.logger.warning(f"Could not detect Proton version for AppID {self.appid} from prefix files.") + # self.proton_ver remains "Unknown" from initialization + return False + + def display_modlist_summary(self, skip_confirmation: bool = False) -> bool: + """Display the detected modlist summary and ask for confirmation.""" + if not self.appid or not self.modlist_dir or not self.modlist_ini: + logger.error("Cannot display summary: Missing essential modlist context.") + return False + + # Detect potentially missing info if not already set + if not self.game_name: + self._detect_game_variables() + if not self.proton_ver or self.proton_ver == "Unknown": + self._detect_proton_version() + + # Don't reset timing - continue from Steam Integration timing + print("=== Configuration Summary ===") + print(f"{self._get_progress_timestamp()} Selected Modlist: {self.game_name}") + print(f"{self._get_progress_timestamp()} Game Type: {self.game_var_full if self.game_var_full else 'Unknown'}") + print(f"{self._get_progress_timestamp()} Steam App ID: {self.appid}") + print(f"{self._get_progress_timestamp()} Modlist Directory: {self.modlist_dir}") + print(f"{self._get_progress_timestamp()} ModOrganizer.ini: {self.modlist_dir}/ModOrganizer.ini") + print(f"{self._get_progress_timestamp()} Proton Version: {self.proton_ver if self.proton_ver else 'Unknown'}") + print(f"{self._get_progress_timestamp()} Resolution: {self.selected_resolution if self.selected_resolution else 'Default'}") + print(f"{self._get_progress_timestamp()} Modlist on SD Card: {self.modlist_sdcard}") + print("") + + if skip_confirmation: + return True + # Ask for confirmation + proceed = input(f"{COLOR_PROMPT}Proceed with configuration? (Y/n): {COLOR_RESET}").lower() + if proceed == 'n': # Now defaults to Yes unless 'n' is entered + logger.info("Configuration cancelled by user after summary.") + return False + else: + return True + + def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False): + """ + Runs the actual configuration steps for the selected modlist. + Args: + status_callback (callable, optional): A function to call with status updates during configuration. + manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow). + """ + # Store status_callback for Configuration Summary + self._current_status_callback = status_callback + + self.logger.info("Executing configuration steps...") + + # Ensure required context is set + if not all([self.modlist_dir, self.appid, self.game_var, self.steamdeck is not None]): + self.logger.error("Cannot execute configuration steps: Missing required context (modlist_dir, appid, game_var, steamdeck status).") + print("Error: Missing required information to start configuration.") + return False + + # Step 1: Set protontricks permissions + if status_callback: + # Reset timing for Prefix Configuration section + from jackify.shared.timing import start_new_phase + start_new_phase() + + status_callback("") # Blank line after Configuration Summary + status_callback("") # Extra blank line before Prefix Configuration + status_callback("=== Prefix Configuration ===") + status_callback(f"{self._get_progress_timestamp()} Setting Protontricks permissions") + self.logger.info("Step 1: Setting Protontricks permissions...") + if not self.protontricks_handler.set_protontricks_permissions(self.modlist_dir, self.steamdeck): + self.logger.error("Failed to set Protontricks permissions. Configuration aborted.") + print("Error: Could not set necessary Protontricks permissions.") + return False # Abort on failure + self.logger.info("Step 1: Setting Protontricks permissions... Done") + + # Step 2: Prompt user for manual steps and wait for compatdata + skip_manual_prompt = False + if not manual_steps_completed: + # Check if Proton Experimental is already set and compatdata exists + proton_ok = False + compatdata_ok = False + + # Check Proton version + self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}") + if self._detect_proton_version(): + self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}") + if self.proton_ver and 'experimental' in self.proton_ver.lower(): + proton_ok = True + self.logger.debug("[MANUAL STEPS DEBUG] Proton Experimental detected - proton_ok = True") + else: + self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version") + + # Check compatdata/prefix + prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) + self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}") + + if prefix_path_str and os.path.isdir(prefix_path_str): + compatdata_ok = True + self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory exists - compatdata_ok = True") + else: + self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory does not exist") + + self.logger.debug(f"[MANUAL STEPS DEBUG] proton_ok: {proton_ok}, compatdata_ok: {compatdata_ok}") + + if proton_ok and compatdata_ok: + self.logger.info("Proton Experimental and compatdata already set for this AppID; skipping manual steps prompt.") + skip_manual_prompt = True + else: + self.logger.debug("[MANUAL STEPS DEBUG] Manual steps will be required") + + self.logger.debug(f"[MANUAL STEPS DEBUG] manual_steps_completed: {manual_steps_completed}, skip_manual_prompt: {skip_manual_prompt}") + + if not manual_steps_completed and not skip_manual_prompt: + # Check if we're in GUI mode - if so, don't show CLI prompts, just fail and let GUI callbacks handle it + gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + + if gui_mode: + # In GUI mode: don't show CLI prompts, just fail so GUI can show dialog and retry + self.logger.info("GUI mode detected: skipping CLI manual steps prompt, will fail configuration to trigger GUI callback") + if status_callback: + status_callback("Manual Steam/Proton setup required - this will be handled by GUI dialog") + # Return False to trigger manual steps callback in GUI + return False + else: + # CLI mode: show the traditional CLI prompt + if status_callback: + status_callback("Please perform the manual steps in Steam (set Proton, launch shortcut, then close MO2)...") + self.logger.info("Prompting user to perform manual Steam/Proton steps and launch shortcut.") + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} Please follow the on-screen instructions to set Proton Experimental and launch the shortcut from Steam.") + print("───────────────────────────────────────────────────────────────────") + input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") + self.logger.info("User confirmed completion of manual steps.") + # Step 3: Download and apply curated user.reg.modlist and system.reg.modlist + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration") + self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...") + try: + prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) + if not prefix_path_str or not os.path.isdir(prefix_path_str): + raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.") + user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist" + user_reg_dest = Path(prefix_path_str) / "user.reg" + response = requests.get(user_reg_url, verify=True) + response.raise_for_status() + with open(user_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}") + system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist" + system_reg_dest = Path(prefix_path_str) / "system.reg" + response = requests.get(system_reg_url, verify=True) + response.raise_for_status() + with open(system_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}") + except Exception as e: + self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}") + print(f"{COLOR_ERROR}Error: Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}{COLOR_RESET}") + return False + self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.") + + # Step 4: Install Wine Components + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)") + self.logger.info("Step 4: Installing Wine components (this may take a while)...") + + # Use canonical logic for all modlists/games + components = self.get_modlist_wine_components(self.game_name, self.game_var_full) + + # Detect special games and use vanilla AppID instead of modlist AppID + special_game_type = self.detect_special_game_type(self.modlist_dir) + self.logger.debug(f"Wine components step - modlist_dir='{self.modlist_dir}', special_game_type='{special_game_type}'") + if special_game_type == "fnv": + target_appid = "22380" # Vanilla Fallout New Vegas AppID + self.logger.info("Installing wine components to vanilla FNV compatdata (AppID 22380)") + elif special_game_type == "enderal": + target_appid = "976620" # Enderal: Forgotten Stories Special Edition AppID + self.logger.info("Installing wine components to vanilla Enderal compatdata (AppID 976620)") + else: + target_appid = self.appid # Normal modlist 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.") + print("Error: Failed to install necessary Wine components.") + return False # Abort on failure + self.logger.info("Step 4: Installing Wine components... Done") + + # Step 5: Ensure permissions of Modlist directory + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory") + self.logger.info("Step 5: Setting ownership and permissions for modlist directory...") + # Convert modlist_dir string to Path object for the method + modlist_path_obj = Path(self.modlist_dir) + if not self.filesystem_handler.set_ownership_and_permissions_sudo(modlist_path_obj): + self.logger.error("Failed to set ownership/permissions for modlist directory. Configuration aborted.") + print("Error: Failed to set permissions for the modlist directory.") + return False # Abort on failure + self.logger.info("Step 5: Setting ownership and permissions... Done") + + # Step 6: Backup ModOrganizer.ini + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Backing up ModOrganizer.ini") + self.logger.info(f"Step 6: Backing up {self.modlist_ini}...") + modlist_ini_path_obj = Path(self.modlist_ini) + backup_path = self.filesystem_handler.backup_file(modlist_ini_path_obj) + if not backup_path: + self.logger.error("Failed to back up ModOrganizer.ini. Configuration aborted.") + print("Error: Failed to back up ModOrganizer.ini.") + return False # Abort on failure + self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}") + self.logger.info("Step 6: Backing up ModOrganizer.ini... Done") + + # Step 7a: Detect Stock Game/Game Root path + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Detecting stock game path") + # This method sets self.stock_game_path if found + if not self._detect_stock_game_path(): + self.logger.error("Failed during stock game path detection.") + print("Error: Failed during stock game path detection.") + return False + + # Step 7b: Detect Steam Library Info (Needed for Step 8) + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Detecting Steam Library info") + self.logger.info("Step 7b: Detecting Steam Library info...") + if not self._detect_steam_library_info(): + self.logger.error("Failed to detect necessary Steam Library information.") + print("Error: Could not find Steam library information.") + return False + self.logger.info("Step 7b: Detecting Steam Library info... Done") + + # Step 8: Update ModOrganizer.ini Paths (gamePath, Binary, workingDirectory) + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Updating ModOrganizer.ini paths") + self.logger.info("Step 8: Updating gamePath, Binary, and workingDirectory paths in ModOrganizer.ini...") + + # Update gamePath using replace_gamepath method + modlist_dir_path_obj = Path(self.modlist_dir) + modlist_ini_path_obj = Path(self.modlist_ini) + stock_game_path_obj = Path(self.stock_game_path) if self.stock_game_path else None + # Only call replace_gamepath if we have a valid stock game path + if stock_game_path_obj: + if not self.path_handler.replace_gamepath( + modlist_ini_path=modlist_ini_path_obj, + new_game_path=stock_game_path_obj, + modlist_sdcard=self.modlist_sdcard + ): + self.logger.error("Failed to update gamePath in ModOrganizer.ini. Configuration aborted.") + print("Error: Failed to update game path in ModOrganizer.ini.") + return False # Abort on failure + else: + 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 + # 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 + if not self.path_handler.edit_binary_working_paths( + modlist_ini_path=modlist_ini_path_obj, + modlist_dir_path=modlist_dir_path_obj, + modlist_sdcard=self.modlist_sdcard, + steam_libraries=steam_libraries + ): + self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini. Configuration aborted.") + 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.info("Step 8: Updating ModOrganizer.ini paths... Done") + + # Step 9: Update Resolution Settings (if applicable) + if hasattr(self, 'selected_resolution') and self.selected_resolution: + if status_callback: + 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 + ): + self.logger.warning("Failed to update resolution settings in some INI files.") + print("Warning: Failed to update resolution settings.") + self.logger.info("Step 9: Updating resolution in INI files... Done") + else: + self.logger.info("Step 9: Skipping resolution update (no resolution selected).") + + # Step 10: Create dxvk.conf (skip for special games using vanilla compatdata) + special_game_type = self.detect_special_game_type(self.modlist_dir) + self.logger.debug(f"DXVK step - modlist_dir='{self.modlist_dir}', special_game_type='{special_game_type}'") + + # Force check specific files for debugging + nvse_path = Path(self.modlist_dir) / "nvse_loader.exe" if self.modlist_dir else None + enderal_path = Path(self.modlist_dir) / "Enderal Launcher.exe" if self.modlist_dir else None + self.logger.debug(f"nvse_loader.exe exists: {nvse_path.exists() if nvse_path else 'N/A'}") + self.logger.debug(f"Enderal Launcher.exe exists: {enderal_path.exists() if enderal_path else 'N/A'}") + + if special_game_type: + self.logger.info(f"Step 10: Skipping dxvk.conf creation for {special_game_type.upper()} (uses vanilla compatdata)") + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Skipping dxvk.conf for {special_game_type.upper()} modlist") + else: + if status_callback: + 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 + 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 + ): + 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 + 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(): + try: + plugin_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.") + else: + self.logger.debug("FixGameRegKey.py plugin not found (this is normal).") + self.logger.info("Step 11a: Plugin deletion check complete.") + + # Step 11b: Download Font + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Downloading required font") + 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" + font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf" + font_dest_path = fonts_dir / "seguisym.ttf" + + # Pass quiet=True to suppress print during configuration steps + if not self.filesystem_handler.download_file(font_url, font_dest_path, quiet=True): + self.logger.warning(f"Failed to download {font_url} to {font_dest_path}") + print("Warning: Failed to download necessary font file (seguisym.ttf).") + # Continue anyway, not critical for all lists + else: + self.logger.info("Font downloaded successfully.") + else: + self.logger.error("Could not get WINEPREFIX path, skipping font download.") + print("Warning: Could not determine Wine prefix path, skipping font download.") + + # Step 12: Modlist-specific steps + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Checking for modlist-specific steps") + status_callback("") # Blank line after final Prefix Configuration step + self.logger.info("Step 12: Checking for modlist-specific steps...") + # ... (rest of the inline logic for step 12) ... + + # Step 13: Launch options for special games are now set during automated prefix workflow (before Steam restart) + # This ensures proper timing and avoids the need for a second Steam restart + special_game_type = self.detect_special_game_type(self.modlist_dir) + if special_game_type: + self.logger.info(f"Step 13: Launch options for {special_game_type.upper()} were set during automated workflow") + else: + self.logger.debug("Step 13: No special launch options needed for this modlist type") + + # Do not call status_callback here, the final message is handled in menu_handler + # if status_callback: + # status_callback("Configuration completed successfully!") + + self.logger.info("Configuration steps completed successfully.") + return True # Return True on success + + def _detect_steam_library_info(self) -> bool: + """Detects Steam Library path and whether it's on an SD card.""" + self.logger.debug("Detecting Steam Library path...") + steam_lib_path_str = PathHandler.find_steam_library() + + if not steam_lib_path_str: + self.logger.error("PathHandler.find_steam_library() failed to find a Steam library.") + self.steam_library = None + self.basegame_sdcard = False # Assume not on SD if path not found + return False # Indicate failure + + self.steam_library = steam_lib_path_str + self.logger.info(f"Detected Steam Library: {self.steam_library}") + + # Check if the base game library is on SD card + self.logger.debug(f"Checking if Steam Library {self.steam_library} is on SD card...") + steam_lib_path_obj = Path(self.steam_library) + self.basegame_sdcard = self.filesystem_handler.is_sd_card(steam_lib_path_obj) + self.logger.info(f"Base game library on SD card: {self.basegame_sdcard}") + + return True + + def _detect_stock_game_path(self): + """Detects common 'Stock Game' or 'Game Root' directories within the modlist path.""" + self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...") + if not self.modlist_dir: + self.logger.error("Modlist directory not set, cannot detect stock game path.") + return False + + modlist_path = Path(self.modlist_dir) + common_names = [ + "Stock Game", + "Game Root", + "STOCK GAME", + "Stock Game Folder", + "Stock Folder", + "Skyrim Stock", + Path("root/Skyrim Special Edition") # Special case for some lists + # Add other common names if needed + ] + + found_path = None + for name in common_names: + potential_path = modlist_path / name + if potential_path.is_dir(): + found_path = str(potential_path) + self.logger.info(f"Found potential stock game directory: {found_path}") + break # Found the first match + + if found_path: + self.stock_game_path = found_path + # Suppress print during configuration + # print(f"Step 7a: Found stock game directory: {os.path.basename(found_path)}") + return True + else: + self.stock_game_path = None + self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.") + # Do not print this warning to the user + # print("Step 7a: No common Stock Game/Game Root directory found.") + # Still return True, as the check completed. Lack of this dir isn't always an error. + return True + + def verify_proton_setup(self, appid_to_check: str) -> Tuple[bool, str]: + """Verifies that Proton is correctly set up for a given AppID. + + Checks config.vdf for Proton Experimental and existence of compatdata/pfx dir. + + Args: + appid_to_check: The AppID string to verify. + + Returns: + tuple: (bool success, str status_code) + Status codes: 'ok', 'invalid_appid', 'config_vdf_missing', + 'config_vdf_error', 'proton_check_failed', + 'wrong_proton_version', 'compatdata_missing', + 'prefix_missing' + """ + self.logger.info(f"Verifying Proton setup for AppID: {appid_to_check}") + + if not appid_to_check or not appid_to_check.isdigit(): + self.logger.error("Invalid AppID provided for verification.") + return False, 'invalid_appid' + + proton_tool_name = None + compatdata_path_found = None + prefix_exists = False + + # 1. Find and Parse config.vdf + config_vdf_path = None + possible_steam_paths = [ + Path.home() / ".steam/steam", + Path.home() / ".local/share/Steam", + Path.home() / ".steam/root" + ] + for steam_path in possible_steam_paths: + potential_path = steam_path / "config/config.vdf" + if potential_path.is_file(): + config_vdf_path = potential_path + self.logger.debug(f"Found config.vdf at: {config_vdf_path}") + break + + if not config_vdf_path: + self.logger.error("Could not locate Steam's config.vdf file.") + return False, 'config_vdf_missing' + + # Add a short delay to allow Steam to potentially finish writing changes + self.logger.debug("Waiting 2 seconds before reading config.vdf...") + time.sleep(2) + + try: + self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}") + # CORRECTION: Use the vdf library directly here, not VDFHandler + with open(str(config_vdf_path), 'r') as f: + config_data = vdf.load(f, mapper=vdf.VDFDict) + + # --- Write full config.vdf to a debug file --- + import json + debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt") + with open(debug_dump_path, "w") as dump_f: + json.dump(config_data, dump_f, indent=2) + self.logger.info(f"Full config.vdf dumped to {debug_dump_path}") + + # --- Log only the relevant section for this AppID --- + steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}) + compat_mapping = steam_config_section.get('CompatToolMapping', {}) + app_mapping = compat_mapping.get(appid_to_check, {}) + self.logger.debug("───────────────────────────────────────────────────────────────────") + self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):") + self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2)) + self.logger.debug("───────────────────────────────────────────────────────────────────") + self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}") + # --- End Debugging --- + + # Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name + compat_mapping = steam_config_section.get('CompatToolMapping', {}) + app_mapping = compat_mapping.get(appid_to_check, {}) + proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name' + self.proton_ver = proton_tool_name # Store detected version + + if proton_tool_name: + self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}") + else: + self.logger.warning(f"CompatToolMapping entry not found for AppID {appid_to_check} in config.vdf.") + # Add more debug info here about what *was* found + self.logger.debug(f"CompatToolMapping contents: {json.dumps(compat_mapping.get(appid_to_check, 'Key not found'), indent=2)}") + return False, 'proton_check_failed' # Compatibility not explicitly set + + except FileNotFoundError: + self.logger.error(f"Config.vdf file not found during load attempt: {config_vdf_path}") + return False, 'config_vdf_missing' + except Exception as e: + self.logger.error(f"Error parsing config.vdf: {e}", exc_info=True) + return False, 'config_vdf_error' + + # 2. Check if the correct Proton version is set (allowing variations) + # Target: Proton Experimental + if not proton_tool_name or 'experimental' not in proton_tool_name.lower(): + self.logger.warning(f"Incorrect Proton version detected: '{proton_tool_name}'. Expected 'Proton Experimental'.") + return False, 'wrong_proton_version' + + self.logger.info("Proton version check passed ('Proton Experimental' set).") + + # 3. Check for compatdata / prefix directory existence + possible_compat_bases = [ + Path.home() / ".steam/steam/steamapps/compatdata", + Path.home() / ".local/share/Steam/steamapps/compatdata", + # Add SD card paths if necessary / detectable + # Path("/run/media/mmcblk0p1/steamapps/compatdata") # Example + ] + + compat_dir_found = False + for base_path in possible_compat_bases: + potential_compat_path = base_path / appid_to_check + if potential_compat_path.is_dir(): + self.logger.debug(f"Found compatdata directory: {potential_compat_path}") + compat_dir_found = True + # Check for prefix *within* the found compatdata dir + prefix_path = potential_compat_path / "pfx" + if prefix_path.is_dir(): + self.logger.info(f"Wine prefix directory verified: {prefix_path}") + prefix_exists = True + break # Found both compatdata and prefix, exit loop + else: + self.logger.warning(f"Compatdata directory found, but prefix missing: {prefix_path}") + # Keep searching other base paths in case prefix exists elsewhere + + if not compat_dir_found: + self.logger.error(f"Compatdata directory not found for AppID {appid_to_check} in standard locations.") + return False, 'compatdata_missing' + + if not prefix_exists: + # This means we found compatdata but not pfx inside any of them + self.logger.error(f"Wine prefix directory (pfx) not found within any located compatdata directory for AppID {appid_to_check}.") + return False, 'prefix_missing' + + # All checks passed + self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.") + return True, 'ok' + + def run_modlist_configuration_phase(self, context: dict = None) -> bool: + """ + Main entry point to run the full modlist configuration sequence. + This orchestrates all the individual steps. + """ + self.logger.info(f"Starting configuration phase for modlist: {self.game_name}") + # Call the private method that contains the actual steps + # Pass along the status_callback if it was provided in the context + status_callback = context.get('status_callback') if context else None + return self._execute_configuration_steps(status_callback=status_callback) + + def set_steam_grid_images(self, appid: str, modlist_dir: str): + """ + Copies hero, logo, and poster images from the modlist's SteamIcons directory + to the grid directory of all non-zero Steam user directories, named after the new AppID. + """ + steam_icons_dir = Path(modlist_dir) / "SteamIcons" + if not steam_icons_dir.is_dir(): + self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.") + return + + # Find all non-zero Steam user directories + userdata_base = Path.home() / ".steam/steam/userdata" + if not userdata_base.is_dir(): + self.logger.error(f"Steam userdata directory not found at {userdata_base}") + return + + for user_dir in userdata_base.iterdir(): + if not user_dir.is_dir() or user_dir.name == "0": + continue + grid_dir = user_dir / "config/grid" + grid_dir.mkdir(parents=True, exist_ok=True) + + images = [ + ("grid-hero.png", f"{appid}_hero.png"), + ("grid-logo.png", f"{appid}_logo.png"), + ("grid-tall.png", f"{appid}.png"), + ] + + for src_name, dest_name in images: + src_path = steam_icons_dir / src_name + dest_path = grid_dir / dest_name + if src_path.exists(): + try: + shutil.copyfile(src_path, dest_path) + self.logger.info(f"Copied {src_path} to {dest_path}") + except Exception as e: + self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}") + else: + self.logger.warning(f"Image {src_path} not found; skipping.") + + def get_modlist_wine_components(self, modlist_name, game_var_full=None): + """ + Returns the full list of Wine components to install for a given modlist/game. + - Always includes the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022) + - Adds game-specific extras (from bash script logic) + - Adds any modlist-specific extras (from MODLIST_WINE_COMPONENTS) + """ + default_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] + extras = [] + # 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: + 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"] + # Add modlist-specific extras + modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else "" + for key, components in self.MODLIST_WINE_COMPONENTS.items(): + if key in modlist_lower: + extras += components + # Remove duplicates while preserving order + seen = set() + full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))] + return full_list + + def _is_steam_deck(self): + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + return True + user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True) + if 'app-steam@autostart.service' in user_services.stdout: + return True + except Exception as e: + self.logger.warning(f"Error detecting Steam Deck: {e}") + return False + + def _prompt_or_set_resolution(self): + # If on Steam Deck, set 1280x800 automatically + if self._is_steam_deck(): + self.selected_resolution = "1280x800" + self.logger.info("Steam Deck detected: setting resolution to 1280x800.") + else: + print("Do you wish to set the display resolution? (This can be changed manually later)") + response = input("Set resolution? (y/N): ").strip().lower() + if response == 'y': + while True: + user_res = input("Enter resolution (e.g., 1920x1080): ").strip() + if re.match(r'^[0-9]+x[0-9]+$', user_res): + self.selected_resolution = user_res + self.logger.info(f"User selected resolution: {user_res}") + break + else: + print("Invalid format. Please use format: 1920x1080") + else: + self.selected_resolution = None + self.logger.info("Resolution setup skipped by user.") + + def detect_special_game_type(self, modlist_dir: str) -> Optional[str]: + """ + Detect if this modlist requires vanilla compatdata instead of new prefix. + + Detects special game types that need to use existing vanilla game compatdata: + - FNV: Has nvse_loader.exe + - Enderal: Has Enderal Launcher.exe + + Args: + modlist_dir: Path to the modlist installation directory + + Returns: + str: Game type ("fnv", "enderal") or None if not a special game + """ + if not modlist_dir: + return None + + modlist_path = Path(modlist_dir) + if not modlist_path.exists() or not modlist_path.is_dir(): + self.logger.debug(f"Modlist directory does not exist: {modlist_dir}") + return None + + self.logger.debug(f"Checking for special game type in: {modlist_dir}") + + # Check ModOrganizer.ini for indicators (nvse/enderal) as an early, robust signal + try: + mo2_ini = modlist_path / "ModOrganizer.ini" + if mo2_ini.exists(): + try: + content = mo2_ini.read_text(errors='ignore').lower() + if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content: + self.logger.info("Detected FNV via ModOrganizer.ini markers") + return "fnv" + # Look for Enderal-specific patterns, not just the word "enderal" + if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']): + self.logger.info("Detected Enderal via ModOrganizer.ini markers") + return "enderal" + except Exception as e: + self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}") + except Exception: + pass + + # Check for FNV (Fallout New Vegas) and Enderal launchers in common locations + candidates = [modlist_path] + try: + # Include common stock game subfolders if present + from .path_handler import STOCK_GAME_FOLDERS + for folder_name in STOCK_GAME_FOLDERS: + sub = modlist_path / folder_name + if sub.exists() and sub.is_dir(): + candidates.append(sub) + except Exception: + # If import fails, continue with root-only + pass + + for base in candidates: + nvse_loader = base / "nvse_loader.exe" + if nvse_loader.exists(): + self.logger.info(f"Detected FNV modlist: found nvse_loader.exe in '{base}'") + return "fnv" + enderal_launcher = base / "Enderal Launcher.exe" + if enderal_launcher.exists(): + self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'") + return "enderal" + + # As a final heuristic, use known game type if available in handler state + try: + game_type = getattr(self, 'game_var', None) + if isinstance(game_type, str): + gt = game_type.strip().lower() + if 'fallout new vegas' in gt or gt == 'fnv': + self.logger.info("Heuristic detection: game_var indicates FNV") + return "fnv" + if 'enderal' in gt: + self.logger.info("Heuristic detection: game_var indicates Enderal") + return "enderal" + except Exception: + pass + + # Not a special game type + 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) \ No newline at end of file diff --git a/jackify/backend/handlers/modlist_install_cli.py b/jackify/backend/handlers/modlist_install_cli.py new file mode 100644 index 0000000..49e2e59 --- /dev/null +++ b/jackify/backend/handlers/modlist_install_cli.py @@ -0,0 +1,1100 @@ +""" +modlist_install_cli.py +Discovery phase for Jackify's modlist install CLI feature. +""" +import os +from pathlib import Path +from typing import Optional, Dict, List, Any, Union +from .protontricks_handler import ProtontricksHandler +from .shortcut_handler import ShortcutHandler +from .menu_handler import MenuHandler, ModlistMenuHandler +from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION +# Standard logging (no file handler) - LoggingHandler import removed +import re +import subprocess +import logging +import sys +import json +import shlex +import time +import pty +# from src.core.compressonator import run_compressonatorcli # TODO: Implement compressonator integration + +# Import UI Colors first - these should always be available +from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SELECTION, COLOR_WARNING + +# Standard logging (no file handler) - LoggingHandler import removed + +# Attempt to import readline for tab completion +READLINE_AVAILABLE = False +try: + import readline + READLINE_AVAILABLE = True + # Check if running in a non-interactive environment (e.g., some CI) + if 'libedit' in readline.__doc__: + # libedit doesn't support set_completion_display_matches_hook + pass + # Add other potential checks if needed +except ImportError: + # readline not available on Windows or potentially minimal environments + pass +except Exception as e: + # Catch other potential errors during readline import/setup + logging.warning(f"Readline import failed: {e}") # Use standard logging before our handler + pass + +# Initialize logger for the module +logger = logging.getLogger(__name__) # Standard logger init + +# Helper function to get path to jackify-install-engine +def get_jackify_engine_path(): + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + # Running in a PyInstaller bundle + # Engine is expected at /jackify/engine/jackify-engine + return os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine') + else: + # Running in a normal Python environment from source + # Current file is in src/jackify/backend/handlers/modlist_install_cli.py + # Engine is at src/jackify/engine/jackify-engine + current_file_dir = os.path.dirname(os.path.abspath(__file__)) + # Navigate up from src/jackify/backend/handlers/ to src/jackify/ + jackify_dir = os.path.dirname(os.path.dirname(current_file_dir)) + return os.path.join(jackify_dir, 'engine', 'jackify-engine') + +class ModlistInstallCLI: + """ + Handles the discovery phase for installing a Wabbajack modlist via CLI. + """ + def __init__(self, menu_handler: MenuHandler, steamdeck: bool = False): + self.menu_handler = menu_handler + self.steamdeck = steamdeck + self.protontricks_handler = ProtontricksHandler(steamdeck=steamdeck) + self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck) + self.context = {} + # Use standard logging (no file handler) + self.logger = logging.getLogger(__name__) + self.logger.propagate = False # Prevent duplicate logs if root logger is also configured + + def run_discovery_phase(self, context_override=None) -> Optional[Dict]: + """ + Run the discovery phase: prompt for all required info, and validate inputs. + Returns a context dict with all collected info, or None if cancelled. + Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow). + """ + self.logger.info("Starting modlist discovery phase (restored logic).") + print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}") + + if context_override: + self.context.update(context_override) + if 'resolution' in context_override: + self.context['resolution'] = context_override['resolution'] + else: + self.context = {} + + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + # Only require game_type for non-Tuxborn workflows + if self.context.get('machineid'): + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key'] + else: + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type'] + has_modlist = self.context.get('modlist_value') or self.context.get('machineid') + missing = [k for k in required_keys if not self.context.get(k)] + if is_gui_mode: + if missing or not has_modlist: + print(f"ERROR: Missing required arguments for GUI workflow: {', '.join(missing)}") + if not has_modlist: + print("ERROR: Missing modlist_value or machineid for GUI workflow.") + print("This workflow must be fully non-interactive. Please report this as a bug if you see this message.") + return None + self.logger.info("All required context present in GUI mode, skipping prompts.") + return self.context + + # Get engine path using the helper + engine_executable = get_jackify_engine_path() + self.logger.debug(f"Engine executable path: {engine_executable}") + + if not os.path.exists(engine_executable): + self.logger.error(f"jackify-install-engine not found at {engine_executable}") + print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") + print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") + return None + + engine_dir = os.path.dirname(engine_executable) + + # 1. Prompt for modlist source (unless using machineid from context_override) + if 'machineid' not in self.context: + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu") + source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() + self.logger.debug(f"User selected modlist source option: {source_choice}") + + if source_choice == '1': + self.context['modlist_source_type'] = 'online_list' + print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}") + try: + env = os.environ.copy() + env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" + self.logger.info("Setting DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 for jackify-engine process.") + + # Use the engine path from the helper function, but the command structure from restored. + engine_executable_path_for_subprocess = get_jackify_engine_path() + command = [engine_executable_path_for_subprocess, 'list-modlists', '--show-all-sizes', '--show-machine-url'] + self.logger.info(f"Executing command: {' '.join(command)} in CWD: {engine_dir}") + + # check=True as in restored logic + result = subprocess.run( + command, + capture_output=True, text=True, check=True, + env=env, cwd=engine_dir + ) + + # self.logger.debug(f"Engine stdout (raw):\n{result.stdout}") # COMMENTED OUT - too verbose + + lines = result.stdout.splitlines() + + # Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL + # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] + raw_modlists_from_engine = [] + for line in lines: + line = line.strip() + if not line or line.startswith('Loading') or line.startswith('Loaded'): + continue + + # Extract status indicators + status_down = '[DOWN]' in line + status_nsfw = '[NSFW]' in line + + # 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(' - ') + if len(parts) != 4: + continue # Skip malformed lines + + modlist_name = parts[0].strip() + game_name = parts[1].strip() + sizes_str = parts[2].strip() + machine_url = parts[3].strip() + + # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") + size_parts = sizes_str.split('|') + if len(size_parts) != 3: + continue # Skip if sizes don't match expected format + + download_size = size_parts[0].strip() + install_size = size_parts[1].strip() + total_size = size_parts[2].strip() + + # Skip if any required data is missing + if not modlist_name or not game_name or not machine_url: + continue + + raw_modlists_from_engine.append({ + 'id': modlist_name, # Use modlist name as ID for compatibility + 'name': modlist_name, + 'game': game_name, + 'download_size': download_size, + 'install_size': install_size, + 'total_size': total_size, + 'machine_url': machine_url, # Store machine URL for installation + 'status_down': status_down, + 'status_nsfw': status_nsfw + }) + + self.logger.info(f"Scraped {len(raw_modlists_from_engine)} modlists after revised regex and filtering logic.") + + if not raw_modlists_from_engine: + print(f"{COLOR_WARNING}No modlists found after applying revised regex and filtering logic.{COLOR_RESET}") + return None + + # EXACT game_type_map and grouping logic from restored file + game_type_map = { + '1': ('Skyrim', ['Skyrim', 'Skyrim Special Edition']), + '2': ('Fallout 4', ['Fallout 4']), + '3': ('Fallout New Vegas', ['Fallout New Vegas']), + '4': ('Oblivion', ['Oblivion']), + '5': ('Other Games', None) # Using None as in restored for keyword list + } + + grouped_modlists = {k: [] for k in game_type_map} + + for m_info in raw_modlists_from_engine: # m_info is like {'id': ..., 'game': ...} + found_category = False + for cat_key, (cat_label, cat_keywords) in game_type_map.items(): + if cat_key == '5': # Skip 'Other Games' for direct matching initially + continue + if cat_keywords: # Ensure there are keywords to check (handles 'Other Games' with None) + for keyword in cat_keywords: + if keyword.lower() in m_info['game'].lower(): + grouped_modlists[cat_key].append(m_info) + found_category = True + break # Found category for this modlist + if found_category: + break # Move to next modlist + if not found_category: + grouped_modlists['5'].append(m_info) # Add to 'Other Games' + + selected_modlist_info = None # Will store {'id': ..., 'game': ...} + while not selected_modlist_info: + print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}") + + category_display_map = {} # Maps displayed number to actual game_type_map key + display_idx = 1 + # Iterate in a defined order for consistent menu + for cat_key_ordered in ['1','2','3','4','5']: + if cat_key_ordered in grouped_modlists and grouped_modlists[cat_key_ordered]: # Only show if non-empty + cat_label = game_type_map[cat_key_ordered][0] + print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {cat_label} ({len(grouped_modlists[cat_key_ordered])} modlists)") + category_display_map[str(display_idx)] = cat_key_ordered + display_idx += 1 + + if display_idx == 1: # No categories had any modlists + print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}") + return None + + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel") + + game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip() + if game_cat_choice == '0': + self.logger.info("User cancelled game category selection.") + return None + + actual_cat_key = category_display_map.get(game_cat_choice) + if not actual_cat_key: + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + continue + + # modlist_group_for_game is a list of dicts like {'id': ..., 'game': ...} + modlist_group_for_game = sorted(grouped_modlists[actual_cat_key], key=lambda x: x['id'].lower()) + + print(f"\n{COLOR_SUCCESS}Available Modlists for {game_type_map[actual_cat_key][0]}:{COLOR_RESET}") + for idx, m_detail in enumerate(modlist_group_for_game, 1): + if actual_cat_key == '5': # 'Other Games' category + print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']} ({m_detail['game']})") + else: + print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']}") + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories") + + while True: + mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip() + if mod_choice_idx_str == '0': + break + if mod_choice_idx_str.isdigit(): + mod_idx = int(mod_choice_idx_str) - 1 + if 0 <= mod_idx < len(modlist_group_for_game): + selected_modlist_info = modlist_group_for_game[mod_idx] + self.context['modlist_source'] = 'identifier' + # Use machine_url for installation, display name for suggestions + self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id']) + self.context['modlist_game'] = selected_modlist_info['game'] + self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1] + self.logger.info(f"User selected online modlist: {selected_modlist_info}") + break + else: + print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") + if selected_modlist_info: + break + + except subprocess.CalledProcessError as e: + self.logger.error(f"list-modlists failed. Code: {e.returncode}") + if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") + if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") + print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_RESET}") + return None + except FileNotFoundError: + self.logger.error(f"Engine not found: {engine_executable_path_for_subprocess}") + print(f"{COLOR_ERROR}Critical error: jackify-install-engine not found.{COLOR_RESET}") + return None + except Exception as e: + self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) + print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}") + return None + + elif source_choice == '2': + self.context['modlist_source_type'] = 'local_file' + print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}") + modlist_path = self.menu_handler.get_existing_file_path( + prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", + extension_filter=".wabbajack", # Ensure this is the exact filter used by the method + no_header=True # To avoid re-printing a header if get_existing_file_path has one + ) + if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q' + self.logger.info("User cancelled .wabbajack file selection.") + print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}") + return None + + self.context['modlist_source'] = 'path' # For install command + self.context['modlist_value'] = str(modlist_path) + # Suggest a name based on the file + self.context['modlist_name_suggestion'] = Path(modlist_path).stem + self.logger.info(f"User selected local .wabbajack file: {modlist_path}") + + elif source_choice == '0': + self.logger.info("User cancelled modlist source selection.") + print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}") + return None + else: + self.logger.warning(f"Invalid modlist source choice: {source_choice}") + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + return self.run_discovery_phase() # Re-prompt + + # --- Prompts for install_dir, download_dir, modlist_name, api_key --- + # (This part is largely similar to the restored version, adapt as needed) + # It will use self.context['modlist_name_suggestion'] if available. + + # 2. Prompt for modlist name (skip if 'modlist_name' already in context from override) + if 'modlist_name' not in self.context or not self.context['modlist_name']: + default_name = self.context.get('modlist_name_suggestion', 'MyModlist') + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}") + print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}") + modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() + if not modlist_name_input: # User hit enter for default + modlist_name = default_name + elif modlist_name_input.lower() == 'q': + self.logger.info("User cancelled at modlist name prompt.") + return None + else: + modlist_name = modlist_name_input + self.context['modlist_name'] = modlist_name + self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}") + + # 3. Prompt for install directory + if 'install_dir' not in self.context: + # Use configurable base directory + config_handler = ConfigHandler() + base_install_dir = Path(config_handler.get_modlist_install_base_dir()) + default_install_dir = base_install_dir / self.context['modlist_name'] + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}") + print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}") + install_dir_path = self.menu_handler.get_directory_path( + prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", + default_path=default_install_dir, + create_if_missing=True, + no_header=True + ) + if install_dir_path is None: + self.logger.info("User cancelled at install directory prompt.") + return None + self.context['install_dir'] = install_dir_path + self.logger.debug(f"Install directory context set to: {self.context['install_dir']}") + + # 4. Prompt for download directory + if 'download_dir' not in self.context: + # Use configurable base directory for downloads + config_handler = ConfigHandler() + base_download_dir = Path(config_handler.get_modlist_downloads_base_dir()) + default_download_dir = base_download_dir / self.context['modlist_name'] + + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}") + print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}") + download_dir_path = self.menu_handler.get_directory_path( + prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", + default_path=default_download_dir, + create_if_missing=True, + no_header=True + ) + if download_dir_path is None: + self.logger.info("User cancelled at download directory prompt.") + return None + self.context['download_dir'] = download_dir_path + self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") + + # 5. Prompt for Nexus API key (skip if in context) + if 'nexus_api_key' not in self.context: + 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("\n" + "-" * 28) + 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_SUCCESS}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("\n" + "-" * 28) + print(f"{COLOR_INFO}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': + self.logger.info("User cancelled or provided no API key.") + return None + 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_SUCCESS}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}") + self.context['nexus_api_key'] = api_key + self.logger.debug(f"NEXUS_API_KEY is set in environment for engine (presence check).") + + # Display summary and confirm + self._display_summary() # Ensure this method exists or implement it + if self.context.get('skip_confirmation'): + confirm = 'y' + else: + confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower() + if confirm != 'y': + self.logger.info("User cancelled at final confirmation.") + print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") + return None + + self.logger.info("Discovery phase complete.") # Log completion first + + # Create a copy of the context for logging, so we don't alter the original + context_for_logging = self.context.copy() + if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None: + context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging + + self.logger.info(f"Context: {context_for_logging}") # Log the redacted context + return self.context + + def _display_summary(self): + """ + Display a summary of the collected context (excluding API key). + """ + print(f"\n{COLOR_INFO}--- Summary of Collected Information ---{COLOR_RESET}") + if self.context.get('modlist_source_type') == 'online_list': + print(f"Modlist Source: Selected from online list") + print(f"Modlist Identifier: {self.context.get('modlist_value')}") + print(f"Detected Game: {self.context.get('modlist_game', 'N/A')}") + elif self.context.get('modlist_source_type') == 'local_file': + print(f"Modlist Source: Local .wabbajack file") + print(f"File Path: {self.context.get('modlist_value')}") + elif 'machineid' in self.context: # For Tuxborn/override flow + print(f"Modlist Identifier (Tuxborn/MachineID): {self.context.get('machineid')}") + + print(f"Steam Shortcut Name: {self.context.get('modlist_name', 'N/A')}") + + install_dir_display = self.context.get('install_dir') + if isinstance(install_dir_display, tuple): + install_dir_display = install_dir_display[0] # Get the Path object from (Path, bool) + print(f"Install Directory: {install_dir_display}") + + download_dir_display = self.context.get('download_dir') + if isinstance(download_dir_display, tuple): + download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool) + print(f"Download Directory: {download_dir_display}") + + if self.context.get('nexus_api_key'): + print(f"Nexus API Key: [SET]") + else: + print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") + print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") + + def configuration_phase(self): + """ + Run the configuration phase: execute the Linux-native Jackify Install Engine. + """ + import subprocess + import time + import sys + from pathlib import Path + # UI Colors and LoggingHandler already imported at module level + print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}") + start_time = time.time() + + # --- BEGIN: TEE LOGGING SETUP & LOG ROTATION --- + log_dir = Path.home() / "Jackify" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + workflow_log_path = log_dir / "Modlist_Install_workflow.log" + # Log rotation: keep last 3 logs, 1MB each (adjust as needed) + max_logs = 3 + max_size = 1024 * 1024 # 1MB + 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"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path + dest = log_dir / f"Modlist_Install_workflow.log.{i}" + if prev.exists(): + if dest.exists(): + dest.unlink() + prev.rename(dest) + workflow_log = open(workflow_log_path, 'a') + 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() + orig_stdout, orig_stderr = sys.stdout, sys.stderr + sys.stdout = TeeStdout(sys.stdout, workflow_log) + sys.stderr = TeeStdout(sys.stderr, workflow_log) + # --- END: TEE LOGGING SETUP & LOG ROTATION --- + try: + # --- Process Paths from context --- + install_dir_context = self.context['install_dir'] + if isinstance(install_dir_context, tuple): + actual_install_path = Path(install_dir_context[0]) + if install_dir_context[1]: # Second element is True if creation was intended + self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}") + actual_install_path.mkdir(parents=True, exist_ok=True) + else: # Should be a Path object or string already + actual_install_path = Path(install_dir_context) + install_dir_str = str(actual_install_path) + self.logger.debug(f"Processed install directory for engine: {install_dir_str}") + + download_dir_context = self.context['download_dir'] + if isinstance(download_dir_context, tuple): + actual_download_path = Path(download_dir_context[0]) + if download_dir_context[1]: # Second element is True if creation was intended + self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}") + actual_download_path.mkdir(parents=True, exist_ok=True) + else: # Should be a Path object or string already + actual_download_path = Path(download_dir_context) + download_dir_str = str(actual_download_path) + self.logger.debug(f"Processed download directory for engine: {download_dir_str}") + # --- End Process Paths --- + + modlist_arg = self.context.get('modlist_value') or self.context.get('machineid') + machineid = self.context.get('machineid') + api_key = self.context['nexus_api_key'] + + # Path to the engine binary + engine_path = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_path) + if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): + print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}") + return + + # --- Patch for GUI/auto: always set modlist_source to 'identifier' if not set, and ensure modlist_value is present --- + if os.environ.get('JACKIFY_GUI_MODE') == '1': + if not self.context.get('modlist_source'): + self.context['modlist_source'] = 'identifier' + if not self.context.get('modlist_value'): + print(f"{COLOR_ERROR}ERROR: modlist_value is missing in context for GUI workflow!{COLOR_RESET}") + self.logger.error("modlist_value is missing in context for GUI workflow!") + return + # --- End Patch --- + + # Build command + cmd = [engine_path, 'install'] + + # Check for debug mode and pass --debug to engine if needed + 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("Debug mode enabled in config - passing --debug flag to jackify-engine") + + # Determine if this is a local .wabbajack file or an online modlist + modlist_value = self.context.get('modlist_value') + machineid = self.context.get('machineid') + + # Check if there's a cached .wabbajack file for this modlist + cached_wabbajack_path = None + 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") + 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): + cmd += ['-w', modlist_value] + self.logger.info(f"Using local .wabbajack file: {modlist_value}") + elif cached_wabbajack_path and os.path.isfile(cached_wabbajack_path): + cmd += ['-w', cached_wabbajack_path] + self.logger.info(f"Using cached .wabbajack file: {cached_wabbajack_path}") + elif modlist_value: + cmd += ['-m', modlist_value] + self.logger.info(f"Using modlist identifier: {modlist_value}") + elif machineid: + cmd += ['-m', machineid] + self.logger.info(f"Using machineid: {machineid}") + cmd += ['-o', install_dir_str, '-d', download_dir_str] + + # Store original environment values to restore later + original_env_values = { + 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), + 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') + } + + try: + # Temporarily modify current process's environment + if api_key: + os.environ['NEXUS_API_KEY'] = api_key + self.logger.debug(f"Temporarily set os.environ['NEXUS_API_KEY'] for engine call using session-provided key.") + elif 'NEXUS_API_KEY' in os.environ: # api_key is None/empty, but a system key might exist + self.logger.debug(f"Session API key not provided. Temporarily removing inherited NEXUS_API_KEY ('{'[REDACTED]' if os.environ.get('NEXUS_API_KEY') else 'None'}') from os.environ for engine call to ensure it is not used.") + del os.environ['NEXUS_API_KEY'] + # If api_key is None and NEXUS_API_KEY was not in os.environ, it remains unset, which is correct. + + os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" + self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.") + + self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.") + self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}") + + pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) + print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}") + + # Temporarily increase file descriptor limit for engine process + from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit + success, old_limit, new_limit, message = increase_file_descriptor_limit() + if success: + self.logger.debug(f"File descriptor limit: {message}") + else: + self.logger.warning(f"File descriptor limit: {message}") + + # Popen now inherits the modified os.environ because env=None + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=None, cwd=engine_dir) + + # Start performance monitoring for the engine process + # Adjust monitoring based on debug mode + if debug_mode: + # More aggressive monitoring in debug mode + performance_monitor = EnginePerformanceMonitor( + logger=self.logger, + stall_threshold=5.0, # CPU below 5% is considered stalled + stall_duration=60.0, # 1 minute of low CPU = stall (faster detection) + sample_interval=5.0 # Check every 5 seconds (more frequent) + ) + # Add debug callback for detailed metrics + from .engine_monitor import create_debug_callback + performance_monitor.add_callback(create_debug_callback(self.logger)) + self.logger.info("Enhanced performance monitoring enabled for debug mode") + else: + # Standard monitoring + performance_monitor = EnginePerformanceMonitor( + logger=self.logger, + stall_threshold=5.0, # CPU below 5% is considered stalled + stall_duration=120.0, # 2 minutes of low CPU = stall + sample_interval=10.0 # Check every 10 seconds + ) + + # Add callback to alert about performance issues + def stall_alert(message: str): + print(f"\nWarning: {message}") + print("If the process appears stuck, you may need to restart it.") + if debug_mode: + print("Debug mode: Use 'python -m jackify.backend.handlers.diagnostic_helper' for detailed analysis") + + performance_monitor.add_callback(create_stall_alert_callback(self.logger, stall_alert)) + + # Start monitoring + monitoring_started = performance_monitor.start_monitoring(proc.pid) + if monitoring_started: + self.logger.info(f"Performance monitoring started for engine PID {proc.pid}") + else: + self.logger.warning("Failed to start performance monitoring") + + try: + # Read output in binary mode to properly handle carriage returns + buffer = b'' + last_progress_time = time.time() + + while True: + chunk = proc.stdout.read(1) + if not chunk: + break + buffer += chunk + + # Process complete lines or carriage return updates + if chunk == b'\n': + # Complete line - decode and print + line = buffer.decode('utf-8', errors='replace') + print(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='') + sys.stdout.flush() + buffer = b'' + last_progress_time = time.time() + + # Check for timeout (no output for too long) + current_time = time.time() + if current_time - last_progress_time > 300: # 5 minutes no output + self.logger.warning("No output from engine for 5 minutes - possible stall") + last_progress_time = current_time # Reset to avoid spam + + # Print any remaining buffer content + if buffer: + line = buffer.decode('utf-8', errors='replace') + print(line, end='') + + proc.wait() + + finally: + # Stop performance monitoring and get summary + if monitoring_started: + performance_monitor.stop_monitoring() + summary = performance_monitor.get_metrics_summary() + + if summary: + self.logger.info(f"Engine Performance Summary: " + f"Duration: {summary.get('monitoring_duration', 0):.1f}s, " + f"Avg CPU: {summary.get('avg_cpu_percent', 0):.1f}%, " + f"Max Memory: {summary.get('max_memory_mb', 0):.1f}MB, " + f"Stalls: {summary.get('stall_percentage', 0):.1f}%") + + # Log detailed summary for debugging + self.logger.debug(f"Detailed performance summary: {summary}") + if proc.returncode != 0: + print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") + self.logger.error(f"Engine exited with code {proc.returncode}.") + return # Configuration phase failed + self.logger.info(f"Engine completed with code {proc.returncode}.") + except Exception as e: + print(f"{COLOR_ERROR}Error running Jackify Install Engine: {e}{COLOR_RESET}\n") + self.logger.error(f"Exception running engine: {e}", exc_info=True) + return # Configuration phase failed + finally: + # Restore original environment state + for key, original_value in original_env_values.items(): + current_value_in_os_environ = os.environ.get(key) # Value after Popen and before our restoration for this key + + # Determine display values for logging, redacting NEXUS_API_KEY + display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'" + # display_current_value_before_restore = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{current_value_in_os_environ}'" + + if original_value is not None: + # Original value existed. We must restore it. + if current_value_in_os_environ != original_value: + os.environ[key] = original_value + self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.") + else: + # If current value is already the original, ensure it's correctly set (os.environ[key] = original_value is harmless) + os.environ[key] = original_value # Ensure it is set + self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.") + else: + # Original value was None (key was not in os.environ initially). + if key in os.environ: # If it's in os.environ now, it means we must have set it or it was set by other means. + self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.") + del os.environ[key] + # If original_value was None and key is not in os.environ now, nothing to do. + + except Exception as e: + print(f"{COLOR_ERROR}Error during Tuxborn installation workflow: {e}{COLOR_RESET}\n") + self.logger.error(f"Exception in Tuxborn workflow: {e}", exc_info=True) + return + finally: + # --- BEGIN: RESTORE STDOUT/STDERR --- + sys.stdout = orig_stdout + sys.stderr = orig_stderr + workflow_log.close() + # --- END: RESTORE STDOUT/STDERR --- + + elapsed = int(time.time() - start_time) + print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n") + print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n") + if self.context.get('machineid') != 'Tuxborn/Tuxborn': + print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}") + # After install, use self.context['modlist_game'] to determine if configuration should be offered + # After install, detect game type from ModOrganizer.ini + modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini") + detected_game = None + if os.path.isfile(modorganizer_ini): + from .modlist_handler import ModlistHandler + handler = ModlistHandler({}, steamdeck=self.steamdeck) + handler.modlist_ini = modorganizer_ini + handler.modlist_dir = install_dir_str + if handler._detect_game_variables(): + detected_game = handler.game_var_full + supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"] + is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn' + if (detected_game in supported_games) or is_tuxborn: + shortcut_name = self.context.get('modlist_name') + if is_tuxborn and not shortcut_name: + self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'") + shortcut_name = "Tuxborn Automatic Installer" # Provide a fallback default + elif not shortcut_name: # For non-Tuxborn, prompt if missing + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}") + raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip() + if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name: + return + shortcut_name = raw_shortcut_name + + # Check if GUI mode to skip interactive prompts + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + + if not is_gui_mode: + # Prompt user if they want to configure Steam shortcut now + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now?{COLOR_RESET}") + configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() + + if configure_choice == 'n': + print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}") + return + + # Proceed with Steam configuration + self.logger.info(f"Starting Steam configuration for '{shortcut_name}'") + + # Step 1: Create Steam shortcut first + mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe') + + # Use the working shortcut creation process from legacy code + from .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" + ) + + 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 + + # 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.") + + 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 .menu_handler import ModlistMenuHandler + from .config_handler import ConfigHandler + config_handler = ConfigHandler() + menu_handler = ModlistMenuHandler(config_handler) + menu_handler._display_manual_proton_steps(shortcut_name) + + input(f"\n{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") + + # Get the updated AppID after launch + 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 + else: + print(f"{COLOR_ERROR}Could not find valid AppID after launch. Configuration may not work properly.{COLOR_RESET}") + 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 .menu_handler import ModlistMenuHandler + from .config_handler import ConfigHandler + + config_handler = ConfigHandler() + modlist_menu = ModlistMenuHandler(config_handler) + + self.logger.info("Running post-installation configuration phase") + configuration_success = modlist_menu.run_modlist_configuration_phase(config_context) + + if configuration_success: + self.logger.info("Post-installation configuration completed successfully") + else: + self.logger.warning("Post-installation configuration had issues") + else: + # Game not supported for automated configuration + print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}") + if detected_game: + print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}") + else: + print(f"{COLOR_WARNING}Could not detect game type from ModOrganizer.ini for automated configuration.{COLOR_RESET}") + print(f"{COLOR_INFO}You may need to manually configure the modlist for Steam/Proton.{COLOR_RESET}") + + def install_modlist(self, selected_modlist_info: Optional[Dict[str, Any]] = None, wabbajack_file_path: Optional[Union[str, Path]] = None): + # This is where we would get the engine path for the actual installation + engine_path = get_jackify_engine_path() # Use the helper + self.logger.info(f"Using engine path for installation: {engine_path}") + + # --- The rest of your install_modlist logic --- + # ... + # When constructing the subprocess command for install, use `engine_path` + # For example: + # install_command = [engine_path, 'install', '--modlist-url', modlist_url, ...] + # ... + self.logger.info("Placeholder for actual modlist installation logic using the engine.") + print("Modlist installation logic would run here.") + return True # Placeholder + + def _get_nexus_api_key(self) -> Optional[str]: + # This method is not provided in the original file or the code block + # It's assumed to exist as it's called in the _display_summary method + # Implement the logic to retrieve the Nexus API key from the context + return self.context.get('nexus_api_key') + + def get_all_modlists_from_engine(self, game_type=None): + """ + Call the Jackify engine with 'list-modlists' and return a list of modlist dicts. + Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags. + + Args: + game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas") + """ + import subprocess + import re + from pathlib import Path + # COLOR_ERROR already imported at module level + engine_executable = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_executable) + if not os.path.exists(engine_executable): + self.logger.error(f"jackify-install-engine not found at {engine_executable}") + print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_ERROR}") + return [] + env = os.environ.copy() + env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" + command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url'] + + # Add game filter if specified + if game_type: + command.extend(['--game', game_type]) + try: + result = subprocess.run( + command, + capture_output=True, text=True, check=True, + env=env, cwd=engine_dir + ) + lines = result.stdout.splitlines() + modlists = [] + for line in lines: + line = line.strip() + if not line or line.startswith('Loading') or line.startswith('Loaded'): + continue + + # Parse the new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL + # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] + + # Extract status indicators + status_down = '[DOWN]' in line + status_nsfw = '[NSFW]' in line + + # 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(' - ') + if len(parts) != 4: + continue # Skip malformed lines + + modlist_name = parts[0].strip() + game_name = parts[1].strip() + sizes_str = parts[2].strip() + machine_url = parts[3].strip() + + # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") + size_parts = sizes_str.split('|') + if len(size_parts) != 3: + continue # Skip if sizes don't match expected format + + download_size = size_parts[0].strip() + install_size = size_parts[1].strip() + total_size = size_parts[2].strip() + + # Skip if any required data is missing + if not modlist_name or not game_name or not machine_url: + continue + + modlists.append({ + 'id': modlist_name, # Use modlist name as ID for compatibility + 'name': modlist_name, + 'game': game_name, + 'download_size': download_size, + 'install_size': install_size, + 'total_size': total_size, + 'machine_url': machine_url, # Store machine URL for installation + 'status_down': status_down, + 'status_nsfw': status_nsfw + }) + return modlists + except subprocess.CalledProcessError as e: + self.logger.error(f"list-modlists failed. Code: {e.returncode}") + if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") + if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") + print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}") + return [] + except Exception as e: + self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) + print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}") + return [] + + def _display_summary(self): + # REMOVE pass AND RESTORE THE METHOD BODY + # print(f"{COLOR_WARNING}DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}{COLOR_RESET}") # Keep commented + # self.logger.info(f"DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}") # Keep commented + print(f"\n{COLOR_INFO}--- Summary of Collected Information ---{COLOR_RESET}") + if self.context.get('modlist_source_type') == 'online_list': + print(f"Modlist Source: Selected from online list") + print(f"Modlist Identifier: {self.context.get('modlist_value')}") + print(f"Detected Game: {self.context.get('modlist_game', 'N/A')}") + elif self.context.get('modlist_source_type') == 'local_file': + print(f"Modlist Source: Local .wabbajack file") + print(f"File Path: {self.context.get('modlist_value')}") + elif 'machineid' in self.context: # For Tuxborn/override flow + print(f"Modlist Identifier (Tuxborn/MachineID): {self.context.get('machineid')}") + + print(f"Steam Shortcut Name: {self.context.get('modlist_name', 'N/A')}") + + install_dir_display = self.context.get('install_dir') + if isinstance(install_dir_display, tuple): + install_dir_display = install_dir_display[0] # Get the Path object from (Path, bool) + print(f"Install Directory: {install_dir_display}") + + download_dir_display = self.context.get('download_dir') + if isinstance(download_dir_display, tuple): + download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool) + print(f"Download Directory: {download_dir_display}") + + if self.context.get('nexus_api_key'): + print(f"Nexus API Key: [SET]") + else: + print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") + print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") \ No newline at end of file diff --git a/jackify/backend/handlers/path_handler.py b/jackify/backend/handlers/path_handler.py new file mode 100644 index 0000000..2ec36df --- /dev/null +++ b/jackify/backend/handlers/path_handler.py @@ -0,0 +1,963 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Path Handler Module +Handles path-related operations for ModOrganizer.ini and other configuration files +""" + +import os +import re +import logging +import shutil +from pathlib import Path +from typing import Optional, Union, Dict, Any, List, Tuple +from datetime import datetime + +# Initialize logger +logger = logging.getLogger(__name__) + +# --- Configuration (Adapted from Proposal) --- +# Define known script extender executables (lowercase for comparisons) +TARGET_EXECUTABLES_LOWER = ["skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"] +# Define known stock game folder names (case-sensitive, as they appear on disk) +STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"] +# Define the SD card path prefix on Steam Deck/Linux +SDCARD_PREFIX = '/run/media/mmcblk0p1/' + +class PathHandler: + """ + Handles path-related operations for ModOrganizer.ini and other configuration files + """ + + @staticmethod + def _strip_sdcard_path_prefix(path_obj: Path) -> str: + """ + Removes the '/run/media/mmcblk0p1/' prefix if present. + 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 + return path_str + + @staticmethod + def update_mo2_ini_paths( + modlist_ini_path: Path, + modlist_dir_path: Path, + modlist_sdcard: bool, + steam_library_common_path: Optional[Path] = None, + basegame_dir_name: Optional[str] = None, + basegame_sdcard: bool = False # Default to False if not provided + ) -> bool: + logger.info(f"[DEBUG] update_mo2_ini_paths called with: modlist_ini_path={modlist_ini_path}, modlist_dir_path={modlist_dir_path}, modlist_sdcard={modlist_sdcard}, steam_library_common_path={steam_library_common_path}, basegame_dir_name={basegame_dir_name}, basegame_sdcard={basegame_sdcard}") + if not modlist_ini_path.is_file(): + logger.error(f"ModOrganizer.ini not found at specified path: {modlist_ini_path}") + # Attempt to create a minimal INI + try: + logger.warning("Creating minimal ModOrganizer.ini with [General] section.") + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.write('[General]\n') + # Continue as if file existed + except Exception as e: + logger.critical(f"Failed to create minimal ModOrganizer.ini: {e}") + return False + if not modlist_dir_path.is_dir(): + logger.error(f"Modlist directory not found or not a directory: {modlist_dir_path}") + # Warn but continue + + # --- Bulletproof game directory detection --- + # 1. Get all Steam libraries and log them + all_steam_libraries = PathHandler.get_all_steam_library_paths() + logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") + import sys + if hasattr(sys, 'argv') and any(arg in ('--debug', '-d') for arg in sys.argv): + self.logger.debug(f"Detected Steam libraries: {all_steam_libraries}") + + # 2. For each library, check for the canonical vanilla game directory + GAME_DIR_NAMES = { + "Skyrim Special Edition": "Skyrim Special Edition", + "Fallout 4": "Fallout 4", + "Fallout New Vegas": "Fallout New Vegas", + "Oblivion": "Oblivion" + } + canonical_name = None + if basegame_dir_name and basegame_dir_name in GAME_DIR_NAMES: + canonical_name = GAME_DIR_NAMES[basegame_dir_name] + elif basegame_dir_name: + canonical_name = basegame_dir_name # fallback, but should match above + gamepath_target_dir = None + gamepath_target_is_sdcard = modlist_sdcard + checked_candidates = [] + if canonical_name: + for lib in all_steam_libraries: + candidate = lib / "steamapps" / "common" / canonical_name + checked_candidates.append(str(candidate)) + logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") + if candidate.is_dir(): + gamepath_target_dir = candidate + logger.info(f"Found vanilla game directory: {candidate}") + break + if not gamepath_target_dir: + logger.error(f"Could not find vanilla game directory '{canonical_name}' in any Steam library. Checked: {checked_candidates}") + # 4. Prompt the user for the path + print("\nCould not automatically detect a Stock Game or vanilla game directory.") + print("Please enter the full path to your vanilla game directory (e.g., /path/to/Skyrim Special Edition):") + while True: + user_input = input("Game directory path: ").strip() + user_path = Path(user_input) + logger.info(f"[DEBUG] User entered: {user_input}") + if user_path.is_dir(): + exe_candidates = list(user_path.glob('*.exe')) + logger.info(f"[DEBUG] .exe files in user path: {exe_candidates}") + if exe_candidates: + gamepath_target_dir = user_path + logger.info(f"User provided valid vanilla game directory: {gamepath_target_dir}") + break + else: + print("Directory exists but does not appear to contain the game executable. Please check and try again.") + logger.warning("User path exists but no .exe files found.") + else: + print("Directory not found. Please enter a valid path.") + logger.warning("User path does not exist.") + if not gamepath_target_dir: + logger.critical("[FATAL] Could not determine a valid target directory for gamePath. Check configuration and paths. Aborting update.") + return False + + # 3. Update gamePath, binary, and workingDirectory entries in the INI + logger.debug(f"Determined gamePath target directory: {gamepath_target_dir}") + logger.debug(f"gamePath target is on SD card: {gamepath_target_is_sdcard}") + try: + logger.debug(f"Reading original INI file: {modlist_ini_path}") + with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f: + original_lines = f.readlines() + + # --- Find and robustly update gamePath line --- + gamepath_line_num = -1 + general_section_line = -1 + for i, line in enumerate(original_lines): + if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE): + general_section_line = i + if re.match(r'^\s*gamepath\s*=\s*', line, re.IGNORECASE): + gamepath_line_num = i + break + processed_str = PathHandler._strip_sdcard_path_prefix(gamepath_target_dir) + windows_style_single = processed_str.replace('/', '\\') + gamepath_drive_letter = "D:" if gamepath_target_is_sdcard else "Z:" + # Use robust formatter + formatted_gamepath = PathHandler._format_gamepath_for_mo2(f'{gamepath_drive_letter}{windows_style_single}') + new_gamepath_line = f'gamePath = @ByteArray({formatted_gamepath})\n' + if gamepath_line_num != -1: + logger.info(f"Updating existing gamePath line: {original_lines[gamepath_line_num].strip()} -> {new_gamepath_line.strip()}") + original_lines[gamepath_line_num] = new_gamepath_line + else: + insert_at = general_section_line + 1 if general_section_line != -1 else 0 + logger.info(f"Adding missing gamePath line at line {insert_at+1}: {new_gamepath_line.strip()}") + original_lines.insert(insert_at, new_gamepath_line) + + # --- Update customExecutables binaries and workingDirectories --- + TARGET_EXECUTABLES_LOWER = [ + "skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe" + ] + in_custom_exec = False + for i, line in enumerate(original_lines): + if re.match(r'^\s*\[customExecutables\]\s*$', line, re.IGNORECASE): + in_custom_exec = True + continue + if in_custom_exec and re.match(r'^\s*\[.*\]\s*$', line): + in_custom_exec = False + if in_custom_exec: + m = re.match(r'^(\d+)\\binary\s*=\s*(.*)$', line.strip(), re.IGNORECASE) + if m: + idx, old_path = m.group(1), m.group(2) + exe_name = os.path.basename(old_path).lower() + if exe_name in TARGET_EXECUTABLES_LOWER: + new_path = f'{gamepath_drive_letter}/{PathHandler._strip_sdcard_path_prefix(gamepath_target_dir)}/{exe_name}' + # Use robust formatter + new_path = PathHandler._format_binary_for_mo2(new_path) + logger.info(f"Updating binary for entry {idx}: {old_path} -> {new_path}") + original_lines[i] = f'{idx}\\binary = {new_path}\n' + m_wd = re.match(r'^(\d+)\\workingDirectory\s*=\s*(.*)$', line.strip(), re.IGNORECASE) + if m_wd: + idx, old_wd = m_wd.group(1), m_wd.group(2) + new_wd = f'{gamepath_drive_letter}{windows_style_single}' + # Use robust formatter + new_wd = PathHandler._format_workingdir_for_mo2(new_wd) + logger.info(f"Updating workingDirectory for entry {idx}: {old_wd} -> {new_wd}") + original_lines[i] = f'{idx}\\workingDirectory = {new_wd}\n' + + # --- Backup and Write New INI --- + backup_path = modlist_ini_path.with_suffix(f".{datetime.now().strftime('%Y%m%d_%H%M%S')}.bak") + try: + shutil.copy2(modlist_ini_path, backup_path) + logger.info(f"Backed up original INI to: {backup_path}") + except Exception as bak_err: + logger.error(f"Failed to backup original INI file: {bak_err}") + return False + try: + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(original_lines) + logger.info(f"Successfully wrote updated paths to {modlist_ini_path}") + return True + except Exception as write_err: + logger.error(f"Failed to write updated INI file {modlist_ini_path}: {write_err}", exc_info=True) + logger.error("Attempting to restore from backup...") + try: + shutil.move(backup_path, modlist_ini_path) + logger.info("Successfully restored original INI from backup.") + except Exception as restore_err: + logger.critical(f"CRITICAL FAILURE: Could not write new INI and failed to restore backup {backup_path}. Manual intervention required at {modlist_ini_path}! Error: {restore_err}") + return False + except Exception as e: + logger.error(f"An unexpected error occurred during INI path update: {e}", exc_info=True) + return False + + @staticmethod + def edit_resolution(modlist_ini, resolution): + """ + Edit resolution settings in ModOrganizer.ini + + Args: + modlist_ini (str): Path to ModOrganizer.ini + resolution (str): Resolution in the format "1920x1080" + + Returns: + bool: True on success, False on failure + """ + try: + logger.info(f"Editing resolution settings to {resolution}...") + + # Parse resolution + width, height = resolution.split('x') + + # Read the current ModOrganizer.ini + with open(modlist_ini, 'r') as f: + content = f.read() + + # Replace width and height settings + content = re.sub(r'^width\s*=\s*\d+$', f'width = {width}', content, flags=re.MULTILINE) + content = re.sub(r'^height\s*=\s*\d+$', f'height = {height}', content, flags=re.MULTILINE) + + # Write the updated content back to the file + with open(modlist_ini, 'w') as f: + f.write(content) + + logger.info("Resolution settings edited successfully") + return True + + except Exception as e: + logger.error(f"Error editing resolution settings: {e}") + return False + + @staticmethod + def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full): + """ + Create dxvk.conf file in the appropriate location + + Args: + modlist_dir (str): Path to the modlist directory + modlist_sdcard (bool): Whether the modlist is on an SD card + 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") + + Returns: + bool: True on success, False on failure + """ + try: + logger.info("Creating dxvk.conf file...") + + # Determine the location for dxvk.conf + dxvk_conf_path = None + + # Check for common stock game directories + 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 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) + ] + + 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 + + # Create simple dxvk.conf content - just one line + dxvk_conf_content = "dxvk.enableGraphicsPipelineLibrary = False\n" + + # Write dxvk.conf to the appropriate location + with open(dxvk_conf_path, 'w') as f: + f.write(dxvk_conf_content) + + logger.info(f"dxvk.conf created successfully at {dxvk_conf_path}") + return True + + except Exception as e: + logger.error(f"Error creating dxvk.conf: {e}") + return False + + @staticmethod + def find_steam_config_vdf() -> Optional[Path]: + """Finds the active Steam config.vdf file.""" + logger.debug("Searching for Steam config.vdf...") + possible_steam_paths = [ + Path.home() / ".steam/steam", + Path.home() / ".local/share/Steam", + Path.home() / ".steam/root" + ] + for steam_path in possible_steam_paths: + potential_path = steam_path / "config/config.vdf" + if potential_path.is_file(): + logger.info(f"Found config.vdf at: {potential_path}") + return potential_path # Return Path object + + logger.warning("Could not locate Steam's config.vdf file in standard locations.") + return None + + @staticmethod + def find_steam_library() -> Optional[Path]: + """Find the primary Steam library common directory containing games.""" + logger.debug("Attempting to find Steam library...") + + # Potential locations for libraryfolders.vdf + libraryfolders_vdf_paths = [ + os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"), + os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"), + # Add other potential standard locations if necessary + ] + + # Simple backup mechanism (optional but good practice) + for path in libraryfolders_vdf_paths: + if os.path.exists(path): + backup_dir = os.path.join(os.path.dirname(path), "backups") + if not os.path.exists(backup_dir): + try: + os.makedirs(backup_dir) + except OSError as e: + logger.warning(f"Could not create backup directory {backup_dir}: {e}") + + # Create timestamped backup if it doesn't exist for today + timestamp = datetime.now().strftime("%Y%m%d") + backup_filename = f"libraryfolders_{timestamp}.vdf.bak" + backup_path = os.path.join(backup_dir, backup_filename) + + if not os.path.exists(backup_path): + try: + import shutil + shutil.copy2(path, backup_path) + logger.debug(f"Created backup of libraryfolders.vdf at {backup_path}") + except Exception as e: + logger.error(f"Failed to create backup of libraryfolders.vdf: {e}") + # Continue anyway, as we're only reading the file + pass + + libraryfolders_vdf_path_obj = None # Will hold the Path object + found_path_str = None + for path_str in libraryfolders_vdf_paths: + if os.path.exists(path_str): + found_path_str = path_str # Keep the string path for logging/opening + libraryfolders_vdf_path_obj = Path(path_str) # Convert to Path object here + logger.debug(f"Found libraryfolders.vdf at: {path_str}") + break + + # Check using the Path object's is_file() method + if not libraryfolders_vdf_path_obj or not libraryfolders_vdf_path_obj.is_file(): + logger.warning("libraryfolders.vdf not found or is not a file. Cannot automatically detect Steam Library.") + return None + + # Parse the VDF file to extract library paths + library_paths = [] + try: + # Open using the original string path is fine, or use the Path object + with open(found_path_str, 'r') as f: # Or use libraryfolders_vdf_path_obj + content = f.read() + + # Use regex to find all path entries + path_matches = re.finditer(r'"path"\s*"([^"]+)"', content) + for match in path_matches: + library_path_str = match.group(1).replace('\\\\', '\\') # Fix potential double escapes + common_path = os.path.join(library_path_str, "steamapps", "common") + if os.path.isdir(common_path): # Verify the common path exists + library_paths.append(Path(common_path)) + logger.debug(f"Found potential common path: {common_path}") + else: + logger.debug(f"Skipping non-existent common path derived from VDF: {common_path}") + + logger.debug(f"Found {len(library_paths)} valid library common paths from VDF.") + + # Return the first valid path found + if library_paths: + logger.info(f"Using Steam library common path: {library_paths[0]}") + return library_paths[0] + + # If no valid paths found in VDF, try the default structure + logger.debug("No valid common paths found in VDF, checking default location...") + default_common_path = Path.home() / ".steam/steam/steamapps/common" + if default_common_path.is_dir(): + logger.info(f"Using default Steam library common path: {default_common_path}") + return default_common_path + + default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common" + if default_common_path_local.is_dir(): + logger.info(f"Using default local Steam library common path: {default_common_path_local}") + return default_common_path_local + + logger.error("No valid Steam library common path found in VDF or default locations.") + return None + + except Exception as e: + logger.error(f"Error parsing libraryfolders.vdf or finding Steam library: {e}", exc_info=True) + return None + + @staticmethod + def find_compat_data(appid: str) -> Optional[Path]: + """Find the compatdata directory for a given AppID.""" + if not appid: + logger.error(f"Invalid AppID provided for compatdata search: {appid}") + return None + + # Handle negative AppIDs (remove minus sign for validation) + appid_clean = appid.lstrip('-') + if not appid_clean.isdigit(): + logger.error(f"Invalid AppID provided for compatdata search: {appid}") + return None + + logger.debug(f"Searching for compatdata directory for AppID: {appid}") + + # Use libraryfolders.vdf to find all Steam library paths + library_paths = PathHandler.get_all_steam_library_paths() + if not library_paths: + logger.error("Could not find any Steam library paths from libraryfolders.vdf") + return None + + logger.debug(f"Checking compatdata in {len(library_paths)} Steam libraries") + + # Check each Steam library's compatdata directory + for library_path in library_paths: + compatdata_base = library_path / "steamapps" / "compatdata" + if not compatdata_base.is_dir(): + logger.debug(f"Compatdata directory does not exist: {compatdata_base}") + continue + + potential_path = compatdata_base / appid + if potential_path.is_dir(): + logger.info(f"Found compatdata directory: {potential_path}") + return potential_path + else: + logger.debug(f"Compatdata for AppID {appid} not found in {compatdata_base}") + + # Fallback: Broad search (can be slow, consider if needed) + # try: + # logger.debug(f"Compatdata not found in standard locations, attempting wider search...") + # # This can be very slow and resource-intensive + # # find_output = subprocess.check_output(['find', '/', '-type', 'd', '-name', appid, '-path', '*/compatdata/*', '-print', '-quit', '2>/dev/null'], text=True).strip() + # # if find_output: + # # logger.info(f"Found compatdata via find command: {find_output}") + # # return Path(find_output) + # except Exception as e: + # logger.warning(f"Error during 'find' command for compatdata: {e}") + + logger.warning(f"Compatdata directory for AppID {appid} not found.") + return None + + @staticmethod + def detect_stock_game_path(game_type: str, steam_library: Path) -> Optional[Path]: + """ + Detect the stock game path for a given game type and Steam library + Returns the path if found, None otherwise + """ + try: + # Map of game types to their Steam App IDs + game_app_ids = { + 'skyrim': '489830', # Skyrim Special Edition + 'fallout4': '377160', # Fallout 4 + 'fnv': '22380', # Fallout: New Vegas + 'oblivion': '22330' # The Elder Scrolls IV: Oblivion + } + + if game_type not in game_app_ids: + return None + + app_id = game_app_ids[game_type] + game_path = steam_library / 'steamapps' / 'common' + + # List of possible game directory names + possible_names = { + 'skyrim': ['Skyrim Special Edition', 'Skyrim'], + 'fallout4': ['Fallout 4'], + 'fnv': ['Fallout New Vegas', 'FalloutNV'], + 'oblivion': ['Oblivion'] + } + + if game_type not in possible_names: + return None + + # Check each possible directory name + for name in possible_names[game_type]: + potential_path = game_path / name + if potential_path.exists(): + return potential_path + + return None + + except Exception as e: + logging.error(f"Error detecting stock game path: {e}") + return None + + @staticmethod + def get_steam_library_path(steam_path: str) -> Optional[str]: + """Get the Steam library path from libraryfolders.vdf.""" + try: + libraryfolders_path = os.path.join(steam_path, 'steamapps', 'libraryfolders.vdf') + if not os.path.exists(libraryfolders_path): + return None + + with open(libraryfolders_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Parse the VDF content + libraries = {} + current_library = None + for line in content.split('\n'): + line = line.strip() + if line.startswith('"path"'): + current_library = line.split('"')[3].replace('\\\\', '\\') + elif line.startswith('"apps"') and current_library: + libraries[current_library] = True + + # Return the first library path that exists + for library_path in libraries: + if os.path.exists(library_path): + return library_path + + return None + except Exception as e: + logger.error(f"Error getting Steam library path: {str(e)}") + return None + + @staticmethod + def get_all_steam_library_paths() -> List[Path]: + """Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak).""" + logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...") + vdf_paths = [ + Path.home() / ".steam/steam/config/libraryfolders.vdf", + Path.home() / ".local/share/Steam/config/libraryfolders.vdf", + Path.home() / ".steam/root/config/libraryfolders.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", # Flatpak + ] + library_paths = set() + for vdf_path in vdf_paths: + if vdf_path.is_file(): + logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}") + try: + with open(vdf_path) as f: + for line in f: + m = re.search(r'"path"\s*"([^"]+)"', line) + if m: + lib_path = Path(m.group(1)) + library_paths.add(lib_path) + except Exception as e: + logger.error(f"[DEBUG] Failed to parse {vdf_path}: {e}") + logger.info(f"[DEBUG] All detected Steam libraries: {library_paths}") + return list(library_paths) + + # 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. + + Iterates through userdata directories and returns the path to the + first found shortcuts.vdf file. + + 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 + + @staticmethod + def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]: + """ + Find installation paths for multiple specified games using Steam app IDs. + + Args: + target_appids: Dictionary mapping game names to app IDs + + Returns: + Dictionary mapping game names to their installation paths + """ + # Get all Steam library paths + library_paths = PathHandler.get_all_steam_library_paths() + if not library_paths: + logger.warning("Failed to find any Steam library paths") + return {} + + results = {} + + # For each library path, look for each target game + for library_path in library_paths: + # Check if the common directory exists + common_dir = library_path / "common" + if not common_dir.is_dir(): + logger.debug(f"No 'common' directory in library: {library_path}") + continue + + # Get subdirectories in common dir + try: + game_dirs = [d for d in common_dir.iterdir() if d.is_dir()] + except (PermissionError, OSError) as e: + logger.warning(f"Cannot access directory {common_dir}: {e}") + continue + + # For each app ID, check if we find its directory + for game_name, app_id in target_appids.items(): + if game_name in results: + continue # Already found this game + + # Try to find by appmanifest + appmanifest_path = library_path / f"appmanifest_{app_id}.acf" + if appmanifest_path.is_file(): + # Find the installdir value + try: + with open(appmanifest_path, 'r', encoding='utf-8') as f: + content = f.read() + match = re.search(r'"installdir"\s+"([^"]+)"', content) + if match: + install_dir_name = match.group(1) + install_path = common_dir / install_dir_name + if install_path.is_dir(): + results[game_name] = install_path + logger.info(f"Found {game_name} at {install_path}") + continue + except Exception as e: + logger.warning(f"Error reading appmanifest for {game_name}: {e}") + + return results + + def replace_gamepath(self, modlist_ini_path: Path, new_game_path: Path, modlist_sdcard: bool = False) -> bool: + """ + Updates the gamePath value in ModOrganizer.ini to the specified path. + Strictly matches the bash script: only replaces an existing gamePath line. + If the file or line does not exist, logs error and aborts. + """ + logger.info(f"Replacing gamePath in {modlist_ini_path} with {new_game_path}") + if not modlist_ini_path.is_file(): + logger.error(f"ModOrganizer.ini not found at: {modlist_ini_path}") + return False + 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:" + processed_path = self._strip_sdcard_path_prefix(new_game_path) + windows_style = processed_path.replace('/', '\\') + windows_style_double = windows_style.replace('\\', '\\\\') + new_gamepath_line = f'gamePath=@ByteArray({drive_letter}{windows_style_double})\n' + gamepath_found = False + for i, line in enumerate(lines): + # Make the check case-insensitive and robust to whitespace + if re.match(r'^\s*gamepath\s*=.*$', line, re.IGNORECASE): + lines[i] = new_gamepath_line + gamepath_found = True + break + if not gamepath_found: + logger.error("No gamePath line found in ModOrganizer.ini") + return False + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info(f"Successfully updated gamePath to {new_game_path}") + return True + except Exception as e: + logger.error(f"Error replacing gamePath: {e}", exc_info=True) + return False + + # ===================================================================================== + # CRITICAL: DO NOT CHANGE THIS FUNCTION WITHOUT UPDATING TESTS AND CONSULTING PROJECT LEAD + # This function implements the exact path rewriting logic required for ModOrganizer.ini + # to match the original, robust bash script. Any change here risks breaking modlist + # configuration for users. If you must change this, update all relevant tests and + # consult the Project Lead for Jackify. See also omni-guides.sh for reference logic. + # ===================================================================================== + def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool, steam_libraries: Optional[List[Path]] = None) -> bool: + """ + Update all binary paths and working directories in a ModOrganizer.ini file. + Handles various ModOrganizer.ini formats (single or double backslashes in keys). + When updating gamePath, binary, and workingDirectory, retain the original stock folder (Stock Game, Game Root, etc) if present in the current value. + steam_libraries: Optional[List[Path]] - already-discovered Steam library paths to use for vanilla detection. + + # DO NOT CHANGE THIS LOGIC WITHOUT UPDATING TESTS AND CONSULTING THE PROJECT LEAD + # This is a critical, regression-prone area. See omni-guides.sh for reference. + """ + try: + logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}") + if not modlist_ini_path.is_file(): + logger.error(f"INI file {modlist_ini_path} does not exist") + return False + with open(modlist_ini_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + game_path_updated = False + binary_paths_updated = 0 + working_dirs_updated = 0 + binary_lines = [] + working_dir_lines = [] + for i, line in enumerate(lines): + stripped = line.strip() + binary_match = re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE) + if binary_match: + index = binary_match.group(1) + backslash_style = binary_match.group(2) + binary_lines.append((i, stripped, index, backslash_style)) + wd_match = re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE) + if wd_match: + index = wd_match.group(1) + 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: + steam_libraries = PathHandler.get_all_steam_library_paths() + for i, line, index, backslash_style in binary_lines: + parts = line.split('=', 1) + if len(parts) != 2: + logger.error(f"Malformed binary line: {line}") + continue + key_part, value_part = parts + exe_name = os.path.basename(value_part).lower() + + # SELECTIVE FILTERING: Only process target executables (script extenders, etc.) + if exe_name not in TARGET_EXECUTABLES_LOWER: + logger.debug(f"Skipping non-target executable: {exe_name}") + continue + + drive_prefix = "D:" if modlist_sdcard else "Z:" + rel_path = None + # --- BEGIN: FULL PARITY LOGIC --- + if 'steamapps' in value_part: + idx = value_part.index('steamapps') + 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 + break + if not correct_steam_lib and steam_libraries: + correct_steam_lib = steam_libraries[0].parent + if correct_steam_lib: + new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/') + else: + logger.error("Could not determine correct Steam library for vanilla game path.") + continue + else: + found_stock = None + for folder in STOCK_GAME_FOLDERS: + folder_pattern = f"/{folder.replace(' ', '')}".lower() + value_part_lower = value_part.replace(' ', '').lower() + if folder_pattern in value_part_lower: + idx = value_part_lower.index(folder_pattern) + rel_path = value_part[idx:].lstrip('/') + found_stock = folder + break + if not rel_path: + mods_pattern = "/mods/" + if mods_pattern in value_part: + idx = value_part.index(mods_pattern) + rel_path = value_part[idx:].lstrip('/') + else: + rel_path = exe_name + new_binary_path = f"{drive_prefix}/{modlist_dir_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}" + logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}") + lines[i] = new_binary_line + "\n" + binary_paths_updated += 1 + binary_paths_by_index[index] = formatted_binary_path + for j, wd_line, index, backslash_style in working_dir_lines: + if index in binary_paths_by_index: + binary_path = binary_paths_by_index[index] + wd_path = os.path.dirname(binary_path) + drive_prefix = "D:" if modlist_sdcard else "Z:" + if wd_path.startswith("D:") or wd_path.startswith("Z:"): + wd_path = wd_path[2:] + 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}" + logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}") + lines[j] = new_wd_line + "\n" + working_dirs_updated += 1 + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info(f"edit_binary_working_paths completed: Game path updated: {game_path_updated}, Binary paths updated: {binary_paths_updated}, Working directories updated: {working_dirs_updated}") + return True + except Exception as e: + logger.error(f"Error updating binary paths in {modlist_ini_path}: {str(e)}") + return False + + def _format_path_for_mo2(self, path: str) -> str: + """Format a path for MO2's ModOrganizer.ini file (working directories).""" + # Replace forward slashes with double backslashes + formatted = path.replace('/', '\\') + # Ensure we have a Windows drive letter format + if not re.match(r'^[A-Za-z]:', formatted): + formatted = 'D:' + formatted + # Double the backslashes for the INI file format + formatted = formatted.replace('\\', '\\\\') + return formatted + + def _format_binary_path_for_mo2(self, path_str): + """Format a binary path for MO2 config file. + + Binary paths need forward slashes (/) in the path portion. + """ + # Replace backslashes with forward slashes + return path_str.replace('\\', '/') + + def _format_working_dir_for_mo2(self, path_str): + """ + Format a working directory path for MO2 config file. + Ensures double backslashes throughout, as required by ModOrganizer.ini. + """ + import re + path = path_str.replace('/', '\\') + path = path.replace('\\', '\\\\') # Double all backslashes + # Ensure only one double backslash after drive letter + path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path) + return path + + @staticmethod + def find_vanilla_game_paths(game_names=None) -> Dict[str, Path]: + """ + For each known game, iterate all Steam libraries and look for the canonical game directory name in steamapps/common. + Returns a dict of found games and their paths. + Args: + game_names: Optional list of game names to check. If None, uses default supported games. + Returns: + Dict[str, Path]: Mapping of game name to found install Path. + """ + # Canonical game directory names (allow list for Fallout 3) + GAME_DIR_NAMES = { + "Skyrim Special Edition": ["Skyrim Special Edition"], + "Fallout 4": ["Fallout 4"], + "Fallout New Vegas": ["Fallout New Vegas"], + "Oblivion": ["Oblivion"], + "Fallout 3": ["Fallout 3", "Fallout 3 goty"] + } + if game_names is None: + game_names = list(GAME_DIR_NAMES.keys()) + all_steam_libraries = PathHandler.get_all_steam_library_paths() + logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") + found_games = {} + for game in game_names: + possible_names = GAME_DIR_NAMES.get(game, [game]) + for lib in all_steam_libraries: + for name in possible_names: + candidate = lib / "steamapps" / "common" / name + logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") + if candidate.is_dir(): + found_games[game] = candidate + logger.info(f"Found vanilla game directory for {game}: {candidate}") + break # Stop after first found location + if game in found_games: + break + return found_games + + def _detect_stock_game_path(self): + """Detects common 'Stock Game' or 'Game Root' directories within the modlist path.""" + self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...") + if not self.modlist_dir: + self.logger.error("Modlist directory not set, cannot detect stock game path.") + return False + + modlist_path = Path(self.modlist_dir) + # Always prefer 'Stock Game' if it exists, then fallback to others + preferred_order = [ + "Stock Game", + "STOCK GAME", + "Skyrim Stock", + "Stock Game Folder", + "Stock Folder", + Path("root/Skyrim Special Edition"), + "Game Root" # 'Game Root' is now last + ] + + found_path = None + for name in preferred_order: + potential_path = modlist_path / name + if potential_path.is_dir(): + found_path = str(potential_path) + self.logger.info(f"Found potential stock game directory: {found_path}") + break # Found the first match + if found_path: + self.stock_game_path = found_path + return True + else: + self.stock_game_path = None + self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.") + return True + + # --- Add robust path formatters for INI fields --- + @staticmethod + def _format_gamepath_for_mo2(path: str) -> str: + import re + path = path.replace('/', '\\') + path = re.sub(r'\\+', r'\\', path) # Collapse multiple backslashes + # Ensure only one double backslash after drive letter + path = re.sub(r'^([A-Z]:)\\+', r'\1\\', path) + return path + + @staticmethod + def _format_binary_for_mo2(path: str) -> str: + import re + path = path.replace('\\', '/') + # Collapse multiple forward slashes after drive letter + path = re.sub(r'^([A-Z]:)//+', r'\1/', path) + return path + + @staticmethod + def _format_workingdir_for_mo2(path: str) -> str: + import re + path = path.replace('/', '\\') + path = path.replace('\\', '\\\\') # Double all backslashes + # Ensure only one double backslash after drive letter + path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path) + return path + +# --- End of PathHandler --- \ No newline at end of file diff --git a/jackify/backend/handlers/progress_aggregator.py b/jackify/backend/handlers/progress_aggregator.py new file mode 100644 index 0000000..4d8c8cb --- /dev/null +++ b/jackify/backend/handlers/progress_aggregator.py @@ -0,0 +1,255 @@ +""" +Progress Aggregator + +Handles aggregation and cleanup of download progress messages to provide +a cleaner, less disorienting user experience when multiple downloads are running. +""" + +import re +import time +from typing import Dict, Optional, List, NamedTuple +from collections import defaultdict, deque +from dataclasses import dataclass + + +@dataclass +class DownloadProgress: + """Represents progress for a single download.""" + file_name: str + current_size: int + total_size: int + speed: float + percentage: float + last_update: float + + +class ProgressStats(NamedTuple): + """Aggregated progress statistics.""" + total_files: int + completed_files: int + active_files: int + total_bytes: int + downloaded_bytes: int + overall_percentage: float + average_speed: float + + +class ProgressAggregator: + """ + Aggregates download progress from multiple concurrent downloads and provides + cleaner progress reporting to avoid UI spam. + """ + + def __init__(self, update_interval: float = 2.0, max_displayed_downloads: int = 3): + self.update_interval = update_interval + self.max_displayed_downloads = max_displayed_downloads + + # Track individual download progress + self._downloads: Dict[str, DownloadProgress] = {} + self._completed_downloads: set = set() + + # Track overall statistics + self._last_update_time = 0.0 + self._recent_speeds = deque(maxlen=10) # For speed averaging + + # Pattern matching for different progress formats + self._progress_patterns = [ + # Common download progress patterns + r'(?:Downloading|Download)\s+(.+?):\s*(\d+)%', + r'(?:Downloading|Download)\s+(.+?)\s+\[([^\]]+)\]', + r'\[(\d+)/(\d+)\]\s*(.+?)\s*(\d+)%', + # Extraction progress patterns + r'(?:Extracting|Extract)\s+(.+?):\s*(\d+)%', + r'(?:Extracting|Extract)\s+(.+?)\s+\[([^\]]+)\]', + ] + + def update_progress(self, message: str) -> Optional[str]: + """ + Update progress with a new message and return aggregated progress if it's time to update. + + Args: + message: Raw progress message from jackify-engine + + Returns: + Cleaned progress message if update interval has passed, None otherwise + """ + current_time = time.time() + + # Parse the progress message + parsed = self._parse_progress_message(message) + if parsed: + self._downloads[parsed.file_name] = parsed + + # Check if it's time for an update + if current_time - self._last_update_time >= self.update_interval: + self._last_update_time = current_time + return self._generate_aggregated_message() + + return None + + def mark_completed(self, file_name: str): + """Mark a download as completed.""" + self._completed_downloads.add(file_name) + if file_name in self._downloads: + del self._downloads[file_name] + + def get_stats(self) -> ProgressStats: + """Get current aggregated statistics.""" + active_downloads = list(self._downloads.values()) + + if not active_downloads: + return ProgressStats(0, len(self._completed_downloads), 0, 0, 0, 0.0, 0.0) + + total_files = len(active_downloads) + len(self._completed_downloads) + total_bytes = sum(d.total_size for d in active_downloads) + downloaded_bytes = sum(d.current_size for d in active_downloads) + + # Calculate overall percentage + if total_bytes > 0: + overall_percentage = (downloaded_bytes / total_bytes) * 100 + else: + overall_percentage = 0.0 + + # Calculate average speed + speeds = [d.speed for d in active_downloads if d.speed > 0] + average_speed = sum(speeds) / len(speeds) if speeds else 0.0 + + return ProgressStats( + total_files=total_files, + completed_files=len(self._completed_downloads), + active_files=len(active_downloads), + total_bytes=total_bytes, + downloaded_bytes=downloaded_bytes, + overall_percentage=overall_percentage, + average_speed=average_speed + ) + + def _parse_progress_message(self, message: str) -> Optional[DownloadProgress]: + """Parse a progress message into structured data.""" + # Clean up the message + clean_message = message.strip() + + # Try each pattern + for pattern in self._progress_patterns: + match = re.search(pattern, clean_message, re.IGNORECASE) + if match: + try: + if len(match.groups()) >= 2: + file_name = match.group(1).strip() + + # Extract percentage or progress info + progress_str = match.group(2) + + # Handle different progress formats + if progress_str.endswith('%'): + percentage = float(progress_str[:-1]) + # Estimate size based on percentage (we don't have exact sizes) + current_size = int(percentage * 1000) # Arbitrary scaling + total_size = 100000 + speed = 0.0 + else: + # Try to parse size/speed format like "45.2MB/s" + percentage = 0.0 + current_size = 0 + total_size = 1 + speed = self._parse_speed(progress_str) + + return DownloadProgress( + file_name=file_name, + current_size=current_size, + total_size=total_size, + speed=speed, + percentage=percentage, + last_update=time.time() + ) + except (ValueError, IndexError): + continue + + return None + + def _parse_speed(self, speed_str: str) -> float: + """Parse speed string like '45.2MB/s' into bytes per second.""" + try: + # Remove '/s' suffix + speed_str = speed_str.replace('/s', '').strip() + + # Extract number and unit + match = re.match(r'([\d.]+)\s*([KMGT]?B)', speed_str, re.IGNORECASE) + if not match: + return 0.0 + + value = float(match.group(1)) + unit = match.group(2).upper() + + # Convert to bytes per second + multipliers = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 * 1024, + 'GB': 1024 * 1024 * 1024, + 'TB': 1024 * 1024 * 1024 * 1024 + } + + return value * multipliers.get(unit, 1) + + except (ValueError, AttributeError): + return 0.0 + + def _generate_aggregated_message(self) -> str: + """Generate a clean, aggregated progress message.""" + stats = self.get_stats() + + if stats.total_files == 0: + return "Processing..." + + # Get most recent active downloads to display + recent_downloads = sorted( + self._downloads.values(), + key=lambda d: d.last_update, + reverse=True + )[:self.max_displayed_downloads] + + # Build message components + components = [] + + # Overall progress + if stats.total_files > 1: + components.append(f"Progress: {stats.completed_files}/{stats.total_files} files") + if stats.overall_percentage > 0: + components.append(f"({stats.overall_percentage:.1f}%)") + + # Current active downloads + if recent_downloads: + if len(recent_downloads) == 1: + download = recent_downloads[0] + if download.percentage > 0: + components.append(f"Downloading: {download.file_name} ({download.percentage:.1f}%)") + else: + components.append(f"Downloading: {download.file_name}") + else: + components.append(f"Downloading {len(recent_downloads)} files") + + # Speed info + if stats.average_speed > 0: + speed_str = self._format_speed(stats.average_speed) + components.append(f"@ {speed_str}") + + return " - ".join(components) if components else "Processing..." + + def _format_speed(self, speed_bytes: float) -> str: + """Format speed in bytes/sec to human readable format.""" + if speed_bytes < 1024: + return f"{speed_bytes:.1f} B/s" + elif speed_bytes < 1024 * 1024: + return f"{speed_bytes / 1024:.1f} KB/s" + elif speed_bytes < 1024 * 1024 * 1024: + return f"{speed_bytes / (1024 * 1024):.1f} MB/s" + else: + return f"{speed_bytes / (1024 * 1024 * 1024):.1f} GB/s" + + def reset(self): + """Reset all progress tracking.""" + self._downloads.clear() + self._completed_downloads.clear() + self._recent_speeds.clear() + self._last_update_time = 0.0 \ No newline at end of file diff --git a/jackify/backend/handlers/protontricks_handler.py b/jackify/backend/handlers/protontricks_handler.py new file mode 100644 index 0000000..2bb27cc --- /dev/null +++ b/jackify/backend/handlers/protontricks_handler.py @@ -0,0 +1,708 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Protontricks Handler Module +Handles detection and operation of Protontricks +""" + +import os +import re +import subprocess +from pathlib import Path +import shutil +import logging +from typing import Dict, Optional, List +import sys + +# Initialize logger +logger = logging.getLogger(__name__) + + +class ProtontricksHandler: + """ + Handles operations related to Protontricks detection and usage + """ + + 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 + + def _get_clean_subprocess_env(self): + """ + Create a clean environment for subprocess calls by removing PyInstaller-specific + environment variables that can interfere with external program execution. + + Returns: + dict: Cleaned environment dictionary + """ + env = os.environ.copy() + + # Remove PyInstaller-specific environment variables + env.pop('_MEIPASS', None) + env.pop('_MEIPASS2', None) + + # Clean library path variables that PyInstaller modifies (Linux/Unix) + if 'LD_LIBRARY_PATH_ORIG' in env: + # Restore original LD_LIBRARY_PATH if it was backed up by PyInstaller + env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG'] + else: + # Remove PyInstaller-modified LD_LIBRARY_PATH + env.pop('LD_LIBRARY_PATH', None) + + # Clean PATH of PyInstaller-specific entries + if 'PATH' in env and hasattr(sys, '_MEIPASS'): + path_entries = env['PATH'].split(os.pathsep) + # Remove any PATH entries that point to PyInstaller temp directory + cleaned_path = [p for p in path_entries if not p.startswith(sys._MEIPASS)] + env['PATH'] = os.pathsep.join(cleaned_path) + + # Clean macOS library path (if present) + if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'): + dyld_entries = env['DYLD_LIBRARY_PATH'].split(os.pathsep) + cleaned_dyld = [p for p in dyld_entries if not p.startswith(sys._MEIPASS)] + if cleaned_dyld: + env['DYLD_LIBRARY_PATH'] = os.pathsep.join(cleaned_dyld) + else: + env.pop('DYLD_LIBRARY_PATH', None) + + return env + + def detect_protontricks(self): + """ + Detect if protontricks is installed and whether it's flatpak or native. + If not found, prompts the user to install the Flatpak version. + + Returns True if protontricks is found or successfully installed, False otherwise + """ + logger.debug("Detecting if protontricks is installed...") + + # Check if protontricks exists as a command + protontricks_path_which = shutil.which("protontricks") + self.flatpak_path = shutil.which("flatpak") # Store for later use + + if protontricks_path_which: + # Check if it's a flatpak wrapper + try: + with open(protontricks_path_which, 'r') as f: + content = f.read() + if "flatpak run" in content: + logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}") + self.which_protontricks = 'flatpak' + # Continue to check flatpak list just to be sure + else: + logger.info(f"Native Protontricks found at {protontricks_path_which}") + self.which_protontricks = 'native' + self.protontricks_path = protontricks_path_which + return True + except Exception as e: + logger.error(f"Error reading protontricks executable: {e}") + + # Check if flatpak protontricks is installed (or if wrapper check indicated flatpak) + flatpak_installed = False + try: + # PyInstaller fix: Comprehensive environment cleaning for subprocess calls + env = self._get_clean_subprocess_env() + + result = subprocess.run( + ["flatpak", "list"], + capture_output=True, + text=True, + check=True, + env=env # Use comprehensively cleaned environment + ) + if "com.github.Matoking.protontricks" in result.stdout: + logger.info("Flatpak Protontricks is installed") + self.which_protontricks = 'flatpak' + flatpak_installed = True + return True + except FileNotFoundError: + logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.") + except subprocess.CalledProcessError as e: + logger.warning(f"Error checking flatpak list: {e}") + except Exception as e: + logger.error(f"Unexpected error checking flatpak: {e}") + + # If neither native nor flatpak found, prompt for installation + if not self.which_protontricks: + logger.warning("Protontricks not found (native or flatpak).") + + should_install = False + if self.steamdeck: + logger.info("Running on Steam Deck, attempting automatic Flatpak installation.") + # Maybe add a brief pause or message? + print("Protontricks not found. Attempting automatic installation via Flatpak...") + should_install = True + else: + try: + response = input("Protontricks not found. Install the Flatpak version? (Y/n): ").lower() + if response == 'y' or response == '': + should_install = True + except KeyboardInterrupt: + print("\nInstallation cancelled.") + return False + + if should_install: + try: + logger.info("Attempting to install Flatpak Protontricks...") + # Use --noninteractive for automatic install where applicable + install_cmd = ["flatpak", "install", "-u", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"] + + # PyInstaller fix: Comprehensive environment cleaning for subprocess calls + env = self._get_clean_subprocess_env() + + # Run with output visible to user + process = subprocess.run(install_cmd, check=True, text=True, env=env) + logger.info("Flatpak Protontricks installation successful.") + print("Flatpak Protontricks installed successfully.") + self.which_protontricks = 'flatpak' + return True + except FileNotFoundError: + logger.error("'flatpak' command not found. Cannot install.") + print("Error: 'flatpak' command not found. Please install Flatpak first.") + return False + except subprocess.CalledProcessError as e: + logger.error(f"Flatpak installation failed: {e}") + print(f"Error: Flatpak installation failed (Command: {' '.join(e.cmd)}). Please try installing manually.") + return False + except Exception as e: + logger.error(f"Unexpected error during Flatpak installation: {e}") + print("An unexpected error occurred during installation.") + return False + else: + logger.error("User chose not to install Protontricks or installation skipped.") + print("Protontricks installation skipped. Cannot continue without Protontricks.") + return False + + # Should not reach here if logic is correct, but acts as a fallback + logger.error("Protontricks detection failed unexpectedly.") + return False + + def check_protontricks_version(self): + """ + Check if the protontricks version is sufficient + Returns True if version is sufficient, False otherwise + """ + try: + if self.which_protontricks == 'flatpak': + cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "-V"] + else: + cmd = ["protontricks", "-V"] + + result = subprocess.run(cmd, capture_output=True, text=True) + version_str = result.stdout.split(' ')[1].strip('()') + + # Clean version string + cleaned_version = re.sub(r'[^0-9.]', '', version_str) + self.protontricks_version = cleaned_version + + # Parse version components + version_parts = cleaned_version.split('.') + if len(version_parts) >= 2: + major, minor = int(version_parts[0]), int(version_parts[1]) + if major < 1 or (major == 1 and minor < 12): + logger.error(f"Protontricks version {cleaned_version} is too old. Version 1.12.0 or newer is required.") + return False + return True + else: + logger.error(f"Could not parse protontricks version: {cleaned_version}") + return False + + except Exception as e: + logger.error(f"Error checking protontricks version: {e}") + return False + + def run_protontricks(self, *args, **kwargs): + """ + Run protontricks with the given arguments and keyword arguments. + kwargs are passed directly to subprocess.run (e.g., stderr=subprocess.DEVNULL). + Use stdout=subprocess.PIPE, stderr=subprocess.PIPE/DEVNULL instead of capture_output=True. + Returns subprocess.CompletedProcess object + """ + # Ensure protontricks is detected first + if self.which_protontricks is None: + if not self.detect_protontricks(): + logger.error("Could not detect protontricks installation") + return None + + if self.which_protontricks == 'flatpak': + cmd = ["flatpak", "run", "com.github.Matoking.protontricks"] + else: + cmd = ["protontricks"] + + cmd.extend(args) + + # Default to capturing stdout/stderr unless specified otherwise in kwargs + run_kwargs = { + 'stdout': subprocess.PIPE, + 'stderr': subprocess.PIPE, + 'text': True, + **kwargs # Allow overriding defaults (like stderr=DEVNULL) + } + # PyInstaller fix: Use cleaned environment for all protontricks calls + env = self._get_clean_subprocess_env() + # Suppress Wine debug output + env['WINEDEBUG'] = '-all' + run_kwargs['env'] = env + try: + return subprocess.run(cmd, **run_kwargs) + except Exception as e: + logger.error(f"Error running protontricks: {e}") + # Consider returning a mock CompletedProcess with an error code? + return None + + def set_protontricks_permissions(self, modlist_dir, steamdeck=False): + """ + Set permissions for Protontricks to access the modlist directory + Returns True on success, False on failure + """ + if self.which_protontricks != 'flatpak': + logger.debug("Using Native protontricks, skip setting permissions") + return True + + logger.info("Setting Protontricks permissions...") + try: + # PyInstaller fix: Use cleaned environment + env = self._get_clean_subprocess_env() + + subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", + f"--filesystem={modlist_dir}"], check=True, env=env) + + if steamdeck: + logger.warn("Checking for SDCard and setting permissions appropriately...") + # Find sdcard path + result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env) + for line in result.stdout.splitlines(): + if "/run/media" in line: + sdcard_path = line.split()[-1] + logger.debug(f"SDCard path: {sdcard_path}") + subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}", + "com.github.Matoking.protontricks"], check=True, env=env) + # Add standard Steam Deck SD card path as fallback + subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1", + "com.github.Matoking.protontricks"], check=True, env=env) + logger.debug("Permissions set successfully") + return True + except Exception as e: + logger.error(f"Failed to set Protontricks permissions: {e}") + return False + + def create_protontricks_alias(self): + """ + Create aliases for protontricks in ~/.bashrc if using flatpak + Returns True if created or already exists, False on failure + """ + if self.which_protontricks != 'flatpak': + logger.debug("Not using flatpak, skipping alias creation") + return True + + try: + bashrc_path = os.path.expanduser("~/.bashrc") + + # Check if file exists and read content + if os.path.exists(bashrc_path): + with open(bashrc_path, 'r') as f: + content = f.read() + + # Check if aliases already exist + protontricks_alias_exists = "alias protontricks=" in content + launch_alias_exists = "alias protontricks-launch" in content + + # Add missing aliases + with open(bashrc_path, 'a') as f: + if not protontricks_alias_exists: + logger.info("Adding protontricks alias to ~/.bashrc") + f.write("\nalias protontricks='flatpak run com.github.Matoking.protontricks'\n") + + if not launch_alias_exists: + logger.info("Adding protontricks-launch alias to ~/.bashrc") + f.write("\nalias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'\n") + + return True + else: + logger.error("~/.bashrc not found, skipping alias creation") + return False + + except Exception as e: + logger.error(f"Failed to create protontricks aliases: {e}") + return False + + # def get_modlists(self): # Keep commented out or remove old method + # """ + # Get a list of Skyrim, Fallout, Oblivion modlists from Steam via protontricks + # Returns a list of modlist names + # """ + # ... (old implementation with filtering) ... + + # 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. + + Runs 'protontricks -l' and parses the output for lines matching + "Non-Steam shortcut: [Name] ([AppID])". + + Returns: + A dictionary mapping the shortcut name (AppName) to its AppID. + Returns an empty dictionary if none are found or an error occurs. + """ + logger.info("Listing ALL non-Steam shortcuts via protontricks...") + non_steam_shortcuts = {} + # --- Ensure protontricks is detected before proceeding --- + if not self.which_protontricks: + self.logger.info("Protontricks type/path not yet determined. Running detection...") + if not self.detect_protontricks(): + self.logger.error("Protontricks detection failed. Cannot list shortcuts.") + return {} + self.logger.info(f"Protontricks detection successful: {self.which_protontricks}") + # --- End detection check --- + try: + cmd = [] # Initialize cmd list + if self.which_protontricks == 'flatpak': + cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "-l"] + elif self.protontricks_path: + cmd = [self.protontricks_path, "-l"] + else: + logger.error("Protontricks path not determined, cannot list shortcuts.") + return {} + self.logger.debug(f"Running command: {' '.join(cmd)}") + # PyInstaller fix: Use cleaned environment + env = self._get_clean_subprocess_env() + result = subprocess.run(cmd, capture_output=True, text=True, check=True, encoding='utf-8', errors='ignore', env=env) + # Regex to capture name and AppID + pattern = re.compile(r"Non-Steam shortcut:\s+(.+)\s+\((\d+)\)") + for line in result.stdout.splitlines(): + line = line.strip() + match = pattern.match(line) + if match: + app_name = match.group(1).strip() # Get the name + app_id = match.group(2).strip() # Get the AppID + non_steam_shortcuts[app_name] = app_id + logger.debug(f"Found non-Steam shortcut: '{app_name}' with AppID {app_id}") + if not non_steam_shortcuts: + logger.warning("No non-Steam shortcuts found in protontricks output.") + except FileNotFoundError: + logger.error(f"Protontricks command not found. Path: {cmd[0] if cmd else 'N/A'}") + return {} + except subprocess.CalledProcessError as e: + # Log error but don't necessarily stop; might have partial output + logger.error(f"Error running protontricks -l (Exit code: {e.returncode}): {e}") + logger.error(f"Stderr (truncated): {e.stderr[:500] if e.stderr else ''}") + # Return what we have, might be useful + except Exception as e: + logger.error(f"Unexpected error listing non-Steam shortcuts: {e}", exc_info=True) + return {} + return non_steam_shortcuts + + def enable_dotfiles(self, appid): + """ + Enable visibility of (.)dot files in the Wine prefix + Returns True on success, False on failure + + Args: + appid (str): The app ID to use + + Returns: + bool: True on success, False on failure + """ + logger.debug(f"APPID={appid}") + logger.info("Enabling visibility of (.)dot files...") + + try: + # Check current setting + result = self.run_protontricks( + "-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles", + appid, + stderr=subprocess.DEVNULL # Suppress stderr for this query + ) + + # Check if the initial query command ran successfully and contained expected output + if result and result.returncode == 0 and "ShowDotFiles" in result.stdout and "Y" in result.stdout: + logger.info("DotFiles already enabled via registry... skipping") + return True + elif result and result.returncode != 0: + # Log as info/debug since non-zero exit is expected if key doesn't exist + logger.info(f"Initial query for ShowDotFiles likely failed because the key doesn't exist yet (Exit Code: {result.returncode}). Proceeding to set it. Stderr: {result.stderr}") + elif not result: + logger.error("Failed to execute initial dotfile query command.") + # Proceed cautiously + + # --- Try to set the value --- + dotfiles_set_success = False + + # Method 1: Set registry key (Primary Method) + logger.debug("Attempting to set ShowDotFiles registry key...") + result_add = self.run_protontricks( + "-c", "WINEDEBUG=-all wine reg add \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles /d Y /f", + appid, + # Keep stderr for this one to log potential errors from reg add + # stderr=subprocess.DEVNULL + ) + if result_add and result_add.returncode == 0: + logger.info("'wine reg add' command executed successfully.") + dotfiles_set_success = True # Tentative success + elif result_add: + logger.warning(f"'wine reg add' command failed (Exit Code: {result_add.returncode}). Stderr: {result_add.stderr}") + else: + logger.error("Failed to execute 'wine reg add' command.") + + # Method 2: Create user.reg entry (Backup Method) + # This is useful if registry commands fail but direct file access works + logger.debug("Ensuring user.reg has correct entry...") + prefix_path = self.get_wine_prefix_path(appid) + if prefix_path: + user_reg_path = Path(prefix_path) / "user.reg" + try: + if user_reg_path.exists(): + content = user_reg_path.read_text(encoding='utf-8', errors='ignore') + 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('"ShowDotFiles"="Y"\n') + dotfiles_set_success = True # Count file write as success too + else: + logger.debug("ShowDotFiles already present in user.reg") + dotfiles_set_success = True # Already there counts as success + 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('"ShowDotFiles"="Y"\n') + dotfiles_set_success = True # Creating file counts as success + except Exception as e: + logger.warning(f"Error reading/writing user.reg: {e}") + else: + logger.warning("Could not get WINEPREFIX path, skipping user.reg modification.") + + # --- Verification Step --- + logger.debug("Verifying dotfile setting after attempts...") + verify_result = self.run_protontricks( + "-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles", + appid, + stderr=subprocess.DEVNULL # Suppress stderr for verification query + ) + + query_verified = False + if verify_result and verify_result.returncode == 0 and "ShowDotFiles" in verify_result.stdout and "Y" in verify_result.stdout: + logger.debug("Verification query successful and key is set.") + query_verified = True + elif verify_result: + # Change Warning to Info - verification failing right after setting is common + logger.info(f"Verification query failed or key not found (Exit Code: {verify_result.returncode}). Stderr: {verify_result.stderr}") + else: + logger.error("Failed to execute verification query command.") + + # --- Final Decision --- + if dotfiles_set_success: + # If the add command or file write succeeded, we report overall success, + # even if the verification query failed, but log the query status. + if query_verified: + logger.info("Dotfiles enabled and verified successfully!") + else: + # Change Warning to Info - verification failing right after setting is common + logger.info("Dotfiles potentially enabled (reg add/user.reg succeeded), but verification query failed.") + return True # Report success based on the setting action + else: + # If both the reg add and user.reg steps failed + logger.error("Failed to enable dotfiles using registry and user.reg methods.") + return False + + except Exception as e: + logger.error(f"Unexpected error enabling dotfiles: {e}", exc_info=True) + return False + + def set_win10_prefix(self, appid): + """ + Set Windows 10 version in the proton prefix + Returns True on success, False on failure + """ + try: + # PyInstaller fix: Use cleaned environment + env = self._get_clean_subprocess_env() + env["WINEDEBUG"] = "-all" + + if self.which_protontricks == 'flatpak': + cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "--no-bwrap", appid, "win10"] + else: + cmd = ["protontricks", "--no-bwrap", appid, "win10"] + + subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except Exception as e: + logger.error(f"Error setting Windows 10 prefix: {e}") + return False + + def protontricks_alias(self): + """ + Create protontricks alias in ~/.bashrc + """ + logger.info("Creating protontricks alias in ~/.bashrc...") + + try: + if self.which_protontricks == 'flatpak': + # Check if aliases already exist + bashrc_path = os.path.expanduser("~/.bashrc") + protontricks_alias_exists = False + launch_alias_exists = False + + if os.path.exists(bashrc_path): + with open(bashrc_path, 'r') as f: + content = f.read() + protontricks_alias_exists = "alias protontricks='flatpak run com.github.Matoking.protontricks'" in content + launch_alias_exists = "alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'" in content + + # Add aliases if they don't exist + with open(bashrc_path, 'a') as f: + if not protontricks_alias_exists: + f.write("\n# Jackify: Protontricks alias\n") + f.write("alias protontricks='flatpak run com.github.Matoking.protontricks'\n") + logger.debug("Added protontricks alias to ~/.bashrc") + + if not launch_alias_exists: + f.write("\n# Jackify: Protontricks-launch alias\n") + f.write("alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'\n") + logger.debug("Added protontricks-launch alias to ~/.bashrc") + + logger.info("Protontricks aliases created successfully") + return True + else: + logger.info("Protontricks is not installed via flatpak, skipping alias creation") + return True + except Exception as e: + logger.error(f"Error creating protontricks alias: {e}") + return False + + def get_wine_prefix_path(self, appid) -> Optional[str]: + """Gets the WINEPREFIX path for a given AppID. + + Args: + appid (str): The Steam AppID. + + Returns: + The WINEPREFIX path as a string, or None if detection fails. + """ + 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(): + prefix_path = result.stdout.strip() + logger.debug(f"Detected WINEPREFIX: {prefix_path}") + return prefix_path + else: + logger.error(f"Failed to get WINEPREFIX for AppID {appid}. Stderr: {result.stderr if result else 'N/A'}") + return None + + def run_protontricks_launch(self, appid, installer_path, *extra_args): + """ + Run protontricks-launch (for WebView or similar installers) using the correct method for flatpak or native. + Returns subprocess.CompletedProcess object. + """ + if self.which_protontricks is None: + if not self.detect_protontricks(): + self.logger.error("Could not detect protontricks installation") + return None + if self.which_protontricks == 'flatpak': + cmd = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)] + else: + launch_path = shutil.which("protontricks-launch") + if not launch_path: + self.logger.error("protontricks-launch command not found in PATH.") + return None + cmd = [launch_path, "--appid", appid, str(installer_path)] + if extra_args: + cmd.extend(extra_args) + self.logger.debug(f"Running protontricks-launch: {' '.join(map(str, cmd))}") + try: + # PyInstaller fix: Use cleaned environment + env = self._get_clean_subprocess_env() + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) + except Exception as e: + self.logger.error(f"Error running protontricks-launch: {e}") + return None + + def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None): + """ + Install the specified Wine components into the given prefix using protontricks. + If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022). + """ + env = self._get_clean_subprocess_env() + env["WINEDEBUG"] = "-all" + if specific_components is not None: + components_to_install = specific_components + self.logger.info(f"Installing specific components: {components_to_install}") + else: + components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] + self.logger.info(f"Installing default components: {components_to_install}") + if not components_to_install: + self.logger.info("No Wine components to install.") + return True + self.logger.info(f"AppID: {appid}, Game: {game_var}, Components: {components_to_install}") + # print(f"\n[Jackify] Installing Wine components for AppID {appid} ({game_var}):\n {', '.join(components_to_install)}\n") # Suppressed per user request + 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: + result = self.run_protontricks("--no-bwrap", appid, "-q", *components_to_install, env=env, timeout=600) + self.logger.debug(f"Protontricks output: {result.stdout if result else ''}") + if result and result.returncode == 0: + self.logger.info("Wine Component installation command completed successfully.") + return True + else: + self.logger.error(f"Protontricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode if result else 'N/A'}") + self.logger.error(f"Stdout: {result.stdout.strip() if result else ''}") + self.logger.error(f"Stderr: {result.stderr.strip() if result else ''}") + except Exception as e: + self.logger.error(f"Error during protontricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True) + self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.") + return False + + def _cleanup_wine_processes(self): + """ + Internal method to clean up wine processes during component installation + """ + try: + subprocess.run("pgrep -f 'win7|win10|ShowDotFiles|protontricks' | xargs -r kill -9", + shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run("pkill -9 winetricks", + shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + logger.error(f"Error cleaning up wine processes: {e}") + + def check_and_setup_protontricks(self) -> bool: + """ + Runs all necessary checks and setup steps for Protontricks. + - Detects (and prompts for install if missing) + - Checks version + - Creates aliases if using Flatpak + + Returns: + bool: True if Protontricks is ready to use, False otherwise. + """ + logger.info("Checking and setting up Protontricks...") + + logger.info("Checking Protontricks installation...") + if not self.detect_protontricks(): + # Error message already printed by detect_protontricks if install fails/skipped + return False + logger.info(f"Protontricks detected: {self.which_protontricks}") + + logger.info("Checking Protontricks version...") + if not self.check_protontricks_version(): + # Error message already printed by check_protontricks_version + print(f"Error: Protontricks version {self.protontricks_version} is too old or could not be checked.") + return False + logger.info(f"Protontricks version {self.protontricks_version} is sufficient.") + + # Aliases are non-critical, log warning if creation fails + if self.which_protontricks == 'flatpak': + logger.info("Ensuring Flatpak aliases exist in ~/.bashrc...") + if not self.protontricks_alias(): + # Logged by protontricks_alias, maybe add print? + print("Warning: Failed to create/verify protontricks aliases in ~/.bashrc") + # Don't necessarily fail the whole setup for this + + logger.info("Protontricks check and setup completed successfully.") + return True \ No newline at end of file diff --git a/jackify/backend/handlers/resolution_handler.py b/jackify/backend/handlers/resolution_handler.py new file mode 100644 index 0000000..7abee18 --- /dev/null +++ b/jackify/backend/handlers/resolution_handler.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Resolution Handler Module +Handles setting resolution in various INI files +""" + +import os +import re +import glob +import logging +import subprocess +from pathlib import Path +from typing import Optional, List, Dict +# Import colors from the new central location +from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_ERROR, COLOR_INFO + +# Initialize logger +logger = logging.getLogger(__name__) + + +class ResolutionHandler: + """ + Handles resolution selection and configuration for games + """ + + def __init__(self, modlist_dir=None, game_var=None, resolution=None): + self.modlist_dir = modlist_dir + self.game_var = game_var # Short version (e.g., "Skyrim") + self.game_var_full = None # Full version (e.g., "Skyrim Special Edition") + self.resolution = resolution + # Add logger initialization + self.logger = logging.getLogger(__name__) + + # Set the full game name based on the short version + if self.game_var: + game_lookup = { + "Skyrim": "Skyrim Special Edition", + "Fallout": "Fallout 4", + "Fallout 4": "Fallout 4", + "Fallout New Vegas": "Fallout New Vegas", + "FNV": "Fallout New Vegas", + "Oblivion": "Oblivion" + } + self.game_var_full = game_lookup.get(self.game_var, self.game_var) + + def set_resolution(self, resolution): + """ + Set the target resolution, e.g. "1280x800" + """ + self.resolution = resolution + logger.debug(f"Resolution set to: {self.resolution}") + return True + + def get_resolution_components(self): + """ + Split resolution into width and height components + """ + if not self.resolution: + logger.error("Resolution not set") + return None, None + + try: + width, height = self.resolution.split('x') + return width, height + except ValueError: + logger.error(f"Invalid resolution format: {self.resolution}") + return None, None + + def detect_steamdeck_resolution(self): + """ + Set resolution to Steam Deck native if on a Steam Deck + """ + try: + if os.path.exists("/etc/os-release"): + with open("/etc/os-release", "r") as f: + if "steamdeck" in f.read(): + self.resolution = "1280x800" + logger.debug("Steam Deck detected, setting resolution to 1280x800") + return True + + return False + except Exception as e: + logger.error(f"Error detecting Steam Deck resolution: {e}") + return False + + def select_resolution(self, steamdeck=False) -> Optional[str]: + """ + Ask the user if they want to set resolution, then prompt and validate. + Returns the selected resolution string (e.g., "1920x1080") or None if skipped/cancelled. + """ + if steamdeck: + logger.info("Steam Deck detected - Setting resolution to 1280x800") + return "1280x800" + + # Ask user if they want to set resolution + response = input(f"{COLOR_PROMPT}Do you wish to set the display resolution now? (y/N): {COLOR_RESET}").lower() + + if response == 'y': + while True: + user_res = input(f"{COLOR_PROMPT}Enter desired resolution (e.g., 1920x1080): {COLOR_RESET}").strip() + if self._validate_resolution_format(user_res): + # Optional: Add confirmation step here if desired + # confirm = input(f"{COLOR_PROMPT}Use resolution {user_res}? (Y/n): {COLOR_RESET}").lower() + # if confirm != 'n': + # return user_res + return user_res # Return validated resolution + else: + print(f"{COLOR_ERROR}Invalid format. Please use format WxH (e.g., 1920x1080){COLOR_RESET}") + else: + self.logger.info("Resolution setup skipped by user.") + return None + + def _validate_resolution_format(self, resolution: str) -> bool: + """Validates the resolution format WxH (e.g., 1920x1080).""" + if not resolution: + return False + # Simple regex to match one or more digits, 'x', one or more digits + if re.match(r"^[0-9]+x[0-9]+$", resolution): + self.logger.debug(f"Resolution format validated: {resolution}") + return True + else: + self.logger.warning(f"Invalid resolution format provided: {resolution}") + return False + + @staticmethod + def get_available_resolutions() -> List[str]: + """Gets available display resolutions using xrandr.""" + resolutions = [] + try: + result = subprocess.run(["xrandr"], capture_output=True, text=True, check=True) + # Regex to find lines like ' 1920x1080 59.96*+' + matches = re.finditer(r"^\s*(\d+x\d+)\s", result.stdout, re.MULTILINE) + for match in matches: + res = match.group(1) + if res not in resolutions: + resolutions.append(res) + # Add common resolutions if xrandr fails or doesn't list them + common_res = ["1280x720", "1280x800", "1920x1080", "1920x1200", "2560x1440"] + for res in common_res: + if res not in resolutions: + resolutions.append(res) + resolutions.sort(key=lambda r: tuple(map(int, r.split('x')))) + logger.debug(f"Detected resolutions: {resolutions}") + return resolutions + except (FileNotFoundError, subprocess.CalledProcessError, Exception) as e: + logger.warning(f"Could not detect resolutions via xrandr: {e}. Falling back to common list.") + # Fallback to a common list if xrandr is not available or fails + return ["1280x720", "1280x800", "1920x1080", "1920x1200", "2560x1440"] + + @staticmethod + def update_ini_resolution(modlist_dir: str, game_var: str, set_res: str) -> bool: + """ + Updates the resolution in relevant INI files for the specified game. + + Args: + 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"). + + Returns: + bool: True if successful or not applicable, False on error. + """ + logger.info(f"Attempting to set resolution to {set_res} for {game_var} in {modlist_dir}") + + try: + isize_w, isize_h = set_res.split('x') + modlist_path = Path(modlist_dir) + success_count = 0 + files_processed = 0 + + # 1. Handle SSEDisplayTweaks.ini (Skyrim SE only) + if game_var == "Skyrim Special Edition": + logger.debug("Processing SSEDisplayTweaks.ini...") + sse_tweaks_files = list(modlist_path.rglob("SSEDisplayTweaks.ini")) + if sse_tweaks_files: + for ini_file in sse_tweaks_files: + files_processed += 1 + logger.debug(f"Updating {ini_file}") + if ResolutionHandler._modify_sse_tweaks(ini_file, set_res): + success_count += 1 + else: + logger.debug("No SSEDisplayTweaks.ini found, skipping.") + + # 1.5. Handle HighFPSPhysicsFix.ini (Fallout 4 only) + elif game_var == "Fallout 4": + logger.debug("Processing HighFPSPhysicsFix.ini...") + highfps_files = list(modlist_path.rglob("HighFPSPhysicsFix.ini")) + if highfps_files: + for ini_file in highfps_files: + files_processed += 1 + logger.debug(f"Updating {ini_file}") + if ResolutionHandler._modify_highfps_physics_fix(ini_file, set_res): + success_count += 1 + else: + logger.debug("No HighFPSPhysicsFix.ini found, skipping.") + + # 2. Handle game-specific Prefs/INI files + prefs_filenames = [] + if game_var == "Skyrim Special Edition": + prefs_filenames = ["skyrimprefs.ini"] + elif game_var == "Fallout 4": + prefs_filenames = ["Fallout4Prefs.ini"] + elif game_var == "Fallout New Vegas": + prefs_filenames = ["falloutprefs.ini"] + elif game_var == "Oblivion": + prefs_filenames = ["Oblivion.ini"] + else: + logger.warning(f"Resolution setting not implemented for game: {game_var}") + return True # Not an error, just not applicable + + 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))) + + 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 + + for ini_file in prefs_files_found: + files_processed += 1 + logger.debug(f"Updating {ini_file}") + if ResolutionHandler._modify_prefs_resolution(ini_file, isize_w, isize_h, game_var == "Oblivion"): + success_count += 1 + + logger.info(f"Resolution update: processed {files_processed} files, {success_count} successfully updated.") + # Return True even if some updates failed, as the overall process didn't halt + return True + + except ValueError: + logger.error(f"Invalid resolution format: {set_res}. Expected WxH (e.g., 1920x1080).") + return False + except Exception as e: + logger.error(f"Error updating INI resolutions: {e}", exc_info=True) + return False + + @staticmethod + def _modify_sse_tweaks(ini_path: Path, resolution: str) -> bool: + """Helper to modify SSEDisplayTweaks.ini""" + try: + with open(ini_path, 'r') as f: + lines = f.readlines() + + new_lines = [] + modified = False + for line in lines: + stripped_line = line.strip() + # Use regex for flexibility with spacing and comments + if re.match(r'^\s*(#?)\s*Resolution\s*=.*$', stripped_line, re.IGNORECASE): + new_lines.append(f"Resolution={resolution}\n") + modified = True + elif re.match(r'^\s*(#?)\s*Fullscreen\s*=.*$', stripped_line, re.IGNORECASE): + new_lines.append("Fullscreen=false\n") + modified = True + elif re.match(r'^\s*(#?)\s*Borderless\s*=.*$', stripped_line, re.IGNORECASE): + new_lines.append("Borderless=true\n") + modified = True + else: + new_lines.append(line) + + if modified: + with open(ini_path, 'w') as f: + f.writelines(new_lines) + logger.debug(f"Successfully modified {ini_path} for SSEDisplayTweaks") + return True + except Exception as e: + logger.error(f"Failed to modify {ini_path}: {e}") + return False + + @staticmethod + def _modify_highfps_physics_fix(ini_path: Path, resolution: str) -> bool: + """Helper to modify HighFPSPhysicsFix.ini for Fallout 4""" + try: + with open(ini_path, 'r') as f: + lines = f.readlines() + + new_lines = [] + modified = False + for line in lines: + stripped_line = line.strip() + # Look for Resolution line (commonly commented out by default) + if re.match(r'^\s*(#?)\s*Resolution\s*=.*$', stripped_line, re.IGNORECASE): + new_lines.append(f"Resolution={resolution}\n") + modified = True + else: + new_lines.append(line) + + if modified: + with open(ini_path, 'w') as f: + f.writelines(new_lines) + logger.debug(f"Successfully modified {ini_path} for HighFPSPhysicsFix") + return True + except Exception as e: + logger.error(f"Failed to modify {ini_path}: {e}") + return False + + @staticmethod + def _modify_prefs_resolution(ini_path: Path, width: str, height: str, is_oblivion: bool) -> bool: + """Helper to modify resolution in skyrimprefs.ini, Fallout4Prefs.ini, etc.""" + try: + with open(ini_path, 'r') as f: + lines = f.readlines() + + 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"): + new_lines.append(width_replace) + modified = True + elif stripped_line.lower().endswith("isize h"): + new_lines.append(height_replace) + modified = True + else: + new_lines.append(line) + + if modified: + with open(ini_path, 'w') as f: + f.writelines(new_lines) + logger.debug(f"Successfully modified {ini_path} for resolution") + return True + except Exception as e: + logger.error(f"Failed to modify {ini_path}: {e}") + return False + + def edit_resolution(self, modlist_dir, game_var, selected_resolution=None): + """ + Edit resolution in INI files + """ + if selected_resolution: + logger.debug(f"Applying resolution: {selected_resolution}") + return self.update_ini_resolution(modlist_dir, game_var, selected_resolution) + else: + logger.debug("Resolution setup skipped") + return True + + def update_sse_display_tweaks(self): + """ + Update SSEDisplayTweaks.ini with the chosen resolution + Returns True on success, False on failure + """ + if not self.modlist_dir or not self.game_var or not self.resolution: + logger.error("Missing required parameters") + return False + + if self.game_var != "Skyrim Special Edition": + logger.debug(f"Not Skyrim, skipping SSEDisplayTweaks") + return False + + try: + # Find all SSEDisplayTweaks.ini files + ini_files = glob.glob(f"{self.modlist_dir}/**/SSEDisplayTweaks.ini", recursive=True) + + if not ini_files: + logger.debug("No SSEDisplayTweaks.ini files found") + return False + + for ini_file in ini_files: + # Read the file + with open(ini_file, 'r', encoding='utf-8', errors='ignore') as f: + content = f.readlines() + + # Process and modify the content + modified_content = [] + for line in content: + if line.strip().startswith("Resolution=") or line.strip().startswith("#Resolution="): + modified_content.append(f"Resolution={self.resolution}\n") + elif line.strip().startswith("Fullscreen=") or line.strip().startswith("#Fullscreen="): + modified_content.append(f"Fullscreen=false\n") + elif line.strip().startswith("Borderless=") or line.strip().startswith("#Borderless="): + modified_content.append(f"Borderless=true\n") + else: + modified_content.append(line) + + # Write the modified content back + with open(ini_file, 'w', encoding='utf-8') as f: + f.writelines(modified_content) + + logger.debug(f"Updated {ini_file} with Resolution={self.resolution}, Fullscreen=false, Borderless=true") + + return True + + except Exception as e: + logger.error(f"Error updating SSEDisplayTweaks.ini: {e}") + return False + + def update_game_prefs_ini(self): + """ + Update game preference INI files with the chosen resolution + Returns True on success, False on failure + """ + if not self.modlist_dir or not self.game_var or not self.resolution: + logger.error("Missing required parameters") + return False + + try: + # Get resolution components + width, height = self.get_resolution_components() + if not width or not height: + return False + + # Define possible stock game folders to search + stock_folders = [ + "profiles", "Stock Game", "Game Root", "STOCK GAME", + "Stock Game Folder", "Stock Folder", "Skyrim Stock" + ] + + # Define the appropriate INI file based on game type + ini_filename = None + if self.game_var == "Skyrim Special Edition": + ini_filename = "skyrimprefs.ini" + elif self.game_var == "Fallout 4": + ini_filename = "Fallout4Prefs.ini" + elif self.game_var == "Fallout New Vegas": + ini_filename = "falloutprefs.ini" + elif self.game_var == "Oblivion": + ini_filename = "Oblivion.ini" + else: + logger.error(f"Unsupported game: {self.game_var}") + return False + + # Search for INI files in the appropriate directories + ini_files = [] + for folder in stock_folders: + path_pattern = os.path.join(self.modlist_dir, folder, f"**/{ini_filename}") + ini_files.extend(glob.glob(path_pattern, recursive=True)) + + if not ini_files: + logger.warn(f"No {ini_filename} files found in specified directories") + return False + + for ini_file in ini_files: + # Read the file + with open(ini_file, 'r', encoding='utf-8', errors='ignore') as f: + content = f.readlines() + + # Process and modify the content + modified_content = [] + for line in content: + line_lower = line.lower() + if "isize w" in line_lower: + # Handle different formats (with = or space) + if "=" in line and not " = " in line: + modified_content.append(f"iSize W={width}\n") + else: + modified_content.append(f"iSize W = {width}\n") + elif "isize h" in line_lower: + # Handle different formats (with = or space) + if "=" in line and not " = " in line: + modified_content.append(f"iSize H={height}\n") + else: + modified_content.append(f"iSize H = {height}\n") + else: + modified_content.append(line) + + # Write the modified content back + with open(ini_file, 'w', encoding='utf-8') as f: + f.writelines(modified_content) + + logger.debug(f"Updated {ini_file} with iSize W={width}, iSize H={height}") + + return True + + except Exception as e: + logger.error(f"Error updating game prefs INI: {e}") + return False + + def update_all_resolution_settings(self): + """ + Update all resolution-related settings in all relevant INI files + Returns True if any files were updated, False if none were updated + """ + if not self.resolution: + logger.error("Resolution not set") + return False + + success = False + + # Update SSEDisplayTweaks.ini if applicable + sse_success = self.update_sse_display_tweaks() + + # Update game preferences INI + prefs_success = self.update_game_prefs_ini() + + return sse_success or prefs_success \ No newline at end of file diff --git a/jackify/backend/handlers/self_update.py b/jackify/backend/handlers/self_update.py new file mode 100644 index 0000000..89f1648 --- /dev/null +++ b/jackify/backend/handlers/self_update.py @@ -0,0 +1,141 @@ +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() \ No newline at end of file diff --git a/jackify/backend/handlers/shortcut_handler.py b/jackify/backend/handlers/shortcut_handler.py new file mode 100644 index 0000000..2646b1a --- /dev/null +++ b/jackify/backend/handlers/shortcut_handler.py @@ -0,0 +1,1369 @@ +#!/usr/bin/env python3 +import os +import random +import subprocess +import logging +import readline # For tab completion +import time +import glob +from pathlib import Path +import vdf +from typing import Optional, List, Dict, Callable, Tuple +import re +import shutil + +# Import other necessary modules +from .protontricks_handler import ProtontricksHandler +from .vdf_handler import VDFHandler # Changed to relative import +from .path_handler import PathHandler # Added PathHandler import +from .completers import path_completer + +# Get logger for the module +logger = logging.getLogger(__name__) + +class ShortcutHandler: + """Handles creation and management of Steam shortcuts""" + + def __init__(self, steamdeck: bool, verbose: bool = False): + """ + Initialize the ShortcutHandler. + + Args: + steamdeck (bool): True if running on Steam Deck, False otherwise. + verbose (bool): Controls verbose output for methods like secure_steam_restart. + """ + self.logger = logging.getLogger(__name__) + self.vdf_handler = VDFHandler() + self.steamdeck = steamdeck + self.verbose = verbose # Store verbose flag + self.path_handler = PathHandler() # Add PathHandler instance + self.shortcuts_path = self.path_handler._find_shortcuts_vdf() # Use PathHandler method + 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) + + def _enable_tab_completion(self): + """Enable tab completion for file paths using the shared completer""" + readline.set_completer(path_completer) + readline.set_completer_delims(' \t\n;') + readline.parse_and_bind("tab: complete") + + def _get_mo2_path(self): + """ + Get the path to ModOrganizer.exe from user with tab completion + Returns: + tuple: (mo2_dir, mo2_path) or (None, None) if cancelled + """ + self._enable_tab_completion() + while True: + try: + path = input("\nEnter the path to ModOrganizer.exe or its containing directory: ").strip() + if not path: + return None, None + + # Convert to absolute path + path = os.path.expanduser(path) + path = os.path.abspath(path) + + # If directory provided, look for ModOrganizer.exe + if os.path.isdir(path): + mo2_path = os.path.join(path, "ModOrganizer.exe") + else: + mo2_path = path + path = os.path.dirname(path) + + # Verify ModOrganizer.exe exists + if os.path.isfile(mo2_path): + self.logger.debug(f"Found ModOrganizer.exe at: {mo2_path}") + return path, mo2_path + else: + print("ModOrganizer.exe not found at specified location. Please try again.") + except KeyboardInterrupt: + return None, None + + def _get_modlist_name(self): + """ + Get the modlist name from user + Returns: + str: Modlist name or None if cancelled + """ + try: + name = input("\nEnter a name for the modlist: ").strip() + if not name: + return None + return name + except KeyboardInterrupt: + return None + + def _check_and_restore_shortcuts_vdf(self): + """ + Check if shortcuts.vdf exists and restore from backup if missing. + Returns: + bool: True if file exists or was restored, False if unable to restore + """ + # Find all shortcuts.vdf paths + shortcuts_files = [] + for user_dir in os.listdir(self.shortcuts_path): + shortcuts_file = os.path.join(self.shortcuts_path, user_dir, "config", "shortcuts.vdf") + if os.path.dirname(shortcuts_file): + shortcuts_files.append(shortcuts_file) + + # Check if any are missing and need restoration + missing_files = [] + for file_path in shortcuts_files: + if not os.path.exists(file_path): + self.logger.warning(f"shortcuts.vdf is missing at: {file_path}") + missing_files.append(file_path) + + if not missing_files: + self.logger.debug("All shortcuts.vdf files are present") + return True + + # Try to restore from backups + restored = 0 + for file_path in missing_files: + # Try timestamped backup first + backup_files = sorted(glob.glob(f"{file_path}.*.bak"), reverse=True) + if backup_files: + try: + import shutil + shutil.copy2(backup_files[0], file_path) + self.logger.info(f"Restored {file_path} from {backup_files[0]}") + restored += 1 + continue + except Exception as e: + self.logger.error(f"Failed to restore from timestamped backup: {e}") + + # Try simple backup + simple_backup = f"{file_path}.bak" + if os.path.exists(simple_backup): + try: + import shutil + shutil.copy2(simple_backup, file_path) + self.logger.info(f"Restored {file_path} from simple backup") + restored += 1 + continue + except Exception as e: + self.logger.error(f"Failed to restore from simple backup: {e}") + + if restored == len(missing_files): + self.logger.info("Successfully restored all missing shortcuts.vdf files") + return True + elif restored > 0: + self.logger.warning(f"Partially restored {restored}/{len(missing_files)} shortcuts.vdf files") + return True + else: + self.logger.error("Failed to restore any shortcuts.vdf files") + return False + + def _modify_shortcuts_directly(self, shortcuts_file, modlist_name, mo2_path, mo2_dir): + """ + Directly modify shortcuts.vdf in a way that preserves Steam's exact binary format. + This is a fallback method when regular VDF handling might cause issues. + + Args: + shortcuts_file (str): Path to shortcuts.vdf + modlist_name (str): Name for the modlist + mo2_path (str): Path to ModOrganizer.exe + mo2_dir (str): Directory containing ModOrganizer.exe + + Returns: + bool: True if successful, False otherwise + """ + try: + # Make a secure backup first + import shutil + backup_path = f"{shortcuts_file}.{int(time.time())}.bak" + shutil.copy2(shortcuts_file, backup_path) + self.logger.info(f"Created backup before direct modification: {backup_path}") + + # Create a new shortcut entry using Steam's expected format + + # Pre-populate shortcuts.vdf if it doesn't exist or is empty + if not os.path.exists(shortcuts_file) or os.path.getsize(shortcuts_file) == 0: + with open(shortcuts_file, 'wb') as f: + f.write(b'\x00shortcuts\x00\x08\x08') + self.logger.info(f"Created new shortcuts.vdf file at {shortcuts_file}") + + # Use direct steam-vdf library for reliable binary operations + try: + # Try to import the steam-vdf library + import sys + import importlib.util + + # Check if steam_vdf is installed + steam_vdf_spec = importlib.util.find_spec("steam_vdf") + + if steam_vdf_spec is None: + # Try to install steam-vdf using pip + print("Installing required dependency (steam-vdf)...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "steam-vdf", "--user"]) + time.sleep(1) # Give some time for the install to complete + + # Now import it + import steam_vdf + + with open(shortcuts_file, 'rb') as f: + shortcuts_data = steam_vdf.load(f) + + # Find the highest shortcut ID to use for the new entry + max_id = -1 + if 'shortcuts' in shortcuts_data: + for id_str in shortcuts_data['shortcuts']: + try: + id_num = int(id_str) + if id_num > max_id: + max_id = id_num + except ValueError: + pass + + # Create a new shortcut entry + new_id = max_id + 1 + + # Ensure 'shortcuts' key exists + if 'shortcuts' not in shortcuts_data: + shortcuts_data['shortcuts'] = {} + + # Add the new shortcut + shortcuts_data['shortcuts'][str(new_id)] = { + 'AppName': modlist_name, + 'Exe': f'"{mo2_path}"', + 'StartDir': mo2_dir, + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'OpenVR': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0 + } + + # Write back to file + with open(shortcuts_file, 'wb') as f: + steam_vdf.dump(shortcuts_data, f) + + self.logger.info(f"Added shortcut for {modlist_name} using steam-vdf library") + return True + + except Exception as e: + self.logger.warning(f"Failed to use steam-vdf library: {e}") + + # Fall back to our safe VDFHandler + self.logger.info("Falling back to VDFHandler for shortcuts.vdf modification") + shortcuts_data = VDFHandler.load(shortcuts_file, binary=True) + + # If the data is empty, initialize it + if not shortcuts_data: + shortcuts_data = {'shortcuts': {}} + + # Create new shortcut entry + new_id = len(shortcuts_data.get('shortcuts', {})) + new_entry = { + 'AppName': modlist_name, + 'Exe': f'"{mo2_path}"', + 'StartDir': mo2_dir, + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'OpenVR': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0 + } + + # Add to shortcuts + if 'shortcuts' not in shortcuts_data: + shortcuts_data['shortcuts'] = {} + shortcuts_data['shortcuts'][str(new_id)] = new_entry + + # Write back to file using our safe VDFHandler + result = VDFHandler.save(shortcuts_file, shortcuts_data, binary=True) + + self.logger.info(f"Added shortcut for {modlist_name} using VDFHandler") + return result + + except Exception as e: + self.logger.error(f"Error in direct shortcut modification: {e}") + return False + + def _add_steam_shortcut_safely(self, shortcuts_file, app_name, exe_path, start_dir, icon_path="", launch_options="", tags=None): + """ + Adds a new shortcut entry to the shortcuts.vdf file using the correct binary format. + This method is carefully designed to maintain file integrity. + + Args: + shortcuts_file (str): Path to shortcuts.vdf + app_name (str): Name for the shortcut + exe_path (str): Path to the executable + start_dir (str): Start directory for the executable + icon_path (str): Path to icon file (optional) + launch_options (str): Command line options (optional) + tags (list): List of tags (optional) + + Returns: + tuple: (bool success, str app_id) - Success status and calculated AppID + """ + if tags is None: + tags = [] # Ensure tags is a list + + # Initialize data structure + data = {'shortcuts': {}} # Default structure if file doesn't exist or is empty + + try: + # CRITICAL: Open in BINARY READ mode ('rb') + if os.path.exists(shortcuts_file): + with open(shortcuts_file, 'rb') as f: + file_data = f.read() + if file_data: # Only try to parse if the file has content + try: + data = vdf.binary_loads(file_data) + # Ensure the top-level 'shortcuts' key exists + if 'shortcuts' not in data: + data['shortcuts'] = {} + except Exception as e: + self.logger.warning(f"Could not parse existing shortcuts.vdf: {e}") + # Reset to default structure if loading fails + data = {'shortcuts': {}} + else: + self.logger.info(f"shortcuts.vdf not found at {shortcuts_file}. A new file will be created.") + except Exception as e: + self.logger.warning(f"Error accessing shortcuts.vdf: {e}") + # Reset to default structure if loading fails + data = {'shortcuts': {}} + + # Ensure the shortcuts key exists + if 'shortcuts' not in data: + data['shortcuts'] = {} + + # Find the next available index key (0, 1, 2, ...) + next_index = 0 + if data.get('shortcuts'): # Check if shortcuts dictionary exists and is not empty + shortcut_indices = [int(k) for k in data['shortcuts'].keys() if k.isdigit()] + if shortcut_indices: + next_index = max(shortcut_indices) + 1 + + # Steam expects specific fields for each shortcut. + # Even empty ones are often necessary. + new_shortcut = { + 'AppName': app_name, + 'Exe': f'"{exe_path}"', # Enclose executable path in quotes + 'StartDir': f'"{start_dir}"', # Enclose start directory in quotes + 'icon': icon_path, + 'ShortcutPath': "", # Usually empty for non-Steam games + 'LaunchOptions': launch_options, + 'IsHidden': 0, # 0 for visible, 1 for hidden + 'AllowDesktopConfig': 1, # Allow Steam Input configuration + 'AllowOverlay': 1, # Allow Steam Overlay + 'OpenVR': 0, # Set to 1 for VR games + 'Devkit': 0, + 'DevkitGameID': '', + 'DevkitOverrideAppID': 0, + 'LastPlayTime': 0, # Timestamp, 0 for never played + 'FlatpakAppID': '', # For Flatpak apps on Linux + 'IsInstalled': 1, # Make it appear in "Locally Installed" filter + } + + # Add tags in the correct format if any + if tags: + new_shortcut['tags'] = {str(i): tag for i, tag in enumerate(tags)} + + # Calculate the AppID - this is how Steam does it + app_id = (0x80000000 + int(next_index)) % (2**32) + + # Ensure the AppID is within the valid 32-bit signed integer range + if app_id > 0x7FFFFFFF: + app_id = app_id - 0x100000000 + + # Add the appid to the shortcut entry (like STL does) + new_shortcut['appid'] = app_id + + # Add the new shortcut entry using the string representation of the index + data['shortcuts'][str(next_index)] = new_shortcut + self.logger.info(f"Adding shortcut '{app_name}' at index {next_index}") + + try: + # CRITICAL: Open in BINARY WRITE mode ('wb') + # First create a temp file to ensure we don't corrupt the original if something goes wrong + temp_file = f"{shortcuts_file}.temp" + with open(temp_file, 'wb') as f: + vdf_data = vdf.binary_dumps(data) + f.write(vdf_data) + + # Now rename the temp file to the actual file + import shutil + shutil.move(temp_file, shortcuts_file) + + self.logger.info(f"Successfully updated shortcuts.vdf! AppID: {app_id}") + return True, app_id + except Exception as e: + self.logger.error(f"Error: Failed to write updated shortcuts.vdf: {e}") + return False, None + + def create_shortcut(self, executable_path=None, shortcut_name=None, launch_options="", icon_path=""): + """ + Create a new Steam shortcut entry. + + Args: + executable_path (str): Path to the main executable (e.g., Hoolamike.exe) + shortcut_name (str): Name for the Steam shortcut + launch_options (str): Launch options string (optional) + icon_path (str): Path to the icon for the shortcut (optional) + + Returns: + tuple: (bool success, Optional[str] app_id) - Success status and the generated AppID, or None if failed. + """ + self.logger.info(f"Attempting to create shortcut for: {shortcut_name}") + self.logger.debug(f"[DEBUG] create_shortcut called with executable_path={executable_path}, shortcut_name={shortcut_name}, icon_path={icon_path}") + self._last_shortcuts_backup = None + self._safe_shortcuts_backup = None + self._shortcuts_file = None # Ensure this is reset/set correctly + + # --- Steam Icons normalization (move here for all flows) --- + if executable_path: + exe_dir = os.path.dirname(executable_path) + steam_icons_path = Path(exe_dir) / "Steam Icons" + steamicons_path = Path(exe_dir) / "SteamIcons" + if steam_icons_path.is_dir() and not steamicons_path.is_dir(): + try: + steam_icons_path.rename(steamicons_path) + self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {exe_dir}") + except Exception as e: + self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}") + # ---------------------------------------------------------- + + # Validate inputs + if not executable_path or not os.path.exists(executable_path): + self.logger.error(f"Invalid or non-existent executable path provided: {executable_path}") + return False, None + else: + start_dir = os.path.dirname(executable_path) + + if not shortcut_name: + self.logger.error("Shortcut name not provided.") + return False, None + + try: + # Use the shortcuts.vdf path found during initialization + shortcuts_file = self.shortcuts_path + self._shortcuts_file = shortcuts_file # Store for potential use + + if not shortcuts_file or not os.path.isfile(shortcuts_file): + self.logger.error("shortcuts.vdf path not found or is invalid.") + print("Error: Could not find the Steam shortcuts file (shortcuts.vdf).") + # Attempt to create a blank one? Might be risky. + # Let's try creating it if the directory exists. + config_dir = os.path.dirname(shortcuts_file) if shortcuts_file else None + if config_dir and os.path.isdir(config_dir): + self.logger.warning(f"Attempting to create blank shortcuts.vdf at {shortcuts_file}") + with open(shortcuts_file, 'wb') as f: + f.write(b'\x00shortcuts\x00\x08\x08') # Minimal valid binary VDF structure + self.logger.info("Created blank shortcuts.vdf.") + else: + self.logger.error("Cannot create shortcuts.vdf as parent directory doesn't exist.") + return False, None + else: + # Ensure the parent directory exists for backups if shortcuts_file was valid + config_dir = os.path.dirname(shortcuts_file) + if not os.path.isdir(config_dir): + self.logger.error(f"Config directory not found: {config_dir}") + print(f"Error: Steam config directory not found: {config_dir}") + return False, None + + # Create a direct backup before making any changes + backup_dir = os.path.join(config_dir, "backups") + os.makedirs(backup_dir, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") + backup_path = os.path.join(backup_dir, f"shortcuts_{timestamp}.bak") + + # Check if the shortcuts file exists before backing up + if os.path.exists(shortcuts_file): + import shutil + shutil.copy2(shortcuts_file, backup_path) + self._last_shortcuts_backup = backup_path # Store for potential restoration + self.logger.info(f"Created backup at {backup_path}") + else: + self.logger.warning(f"shortcuts.vdf does not exist at {shortcuts_file}, cannot create backup. Proceeding with potentially new file.") + + # --- Add STEAM_COMPAT_MOUNTS --- (Keep this logic) + compat_mounts_str = "" + try: + self.logger.info("Determining necessary STEAM_COMPAT_MOUNTS...") + all_libs = self.path_handler.get_all_steam_library_paths() + main_steam_lib_path_obj = self.path_handler.find_steam_library() + if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": + main_steam_lib_path = main_steam_lib_path_obj.parent.parent + else: + main_steam_lib_path = main_steam_lib_path_obj + + mount_paths = [] + if main_steam_lib_path: + self.logger.debug(f"Identified main Steam library: {main_steam_lib_path}") + main_resolved = main_steam_lib_path.resolve() + for lib_path in all_libs: + if lib_path.resolve() != main_resolved: + mount_paths.append(str(lib_path.resolve())) + else: + self.logger.debug(f"Excluding main library {lib_path} from mounts.") + else: + self.logger.warning("Could not reliably determine the main Steam library. STEAM_COMPAT_MOUNTS may include it or be empty.") + mount_paths = [] + + if mount_paths: + compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}"' + self.logger.info(f"Generated STEAM_COMPAT_MOUNTS string: {compat_mounts_str}") + else: + self.logger.info("No additional libraries identified or needed for STEAM_COMPAT_MOUNTS.") + + except Exception as e: + self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True) + + # Prepend STEAM_COMPAT_MOUNTS to existing launch options + final_launch_options = launch_options + if compat_mounts_str: + if final_launch_options: + final_launch_options = f"{compat_mounts_str} {final_launch_options}" + else: + final_launch_options = compat_mounts_str + + # Ensure %command% is at the end if not already present + if not final_launch_options.strip().endswith("%command%"): + if final_launch_options: + final_launch_options = f"{final_launch_options} %command%" + else: + final_launch_options = "%command%" + + self.logger.debug(f"Final launch options string: {final_launch_options}") + # --- End STEAM_COMPAT_MOUNTS --- + + # Add the shortcut using our safe method + success, app_id = self._add_steam_shortcut_safely( + shortcuts_file, + shortcut_name, + executable_path, # Use the validated path + start_dir, # Use the derived start_dir + icon_path=icon_path, # Pass the icon path + launch_options=final_launch_options, # Pass the combined options + tags=["Jackify", "Tool"] # Add relevant tags + ) + + if not success: + self.logger.error("Failed to add shortcut entry safely.") + return False, None + + self.logger.info(f"Shortcut created successfully for {shortcut_name} with AppID {app_id}") + return True, app_id + + except Exception as e: + self.logger.error(f"Error creating shortcut: {e}", exc_info=True) + print(f"An error occurred while creating the shortcut: {e}") + return False, None + + def _is_steam_deck(self): + # Check /etc/os-release for 'steamdeck' or if the systemd service exists + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + return True + # Check for the systemd user service + user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True) + if 'app-steam@autostart.service' in user_services.stdout: + return True + except Exception as e: + self.logger.warning(f"Error detecting Steam Deck: {e}") + return False + + def secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool: + """ + Secure Steam restart with comprehensive error handling to prevent segfaults. + Now delegates to the robust steam restart service for cross-distro compatibility. + """ + try: + from ..services.steam_restart_service import robust_steam_restart + return robust_steam_restart(progress_callback=status_callback, timeout=60) + except ImportError as e: + self.logger.error(f"Failed to import steam restart service: {e}") + # Fallback to original implementation if service is not available + return self._legacy_secure_steam_restart(status_callback) + except Exception as e: + self.logger.error(f"Error in robust steam restart: {e}") + # Fallback to original implementation on any error + return self._legacy_secure_steam_restart(status_callback) + + def _legacy_secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool: + """ + Legacy secure Steam restart implementation (fallback). + """ + import subprocess + import time + import os + + self.logger.info("Attempting secure Steam restart sequence...") + + # Wrap all subprocess calls in try-catch to prevent segfaults + def safe_subprocess_run(cmd, **kwargs): + """Safely run subprocess with error handling""" + try: + return subprocess.run(cmd, **kwargs) + except Exception as e: + self.logger.error(f"Subprocess error with cmd {cmd}: {e}") + return subprocess.CompletedProcess(cmd, 1, "", str(e)) + + def safe_subprocess_popen(cmd, **kwargs): + """Safely start subprocess with error handling""" + try: + return subprocess.Popen(cmd, **kwargs) + except Exception as e: + self.logger.error(f"Popen error with cmd {cmd}: {e}") + return None + + if self._is_steam_deck(): + self.logger.info("Detected Steam Deck. Using systemd to restart Steam.") + if status_callback: + try: + status_callback("Restarting Steam via systemd...") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + try: + result = safe_subprocess_run(['systemctl', '--user', 'restart', 'app-steam@autostart.service'], capture_output=True, text=True, timeout=30) + self.logger.info(f"systemctl restart output: {result.stdout.strip()} {result.stderr.strip()}") + # Wait a bit for Steam to come up + time.sleep(10) + # Optionally, check if Steam is running + check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) + if check.returncode == 0: + self.logger.info("Steam restarted successfully via systemd.") + if status_callback: + try: + status_callback("Steam Started") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return True + else: + self.logger.error("Steam did not start after systemd restart.") + if status_callback: + try: + status_callback("Start Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + except Exception as e: + self.logger.error(f"Error restarting Steam via systemd: {e}") + if status_callback: + try: + status_callback("Restart Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + # --- Non-Steam Deck (generic Linux) implementation --- + try: + if status_callback: + try: + status_callback("Stopping Steam...") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + self.logger.info("Attempting clean Steam shutdown via 'steam -shutdown'...") + shutdown_timeout = 30 + result = safe_subprocess_run(['steam', '-shutdown'], timeout=shutdown_timeout, check=False, capture_output=True, text=True) + if result.returncode != 1: # subprocess.run returns CompletedProcess even on error + self.logger.debug("'steam -shutdown' command executed (exit code ignored, verification follows).") + else: + self.logger.warning(f"'steam -shutdown' had issues: {result.stderr}") + except Exception as e: + self.logger.warning(f"Error executing 'steam -shutdown': {e}. Will proceed to check processes.") + + if status_callback: + try: + status_callback("Waiting for Steam to close...") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + self.logger.info("Verifying Steam processes are terminated...") + max_attempts = 6 + steam_closed_successfully = False + + for attempt in range(max_attempts): + try: + check_cmd = ['pgrep', '-f', 'steamwebhelper'] + self.logger.debug(f"Executing check: {' '.join(check_cmd)}") + result = safe_subprocess_run(check_cmd, capture_output=True, timeout=10) + if result.returncode != 0: + self.logger.info("No Steam web helper processes found via pgrep.") + steam_closed_successfully = True + break + else: + try: + steam_pids = result.stdout.decode().strip().split('\n') if result.stdout else [] + self.logger.debug(f"Steam web helper processes still detected (PIDs: {steam_pids}). Waiting... (Attempt {attempt + 1}/{max_attempts} after shutdown cmd)") + except Exception as e: + self.logger.warning(f"Error parsing pgrep output: {e}") + time.sleep(5) + except Exception as e: + self.logger.warning(f"Error checking Steam processes (attempt {attempt + 1}): {e}") + time.sleep(5) + + if not steam_closed_successfully: + self.logger.debug("Steam processes still running after 'steam -shutdown'. Attempting fallback with 'pkill steam'...") + if status_callback: + try: + status_callback("Force stopping Steam...") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + # Fallback: Use pkill to force terminate Steam processes + try: + self.logger.info("Attempting force shutdown via 'pkill steam'...") + pkill_result = safe_subprocess_run(['pkill', '-f', 'steam'], timeout=15, check=False, capture_output=True, text=True) + self.logger.info(f"pkill steam result: {pkill_result.returncode} - {pkill_result.stdout.strip()} {pkill_result.stderr.strip()}") + + # Wait a bit for processes to terminate + time.sleep(3) + + # Check again if Steam processes are terminated + final_check = safe_subprocess_run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10) + if final_check.returncode != 0: + self.logger.info("Steam processes successfully terminated via pkill fallback.") + steam_closed_successfully = True + else: + self.logger.debug("Steam processes still running after pkill fallback.") + if status_callback: + try: + status_callback("Shutdown Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + except Exception as e: + self.logger.error(f"Error during pkill fallback: {e}") + if status_callback: + try: + status_callback("Shutdown Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + if not steam_closed_successfully: + self.logger.error("Failed to terminate Steam processes via all methods.") + if status_callback: + try: + status_callback("Shutdown Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + self.logger.info("Steam confirmed closed.") + + start_methods = [ + {"name": "Popen", "cmd": ["steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}}, + {"name": "setsid", "cmd": ["setsid", "steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL}}, + {"name": "nohup", "cmd": ["nohup", "steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "preexec_fn": os.setpgrp}} + ] + steam_start_initiated = False + + for i, method in enumerate(start_methods): + method_name = method["name"] + status_msg = f"Starting Steam ({method_name})" + if status_callback: + try: + status_callback(status_msg) + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + self.logger.info(f"Attempting to start Steam using method: {method_name}") + try: + process = safe_subprocess_popen(method["cmd"], **method["kwargs"]) + if process is not None: + self.logger.info(f"Initiated Steam start with {method_name}.") + time.sleep(5) + check_result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) + if check_result.returncode == 0: + self.logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.") + steam_start_initiated = True + break + else: + self.logger.warning(f"Steam process not detected after initiating with {method_name}. Trying next method.") + else: + self.logger.warning(f"Failed to start process with {method_name}. Trying next method.") + except FileNotFoundError: + self.logger.error(f"Command not found for method {method_name} (e.g., setsid, nohup). Trying next method.") + except Exception as e: + self.logger.error(f"Error starting Steam with {method_name}: {e}. Trying next method.") + + if not steam_start_initiated: + self.logger.error("All methods to initiate Steam start failed.") + if status_callback: + try: + status_callback("Start Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + status_msg = "Waiting for Steam to fully start" + if status_callback: + try: + status_callback(status_msg) + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + self.logger.info("Waiting up to 2 minutes for Steam to fully initialize...") + max_startup_wait = 120 + elapsed_wait = 0 + initial_wait_done = False + + while elapsed_wait < max_startup_wait: + try: + result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) + if result.returncode == 0: + if not initial_wait_done: + self.logger.info("Steam process detected. Waiting additional time for full initialization...") + initial_wait_done = True + time.sleep(5) + elapsed_wait += 5 + if initial_wait_done and elapsed_wait >= 15: + final_check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) + if final_check.returncode == 0: + if status_callback: + try: + status_callback("Steam Started") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + self.logger.info("Steam confirmed running after wait.") + return True + else: + self.logger.warning("Steam process disappeared during final initialization wait.") + break + else: + self.logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)") + time.sleep(5) + elapsed_wait += 5 + except Exception as e: + self.logger.warning(f"Error during Steam startup wait: {e}") + time.sleep(5) + elapsed_wait += 5 + + self.logger.error("Steam failed to start/initialize within the allowed time.") + if status_callback: + try: + status_callback("Start Timed Out") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + def _verify_and_restore_shortcuts(self): + """ + Verify shortcuts.vdf exists after Steam restart and restore it if needed. + """ + shortcuts_file = getattr(self, '_shortcuts_file', None) + if not shortcuts_file: + self.logger.warning("No shortcuts file to verify") + return + + if not os.path.exists(shortcuts_file) or os.path.getsize(shortcuts_file) == 0: + self.logger.warning(f"shortcuts.vdf missing or empty after restart: {shortcuts_file}") + + # Try to restore from pre-restart backup + safe_backup = getattr(self, '_safe_shortcuts_backup', None) + if safe_backup and os.path.exists(safe_backup): + try: + import shutil + shutil.copy2(safe_backup, shortcuts_file) + self.logger.info(f"Restored shortcuts.vdf from pre-restart backup") + print("Restored shortcuts file after Steam restart") + return + except Exception as e: + self.logger.error(f"Failed to restore from pre-restart backup: {e}") + + # Try regular backup if pre-restart failed + backup = getattr(self, '_last_shortcuts_backup', None) + if backup and os.path.exists(backup): + try: + import shutil + shutil.copy2(backup, shortcuts_file) + self.logger.info(f"Restored shortcuts.vdf from regular backup") + print("Restored shortcuts file after Steam restart") + except Exception as e: + self.logger.error(f"Failed to restore from backup: {e}") + print("Failed to restore shortcuts file. You may need to recreate your shortcut.") + else: + self.logger.info(f"shortcuts.vdf verified intact after restart") + + def create_shortcut_workflow(self): + """ + Run the complete shortcut creation workflow + Returns: + bool: True if successful, False otherwise + """ + # Create the shortcut + shortcut_data = self.create_shortcut() + if not shortcut_data: + return False + + # Note: Steam restart is now handled within create_shortcut() + return True + + def create_new_modlist_shortcut(self): + """ + Create a new modlist shortcut in Steam + This follows the procedure described in the documentation + + Returns: + bool: True if successful, False otherwise + """ + print("\nShortcut Creation") + print("───────────────────────────────────────────────────────────────────") + print("This will create a new Steam shortcut for your modlist.") + print("You will need to provide the path to ModOrganizer.exe and a name for your modlist.") + + # Create the shortcut + modlist_data = self.create_shortcut() + if not modlist_data: + print("Shortcut creation cancelled or failed.") + return False + + # Present the user with a summary of what was created + print("\nShortcut created successfully!") + print("───────────────────────────────────────────────────────────────────") + print(f"Modlist Name: {modlist_data['name']}") + print(f"Directory: {modlist_data['directory']}") + print(f"Steam AppID: {modlist_data['app_id']}") + print("───────────────────────────────────────────────────────────────────") + + return True + + def get_selected_modlist(self): + """ + Get the selected modlist string in the format expected by ModlistHandler.configure_modlist + + Returns: + str: Selected modlist string in the format "Non-Steam shortcut: Name (AppID)" + or None if no modlist was selected + """ + return getattr(self, 'selected_modlist', None) + + def get_appid_for_shortcut(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]: + """ + Find the current AppID for a given shortcut name and (optionally) executable path using protontricks. + + Args: + shortcut_name (str): The name of the Steam shortcut. + exe_path (Optional[str]): The path to the executable (for robust matching after Steam restart). + + Returns: + Optional[str]: The found AppID string, or None if not found or error occurs. + """ + 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) + if not pt_handler.detect_protontricks(): + self.logger.error("Protontricks not detected") + return None + result = pt_handler.run_protontricks("-l") + if not result or result.returncode != 0: + self.logger.error(f"Protontricks failed to list applications: {result.stderr if result else 'No result'}") + return None + # Build a list of all shortcuts + found_shortcuts = [] + for line in result.stdout.splitlines(): + m = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line) + if m: + pt_name = m.group(1).strip() + pt_appid = m.group(2) + found_shortcuts.append((pt_name, pt_appid)) + # For robust matching, also parse shortcuts.vdf for exe paths + vdf_shortcuts = [] + shortcuts_vdf_path = self.shortcuts_path + if shortcuts_vdf_path and os.path.isfile(shortcuts_vdf_path): + try: + 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() + vdf_shortcuts.append((app_name, exe, idx)) + except Exception as e: + self.logger.error(f"Error parsing shortcuts.vdf for exe path matching: {e}") + # Try to match by both name and exe_path if exe_path is provided + if exe_path: + exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower() + shortcut_name_clean = shortcut_name.strip().lower() + for pt_name, pt_appid in found_shortcuts: + for vdf_name, vdf_exe, vdf_idx in vdf_shortcuts: + if vdf_name.strip().lower() == pt_name.strip().lower() == shortcut_name_clean: + vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower() + if vdf_exe_norm == exe_path_norm: + self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' with exe '{vdf_exe}' (input: '{exe_path}')") + return pt_appid + self.logger.error(f"No shortcut found matching both name '{shortcut_name}' and exe_path '{exe_path}'.") + return None + # Fallback: match by name only (for existing modlist config) + shortcut_name_clean = shortcut_name.strip().lower() + for pt_name, pt_appid in found_shortcuts: + if pt_name.strip().lower() == shortcut_name_clean: + self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' (input: '{shortcut_name}')") + return pt_appid + self.logger.error(f"Could not find an AppID for shortcut named '{shortcut_name}' via protontricks.") + return None + except Exception as e: + self.logger.error(f"Error getting AppID for shortcut '{shortcut_name}': {e}") + self.logger.exception("Traceback:") + return None + + # --- Discovery Methods Moved from ModlistHandler --- + + def _scan_shortcuts_for_executable(self, executable_name: str) -> List[Dict[str, str]]: + """ + Scans the user's shortcuts.vdf file for entries pointing to a specific executable. + + Args: + executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe") + + Returns: + List[Dict[str, str]]: A list of dictionaries, each containing {'name': AppName, 'path': StartDir} + for shortcuts matching the executable name. + """ + self.logger.info(f"Scanning {self.shortcuts_path} for executable '{executable_name}'...") + 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}") + return [] + + # Directly process the single shortcuts.vdf file found during init + shortcuts_file = self.shortcuts_path + try: + # Use VDFHandler static method for loading + shortcuts_data = VDFHandler.load(shortcuts_file, binary=True) + if shortcuts_data is None or 'shortcuts' not in shortcuts_data: + self.logger.warning(f"Could not load or parse data from {shortcuts_file}") + return [] # Cannot proceed if file is empty/invalid + + for shortcut_id, shortcut in shortcuts_data['shortcuts'].items(): + # Ensure shortcut entry is a dictionary + if not isinstance(shortcut, dict): + 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('"') + + # 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: + # Perform a basic check for MO2 ini if looking for MO2 + is_valid = True + if executable_name == "ModOrganizer.exe": + # Use Path object for exists check + if not (Path(start_dir) / 'ModOrganizer.ini').exists(): + self.logger.warning(f"Found MO2 shortcut '{app_name}' but ModOrganizer.ini missing in '{start_dir}'") + is_valid = False + + if is_valid: + matched_shortcuts.append({'name': app_name, 'path': start_dir}) + self.logger.debug(f"Found '{executable_name}' shortcut in VDF: {app_name} -> {start_dir}") + + except Exception as e: + self.logger.error(f"Error processing {shortcuts_file}: {e}") + # Return empty list on error processing the file + return [] + + self.logger.info(f"Scan complete. Found {len(matched_shortcuts)} potential '{executable_name}' shortcuts in VDF file.") + return matched_shortcuts + + def discover_executable_shortcuts(self, executable_name: str) -> List[str]: + """ + Discovers non-Steam shortcuts for a specific executable, cross-referencing + VDF files with the Protontricks runtime list. + + Args: + executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe") + + Returns: + List[str]: A list of strings in the format "Non-Steam shortcut: Name (AppID)" + for valid, matched shortcuts. + """ + self.logger.info(f"Discovering configured shortcuts for '{executable_name}'...") + + # 1. Get potential shortcuts from VDF files + vdf_shortcuts = self._scan_shortcuts_for_executable(executable_name) + if not vdf_shortcuts: + self.logger.warning(f"No '{executable_name}' shortcuts found in VDF files.") + # Don't exit yet, maybe protontricks lists something VDF missed? + + # 2. Get the list of shortcuts known to Protontricks + # Use the handler initialized in __init__ + pt_result = self.protontricks_handler.run_protontricks("-l") + if not pt_result or pt_result.returncode != 0: + self.logger.error(f"Protontricks failed to list applications: {pt_result.stderr if pt_result else 'No result'}") + return [] # Cannot proceed without protontricks list + + # Extract names and AppIDs from protontricks output + pt_shortcuts = {} + for line in pt_result.stdout.splitlines(): + line = line.strip() + if "Non-Steam shortcut:" in line: + match = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line) + if match: + pt_name = match.group(1).strip() + pt_appid = match.group(2) + pt_shortcuts[pt_name] = pt_appid # Store AppName -> AppID + + if not pt_shortcuts: + self.logger.warning("No Non-Steam shortcuts listed by Protontricks.") + return [] + + # 3. Cross-reference VDF shortcuts with Protontricks list + final_list = [] + vdf_names_found = {item['name'] for item in vdf_shortcuts} + # pt_names_found = set(pt_shortcuts.keys()) # Not needed directly + + for vdf_shortcut in vdf_shortcuts: + vdf_name = vdf_shortcut['name'] + if vdf_name in pt_shortcuts: + # Match found! + runtime_appid = pt_shortcuts[vdf_name] + modlist_string = f"Non-Steam shortcut: {vdf_name} ({runtime_appid})" + final_list.append(modlist_string) + self.logger.debug(f"Validated shortcut: {modlist_string}") + + if not final_list: + self.logger.warning(f"No shortcuts for '{executable_name}' found in VDF matched the Protontricks list.") + + self.logger.info(f"Discovery complete. Found {len(final_list)} validated shortcuts for '{executable_name}'.") + return final_list + + def find_shortcuts_by_exe(self, executable_name: str) -> List[Dict]: + """Finds shortcuts in shortcuts.vdf that point to a specific executable. + + Args: + executable_name: The name of the executable (e.g., "ModOrganizer.exe") + to search for within the 'Exe' path. + + Returns: + A list of dictionaries, each representing a matching shortcut + and containing keys like 'AppName', 'Exe', 'StartDir'. + Returns an empty list if no matches are found or an error occurs. + """ + self.logger.info(f"Scanning {self.shortcuts_path} for executable: {executable_name}") + matching_shortcuts = [] + + # --- 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}") + return [] + + vdf_path = self.shortcuts_path + try: + self.logger.debug(f"Parsing shortcuts file: {vdf_path}") + shortcuts_data = VDFHandler.load(vdf_path, binary=True) + + if not shortcuts_data or 'shortcuts' not in shortcuts_data: + self.logger.warning(f"Shortcuts data is empty or invalid in {vdf_path}") + return [] # Return empty if no data + + # The shortcuts are under a top-level 'shortcuts' key + shortcuts_dict = shortcuts_data.get('shortcuts', {}) + + for index, shortcut_details in shortcuts_dict.items(): + # Ensure shortcut_details is a dictionary + if not isinstance(shortcut_details, dict): + 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') + + # 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 + 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' + } + matching_shortcuts.append(match) + else: + self.logger.debug(f"Skipping shortcut '{app_name}': Exe path '{exe_path}' does not contain '{executable_name}'") + + except Exception as e: + self.logger.error(f"Error processing shortcuts file {vdf_path}: {e}", exc_info=True) + # Return empty list on error + return [] + + if not matching_shortcuts: + # Changed log level to warning as this is an expected outcome sometimes + self.logger.warning(f"No shortcuts found pointing to '{executable_name}' in {vdf_path}.") + + return matching_shortcuts + + def update_shortcut_launch_options(self, app_name, exe_path, new_launch_options): + """ + Updates the LaunchOptions for a specific existing shortcut in shortcuts.vdf by matching AppName and Exe. + + Args: + app_name (str): The AppName of the shortcut to update (from config summary). + exe_path (str): The Exe path of the shortcut to update (from config summary, including quotes if present in VDF). + new_launch_options (str): The new string to set for LaunchOptions. + + Returns: + bool: True if the update was successful, False otherwise. + """ + self.logger.info(f"Attempting to update launch options for shortcut with AppName '{app_name}' and Exe '{exe_path}' (no AppID matching)...") + + # Find the user's shortcuts.vdf + shortcuts_file = self.path_handler._find_shortcuts_vdf() + if not shortcuts_file: + self.logger.error("Could not find shortcuts.vdf to update.") + return False + + data = {'shortcuts': {}} + # Load existing shortcuts safely (binary read) + try: + if os.path.exists(shortcuts_file): + with open(shortcuts_file, 'rb') as f: + file_data = f.read() + if file_data: + data = vdf.binary_loads(file_data) + if 'shortcuts' not in data: + data['shortcuts'] = {} + else: + self.logger.error(f"shortcuts.vdf does not exist at {shortcuts_file}. Cannot update.") + return False + except Exception as e: + self.logger.error(f"Error reading or parsing shortcuts.vdf: {e}") + return False + + # Normalize paths for robust matching (handle quotes, absolute paths, case) + def _normalize_path(p: str) -> str: + try: + # Strip surrounding quotes, expanduser, abspath, collapse duplicate slashes + p_clean = os.path.abspath(os.path.expanduser(p.strip().strip('"'))) + return os.path.normpath(p_clean).lower() + except Exception: + return p.strip().strip('"').lower() + + exe_norm = _normalize_path(exe_path) + target_index = None + for index, shortcut_data in data.get('shortcuts', {}).items(): + shortcut_name = (shortcut_data.get('AppName', '') or '').strip() + shortcut_exe_raw = shortcut_data.get('Exe', '') + shortcut_exe_norm = _normalize_path(shortcut_exe_raw) + if shortcut_name == app_name and shortcut_exe_norm == exe_norm: + target_index = index + break + + if target_index is None: + self.logger.error(f"Could not find shortcut with AppName '{app_name}' and Exe '{exe_path}' in shortcuts.vdf.") + # Log all AppNames and Exe values for debugging + for index, shortcut_data in data.get('shortcuts', {}).items(): + shortcut_name = shortcut_data.get('AppName', '') + shortcut_exe = shortcut_data.get('Exe', '') + self.logger.error(f"Found shortcut: AppName='{shortcut_name}', Exe='{shortcut_exe}' -> norm='{_normalize_path(shortcut_exe)}'") + return False + + # Update the LaunchOptions for the found shortcut + if target_index in data['shortcuts']: + self.logger.info(f"Found shortcut at index {target_index}. Updating LaunchOptions...") + data['shortcuts'][target_index]['LaunchOptions'] = new_launch_options + else: + self.logger.error(f"Target index {target_index} not found in shortcuts dictionary after identification.") + return False + + # Write the updated data back safely (binary write to temp file first) + try: + temp_file = f"{shortcuts_file}.temp" + with open(temp_file, 'wb') as f: + vdf_data = vdf.binary_dumps(data) + f.write(vdf_data) + + # Create backup before overwriting + backup_dir = os.path.join(os.path.dirname(shortcuts_file), "backups") + os.makedirs(backup_dir, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") + backup_path = os.path.join(backup_dir, f"shortcuts_update_{app_name}_{timestamp}.bak") + if os.path.exists(shortcuts_file): + shutil.copy2(shortcuts_file, backup_path) + self.logger.info(f"Created backup before update at {backup_path}") + + shutil.move(temp_file, shortcuts_file) + self.logger.info(f"Successfully updated LaunchOptions for shortcut '{app_name}' in {shortcuts_file}.") + return True + except Exception as e: + self.logger.error(f"Error writing updated shortcuts.vdf: {e}") + # Attempt to restore backup if update failed + if 'backup_path' in locals() and os.path.exists(backup_path): + try: + shutil.copy2(backup_path, shortcuts_file) + self.logger.warning(f"Restored shortcuts.vdf from backup {backup_path} after update failure.") + except Exception as restore_e: + self.logger.critical(f"CRITICAL: Failed to write updated shortcuts.vdf AND failed to restore backup! Error: {restore_e}") + return False + + @staticmethod + def get_steam_shortcut_icon_path(exe_path, steamicons_dir=None, logger=None): + """ + Select the best icon for a Steam shortcut given an executable path and optional SteamIcons directory. + Prefers grid-tall.png, else any .png, else returns ''. + Logs selection steps if logger is provided. + """ + exe_dir = os.path.dirname(exe_path) + if not steamicons_dir: + steamicons_dir = os.path.join(exe_dir, "SteamIcons") + if logger: + logger.debug(f"[DEBUG] Looking for Steam shortcut icon in: {steamicons_dir}") + if os.path.isdir(steamicons_dir): + preferred_icon = os.path.join(steamicons_dir, "grid-tall.png") + if os.path.isfile(preferred_icon): + if logger: + logger.debug(f"[DEBUG] Using grid-tall.png as shortcut icon: {preferred_icon}") + return preferred_icon + pngs = [f for f in os.listdir(steamicons_dir) if f.lower().endswith('.png')] + if pngs: + icon_path = os.path.join(steamicons_dir, pngs[0]) + if logger: + logger.debug(f"[DEBUG] Using fallback icon for shortcut: {icon_path}") + return icon_path + if logger: + logger.debug("[DEBUG] No .png icon found in SteamIcons directory.") + return "" + if logger: + logger.debug("[DEBUG] No SteamIcons directory found; shortcut will have no icon.") + return "" + + def write_nxmhandler_ini(self, modlist_dir, mo2_exe_path): + """ + Create nxmhandler.ini in the modlist directory to suppress the NXM Handling popup on first MO2 launch. + If the file already exists, do nothing. + The executable path will be written as Z:\\, matching MO2's format. + """ + ini_path = os.path.join(modlist_dir, "nxmhandler.ini") + if os.path.exists(ini_path): + self.logger.info(f"nxmhandler.ini already exists at {ini_path}") + return + # Build the correct executable path: Z:\\ + abs_path = os.path.abspath(mo2_exe_path) + z_path = f"Z:{abs_path}" + win_path = z_path.replace('/', '\\') # single backslash first + win_path = win_path.replace('\\', '\\\\') # double all backslashes + content = ( + "[handlers]\n" + "size=1\n" + "1\\games=\"skyrimse,skyrim\"\n" + f"1\\executable={win_path}\n" + "1\\arguments=\n" + ) + with open(ini_path, "w") as f: + f.write(content) + self.logger.info(f"[SUCCESS] nxmhandler.ini written to {ini_path}") \ No newline at end of file diff --git a/jackify/backend/handlers/status_utils.py b/jackify/backend/handlers/status_utils.py new file mode 100644 index 0000000..8ebbaf5 --- /dev/null +++ b/jackify/backend/handlers/status_utils.py @@ -0,0 +1,10 @@ +from .ui_colors import COLOR_INFO, COLOR_RESET + +def show_status(message: str): + """Show a single-line status message, overwriting the current line.""" + status_width = 80 # Pad to clear previous text + print(f"\r\033[K{COLOR_INFO}{message:<{status_width}}{COLOR_RESET}", end="", flush=True) + +def clear_status(): + """Clear the current status line.""" + print("\r\033[K", end="", flush=True) \ No newline at end of file diff --git a/jackify/backend/handlers/subprocess_utils.py b/jackify/backend/handlers/subprocess_utils.py new file mode 100644 index 0000000..1253c60 --- /dev/null +++ b/jackify/backend/handlers/subprocess_utils.py @@ -0,0 +1,137 @@ +import os +import signal +import subprocess +import time +import resource + +def get_clean_subprocess_env(extra_env=None): + """ + Returns a copy of os.environ with PyInstaller and other problematic variables removed. + Optionally merges in extra_env dict. + """ + env = os.environ.copy() + # Remove PyInstaller-specific variables + for k in list(env): + if k.startswith('_MEIPASS'): + del env[k] + # Optionally restore LD_LIBRARY_PATH to system default if needed + # (You can add more logic here if you know your system's default) + if extra_env: + env.update(extra_env) + return env + +def increase_file_descriptor_limit(target_limit=1048576): + """ + Temporarily increase the file descriptor limit for the current process. + + Args: + target_limit (int): Desired file descriptor limit (default: 1048576) + + Returns: + tuple: (success: bool, old_limit: int, new_limit: int, message: str) + """ + try: + # Get current soft and hard limits + soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE) + + # Don't decrease the limit if it's already higher + if soft_limit >= target_limit: + return True, soft_limit, soft_limit, f"Current limit ({soft_limit}) already sufficient" + + # Set new limit (can't exceed hard limit) + new_limit = min(target_limit, hard_limit) + resource.setrlimit(resource.RLIMIT_NOFILE, (new_limit, hard_limit)) + + return True, soft_limit, new_limit, f"Increased file descriptor limit from {soft_limit} to {new_limit}" + + except (OSError, ValueError) as e: + # Get current limit for reporting + try: + soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE) + except: + soft_limit = "unknown" + + return False, soft_limit, soft_limit, f"Failed to increase file descriptor limit: {e}" + +class ProcessManager: + """ + Shared process manager for robust subprocess launching, tracking, and cancellation. + """ + def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0): + self.cmd = cmd + self.env = env + self.cwd = cwd + self.text = text + self.bufsize = bufsize + self.proc = None + self.process_group_pid = None + self._start_process() + + def _start_process(self): + self.proc = subprocess.Popen( + self.cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=self.env, + cwd=self.cwd, + text=self.text, + bufsize=self.bufsize, + start_new_session=True + ) + self.process_group_pid = os.getpgid(self.proc.pid) + + def cancel(self, timeout_terminate=2, timeout_kill=1, max_cleanup_attempts=3): + """ + Attempt to robustly terminate the process and its children. + """ + cleanup_attempts = 0 + if self.proc: + try: + self.proc.terminate() + try: + self.proc.wait(timeout=timeout_terminate) + return + except subprocess.TimeoutExpired: + pass + except Exception: + pass + try: + self.proc.kill() + try: + self.proc.wait(timeout=timeout_kill) + return + except subprocess.TimeoutExpired: + pass + except Exception: + pass + # Kill process group if possible + if self.process_group_pid: + try: + os.killpg(self.process_group_pid, signal.SIGKILL) + except Exception: + pass + # Last resort: pkill by command name + while cleanup_attempts < max_cleanup_attempts: + try: + subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True) + except Exception: + pass + cleanup_attempts += 1 + + def is_running(self): + return self.proc and self.proc.poll() is None + + def wait(self, timeout=None): + if self.proc: + return self.proc.wait(timeout=timeout) + return None + + def read_stdout_line(self): + if self.proc and self.proc.stdout: + return self.proc.stdout.readline() + return None + + def read_stdout_char(self): + if self.proc and self.proc.stdout: + return self.proc.stdout.read(1) + return None \ No newline at end of file diff --git a/jackify/backend/handlers/ui_colors.py b/jackify/backend/handlers/ui_colors.py new file mode 100644 index 0000000..204df1f --- /dev/null +++ b/jackify/backend/handlers/ui_colors.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +UI Color Constants +""" + +COLOR_PROMPT = '\033[93m' # Yellow +COLOR_SELECTION = '\033[96m' # Cyan +COLOR_RESET = '\033[0m' +COLOR_INFO = '\033[94m' # Blue +COLOR_ERROR = '\033[91m' # Red +COLOR_SUCCESS = '\033[92m' # Green +COLOR_WARNING = '\033[93m' # Yellow (reusing prompt color) +COLOR_DISABLED = '\033[90m' # Grey + +COLOR_ACTION = '\033[97m' # Bright White for action/descriptions +COLOR_INPUT = '\033[97m' # Bright White for input prompts \ No newline at end of file diff --git a/jackify/backend/handlers/ui_handler.py b/jackify/backend/handlers/ui_handler.py new file mode 100644 index 0000000..21bb042 --- /dev/null +++ b/jackify/backend/handlers/ui_handler.py @@ -0,0 +1,180 @@ +""" +UIHandler module for managing user interface operations. +This module handles menus, prompts, and user interaction. +""" + +import os +import logging +from typing import Optional, List, Dict, Tuple, Callable, Any +from pathlib import Path + +class UIHandler: + def __init__(self): + self.logger = logging.getLogger(__name__) + + def show_menu(self, title: str, options: List[Dict[str, Any]]) -> Optional[str]: + """Display a menu and get user selection.""" + try: + print(f"\n{title}") + print("=" * len(title)) + + for i, option in enumerate(options, 1): + print(f"{i}. {option['label']}") + + while True: + try: + choice = input("\nEnter your choice (or 'q' to quit): ") + if choice.lower() == 'q': + return None + + choice = int(choice) + if 1 <= choice <= len(options): + return options[choice - 1]['value'] + else: + print("Invalid choice. Please try again.") + except ValueError: + print("Please enter a number.") + except Exception as e: + self.logger.error(f"Failed to show menu: {e}") + return None + + def show_progress(self, message: str, total: int = 100) -> None: + """Display a progress indicator.""" + try: + print(f"\n{message}") + print("[" + " " * 50 + "] 0%", end="\r") + except Exception as e: + self.logger.error(f"Failed to show progress: {e}") + + def update_progress(self, current: int, message: Optional[str] = None) -> None: + """Update the progress indicator.""" + try: + if message: + print(f"\n{message}") + progress = int(current / 2) + print("[" + "=" * progress + " " * (50 - progress) + f"] {current}%", end="\r") + except Exception as e: + self.logger.error(f"Failed to update progress: {e}") + + def show_error(self, message: str, details: Optional[str] = None) -> None: + """Display an error message.""" + try: + print(f"\nError: {message}") + if details: + print(f"Details: {details}") + except Exception as e: + self.logger.error(f"Failed to show error: {e}") + + def show_success(self, message: str, details: Optional[str] = None) -> None: + """Display a success message.""" + try: + print(f"\n✓ Success: {message}") + if details: + print(f"Details: {details}") + except Exception as e: + self.logger.error(f"Failed to show success: {e}") + + def show_warning(self, message: str, details: Optional[str] = None) -> None: + """Display a warning message.""" + try: + print(f"\nWarning: {message}") + if details: + print(f"Details: {details}") + except Exception as e: + self.logger.error(f"Failed to show warning: {e}") + + def get_input(self, prompt: str, default: Optional[str] = None) -> str: + """Get user input with optional default value.""" + try: + if default: + user_input = input(f"{prompt} [{default}]: ") + return user_input if user_input else default + return input(f"{prompt}: ") + except Exception as e: + self.logger.error(f"Failed to get input: {e}") + return "" + + def get_confirmation(self, message: str, default: bool = True) -> bool: + """Get user confirmation for an action.""" + try: + default_str = "Y/n" if default else "y/N" + while True: + response = input(f"{message} [{default_str}]: ").lower() + if not response: + return default + if response in ['y', 'yes']: + return True + if response in ['n', 'no']: + return False + print("Please enter 'y' or 'n'.") + except Exception as e: + self.logger.error(f"Failed to get confirmation: {e}") + return default + + def show_list(self, title: str, items: List[str], selectable: bool = True) -> Optional[str]: + """Display a list of items, optionally selectable.""" + try: + print(f"\n{title}") + print("=" * len(title)) + + for i, item in enumerate(items, 1): + print(f"{i}. {item}") + + if selectable: + while True: + try: + choice = input("\nEnter your choice (or 'q' to quit): ") + if choice.lower() == 'q': + return None + + choice = int(choice) + if 1 <= choice <= len(items): + return items[choice - 1] + else: + print("Invalid choice. Please try again.") + except ValueError: + print("Please enter a number.") + return None + except Exception as e: + self.logger.error(f"Failed to show list: {e}") + return None + + def show_table(self, title: str, headers: List[str], rows: List[List[str]]) -> None: + """Display data in a table format.""" + try: + print(f"\n{title}") + print("=" * len(title)) + + # Calculate column widths + widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + widths[i] = max(widths[i], len(str(cell))) + + # Print headers + header_str = " | ".join(f"{h:<{w}}" for h, w in zip(headers, widths)) + print(header_str) + print("-" * len(header_str)) + + # Print rows + for row in rows: + print(" | ".join(f"{str(cell):<{w}}" for cell, w in zip(row, widths))) + except Exception as e: + self.logger.error(f"Failed to show table: {e}") + + def show_help(self, topic: str) -> None: + """Display help information for a topic.""" + try: + # This would typically load help content from a file or database + print(f"\nHelp: {topic}") + print("=" * (len(topic) + 6)) + print("Help content would be displayed here.") + except Exception as e: + self.logger.error(f"Failed to show help: {e}") + + def clear_screen(self) -> None: + """Clear the terminal screen.""" + try: + os.system('clear' if os.name == 'posix' else 'cls') + except Exception as e: + self.logger.error(f"Failed to clear screen: {e}") \ No newline at end of file diff --git a/jackify/backend/handlers/validation_handler.py b/jackify/backend/handlers/validation_handler.py new file mode 100644 index 0000000..7c9ab3c --- /dev/null +++ b/jackify/backend/handlers/validation_handler.py @@ -0,0 +1,318 @@ +""" +ValidationHandler module for managing validation operations. +This module handles input validation, path validation, and configuration validation. +""" + +import os +import logging +import re +import shutil +import vdf +from pathlib import Path +from typing import Optional, Dict, List, Tuple, Any + +class ValidationHandler: + def __init__(self): + self.logger = logging.getLogger(__name__) + + def validate_path(self, path: Path, must_exist: bool = True) -> Tuple[bool, str]: + """Validate a path.""" + try: + if not isinstance(path, Path): + return False, "Path must be a Path object" + + if must_exist and not path.exists(): + return False, f"Path does not exist: {path}" + + if not os.access(path, os.R_OK | os.W_OK): + return False, f"Path is not accessible: {path}" + + return True, "Path is valid" + except Exception as e: + self.logger.error(f"Failed to validate path {path}: {e}") + return False, str(e) + + def validate_input(self, value: Any, rules: Dict) -> Tuple[bool, str]: + """Validate user input against rules.""" + try: + # Check required + if rules.get('required', False) and not value: + return False, "Value is required" + + # Check type + if 'type' in rules and not isinstance(value, rules['type']): + return False, f"Value must be of type {rules['type'].__name__}" + + # Check min/max length for strings + if isinstance(value, str): + if 'min_length' in rules and len(value) < rules['min_length']: + return False, f"Value must be at least {rules['min_length']} characters" + if 'max_length' in rules and len(value) > rules['max_length']: + return False, f"Value must be at most {rules['max_length']} characters" + + # Check min/max value for numbers + if isinstance(value, (int, float)): + if 'min_value' in rules and value < rules['min_value']: + return False, f"Value must be at least {rules['min_value']}" + if 'max_value' in rules and value > rules['max_value']: + return False, f"Value must be at most {rules['max_value']}" + + # Check pattern for strings + if isinstance(value, str) and 'pattern' in rules: + if not re.match(rules['pattern'], value): + return False, f"Value must match pattern: {rules['pattern']}" + + # Check custom validation function + if 'validate' in rules and callable(rules['validate']): + result = rules['validate'](value) + if isinstance(result, tuple): + return result + elif not result: + return False, "Custom validation failed" + + return True, "Input is valid" + except Exception as e: + self.logger.error(f"Failed to validate input: {e}") + return False, str(e) + + def validate_config(self, config: Dict, schema: Dict) -> Tuple[bool, List[str]]: + """Validate configuration against a schema.""" + try: + errors = [] + + # Check required fields + for field, rules in schema.items(): + if rules.get('required', False) and field not in config: + errors.append(f"Missing required field: {field}") + + # Check field types and values + for field, value in config.items(): + if field not in schema: + errors.append(f"Unknown field: {field}") + continue + + rules = schema[field] + if 'type' in rules and not isinstance(value, rules['type']): + errors.append(f"Invalid type for {field}: expected {rules['type'].__name__}") + + if isinstance(value, str): + if 'min_length' in rules and len(value) < rules['min_length']: + errors.append(f"{field} must be at least {rules['min_length']} characters") + if 'max_length' in rules and len(value) > rules['max_length']: + errors.append(f"{field} must be at most {rules['max_length']} characters") + if 'pattern' in rules and not re.match(rules['pattern'], value): + errors.append(f"{field} must match pattern: {rules['pattern']}") + + if isinstance(value, (int, float)): + if 'min_value' in rules and value < rules['min_value']: + errors.append(f"{field} must be at least {rules['min_value']}") + if 'max_value' in rules and value > rules['max_value']: + errors.append(f"{field} must be at most {rules['max_value']}") + + if 'validate' in rules and callable(rules['validate']): + result = rules['validate'](value) + if isinstance(result, tuple): + if not result[0]: + errors.append(f"{field}: {result[1]}") + elif not result: + errors.append(f"Custom validation failed for {field}") + + return len(errors) == 0, errors + except Exception as e: + self.logger.error(f"Failed to validate config: {e}") + return False, [str(e)] + + def validate_dependencies(self, dependencies: List[str]) -> Tuple[bool, List[str]]: + """Validate system dependencies.""" + try: + missing = [] + for dep in dependencies: + if not shutil.which(dep): + missing.append(dep) + return len(missing) == 0, missing + except Exception as e: + self.logger.error(f"Failed to validate dependencies: {e}") + return False, [str(e)] + + def validate_game_installation(self, game_type: str, path: Path) -> Tuple[bool, str]: + """Validate a game installation.""" + try: + # Check if path exists + if not path.exists(): + return False, f"Game path does not exist: {path}" + + # Check if path is accessible + if not os.access(path, os.R_OK | os.W_OK): + return False, f"Game path is not accessible: {path}" + + # Check for game-specific files + if game_type == 'skyrim': + if not (path / 'SkyrimSE.exe').exists(): + return False, "SkyrimSE.exe not found" + elif game_type == 'fallout4': + if not (path / 'Fallout4.exe').exists(): + return False, "Fallout4.exe not found" + elif game_type == 'falloutnv': + if not (path / 'FalloutNV.exe').exists(): + return False, "FalloutNV.exe not found" + elif game_type == 'oblivion': + if not (path / 'Oblivion.exe').exists(): + return False, "Oblivion.exe not found" + else: + return False, f"Unknown game type: {game_type}" + + return True, "Game installation is valid" + except Exception as e: + self.logger.error(f"Failed to validate game installation: {e}") + return False, str(e) + + def validate_modlist(self, modlist_path: Path) -> Tuple[bool, List[str]]: + """Validate a modlist installation.""" + try: + errors = [] + + # Check if path exists + if not modlist_path.exists(): + errors.append(f"Modlist path does not exist: {modlist_path}") + return False, errors + + # Check if path is accessible + if not os.access(modlist_path, os.R_OK | os.W_OK): + errors.append(f"Modlist path is not accessible: {modlist_path}") + return False, errors + + # Check for ModOrganizer.ini + if not (modlist_path / 'ModOrganizer.ini').exists(): + errors.append("ModOrganizer.ini not found") + + # Check for mods directory + if not (modlist_path / 'mods').exists(): + errors.append("mods directory not found") + + # Check for profiles directory + if not (modlist_path / 'profiles').exists(): + errors.append("profiles directory not found") + + return len(errors) == 0, errors + except Exception as e: + self.logger.error(f"Failed to validate modlist: {e}") + return False, [str(e)] + + def validate_wine_prefix(self, app_id: str) -> Tuple[bool, str]: + """Validate a Wine prefix.""" + try: + # Check if prefix exists + prefix_path = Path.home() / '.steam' / 'steam' / 'steamapps' / 'compatdata' / app_id / 'pfx' + if not prefix_path.exists(): + return False, f"Wine prefix does not exist: {prefix_path}" + + # Check if prefix is accessible + if not os.access(prefix_path, os.R_OK | os.W_OK): + return False, f"Wine prefix is not accessible: {prefix_path}" + + # Check for system.reg + if not (prefix_path / 'system.reg').exists(): + return False, "system.reg not found" + + return True, "Wine prefix is valid" + except Exception as e: + self.logger.error(f"Failed to validate Wine prefix: {e}") + return False, str(e) + + 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' + 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) + + # Check if shortcut exists + for shortcut in shortcuts_data.get('shortcuts', {}).values(): + if str(shortcut.get('appid')) == app_id: + return True, "Steam shortcut is valid" + + return False, f"Steam shortcut not found: {app_id}" + except Exception as e: + self.logger.error(f"Failed to validate Steam shortcut: {e}") + return False, str(e) + + def validate_resolution(self, resolution: str) -> Tuple[bool, str]: + """Validate a resolution string.""" + try: + # Check format + if not re.match(r'^\d+x\d+$', resolution): + return False, "Resolution must be in format WIDTHxHEIGHT" + + # Parse dimensions + width, height = map(int, resolution.split('x')) + + # Check minimum dimensions + if width < 640 or height < 480: + return False, "Resolution must be at least 640x480" + + # Check maximum dimensions + if width > 7680 or height > 4320: + return False, "Resolution must be at most 7680x4320" + + return True, "Resolution is valid" + except Exception as e: + self.logger.error(f"Failed to validate resolution: {e}") + return False, str(e) + + def validate_permissions(self, path: Path, required_permissions: int) -> Tuple[bool, str]: + """Validate file or directory permissions.""" + try: + # Get current permissions + current_permissions = os.stat(path).st_mode & 0o777 + + # Check if current permissions include required permissions + if current_permissions & required_permissions != required_permissions: + return False, f"Missing required permissions: {required_permissions:o}" + + return True, "Permissions are valid" + except Exception as e: + self.logger.error(f"Failed to validate permissions: {e}") + return False, str(e) + + def is_dangerous_directory(self, path: Path) -> bool: + """Return True if the directory is a dangerous system or user root directory.""" + dangerous = [ + Path('/'), Path('/home'), Path('/root'), Path('/etc'), Path('/usr'), Path('/bin'), Path('/lib'), + Path('/opt'), Path('/var'), Path('/tmp'), Path.home() + ] + abs_path = path.resolve() + return any(abs_path == d.resolve() for d in dangerous) + + def looks_like_modlist_dir(self, path: Path) -> bool: + """Return True if the directory contains files/folders typical of a modlist install.""" + expected = [ + 'ModOrganizer.exe', 'profiles', 'mods', 'downloads', '.wabbajack', '.jackify_modlist_marker', 'ModOrganizer.ini' + ] + for item in expected: + if (path / item).exists(): + return True + return False + + def has_jackify_marker(self, path: Path) -> bool: + """Return True if the directory contains a .jackify_modlist_marker file.""" + return (path / '.jackify_modlist_marker').exists() + + def is_safe_install_directory(self, path: Path) -> (bool, str): + """Check if the directory is safe for install. Returns (True, reason) or (False, warning).""" + if self.is_dangerous_directory(path): + return False, f"The directory '{path}' is a system or user root and cannot be used for modlist installation." + if not path.exists(): + return True, "Directory does not exist and will be created." + if not any(path.iterdir()): + return True, "Directory is empty." + if self.looks_like_modlist_dir(path): + return True, "Directory looks like a valid modlist install." + return False, f"The directory '{path}' is not empty and does not look like a valid modlist install. Please choose an empty directory or a valid modlist directory." \ No newline at end of file diff --git a/jackify/backend/handlers/vdf_handler.py b/jackify/backend/handlers/vdf_handler.py new file mode 100644 index 0000000..709e369 --- /dev/null +++ b/jackify/backend/handlers/vdf_handler.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +VDFHandler module for safely handling VDF files. +This module provides wrappers around the VDF library with additional safety checks. +""" + +import os +import logging +import vdf +from pathlib import Path +from typing import Dict, Any, Optional + +# Initialize logger +logger = logging.getLogger(__name__) + +# List of protected VDF files that should never be modified +PROTECTED_VDF_FILES = [ + "libraryfolders.vdf", + "config.vdf", + "loginusers.vdf", + "registry.vdf", + "localconfig.vdf", + "remotecache.vdf", + "sharedconfig.vdf", + "appinfo.vdf", + "packageinfo.vdf", + "appmanifest_*.acf" +] + +# Critical Steam directories we should never modify +CRITICAL_STEAM_DIRS = [ + "appcache", + "controller_base", + "config", + "logs", + "package", + "public", + "resource", + "steam", + "steamapps", + "tenfoot" +] + +class VDFHandler: + """ + Safe handler for VDF operations with protection against modifying critical Steam files. + """ + + @staticmethod + def is_protected_file(file_path: str) -> bool: + """ + Check if a file is protected from modification. + + Args: + file_path: Path to the VDF file + + Returns: + bool: True if the file is protected, False otherwise + """ + file_name = os.path.basename(file_path) + + # Special exception for shortcuts.vdf - we always want to be able to modify this + if file_name == "shortcuts.vdf": + return False + + # Check exact filename match + if file_name in PROTECTED_VDF_FILES: + return True + + # Check pattern match (for appmanifest_*.acf) + for pattern in PROTECTED_VDF_FILES: + if '*' in pattern and pattern.replace('*', '') in file_name: + return True + + # Check if file is in critical Steam directories + for dir_name in CRITICAL_STEAM_DIRS: + if f"/{dir_name}/" in file_path or f"\\{dir_name}\\" in file_path: + return True + + return False + + @staticmethod + def load(file_path: str, binary: bool = True) -> Dict[str, Any]: + """ + Safely load a VDF file. + + Args: + file_path: Path to the VDF file + binary: Whether the file is binary VDF format + + Returns: + Dict: Parsed VDF data + + Raises: + ValueError: If the file is protected and being loaded for writing + """ + # Always create a backup before reading critical files + if VDFHandler.is_protected_file(file_path): + backup_path = f"{file_path}.bak" + if not os.path.exists(backup_path): + try: + import shutil + shutil.copy2(file_path, backup_path) + logger.debug(f"Created backup of {os.path.basename(file_path)} at {backup_path}") + except Exception as e: + logger.error(f"Failed to create backup of {file_path}: {e}") + + # Load the VDF file + try: + if binary: + # Use ValvePython/vdf library for binary files + logger.debug(f"Attempting to load binary VDF with ValvePython/vdf: {file_path}") + if not os.path.exists(file_path): + logger.error(f"Binary VDF file not found: {file_path}") + return None + with open(file_path, 'rb') as f_vdf: + return vdf.binary_loads(f_vdf.read()) + else: + # Handle text VDF files (e.g., config.vdf) + logger.debug(f"Attempting to load text VDF with ValvePython/vdf: {file_path}") + if not os.path.exists(file_path): + logger.error(f"Text VDF file not found: {file_path}") + return None + with open(file_path, 'r', encoding='utf-8') as f_text: + return vdf.load(f_text) + + except FileNotFoundError: + # This might be redundant due to os.path.exists checks, but keep for safety + logger.error(f"VDF file not found during load operation: {file_path}") + return None + except PermissionError: + logger.error(f"Permission denied when trying to read VDF file: {file_path}") + return None + except Exception as e: + # Catch any other unexpected errors (including parsing errors from vdf.binary_loads) + logger.error(f"Unexpected error loading VDF file {file_path}: {e}", exc_info=True) + return None # Return None instead of {} + + @staticmethod + def save(file_path: str, data: Dict[str, Any], binary: bool = True) -> bool: + """ + Safely save a VDF file with protection for critical files. + + Args: + file_path: Path to the VDF file + data: VDF data to save + binary: Whether to save in binary VDF format + + Returns: + bool: True if save was successful, False otherwise + + Raises: + ValueError: If attempting to modify a protected file + """ + # Normalize path for consistent checks + file_path = os.path.normpath(file_path) + + # FIRST LINE OF DEFENSE: Prevent modification of protected files + if VDFHandler.is_protected_file(file_path): + error_msg = f"CRITICAL SAFETY ERROR: Attempted to modify protected Steam file: {file_path}" + logger.error(error_msg) + raise ValueError(error_msg) + + # SECOND LINE OF DEFENSE: Only allow saving to shortcuts.vdf + file_name = os.path.basename(file_path) + if file_name != "shortcuts.vdf": + error_msg = f"CRITICAL SAFETY ERROR: Only shortcuts.vdf can be modified, attempted: {file_path}" + logger.error(error_msg) + raise ValueError(error_msg) + + # THIRD LINE OF DEFENSE: Create backup before saving + if os.path.exists(file_path): + # Create timestamped backup + timestamp = Path(file_path).stat().st_mtime + backup_path = f"{file_path}.{int(timestamp)}.bak" + + # Also create a simple .bak file if it doesn't exist + simple_backup = f"{file_path}.bak" + + try: + import shutil + # Create timestamped backup + shutil.copy2(file_path, backup_path) + logger.info(f"Created timestamped backup of {file_name} at {backup_path}") + + # Create simple backup if it doesn't exist + if not os.path.exists(simple_backup): + shutil.copy2(file_path, simple_backup) + logger.info(f"Created backup of {file_name} at {simple_backup}") + except Exception as e: + logger.error(f"Failed to create backup before modifying {file_path}: {e}") + return False + + # Save the file + try: + # Additional safety: Verify we're only saving to shortcuts.vdf again + if not file_name == "shortcuts.vdf": + raise ValueError(f"Final safety check failed: Attempted to save to non-shortcuts file: {file_path}") + + if binary: + with open(file_path, 'wb') as f: + vdf.binary_dumps(data, f) + else: + with open(file_path, 'w', encoding='utf-8') as f: + vdf.dump(data, f, pretty=True) + + logger.info(f"Successfully saved changes to {file_path}") + return True + except Exception as e: + logger.error(f"Error saving VDF file {file_path}: {e}") + return False + + @staticmethod + def update_shortcuts(shortcuts_path: str, update_function) -> bool: + """ + Safely update shortcuts.vdf using a callback function. + + Args: + shortcuts_path: Path to the shortcuts.vdf file + update_function: Callback function that takes shortcuts data and returns updated data + Signature: function(shortcuts_data) -> updated_shortcuts_data + + Returns: + bool: True if update was successful, False otherwise + """ + try: + # Check that we're only operating on shortcuts.vdf + if os.path.basename(shortcuts_path) != "shortcuts.vdf": + error_msg = f"Can only update shortcuts.vdf, not: {shortcuts_path}" + logger.error(error_msg) + raise ValueError(error_msg) + + # Load the shortcuts file + logger.info(f"Loading shortcuts from: {shortcuts_path}") + shortcuts_data = VDFHandler.load(shortcuts_path, binary=True) + + if not shortcuts_data: + logger.error(f"Failed to load shortcuts data from {shortcuts_path}") + return False + + # Apply the update function + logger.info("Applying updates to shortcuts data") + updated_data = update_function(shortcuts_data) + + if updated_data is None: + logger.error("Update function returned None") + return False + + # Save the updated data + logger.info(f"Saving updated shortcuts to: {shortcuts_path}") + return VDFHandler.save(shortcuts_path, updated_data, binary=True) + + except Exception as e: + logger.error(f"Error updating shortcuts: {e}") + return False \ No newline at end of file diff --git a/jackify/backend/handlers/wabbajack_handler.py b/jackify/backend/handlers/wabbajack_handler.py new file mode 100644 index 0000000..69a180a --- /dev/null +++ b/jackify/backend/handlers/wabbajack_handler.py @@ -0,0 +1,1652 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Install Wabbajack Handler Module +Handles the installation and updating of Wabbajack +""" + +import os +import logging +from pathlib import Path +from typing import Optional, Tuple +import shutil +import subprocess +import pwd +import requests +from tqdm import tqdm +import tempfile +import time +import re + +# Attempt to import readline for tab completion +READLINE_AVAILABLE = False +try: + import readline + READLINE_AVAILABLE = True + # Check if running in a non-interactive environment (e.g., some CI) + if 'libedit' in readline.__doc__: + # libedit doesn't support set_completion_display_matches_hook + pass + # Add other potential checks if needed +except ImportError: + # readline not available on Windows or potentially minimal environments + pass +except Exception as e: + # Catch other potential errors during readline import/setup + logging.warning(f"Readline import failed: {e}") + pass + +# Import UI Colors first - these should always be available +from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR + +# Import necessary components from other modules +try: + from .path_handler import PathHandler + from .protontricks_handler import ProtontricksHandler + from .shortcut_handler import ShortcutHandler + from .vdf_handler import VDFHandler + from .modlist_handler import ModlistHandler + from .filesystem_handler import FileSystemHandler + from .menu_handler import MenuHandler, simple_path_completer + # Standard logging (no file handler) - LoggingHandler import removed + from .status_utils import show_status, clear_status + from jackify.shared.ui_utils import print_section_header +except ImportError as e: + logging.error(f"Import error in InstallWabbajackHandler: {e}") + logging.error("Could not import FileSystemHandler or simple_path_completer. Ensure structure is correct.") + +# Default locations +WABBAJACK_DEFAULT_DIR = os.path.expanduser("~/.config/Jackify/Wabbajack") + +# Initialize logger for the module +logger = logging.getLogger(__name__) + +DEFAULT_WABBAJACK_PATH = "~/Wabbajack" +DEFAULT_WABBAJACK_NAME = "Wabbajack" + +class InstallWabbajackHandler: + """Handles the workflow for installing Wabbajack via Jackify.""" + + def __init__(self, steamdeck: bool, protontricks_handler: ProtontricksHandler, shortcut_handler: ShortcutHandler, path_handler: PathHandler, vdf_handler: VDFHandler, modlist_handler: ModlistHandler, filesystem_handler: FileSystemHandler, menu_handler=None): + """ + Initializes the handler. + + Args: + steamdeck (bool): True if running on a Steam Deck, False otherwise. + protontricks_handler (ProtontricksHandler): An initialized instance. + shortcut_handler (ShortcutHandler): An initialized instance. + path_handler (PathHandler): An initialized instance. + vdf_handler (VDFHandler): An initialized instance. + modlist_handler (ModlistHandler): An initialized instance. + filesystem_handler (FileSystemHandler): An initialized instance. + menu_handler: An optional MenuHandler instance for improved UI interactions. + """ + # Use standard logging (no file handler) + self.logger = logging.getLogger(__name__) + self.logger.propagate = False + self.steamdeck = steamdeck + self.protontricks_handler = protontricks_handler # Store the handler + self.shortcut_handler = shortcut_handler # Store the handler + self.path_handler = path_handler # Store the handler + self.vdf_handler = vdf_handler # Store the handler + self.modlist_handler = modlist_handler # Store the handler + self.filesystem_handler = filesystem_handler # Store the handler + self.menu_handler = menu_handler # Store the menu handler + self.logger.info(f"InstallWabbajackHandler initialized. Steam Deck status: {self.steamdeck}") + self.install_path: Optional[Path] = None + self.shortcut_name: Optional[str] = None + self.initial_appid: Optional[str] = None # To store the AppID from shortcut creation + self.final_appid: Optional[str] = None # To store the AppID after verification + self.compatdata_path: Optional[Path] = None # To store the compatdata path + # Add other state variables as needed + + def _print_default_status(self, message: str): + """Prints overwriting status line, ONLY if not in verbose/debug mode.""" + verbose_console = False + for handler in logging.getLogger().handlers: + if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): + if handler.level <= logging.INFO: + verbose_console = True + break + + if not verbose_console: + # Use \r to return to start, \033[K to clear line, then print message + # Prepend "Current Task: " to the message + status_text = f"Current Task: {message}" + # Use a fixed-width field for consistent display and proper line clearing + status_width = 80 # Ensure sufficient width to cover previous text + # Pad with spaces and use \r to stay on the same line + print(f"\r\033[K{COLOR_INFO}{status_text:<{status_width}}{COLOR_RESET}", end="", flush=True) + + def _clear_default_status(self): + """Clears the status line, ONLY if not in verbose/debug mode.""" + verbose_console = False + for handler in logging.getLogger().handlers: + if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): + if handler.level <= logging.INFO: + verbose_console = True + break + if not verbose_console: + print("\r\033[K", end="", flush=True) + + def _download_file(self, url: str, destination_path: Path) -> bool: + """Downloads a file from a URL to a destination path. + Handles temporary file and overwrites destination if download succeeds. + + Args: + url (str): The URL to download from. + destination_path (Path): The path to save the downloaded file. + + Returns: + bool: True if download succeeds, False otherwise. + """ + self.logger.info(f"Downloading {destination_path.name} from {url}") + + # Ensure parent directory exists + destination_path.parent.mkdir(parents=True, exist_ok=True) + + # --- Download --- + temp_path = destination_path.with_suffix(destination_path.suffix + ".part") + self.logger.debug(f"Downloading to temporary path: {temp_path}") + + try: + with requests.get(url, stream=True, timeout=30, verify=True) as r: + r.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) + # total_size_in_bytes = int(r.headers.get('content-length', 0)) + block_size = 8192 # 8KB chunks + + with open(temp_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=block_size): + if chunk: # filter out keep-alive new chunks + f.write(chunk) + + # --- Post-Download Actions --- + actual_downloaded_size = temp_path.stat().st_size + self.logger.debug(f"Download finished. Actual size: {actual_downloaded_size} bytes.") + + # Overwrite final destination with temp file + # Use shutil.move for better cross-filesystem compatibility if needed + # temp_path.rename(destination_path) # Simple rename + shutil.move(str(temp_path), str(destination_path)) + self.logger.info(f"Successfully downloaded and moved to {destination_path}") + return True + + except requests.exceptions.RequestException as e: + self.logger.error(f"Download failed for {url}: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Error downloading {destination_path.name}: {e}{COLOR_RESET}") + # Clean up partial file if download fails + if temp_path.exists(): + try: + temp_path.unlink() + except OSError as unlink_err: + self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") + return False + except Exception as e: + self.logger.error(f"An unexpected error occurred during download: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}An unexpected error occurred during download: {e}{COLOR_RESET}") + if temp_path.exists(): + try: + temp_path.unlink() + except OSError as unlink_err: + self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") + return False + + def _prepare_install_directory(self) -> bool: + """ + Ensures the target installation directory exists and is accessible. + Handles directory creation, prompting the user if outside $HOME. + + Returns: + bool: True if the directory exists and is ready, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot prepare directory: install_path is not set.") + return False + + self.logger.info(f"Preparing installation directory: {self.install_path}") + + if self.install_path.exists(): + if self.install_path.is_dir(): + self.logger.info(f"Directory already exists: {self.install_path}") + # Check write permissions + if not os.access(self.install_path, os.W_OK | os.X_OK): + self.logger.error(f"Directory exists but lacks write/execute permissions: {self.install_path}") + print(f"\n{COLOR_ERROR}Error: Directory exists but lacks necessary write/execute permissions.{COLOR_RESET}") + return False + return True + else: + self.logger.error(f"Path exists but is not a directory: {self.install_path}") + print(f"\n{COLOR_ERROR}Error: The specified path exists but is a file, not a directory.{COLOR_RESET}") + return False + else: + # Directory does not exist, attempt creation + self.logger.info("Directory does not exist. Attempting creation...") + try: + home_dir = Path.home() + is_outside_home = not str(self.install_path.resolve()).startswith(str(home_dir.resolve())) + + if is_outside_home: + self.logger.warning(f"Install path {self.install_path} is outside home directory {home_dir}.") + print(f"\n{COLOR_PROMPT}The chosen path is outside your home directory and may require manual creation.{COLOR_RESET}") + while True: + response = input(f"{COLOR_PROMPT}Please create the directory \"{self.install_path}\" manually,\nensure you have write permissions, and then press Enter to continue (or 'q' to quit): {COLOR_RESET}").lower() + if response == 'q': + self.logger.warning("User aborted manual directory creation.") + return False + # Re-check after user presses Enter + if self.install_path.exists(): + if self.install_path.is_dir(): + self.logger.info("Directory created manually by user.") + if not os.access(self.install_path, os.W_OK | os.X_OK): + self.logger.warning(f"Directory created but may lack write/execute permissions: {self.install_path}") + print(f"\n{COLOR_ERROR}Warning: Directory created, but write/execute permissions might be missing.{COLOR_RESET}") + # Decide whether to proceed or fail here - let's proceed but warn + return True + else: + self.logger.error("User indicated directory created, but path is not a directory.") + print(f"\n{COLOR_ERROR}Error: Path exists now, but it is not a directory. Please fix and try again.{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}Directory still not found. Please create it or enter 'q' to quit.{COLOR_RESET}") + else: + # Inside home directory, attempt direct creation + self.logger.info("Path is inside home directory. Creating...") + os.makedirs(self.install_path) + self.logger.info(f"Successfully created directory: {self.install_path}") + # Verify permissions after creation + if not os.access(self.install_path, os.W_OK | os.X_OK): + self.logger.warning(f"Directory created but lacks write/execute permissions: {self.install_path}") + print(f"\n{COLOR_ERROR}Warning: Directory created, but lacks write/execute permissions. Subsequent steps might fail.{COLOR_RESET}") + # Proceed anyway? + return True + + except PermissionError: + self.logger.error(f"Permission denied when trying to create directory: {self.install_path}", exc_info=True) + print(f"\n{COLOR_ERROR}Error: Permission denied creating directory.{COLOR_RESET}") + print(f"{COLOR_INFO}Please check permissions for the parent directory or choose a different location.{COLOR_RESET}") + return False + except OSError as e: + self.logger.error(f"Failed to create directory {self.install_path}: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Error creating directory: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"An unexpected error occurred during directory preparation: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _get_wabbajack_install_path(self) -> Optional[Path]: + """ + Prompts the user for the Wabbajack installation path with tab completion. + Uses the FileSystemHandler for path validation and completion. + + Returns: + Optional[Path]: The chosen installation path as a Path object, or None if cancelled. + """ + self.logger.info("Prompting for Wabbajack installation path.") + # Use default path if set, otherwise prompt with suggestion + current_path = self.install_path if self.install_path else Path(DEFAULT_WABBAJACK_PATH).expanduser() + + # Enable tab completion if readline is available + if READLINE_AVAILABLE: + readline.set_completer_delims(' \t\n;') + readline.parse_and_bind("tab: complete") + # Use the simple_path_completer from FileSystemHandler for directory completion + readline.set_completer(simple_path_completer) + + while True: + try: + prompt_text = f"{COLOR_PROMPT}Enter Wabbajack installation path (default: {current_path}): {COLOR_RESET}" + user_input = input(prompt_text).strip() + + if not user_input: # User pressed Enter for default + chosen_path_str = str(current_path) + else: + chosen_path_str = user_input + + # Expand ~ and make absolute + chosen_path = Path(chosen_path_str).expanduser().resolve() + + # Basic validation (is it a plausible path format?) + if not chosen_path.name: # e.g. if user entered just "/" + print(f"{COLOR_ERROR}Invalid path. Please enter a valid directory path.{COLOR_RESET}") + continue + + # Check if path exists and is a directory, or can be created + if chosen_path.exists() and not chosen_path.is_dir(): + print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}") + continue + + # Confirm with user + confirm_prompt = f"{COLOR_PROMPT}Install Wabbajack to {chosen_path}? (Y/n/c to cancel): {COLOR_RESET}" + confirmation = input(confirm_prompt).lower() + + if confirmation == 'c': + self.logger.info("Wabbajack installation path selection cancelled by user.") + return None # User cancelled + elif confirmation != 'n': + self.install_path = chosen_path # Store the confirmed path + self.logger.info(f"Wabbajack installation path set to: {self.install_path}") + return self.install_path + # If 'n', loop again to ask for path + except KeyboardInterrupt: + self.logger.info("Wabbajack installation path selection cancelled by user (Ctrl+C).") + print("\nPath selection cancelled.") + return None + except Exception as e: + self.logger.error(f"Error during path input: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + # Decide if we should return None or retry on general exception + return None + finally: + # Restore default completer if it was changed + if READLINE_AVAILABLE: + readline.set_completer(None) + + def _get_wabbajack_shortcut_name(self) -> Optional[str]: + """ + Prompts the user for the Wabbajack shortcut name. + + Returns: + Optional[str]: The name chosen by the user, or None if cancelled. + """ + self.logger.debug("Getting Wabbajack shortcut name.") + + # Return pre-configured shortcut name if already set + if self.shortcut_name: + self.logger.info(f"Using pre-configured shortcut name: {self.shortcut_name}") + return self.shortcut_name + + chosen_name = DEFAULT_WABBAJACK_NAME + + # Use menu_handler if available for consistent UI + if self.menu_handler: + self.logger.debug("Using menu_handler for shortcut name input") + print(f"\nWabbajack Shortcut Name:") + name_input = self.menu_handler.get_input_with_default( + prompt=f"Enter the desired name for the Wabbajack Steam shortcut (default: {chosen_name})", + default=chosen_name + ) + + if name_input is not None: + self.logger.info(f"User provided shortcut name: {name_input}") + return name_input + else: + self.logger.info("User cancelled shortcut name input") + return None + + # Fallback to direct input if no menu_handler + try: + print(f"\n{COLOR_PROMPT}Enter the desired name for the Wabbajack Steam shortcut.{COLOR_RESET}") + name_input = input(f"{COLOR_PROMPT}Name [{chosen_name}]: {COLOR_RESET}").strip() + + if not name_input: + self.logger.info(f"User did not provide input, using default name: {chosen_name}") + else: + chosen_name = name_input + self.logger.info(f"User provided name: {chosen_name}") + + return chosen_name + + except KeyboardInterrupt: + print(f"\n{COLOR_ERROR}Input cancelled by user.{COLOR_RESET}") + self.logger.warning("User cancelled name input.") + return None + except Exception as e: + self.logger.error(f"An unexpected error occurred while getting name input: {e}", exc_info=True) + return None + + def run_install_workflow(self, context: dict = None) -> bool: + """ + Main entry point for the Wabbajack installation workflow. + """ + os.system('cls' if os.name == 'nt' else 'clear') + # Banner display handled by frontend + print_section_header('Wabbajack Installation') + # Standard logging (no file handler) - LoggingHandler calls removed + + self.logger.info("Starting Wabbajack installation workflow...") + # Remove legacy divider + # print(f"\n{COLOR_INFO}--- Wabbajack Installation ---{COLOR_RESET}") + # 1. Get Installation Path + if self.menu_handler: + print("\nWabbajack Installation Location:") + default_path = Path.home() / 'Wabbajackify' + install_path_result = self.menu_handler.get_directory_path( + prompt_message=f"Enter path (Default: {default_path}):", + default_path=default_path, + create_if_missing=True, + no_header=True + ) + if not install_path_result: + self.logger.info("User cancelled path input via menu_handler") + return True # Return to menu to allow user to retry or exit gracefully + # Handle the result from get_directory_path (could be Path or tuple) + if isinstance(install_path_result, tuple): + self.install_path = install_path_result[0] # Path object + self.logger.info(f"Install path set to {self.install_path}, user confirmed creation if new.") + else: + self.install_path = install_path_result # Already a Path object + self.logger.info(f"Install path set to {self.install_path}.") + else: # Fallback if no menu_handler (should ideally not happen in normal flow) + default_path = Path.home() / 'Wabbajackify' + print(f"\n{COLOR_PROMPT}Enter the full path where Wabbajack should be installed.{COLOR_RESET}") + print(f"Default: {default_path}") + try: + user_input = input(f"{COLOR_PROMPT}Enter path (or press Enter for default: {default_path}): {COLOR_RESET}").strip() + if not user_input: + install_path = default_path + else: + install_path = Path(user_input).expanduser().resolve() + self.install_path = install_path + except KeyboardInterrupt: + print("\nOperation cancelled by user.") + self.logger.info("User cancelled path input.") + return True + + # 2. Get Shortcut Name + self.shortcut_name = self._get_wabbajack_shortcut_name() + if not self.shortcut_name: + self.logger.warning("Workflow aborted: Failed to get shortcut name.") + return True # Return to menu + + # 3. Steam Deck status is already known (self.steamdeck) + self.logger.info(f"Proceeding with Steam Deck status: {self.steamdeck}") + + # 4. Check Prerequisite: Protontricks + self.logger.info("Checking Protontricks prerequisite...") + protontricks_ok = self.protontricks_handler.check_and_setup_protontricks() + if not protontricks_ok: + self.logger.error("Workflow aborted: Protontricks requirement not met or setup failed.") + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + self.logger.info("Protontricks check successful.") + + # --- Show summary (no input required) --- + self._display_summary() # Show the summary only, no input here + # --- Single confirmation prompt before making changes/restarting Steam --- + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_PROMPT}Important:{COLOR_RESET} Steam will now restart so Jackify can create the Wabbajack shortcut.\n\nPlease do not manually start or close Steam until Jackify is finished.") + print("───────────────────────────────────────────────────────────────────") + confirm = input(f"{COLOR_PROMPT}Do you wish to continue? (y/N): {COLOR_RESET}").strip().lower() + if confirm not in ('y', ''): + print("Installation cancelled by user.") + return True + + # --- Phase 2: All changes happen after confirmation --- + + # 5. Prepare Install Directory + show_status("Preparing install directory") + if not self._prepare_install_directory(): + self.logger.error("Workflow aborted: Failed to prepare installation directory.") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + self.logger.info("Installation directory prepared successfully.") + + # 6. Download Wabbajack.exe + show_status("Downloading Wabbajack.exe") + if not self._download_wabbajack_executable(): + self.logger.error("Workflow aborted: Failed to download Wabbajack.exe.") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + clear_status() + + # 7. Create Steam Shortcut + show_status("Creating Steam shortcut") + shortcut_created = self._create_steam_shortcut() + clear_status() + if not shortcut_created: + self.logger.error("Workflow aborted: Failed to create Steam shortcut.") + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + + # Print the AppID immediately after shortcut creation, before any other output + print("\n==================== Steam Shortcut Created ====================") + if self.initial_appid: + print(f"{COLOR_INFO}Initial Steam AppID (before Steam restart): {self.initial_appid}{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Warning: Could not determine initial AppID after shortcut creation.{COLOR_RESET}") + print("==============================================================\n") + + # 8. Handle Steam Restart & Manual Steps (Calls _print_default_status internally) + if not self._handle_steam_restart_and_manual_steps(): + # Status already cleared by the function if needed + self.logger.info("Workflow aborted: Steam restart/manual steps issue or user needs to re-run.") + return True # Return to menu, user needs to act + + # 9. Verify Manual Steps + # Move cursor up, return to start, clear line - attempt to overwrite input prompt line + print("\033[A\r\033[K", end="", flush=True) + show_status("Verifying Proton Setup") + while True: + if self._verify_manual_steps(): + show_status("Manual Steps Successful") + # Print the AppID after Steam restart and re-detection + if self.final_appid: + print(f"\n{COLOR_INFO}Final Steam AppID (after Steam restart): {self.final_appid}{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}Warning: Could not determine AppID after Steam restart.{COLOR_RESET}") + break # Verification successful + else: + self.logger.warning("Manual steps verification failed.") + clear_status() # Clear status before printing error/prompt + print(f"\n{COLOR_ERROR}Verification failed. Please ensure you have completed all manual steps correctly.{COLOR_RESET}") + self._display_manual_proton_steps() # Re-display steps + try: + # Add a newline before the input prompt for clarity + response = input(f"\n{COLOR_PROMPT}Press Enter to retry verification, or 'q' to quit: {COLOR_RESET}").lower() + if response == 'q': + self.logger.warning("User quit during verification loop.") + return True # Return to menu, aborting config + show_status("Retrying Verification") + except KeyboardInterrupt: + clear_status() + print("\nOperation cancelled by user.") + self.logger.warning("User cancelled during verification loop.") + return True # Return to menu + + # --- Start Actual Configuration --- + self.logger.info(f"Starting final configuration for AppID {self.final_appid}...") + # logger.info("--- Configuration --- Applying final configurations...") # Keep this log for file + + # Check console level for verbose output + verbose_console = False + for handler in logging.getLogger().handlers: + if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): + if handler.level <= logging.INFO: # Check if INFO or DEBUG + verbose_console = True + break + + if verbose_console: + print(f"{COLOR_INFO}Applying final configurations...{COLOR_RESET}") + + # 10. Set Protontricks Permissions (Flatpak) + show_status("Setting Protontricks permissions") + if not self.protontricks_handler.set_protontricks_permissions(str(self.install_path), self.steamdeck): + self.logger.warning("Failed to set Flatpak Protontricks permissions. Continuing, but subsequent steps might fail if Flatpak Protontricks is used.") + clear_status() # Clear status before printing warning + print(f"\n{COLOR_ERROR}Warning: Could not set Flatpak permissions automatically.{COLOR_RESET}") + + # 12. Download WebView Installer (Check happens BEFORE setting prefix) + show_status("Checking WebView Installer") + if not self._download_webview_installer(): + self.logger.error("Workflow aborted: Failed to download WebView installer.") + # Error message printed by the download function + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + + # 13. Configure Prefix (Set to Win7 for WebView install) + show_status("Applying Initial Win7 Registry Settings (for WebView install)") + try: + import requests + # Download minimal Win7 system.reg (corrected URL) + system_reg_win7_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.wj.win7" + system_reg_dest = self.compatdata_path / 'pfx' / 'system.reg' + system_reg_dest.parent.mkdir(parents=True, exist_ok=True) + self.logger.info(f"Downloading system.reg.wj.win7 from {system_reg_win7_url} to {system_reg_dest}") + response = requests.get(system_reg_win7_url, verify=True) + response.raise_for_status() + with open(system_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"system.reg.wj.win7 downloaded and applied to {system_reg_dest}") + except Exception as e: + self.logger.error(f"Failed to download or apply initial Win7 system.reg: {e}") + print(f"{COLOR_ERROR}Error: Failed to download or apply initial Win7 system.reg. {e}{COLOR_RESET}") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True + + # 14. Install WebView (using protontricks-launch) + show_status("Installing WebView (Edge)") + webview_installer_path = self.install_path / "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + webview_result = self.protontricks_handler.run_protontricks_launch( + self.final_appid, webview_installer_path, "/silent", "/install" + ) + self.logger.debug(f"WebView install result: {webview_result}") + if not webview_result or webview_result.returncode != 0: + self.logger.error("WebView installation failed via protontricks-launch.") + print(f"{COLOR_ERROR}Error: WebView installation failed via protontricks-launch.{COLOR_RESET}") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True + show_status("WebView installation Complete") + + # 15. Configure Prefix (Part 2 - Final Settings) + show_status("Applying Final Registry Settings") + try: + # Download final system.reg + system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.wj" + system_reg_dest = self.compatdata_path / 'pfx' / 'system.reg' + self.logger.info(f"Downloading final system.reg from {system_reg_url} to {system_reg_dest}") + response = requests.get(system_reg_url, verify=True) + response.raise_for_status() + with open(system_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"Final system.reg downloaded and applied to {system_reg_dest}") + # Download final user.reg + user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.wj" + user_reg_dest = self.compatdata_path / 'pfx' / 'user.reg' + self.logger.info(f"Downloading final user.reg from {user_reg_url} to {user_reg_dest}") + response = requests.get(user_reg_url, verify=True) + response.raise_for_status() + with open(user_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"Final user.reg downloaded and applied to {user_reg_dest}") + except Exception as e: + self.logger.error(f"Failed to download or apply final user.reg/system.reg: {e}") + print(f"{COLOR_ERROR}Error: Failed to download or apply final user.reg/system.reg. {e}{COLOR_RESET}") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True + + # 16. Configure Prefix Steam Library VDF + show_status("Configuring Steam Library in Prefix") + if not self._create_prefix_library_vdf(): return False + + # 17. Create Dotnet Bundle Cache Directory + show_status("Creating .NET Cache Directory") + if not self._create_dotnet_cache_dir(): + self.logger.error("Workflow aborted: Failed to create dotnet cache directory.") + clear_status() + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + + # --- Final Steps --- + # Check for and optionally apply Flatpak overrides *before* final cleanup/completion + self._check_and_prompt_flatpak_overrides() + + # Attempt to clean up any stray Wine/Protontricks processes as a final measure + self.logger.info("Performing final Wine process cleanup...") + try: + # Ensure the ProtontricksHandler instance exists and has the method + if hasattr(self, 'protontricks_handler') and hasattr(self.protontricks_handler, '_cleanup_wine_processes'): + self.protontricks_handler._cleanup_wine_processes() + self.logger.info("Wine process cleanup command executed.") + else: + self.logger.warning("Protontricks handler or cleanup method not available, skipping cleanup.") + except Exception as cleanup_e: + self.logger.error(f"Error during final Wine process cleanup: {cleanup_e}", exc_info=True) + # Don't abort the whole workflow for a cleanup failure, just log it. + + # 18b. Display Completion Message + clear_status() + self._display_completion_message() + + # End of successful workflow + self.logger.info("Wabbajack installation workflow completed successfully.") + clear_status() # Clear status before final prompt + input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") + return True # Return to menu + + def _display_summary(self): + """Displays a summary of settings (no confirmation prompt).""" + if not self.install_path or not self.shortcut_name: + self.logger.error("Cannot display summary: Install path or shortcut name missing.") + return False # Should not happen if called at the right time + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_PROMPT}--- Installation Summary ---{COLOR_RESET}") + print(f" Install Path: {self.install_path}") + print(f" Shortcut Name: {self.shortcut_name}") + print(f" Environment: {'Steam Deck' if self.steamdeck else 'Desktop Linux'}") + print(f" Protontricks: {self.protontricks_handler.which_protontricks or 'Unknown'}") + print("───────────────────────────────────────────────────────────────────") + return True + + def _backup_and_replace_final_reg_files(self) -> bool: + """Backs up current reg files and replaces them with the final downloaded versions.""" + if not self.compatdata_path: + self.logger.error("Cannot backup/replace reg files: compatdata_path not set.") + return False + + pfx_path = self.compatdata_path / 'pfx' + system_reg = pfx_path / 'system.reg' + user_reg = pfx_path / 'user.reg' + system_reg_bak = pfx_path / 'system.reg.orig' + user_reg_bak = pfx_path / 'user.reg.orig' + + # Backup existing files + self.logger.info("Backing up existing registry files...") + logger.info("Backing up current registry files...") + try: + if system_reg.exists(): + shutil.copy2(system_reg, system_reg_bak) + self.logger.debug(f"Backed up {system_reg} to {system_reg_bak}") + else: + self.logger.warning(f"Original {system_reg} not found for backup.") + + if user_reg.exists(): + shutil.copy2(user_reg, user_reg_bak) + self.logger.debug(f"Backed up {user_reg} to {user_reg_bak}") + else: + self.logger.warning(f"Original {user_reg} not found for backup.") + + except Exception as e: + self.logger.error(f"Error backing up registry files: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error backing up registry files: {e}{COLOR_RESET}") + return False # Treat backup failure as critical? + + # Define final registry file URLs + final_system_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/system.reg.github" + final_user_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/user.reg.github" + + # Download and replace + logger.info("Downloading and applying final registry settings...") + system_ok = self._download_and_replace_reg_file(final_system_reg_url, system_reg) + user_ok = self._download_and_replace_reg_file(final_user_reg_url, user_reg) + + if system_ok and user_ok: + self.logger.info("Successfully applied final registry files.") + return True + else: + self.logger.error("Failed to download or replace one or both final registry files.") + print(f"{COLOR_ERROR}Error: Failed to apply final registry settings.{COLOR_RESET}") + # Should we attempt to restore backups here? + return False + + def _install_webview(self) -> bool: + """Installs the WebView2 runtime using protontricks-launch.""" + if not self.final_appid or not self.install_path: + self.logger.error("Cannot install WebView: final_appid or install_path not set.") + return False + + installer_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + installer_path = self.install_path / installer_name + + if not installer_path.is_file(): + self.logger.error(f"WebView installer not found at {installer_path}. Cannot install.") + print(f"{COLOR_ERROR}Error: WebView installer file missing. Please ensure step 12 completed.{COLOR_RESET}") + return False + + self.logger.info(f"Starting WebView installation for AppID {self.final_appid}...") + # Remove print, handled by caller + # print("\nInstalling WebView (this can take a while, please be patient)...") + + cmd_prefix = [] + if self.protontricks_handler.which_protontricks == 'flatpak': + # Using full command path is safer than relying on alias being sourced + cmd_prefix = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks"] + else: + launch_path = shutil.which("protontricks-launch") + if not launch_path: + self.logger.error("protontricks-launch command not found in PATH.") + print(f"{COLOR_ERROR}Error: protontricks-launch command not found.{COLOR_RESET}") + return False + cmd_prefix = [launch_path] + + # Arguments for protontricks-launch + args = ["--appid", self.final_appid, str(installer_path), "/silent", "/install"] + full_cmd = cmd_prefix + args + + self.logger.debug(f"Executing WebView install command: {' '.join(full_cmd)}") + + try: + # Use check=True to raise CalledProcessError on non-zero exit + # Set a longer timeout as this can take time. + result = subprocess.run(full_cmd, check=True, capture_output=True, text=True, timeout=600) # 10 minute timeout + self.logger.info("WebView installation command completed successfully.") + # Do NOT log result.stdout or result.stderr here + return True + except FileNotFoundError: + self.logger.error(f"Command not found: {cmd_prefix[0]}") + print(f"{COLOR_ERROR}Error: Could not execute {cmd_prefix[0]}. Is it installed correctly?{COLOR_RESET}") + return False + except subprocess.TimeoutExpired: + self.logger.error("WebView installation timed out after 10 minutes.") + print(f"{COLOR_ERROR}Error: WebView installation took too long and timed out.{COLOR_RESET}") + return False + except subprocess.CalledProcessError as e: + self.logger.error(f"WebView installation failed with return code {e.returncode}") + # Only log a short snippet of output for debugging + self.logger.error(f"STDERR (truncated):\n{e.stderr[:500] if e.stderr else ''}") + print(f"{COLOR_ERROR}Error: WebView installation failed (Return Code: {e.returncode}). Check logs for details.{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Unexpected error during WebView installation: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred during WebView installation: {e}{COLOR_RESET}") + return False + + def _find_steam_library_and_vdf_path(self) -> Tuple[Optional[Path], Optional[Path]]: + """Finds the Steam library root and the path to the real libraryfolders.vdf.""" + self.logger.info("Attempting to find Steam library and libraryfolders.vdf...") + try: + # Check if PathHandler uses static methods or needs instantiation + if isinstance(self.path_handler, type): + common_path = self.path_handler.find_steam_library() + else: + common_path = self.path_handler.find_steam_library() + + if not common_path or not common_path.is_dir(): + self.logger.error("Could not find Steam library common path.") + return None, None + + # Navigate up to find the library root + library_root = common_path.parent.parent # steamapps/common -> steamapps -> library_root + self.logger.debug(f"Deduced library root: {library_root}") + + # Construct path to the real libraryfolders.vdf + # Common locations relative to library root + vdf_path_candidates = [ + library_root / 'config/libraryfolders.vdf', # For non-Flatpak? ~/.steam/steam/config + library_root / '../config/libraryfolders.vdf' # Flatpak? ~/.var/app/../Steam/config + ] + + real_vdf_path = None + for candidate in vdf_path_candidates: + resolved_candidate = candidate.resolve() # Resolve symlinks/.. parts + if resolved_candidate.is_file(): + real_vdf_path = resolved_candidate + self.logger.info(f"Found real libraryfolders.vdf at: {real_vdf_path}") + break + + if not real_vdf_path: + self.logger.error(f"Could not find libraryfolders.vdf within library root: {library_root}") + return None, None + + return library_root, real_vdf_path + + except Exception as e: + self.logger.error(f"Error finding Steam library/VDF: {e}", exc_info=True) + return None, None + + def _link_steam_library_config(self) -> bool: + """Creates the necessary directory structure and symlinks libraryfolders.vdf.""" + if not self.compatdata_path: + self.logger.error("Cannot link Steam library: compatdata_path not set.") + return False + + self.logger.info("Linking Steam library configuration (libraryfolders.vdf)...") + + library_root, real_vdf_path = self._find_steam_library_and_vdf_path() + if not library_root or not real_vdf_path: + print(f"{COLOR_ERROR}Error: Could not locate Steam library or libraryfolders.vdf.{COLOR_RESET}") + return False + + target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' + link_path = target_dir / 'libraryfolders.vdf' + + try: + # Backup the original libraryfolders.vdf before doing anything else + # Use FileSystemHandler for consistency - NOW USE INSTANCE + self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") + if not self.filesystem_handler.backup_file(real_vdf_path): + self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") + # Optionally, prompt user or fail here? For now, just warn. + print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") + + # Create the target directory + self.logger.debug(f"Creating directory: {target_dir}") + os.makedirs(target_dir, exist_ok=True) + + # Remove existing symlink if it exists + if link_path.is_symlink(): + self.logger.debug(f"Removing existing symlink at {link_path}") + link_path.unlink() + elif link_path.exists(): + # It exists but isn't a symlink - this is unexpected + self.logger.warning(f"Path {link_path} exists but is not a symlink. Removing it.") + if link_path.is_dir(): + shutil.rmtree(link_path) + else: + link_path.unlink() + + # Create the symlink + self.logger.info(f"Creating symlink from {real_vdf_path} to {link_path}") + os.symlink(real_vdf_path, link_path) + + # Verification (optional but good) + if link_path.is_symlink() and link_path.resolve() == real_vdf_path.resolve(): + self.logger.info("Symlink created and verified successfully.") + return True + else: + self.logger.error("Symlink creation failed or verification failed.") + return False + + except OSError as e: + self.logger.error(f"OSError during symlink creation: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error creating Steam library link: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Unexpected error during symlink creation: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _create_prefix_library_vdf(self) -> bool: + """Creates the necessary directory structure and copies a modified libraryfolders.vdf.""" + if not self.compatdata_path: + self.logger.error("Cannot create prefix VDF: compatdata_path not set.") + return False + + self.logger.info("Creating modified libraryfolders.vdf in prefix...") + + # 1. Find the real host VDF file + library_root, real_vdf_path = self._find_steam_library_and_vdf_path() + if not real_vdf_path: + # Error logged by _find_steam_library_and_vdf_path + print(f"{COLOR_ERROR}Error: Could not locate real libraryfolders.vdf.{COLOR_RESET}") + return False + + # 2. Backup the real VDF file + self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") + if not self.filesystem_handler.backup_file(real_vdf_path): + self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") + print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") + + # 3. Define target location in prefix + target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' + target_vdf_path = target_dir / 'libraryfolders.vdf' + + try: + # 4. Read the content of the real VDF + self.logger.debug(f"Reading content from {real_vdf_path}") + vdf_content = real_vdf_path.read_text(encoding='utf-8') + + # 5. Convert Linux paths to Wine paths within the content string + modified_content = vdf_content + # Regex to find "path" "/linux/path" entries reliably + path_pattern = re.compile(r'("path"\s*")([^"]+)(")') + + # Use a function for replacement logic to handle potential errors + def replace_path(match): + prefix, linux_path_str, suffix = match.groups() + self.logger.debug(f"Found path entry to convert: {linux_path_str}") + try: + linux_path = Path(linux_path_str) + # Check if it's an SD card path + if self.filesystem_handler.is_sd_card(linux_path): + # Assuming SD card maps to D: + # Remove prefix like /run/media/mmcblk0p1/ + relative_sd_path_str = self.filesystem_handler._strip_sdcard_path_prefix(linux_path) + wine_path = "D:\\" + relative_sd_path_str.replace('/', '\\') + self.logger.debug(f" Converted SD card path: {linux_path_str} -> {wine_path}") + else: + # Assume non-SD maps relative to Z: + # Need the full path prefixed with Z: + wine_path = "Z:\\" + linux_path_str.strip('/').replace('/', '\\') + self.logger.debug(f" Converted standard path: {linux_path_str} -> {wine_path}") + + # Ensure backslashes are doubled for VDF format + wine_path_vdf_escaped = wine_path.replace('\\', '\\\\') + return f'{prefix}{wine_path_vdf_escaped}{suffix}' + except Exception as e: + self.logger.error(f"Error converting path '{linux_path_str}': {e}. Keeping original.") + return match.group(0) # Return original match on error + + # Perform the replacement using re.sub with the function + modified_content = path_pattern.sub(replace_path, vdf_content) + + # Log comparison if content changed (optional) + if modified_content != vdf_content: + self.logger.info("Successfully converted Linux paths to Wine paths in VDF content.") + else: + self.logger.warning("VDF content unchanged after conversion attempt. Did it contain Linux paths?") + + # 6. Ensure target directory exists + self.logger.debug(f"Ensuring target directory exists: {target_dir}") + os.makedirs(target_dir, exist_ok=True) + + # 7. Write the modified content to the target file in the prefix + self.logger.info(f"Writing modified VDF content to {target_vdf_path}") + target_vdf_path.write_text(modified_content, encoding='utf-8') + + # 8. Verification (optional: check file exists and content) + if target_vdf_path.is_file(): + self.logger.info("Prefix libraryfolders.vdf created successfully.") + return True + else: + self.logger.error("Failed to create prefix libraryfolders.vdf.") + return False + + except Exception as e: + self.logger.error(f"Error processing or writing prefix libraryfolders.vdf: {e}", exc_info=True) + print(f"{COLOR_ERROR}An error occurred configuring the Steam library in the prefix: {e}{COLOR_RESET}") + return False + + def _create_dotnet_cache_dir(self) -> bool: + """Creates the dotnet_bundle_extract cache directory.""" + if not self.install_path: + self.logger.error("Cannot create dotnet cache dir: install_path not set.") + return False + + try: + # Get username reliably + username = pwd.getpwuid(os.getuid()).pw_name + # Fallback if pwd fails for some reason? + # username = os.getlogin() # Can fail in some environments + except Exception as e: + self.logger.error(f"Could not determine username: {e}") + print(f"{COLOR_ERROR}Error: Could not determine username to create cache directory.{COLOR_RESET}") + return False + + cache_dir = self.install_path / 'home' / username / '.cache' / 'dotnet_bundle_extract' + self.logger.info(f"Creating dotnet bundle cache directory: {cache_dir}") + + try: + os.makedirs(cache_dir, exist_ok=True) + # Optionally set permissions? The bash script didn't explicitly. + self.logger.info("dotnet cache directory created successfully.") + return True + except OSError as e: + self.logger.error(f"Failed to create dotnet cache directory {cache_dir}: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error creating dotnet cache directory: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Unexpected error creating dotnet cache directory: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _check_and_prompt_flatpak_overrides(self): + """Checks if Flatpak Steam needs filesystem overrides and prompts the user to apply them.""" + self.logger.info("Checking for necessary Flatpak Steam filesystem overrides...") + is_flatpak_steam = False + # Use compatdata_path as indicator + if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): + is_flatpak_steam = True + self.logger.debug("Flatpak Steam detected based on compatdata path.") + # Add other checks if needed (e.g., check if `flatpak info com.valvesoftware.Steam` runs) + + if not is_flatpak_steam: + self.logger.info("Flatpak Steam not detected, skipping override check.") + return + + paths_to_check = [] + if self.install_path: + paths_to_check.append(self.install_path) + + # Get all library paths from libraryfolders.vdf + try: + all_libs = self.path_handler.get_all_steam_libraries() + paths_to_check.extend(all_libs) + except Exception as e: + self.logger.warning(f"Could not get all Steam libraries to check for overrides: {e}") + + needed_overrides = set() # Use a set to store unique parent paths needing override + home_dir = Path.home() + flatpak_steam_data_dir = home_dir / ".var/app/com.valvesoftware.Steam" + + for path in paths_to_check: + if not path: + continue + resolved_path = path.resolve() + # Check if path is outside $HOME AND outside the Flatpak data dir + is_outside_home = not str(resolved_path).startswith(str(home_dir)) + is_outside_flatpak_data = not str(resolved_path).startswith(str(flatpak_steam_data_dir)) + + if is_outside_home and is_outside_flatpak_data: + # Need override for the parent directory containing this path + # Go up levels until we find a reasonable base (e.g., /mnt/Games, /data/Steam) + # Avoid adding /, /home, etc. + parent_to_add = resolved_path.parent + while parent_to_add != parent_to_add.parent and len(str(parent_to_add)) > 1 and parent_to_add.name != 'home': + # Check if adding this parent makes sense (e.g., it exists, not too high up) + if parent_to_add.is_dir(): # Simple check for existence + # Further heuristics could be added here + needed_overrides.add(str(parent_to_add)) + self.logger.debug(f"Path {resolved_path} is outside sandbox. Adding parent {parent_to_add} to needed overrides.") + break # Add the first reasonable parent found + parent_to_add = parent_to_add.parent + + if not needed_overrides: + self.logger.info("No external paths requiring Flatpak overrides detected.") + return + + # Construct the command string(s) + override_commands = [] + for path_str in sorted(list(needed_overrides)): + # Add specific path override + override_commands.append(f"flatpak override --user --filesystem=\"{path_str}\" com.valvesoftware.Steam") + + # Combine into a single string for display, but keep list for execution + command_display = "\n".join([f" {cmd}" for cmd in override_commands]) + + print(f"\n{COLOR_PROMPT}--- Flatpak Steam Permissions ---{COLOR_RESET}") + print("Jackify has detected that you are using Flatpak Steam and have paths") + print("(e.g., Wabbajack install location or other Steam libraries) outside") + print("the standard Flatpak sandbox. For Wabbajack to access these locations,") + print("Steam needs the following filesystem permissions:") + print(f"{COLOR_INFO}{command_display}{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + + try: + confirm = input(f"{COLOR_PROMPT}Do you want Jackify to apply these permissions now? (y/N): {COLOR_RESET}").lower().strip() + if confirm == 'y': + self.logger.info("User confirmed applying Flatpak overrides.") + success_count = 0 + for cmd_str in override_commands: + self.logger.info(f"Executing: {cmd_str}") + try: + # Split command string for subprocess + cmd_list = cmd_str.split() + result = subprocess.run(cmd_list, check=True, capture_output=True, text=True, timeout=30) + self.logger.debug(f"Override command successful: {result.stdout}") + success_count += 1 + except FileNotFoundError: + self.logger.error(f"'flatpak' command not found. Cannot apply override: {cmd_str}") + print(f"{COLOR_ERROR}Error: 'flatpak' command not found.{COLOR_RESET}") + break # Stop trying if flatpak isn't found + except subprocess.TimeoutExpired: + self.logger.error(f"Flatpak override command timed out: {cmd_str}") + print(f"{COLOR_ERROR}Error: Command timed out: {cmd_str}{COLOR_RESET}") + except subprocess.CalledProcessError as e: + self.logger.error(f"Flatpak override failed: {cmd_str}. Error: {e.stderr}") + print(f"{COLOR_ERROR}Error applying override: {cmd_str}\n{e.stderr}{COLOR_RESET}") + except Exception as e: + self.logger.error(f"Unexpected error applying override {cmd_str}: {e}") + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + + if success_count == len(override_commands): + print(f"{COLOR_INFO}Successfully applied necessary Flatpak permissions.{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Applied {success_count}/{len(override_commands)} permissions. Some overrides may have failed. Check logs.{COLOR_RESET}") + else: + self.logger.info("User declined applying Flatpak overrides.") + print("Permissions not applied. You may need to run the override command(s) manually") + print("if Wabbajack has issues accessing files or game installations.") + + except KeyboardInterrupt: + print("\nOperation cancelled by user.") + self.logger.warning("User cancelled during Flatpak override prompt.") + except Exception as e: + self.logger.error(f"Error during Flatpak override prompt/execution: {e}") + + def _disable_prefix_decoration(self) -> bool: + """Disables window manager decoration in the Wine prefix using protontricks -c.""" + if not self.final_appid: + self.logger.error("Cannot disable decoration: final_appid not set.") + return False + + self.logger.info(f"Disabling window manager decoration for AppID {self.final_appid} via -c 'wine reg add...'") + # Original command string + command = 'wine reg add "HKCU\\Software\\Wine\\X11 Driver" /v Decorated /t REG_SZ /d N /f' + + try: + # Ensure ProtontricksHandler is available + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized!") + print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") + return False + + # Use the original -c method + result = self.protontricks_handler.run_protontricks( + '-c', + command, + self.final_appid # AppID comes last for -c commands + ) + + # Check the return code + if result and result.returncode == 0: + self.logger.info("Successfully disabled window decoration (command returned 0).") + # Add a small delay just in case there's a write lag? + time.sleep(1) + return True + else: + err_msg = result.stderr if result else "Command execution failed or returned non-zero" + # Add stdout to error message if stderr is empty + if result and not result.stderr and result.stdout: + err_msg += f"\nSTDOUT: {result.stdout}" + self.logger.error(f"Failed to disable window decoration via -c. Error: {err_msg}") + print(f"{COLOR_ERROR}Error: Failed to disable window decoration via protontricks -c.{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Exception disabling window decoration: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error disabling window decoration: {e}.{COLOR_RESET}") + return False + + def _display_completion_message(self): + """Displays the final success message and next steps.""" + # Basic log file path (assuming standard location) + # TODO: Get log file path more reliably if needed + log_path = Path.home() / "Jackify" / "logs" / "jackify-cli.log" + + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Wabbajack Installation Completed Successfully!{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + print("Next Steps:") + print(f" • Launch '{COLOR_INFO}{self.shortcut_name or 'Wabbajack'}{COLOR_RESET}' through Steam.") + print(f" • When Wabbajack opens, log in to Nexus using the Settings button (cog icon).") + print(f" • Once logged in, you can browse and install modlists as usual!") + + # Check for Flatpak Steam (Placeholder check) + # A more robust check might involve inspecting self.path_handler findings or config + # For now, check if compatdata path hints at flatpak + is_flatpak_steam = False + if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): + is_flatpak_steam = True + + if is_flatpak_steam: + self.logger.info("Detected Flatpak Steam usage.") + print(f"\n{COLOR_PROMPT}Note: Flatpak Steam Detected:{COLOR_RESET}") + print(f" You may need to grant Wabbajack filesystem access for modlist downloads/installations.") + print(f" Example: If installing to \"/home/{os.getlogin()}/Games/SkyrimSEModlist\", run:") + print(f" {COLOR_INFO}flatpak override --user --filesystem=/home/{os.getlogin()}/Games com.valvesoftware.Steam{COLOR_RESET}") + + print(f"\nDetailed log available at: {log_path}") + print("───────────────────────────────────────────────────────────────────") + + def _download_wabbajack_executable(self) -> bool: + """ + Downloads the latest Wabbajack.exe to the install directory. + Checks existence first. + + Returns: + bool: True on success or if file exists, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot download Wabbajack.exe: install_path is not set.") + return False + + url = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe" + destination = self.install_path / "Wabbajack.exe" + + # Check if file exists first + if destination.is_file(): + self.logger.info(f"Wabbajack.exe already exists at {destination}. Skipping download.") + # print("Wabbajack.exe already present.") # Replaced by logger + return True + + # print(f"\nDownloading latest Wabbajack.exe...") # Replaced by logger + self.logger.info("Wabbajack.exe not found. Downloading...") + if self._download_file(url, destination): + # print("Wabbajack.exe downloaded successfully.") # Replaced by logger + # Set executable permissions + try: + os.chmod(destination, 0o755) + self.logger.info(f"Set execute permissions on {destination}") + except Exception as e: + self.logger.warning(f"Could not set execute permission on {destination}: {e}") + print(f"{COLOR_ERROR}Warning: Could not set execute permission on Wabbajack.exe.{COLOR_RESET}") + return True + else: + self.logger.error("Failed to download Wabbajack.exe.") + # Error message printed by _download_file + return False + + def _create_steam_shortcut(self) -> bool: + """ + Creates the Steam shortcut for Wabbajack using the ShortcutHandler. + + Returns: + bool: True on success, False otherwise. + """ + if not self.shortcut_name or not self.install_path: + self.logger.error("Cannot create shortcut: Missing shortcut name or install path.") + return False + + self.logger.info(f"Creating Steam shortcut '{self.shortcut_name}'...") + executable_path = str(self.install_path / "Wabbajack.exe") + + # Ensure the ShortcutHandler instance exists + # 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=self.shortcut_name, + exe_path=executable_path, + start_dir=os.path.dirname(executable_path), + launch_options="PROTON_USE_WINED3D=1 %command%", + tags=["Jackify", "Wabbajack"], + proton_version="proton_experimental" + ) + + if success and app_id: + self.initial_appid = app_id # Store the initially generated AppID + self.logger.info(f"Shortcut created successfully with initial AppID: {self.initial_appid}") + # Remove direct print, rely on status indicator from caller + # print(f"Steam shortcut '{self.shortcut_name}' created.") + return True + else: + self.logger.error("Failed to create Steam shortcut via ShortcutHandler.") + print(f"{COLOR_ERROR}Error: Failed to create the Steam shortcut for Wabbajack.{COLOR_RESET}") + # Further error details should be logged by the ShortcutHandler + return False + + # --- Helper Methods for Workflow Steps --- + + def _display_manual_proton_steps(self): + """Displays the detailed manual steps required for Proton setup.""" + if not self.shortcut_name: + self.logger.error("Cannot display manual steps: shortcut_name not set.") + print(f"{COLOR_ERROR}Internal Error: Shortcut name missing.{COLOR_RESET}") + return + + print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}") + print("Please complete the following steps in Steam:") + print(f" 1. Locate the '{COLOR_INFO}{self.shortcut_name}{COLOR_RESET}' entry in your Steam Library") + print(" 2. Right-click and select 'Properties'") + print(" 3. Switch to the 'Compatibility' tab") + print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'") + print(" 5. Select 'Proton - Experimental' from the dropdown menu") + print(" 6. Close the Properties window") + print(f" 7. Launch '{COLOR_INFO}{self.shortcut_name}{COLOR_RESET}' from your Steam Library") + print(" 8. Wait for Wabbajack to download its files and fully load") + print(" 9. Once Wabbajack has fully loaded, CLOSE IT completely and return here") + print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}") + + def _handle_steam_restart_and_manual_steps(self) -> bool: + """ + Handles Steam restart and manual steps prompt, but no extra confirmation. + """ + self.logger.info("Handling Steam restart and manual steps prompt.") + clear_status() + # Condensed message: only show essential manual steps guidance + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} After Steam restarts, follow the on-screen instructions to set Proton Experimental.") + print("───────────────────────────────────────────────────────────────────") + self.logger.info("Attempting secure Steam restart...") + show_status("Restarting Steam") + if not hasattr(self, 'shortcut_handler') or not self.shortcut_handler: + self.logger.critical("ShortcutHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Shortcut handler not available for restart.{COLOR_RESET}") + return False + if self.shortcut_handler.secure_steam_restart(): + self.logger.info("Secure Steam restart successful.") + clear_status() + self._display_manual_proton_steps() + print() + input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") + self.logger.info("User confirmed completion of manual steps.") + return True + else: + self.logger.error("Secure Steam restart failed.") + clear_status() + print(f"\n{COLOR_ERROR}Error: Steam restart failed.{COLOR_RESET}") + print("Please try restarting Steam manually:") + print("1. Exit Steam completely (Steam -> Exit or right-click tray icon -> Exit)") + print("2. Wait a few seconds") + print("3. Start Steam again") + print("\nAfter restarting, you MUST perform the manual Proton setup steps:") + self._display_manual_proton_steps() + print(f"\n{COLOR_ERROR}You will need to re-run this Jackify option after completing these steps.{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + return False + + def _redetect_appid(self) -> bool: + """ + Re-detects the AppID for the shortcut after Steam restart. + + Returns: + bool: True if AppID is found, False otherwise. + """ + if not self.shortcut_name: + self.logger.error("Cannot redetect AppID: shortcut_name not set.") + return False + + self.logger.info(f"Re-detecting AppID for shortcut '{self.shortcut_name}'...") + try: + # Ensure the ProtontricksHandler instance exists + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") + return False + + all_shortcuts = self.protontricks_handler.list_non_steam_shortcuts() + + if not all_shortcuts: + self.logger.error("Protontricks listed no non-Steam shortcuts.") + return False + + found_appid = None + for name, appid in all_shortcuts.items(): + if name.lower() == self.shortcut_name.lower(): + found_appid = appid + break + + if found_appid: + self.final_appid = found_appid + self.logger.info(f"Successfully re-detected AppID: {self.final_appid}") + if self.initial_appid and self.initial_appid != self.final_appid: + # Change Warning to Info - this is expected behavior + self.logger.info(f"AppID changed after Steam restart: {self.initial_appid} -> {self.final_appid}") + elif not self.initial_appid: + self.logger.warning("Initial AppID was not set, cannot compare.") + return True + else: + self.logger.error(f"Shortcut '{self.shortcut_name}' not found in protontricks list after restart.") + return False + + except Exception as e: + self.logger.error(f"Error re-detecting AppID: {e}", exc_info=True) + return False + + def _find_steam_config_vdf(self) -> Optional[Path]: + """Finds the path to the primary Steam config.vdf file.""" + self.logger.debug("Searching for Steam config.vdf...") + # Use PathHandler if it has this logic? For now, check common paths. + common_paths = [ + Path.home() / ".steam/steam/config/config.vdf", + Path.home() / ".local/share/Steam/config/config.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.config/Valve Corporation/Steam/config/config.vdf" # Check Flatpak path + ] + for path in common_paths: + if path.is_file(): + self.logger.info(f"Found config.vdf at: {path}") + return path + self.logger.error("Could not find Steam config.vdf in common locations.") + return None + + def _verify_manual_steps(self) -> bool: + """ + Verifies that the user has performed the manual steps using ModlistHandler. + Checks AppID, Proton version set, and prefix existence. + + Returns: + bool: True if verification passes AND compatdata_path is set, False otherwise. + """ + self.logger.info("Verifying manual Proton setup steps...") + self.compatdata_path = None # Explicitly reset before verification + + # 1. Re-detect AppID + # Clear status BEFORE potentially failing here + clear_status() + if not self._redetect_appid(): + print(f"{COLOR_ERROR}Error: Could not find the Steam shortcut '{self.shortcut_name}' using protontricks.{COLOR_RESET}") + print(f"{COLOR_INFO}Ensure Steam has restarted and the shortcut is visible.{COLOR_RESET}") + return False # Indicate failure + + self.logger.debug(f"Verification using final AppID: {self.final_appid}") + + # Add padding after user confirmation before the next status update + # Removed print() call - padding should come AFTER status clear + + # Print status JUST before calling the verification logic + show_status("Verifying Proton Setup") + + # Ensure ModlistHandler is available + if not hasattr(self, 'modlist_handler') or not self.modlist_handler: + self.logger.critical("ModlistHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Modlist handler not available for verification.{COLOR_RESET}") + return False + + # 2. Call the existing verification logic from ModlistHandler + verified, status_code = self.modlist_handler.verify_proton_setup(self.final_appid) + + if not verified: + # Handle Verification Failure Messages based on status_code + if status_code == 'wrong_proton_version': + proton_ver = getattr(self.modlist_handler, 'proton_ver', 'Unknown') + print(f"{COLOR_ERROR}\nVerification Failed: Incorrect Proton version detected ('{proton_ver}'). Expected 'Proton Experimental' (or similar).{COLOR_RESET}") + print(f"{COLOR_INFO}Please ensure you selected the correct Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") + elif status_code == 'proton_check_failed': + print(f"{COLOR_ERROR}\nVerification Failed: Compatibility tool not detected as set for '{self.shortcut_name}' in Steam config.{COLOR_RESET}") + print(f"{COLOR_INFO}Please ensure you forced a Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") + elif status_code == 'compatdata_missing': + print(f"{COLOR_ERROR}\nVerification Failed: Steam compatdata directory for AppID {self.final_appid} not found.{COLOR_RESET}") + print(f"{COLOR_INFO}Have you launched the shortcut '{self.shortcut_name}' at least once after setting Proton?{COLOR_RESET}") + elif status_code == 'prefix_missing': + print(f"{COLOR_ERROR}\nVerification Failed: Wine prefix directory (pfx) not found inside compatdata.{COLOR_RESET}") + print(f"{COLOR_INFO}This usually means the shortcut hasn't been launched successfully after setting Proton.{COLOR_RESET}") + elif status_code == 'config_vdf_missing' or status_code == 'config_vdf_error': + print(f"{COLOR_ERROR}\nVerification Failed: Could not read or parse Steam's config.vdf file ({status_code}).{COLOR_RESET}") + print(f"{COLOR_INFO}Check file permissions or integrity. Check logs for details.{COLOR_RESET}") + else: # General/unknown error + print(f"{COLOR_ERROR}\nVerification Failed: An unexpected error occurred ({status_code}). Check logs.{COLOR_RESET}") + return False # Indicate verification failure + + # If we reach here, basic verification passed (proton set, prefix exists) + # Now, ensure we have the compatdata path. + self.logger.info("Basic verification checks passed. Confirming compatdata path...") + + modlist_handler_compat_path = getattr(self.modlist_handler, 'compat_data_path', None) + if modlist_handler_compat_path: + self.compatdata_path = modlist_handler_compat_path + self.logger.info(f"Compatdata path obtained from ModlistHandler: {self.compatdata_path}") + else: + # If modlist_handler didn't set it, try path_handler + # Change Warning to Info - Fallback is acceptable + self.logger.info("ModlistHandler did not set compat_data_path. Attempting manual lookup via PathHandler.") + # Ensure path_handler is available + if not hasattr(self, 'path_handler') or not self.path_handler: + self.logger.critical("PathHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Path handler not available for verification.{COLOR_RESET}") + return False + + self.compatdata_path = self.path_handler.find_compat_data(self.final_appid) + if self.compatdata_path: + self.logger.info(f"Manually found compatdata path via PathHandler: {self.compatdata_path}") + else: + self.logger.error("Verification checks passed, but COULD NOT FIND compatdata path via ModlistHandler or PathHandler.") + print(f"{COLOR_ERROR}\nVerification Error: Basic checks passed, but failed to locate the compatdata directory for AppID {self.final_appid}.{COLOR_RESET}") + print(f"{COLOR_INFO}This is unexpected. Check Steam filesystem structure and logs.{COLOR_RESET}") + return False # CRITICAL: Return False if path is unobtainable + + # If we get here, verification passed AND we have the compatdata_path + self.logger.info("Manual steps verification successful (including path confirmation).") + logger.info(f"Verification successful! (AppID: {self.final_appid}, Path: {self.compatdata_path})") + return True + + def _download_webview_installer(self) -> bool: + """ + Downloads the specific WebView2 installer needed by Wabbajack. + Checks existence first. + + Returns: + bool: True on success or if file already exists correctly, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot download WebView installer: install_path is not set.") + return False + + url = "https://node10.sokloud.com/filebrowser/api/public/dl/yqVTbUT8/rwatch/WebView/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + file_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + destination = self.install_path / file_name + + self.logger.info(f"Checking WebView installer: {destination}") + # print(f"\nChecking required WebView installer ({file_name})...") # Replaced by logger + + if destination.is_file(): + self.logger.info(f"WebView installer {destination.name} already exists. Skipping download.") + # Consider adding a message here if verbose/debug? + return True + + # File doesn't exist, attempt download + self.logger.info(f"WebView installer not found locally. Downloading {file_name}...") + # Update status before starting download - Use a more user-friendly message + show_status("Downloading WebView Installer") + + if self._download_file(url, destination): + # Status will be cleared by caller or next step + return True + else: + self.logger.error(f"Failed to download WebView installer from {url}.") + # Error message already printed by _download_file + return False + + def _set_prefix_renderer(self, renderer: str = 'vulkan') -> bool: + """Sets the prefix renderer using protontricks.""" + if not self.final_appid: + self.logger.error("Cannot set renderer: final_appid not set.") + return False + + self.logger.info(f"Setting prefix renderer to {renderer} for AppID {self.final_appid}...") + try: + # Ensure the ProtontricksHandler instance exists + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") + print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") + return False + + result = self.protontricks_handler.run_protontricks( + self.final_appid, + 'settings', + f'renderer={renderer}' + ) + if result and result.returncode == 0: + self.logger.info(f"Successfully set renderer to {renderer}.") + return True + else: + err_msg = result.stderr if result else "Command execution failed" + self.logger.error(f"Failed to set renderer to {renderer}. Error: {err_msg}") + print(f"{COLOR_ERROR}Error: Failed to set prefix renderer to {renderer}.{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Exception setting renderer: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error setting prefix renderer: {e}.{COLOR_RESET}") + return False + + def _download_and_replace_reg_file(self, url: str, target_reg_path: Path) -> bool: + """Downloads a .reg file and replaces the target file. + Always downloads and overwrites. + """ + self.logger.info(f"Downloading registry file from {url} to replace {target_reg_path}") + + # Always download and replace for registry files + if self._download_file(url, target_reg_path): + self.logger.info(f"Successfully downloaded and replaced {target_reg_path}") + return True + else: + self.logger.error(f"Failed to download/replace {target_reg_path} from {url}") + return False + +# Example usage (for testing - keep this section for easy module testing) +if __name__ == '__main__': + # Configure logging for standalone testing + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + print("Testing Wabbajack Install Handler...") + # Simulate running on or off deck + test_on_deck = False + print(f"Simulating run with steamdeck={test_on_deck}") + + # Need dummy handlers for direct testing + class DummyProton: + which_protontricks = 'native' + def check_and_setup_protontricks(self): return True + def set_protontricks_permissions(self, path, steamdeck): return True + def enable_dotfiles(self, appid): return True + def _cleanup_wine_processes(self): pass + def run_protontricks(self, *args, **kwargs): return subprocess.CompletedProcess(args=[], returncode=0) + def list_non_steam_shortcuts(self): return {"Wabbajack": "12345"} + + class DummyShortcut: + def create_shortcut(self, *args, **kwargs): return True, "12345" + def secure_steam_restart(self): return True + + class DummyPath: + def find_compat_data(self, appid): return Path(f"/tmp/test_compat/{appid}") + def find_steam_library(self): return Path("/tmp/test_steam/steamapps/common") + + class DummyVDF: + @staticmethod + def load(path): + if "config.vdf" in str(path): + # Simulate structure needed for proton check + return {'UserLocalConfigStore': {'Software': {'Valve': {'Steam': {'apps': {'12345': {'CompatTool': 'proton_experimental'}}}}}}} + return {} + + handler = InstallWabbajackHandler( + steamdeck=test_on_deck, + protontricks_handler=DummyProton(), + shortcut_handler=DummyShortcut(), + path_handler=DummyPath(), + vdf_handler=DummyVDF(), + modlist_handler=ModlistHandler(), + filesystem_handler=FileSystemHandler() + ) + # Pre-create dummy compatdata dir for verification step + if not Path("/tmp/test_compat/12345/pfx").exists(): + os.makedirs("/tmp/test_compat/12345/pfx", exist_ok=True) + + handler.run_install_workflow() + + print("\nTesting completed.") \ No newline at end of file diff --git a/jackify/backend/handlers/wabbajack_parser.py b/jackify/backend/handlers/wabbajack_parser.py new file mode 100644 index 0000000..9b3f396 --- /dev/null +++ b/jackify/backend/handlers/wabbajack_parser.py @@ -0,0 +1,152 @@ +""" +Wabbajack file parser for extracting game type information from .wabbajack files. + +This module provides efficient parsing of .wabbajack files (which are ZIP archives) +to extract game type information without loading the entire archive. +""" + +import json +import logging +import zipfile +from pathlib import Path +from typing import Optional, Dict, Any + + +class WabbajackParser: + """Parser for .wabbajack files to extract game type information.""" + + def __init__(self): + self.logger = logging.getLogger(__name__) + + # Mapping from Wabbajack Game enum values to Jackify game types + self.game_type_mapping = { + 'Starfield': 'starfield', + 'oblivionremastered': 'oblivion_remastered', + 'SkyrimSpecialEdition': 'skyrim', + 'Fallout4': 'fallout4', + 'FalloutNewVegas': 'falloutnv', + 'Oblivion': 'oblivion', + 'Skyrim': 'skyrim', # Legacy Skyrim + 'Fallout3': 'fallout3', # For completeness + 'SkyrimVR': 'skyrim', # Treat as Skyrim + 'Fallout4VR': 'fallout4', # Treat as Fallout 4 + 'Enderal': 'enderal', # Enderal: Forgotten Stories + 'EnderalSpecialEdition': 'enderal', # Enderal SE + } + + # List of supported games in Jackify + self.supported_games = [ + 'skyrim', 'fallout4', 'falloutnv', 'oblivion', + 'starfield', 'oblivion_remastered', 'enderal' + ] + + def parse_wabbajack_game_type(self, wabbajack_path: Path) -> Optional[tuple]: + """ + Parse a .wabbajack file to extract the game type. + + Args: + wabbajack_path: Path to the .wabbajack file + + Returns: + Tuple containing Jackify game type string (e.g., 'skyrim', 'starfield') and raw game type string + """ + try: + if not wabbajack_path.exists(): + self.logger.error(f"Wabbajack file not found: {wabbajack_path}") + return None + + if not wabbajack_path.suffix.lower() == '.wabbajack': + self.logger.error(f"File is not a .wabbajack file: {wabbajack_path}") + return None + + # Open the .wabbajack file as a ZIP archive + with zipfile.ZipFile(wabbajack_path, 'r') as zip_file: + # Look for the modlist file (could be 'modlist' or 'modlist.json') + modlist_files = [f for f in zip_file.namelist() if f in ['modlist', 'modlist.json']] + + if not modlist_files: + self.logger.error(f"No modlist file found in {wabbajack_path}") + return None + + # Extract and parse the modlist file + modlist_file = modlist_files[0] + with zip_file.open(modlist_file) as modlist_stream: + modlist_data = json.load(modlist_stream) + + # Extract the game type + game_type = modlist_data.get('GameType') + if not game_type: + self.logger.error(f"No GameType found in modlist: {wabbajack_path}") + return None + + # Map to Jackify game type + jackify_game_type = self.game_type_mapping.get(game_type) + if jackify_game_type: + self.logger.info(f"Detected game type: {game_type} -> {jackify_game_type}") + return jackify_game_type, game_type + else: + self.logger.warning(f"Unknown game type in modlist: {game_type}") + return 'unknown', game_type + + except zipfile.BadZipFile: + self.logger.error(f"Invalid ZIP file: {wabbajack_path}") + return None + except json.JSONDecodeError as e: + self.logger.error(f"Invalid JSON in modlist file: {e}") + return None + except Exception as e: + self.logger.error(f"Error parsing .wabbajack file {wabbajack_path}: {e}") + return None + + def is_supported_game(self, game_type: str) -> bool: + """ + Check if a game type is supported by Jackify's post-install configuration. + + Args: + game_type: Jackify game type string + + Returns: + True if the game is supported, False otherwise + """ + return game_type in self.supported_games + + def get_supported_games_list(self) -> list: + """ + Get the list of games supported by Jackify's post-install configuration. + + Returns: + List of supported game names + """ + return self.supported_games.copy() + + def get_supported_games_display_names(self) -> list: + """ + Get the display names of supported games for user-facing messages. + + Returns: + List of display names for supported games + """ + display_names = { + 'skyrim': 'Skyrim Special Edition', + 'fallout4': 'Fallout 4', + 'falloutnv': 'Fallout New Vegas', + 'oblivion': 'Oblivion', + 'starfield': 'Starfield', + 'oblivion_remastered': 'Oblivion Remastered' + } + return [display_names.get(game, game) for game in self.supported_games] + + +# Convenience function for easy access +def parse_wabbajack_game_type(wabbajack_path: Path) -> Optional[tuple]: + """ + Convenience function to parse a .wabbajack file and get the game type. + + Args: + wabbajack_path: Path to the .wabbajack file + + Returns: + Tuple containing Jackify game type string and raw game type string or None if parsing fails + """ + parser = WabbajackParser() + return parser.parse_wabbajack_game_type(wabbajack_path) \ No newline at end of file diff --git a/jackify/backend/handlers/wine_utils.py b/jackify/backend/handlers/wine_utils.py new file mode 100644 index 0000000..d5ec8ae --- /dev/null +++ b/jackify/backend/handlers/wine_utils.py @@ -0,0 +1,701 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Wine Utilities Module +Handles wine-related operations and utilities +""" + +import os +import re +import subprocess +import logging +import shutil +import time +from pathlib import Path +import glob +from typing import Optional, Tuple +from .subprocess_utils import get_clean_subprocess_env + +# Initialize logger +logger = logging.getLogger(__name__) + + +class WineUtils: + """ + Utilities for wine-related operations + """ + + @staticmethod + def cleanup_wine_processes(): + """ + Clean up wine processes + Returns True on success, False on failure + """ + try: + # Find and kill processes containing various process names + processes = subprocess.run( + "pgrep -f 'win7|win10|ShowDotFiles|protontricks'", + shell=True, + capture_output=True, + text=True, + env=get_clean_subprocess_env() + ).stdout.strip() + + if processes: + for pid in processes.split("\n"): + try: + subprocess.run(f"kill -9 {pid}", shell=True, check=True, env=get_clean_subprocess_env()) + except subprocess.CalledProcessError: + logger.warning(f"Failed to kill process {pid}") + logger.debug("Processes killed successfully") + else: + logger.debug("No matching processes found") + + # Kill winetricks processes + subprocess.run("pkill -9 winetricks", shell=True, env=get_clean_subprocess_env()) + return True + except Exception as e: + logger.error(f"Failed to cleanup wine processes: {e}") + return False + + @staticmethod + def edit_binary_working_paths(modlist_ini, modlist_dir, modlist_sdcard, steam_library, basegame_sdcard): + """ + Edit binary and working directory paths in ModOrganizer.ini + Returns True on success, False on failure + """ + if not os.path.isfile(modlist_ini): + logger.error(f"ModOrganizer.ini not found at {modlist_ini}") + return False + + try: + # Read the file + with open(modlist_ini, 'r', encoding='utf-8', errors='ignore') as f: + content = f.readlines() + + modified_content = [] + found_skse = False + + # First pass to identify SKSE/F4SE launcher entries + skse_lines = [] + for i, line in enumerate(content): + if re.search(r'skse64_loader\.exe|f4se_loader\.exe', line): + skse_lines.append((i, line)) + found_skse = True + + if not found_skse: + logger.debug("No SKSE/F4SE launcher entries found") + return False + + # Process each SKSE/F4SE entry + for line_num, orig_line in skse_lines: + # Split the line into key and value + if '=' not in orig_line: + continue + + binary_num, skse_loc = orig_line.split('=', 1) + + # Set drive letter based on whether using SD card + if modlist_sdcard: + drive_letter = " = D:" + else: + drive_letter = " = Z:" + + # Determine the working directory key + just_num = binary_num.split('\\')[0] + bin_path_start = binary_num.strip().replace('\\', '\\\\') + path_start = f"{just_num}\\\\workingDirectory".replace('\\', '\\\\') + + # Process the path based on its type + if "mods" in orig_line: + # mods path type + if modlist_sdcard: + path_middle = WineUtils._strip_sdcard_path(modlist_dir) + else: + path_middle = modlist_dir + + path_end = re.sub(r'.*/mods', '/mods', skse_loc.split('/')[0]) + bin_path_end = re.sub(r'.*/mods', '/mods', skse_loc) + + elif any(term in orig_line for term in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]): + # Stock Game or Game Root type + if modlist_sdcard: + path_middle = WineUtils._strip_sdcard_path(modlist_dir) + else: + path_middle = modlist_dir + + # Determine the specific stock folder type + if "Stock Game" in orig_line: + dir_type = "stockgame" + path_end = re.sub(r'.*/Stock Game', '/Stock Game', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/Stock Game', '/Stock Game', skse_loc) + elif "Game Root" in orig_line: + dir_type = "gameroot" + path_end = re.sub(r'.*/Game Root', '/Game Root', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/Game Root', '/Game Root', skse_loc) + elif "STOCK GAME" in orig_line: + dir_type = "STOCKGAME" + path_end = re.sub(r'.*/STOCK GAME', '/STOCK GAME', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/STOCK GAME', '/STOCK GAME', skse_loc) + elif "Stock Folder" in orig_line: + dir_type = "stockfolder" + path_end = re.sub(r'.*/Stock Folder', '/Stock Folder', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/Stock Folder', '/Stock Folder', skse_loc) + elif "Skyrim Stock" in orig_line: + dir_type = "skyrimstock" + path_end = re.sub(r'.*/Skyrim Stock', '/Skyrim Stock', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/Skyrim Stock', '/Skyrim Stock', skse_loc) + elif "Stock Game Folder" in orig_line: + dir_type = "stockgamefolder" + path_end = re.sub(r'.*/Stock Game Folder', '/Stock Game Folder', skse_loc) + bin_path_end = path_end + elif "root/Skyrim Special Edition" in orig_line: + dir_type = "rootskyrimse" + path_end = '/' + skse_loc.lstrip() + bin_path_end = path_end + else: + logger.error(f"Unknown stock game type in line: {orig_line}") + continue + + elif "steamapps" in orig_line: + # Steam apps path type + if basegame_sdcard: + path_middle = WineUtils._strip_sdcard_path(steam_library) + drive_letter = " = D:" + else: + path_middle = steam_library.split('steamapps')[0] + + path_end = re.sub(r'.*/steamapps', '/steamapps', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/steamapps', '/steamapps', skse_loc) + + else: + logger.warning(f"No matching pattern found in the path: {orig_line}") + continue + + # Combine paths + full_bin_path = f"{bin_path_start}{drive_letter}{path_middle}{bin_path_end}" + full_path = f"{path_start}{drive_letter}{path_middle}{path_end}" + + # Replace forward slashes with double backslashes for Windows paths + new_path = full_path.replace('/', '\\\\') + + # Update the content with new paths + for i, line in enumerate(content): + if line.startswith(bin_path_start): + content[i] = f"{full_bin_path}\n" + elif line.startswith(path_start): + content[i] = f"{new_path}\n" + + # Write back the modified content + with open(modlist_ini, 'w', encoding='utf-8') as f: + f.writelines(content) + + logger.debug("Updated binary and working directory paths successfully") + return True + + except Exception as e: + logger.error(f"Error editing binary working paths: {e}") + return False + + @staticmethod + def _strip_sdcard_path(path): + """ + Strip /run/media/deck/UUID from SD card paths + Internal helper method + """ + if path.startswith("/run/media/deck/"): + parts = path.split("/", 5) + if len(parts) >= 6: + return "/" + parts[5] + return path + + @staticmethod + def all_owned_by_user(path): + """ + Returns True if all files and directories under 'path' are owned by the current user. + """ + uid = os.getuid() + gid = os.getgid() + for root, dirs, files in os.walk(path): + for name in dirs + files: + full_path = os.path.join(root, name) + try: + stat = os.stat(full_path) + if stat.st_uid != uid or stat.st_gid != gid: + return False + except Exception: + return False + return True + + @staticmethod + def chown_chmod_modlist_dir(modlist_dir): + """ + Change ownership and permissions of modlist directory + Returns True on success, False on failure + """ + if WineUtils.all_owned_by_user(modlist_dir): + logger.info(f"All files in {modlist_dir} are already owned by the current user. Skipping sudo chown/chmod.") + return True + logger.warn("Changing Ownership and Permissions of modlist directory (may require sudo password)") + + try: + user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip() + group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip() + + logger.debug(f"User is {user} and Group is {group}") + + # Change ownership + result1 = subprocess.run( + f"sudo chown -R {user}:{group} \"{modlist_dir}\"", + shell=True, + capture_output=True, + text=True + ) + + # Change permissions + result2 = subprocess.run( + f"sudo chmod -R 755 \"{modlist_dir}\"", + shell=True, + capture_output=True, + text=True + ) + + if result1.returncode != 0 or result2.returncode != 0: + logger.error("Failed to change ownership/permissions") + logger.error(f"chown output: {result1.stderr}") + logger.error(f"chmod output: {result2.stderr}") + return False + + return True + + except Exception as e: + logger.error(f"Error changing ownership and permissions: {e}") + return False + + @staticmethod + def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full): + """ + Create DXVK file in the modlist directory + """ + try: + # Construct the path to the game directory + game_dir = os.path.join(steam_library, game_var_full) + + # Create the DXVK file + dxvk_file = os.path.join(modlist_dir, "DXVK") + with open(dxvk_file, 'w') as f: + f.write(game_dir) + + logger.debug(f"Created DXVK file at {dxvk_file} pointing to {game_dir}") + return True + except Exception as e: + logger.error(f"Error creating DXVK file: {e}") + return False + + @staticmethod + def small_additional_tasks(modlist_dir, compat_data_path): + """ + Perform small additional tasks like deleting unsupported plugins + Returns True on success, False on failure + """ + try: + # Delete MO2 plugins that don't work via Proton + file_to_delete = os.path.join(modlist_dir, "plugins/FixGameRegKey.py") + if os.path.exists(file_to_delete): + os.remove(file_to_delete) + logger.debug(f"File deleted: {file_to_delete}") + + # Download Font to support Bethini + if compat_data_path and os.path.isdir(compat_data_path): + font_path = os.path.join(compat_data_path, "pfx/drive_c/windows/Fonts/seguisym.ttf") + font_dir = os.path.dirname(font_path) + + # Ensure the directory exists + os.makedirs(font_dir, exist_ok=True) + + # Download the font + font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf" + subprocess.run( + f"wget {font_url} -q -nc -O \"{font_path}\"", + shell=True, + check=True + ) + logger.debug(f"Downloaded font to: {font_path}") + + return True + + except Exception as e: + logger.error(f"Error performing additional tasks: {e}") + return False + + @staticmethod + def modlist_specific_steps(modlist, appid): + """ + Perform modlist-specific steps + Returns True on success, False on failure + """ + try: + # Define modlist-specific configurations + modlist_configs = { + "wildlander": ["dotnet48", "dotnet472", "vcrun2019"], + "septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"], + "masterstroke": ["dotnet48", "dotnet472"], + "diablo": ["dotnet48", "dotnet472"], + "living_skyrim": ["dotnet48", "dotnet472", "dotnet462"], + "nolvus": ["dotnet8"] + } + + modlist_lower = modlist.lower().replace(" ", "") + + # Check for wildlander special case + if "wildlander" in modlist_lower: + logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!") + # Implementation for wildlander-specific steps + return True + + # Check for other modlists + for pattern, components in modlist_configs.items(): + if re.search(pattern.replace("|", "|.*"), modlist_lower): + logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!") + + # Install components + for component in components: + if component == "dotnet8": + # Special handling for .NET 8 + logger.info("Downloading .NET 8 Runtime") + # Implementation for .NET 8 installation + pass + else: + # Standard component installation + logger.info(f"Installing {component}...") + # Implementation for standard component installation + pass + + # Set Windows 10 prefix + # Implementation for setting Windows 10 prefix + + return True + + # No specific steps for this modlist + logger.debug(f"No specific steps needed for {modlist}") + return True + + except Exception as e: + logger.error(f"Error performing modlist-specific steps: {e}") + return False + + @staticmethod + def fnv_launch_options(game_var, compat_data_path, modlist): + """ + Set up Fallout New Vegas launch options + Returns True on success, False on failure + """ + if game_var != "Fallout New Vegas": + return True + + try: + appid_to_check = "22380" # Fallout New Vegas AppID + + for path in [ + os.path.expanduser("~/.local/share/Steam/steamapps/compatdata"), + os.path.expanduser("~/.steam/steam/steamapps/compatdata"), + os.path.expanduser("~/.steam/root/steamapps/compatdata") + ]: + compat_path = os.path.join(path, appid_to_check) + if os.path.exists(compat_path): + logger.warning(f"\nFor {modlist}, please add the following line to the Launch Options in Steam for your '{modlist}' entry:") + logger.info(f"\nSTEAM_COMPAT_DATA_PATH=\"{compat_path}\" %command%") + logger.warning("\nThis is essential for the modlist to load correctly.") + return True + + logger.error("Could not determine the compatdata path for Fallout New Vegas") + return False + + except Exception as e: + logger.error(f"Error setting FNV launch options: {e}") + return False + + @staticmethod + def get_proton_version(compat_data_path): + """ + Detect the Proton version used by a Steam game/shortcut + + Args: + compat_data_path (str): Path to the compatibility data directory + + Returns: + str: Detected Proton version or 'Unknown' if not found + """ + logger.info("Detecting Proton version...") + + # Validate the compatdata path exists + if not os.path.isdir(compat_data_path): + logger.warning(f"Compatdata directory not found at '{compat_data_path}'") + return "Unknown" + + # First try to get Proton version from the registry + system_reg_path = os.path.join(compat_data_path, "pfx", "system.reg") + if os.path.isfile(system_reg_path): + try: + with open(system_reg_path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + + # Use regex to find SteamClientProtonVersion entry + match = re.search(r'"SteamClientProtonVersion"="([^"]+)"', content) + if match: + version = match.group(1).strip() + # Keep GE versions as is, otherwise prefix with "Proton" + if "GE" in version: + proton_ver = version + else: + proton_ver = f"Proton {version}" + + logger.debug(f"Detected Proton version from registry: {proton_ver}") + return proton_ver + except Exception as e: + logger.debug(f"Error reading system.reg: {e}") + + # Fallback to config_info if registry method fails + config_info_path = os.path.join(compat_data_path, "config_info") + if os.path.isfile(config_info_path): + try: + with open(config_info_path, "r") as f: + config_ver = f.readline().strip() + + if config_ver: + # Keep GE versions as is, otherwise prefix with "Proton" + if "GE" in config_ver: + proton_ver = config_ver + else: + proton_ver = f"Proton {config_ver}" + + logger.debug(f"Detected Proton version from config_info: {proton_ver}") + return proton_ver + except Exception as e: + logger.debug(f"Error reading config_info: {e}") + + logger.warning("Could not detect Proton version") + return "Unknown" + + @staticmethod + def update_executables(modlist_ini, modlist_dir, modlist_sdcard, steam_library, basegame_sdcard): + """ + Update executable paths in ModOrganizer.ini + """ + logger.info("Updating executable paths in ModOrganizer.ini...") + + try: + # Find SKSE or F4SE loader entries + with open(modlist_ini, 'r') as f: + lines = f.readlines() + + # Process each line + for i, line in enumerate(lines): + if "skse64_loader.exe" in line or "f4se_loader.exe" in line: + # Extract the binary path + binary_path = line.strip().split('=', 1)[1] if '=' in line else "" + + # Determine drive letter + drive_letter = "D:" if modlist_sdcard else "Z:" + + # Extract binary number + binary_num = line.strip().split('=', 1)[0] if '=' in line else "" + + # Find the equivalent workingDirectory + justnum = binary_num.split('\\')[0] if '\\' in binary_num else binary_num + bin_path_start = binary_num.replace('\\', '\\\\') + path_start = f"{justnum}\\workingDirectory".replace('\\', '\\\\') + + # Determine path type and construct new paths + 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] + else: + path_middle = modlist_dir + + path_end = '/' + '/'.join(binary_path.split('/mods/', 1)[1].split('/')[:-1]) if '/mods/' in binary_path else "" + bin_path_end = '/' + '/'.join(binary_path.split('/mods/', 1)[1].split('/')) if '/mods/' in binary_path else "" + + 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] + else: + path_middle = modlist_dir + + # Determine directory type + if "Stock Game" in binary_path: + dir_type = "stockgame" + path_end = '/' + '/'.join(binary_path.split('/Stock Game/', 1)[1].split('/')[:-1]) if '/Stock Game/' in binary_path else "" + bin_path_end = '/' + '/'.join(binary_path.split('/Stock Game/', 1)[1].split('/')) if '/Stock Game/' in binary_path else "" + elif "Game Root" in binary_path: + dir_type = "gameroot" + path_end = '/' + '/'.join(binary_path.split('/Game Root/', 1)[1].split('/')[:-1]) if '/Game Root/' in binary_path else "" + bin_path_end = '/' + '/'.join(binary_path.split('/Game Root/', 1)[1].split('/')) if '/Game Root/' in binary_path else "" + elif "STOCK GAME" in binary_path: + dir_type = "STOCKGAME" + path_end = '/' + '/'.join(binary_path.split('/STOCK GAME/', 1)[1].split('/')[:-1]) if '/STOCK GAME/' in binary_path else "" + bin_path_end = '/' + '/'.join(binary_path.split('/STOCK GAME/', 1)[1].split('/')) if '/STOCK GAME/' in binary_path else "" + elif "Stock Folder" in binary_path: + dir_type = "stockfolder" + path_end = '/' + '/'.join(binary_path.split('/Stock Folder/', 1)[1].split('/')[:-1]) if '/Stock Folder/' in binary_path else "" + bin_path_end = '/' + '/'.join(binary_path.split('/Stock Folder/', 1)[1].split('/')) if '/Stock Folder/' in binary_path else "" + elif "Skyrim Stock" in binary_path: + dir_type = "skyrimstock" + path_end = '/' + '/'.join(binary_path.split('/Skyrim Stock/', 1)[1].split('/')[:-1]) if '/Skyrim Stock/' in binary_path else "" + bin_path_end = '/' + '/'.join(binary_path.split('/Skyrim Stock/', 1)[1].split('/')) if '/Skyrim Stock/' in binary_path else "" + elif "Stock Game Folder" in binary_path: + dir_type = "stockgamefolder" + path_end = '/' + '/'.join(binary_path.split('/Stock Game Folder/', 1)[1].split('/')) if '/Stock Game Folder/' in binary_path else "" + elif "root/Skyrim Special Edition" in binary_path: + dir_type = "rootskyrimse" + path_end = '/' + binary_path.split('root/Skyrim Special Edition', 1)[1] if 'root/Skyrim Special Edition' in binary_path else "" + bin_path_end = '/' + binary_path.split('root/Skyrim Special Edition', 1)[1] if 'root/Skyrim Special Edition' in binary_path else "" + + 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 + drive_letter = "D:" + else: + path_middle = steam_library.split('steamapps', 1)[0] if 'steamapps' in steam_library else steam_library + + path_end = '/' + '/'.join(binary_path.split('/steamapps/', 1)[1].split('/')[:-1]) if '/steamapps/' in binary_path else "" + bin_path_end = '/' + '/'.join(binary_path.split('/steamapps/', 1)[1].split('/')) if '/steamapps/' in binary_path else "" + + else: + logger.warning(f"No matching pattern found in the path: {binary_path}") + continue + + # Combine paths + full_bin_path = f"{bin_path_start}={drive_letter}{path_middle}{bin_path_end}" + full_path = f"{path_start}={drive_letter}{path_middle}{path_end}" + + # Replace forward slashes with double backslashes + new_path = full_path.replace('/', '\\\\') + + # Update the lines + lines[i] = f"{full_bin_path}\n" + + # Find and update the workingDirectory line + for j, working_line in enumerate(lines): + if working_line.startswith(path_start): + lines[j] = f"{new_path}\n" + break + + # Write the updated content back to the file + with open(modlist_ini, 'w') as f: + f.writelines(lines) + + logger.info("Executable paths updated successfully") + return True + except Exception as e: + logger.error(f"Error updating executable paths: {e}") + return False + + @staticmethod + def find_proton_binary(proton_version: str): + """ + Find the full path to the Proton binary given a version string (e.g., 'Proton 8.0', 'GE-Proton8-15'). + Searches standard Steam library locations. + Returns the path to the 'files/bin/wine' executable, or None if not found. + """ + # 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" + ] + # 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)"] + for base_path in steam_common_paths: + for name in proton9_candidates: + candidate = base_path / name / "files/bin/wine" + if candidate.is_file(): + return str(candidate) + # Fallback: any Proton 9* directory + for subdir in base_path.glob("Proton 9*"): + 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: + if not base_path.is_dir(): + continue + for pattern in version_patterns: + # Try direct match for Proton directory + proton_dir = base_path / pattern + wine_bin = proton_dir / "files/bin/wine" + if wine_bin.is_file(): + return str(wine_bin) + # Try glob for GE/other variants + for subdir in base_path.glob(f"*{pattern}*"): + wine_bin = subdir / "files/bin/wine" + if wine_bin.is_file(): + return str(wine_bin) + # 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(): + logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to 'Proton - Experimental'.") + return str(wine_bin) + return None + + @staticmethod + def get_proton_paths(appid: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Get the Proton paths for a given AppID. + + Args: + appid (str): The Steam AppID to get paths for + + Returns: + tuple: (compatdata_path, proton_path, wine_bin) or (None, None, None) if not found + """ + logger.info(f"Getting Proton paths for AppID {appid}") + + # Find compatdata path + possible_compat_bases = [ + Path.home() / ".steam/steam/steamapps/compatdata", + Path.home() / ".local/share/Steam/steamapps/compatdata" + ] + + compatdata_path = None + for base_path in possible_compat_bases: + potential_compat_path = base_path / appid + if potential_compat_path.is_dir(): + compatdata_path = str(potential_compat_path) + logger.debug(f"Found compatdata directory: {compatdata_path}") + break + + if not compatdata_path: + logger.error(f"Could not find compatdata directory for AppID {appid}") + return None, None, None + + # Get Proton version + proton_version = WineUtils.get_proton_version(compatdata_path) + if proton_version == "Unknown": + logger.error(f"Could not determine Proton version for AppID {appid}") + return None, None, None + + # Find Proton binary + wine_bin = WineUtils.find_proton_binary(proton_version) + if not wine_bin: + logger.error(f"Could not find Proton binary for version {proton_version}") + return None, None, None + + # Get Proton path (parent of wine binary) + proton_path = str(Path(wine_bin).parent.parent) + logger.debug(f"Found Proton path: {proton_path}") + + return compatdata_path, proton_path, wine_bin \ No newline at end of file diff --git a/jackify/backend/models/__init__.py b/jackify/backend/models/__init__.py new file mode 100644 index 0000000..8694e91 --- /dev/null +++ b/jackify/backend/models/__init__.py @@ -0,0 +1,5 @@ +""" +Backend Data Models + +Data structures for passing context between frontend and backend. +""" \ No newline at end of file diff --git a/jackify/backend/models/configuration.py b/jackify/backend/models/configuration.py new file mode 100644 index 0000000..fcacb0b --- /dev/null +++ b/jackify/backend/models/configuration.py @@ -0,0 +1,79 @@ +""" +Configuration Data Models + +Data structures for configuration context between frontend and backend. +""" + +from pathlib import Path +from typing import Optional, Dict, Any +from dataclasses import dataclass + + +@dataclass +class ConfigurationContext: + """Context object for modlist configuration operations.""" + modlist_name: str + install_dir: Path + mo2_exe_path: Optional[Path] = None + resolution: Optional[str] = None + download_dir: Optional[Path] = None + nexus_api_key: Optional[str] = None + modlist_value: Optional[str] = None + modlist_source: Optional[str] = None + skip_confirmation: bool = False + + def __post_init__(self): + """Convert string paths to Path objects.""" + if isinstance(self.install_dir, str): + self.install_dir = Path(self.install_dir) + if isinstance(self.download_dir, str): + self.download_dir = Path(self.download_dir) + if isinstance(self.mo2_exe_path, str): + self.mo2_exe_path = Path(self.mo2_exe_path) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for legacy compatibility.""" + return { + 'name': self.modlist_name, + 'path': str(self.install_dir), + 'mo2_exe_path': str(self.mo2_exe_path) if self.mo2_exe_path else None, + 'resolution': self.resolution, + 'download_dir': str(self.download_dir) if self.download_dir else None, + 'nexus_api_key': self.nexus_api_key, + 'modlist_value': self.modlist_value, + 'modlist_source': self.modlist_source, + 'skip_confirmation': self.skip_confirmation, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ConfigurationContext': + """Create from dictionary for legacy compatibility.""" + return cls( + modlist_name=data.get('name', data.get('modlist_name', '')), + install_dir=Path(data.get('path', data.get('install_dir', ''))), + mo2_exe_path=Path(data['mo2_exe_path']) if data.get('mo2_exe_path') else None, + resolution=data.get('resolution'), + download_dir=Path(data['download_dir']) if data.get('download_dir') else None, + nexus_api_key=data.get('nexus_api_key'), + modlist_value=data.get('modlist_value'), + modlist_source=data.get('modlist_source'), + skip_confirmation=data.get('skip_confirmation', False), + ) + + +@dataclass +class SystemInfo: + """System information context.""" + is_steamdeck: bool + steam_root: Optional[Path] = None + steam_user_id: Optional[str] = None + proton_version: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'is_steamdeck': self.is_steamdeck, + 'steam_root': str(self.steam_root) if self.steam_root else None, + 'steam_user_id': self.steam_user_id, + 'proton_version': self.proton_version, + } \ No newline at end of file diff --git a/jackify/backend/models/modlist.py b/jackify/backend/models/modlist.py new file mode 100644 index 0000000..695f51d --- /dev/null +++ b/jackify/backend/models/modlist.py @@ -0,0 +1,105 @@ +""" +Modlist Data Models + +Data structures for passing modlist context between frontend and backend. +""" + +from pathlib import Path +from typing import Optional, Dict, Any +from dataclasses import dataclass + + +@dataclass +class ModlistContext: + """Context object for modlist operations.""" + name: str + install_dir: Path + download_dir: Path + game_type: str + nexus_api_key: str + modlist_value: Optional[str] = None + modlist_source: Optional[str] = None # 'identifier' or 'file' + resolution: Optional[str] = None + mo2_exe_path: Optional[Path] = None + skip_confirmation: bool = False + engine_installed: bool = False # True if installed via jackify-engine + + def __post_init__(self): + """Convert string paths to Path objects.""" + if isinstance(self.install_dir, str): + self.install_dir = Path(self.install_dir) + if isinstance(self.download_dir, str): + self.download_dir = Path(self.download_dir) + if isinstance(self.mo2_exe_path, str): + self.mo2_exe_path = Path(self.mo2_exe_path) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for legacy compatibility.""" + return { + 'modlist_name': self.name, + 'install_dir': str(self.install_dir), + 'download_dir': str(self.download_dir), + 'game_type': self.game_type, + 'nexus_api_key': self.nexus_api_key, + 'modlist_value': self.modlist_value, + 'modlist_source': self.modlist_source, + 'resolution': self.resolution, + 'mo2_exe_path': str(self.mo2_exe_path) if self.mo2_exe_path else None, + 'skip_confirmation': self.skip_confirmation, + 'engine_installed': self.engine_installed, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ModlistContext': + """Create from dictionary for legacy compatibility.""" + return cls( + name=data.get('modlist_name', ''), + install_dir=Path(data.get('install_dir', '')), + download_dir=Path(data.get('download_dir', '')), + game_type=data.get('game_type', ''), + nexus_api_key=data.get('nexus_api_key', ''), + modlist_value=data.get('modlist_value'), + modlist_source=data.get('modlist_source'), + resolution=data.get('resolution'), + mo2_exe_path=Path(data['mo2_exe_path']) if data.get('mo2_exe_path') else None, + skip_confirmation=data.get('skip_confirmation', False), + engine_installed=data.get('engine_installed', False), + ) + + +@dataclass +class ModlistInfo: + """Information about a modlist from the engine.""" + id: str + name: str + game: str + description: Optional[str] = None + version: Optional[str] = None + size: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + result = { + 'id': self.id, + 'name': self.name, + 'game': self.game, + 'description': self.description, + 'version': self.version, + 'size': self.size, + } + + # Include any dynamically added attributes + if hasattr(self, 'machine_url'): + result['machine_url'] = self.machine_url + if hasattr(self, 'download_size'): + result['download_size'] = self.download_size + if hasattr(self, 'install_size'): + result['install_size'] = self.install_size + if hasattr(self, 'total_size'): + result['total_size'] = self.total_size + if hasattr(self, 'status_down'): + result['status_down'] = self.status_down + if hasattr(self, 'status_nsfw'): + result['status_nsfw'] = self.status_nsfw + + return result \ No newline at end of file diff --git a/jackify/backend/services/__init__.py b/jackify/backend/services/__init__.py new file mode 100644 index 0000000..88da8c0 --- /dev/null +++ b/jackify/backend/services/__init__.py @@ -0,0 +1,5 @@ +""" +Backend Services + +High-level service classes that orchestrate handlers. +""" \ No newline at end of file diff --git a/jackify/backend/services/api_key_service.py b/jackify/backend/services/api_key_service.py new file mode 100644 index 0000000..560df52 --- /dev/null +++ b/jackify/backend/services/api_key_service.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +API Key Service Module +Centralized service for managing Nexus API keys across CLI and GUI frontends +""" + +import logging +from typing import Optional, Tuple +from ..handlers.config_handler import ConfigHandler + +# Initialize logger +logger = logging.getLogger(__name__) + + +class APIKeyService: + """ + Centralized service for managing Nexus API keys + Handles saving, loading, and validation of API keys + """ + + def __init__(self): + """Initialize the API key service""" + self.config_handler = ConfigHandler() + logger.debug("APIKeyService initialized") + + def save_api_key(self, api_key: str) -> bool: + """ + Save an API key to configuration + + Args: + api_key (str): The API key to save + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + # Validate API key format (basic check) + if not self._validate_api_key_format(api_key): + logger.warning("Invalid API key format provided") + return False + + # Check if we can write to config directory + import os + config_dir = os.path.dirname(self.config_handler.config_file) + if not os.path.exists(config_dir): + try: + os.makedirs(config_dir, exist_ok=True) + logger.debug(f"Created config directory: {config_dir}") + except PermissionError: + logger.error(f"Permission denied creating config directory: {config_dir}") + return False + except Exception as dir_error: + logger.error(f"Error creating config directory: {dir_error}") + return False + + # Check write permissions + if not os.access(config_dir, os.W_OK): + logger.error(f"No write permission for config directory: {config_dir}") + return False + + success = self.config_handler.save_api_key(api_key) + if success: + logger.info("API key saved successfully") + # Verify the save worked by reading it back + saved_key = self.config_handler.get_api_key() + if saved_key != api_key: + logger.error("API key save verification failed - key mismatch") + return False + else: + logger.error("Failed to save API key via config handler") + + return success + except Exception as e: + logger.error(f"Error in save_api_key: {e}") + return False + + def get_saved_api_key(self) -> Optional[str]: + """ + Retrieve the saved API key from configuration + + Returns: + str: The decoded API key or None if not saved + """ + try: + api_key = self.config_handler.get_api_key() + if api_key: + logger.debug("Retrieved saved API key") + else: + logger.debug("No saved API key found") + return api_key + except Exception as e: + logger.error(f"Error retrieving API key: {e}") + return None + + def has_saved_api_key(self) -> bool: + """ + Check if an API key is saved in configuration + + Returns: + bool: True if API key exists, False otherwise + """ + try: + return self.config_handler.has_saved_api_key() + except Exception as e: + logger.error(f"Error checking for saved API key: {e}") + return False + + def clear_saved_api_key(self) -> bool: + """ + Clear the saved API key from configuration + + Returns: + bool: True if cleared successfully, False otherwise + """ + try: + success = self.config_handler.clear_api_key() + if success: + logger.info("API key cleared successfully") + else: + logger.error("Failed to clear API key") + return success + except Exception as e: + logger.error(f"Error clearing API key: {e}") + return False + + def get_api_key_for_session(self, provided_key: Optional[str] = None, + use_saved: bool = True) -> Tuple[Optional[str], str]: + """ + Get the API key to use for a session, with priority logic + + Args: + provided_key (str, optional): API key provided by user for this session + use_saved (bool): Whether to use saved API key if no key provided + + Returns: + tuple: (api_key, source) where source is 'provided', 'saved', or 'none' + """ + try: + # Priority 1: Use provided key if given + if provided_key and self._validate_api_key_format(provided_key): + logger.debug("Using provided API key for session") + return provided_key, 'provided' + + # Priority 2: Use saved key if enabled and available + if use_saved and self.has_saved_api_key(): + saved_key = self.get_saved_api_key() + if saved_key: + logger.debug("Using saved API key for session") + return saved_key, 'saved' + + # No valid API key available + logger.debug("No valid API key available for session") + return None, 'none' + + except Exception as e: + logger.error(f"Error getting API key for session: {e}") + return None, 'none' + + def _validate_api_key_format(self, api_key: str) -> bool: + """ + Validate basic API key format + + Args: + api_key (str): API key to validate + + Returns: + bool: True if format appears valid, False otherwise + """ + if not api_key or not isinstance(api_key, str): + return False + + # Basic validation: should be alphanumeric string of reasonable length + # Nexus API keys are typically 32+ characters, alphanumeric with some special chars + api_key = api_key.strip() + if len(api_key) < 10: # Too short to be valid + return False + + if len(api_key) > 200: # Unreasonably long + return False + + # Should contain some alphanumeric characters + if not any(c.isalnum() for c in api_key): + return False + + return True + + def get_api_key_display(self, api_key: str, mask_after_chars: int = 4) -> str: + """ + Get a masked version of the API key for display purposes + + Args: + api_key (str): The API key to mask + mask_after_chars (int): Number of characters to show before masking + + Returns: + str: Masked API key for display + """ + if not api_key: + return "" + + if len(api_key) <= mask_after_chars: + return "*" * len(api_key) + + visible_part = api_key[:mask_after_chars] + masked_part = "*" * (len(api_key) - mask_after_chars) + return visible_part + masked_part + + def validate_api_key_works(self, api_key: str) -> Tuple[bool, str]: + """ + Validate that an API key actually works with Nexus API + Tests the key against the Nexus Mods validation endpoint + + Args: + api_key (str): API key to validate + + Returns: + tuple: (is_valid, message) + """ + # First check format + if not self._validate_api_key_format(api_key): + return False, "API key format is invalid" + + try: + import requests + import time + + # Nexus API validation endpoint + url = "https://api.nexusmods.com/v1/users/validate.json" + headers = { + 'apikey': api_key, + 'User-Agent': 'Jackify/1.0' # Required by Nexus API + } + + # Set a reasonable timeout + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + # API key is valid + try: + data = response.json() + username = data.get('name', 'Unknown') + # Don't log the actual API key - use masking + masked_key = self.get_api_key_display(api_key) + logger.info(f"API key validation successful for user: {username} (key: {masked_key})") + return True, f"API key valid for user: {username}" + except Exception as json_error: + logger.warning(f"API key valid but couldn't parse user info: {json_error}") + return True, "API key is valid" + elif response.status_code == 401: + # Invalid API key + logger.warning("API key validation failed: Invalid key") + return False, "Invalid API key" + elif response.status_code == 429: + # Rate limited + logger.warning("API key validation rate limited") + return False, "Rate limited - try again later" + else: + # Other error + logger.warning(f"API key validation failed with status {response.status_code}") + return False, f"Validation failed (HTTP {response.status_code})" + + except requests.exceptions.Timeout: + logger.warning("API key validation timed out") + return False, "Validation timed out - check connection" + except requests.exceptions.ConnectionError: + logger.warning("API key validation connection error") + return False, "Connection error - check internet" + except Exception as e: + logger.error(f"API key validation error: {e}") + return False, f"Validation error: {str(e)}" \ No newline at end of file diff --git a/jackify/backend/services/automated_prefix_service.py b/jackify/backend/services/automated_prefix_service.py new file mode 100644 index 0000000..6d0501a --- /dev/null +++ b/jackify/backend/services/automated_prefix_service.py @@ -0,0 +1,2739 @@ +#!/usr/bin/env python3 +""" +Automated Prefix Creation Service + +This service implements the automated Proton prefix creation workflow +that eliminates the need for manual steps in Jackify. +""" +import os +import sys +import time +import subprocess +import logging +from pathlib import Path +from typing import Optional, Tuple, Union, List, Dict +import vdf + +logger = logging.getLogger(__name__) + +def debug_print(message): + """Print debug message only if debug mode is enabled""" + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if config_handler.get('debug_mode', False): + print(message) + +class AutomatedPrefixService: + """ + Service for automated Proton prefix creation using temporary batch files + and direct Proton wrapper integration. + """ + + def __init__(self): + self.scripts_dir = Path.home() / "Jackify/scripts" + self.scripts_dir.mkdir(parents=True, exist_ok=True) + # Use shared timing for consistency across services + + def _get_progress_timestamp(self): + """Get consistent progress timestamp""" + from jackify.shared.timing import get_timestamp + return get_timestamp() + + + def create_shortcut_with_native_service(self, shortcut_name: str, exe_path: str, + modlist_install_dir: str, custom_launch_options: str = None) -> Tuple[bool, Optional[int]]: + """ + Create a Steam shortcut using the native Steam service (no STL). + + Args: + shortcut_name: Name for the shortcut + exe_path: Path to the executable + modlist_install_dir: Directory where the modlist is installed + custom_launch_options: Pre-generated launch options (overrides default generation) + + Returns: + (success, unsigned_app_id) + """ + logger.info(f"Creating shortcut with native service: {shortcut_name}") + + try: + from ..services.native_steam_service import NativeSteamService + + # Initialize native Steam service + steam_service = NativeSteamService() + + # Use custom launch options if provided, otherwise generate default + if custom_launch_options: + launch_options = custom_launch_options + logger.info(f"Using pre-generated launch options: {launch_options}") + else: + # Generate STEAM_COMPAT_MOUNTS launch option for compatibility + launch_options = "%command%" + try: + from ..handlers.path_handler import PathHandler + path_handler = PathHandler() + + all_libs = path_handler.get_all_steam_library_paths() + main_steam_lib_path_obj = path_handler.find_steam_library() + if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": + main_steam_lib_path = main_steam_lib_path_obj.parent.parent + + filtered_libs = [lib for lib in all_libs if str(lib) != str(main_steam_lib_path)] + if filtered_libs: + mount_paths = ":".join(str(lib) for lib in filtered_libs) + launch_options = f'STEAM_COMPAT_MOUNTS="{mount_paths}" %command%' + logger.info(f"Generated launch options with mounts: {launch_options}") + except Exception as e: + logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}") + launch_options = "%command%" + + # Create shortcut with Proton using native service + success, app_id = steam_service.create_shortcut_with_proton( + app_name=shortcut_name, + exe_path=exe_path, + start_dir=modlist_install_dir, + launch_options=launch_options, + tags=["Jackify"], + proton_version="proton_experimental" + ) + + if success and app_id: + logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}") + return True, app_id + else: + logger.error("❌ Native Steam service failed to create shortcut") + return False, None + + except Exception as e: + logger.error(f"Error creating shortcut with native service: {e}") + return False, None + + def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]: + """ + Generate launch options for FNV/Enderal games that require vanilla compatdata. + + Args: + special_game_type: "fnv" or "enderal" + modlist_install_dir: Directory where the modlist is installed + + Returns: + Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed + """ + if not special_game_type or special_game_type not in ["fnv", "enderal"]: + return None + + logger.info(f"Generating {special_game_type.upper()} launch options") + + # Map game types to AppIDs + appid_map = {"fnv": "22380", "enderal": "976620"} + appid = appid_map[special_game_type] + + # Find vanilla game compatdata + from ..handlers.path_handler import PathHandler + compatdata_path = PathHandler.find_compat_data(appid) + if not compatdata_path: + logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})") + return None + + # Create STEAM_COMPAT_DATA_PATH string + compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"' + + # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist + compat_mounts_str = "" + try: + all_libs = PathHandler.get_all_steam_library_paths() + main_steam_lib_path_obj = PathHandler.find_steam_library() + if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": + main_steam_lib_path = main_steam_lib_path_obj.parent.parent + else: + main_steam_lib_path = main_steam_lib_path_obj + + mount_paths = [] + if main_steam_lib_path: + main_resolved = main_steam_lib_path.resolve() + for lib_path in all_libs: + if lib_path.resolve() != main_resolved: + mount_paths.append(str(lib_path.resolve())) + + if mount_paths: + mount_paths_str = ':'.join(mount_paths) + compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"' + logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}") + except Exception as e: + logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}") + + # Combine all launch options + launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip() + launch_options = ' '.join(launch_options.split()) # Clean up spacing + + logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}") + return launch_options + + def check_shortcut_proton_version(self, shortcut_name: str): + """ + Check if the shortcut has the Proton version set correctly. + + Args: + shortcut_name: Name of the shortcut to check + """ + # STL sets the compatibility tool in config.vdf, not shortcuts.vdf + # We know this works from manual testing, so just log that we're skipping this check + logger.info(f"Skipping Proton version check for '{shortcut_name}' - STL handles this correctly") + debug_print(f"[DEBUG] Skipping Proton version check for '{shortcut_name}' - STL handles this correctly") + + + def handle_existing_shortcut_conflict(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Union[bool, List[Dict]]: + """ + Check for existing shortcut with same name and path, prompt user if found. + + Args: + shortcut_name: Name of the shortcut to create + exe_path: Path to the executable + modlist_install_dir: Directory where the modlist is installed + + Returns: + True if we should proceed (no conflict or user chose to replace), False if user cancelled + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return True # No shortcuts file, no conflict + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + conflicts = [] + + # Look for shortcuts with the same name AND path + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + shortcut_exe = shortcut.get('Exe', '').strip('"') # Remove quotes + shortcut_startdir = shortcut.get('StartDir', '').strip('"') # Remove quotes + + # Check if name matches AND (exe path matches OR startdir matches) + # Use exact name match instead of partial match to avoid false positives + name_matches = shortcut_name == name + exe_matches = shortcut_exe == exe_path + startdir_matches = shortcut_startdir == modlist_install_dir + + if (name_matches and (exe_matches or startdir_matches)): + conflicts.append({ + 'index': i, + 'name': name, + 'exe': shortcut_exe, + 'startdir': shortcut_startdir + }) + + if conflicts: + logger.warning(f"Found {len(conflicts)} existing shortcut(s) with same name and path") + + # Log details about each conflict for debugging + for i, conflict in enumerate(conflicts): + logger.info(f"Conflict {i+1}: Name='{conflict['name']}', Exe='{conflict['exe']}', StartDir='{conflict['startdir']}'") + + # Return the conflict information so the frontend can handle it + return conflicts + else: + logger.debug("No conflicting shortcuts found") + return True + + except Exception as e: + logger.error(f"Error handling shortcut conflict: {e}") + return True # Proceed on error to avoid blocking + + def format_conflict_message(self, conflicts: List[Dict]) -> str: + """ + Format conflict information into a user-friendly message. + + Args: + conflicts: List of conflict dictionaries from handle_existing_shortcut_conflict + + Returns: + Formatted message for the user + """ + if not conflicts: + return "No conflicts found." + + message = f"Found {len(conflicts)} existing Steam shortcut(s) with the same name and path:\n\n" + + for i, conflict in enumerate(conflicts, 1): + message += f"{i}. **Name:** {conflict['name']}\n" + message += f" **Executable:** {conflict['exe']}\n" + message += f" **Start Directory:** {conflict['startdir']}\n\n" + + message += "**Options:**\n" + message += "• **Replace** - Remove the existing shortcut and create a new one\n" + message += "• **Cancel** - Keep the existing shortcut and stop the installation\n" + message += "• **Skip** - Continue without creating a Steam shortcut\n\n" + message += "The existing shortcut will be removed if you choose to replace it." + + return message + + def _get_shortcuts_path(self) -> Optional[Path]: + """Get the path to shortcuts.vdf using proper Steam path detection.""" + try: + from ..handlers.path_handler import PathHandler + + # Use find_steam_config_vdf to get the Steam config path, then derive the Steam root + config_vdf_path = PathHandler.find_steam_config_vdf() + if not config_vdf_path: + logger.error("Could not find Steam config.vdf") + return None + + # Get Steam root directory (config.vdf is in steam/config/config.vdf) + steam_path = config_vdf_path.parent.parent # steam/config/config.vdf -> steam + logger.debug(f"Detected Steam path: {steam_path}") + + # Find the userdata directory + userdata_dir = steam_path / "userdata" + if not userdata_dir.exists(): + 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") + 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(): + logger.error(f"shortcuts.vdf not found: {shortcuts_path}") + return None + + logger.info(f"Found shortcuts.vdf at: {shortcuts_path}") + return shortcuts_path + + except Exception as e: + logger.error(f"Error getting shortcuts path: {e}") + import traceback + logger.debug(f"Full traceback: {traceback.format_exc()}") + return None + + def create_temp_batch_file(self, shortcut_name: str) -> Optional[str]: + """ + Create a temporary batch file for silent prefix creation. + + Args: + shortcut_name: Name of the shortcut (used for unique filename) + + Returns: + Path to the created batch file, or None if failed + """ + try: + # Create a unique batch file name + timestamp = int(time.time()) + batch_filename = f"prefix_creation_{shortcut_name}_{timestamp}.bat" + batch_path = self.scripts_dir / batch_filename + + # Create the batch file content + batch_content = f"""@echo off +echo Creating prefix for {shortcut_name} +REM This will trigger Proton to create a prefix +echo Prefix creation in progress... +REM Wait a bit for Proton to initialize +timeout /t 5 /nobreak >nul +REM Try to run a simple command to ensure prefix is created +echo Prefix creation completed +exit""" + + with open(batch_path, 'w') as f: + f.write(batch_content) + + # Make it executable + os.chmod(str(batch_path), 0o755) + + logger.info(f"Created temporary batch file: {batch_path}") + return str(batch_path) + + except Exception as e: + logger.error(f"Failed to create batch file: {e}") + return None + + def find_proton_experimental(self) -> Optional[Path]: + """ + Find Proton Experimental installation. + + Returns: + Path to Proton Experimental, or None if not found + """ + proton_paths = [ + Path.home() / ".local/share/Steam/steamapps/common/Proton - Experimental", + Path.home() / ".steam/steam/steamapps/common/Proton - Experimental", + Path.home() / ".local/share/Steam/steamapps/common/Proton Experimental", + Path.home() / ".steam/steam/steamapps/common/Proton Experimental", + ] + + for path in proton_paths: + if path.exists(): + logger.info(f"Found Proton Experimental at: {path}") + return path + + logger.error("Proton Experimental not found") + return None + + + + def verify_shortcut_created(self, shortcut_name: str) -> Optional[int]: + """ + Verify the shortcut was created and get its AppID. + + Args: + shortcut_name: Name of the shortcut to look for + + Returns: + AppID if found, None otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return None + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Look for our shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name in name: + appid = shortcut.get('appid') + exe_path = shortcut.get('Exe', '') + + logger.info(f"Found shortcut: {name}") + logger.info(f" AppID: {appid}") + logger.info(f" Exe: {exe_path}") + logger.info(f" CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}") + + return appid + + logger.error(f"Shortcut '{shortcut_name}' not found") + return None + + except Exception as e: + logger.error(f"Error reading shortcuts: {e}") + return None + + def detect_actual_prefix_appid(self, initial_appid: int, shortcut_name: str) -> Optional[int]: + """ + After Steam restart, detect the actual prefix AppID that was created. + Use protontricks -l to find the actual positive AppID. + + Args: + initial_appid: The initial (negative) AppID from shortcuts.vdf + shortcut_name: Name of the shortcut for logging + + Returns: + The actual (positive) AppID of the created prefix, or None if not found + """ + try: + logger.info(f"Using protontricks -l to detect actual AppID for shortcut: {shortcut_name}") + + # Wait up to 30 seconds for the shortcut to appear in protontricks + for i in range(30): + try: + # Use the existing protontricks handler + from jackify.backend.handlers.protontricks_handler import ProtontricksHandler + protontricks_handler = ProtontricksHandler(steamdeck=False) + result = protontricks_handler.run_protontricks('-l') + + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + + # Look for our shortcut name in the protontricks output + for line in lines: + if shortcut_name in line and 'Non-Steam shortcut:' in line: + # Extract AppID from line like "Non-Steam shortcut: Tuxborn (3106560878)" + if '(' in line and ')' in line: + appid_str = line.split('(')[1].split(')')[0] + actual_appid = int(appid_str) + logger.info(f" Found shortcut in protontricks: {line.strip()}") + logger.info(f" Initial AppID: {initial_appid}") + logger.info(f" Actual AppID: {actual_appid}") + return actual_appid + + logger.debug(f"Shortcut '{shortcut_name}' not found in protontricks yet (attempt {i+1}/30)") + time.sleep(1) + + except subprocess.TimeoutExpired: + logger.warning(f"protontricks -l timed out on attempt {i+1}") + time.sleep(1) + except Exception as e: + 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") + return None + + except Exception as e: + logger.error(f"Error detecting actual prefix AppID: {e}") + return None + + def restart_steam(self) -> bool: + """ + Restart Steam using the robust service method. + + Returns: + True if successful, False otherwise + """ + try: + from .steam_restart_service import robust_steam_restart + return robust_steam_restart(progress_callback=None, timeout=60) + except Exception as e: + logger.error(f"Error restarting Steam: {e}") + return False + + def generate_steam_short_id(self, signed_appid: int) -> int: + """ + Convert signed 32-bit integer to unsigned 32-bit integer (same as STL's generateSteamShortID). + + Args: + signed_appid: Signed 32-bit integer AppID + + Returns: + Unsigned 32-bit integer AppID + """ + return signed_appid & 0xFFFFFFFF + + def launch_shortcut_to_trigger_prefix(self, initial_appid: int) -> bool: + """ + Launch the shortcut using rungameid to trigger prefix creation. + This follows the same pattern as the working test script. + + Args: + initial_appid: The initial (negative) AppID from shortcuts.vdf + + Returns: + True if successful, False otherwise + """ + try: + # Convert signed AppID to unsigned AppID (same as STL's generateSteamShortID) + unsigned_appid = self.generate_steam_short_id(initial_appid) + + # Calculate rungameid using the unsigned AppID + rungameid = (unsigned_appid << 32) | 0x02000000 + + logger.info(f"Launching shortcut with rungameid: {rungameid}") + debug_print(f"[DEBUG] Launching shortcut with rungameid: {rungameid}") + debug_print(f"[DEBUG] Initial signed AppID: {initial_appid}") + debug_print(f"[DEBUG] Unsigned AppID: {unsigned_appid}") + + # Launch using rungameid + cmd = ['steam', f'steam://rungameid/{rungameid}'] + debug_print(f"[DEBUG] About to run launch command: {' '.join(cmd)}") + + # Use subprocess.Popen to launch asynchronously (steam command returns immediately) + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + # Wait a moment for the process to start + time.sleep(1) + + # Check if the process is still running (steam command should exit quickly) + try: + return_code = process.poll() + if return_code is None: + # Process is still running, wait a bit more + time.sleep(2) + return_code = process.poll() + + debug_print(f"[DEBUG] Steam launch process return code: {return_code}") + + # Get any output + stdout, stderr = process.communicate(timeout=1) + if stdout: + debug_print(f"[DEBUG] Steam launch stdout: {stdout}") + if stderr: + debug_print(f"[DEBUG] Steam launch stderr: {stderr}") + + except subprocess.TimeoutExpired: + debug_print("[DEBUG] Steam launch process timed out, but that's OK") + process.kill() + + logger.info(f"Launch command executed: {' '.join(cmd)}") + + # Give it a moment for the shortcut to actually start + time.sleep(5) + + return True + + except subprocess.TimeoutExpired: + logger.error("Launch command timed out") + debug_print("[DEBUG] Launch command timed out") + return False + except Exception as e: + logger.error(f"Error launching shortcut: {e}") + debug_print(f"[DEBUG] Error launching shortcut: {e}") + return False + + def create_shortcut_directly(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool: + """ + Create a Steam shortcut directly by modifying shortcuts.vdf. + This is a fallback when STL fails. + + Args: + shortcut_name: Name for the shortcut + exe_path: Path to the executable + modlist_install_dir: Directory where the modlist is installed + + Returns: + True if successful, False otherwise + """ + try: + debug_print(f"[DEBUG] create_shortcut_directly called for '{shortcut_name}' - this is the fallback method") + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + debug_print("[DEBUG] No shortcuts path found") + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the next available index + next_index = str(len(shortcuts)) + + # Calculate AppID for the new shortcut (negative for non-Steam shortcuts) + import hashlib + app_name_bytes = shortcut_name.encode('utf-8') + exe_bytes = exe_path.encode('utf-8') + combined = app_name_bytes + exe_bytes + hash_value = int(hashlib.md5(combined).hexdigest()[:8], 16) + appid = -(hash_value & 0x7FFFFFFF) # Make it negative and within 32-bit range + + # Create new shortcut entry + new_shortcut = { + 'AppName': shortcut_name, + 'Exe': f'"{exe_path}"', + 'StartDir': f'"{modlist_install_dir}"', + 'appid': appid, + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'openvr': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0, + 'FlatpakAppID': '', + 'tags': {}, + 'CompatTool': 'proton_experimental', # Set Proton Experimental + 'IsInstalled': 1 # Make it appear in "Locally Installed" filter + } + + # Add the new shortcut + shortcuts[next_index] = new_shortcut + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Created shortcut directly: {shortcut_name}") + return True + + except Exception as e: + logger.error(f"Error creating shortcut directly: {e}") + return False + + def create_shortcut_directly_with_proton(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool: + """ + Create a Steam shortcut with temporary batch file for invisible prefix creation. + This uses the CRC32-based AppID calculation for predictable results. + + Args: + shortcut_name: Name for the shortcut + exe_path: Path to the final ModOrganizer.exe executable + modlist_install_dir: Directory where the modlist is installed + + Returns: + True if successful, False otherwise + """ + try: + debug_print(f"[DEBUG] create_shortcut_directly_with_proton called for '{shortcut_name}' - using temporary batch file approach") + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + debug_print("[DEBUG] No shortcuts path found") + return False + + # Calculate predictable AppID using CRC32 (based on FINAL exe_path) + from zlib import crc32 + combined_string = exe_path + shortcut_name + crc = crc32(combined_string.encode('utf-8')) + appid = -(crc & 0x7FFFFFFF) # Make it negative and within 32-bit range (like other shortcuts) + + debug_print(f"[DEBUG] Calculated AppID: {appid} from '{combined_string}'") + + # Create temporary batch file for invisible prefix creation + batch_content = """@echo off +echo Creating Proton prefix... +timeout /t 3 /nobreak >nul +echo Prefix creation complete. +""" + batch_path = Path.home() / "Jackify/temp_prefix_creation.bat" + batch_path.parent.mkdir(parents=True, exist_ok=True) + + with open(batch_path, 'w') as f: + f.write(batch_content) + + debug_print(f"[DEBUG] Created temporary batch file: {batch_path}") + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Check if shortcut already exists (idempotent) + found = False + new_shortcuts_list = [] + shortcuts_list = list(shortcuts.values()) + + for shortcut in shortcuts_list: + if shortcut.get('AppName') == shortcut_name: + debug_print(f"[DEBUG] Updating existing shortcut for '{shortcut_name}'") + # Update existing shortcut with temporary batch file + shortcut.update({ + 'Exe': f'"{batch_path}"', # Point to temporary batch file + 'StartDir': f'"{batch_path.parent}"', # Batch file directory + 'appid': appid, + 'LaunchOptions': '', # Empty like working shortcuts + 'tags': {}, # Empty tags like working shortcuts + 'CompatTool': 'proton_experimental' # Set Proton version directly in shortcut + }) + new_shortcuts_list.append(shortcut) + found = True + else: + new_shortcuts_list.append(shortcut) + + if not found: + debug_print(f"[DEBUG] Creating new shortcut for '{shortcut_name}'") + # Create new shortcut entry pointing to temporary batch file + new_shortcut = { + 'AppName': shortcut_name, + 'Exe': f'"{batch_path}"', # Point to temporary batch file + 'StartDir': f'"{batch_path.parent}"', # Batch file directory + 'appid': appid, + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', # Empty like working shortcuts + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'OpenVR': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0, + 'FlatpakAppID': '', + 'tags': {}, # Empty tags like working shortcuts + 'sortas': '', + 'CompatTool': 'proton_experimental' # Set Proton version directly in shortcut + } + new_shortcuts_list.append(new_shortcut) + + # Rebuild shortcuts dict with new order + shortcuts_data['shortcuts'] = {str(i): s for i, s in enumerate(new_shortcuts_list)} + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Created/updated shortcut with temporary batch file: {shortcut_name} with AppID {appid}") + debug_print(f"[DEBUG] Shortcut created/updated with temporary batch file, AppID {appid}") + + # Set Proton version in config.vdf BEFORE creating shortcut + if self.set_proton_version_for_shortcut(appid, 'proton_experimental'): + logger.info(f"Set Proton Experimental for shortcut {shortcut_name}") + return True + else: + logger.warning(f"Failed to set Proton version for shortcut {shortcut_name}") + return False + + except Exception as e: + logger.error(f"Error creating shortcut with temporary batch file: {e}") + return False + + def replace_shortcut_with_final_exe(self, shortcut_name: str, final_exe_path: str, modlist_install_dir: str) -> bool: + """ + Replace the temporary batch file shortcut with the final ModOrganizer.exe. + This should be called after the prefix has been created. + + Args: + shortcut_name: Name of the shortcut to update + final_exe_path: Path to the final ModOrganizer.exe executable + modlist_install_dir: Directory where the modlist is installed + + Returns: + True if successful, False otherwise + """ + try: + debug_print(f"[DEBUG] replace_shortcut_with_final_exe called for '{shortcut_name}'") + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + debug_print("[DEBUG] No shortcuts path found") + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find and update the shortcut + found = False + new_shortcuts_list = [] + shortcuts_list = list(shortcuts.values()) + + for shortcut in shortcuts_list: + if shortcut.get('AppName') == shortcut_name: + debug_print(f"[DEBUG] Replacing temporary batch file with final exe for '{shortcut_name}'") + # Update shortcut to point to final ModOrganizer.exe + shortcut.update({ + 'Exe': f'"{final_exe_path}"', # Point to final ModOrganizer.exe + 'StartDir': modlist_install_dir, # ModOrganizer directory + 'LaunchOptions': '', # Empty like working shortcuts + 'tags': {}, # Empty tags like working shortcuts + # Keep existing appid and CompatibilityTool + }) + new_shortcuts_list.append(shortcut) + found = True + else: + new_shortcuts_list.append(shortcut) + + if not found: + logger.error(f"Shortcut '{shortcut_name}' not found for replacement") + return False + + # Rebuild shortcuts dict with new order + shortcuts_data['shortcuts'] = {str(i): s for i, s in enumerate(new_shortcuts_list)} + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Replaced shortcut with final exe: {shortcut_name}") + debug_print(f"[DEBUG] Shortcut replaced with final ModOrganizer.exe") + + return True + + except Exception as e: + logger.error(f"Error replacing shortcut with final exe: {e}") + return False + + def set_proton_version_for_shortcut(self, appid: int, proton_version: str) -> bool: + """ + Set the Proton version for a shortcut in config.vdf. + + Args: + appid: The AppID of the shortcut (negative for non-Steam shortcuts) + proton_version: The Proton version to set (e.g., 'proton_experimental') + + Returns: + True if successful, False otherwise + """ + try: + # Get the config.vdf path + config_path = self._get_config_path() + if not config_path: + logger.error("No config.vdf path found") + return False + + # Read current config (config.vdf is text format) + with open(config_path, 'r') as f: + config_data = vdf.load(f) + + # Navigate to the correct location in the VDF structure + if 'Software' not in config_data: + config_data['Software'] = {} + if 'Valve' not in config_data['Software']: + config_data['Software']['Valve'] = {} + if 'Steam' not in config_data['Software']['Valve']: + config_data['Software']['Valve']['Steam'] = {} + + # Get or create CompatToolMapping + if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']: + config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {} + + # Set the Proton version for this AppID + config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = proton_version + + # Write back to file (text format) + with open(config_path, 'w') as f: + vdf.dump(config_data, f) + + logger.info(f"Set Proton version {proton_version} for AppID {appid}") + debug_print(f"[DEBUG] Set Proton version {proton_version} for AppID {appid} in config.vdf") + + # Verify it was set correctly + with open(config_path, 'r') as f: + verify_data = vdf.load(f) + actual_value = verify_data.get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid)) + debug_print(f"[DEBUG] Verification: AppID {appid} -> {actual_value}") + + return True + + except Exception as e: + logger.error(f"Error setting Proton version: {e}") + return False + + def _get_config_path(self) -> Optional[Path]: + """Get the path to config.vdf""" + try: + from ..handlers.path_handler import PathHandler + + # Use find_steam_config_vdf to get the Steam config path + config_vdf_path = PathHandler.find_steam_config_vdf() + if not config_vdf_path: + logger.error("Could not find Steam config.vdf") + return None + + return config_vdf_path + + except Exception as e: + logger.error(f"Error getting config path: {e}") + return None + + + def kill_running_processes(self) -> bool: + """ + Kill any running processes that might interfere with prefix creation. + This follows the same pattern as the working test script. + + Returns: + True if successful, False otherwise + """ + try: + import psutil + + logger.info("Looking for processes to kill...") + + # Look for ModOrganizer.exe process or any wine processes + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + proc_info = proc.info + name = proc_info.get('name', '') + cmdline = proc_info.get('cmdline', []) + + # Check for ModOrganizer.exe or wine processes + if ('ModOrganizer.exe' in name or + 'wine' in name.lower() or + any('ModOrganizer.exe' in str(arg) for arg in (cmdline or [])) or + any('wine' in str(arg).lower() for arg in (cmdline or []))): + + logger.info(f"Found process to kill: {name} (PID {proc_info['pid']})") + proc.terminate() + proc.wait(timeout=5) + logger.info(f" Process killed successfully") + + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.TimeoutExpired): + continue + + logger.info("ℹ️ No more processes to kill") + return True + + except Exception as e: + logger.error(f"Error killing processes: {e}") + return False + + def create_prefix_directly(self, appid: int, batch_file_path: str) -> Optional[Path]: + """ + Create prefix directly using Proton wrapper. + + Args: + appid: The AppID from the shortcut + batch_file_path: Path to the temporary batch file + + Returns: + Path to the created prefix, or None if failed + """ + proton_path = self.find_proton_experimental() + if not proton_path: + return None + + # Steam uses negative AppIDs for non-Steam shortcuts, but we need the positive value for the prefix path + positive_appid = abs(appid) + logger.info(f"Using positive AppID {positive_appid} for prefix creation (original: {appid})") + + # Create the prefix directory structure + prefix_path = self._get_compatdata_path_for_appid(positive_appid) + if not prefix_path: + logger.error(f"Could not determine compatdata path for AppID {positive_appid}") + return None + + # Create the prefix directory structure + prefix_path.mkdir(parents=True, exist_ok=True) + pfx_dir = prefix_path / "pfx" + pfx_dir.mkdir(exist_ok=True) + + # Set up environment + env = os.environ.copy() + env['STEAM_COMPAT_DATA_PATH'] = str(prefix_path) + env['STEAM_COMPAT_APP_ID'] = str(positive_appid) # Use positive AppID for environment + env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(Path.home() / ".local/share/Steam") + + # Build the command + cmd = [ + str(proton_path / "proton"), + "run", + batch_file_path + ] + + logger.info(f"Creating prefix with command: {' '.join(cmd)}") + logger.info(f"Prefix path: {prefix_path}") + logger.info(f"Using AppID: {positive_appid} (original: {appid})") + + try: + # Run the command with a timeout + result = subprocess.run( + cmd, + env=env, + capture_output=True, + text=True, + timeout=30 + ) + + # Check if prefix was created + time.sleep(2) # Give it a moment to settle + + prefix_created = prefix_path.exists() + pfx_exists = (prefix_path / "pfx").exists() + + logger.info(f"Return code: {result.returncode}") + logger.info(f"Prefix created: {prefix_created}") + logger.info(f"pfx directory exists: {pfx_exists}") + + if result.stderr: + logger.debug(f"stderr: {result.stderr.strip()}") + + success = prefix_created and pfx_exists + + if success: + logger.info(f"Prefix created successfully at: {prefix_path}") + return prefix_path + else: + logger.error("Failed to create prefix") + return None + + except subprocess.TimeoutExpired: + logger.warning("Command timed out, but this might be normal") + # Check if prefix was created despite timeout + prefix_created = prefix_path.exists() + pfx_exists = (prefix_path / "pfx").exists() + + if prefix_created and pfx_exists: + logger.info(f"Prefix created successfully despite timeout at: {prefix_path}") + return prefix_path + else: + logger.error("No prefix created") + return None + + except Exception as e: + logger.error(f"Error creating prefix: {e}") + return None + + def _get_compatdata_path_for_appid(self, appid: int) -> Optional[Path]: + """ + Get the compatdata path for a given AppID using existing Jackify functions. + + Args: + appid: The AppID to get the path for + + Returns: + Path to the compatdata directory, or None if not found + """ + # Use existing Jackify path detection + from ..handlers.path_handler import PathHandler + + compatdata_path = PathHandler.find_compat_data(str(appid)) + if compatdata_path: + return compatdata_path + + # Fallback: construct the path manually + possible_bases = [ + 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 base_path in possible_bases: + if base_path.is_dir(): + return base_path / str(appid) + + return None + + def verify_prefix_creation(self, prefix_path: Path) -> bool: + """ + Verify that the prefix was created successfully. + + Args: + prefix_path: Path to the prefix directory + + Returns: + True if prefix is valid, False otherwise + """ + try: + logger.info(f"Verifying prefix: {prefix_path}") + + # Check if prefix exists and has proper structure + if not prefix_path.exists(): + logger.error("Prefix directory does not exist") + return False + + pfx_dir = prefix_path / "pfx" + if not pfx_dir.exists(): + logger.error("Prefix exists but no pfx subdirectory") + return False + + # Check for key Wine files + system_reg = pfx_dir / "system.reg" + user_reg = pfx_dir / "user.reg" + drive_c = pfx_dir / "drive_c" + + if not system_reg.exists(): + logger.error("No system.reg found in prefix") + return False + + if not user_reg.exists(): + logger.error("No user.reg found in prefix") + return False + + if not drive_c.exists(): + logger.error("No drive_c directory found in prefix") + return False + + logger.info("Prefix structure verified successfully") + return True + + except Exception as e: + logger.error(f"Error verifying prefix: {e}") + return False + + def modify_shortcut_to_final_exe(self, shortcut_name: str, final_exe_path: str, + final_start_dir: str) -> bool: + """ + Update the existing batch file shortcut to point to the final executable. + This preserves the AppID and prefix association while changing the target. + + Args: + shortcut_name: Name of the shortcut to modify + final_exe_path: Path to the final executable (e.g., ModOrganizer.exe) + final_start_dir: Start directory for the executable + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the batch file shortcut that created the prefix + logger.info(f"Looking for batch file shortcut '{shortcut_name}' among {len(shortcuts)} shortcuts...") + target_shortcut = None + target_index = None + + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + exe = shortcut.get('Exe', '') + + # Find the specific shortcut that points to our batch file (handle quoted paths) + if (name == shortcut_name and + exe and 'prefix_creation_' in exe and (exe.endswith('.bat') or exe.endswith('.bat"'))): + target_shortcut = shortcut + target_index = str(i) + logger.info(f"Found batch file shortcut '{shortcut_name}' at index {i}") + logger.info(f" Current Exe: {exe}") + logger.info(f" Current StartDir: {shortcut.get('StartDir', '')}") + logger.info(f" Current CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}") + logger.info(f" AppID: {shortcut.get('appid', 'NOT_SET')}") + break + + if target_shortcut is None: + logger.error(f"No batch file shortcut found with name '{shortcut_name}'") + # Debug: show all available shortcuts + logger.debug("Available shortcuts:") + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + exe = shortcut.get('Exe', '') + logger.debug(f" [{i}] {name} -> {exe}") + return False + + # Update the existing shortcut IN-PLACE (preserves AppID and all other fields) + logger.info(f"Updating shortcut at index {target_index} IN-PLACE...") + + # Only change Exe and StartDir - preserve everything else including AppID + old_exe = target_shortcut.get('Exe', '') + old_start_dir = target_shortcut.get('StartDir', '') + + target_shortcut['Exe'] = f'"{final_exe_path}"' + target_shortcut['StartDir'] = f'"{final_start_dir}"' + + # Ensure CompatTool is set (STL should have set this, but make sure) + if not target_shortcut.get('CompatTool', '').strip(): + target_shortcut['CompatTool'] = 'proton_experimental' + logger.info("Set CompatTool to proton_experimental (was not set)") + + logger.info(f" Updated shortcut '{shortcut_name}' at index {target_index}:") + logger.info(f" Exe: {old_exe} → {target_shortcut['Exe']}") + logger.info(f" StartDir: {old_start_dir} → {target_shortcut['StartDir']}") + logger.info(f" AppID: {target_shortcut.get('appid', 'NOT_SET')} (preserved)") + logger.info(f" CompatTool: {target_shortcut.get('CompatTool', 'NOT_SET')} (preserved)") + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(" Shortcut updated successfully - no duplicates created") + return True + + except Exception as e: + logger.error(f"Error modifying shortcut: {e}") + return False + + def verify_final_shortcut(self, shortcut_name: str, expected_exe_path: str) -> bool: + """ + Verify the shortcut now points to the final executable. + + Args: + shortcut_name: Name of the shortcut to verify + expected_exe_path: Expected executable path + + Returns: + True if shortcut is correct, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find our shortcut + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name in name: + exe_path = shortcut.get('Exe', '') + start_dir = shortcut.get('StartDir', '') + + logger.info(f"Final shortcut configuration:") + logger.info(f" Name: {name}") + logger.info(f" Exe: {exe_path}") + logger.info(f" StartDir: {start_dir}") + + # Verify it points to the final executable + if expected_exe_path in exe_path: + logger.info("Shortcut correctly points to final executable") + return True + else: + logger.error("Shortcut does not point to final executable") + return False + + logger.error(f"Shortcut '{shortcut_name}' not found") + return False + + except Exception as e: + logger.error(f"Error reading shortcuts: {e}") + return False + + def wait_for_prefix_completion(self, prefix_id: str, timeout: int = 60) -> bool: + """ + Wait for system.reg to stop growing (indicates prefix creation is complete). + + Args: + prefix_id: The Steam prefix ID to monitor + timeout: Maximum seconds to wait + + Returns: + True if prefix creation completed, False if timeout + """ + try: + prefix_path = Path.home() / f".local/share/Steam/steamapps/compatdata/{prefix_id}" + system_reg = prefix_path / "pfx/system.reg" + + logger.info(f"Monitoring prefix completion: {system_reg}") + + last_size = 0 + stable_count = 0 + + for i in range(timeout): + if system_reg.exists(): + current_size = system_reg.stat().st_size + logger.debug(f"system.reg size: {current_size} bytes") + + if current_size == last_size: + stable_count += 1 + if stable_count >= 3: # Stable for 3 seconds + logger.info(" system.reg size stable - prefix creation complete") + return True + else: + stable_count = 0 + last_size = current_size + + time.sleep(1) + + logger.warning(f"❌ Timeout waiting for prefix completion after {timeout} seconds") + return False + + except Exception as e: + logger.error(f"Error monitoring prefix completion: {e}") + return False + + def kill_mo_processes(self) -> int: + """ + Kill all ModOrganizer.exe processes. + + Returns: + Number of processes killed + """ + try: + import psutil + killed_count = 0 + + logger.info("Searching for ModOrganizer processes...") + + for proc in psutil.process_iter(): + try: + proc_info = proc.as_dict(attrs=['pid', 'name', 'cmdline']) + name = proc_info.get('name', '').lower() + cmdline = proc_info.get('cmdline') or [] + + # Check process name and command line + is_mo_process = ( + 'modorganizer' in name or + 'mo2' in name or + any('modorganizer' in str(arg).lower() for arg in cmdline) or + any('ModOrganizer.exe' in str(arg) for arg in cmdline) + ) + + if is_mo_process: + pid = proc_info['pid'] + logger.info(f"Found ModOrganizer process: PID {pid}, name='{name}', cmdline={cmdline}") + + # Force kill with SIGTERM first, then SIGKILL if needed + proc.terminate() + try: + proc.wait(timeout=3) + logger.info(f" Process {pid} terminated gracefully") + except psutil.TimeoutExpired: + logger.info(f"Process {pid} didn't terminate, force killing...") + proc.kill() + proc.wait(timeout=2) + logger.info(f" Process {pid} force killed") + + killed_count += 1 + + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + except Exception as e: + logger.debug(f"Error checking process: {e}") + continue + + if killed_count > 0: + logger.info(f" Killed {killed_count} ModOrganizer processes") + else: + logger.warning("❌ No ModOrganizer processes found to kill") + + return killed_count + + except Exception as e: + logger.error(f"Error killing ModOrganizer processes: {e}") + return 0 + + def run_complete_workflow(self, shortcut_name: str, modlist_install_dir: str, + final_exe_path: str, progress_callback=None) -> Tuple[bool, Optional[Path], Optional[int]]: + """ + Run the simple automated prefix creation workflow. + + Args: + shortcut_name: Name for the Steam shortcut + modlist_install_dir: Directory where the modlist is installed + final_exe_path: Path to ModOrganizer.exe + + Returns: + Tuple of (success, prefix_path, appid) + """ + debug_print(f"[DEBUG] run_complete_workflow called with shortcut_name={shortcut_name}, modlist_install_dir={modlist_install_dir}, final_exe_path={final_exe_path}") + logger.info("Starting simple automated prefix creation workflow") + + # Initialize shared timing to continue from jackify-engine + from jackify.shared.timing import initialize_from_console_output + # TODO: Pass console output if available to continue timeline + initialize_from_console_output() + + # Show immediate feedback to user + if progress_callback: + progress_callback("Starting automated Steam setup...") + + try: + # Step 1: Create shortcut directly (NO STL needed!) + logger.info("Step 1: Creating shortcut directly to ModOrganizer.exe") + if progress_callback: + progress_callback("Creating Steam shortcut...") + if not self.create_shortcut_directly_with_proton(shortcut_name, final_exe_path, modlist_install_dir): + logger.error("Failed to create shortcut directly") + return False, None, None, None + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully") + logger.info("Step 1 completed: Shortcut created directly") + + # Step 2: Calculate the predictable AppID and rungameid + logger.info("Step 2: Calculating predictable AppID") + if progress_callback: + progress_callback("Calculating AppID...") + + # Calculate AppID using the same method as create_shortcut_directly_with_proton + from zlib import crc32 + combined_string = final_exe_path + shortcut_name + crc = crc32(combined_string.encode('utf-8')) + initial_appid = -(crc & 0x7FFFFFFF) # Make it negative and within 32-bit range + + # Calculate rungameid for launching + rungameid = (initial_appid << 32) | 0x02000000 + + # Convert AppID to positive prefix ID + expected_prefix_id = str(abs(initial_appid)) + + if progress_callback: + progress_callback("AppID calculated") + logger.info(f"Step 2 completed: AppID = {initial_appid}, rungameid = {rungameid}, expected_prefix_id = {expected_prefix_id}") + + # Step 3: Restart Steam + logger.info("Step 3: Restarting Steam") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Restarting Steam...") + if not self.restart_steam(): + logger.error("Failed to restart Steam") + return False, None, None, None + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully") + logger.info("Step 3 completed: Steam restarted") + + # Step 4: Launch temporary batch file to create prefix invisibly + logger.info("Step 4: Launching temporary batch file to create prefix") + debug_print(f"[DEBUG] About to launch temporary batch file with rungameid={rungameid}") + + # Launch using rungameid (this will run the batch file invisibly) + try: + result = subprocess.run(['steam', f'steam://rungameid/{rungameid}'], + capture_output=True, text=True, timeout=5) + debug_print(f"[DEBUG] Launch result: return_code={result.returncode}") + if result.returncode != 0: + logger.error(f"Failed to launch temporary batch file: {result.stderr}") + return False, None, None, None + except subprocess.TimeoutExpired: + debug_print("[DEBUG] Launch timed out (expected)") + except Exception as e: + logger.error(f"Error launching temporary batch file: {e}") + return False, None, None, None + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Temporary batch file launched") + logger.info("Step 4 completed: Temporary batch file launched") + + # Step 5: Wait for temporary batch file to complete (invisible) + logger.info("Step 5: Waiting for temporary batch file to complete") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix (please wait)...") + + # Wait for batch file to complete (3 seconds + buffer) + time.sleep(5) + logger.info("Step 5 completed: Temporary batch file completed") + + # Step 6: Verify prefix was created + logger.info("Step 6: Verifying prefix creation") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Verifying prefix creation...") + + compatdata_path = Path.home() / ".local/share/Steam/steamapps/compatdata" / expected_prefix_id + if not compatdata_path.exists(): + logger.error(f"Prefix not found at {compatdata_path}") + return False, None, None, None + + logger.info(f"Step 6 completed: Prefix verified at {compatdata_path}") + + # Step 7: Replace temporary batch file with final ModOrganizer.exe + logger.info("Step 7: Replacing temporary batch file with final ModOrganizer.exe") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Updating shortcut...") + + if not self.replace_shortcut_with_final_exe(shortcut_name, final_exe_path, modlist_install_dir): + logger.error("Failed to replace shortcut with final exe") + return False, None, None, None + + logger.info("Step 7 completed: Shortcut updated with final ModOrganizer.exe") + + # Step 8: Detect actual AppID using protontricks -l + logger.info("Step 8: Detecting actual AppID") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Detecting actual AppID...") + actual_appid = self.detect_actual_prefix_appid(initial_appid, shortcut_name) + if actual_appid is None: + logger.error("Failed to detect actual AppID") + return False, None, None, None + logger.info(f"Step 8 completed: Actual AppID = {actual_appid}") + + # Step 9: Verify prefix was created successfully + logger.info("Step 9: Verifying prefix creation") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Verifying prefix creation...") + prefix_path = self._get_compatdata_path_for_appid(actual_appid) + if not prefix_path or not prefix_path.exists(): + logger.error(f"Prefix path not found: {prefix_path}") + return False, None, None, None + + if not self.verify_prefix_creation(prefix_path): + logger.error("Prefix verification failed") + return False, None, None, None + logger.info(f"Step 9 completed: Prefix verified at {prefix_path}") + + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam Configuration complete!") + logger.info(" Simple automated prefix creation workflow completed successfully") + return True, prefix_path, actual_appid + + except Exception as e: + logger.error(f"Error in automated prefix creation workflow: {e}") + import traceback + logger.error(f"Full traceback: {traceback.format_exc()}") + return False, None, None, None + + def cleanup_old_batch_shortcuts(self, shortcut_name: str) -> bool: + """ + Clean up any old batch file shortcuts for this modlist to prevent duplicates. + + Args: + shortcut_name: Name of the shortcut to clean up old batch versions for + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + indices_to_remove = [] + + # Find all batch file shortcuts with the same name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + exe = shortcut.get('Exe', '') + + if (name == shortcut_name and + 'prefix_creation_' in exe and + exe.endswith('.bat')): + indices_to_remove.append(str(i)) + logger.info(f"Marking old batch shortcut for removal: {name} -> {exe}") + + if not indices_to_remove: + logger.debug(f"No old batch shortcuts found for '{shortcut_name}'") + return True + + # Remove shortcuts by rebuilding the shortcuts dict + new_shortcuts = {} + new_index = 0 + + for i in range(len(shortcuts)): + if str(i) not in indices_to_remove: + new_shortcuts[str(new_index)] = shortcuts[str(i)] + new_index += 1 + + shortcuts_data['shortcuts'] = new_shortcuts + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Cleaned up {len(indices_to_remove)} old batch shortcuts for '{shortcut_name}'") + return True + + except Exception as e: + logger.error(f"Error cleaning up old shortcuts: {e}") + return False + + def set_compatool_on_shortcut(self, shortcut_name: str) -> bool: + """ + Set CompatTool on a shortcut immediately after STL creation. + This is CRITICAL to ensure the batch file shortcut has Proton set + so it can create a prefix when launched. + + Args: + shortcut_name: Name of the shortcut to modify + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name == name: + # Check current CompatTool setting + current_compat = shortcut.get('CompatTool', 'NOT_SET') + logger.info(f"Found shortcut '{name}' with CompatTool: '{current_compat}'") + + # Set CompatTool to ensure batch file can create prefix + shortcut['CompatTool'] = 'proton_experimental' + logger.info(f" Set CompatTool=proton_experimental on shortcut: {name}") + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + return True + + logger.error(f"❌ Shortcut '{shortcut_name}' not found for CompatTool setting") + return False + + except Exception as e: + logger.error(f"❌ Error setting CompatTool on shortcut: {e}") + return False + + def _set_proton_on_shortcut(self, shortcut_name: str) -> bool: + """ + Set Proton Experimental on a shortcut by name. + + Args: + shortcut_name: Name of the shortcut to modify + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name == name: + # Set CompatTool + shortcut['CompatTool'] = 'proton_experimental' + logger.info(f"Set CompatTool=proton_experimental on shortcut: {name}") + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + return True + + logger.error(f"Shortcut '{shortcut_name}' not found for Proton setting") + return False + + except Exception as e: + logger.error(f"Error setting Proton on shortcut: {e}") + return False + + def run_working_workflow(self, shortcut_name: str, modlist_install_dir: str, + final_exe_path: str, progress_callback=None, steamdeck: Optional[bool] = None) -> Tuple[bool, Optional[Path], Optional[int], Optional[str]]: + """ + Run the proven working automated prefix creation workflow. + + This implements our tested and working approach: + 1. Create shortcut with native Steam service (pointing to ModOrganizer.exe initially) + 2. Restart Steam using Jackify's robust method + 3. Create Proton prefix invisibly using Proton wrapper with DISPLAY= + 4. Verify everything persists + + Args: + shortcut_name: Name for the Steam shortcut + modlist_install_dir: Directory where the modlist is installed + final_exe_path: Path to ModOrganizer.exe + + Returns: + Tuple of (success, prefix_path, appid, last_timestamp) + """ + logger.info("Starting proven working automated prefix creation workflow") + + # Show installation complete and configuration start headers FIRST + if progress_callback: + progress_callback("") + progress_callback("=" * 64) + progress_callback("= Installation phase complete =") + progress_callback("=" * 64) + progress_callback("") + progress_callback("=" * 64) + progress_callback("= Starting Configuration Phase =") + progress_callback("=" * 64) + progress_callback("") + + # Reset timing for Steam Integration section (part of Configuration Phase) + from jackify.shared.timing import start_new_phase + start_new_phase() + + # Show immediate feedback to user with section header + if progress_callback: + progress_callback("") # Blank line before Steam Integration + progress_callback("=== Steam Integration ===") + progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service") + + # Detect special game types early to generate proper launch options + from ..handlers.modlist_handler import ModlistHandler + modlist_handler = ModlistHandler() + special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir) + + # Generate complete launch options for special games + custom_launch_options = None + if special_game_type in ["fnv", "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 {special_game_type.upper()} modlist") + return False, None, None, None + + try: + # Step 1: Create shortcut with native Steam service (pointing to ModOrganizer.exe initially) + logger.info("Step 1: Creating shortcut with native Steam service") + + # TEMPORARILY DISABLED: Check if shortcut already exists and handle conflict + # conflict_result = self.handle_existing_shortcut_conflict(shortcut_name, final_exe_path, modlist_install_dir) + # if isinstance(conflict_result, list): # Conflicts found + # logger.warning(f"Found {len(conflict_result)} existing shortcut(s) with same name and path") + # # Return a special tuple to indicate conflict that needs user resolution + # return ("CONFLICT", conflict_result, None) + # elif not conflict_result: # User cancelled or other failure + # logger.error("User cancelled due to shortcut conflict") + # return False, None, None, None + logger.info("Conflict detection temporarily disabled - proceeding with shortcut creation") + + # Create shortcut using native Steam service with special game launch options + success, appid = self.create_shortcut_with_native_service(shortcut_name, final_exe_path, modlist_install_dir, custom_launch_options) + if not success: + logger.error("Failed to create shortcut with native Steam service") + return False, None, None, None + + logger.info(f"Step 1 completed: Shortcut created with native service, AppID: {appid}") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully") + + # Apply Steam artwork if available + try: + from ..handlers.modlist_handler import ModlistHandler + modlist_handler = ModlistHandler() + modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir) + logger.info(f"Applied Steam artwork for shortcut '{shortcut_name}' (AppID: {appid})") + except Exception as e: + logger.warning(f"Failed to apply Steam artwork: {e}") + + # Step 2: Restart Steam using Jackify's robust method + logger.info("Step 2: Restarting Steam using Jackify's robust method") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Restarting Steam...") + + if not self.restart_steam(): + logger.error("Failed to restart Steam") + return False, None, None, None + + logger.info("Step 2 completed: Steam restarted") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully") + + # Step 3: Create Proton prefix invisibly using Proton wrapper + logger.info("Step 3: Creating Proton prefix invisibly") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...") + + if not self.create_prefix_with_proton_wrapper(appid): + logger.error("Failed to create Proton prefix") + return False, None, None, None + + logger.info("Step 3 completed: Proton prefix created") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully") + + # Step 4: Verify everything persists + logger.info("Step 4: Verifying compatibility tool persists") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Verifying setup...") + + if not self.verify_compatibility_tool_persists(appid): + logger.warning("Compatibility tool verification failed, but continuing") + + logger.info("Step 4 completed: Verification done") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Setup verification completed") + + # Get the prefix path + prefix_path = self.get_prefix_path(appid) + + last_timestamp = self._get_progress_timestamp() + logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}") + if progress_callback: + progress_callback(f"{last_timestamp} Steam integration complete") + progress_callback("") # Blank line after Steam integration complete + progress_callback("") # Extra blank line to span across Configuration Summary + progress_callback("") # And one more to create space before Prefix Configuration + + return True, prefix_path, appid, last_timestamp + + except Exception as e: + logger.error(f"Error in working workflow: {e}") + if progress_callback: + progress_callback(f"Error: {str(e)}") + return False, None, None, None + + def continue_workflow_after_conflict_resolution(self, shortcut_name: str, modlist_install_dir: str, + final_exe_path: str, appid: int, progress_callback=None) -> Tuple[bool, Optional[Path], Optional[int]]: + """ + Continue the workflow after a shortcut conflict has been resolved. + + Args: + shortcut_name: Name of the shortcut + modlist_install_dir: Directory where the modlist is installed + final_exe_path: Path to the final executable + appid: The AppID of the shortcut that was created/replaced + progress_callback: Optional callback for progress updates + + Returns: + Tuple of (success, prefix_path, appid) + """ + try: + logger.info("Continuing workflow after conflict resolution") + + # Step 2: Restart Steam using Jackify's robust method + logger.info("Step 2: Restarting Steam using Jackify's robust method") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Restarting Steam...") + + if not self.restart_steam(): + logger.error("Failed to restart Steam") + return False, None, None, None + + logger.info("Step 2 completed: Steam restarted") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully") + + # Step 3: Create Proton prefix invisibly using Proton wrapper + logger.info("Step 3: Creating Proton prefix invisibly") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...") + + if not self.create_prefix_with_proton_wrapper(appid): + logger.error("Failed to create Proton prefix") + return False, None, None, None + + logger.info("Step 3 completed: Proton prefix created") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully") + + # Step 4: Verify everything persists + logger.info("Step 4: Verifying compatibility tool persists") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Verifying setup...") + + if not self.verify_compatibility_tool_persists(appid): + logger.warning("Compatibility tool verification failed, but continuing") + + logger.info("Step 4 completed: Verification done") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Setup verification completed") + + # Get the prefix path + prefix_path = self.get_prefix_path(appid) + + last_timestamp = self._get_progress_timestamp() + logger.info(f" Workflow completed successfully after conflict resolution! AppID: {appid}, Prefix: {prefix_path}") + if progress_callback: + progress_callback(f"{last_timestamp} Automated Steam setup completed successfully!") + + return True, prefix_path, appid, last_timestamp + + except Exception as e: + logger.error(f"Error continuing workflow after conflict resolution: {e}") + if progress_callback: + progress_callback(f"Error: {str(e)}") + return False, None, None, None + + def modify_shortcut_to_batch_file(self, shortcut_name: str, batch_file_path: str, + modlist_install_dir: str) -> bool: + """ + Modify an existing shortcut to point to a temporary batch file. + + Args: + shortcut_name: Name of the shortcut to modify + batch_file_path: Path to the temporary batch file + modlist_install_dir: Directory where the modlist is installed + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name == name: + # Update the shortcut to point to the batch file + old_exe = shortcut.get('Exe', '') + shortcut['Exe'] = f'"{batch_file_path}"' + shortcut['StartDir'] = f'"{modlist_install_dir}"' + + logger.info(f"Modified shortcut '{shortcut_name}':") + logger.info(f" Exe: {old_exe} → {shortcut['Exe']}") + logger.info(f" StartDir: {shortcut['StartDir']}") + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + return True + + logger.error(f"Shortcut '{shortcut_name}' not found for modification") + return False + + except Exception as e: + logger.error(f"Error modifying shortcut to batch file: {e}") + return False + + def find_appid_in_shortcuts_vdf(self, shortcut_name: str) -> Optional[str]: + """ + Find the AppID for a shortcut by name directly in shortcuts.vdf. + This is a fallback method when protontricks detection fails. + + Args: + shortcut_name: Name of the shortcut to find + + Returns: + AppID as string, or None if not found + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return None + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Look for shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name == name: + appid = shortcut.get('appid') + if appid: + logger.info(f"Found AppID {appid} for shortcut '{shortcut_name}' in shortcuts.vdf") + return str(appid) + + logger.warning(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf") + return None + + except Exception as e: + logger.error(f"Error finding AppID in shortcuts.vdf: {e}") + return None + + def predict_appid_using_stl_algorithm(self, shortcut_name: str, exe_path: str) -> Optional[int]: + """ + Predict the AppID using SteamTinkerLaunch's exact algorithm. + + This implements the same logic as STL's generateShortcutVDFAppId and generateSteamShortID functions: + 1. Combine AppName + ExePath + 2. Generate MD5 hash, take first 8 characters + 3. Convert to decimal, make negative, ensure < 1 billion + 4. Convert to unsigned 32-bit integer + + Args: + shortcut_name: Name of the shortcut + exe_path: Path to the executable + + Returns: + Predicted AppID as integer, or None if failed + """ + try: + import hashlib + + # Step 1: Combine AppName + ExePath (exactly like STL) + combined_string = f"{shortcut_name}{exe_path}" + logger.debug(f"Combined string for AppID prediction: '{combined_string}'") + + # Step 2: Generate MD5 hash and take first 8 characters + md5_hash = hashlib.md5(combined_string.encode()).hexdigest() + seed_hex = md5_hash[:8] + logger.debug(f"MD5 hash: {md5_hash}, seed hex: {seed_hex}") + + # Step 3: Convert to decimal, make negative, ensure < 1 billion + seed_decimal = int(seed_hex, 16) + signed_appid = -(seed_decimal % 1000000000) + logger.debug(f"Seed decimal: {seed_decimal}, signed AppID: {signed_appid}") + + # Step 4: Convert to unsigned 32-bit integer (STL's generateSteamShortID) + unsigned_appid = signed_appid & 0xFFFFFFFF + logger.debug(f"Unsigned AppID: {unsigned_appid}") + + logger.info(f"Predicted AppID using STL algorithm: {unsigned_appid} (signed: {signed_appid})") + return unsigned_appid + + except Exception as e: + logger.error(f"Error predicting AppID using STL algorithm: {e}") + return None + + def create_shortcut_with_stl_algorithm(self, shortcut_name: str, exe_path: str, start_dir: str, compatibility_tool: str = None) -> bool: + """ + Create a shortcut using STL's exact algorithm for consistent AppID calculation. + + Args: + shortcut_name: Name of the shortcut + exe_path: Path to the executable + start_dir: Start directory + compatibility_tool: Optional compatibility tool to set immediately (like STL does) + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the next available index + next_index = str(len(shortcuts)) + + # Calculate AppID using STL's algorithm + predicted_appid = self.predict_appid_using_stl_algorithm(shortcut_name, exe_path) + if not predicted_appid: + logger.error("Failed to predict AppID for shortcut creation") + return False + + # Convert to signed AppID (STL stores the signed version in shortcuts.vdf) + signed_appid = predicted_appid + if predicted_appid > 0x7FFFFFFF: # If it's a large positive number, make it negative + signed_appid = predicted_appid - 0x100000000 + + # Create new shortcut entry + new_shortcut = { + 'AppName': shortcut_name, + 'Exe': f'"{exe_path}"', + 'StartDir': f'"{start_dir}"', + 'appid': signed_appid, # Use the signed AppID + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'openvr': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0, + 'FlatpakAppID': '', + 'tags': {}, + 'IsInstalled': 1 # Make it appear in "Locally Installed" filter + } + + # Add the new shortcut + shortcuts[next_index] = new_shortcut + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Created shortcut with STL algorithm: {shortcut_name} with AppID {signed_appid} (unsigned: {predicted_appid})") + + # Set compatibility tool immediately if provided (like STL does) + if compatibility_tool: + logger.info(f"Setting compatibility tool immediately: {compatibility_tool}") + success = self.set_compatibility_tool_complete_stl_style(predicted_appid, compatibility_tool) + if not success: + logger.warning("Failed to set compatibility tool immediately") + + return True + + except Exception as e: + logger.error(f"Error creating shortcut with STL algorithm: {e}") + return False + + def set_compatibility_tool_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool: + """ + Set compatibility tool using STL's exact method. + + This adds an entry to config.vdf's CompatToolMapping section using the unsigned AppID as the key, + exactly like STL does. + + Args: + unsigned_appid: The unsigned AppID (Grid ID) to use as the key + compat_tool: The compatibility tool name (e.g., 'proton_experimental') + + Returns: + True if successful, False otherwise + """ + try: + config_path = self._get_config_path() + if not config_path: + logger.error("No config.vdf path found") + return False + + # Read current config (config.vdf is text format) + with open(config_path, 'r') as f: + config_data = vdf.load(f) + + # Navigate to the correct location in the VDF structure + if 'Software' not in config_data: + config_data['Software'] = {} + if 'Valve' not in config_data['Software']: + config_data['Software']['Valve'] = {} + if 'Steam' not in config_data['Software']['Valve']: + config_data['Software']['Valve']['Steam'] = {} + + # Get or create CompatToolMapping + if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']: + config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {} + + # Create the compatibility tool entry exactly like STL does + compat_entry = { + 'name': compat_tool, + 'config': '', + 'priority': '250' + } + + # Set the compatibility tool for this AppID (using unsigned AppID as key) + config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(unsigned_appid)] = compat_entry + + logger.info(f"Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}") + debug_print(f"[DEBUG] Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}") + + # Write back to file (text format) + with open(config_path, 'w') as f: + vdf.dump(config_data, f) + + logger.info(f"Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}") + debug_print(f"[DEBUG] Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}") + + return True + + except Exception as e: + logger.error(f"Error setting compatibility tool STL-style: {e}") + return False + + def set_compatibility_tool_complete_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool: + """ + Set compatibility tool using STL's complete method with direct text manipulation. + + This replicates STL's approach by using direct text manipulation instead of VDF libraries + to preserve existing entries in both config.vdf and localconfig.vdf. + + Args: + unsigned_appid: The unsigned AppID (Grid ID) to use as the key + compat_tool: The compatibility tool name (e.g., 'proton_experimental') + + Returns: + True if successful, False otherwise + """ + try: + # Step 1: Update config.vdf using direct text manipulation (like STL does) + config_path = self._get_config_path() + if not config_path: + logger.error("No config.vdf path found") + return False + + # Read the entire file as text + with open(config_path, 'r') as f: + lines = f.readlines() + + # Find the CompatToolMapping section + compat_section_start = None + compat_section_end = None + for i, line in enumerate(lines): + if '"CompatToolMapping"' in line.strip(): + compat_section_start = i + # Find the end of the CompatToolMapping section + brace_count = 0 + for j in range(i + 1, len(lines)): + if '{' in lines[j]: + brace_count += 1 + if '}' in lines[j]: + brace_count -= 1 + if brace_count == 0: + compat_section_end = j + break + break + + if compat_section_start is None: + logger.error("CompatToolMapping section not found in config.vdf") + return False + + # Check if our AppID entry already exists + appid_entry_start = None + appid_entry_end = None + for i in range(compat_section_start, compat_section_end + 1): + if f'"{unsigned_appid}"' in lines[i]: + appid_entry_start = i + # Find the end of this AppID entry + brace_count = 0 + for j in range(i + 1, compat_section_end + 1): + if '{' in lines[j]: + brace_count += 1 + if '}' in lines[j]: + brace_count -= 1 + if brace_count == 0: + appid_entry_end = j + break + break + + # Create the new entry in Steam's exact format + new_entry_lines = [ + f'\t\t\t\t\t\t\t\t\t"{unsigned_appid}"\n', + f'\t\t\t\t\t\t\t\t\t{{\n', + f'\t\t\t\t\t\t\t\t\t\t"name"\t\t\t\t"{compat_tool}"\n', + f'\t\t\t\t\t\t\t\t\t\t"config"\t\t\t\t\t""\n', + f'\t\t\t\t\t\t\t\t\t\t"priority"\t\t\t\t\t"250"\n', + f'\t\t\t\t\t\t\t\t\t}}\n' + ] + + if appid_entry_start is None: + # AppID entry doesn't exist, add it before the closing brace of CompatToolMapping + lines.insert(compat_section_end, ''.join(new_entry_lines)) + else: + # AppID entry exists, replace it + del lines[appid_entry_start:appid_entry_end + 1] + lines.insert(appid_entry_start, ''.join(new_entry_lines)) + + # Write the updated file back + with open(config_path, 'w') as f: + f.writelines(lines) + + logger.info(f"Updated config.vdf: AppID {unsigned_appid} -> {compat_tool}") + + # Step 2: Update localconfig.vdf using direct text manipulation (like STL) + localconfig_path = self._get_localconfig_path() + if not localconfig_path: + logger.error("No localconfig.vdf path found") + return False + + # Calculate signed AppID (like STL does) + signed_appid = (unsigned_appid | 0x80000000) & 0xFFFFFFFF + # Convert to signed 32-bit integer + import ctypes + signed_appid_int = ctypes.c_int32(signed_appid).value + + # Read the entire file as text + with open(localconfig_path, 'r') as f: + lines = f.readlines() + + # Check if Apps section exists + apps_section_start = None + apps_section_end = None + for i, line in enumerate(lines): + if line.strip() == '"Apps"': + apps_section_start = i + # Find the end of the Apps section + brace_count = 0 + for j in range(i + 1, len(lines)): + if '{' in lines[j]: + brace_count += 1 + if '}' in lines[j]: + brace_count -= 1 + if brace_count == 0: + apps_section_end = j + break + break + + # If Apps section doesn't exist, create it at the end of the file + if apps_section_start is None: + logger.info("Apps section not found, creating it at the end of the file") + + # Find the last closing brace (before the final closing brace) + last_brace_pos = None + for i in range(len(lines) - 1, -1, -1): + if lines[i].strip() == '}': + last_brace_pos = i + break + + if last_brace_pos is None: + logger.error("Could not find closing brace in localconfig.vdf") + return False + + # Insert Apps section before the last closing brace + apps_section = [ + ' "Apps"\n', + ' {\n', + f' "{signed_appid_int}"\n', + ' {\n', + ' "OverlayAppEnable" "1"\n', + ' "DisableLaunchInVR" "1"\n', + ' }\n', + ' }\n' + ] + + lines.insert(last_brace_pos, ''.join(apps_section)) + + else: + # Apps section exists, check if our AppID entry exists + appid_entry_start = None + appid_entry_end = None + for i in range(apps_section_start, apps_section_end + 1): + if f'"{signed_appid_int}"' in lines[i]: + appid_entry_start = i + # Find the end of this AppID entry + brace_count = 0 + for j in range(i + 1, apps_section_end + 1): + if '{' in lines[j]: + brace_count += 1 + if '}' in lines[j]: + brace_count -= 1 + if brace_count == 0: + appid_entry_end = j + break + break + + if appid_entry_start is None: + # AppID entry doesn't exist, add it to the Apps section + logger.info(f"AppID {signed_appid_int} entry not found, adding it to Apps section") + + # Insert before the closing brace of the Apps section + appid_entry = [ + f' "{signed_appid_int}"\n', + ' {\n', + ' "OverlayAppEnable" "1"\n', + ' "DisableLaunchInVR" "1"\n', + ' }\n' + ] + + lines.insert(apps_section_end, ''.join(appid_entry)) + + else: + # AppID entry exists, update the values + logger.info(f"AppID {signed_appid_int} entry exists, updating values") + + # Check if the values already exist and update them + overlay_found = False + vr_found = False + + for i in range(appid_entry_start, appid_entry_end + 1): + if '"OverlayAppEnable"' in lines[i]: + lines[i] = ' "OverlayAppEnable" "1"\n' + overlay_found = True + elif '"DisableLaunchInVR"' in lines[i]: + lines[i] = ' "DisableLaunchInVR" "1"\n' + vr_found = True + + # Add missing values + if not overlay_found or not vr_found: + # Find the position to insert (before the closing brace of the AppID entry) + insert_pos = appid_entry_end + for i in range(appid_entry_start, appid_entry_end + 1): + if lines[i].strip() == '}': + insert_pos = i + break + + new_values = [] + if not overlay_found: + new_values.append(' "OverlayAppEnable" "1"\n') + if not vr_found: + new_values.append(' "DisableLaunchInVR" "1"\n') + + for value in new_values: + lines.insert(insert_pos, value) + + # Write the updated file back + with open(localconfig_path, 'w') as f: + f.writelines(lines) + + logger.info(f"Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1") + debug_print(f"[DEBUG] Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1") + + return True + + except Exception as e: + logger.error(f"Error setting compatibility tool complete STL-style: {e}") + return False + + def modify_shortcut_to_batch_file(self, shortcut_name: str, new_exe_path: str, new_start_dir: str) -> bool: + """ + Modify an existing shortcut's target and start directory. + + This is used in the workflow to: + 1. Change the shortcut target to a batch file (to create prefix) + 2. Change it back to ModOrganizer.exe (after prefix creation) + + Args: + shortcut_name: The name of the shortcut to modify + new_exe_path: The new executable path + new_start_dir: The new start directory + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + logger.error("No shortcuts.vdf path found") + return False + + # Read the current shortcuts.vdf + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + if 'shortcuts' not in shortcuts_data: + logger.error("No shortcuts found in shortcuts.vdf") + return False + + shortcuts = shortcuts_data['shortcuts'] + shortcut_found = False + + # Find the shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + if shortcut.get('AppName', '') == shortcut_name: + # Update the shortcut + shortcut['Exe'] = new_exe_path + shortcut['StartDir'] = new_start_dir + shortcut_found = True + logger.info(f"Modified shortcut '{shortcut_name}' to target: {new_exe_path}") + break + + if not shortcut_found: + logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf") + return False + + # Write the updated shortcuts.vdf back + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Successfully modified shortcut '{shortcut_name}'") + return True + + except Exception as e: + logger.error(f"Error modifying shortcut: {e}") + return False + + def _get_localconfig_path(self) -> str: + """ + Get the path to localconfig.vdf file. + + 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" + if localconfig_path.exists(): + return str(localconfig_path) + + logger.error("Could not find localconfig.vdf") + return None + + + + def modify_shortcut_target(self, shortcut_name: str, new_exe_path: str, new_start_dir: str) -> bool: + """ + Modify an existing shortcut's target and start directory. + Preserves existing launch options (including STEAM_COMPAT_MOUNTS). + + Args: + shortcut_name: The name of the shortcut to modify + new_exe_path: The new executable path + new_start_dir: The new start directory + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + logger.error("No shortcuts.vdf path found") + return False + + # Read the current shortcuts.vdf + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + if 'shortcuts' not in shortcuts_data: + logger.error("No shortcuts found in shortcuts.vdf") + return False + + shortcuts = shortcuts_data['shortcuts'] + shortcut_found = False + + # Find the shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + if shortcut.get('AppName', '') == shortcut_name: + # Preserve existing launch options + existing_launch_options = shortcut.get('LaunchOptions', '') + + # Update the shortcut EXACTLY as provided by the caller. + # - For temporary prefix creation we pass a Windows path (cmd.exe) + # - For final ModOrganizer.exe we pass the Linux path inside the modlist directory + shortcut['Exe'] = new_exe_path + shortcut['StartDir'] = new_start_dir + # Preserve the launch options (including STEAM_COMPAT_MOUNTS) + shortcut['LaunchOptions'] = existing_launch_options + + shortcut_found = True + logger.info(f"Modified shortcut '{shortcut_name}' to target: {new_exe_path}") + logger.info(f"Preserved launch options: {existing_launch_options}") + break + + if not shortcut_found: + logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf") + return False + + # Write the updated shortcuts.vdf back + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Successfully modified shortcut '{shortcut_name}'") + return True + + except Exception as e: + logger.error(f"Error modifying shortcut: {e}") + return False + + def create_prefix_with_proton_wrapper(self, appid: int) -> bool: + """ + Create a Proton prefix directly using Proton's wrapper and STEAM_COMPAT_DATA_PATH. + + Args: + appid: The AppID to create the prefix for + + Returns: + True if successful, False otherwise + """ + try: + steam_root = Path.home() / ".steam/steam" + compatdata_dir = steam_root / "steamapps/compatdata" + proton_common_dir = steam_root / "steamapps/common" + + # Find a Proton wrapper to use + proton_path = self._find_proton_binary(proton_common_dir) + if not proton_path: + logger.error("No Proton wrapper found") + return False + + # Set up environment variables + env = os.environ.copy() + env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root) + env['STEAM_COMPAT_DATA_PATH'] = str(compatdata_dir / str(abs(appid))) + # Suppress GUI windows by unsetting DISPLAY + env['DISPLAY'] = '' + + # Create the compatdata directory + compat_dir = compatdata_dir / str(abs(appid)) + compat_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"Creating Proton prefix for AppID {appid}") + logger.info(f"STEAM_COMPAT_CLIENT_INSTALL_PATH={env['STEAM_COMPAT_CLIENT_INSTALL_PATH']}") + logger.info(f"STEAM_COMPAT_DATA_PATH={env['STEAM_COMPAT_DATA_PATH']}") + + # Run proton run wineboot -u to initialize the prefix + cmd = [str(proton_path), 'run', 'wineboot', '-u'] + logger.info(f"Running: {' '.join(cmd)}") + + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=60) + logger.info(f"Proton exit code: {result.returncode}") + + if result.stdout: + logger.info(f"stdout: {result.stdout.strip()[:500]}") + if result.stderr: + logger.info(f"stderr: {result.stderr.strip()[:500]}") + + # Give a moment for files to land + time.sleep(3) + + # Check if prefix was created + pfx = compat_dir / 'pfx' + if pfx.exists(): + logger.info(f" Proton prefix created at: {pfx}") + return True + else: + logger.warning(f"⚠️ Proton prefix not found at: {pfx}") + return False + + except subprocess.TimeoutExpired: + logger.warning("Proton timed out; prefix may still be initializing") + return False + except Exception as e: + logger.error(f"Error creating prefix: {e}") + return False + + 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") + 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]]: + """ + Replace an existing shortcut with a new one using STL. + + Args: + shortcut_name: Name of the shortcut to replace + exe_path: Path to the executable + modlist_install_dir: Directory where the modlist is installed + + Returns: + Tuple of (success, appid) + """ + try: + logger.info(f"Replacing existing shortcut: {shortcut_name}") + + # First, remove the existing shortcut using STL + if getattr(sys, 'frozen', False): + stl_path = Path(sys._MEIPASS) / "steamtinkerlaunch" + else: + project_root = Path(__file__).parent.parent.parent.parent.parent + stl_path = project_root / "external_repos/steamtinkerlaunch/steamtinkerlaunch" + + if not stl_path.exists(): + logger.error(f"STL not found at: {stl_path}") + return False, None + + # Remove existing shortcut + remove_cmd = [str(stl_path), "rnsg", f"--appname={shortcut_name}"] + env = os.environ.copy() + env['STL_QUIET'] = '1' + + logger.info(f"Removing existing shortcut: {' '.join(remove_cmd)}") + result = subprocess.run(remove_cmd, capture_output=True, text=True, timeout=30, env=env) + + if result.returncode != 0: + logger.warning(f"Failed to remove existing shortcut: {result.stderr}") + # Continue anyway, STL might create a new one + + # Now create the new shortcut using NativeSteamService + success, app_id = self.create_shortcut_with_native_service(shortcut_name, exe_path, modlist_install_dir) + return success, app_id + + except Exception as e: + logger.error(f"Error replacing shortcut: {e}") + return False, None + + def verify_compatibility_tool_persists(self, appid: int) -> bool: + """ + Verify that the compatibility tool setting persists. + + Args: + appid: The AppID to check + + Returns: + True if compatibility tool is 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") + return False + + except Exception as e: + logger.error(f"Error verifying compatibility tool: {e}") + return False + + def get_prefix_path(self, appid: int) -> Optional[Path]: + """ + Get the path to the Proton prefix for the given AppID. + + Args: + appid: The AppID (unsigned, positive number) + + Returns: + Path to the prefix directory, or None if not found + """ + compatdata_dir = Path.home() / ".steam/steam/steamapps/compatdata" + # Ensure we use the absolute value (unsigned AppID) + prefix_dir = compatdata_dir / str(abs(appid)) + + if prefix_dir.exists(): + return prefix_dir + else: + return None + diff --git a/jackify/backend/services/modlist_service.py b/jackify/backend/services/modlist_service.py new file mode 100644 index 0000000..e128fcc --- /dev/null +++ b/jackify/backend/services/modlist_service.py @@ -0,0 +1,690 @@ +""" +Modlist Service + +High-level service for modlist operations, orchestrating various handlers. +""" + +import logging +from typing import List, Optional, Dict, Any +from pathlib import Path + +from ..models.modlist import ModlistContext, ModlistInfo +from ..models.configuration import SystemInfo + +logger = logging.getLogger(__name__) + + +class ModlistService: + """Service for managing modlist operations.""" + + def __init__(self, system_info: SystemInfo): + """Initialize the modlist service. + + Args: + system_info: System information context + """ + self.system_info = system_info + + # Handlers will be initialized when needed + self._modlist_handler = None + self._wabbajack_handler = None + self._filesystem_handler = None + + def _get_modlist_handler(self): + """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() + return self._modlist_handler + + def _get_wabbajack_handler(self): + """Lazy initialization of wabbajack handler.""" + if self._wabbajack_handler is None: + from ..handlers.wabbajack_handler import InstallWabbajackHandler + # Initialize with proper dependencies + self._wabbajack_handler = InstallWabbajackHandler() + return self._wabbajack_handler + + def _get_filesystem_handler(self): + """Lazy initialization of filesystem handler.""" + if self._filesystem_handler is None: + from ..handlers.filesystem_handler import FileSystemHandler + self._filesystem_handler = FileSystemHandler() + return self._filesystem_handler + + def list_modlists(self, game_type: Optional[str] = None) -> List[ModlistInfo]: + """List available modlists. + + Args: + game_type: Optional filter by game type + + Returns: + List of available modlists + """ + logger.info(f"Listing modlists for game_type: {game_type}") + + try: + # Use the working ModlistInstallCLI to get modlists from engine + from ..core.modlist_operations import ModlistInstallCLI + + # Use new SystemInfo pattern + modlist_cli = ModlistInstallCLI(self.system_info) + + # Get all modlists and do client-side filtering for better control + raw_modlists = modlist_cli.get_all_modlists_from_engine(game_type=None) + + # Apply client-side filtering based on game_type + if game_type: + game_type_lower = game_type.lower() + + if game_type_lower == 'skyrim': + # Include both "Skyrim" and "Skyrim Special Edition" and "Skyrim VR" + raw_modlists = [m for m in raw_modlists if 'skyrim' in m.get('game', '').lower()] + + elif game_type_lower == 'fallout4': + raw_modlists = [m for m in raw_modlists if 'fallout 4' in m.get('game', '').lower()] + + elif game_type_lower == 'falloutnv': + raw_modlists = [m for m in raw_modlists if 'fallout new vegas' in m.get('game', '').lower()] + + elif game_type_lower == 'oblivion': + raw_modlists = [m for m in raw_modlists if 'oblivion' in m.get('game', '').lower() and 'remastered' not in m.get('game', '').lower()] + + elif game_type_lower == 'starfield': + raw_modlists = [m for m in raw_modlists if 'starfield' in m.get('game', '').lower()] + + elif game_type_lower == 'oblivion_remastered': + raw_modlists = [m for m in raw_modlists if 'oblivion remastered' in m.get('game', '').lower()] + + elif game_type_lower == 'enderal': + raw_modlists = [m for m in raw_modlists if 'enderal' in m.get('game', '').lower()] + + elif game_type_lower == 'other': + # Exclude all main category games to show only "Other" games + main_category_keywords = ['skyrim', 'fallout 4', 'fallout new vegas', 'oblivion', 'starfield', 'enderal'] + def is_main_category(game_name): + game_lower = game_name.lower() + return any(keyword in game_lower for keyword in main_category_keywords) + + raw_modlists = [m for m in raw_modlists if not is_main_category(m.get('game', ''))] + + # Convert to ModlistInfo objects with enhanced metadata + modlists = [] + for m_info in raw_modlists: + modlist_info = ModlistInfo( + id=m_info.get('id', ''), + name=m_info.get('name', m_info.get('id', '')), # Use name from enhanced data + game=m_info.get('game', ''), + description='', # Engine doesn't provide description yet + version='', # Engine doesn't provide version yet + size=f"{m_info.get('download_size', '')}|{m_info.get('install_size', '')}|{m_info.get('total_size', '')}" # Store all three sizes + ) + + # Add enhanced metadata as additional properties + if hasattr(modlist_info, '__dict__'): + modlist_info.__dict__.update({ + 'download_size': m_info.get('download_size', ''), + 'install_size': m_info.get('install_size', ''), + 'total_size': m_info.get('total_size', ''), + 'machine_url': m_info.get('machine_url', ''), # Store machine URL for installation + 'status_down': m_info.get('status_down', False), + 'status_nsfw': m_info.get('status_nsfw', False) + }) + + # No client-side filtering needed - engine handles it + modlists.append(modlist_info) + + logger.info(f"Found {len(modlists)} modlists") + return modlists + + except Exception as e: + logger.error(f"Failed to list modlists: {e}") + raise + + def install_modlist(self, context: ModlistContext, + progress_callback=None, + output_callback=None) -> bool: + """Install a modlist (ONLY installation, no configuration). + + This method only runs the engine installation phase. + Configuration must be called separately after Steam setup. + + Args: + context: Modlist installation context + progress_callback: Optional callback for progress updates + output_callback: Optional callback for output/logging + + Returns: + True if installation successful, False otherwise + """ + logger.info(f"Installing modlist (INSTALLATION ONLY): {context.name}") + + try: + # Validate context + if not self._validate_install_context(context): + logger.error("Invalid installation context") + return False + + # Prepare directories + fs_handler = self._get_filesystem_handler() + fs_handler.ensure_directory(context.install_dir) + fs_handler.ensure_directory(context.download_dir) + + # Use the working ModlistInstallCLI for discovery phase only + from ..core.modlist_operations import ModlistInstallCLI + + # Use new SystemInfo pattern + modlist_cli = ModlistInstallCLI(self.system_info) + + # Build context for ModlistInstallCLI + install_context = { + 'modlist_name': context.name, + 'install_dir': context.install_dir, + 'download_dir': context.download_dir, + 'nexus_api_key': context.nexus_api_key, + 'game_type': context.game_type, + 'modlist_value': context.modlist_value, + 'resolution': getattr(context, 'resolution', None), + 'skip_confirmation': True # Service layer should be non-interactive + } + + # Set GUI mode for non-interactive operation + import os + original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') + os.environ['JACKIFY_GUI_MODE'] = '1' + + try: + # Run discovery phase with pre-filled context + confirmed_context = modlist_cli.run_discovery_phase(context_override=install_context) + if not confirmed_context: + logger.error("Discovery phase failed or was cancelled") + return False + + # Now run ONLY the installation part (NOT configuration) + success = self._run_installation_only( + confirmed_context, + progress_callback=progress_callback, + output_callback=output_callback + ) + + if success: + logger.info("Modlist installation completed successfully (configuration will be done separately)") + return True + else: + logger.error("Modlist installation failed") + return False + + finally: + # Restore original GUI mode + if original_gui_mode is not None: + os.environ['JACKIFY_GUI_MODE'] = original_gui_mode + else: + os.environ.pop('JACKIFY_GUI_MODE', None) + + except Exception as e: + error_message = str(e) + logger.error(f"Failed to install modlist {context.name}: {error_message}") + + # Check for file descriptor limit issues and attempt to handle them + from .resource_manager import handle_file_descriptor_error + try: + if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']): + result = handle_file_descriptor_error(error_message, "modlist installation") + if result['auto_fix_success']: + logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") + elif result['error_detected']: + logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}") + if result['manual_instructions']: + distro = result['manual_instructions']['distribution'] + logger.info(f"Manual ulimit increase instructions available for {distro} distribution") + except Exception as resource_error: + logger.debug(f"Error checking for resource limit issues: {resource_error}") + + return False + + def _run_installation_only(self, context, progress_callback=None, output_callback=None) -> bool: + """Run only the installation phase using the engine (COPIED FROM WORKING CODE).""" + import subprocess + import os + import sys + from pathlib import Path + from ..core.modlist_operations import get_jackify_engine_path + + try: + # COPIED EXACTLY from working Archive_Do_Not_Write/modules/modlist_install_cli.py + + # Process paths (copied from working code) + install_dir_context = context['install_dir'] + if isinstance(install_dir_context, tuple): + actual_install_path = Path(install_dir_context[0]) + if install_dir_context[1]: + actual_install_path.mkdir(parents=True, exist_ok=True) + else: + actual_install_path = Path(install_dir_context) + install_dir_str = str(actual_install_path) + + download_dir_context = context['download_dir'] + if isinstance(download_dir_context, tuple): + actual_download_path = Path(download_dir_context[0]) + if download_dir_context[1]: + actual_download_path.mkdir(parents=True, exist_ok=True) + else: + actual_download_path = Path(download_dir_context) + download_dir_str = str(actual_download_path) + + api_key = context['nexus_api_key'] + + # Path to the engine binary (copied from working code) + engine_path = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_path) + if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): + if output_callback: + output_callback(f"Jackify Install Engine not found or not executable at: {engine_path}") + return False + + # Build command (copied from working code) + cmd = [engine_path, 'install'] + modlist_value = context.get('modlist_value') + if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): + cmd += ['-w', modlist_value] + elif modlist_value: + cmd += ['-m', modlist_value] + elif context.get('machineid'): + cmd += ['-m', context['machineid']] + cmd += ['-o', install_dir_str, '-d', download_dir_str] + + # 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) + original_env_values = { + 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), + 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') + } + + try: + # Environment setup (copied from working code) + if api_key: + os.environ['NEXUS_API_KEY'] = api_key + elif 'NEXUS_API_KEY' in os.environ: + del os.environ['NEXUS_API_KEY'] + + os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" + + pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) + if output_callback: + output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}") + + # Temporarily increase file descriptor limit for engine process + from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit + success, old_limit, new_limit, message = increase_file_descriptor_limit() + if output_callback: + if success: + output_callback(f"File descriptor limit: {message}") + else: + output_callback(f"File descriptor limit warning: {message}") + + # Subprocess call (copied from working code) + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=None, cwd=engine_dir) + + # Output processing (copied from working code) + buffer = b'' + while True: + chunk = proc.stdout.read(1) + if not chunk: + break + buffer += chunk + + if chunk == b'\n': + line = buffer.decode('utf-8', errors='replace') + if output_callback: + output_callback(line.rstrip()) + buffer = b'' + elif chunk == b'\r': + line = buffer.decode('utf-8', errors='replace') + if output_callback: + output_callback(line.rstrip()) + buffer = b'' + + if buffer: + line = buffer.decode('utf-8', errors='replace') + if output_callback: + output_callback(line.rstrip()) + + proc.wait() + if proc.returncode != 0: + if output_callback: + output_callback(f"Jackify Install Engine exited with code {proc.returncode}.") + return False + else: + if output_callback: + output_callback("Installation completed successfully") + return True + + finally: + # Restore environment (copied from working code) + for key, original_value in original_env_values.items(): + if original_value is not None: + os.environ[key] = original_value + else: + if key in os.environ: + del os.environ[key] + + except Exception as e: + error_msg = f"Error running Jackify Install Engine: {e}" + logger.error(error_msg) + if output_callback: + output_callback(error_msg) + return False + + def configure_modlist_post_steam(self, context: ModlistContext, + progress_callback=None, + manual_steps_callback=None, + completion_callback=None) -> bool: + """Configure a modlist after Steam setup is complete. + + This method should only be called AFTER: + 1. Modlist installation is complete + 2. Steam shortcut has been created + 3. Steam has been restarted + 4. Manual Proton steps have been completed + + Args: + context: Modlist context with updated app_id + progress_callback: Optional callback for progress updates + manual_steps_callback: Called when manual steps needed + completion_callback: Called when configuration is complete + + Returns: + True if configuration successful, False otherwise + """ + logger.info(f"Configuring modlist after Steam setup: {context.name}") + + # Check if debug mode is enabled and create debug callback + from ..handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + + def debug_callback(message): + """Send debug message to GUI if debug mode is enabled""" + if debug_mode and progress_callback: + progress_callback(f"[DEBUG] {message}") + + debug_callback(f"Starting configuration for {context.name}") + debug_callback(f"Debug mode enabled: {debug_mode}") + debug_callback(f"Install directory: {context.install_dir}") + debug_callback(f"Resolution: {getattr(context, 'resolution', 'Not set')}") + debug_callback(f"App ID: {getattr(context, 'app_id', 'Not set')}") + + # Set up a custom logging handler to capture backend DEBUG messages + gui_log_handler = None + if debug_mode and progress_callback: + import logging + + class GuiLogHandler(logging.Handler): + def __init__(self, callback): + super().__init__() + self.callback = callback + self.setLevel(logging.DEBUG) + + def emit(self, record): + try: + msg = self.format(record) + if record.levelno == logging.DEBUG: + self.callback(f"[DEBUG] {msg}") + elif record.levelno >= logging.WARNING: + self.callback(f"[{record.levelname}] {msg}") + except Exception: + pass + + gui_log_handler = GuiLogHandler(progress_callback) + gui_log_handler.setFormatter(logging.Formatter('%(message)s')) + + # Add the GUI handler to key backend loggers + backend_logger_names = [ + 'jackify.backend.handlers.menu_handler', + 'jackify.backend.handlers.modlist_handler', + 'jackify.backend.handlers.install_wabbajack_handler', + 'jackify.backend.handlers.wabbajack_handler', + 'jackify.backend.handlers.shortcut_handler', + 'jackify.backend.handlers.protontricks_handler', + 'jackify.backend.handlers.validation_handler', + 'jackify.backend.handlers.resolution_handler' + ] + + for logger_name in backend_logger_names: + backend_logger = logging.getLogger(logger_name) + backend_logger.addHandler(gui_log_handler) + backend_logger.setLevel(logging.DEBUG) + + debug_callback("GUI logging handler installed for backend services") + + try: + # COPY THE WORKING LOGIC: Use menu handler for configuration only + from ..handlers.menu_handler import ModlistMenuHandler + + # Initialize handlers (same as working code) + modlist_menu = ModlistMenuHandler(config_handler) + + # Build configuration context (copied from working code) + config_context = { + 'name': context.name, + 'path': str(context.install_dir), + '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': True, # Manual steps were done in GUI + 'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam + 'engine_installed': getattr(context, 'engine_installed', False) # Path manipulation flag + } + + debug_callback(f"Configuration context built: {config_context}") + debug_callback("Setting up GUI mode and stdout redirection") + + # Set GUI mode for proper callback handling + import os + original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') + original_stdout = None + + try: + # Force GUI mode to prevent input prompts + os.environ['JACKIFY_GUI_MODE'] = '1' + + # CRITICAL FIX: Redirect print output to capture progress messages + import sys + from io import StringIO + + # Create a custom stdout that forwards to GUI + class GuiRedirectStdout: + def __init__(self, callback): + self.callback = callback + self.buffer = "" + + def write(self, text): + if self.callback and text.strip(): + # Convert ANSI codes to HTML for colored GUI output + try: + from ...frontends.gui.utils import ansi_to_html + # Clean up carriage returns but preserve ANSI colors + clean_text = text.replace('\r', '').strip() + if clean_text and clean_text != "Current Task: ": + # Convert ANSI to HTML for colored display + html_text = ansi_to_html(clean_text) + self.callback(html_text) + except ImportError: + # Fallback: strip ANSI codes if ansi_to_html not available + import re + clean_text = re.sub(r'\x1b\[[0-9;]*[mK]', '', text) + clean_text = clean_text.replace('\r', '').strip() + if clean_text and clean_text != "Current Task: ": + self.callback(clean_text) + return len(text) + + def flush(self): + pass + + # Redirect stdout to capture print statements + if progress_callback: + original_stdout = sys.stdout + sys.stdout = GuiRedirectStdout(progress_callback) + + # Call the working configuration-only method + debug_callback("Calling run_modlist_configuration_phase") + success = modlist_menu.run_modlist_configuration_phase(config_context) + debug_callback(f"Configuration phase result: {success}") + + # Restore stdout before calling completion callback + if original_stdout: + sys.stdout = original_stdout + original_stdout = None + + if completion_callback: + if success: + debug_callback("Configuration completed successfully, calling completion callback") + completion_callback(True, "Configuration completed successfully!", context.name) + else: + debug_callback("Configuration failed, calling completion callback with failure") + completion_callback(False, "Configuration failed", context.name) + + return success + + finally: + # Always restore stdout and environment + if original_stdout: + sys.stdout = original_stdout + + if original_gui_mode is not None: + os.environ['JACKIFY_GUI_MODE'] = original_gui_mode + else: + os.environ.pop('JACKIFY_GUI_MODE', None) + + # Remove GUI log handler to avoid memory leaks + if gui_log_handler: + for logger_name in [ + 'jackify.backend.handlers.menu_handler', + 'jackify.backend.handlers.modlist_handler', + 'jackify.backend.handlers.install_wabbajack_handler', + 'jackify.backend.handlers.wabbajack_handler', + 'jackify.backend.handlers.shortcut_handler', + 'jackify.backend.handlers.protontricks_handler', + 'jackify.backend.handlers.validation_handler', + 'jackify.backend.handlers.resolution_handler' + ]: + backend_logger = logging.getLogger(logger_name) + backend_logger.removeHandler(gui_log_handler) + + except Exception as e: + logger.error(f"Failed to configure modlist {context.name}: {e}") + if completion_callback: + completion_callback(False, f"Configuration failed: {e}", context.name) + + # Clean up GUI log handler on exception + if gui_log_handler: + for logger_name in [ + 'jackify.backend.handlers.menu_handler', + 'jackify.backend.handlers.modlist_handler', + 'jackify.backend.handlers.install_wabbajack_handler', + 'jackify.backend.handlers.wabbajack_handler', + 'jackify.backend.handlers.shortcut_handler', + 'jackify.backend.handlers.protontricks_handler', + 'jackify.backend.handlers.validation_handler', + 'jackify.backend.handlers.resolution_handler' + ]: + backend_logger = logging.getLogger(logger_name) + backend_logger.removeHandler(gui_log_handler) + + return False + + def configure_modlist(self, context: ModlistContext, + progress_callback=None, + manual_steps_callback=None, + completion_callback=None, + output_callback=None) -> bool: + """Configure a modlist after installation. + + Args: + context: Modlist context + progress_callback: Optional callback for progress updates + manual_steps_callback: Optional callback for manual steps + completion_callback: Optional callback for completion + output_callback: Optional callback for output/logging + + Returns: + True if configuration successful, False otherwise + """ + logger.info(f"Configuring modlist: {context.name}") + + try: + # Use the working ModlistMenuHandler for configuration + from ..handlers.menu_handler import ModlistMenuHandler + from ..handlers.config_handler import ConfigHandler + + config_handler = ConfigHandler() + modlist_menu = ModlistMenuHandler(config_handler) + + # Build configuration context + config_context = { + 'name': context.name, + 'path': str(context.install_dir), + '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 + } + + # Run the complete configuration phase + success = modlist_menu.run_modlist_configuration_phase(config_context) + + if success: + logger.info("Modlist configuration completed successfully") + if completion_callback: + completion_callback(True, "Configuration completed successfully", context.name) + else: + logger.warning("Modlist configuration had issues") + if completion_callback: + completion_callback(False, "Configuration failed", context.name) + + return success + + except Exception as e: + logger.error(f"Failed to configure modlist {context.name}: {e}") + return False + + def _validate_install_context(self, context: ModlistContext) -> bool: + """Validate that the installation context is complete and valid. + + Args: + context: The context to validate + + Returns: + True if valid, False otherwise + """ + if not context.name: + logger.error("Modlist name is required") + return False + + if not context.install_dir: + logger.error("Install directory is required") + return False + + if not context.download_dir: + logger.error("Download directory is required") + return False + + if not context.nexus_api_key: + logger.error("Nexus API key is required") + return False + + if not context.game_type: + logger.error("Game type is required") + return False + + return True \ No newline at end of file diff --git a/jackify/backend/services/native_steam_service.py b/jackify/backend/services/native_steam_service.py new file mode 100644 index 0000000..22884a1 --- /dev/null +++ b/jackify/backend/services/native_steam_service.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +""" +Native Steam Shortcut and Proton Management Service + +This service replaces STL entirely with native Python VDF manipulation. +Handles both shortcut creation and Proton version setting reliably. +""" + +import os +import sys +import time +import logging +import hashlib +import vdf +from pathlib import Path +from typing import Optional, Tuple, Dict, Any, List + +logger = logging.getLogger(__name__) + +class NativeSteamService: + """ + Native Steam shortcut and Proton management service. + + This completely replaces STL with reliable VDF manipulation that: + 1. Creates shortcuts with proper VDF structure + 2. Sets Proton versions in the correct config files + 3. Never corrupts existing shortcuts + """ + + def __init__(self): + self.steam_path = Path.home() / ".steam" / "steam" + self.userdata_path = self.steam_path / "userdata" + self.user_id = None + self.user_config_path = None + + def find_steam_user(self) -> bool: + """Find the active Steam user directory""" + try: + if not self.userdata_path.exists(): + logger.error("Steam userdata directory not 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") + 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}") + logger.info(f"User config path: {self.user_config_path}") + return True + + except Exception as e: + logger.error(f"Error finding Steam user: {e}") + return False + + def get_shortcuts_vdf_path(self) -> Optional[Path]: + """Get the path to shortcuts.vdf""" + if not self.user_config_path: + if not self.find_steam_user(): + return None + + shortcuts_path = self.user_config_path / "shortcuts.vdf" + return shortcuts_path if shortcuts_path.exists() else shortcuts_path + + def get_localconfig_vdf_path(self) -> Optional[Path]: + """Get the path to localconfig.vdf""" + if not self.user_config_path: + if not self.find_steam_user(): + return None + + return self.user_config_path / "localconfig.vdf" + + def read_shortcuts_vdf(self) -> Dict[str, Any]: + """Read the shortcuts.vdf file safely""" + shortcuts_path = self.get_shortcuts_vdf_path() + if not shortcuts_path: + return {'shortcuts': {}} + + try: + if shortcuts_path.exists(): + with open(shortcuts_path, 'rb') as f: + data = vdf.binary_load(f) + return data + else: + logger.info("shortcuts.vdf does not exist, will create new one") + return {'shortcuts': {}} + + except Exception as e: + logger.error(f"Error reading shortcuts.vdf: {e}") + return {'shortcuts': {}} + + def write_shortcuts_vdf(self, data: Dict[str, Any]) -> bool: + """Write the shortcuts.vdf file safely""" + shortcuts_path = self.get_shortcuts_vdf_path() + if not shortcuts_path: + return False + + try: + # Create backup first + if shortcuts_path.exists(): + backup_path = shortcuts_path.with_suffix(f".vdf.backup_{int(time.time())}") + import shutil + shutil.copy2(shortcuts_path, backup_path) + logger.info(f"Created backup: {backup_path}") + + # Ensure parent directory exists + shortcuts_path.parent.mkdir(parents=True, exist_ok=True) + + # Write the VDF file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(data, f) + + logger.info("Successfully wrote shortcuts.vdf") + return True + + except Exception as e: + logger.error(f"Error writing shortcuts.vdf: {e}") + return False + + def generate_app_id(self, app_name: str, exe_path: str) -> Tuple[int, int]: + """ + Generate AppID using STL's exact algorithm (MD5-based). + + This matches STL's generateShortcutVDFAppId and generateSteamShortID functions: + 1. Combine AppName + ExePath + 2. Generate MD5 hash, take first 8 characters + 3. Convert to decimal, make negative, ensure < 1 billion + 4. Convert signed to unsigned for CompatToolMapping + + Returns: + (signed_app_id, unsigned_app_id) - Both the signed and unsigned versions + """ + # STL's algorithm: MD5 of app_name + exe_path + input_string = f"{app_name}{exe_path}" + + # Generate MD5 hash and take first 8 characters + md5_hash = hashlib.md5(input_string.encode('utf-8')).hexdigest() + seed = md5_hash[:8] + + # Convert hex to decimal and make it negative with modulo 1 billion + seed_decimal = int(seed, 16) + signed_app_id = -(seed_decimal % 1000000000) + + # Convert to unsigned using steam-conductor/trentondyck method (signed_app_id + 2^32) + unsigned_app_id = signed_app_id + 2**32 + + logger.info(f"Generated AppID using STL algorithm for '{app_name}' + '{exe_path}': {signed_app_id} (unsigned: {unsigned_app_id})") + return signed_app_id, unsigned_app_id + + def create_shortcut(self, app_name: str, exe_path: str, start_dir: str = None, + launch_options: str = "%command%", tags: List[str] = None) -> Tuple[bool, Optional[int]]: + """ + Create a Steam shortcut using direct VDF manipulation. + + Args: + app_name: The shortcut name + exe_path: Path to the executable + start_dir: Start directory (defaults to exe directory) + launch_options: Launch options (defaults to "%command%") + tags: List of tags to apply + + Returns: + (success, unsigned_app_id) - Success status and the AppID + """ + if not start_dir: + start_dir = str(Path(exe_path).parent) + + if not tags: + tags = ["Jackify"] + + logger.info(f"Creating shortcut '{app_name}' for '{exe_path}'") + + try: + # Read current shortcuts + data = self.read_shortcuts_vdf() + shortcuts = data.get('shortcuts', {}) + + # Generate AppID + signed_app_id, unsigned_app_id = self.generate_app_id(app_name, exe_path) + + # Find next available index + indices = [int(k) for k in shortcuts.keys() if k.isdigit()] + next_index = max(indices, default=-1) + 1 + + # Get icon path from SteamIcons directory if available + icon_path = '' + steamicons_dir = Path(exe_path).parent / "SteamIcons" + if steamicons_dir.is_dir(): + grid_tall_icon = steamicons_dir / "grid-tall.png" + if grid_tall_icon.exists(): + icon_path = str(grid_tall_icon) + logger.info(f"Using icon from SteamIcons: {icon_path}") + else: + # Look for any PNG file + png_files = list(steamicons_dir.glob("*.png")) + if png_files: + icon_path = str(png_files[0]) + logger.info(f"Using fallback icon: {icon_path}") + + # Create the shortcut entry with proper structure + shortcut_entry = { + 'appid': signed_app_id, # Use signed AppID in shortcuts.vdf + 'AppName': app_name, + 'Exe': f'"{exe_path}"', + 'StartDir': f'"{start_dir}"', + 'icon': icon_path, + 'ShortcutPath': '', + 'LaunchOptions': launch_options, + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'OpenVR': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'DevkitOverrideAppID': 0, + 'LastPlayTime': 0, + 'IsInstalled': 1, # Mark as installed so it appears in "Installed locally" + 'FlatpakAppID': '', + 'tags': {} + } + + # Add tags + for i, tag in enumerate(tags): + shortcut_entry['tags'][str(i)] = tag + + # Add to shortcuts + shortcuts[str(next_index)] = shortcut_entry + data['shortcuts'] = shortcuts + + # Write back to file + if self.write_shortcuts_vdf(data): + logger.info(f"✅ Shortcut created successfully at index {next_index}") + return True, unsigned_app_id + else: + logger.error("❌ Failed to write shortcut to VDF") + return False, None + + except Exception as 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 + proton_version: The Proton version to set + + Returns: + True if successful + """ + 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" + + if not config_path.exists(): + logger.error(f"Steam config.vdf not found at: {config_path}") + return False + + # Create backup first + backup_path = config_path.with_suffix(f".vdf.backup_{int(time.time())}") + import shutil + shutil.copy2(config_path, backup_path) + logger.info(f"Created backup: {backup_path}") + + # Read the file as text to avoid VDF library formatting issues + with open(config_path, 'r', encoding='utf-8', errors='ignore') as f: + config_text = f.read() + + # 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 + + # Find the closing brace for CompatToolMapping + # Look for the opening brace after CompatToolMapping + brace_start = config_text.find('{', compat_start) + if brace_start == -1: + logger.error("CompatToolMapping opening brace not found") + return False + + # Count braces to find the matching closing brace + brace_count = 1 + pos = brace_start + 1 + compat_end = -1 + + while pos < len(config_text) and brace_count > 0: + if config_text[pos] == '{': + brace_count += 1 + elif config_text[pos] == '}': + brace_count -= 1 + if brace_count == 0: + compat_end = pos + break + pos += 1 + + if compat_end == -1: + logger.error("CompatToolMapping closing brace not found") + return False + + # Check if this AppID already exists + app_id_pattern = f'"{app_id}"' + app_id_exists = app_id_pattern in config_text[compat_start:compat_end] + + if app_id_exists: + logger.info(f"AppID {app_id} already exists in CompatToolMapping, will be overwritten") + # Remove the existing entry by finding and removing the entire block + # This is complex, so for now just add at the end + + # Create the new entry in STL's exact format (tabs between key and value) + new_entry = f'\t\t\t\t\t"{app_id}"\n\t\t\t\t\t{{\n\t\t\t\t\t\t"name"\t\t"{proton_version}"\n\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"250"\n\t\t\t\t\t}}\n' + + # Insert the new entry just before the closing brace of CompatToolMapping + new_config_text = config_text[:compat_end] + new_entry + config_text[compat_end:] + + # Write back the modified text + 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)") + return True + + except Exception as 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, + launch_options: str = "%command%", tags: List[str] = None, + proton_version: str = "proton_experimental") -> 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 + """ + logger.info(f"Creating shortcut with Proton: '{app_name}' -> '{proton_version}'") + + # Step 1: Create the shortcut + success, app_id = self.create_shortcut(app_name, exe_path, start_dir, launch_options, tags) + if not success: + logger.error("Failed to create shortcut") + return False, None + + # Step 2: Set the Proton version + if not self.set_proton_version(app_id, proton_version): + 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}'") + return True, app_id + + def list_shortcuts(self) -> Dict[str, str]: + """List all existing shortcuts (for debugging)""" + shortcuts = self.read_shortcuts_vdf().get('shortcuts', {}) + shortcut_list = {} + + for index, shortcut in shortcuts.items(): + app_name = shortcut.get('AppName', 'Unknown') + shortcut_list[index] = app_name + + return shortcut_list + + def remove_shortcut(self, app_name: str) -> bool: + """Remove a shortcut by name""" + try: + data = self.read_shortcuts_vdf() + shortcuts = data.get('shortcuts', {}) + + # Find shortcut by name + to_remove = None + for index, shortcut in shortcuts.items(): + if shortcut.get('AppName') == app_name: + to_remove = index + break + + if to_remove is None: + logger.warning(f"Shortcut '{app_name}' not found") + return False + + # Remove the shortcut + del shortcuts[to_remove] + data['shortcuts'] = shortcuts + + # Write back + if self.write_shortcuts_vdf(data): + logger.info(f"✅ Removed shortcut '{app_name}'") + return True + else: + logger.error("❌ Failed to write updated shortcuts") + return False + + except Exception as e: + logger.error(f"❌ Error removing shortcut: {e}") + return False \ No newline at end of file diff --git a/jackify/backend/services/protontricks_detection_service.py b/jackify/backend/services/protontricks_detection_service.py new file mode 100644 index 0000000..3c85ade --- /dev/null +++ b/jackify/backend/services/protontricks_detection_service.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Protontricks Detection Service Module +Centralized service for detecting and managing protontricks installation across CLI and GUI frontends +""" + +import logging +import shutil +import subprocess +from typing import Optional, Tuple +from ..handlers.protontricks_handler import ProtontricksHandler +from ..handlers.config_handler import ConfigHandler + +# Initialize logger +logger = logging.getLogger(__name__) + + +class ProtontricksDetectionService: + """ + Centralized service for detecting and managing protontricks installation + Handles detection, validation, and installation guidance for both CLI and GUI + """ + + def __init__(self, steamdeck: bool = False): + """ + Initialize the protontricks detection service + + Args: + steamdeck (bool): Whether running on Steam Deck + """ + self.steamdeck = steamdeck + self.config_handler = ConfigHandler() + self._protontricks_handler = None + self._last_detection_result = None + self._cached_detection_valid = False + logger.debug(f"ProtontricksDetectionService initialized (steamdeck={steamdeck})") + + def _get_protontricks_handler(self) -> ProtontricksHandler: + """Get or create ProtontricksHandler instance""" + if self._protontricks_handler is None: + self._protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck) + return self._protontricks_handler + + def detect_protontricks(self, use_cache: bool = True) -> Tuple[bool, str, str]: + """ + Detect if protontricks is installed and get installation details + + Args: + use_cache (bool): Whether to use cached detection result + + Returns: + Tuple[bool, str, str]: (is_installed, installation_type, details_message) + - is_installed: True if protontricks is available + - installation_type: 'native', 'flatpak', or 'none' + - details_message: Human-readable status message + """ + if use_cache and self._cached_detection_valid and self._last_detection_result: + logger.debug("Using cached protontricks detection result") + return self._last_detection_result + + logger.info("Detecting protontricks installation...") + + handler = self._get_protontricks_handler() + + # Reset handler state for fresh detection + handler.which_protontricks = None + handler.protontricks_path = None + handler.protontricks_version = None + + # Perform detection without user prompts + is_installed = self._detect_without_prompts(handler) + + # Determine installation type and create message + if is_installed: + installation_type = handler.which_protontricks or 'unknown' + if installation_type == 'native': + details_message = f"Native protontricks found at {handler.protontricks_path}" + elif installation_type == 'flatpak': + details_message = "Flatpak protontricks is installed" + else: + details_message = "Protontricks is installed (unknown type)" + else: + installation_type = 'none' + details_message = "Protontricks not found - required for Jackify functionality" + + # Cache the result + self._last_detection_result = (is_installed, installation_type, details_message) + self._cached_detection_valid = True + + logger.info(f"Protontricks detection complete: {details_message}") + return self._last_detection_result + + def _detect_without_prompts(self, handler: ProtontricksHandler) -> bool: + """ + Detect protontricks without user prompts or installation attempts + + Args: + handler (ProtontricksHandler): Handler instance to use + + Returns: + bool: True if protontricks is found + """ + import shutil + + # Check if protontricks exists as a command + protontricks_path_which = shutil.which("protontricks") + + if protontricks_path_which: + # Check if it's a flatpak wrapper + try: + with open(protontricks_path_which, 'r') as f: + content = f.read() + if "flatpak run" in content: + logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}") + handler.which_protontricks = 'flatpak' + # Continue to check flatpak list just to be sure + else: + logger.info(f"Native Protontricks found at {protontricks_path_which}") + handler.which_protontricks = 'native' + handler.protontricks_path = protontricks_path_which + return True + except Exception as e: + logger.error(f"Error reading protontricks executable: {e}") + + # Check if flatpak protontricks is installed + try: + env = handler._get_clean_subprocess_env() + result = subprocess.run( + ["flatpak", "list"], + capture_output=True, + text=True, + check=True, + env=env + ) + if "com.github.Matoking.protontricks" in result.stdout: + logger.info("Flatpak Protontricks is installed") + handler.which_protontricks = 'flatpak' + return True + except FileNotFoundError: + logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.") + except subprocess.CalledProcessError as e: + logger.warning(f"Error checking flatpak list: {e}") + except Exception as e: + logger.error(f"Unexpected error checking flatpak: {e}") + + return False + + def install_flatpak_protontricks(self) -> Tuple[bool, str]: + """ + Install protontricks via Flatpak + + Returns: + Tuple[bool, str]: (success, message) + """ + logger.info("Attempting to install Flatpak Protontricks...") + + try: + handler = self._get_protontricks_handler() + + # Check if flatpak is available + if not shutil.which("flatpak"): + error_msg = "Flatpak not found. Please install Flatpak first." + logger.error(error_msg) + return False, error_msg + + # Install command + install_cmd = ["flatpak", "install", "-u", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"] + + # Use clean environment + env = handler._get_clean_subprocess_env() + + # Run installation + process = subprocess.run(install_cmd, check=True, text=True, env=env, capture_output=True) + + # Clear cache to force re-detection + self._cached_detection_valid = False + + success_msg = "Flatpak Protontricks installed successfully." + logger.info(success_msg) + return True, success_msg + + except FileNotFoundError: + error_msg = "Flatpak command not found. Please install Flatpak first." + logger.error(error_msg) + return False, error_msg + except subprocess.CalledProcessError as e: + error_msg = f"Flatpak installation failed: {e}" + logger.error(error_msg) + return False, error_msg + except Exception as e: + error_msg = f"Unexpected error during Flatpak installation: {e}" + logger.error(error_msg) + return False, error_msg + + def get_installation_guidance(self) -> str: + """ + Get guidance message for installing protontricks natively + + Returns: + str: Installation guidance message + """ + return """To install protontricks natively, use your distribution's package manager: + +• Arch Linux: sudo pacman -S protontricks +• Ubuntu/Debian: sudo apt install protontricks +• Fedora: sudo dnf install protontricks +• OpenSUSE: sudo zypper install protontricks +• Gentoo: sudo emerge protontricks + +Alternatively, you can install via Flatpak: +flatpak install flathub com.github.Matoking.protontricks + +After installation, click "Re-detect" to continue.""" + + def clear_cache(self): + """Clear cached detection results to force re-detection""" + self._cached_detection_valid = False + self._last_detection_result = None + logger.debug("Protontricks detection cache cleared") \ No newline at end of file diff --git a/jackify/backend/services/resolution_service.py b/jackify/backend/services/resolution_service.py new file mode 100644 index 0000000..b95a0e2 --- /dev/null +++ b/jackify/backend/services/resolution_service.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Resolution Service Module +Centralized service for managing resolution settings across CLI and GUI frontends +""" + +import logging +from typing import Optional +from ..handlers.config_handler import ConfigHandler + +# Initialize logger +logger = logging.getLogger(__name__) + + +class ResolutionService: + """ + Centralized service for managing resolution settings + Handles saving, loading, and validation of resolution settings + """ + + def __init__(self): + """Initialize the resolution service""" + self.config_handler = ConfigHandler() + logger.debug("ResolutionService initialized") + + def save_resolution(self, resolution: str) -> bool: + """ + Save a resolution setting to configuration + + Args: + resolution (str): The resolution to save (e.g., '1920x1080') + + Returns: + bool: True if saved successfully, False otherwise + """ + try: + # Validate resolution format (basic check) + if not self._validate_resolution_format(resolution): + logger.warning("Invalid resolution format provided") + return False + + success = self.config_handler.save_resolution(resolution) + if success: + logger.info(f"Resolution saved successfully: {resolution}") + else: + logger.error("Failed to save resolution") + + return success + except Exception as e: + logger.error(f"Error in save_resolution: {e}") + return False + + def get_saved_resolution(self) -> Optional[str]: + """ + Retrieve the saved resolution from configuration + + Returns: + str: The saved resolution or None if not saved + """ + try: + resolution = self.config_handler.get_saved_resolution() + if resolution: + logger.debug(f"Retrieved saved resolution: {resolution}") + else: + logger.debug("No saved resolution found") + return resolution + except Exception as e: + logger.error(f"Error retrieving resolution: {e}") + return None + + def has_saved_resolution(self) -> bool: + """ + Check if a resolution is saved in configuration + + Returns: + bool: True if resolution exists, False otherwise + """ + try: + return self.config_handler.has_saved_resolution() + except Exception as e: + logger.error(f"Error checking for saved resolution: {e}") + return False + + def clear_saved_resolution(self) -> bool: + """ + Clear the saved resolution from configuration + + Returns: + bool: True if cleared successfully, False otherwise + """ + try: + success = self.config_handler.clear_saved_resolution() + if success: + logger.info("Resolution cleared successfully") + else: + logger.error("Failed to clear resolution") + return success + except Exception as e: + logger.error(f"Error clearing resolution: {e}") + return False + + def _validate_resolution_format(self, resolution: str) -> bool: + """ + Validate resolution format (e.g., '1920x1080' or '1280x800 (Steam Deck)') + + Args: + resolution (str): Resolution string to validate + + Returns: + bool: True if valid format, False otherwise + """ + import re + + if not resolution or resolution == 'Leave unchanged': + return True # Allow 'Leave unchanged' as valid + + # Handle Steam Deck format: '1280x800 (Steam Deck)' + if ' (Steam Deck)' in resolution: + resolution = resolution.replace(' (Steam Deck)', '') + + # Check for WxH format (e.g., 1920x1080) + if re.match(r'^[0-9]+x[0-9]+$', resolution): + # Extract width and height + try: + width, height = resolution.split('x') + width_int = int(width) + height_int = int(height) + + # Basic sanity checks + if width_int > 0 and height_int > 0 and width_int <= 10000 and height_int <= 10000: + return True + else: + logger.warning(f"Resolution dimensions out of reasonable range: {resolution}") + return False + except ValueError: + logger.warning(f"Invalid resolution format: {resolution}") + return False + else: + logger.warning(f"Resolution does not match WxH format: {resolution}") + return False + + def get_resolution_index(self, resolution: str, combo_items: list) -> int: + """ + Get the index of a resolution in a combo box list + + Args: + resolution (str): Resolution to find + combo_items (list): List of combo box items + + Returns: + int: Index of the resolution, or 0 (Leave unchanged) if not found + """ + if not resolution: + return 0 # Default to 'Leave unchanged' + + # Handle Steam Deck special case + if resolution == '1280x800' and '1280x800 (Steam Deck)' in combo_items: + return combo_items.index('1280x800 (Steam Deck)') + + # Try exact match first + if resolution in combo_items: + return combo_items.index(resolution) + + # Try partial match (e.g., '1920x1080' in '1920x1080 (Steam Deck)') + for i, item in enumerate(combo_items): + if resolution in item: + return i + + # Default to 'Leave unchanged' + return 0 diff --git a/jackify/backend/services/resource_manager.py b/jackify/backend/services/resource_manager.py new file mode 100644 index 0000000..283ae01 --- /dev/null +++ b/jackify/backend/services/resource_manager.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Resource Manager Module +Handles system resource limits for Jackify operations +""" + +import resource +import logging +import os +from typing import Tuple, Optional + +# Initialize logger +logger = logging.getLogger(__name__) + + +class ResourceManager: + """ + Manages system resource limits for Jackify operations + Focuses on file descriptor limits to resolve ulimit issues + """ + + # Target file descriptor limit based on successful user testing + TARGET_FILE_DESCRIPTORS = 64556 + + def __init__(self): + """Initialize the resource manager""" + self.original_limits = None + self.current_limits = None + self.target_achieved = False + logger.debug("ResourceManager initialized") + + def get_current_file_descriptor_limits(self) -> Tuple[int, int]: + """ + Get current file descriptor limits (soft, hard) + + Returns: + tuple: (soft_limit, hard_limit) + """ + try: + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + return soft, hard + except Exception as e: + logger.error(f"Error getting file descriptor limits: {e}") + return 0, 0 + + def increase_file_descriptor_limit(self, target_limit: Optional[int] = None) -> bool: + """ + Increase file descriptor limit to target value + + Args: + target_limit (int, optional): Target limit. Defaults to TARGET_FILE_DESCRIPTORS + + Returns: + bool: True if limit was increased or already adequate, False if failed + """ + if target_limit is None: + target_limit = self.TARGET_FILE_DESCRIPTORS + + try: + # Get current limits + current_soft, current_hard = self.get_current_file_descriptor_limits() + self.original_limits = (current_soft, current_hard) + + logger.info(f"Current file descriptor limits: soft={current_soft}, hard={current_hard}") + + # Check if we already have adequate limits + if current_soft >= target_limit: + logger.info(f"File descriptor limit already adequate: {current_soft} >= {target_limit}") + self.target_achieved = True + self.current_limits = (current_soft, current_hard) + return True + + # Calculate new soft limit (can't exceed hard limit) + new_soft = min(target_limit, current_hard) + + if new_soft <= current_soft: + logger.warning(f"Cannot increase file descriptor limit: hard limit ({current_hard}) too low for target ({target_limit})") + self.current_limits = (current_soft, current_hard) + return False + + # Attempt to set new limits + try: + resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, current_hard)) + + # Verify the change worked + verify_soft, verify_hard = self.get_current_file_descriptor_limits() + self.current_limits = (verify_soft, verify_hard) + + if verify_soft >= new_soft: + logger.info(f"Successfully increased file descriptor limit: {current_soft} -> {verify_soft}") + self.target_achieved = (verify_soft >= target_limit) + if not self.target_achieved: + logger.warning(f"Increased limit ({verify_soft}) is below target ({target_limit}) but above original ({current_soft})") + return True + else: + logger.error(f"File descriptor limit increase failed verification: expected {new_soft}, got {verify_soft}") + return False + + except (ValueError, OSError) as e: + logger.error(f"Failed to set file descriptor limit: {e}") + self.current_limits = (current_soft, current_hard) + return False + + except Exception as e: + logger.error(f"Error in increase_file_descriptor_limit: {e}") + return False + + def get_limit_status(self) -> dict: + """ + Get detailed status of file descriptor limits + + Returns: + dict: Status information about limits + """ + current_soft, current_hard = self.get_current_file_descriptor_limits() + + return { + 'current_soft': current_soft, + 'current_hard': current_hard, + 'original_limits': self.original_limits, + 'target_limit': self.TARGET_FILE_DESCRIPTORS, + 'target_achieved': self.target_achieved, + 'increase_needed': current_soft < self.TARGET_FILE_DESCRIPTORS, + 'can_increase': current_hard >= self.TARGET_FILE_DESCRIPTORS, + 'max_possible': current_hard + } + + def get_manual_increase_instructions(self) -> dict: + """ + Get distribution-specific instructions for manually increasing limits + + Returns: + dict: Instructions organized by distribution/method + """ + status = self.get_limit_status() + target = self.TARGET_FILE_DESCRIPTORS + + # Detect distribution + distro = self._detect_distribution() + + instructions = { + 'target_limit': target, + 'current_limit': status['current_soft'], + 'distribution': distro, + 'methods': {} + } + + # Temporary increase (all distributions) + instructions['methods']['temporary'] = { + 'title': 'Temporary Increase (Current Session Only)', + 'commands': [ + f'ulimit -n {target}', + 'jackify # Re-run Jackify after setting ulimit' + ], + 'note': 'This only affects the current terminal session' + } + + # Permanent increase (varies by distribution) + if distro in ['cachyos', 'arch', 'manjaro']: + instructions['methods']['permanent'] = { + 'title': 'Permanent Increase (Arch-based Systems)', + 'commands': [ + 'sudo nano /etc/security/limits.conf', + f'# Add these lines to the file:', + f'* soft nofile {target}', + f'* hard nofile {target}', + '# Save file and reboot, or logout/login' + ], + 'note': 'Requires root privileges and reboot/re-login' + } + elif distro in ['opensuse', 'suse']: + instructions['methods']['permanent'] = { + 'title': 'Permanent Increase (openSUSE)', + 'commands': [ + 'sudo nano /etc/security/limits.conf', + f'# Add these lines to the file:', + f'* soft nofile {target}', + f'* hard nofile {target}', + '# Save file and reboot, or logout/login', + '# Alternative: Set in systemd service file' + ], + 'note': 'May require additional systemd configuration on openSUSE' + } + else: + instructions['methods']['permanent'] = { + 'title': 'Permanent Increase (Generic Linux)', + 'commands': [ + 'sudo nano /etc/security/limits.conf', + f'# Add these lines to the file:', + f'* soft nofile {target}', + f'* hard nofile {target}', + '# Save file and reboot, or logout/login' + ], + 'note': 'Standard method for most Linux distributions' + } + + return instructions + + def _detect_distribution(self) -> str: + """ + Detect the Linux distribution + + Returns: + str: Distribution identifier + """ + try: + # Check /etc/os-release + if os.path.exists('/etc/os-release'): + with open('/etc/os-release', 'r') as f: + content = f.read().lower() + + if 'cachyos' in content: + return 'cachyos' + elif 'arch' in content: + return 'arch' + elif 'manjaro' in content: + return 'manjaro' + elif 'opensuse' in content or 'suse' in content: + return 'opensuse' + elif 'ubuntu' in content: + return 'ubuntu' + elif 'debian' in content: + return 'debian' + elif 'fedora' in content: + return 'fedora' + + # Fallback detection methods + if os.path.exists('/etc/arch-release'): + return 'arch' + elif os.path.exists('/etc/SuSE-release'): + return 'opensuse' + + except Exception as e: + logger.warning(f"Could not detect distribution: {e}") + + return 'unknown' + + def is_too_many_files_error(self, error_message: str) -> bool: + """ + Check if an error message indicates a 'too many open files' issue + + Args: + error_message (str): Error message to check + + Returns: + bool: True if error is related to file descriptor limits + """ + if not error_message: + return False + + error_lower = error_message.lower() + indicators = [ + 'too many open files', + 'too many files open', + 'cannot open', + 'emfile', # errno 24 + 'file descriptor', + 'ulimit', + 'resource temporarily unavailable' + ] + + return any(indicator in error_lower for indicator in indicators) + + def apply_recommended_limits(self) -> bool: + """ + Apply recommended resource limits for Jackify operations + + Returns: + bool: True if limits were successfully applied + """ + logger.info("Applying recommended resource limits for Jackify operations") + + # Focus on file descriptor limits as the primary issue + success = self.increase_file_descriptor_limit() + + if success: + status = self.get_limit_status() + logger.info(f"Resource limits applied successfully. Current file descriptors: {status['current_soft']}") + else: + logger.warning("Failed to apply optimal resource limits") + + return success + + def handle_too_many_files_error(self, error_message: str, context: str = "") -> dict: + """ + Handle a 'too many open files' error by attempting to increase limits and providing guidance + + Args: + error_message (str): The error message that triggered this handler + context (str): Additional context about where the error occurred + + Returns: + dict: Result of handling the error, including success status and guidance + """ + logger.warning(f"Detected 'too many open files' error in {context}: {error_message}") + + result = { + 'error_detected': True, + 'error_message': error_message, + 'context': context, + 'auto_fix_attempted': False, + 'auto_fix_success': False, + 'manual_instructions': None, + 'recommendation': '' + } + + # Check if this is actually a file descriptor limit error + if not self.is_too_many_files_error(error_message): + result['error_detected'] = False + return result + + # Get current status + status = self.get_limit_status() + + # Attempt automatic fix if we haven't already optimized + if not self.target_achieved and status['can_increase']: + logger.info("Attempting to automatically increase file descriptor limits...") + result['auto_fix_attempted'] = True + + success = self.increase_file_descriptor_limit() + result['auto_fix_success'] = success + + if success: + new_status = self.get_limit_status() + result['recommendation'] = f"File descriptor limit increased to {new_status['current_soft']}. Please retry the operation." + logger.info(f"Successfully increased file descriptor limit to {new_status['current_soft']}") + else: + result['recommendation'] = "Automatic limit increase failed. Manual intervention required." + logger.warning("Automatic file descriptor limit increase failed") + else: + result['recommendation'] = "File descriptor limits already at maximum or cannot be increased automatically." + + # Always provide manual instructions as fallback + result['manual_instructions'] = self.get_manual_increase_instructions() + + return result + + def show_guidance_dialog(self, parent=None): + """ + Show the ulimit guidance dialog (GUI only) + + Args: + parent: Parent widget for the dialog + + Returns: + Dialog result or None if not in GUI mode + """ + try: + # Only available in GUI mode + from jackify.frontends.gui.dialogs.ulimit_guidance_dialog import show_ulimit_guidance + return show_ulimit_guidance(parent, self) + except ImportError: + logger.debug("GUI ulimit guidance dialog not available (likely CLI mode)") + return None + + +# Convenience functions for easy use +def ensure_adequate_file_descriptor_limits() -> bool: + """ + Convenience function to ensure adequate file descriptor limits + + Returns: + bool: True if limits are adequate or were successfully increased + """ + manager = ResourceManager() + return manager.apply_recommended_limits() + + +def handle_file_descriptor_error(error_message: str, context: str = "") -> dict: + """ + Convenience function to handle file descriptor limit errors + + Args: + error_message (str): The error message that triggered this handler + context (str): Additional context about where the error occurred + + Returns: + dict: Result of handling the error, including success status and guidance + """ + manager = ResourceManager() + return manager.handle_too_many_files_error(error_message, context) + + +# Module-level testing +if __name__ == '__main__': + # Configure logging for testing + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + print("Testing ResourceManager...") + + manager = ResourceManager() + + # Show current status + status = manager.get_limit_status() + print(f"\nCurrent Status:") + print(f" Current soft limit: {status['current_soft']}") + print(f" Current hard limit: {status['current_hard']}") + print(f" Target limit: {status['target_limit']}") + print(f" Increase needed: {status['increase_needed']}") + print(f" Can increase: {status['can_increase']}") + + # Test limit increase + print(f"\nAttempting to increase limits...") + success = manager.apply_recommended_limits() + print(f"Success: {success}") + + # Show final status + final_status = manager.get_limit_status() + print(f"\nFinal Status:") + print(f" Current soft limit: {final_status['current_soft']}") + print(f" Target achieved: {final_status['target_achieved']}") + + # Test manual instructions + instructions = manager.get_manual_increase_instructions() + print(f"\nDetected distribution: {instructions['distribution']}") + print(f"Manual increase available if needed") + + print("\nTesting completed successfully!") \ No newline at end of file diff --git a/jackify/backend/services/steam_restart_service.py b/jackify/backend/services/steam_restart_service.py new file mode 100644 index 0000000..daa6184 --- /dev/null +++ b/jackify/backend/services/steam_restart_service.py @@ -0,0 +1,274 @@ +import os +import time +import subprocess +import signal +import psutil +import logging +import sys +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +def _get_clean_subprocess_env(): + """ + Create a clean environment for subprocess calls by removing PyInstaller-specific + environment variables that can interfere with Steam execution. + + Returns: + dict: Cleaned environment dictionary + """ + env = os.environ.copy() + pyinstaller_vars_removed = [] + + # Remove PyInstaller-specific environment variables + if env.pop('_MEIPASS', None): + pyinstaller_vars_removed.append('_MEIPASS') + if env.pop('_MEIPASS2', None): + pyinstaller_vars_removed.append('_MEIPASS2') + + # Clean library path variables that PyInstaller modifies (Linux/Unix) + if 'LD_LIBRARY_PATH_ORIG' in env: + # Restore original LD_LIBRARY_PATH if it was backed up by PyInstaller + env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG'] + pyinstaller_vars_removed.append('LD_LIBRARY_PATH (restored from _ORIG)') + else: + # Remove PyInstaller-modified LD_LIBRARY_PATH + if env.pop('LD_LIBRARY_PATH', None): + pyinstaller_vars_removed.append('LD_LIBRARY_PATH (removed)') + + # Clean PATH of PyInstaller-specific entries + if 'PATH' in env and hasattr(sys, '_MEIPASS'): + path_entries = env['PATH'].split(os.pathsep) + original_count = len(path_entries) + # Remove any PATH entries that point to PyInstaller temp directory + cleaned_path = [p for p in path_entries if not p.startswith(sys._MEIPASS)] + env['PATH'] = os.pathsep.join(cleaned_path) + if len(cleaned_path) < original_count: + pyinstaller_vars_removed.append(f'PATH (removed {original_count - len(cleaned_path)} PyInstaller entries)') + + # Clean macOS library path (if present) + if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'): + dyld_entries = env['DYLD_LIBRARY_PATH'].split(os.pathsep) + cleaned_dyld = [p for p in dyld_entries if not p.startswith(sys._MEIPASS)] + if cleaned_dyld: + env['DYLD_LIBRARY_PATH'] = os.pathsep.join(cleaned_dyld) + pyinstaller_vars_removed.append('DYLD_LIBRARY_PATH (cleaned)') + else: + env.pop('DYLD_LIBRARY_PATH', None) + pyinstaller_vars_removed.append('DYLD_LIBRARY_PATH (removed)') + + # Log what was cleaned for debugging + if pyinstaller_vars_removed: + logger.debug(f"Steam restart: Cleaned PyInstaller environment variables: {', '.join(pyinstaller_vars_removed)}") + else: + logger.debug("Steam restart: No PyInstaller environment variables detected (likely DEV mode)") + + return env + +class SteamRestartError(Exception): + pass + +def is_steam_deck() -> bool: + """Detect if running on Steam Deck/SteamOS.""" + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release', 'r') as f: + content = f.read().lower() + if 'steamos' in content or 'steam deck' in content: + return True + if os.path.exists('/sys/devices/virtual/dmi/id/product_name'): + with open('/sys/devices/virtual/dmi/id/product_name', 'r') as f: + if 'steam deck' in f.read().lower(): + return True + if os.environ.get('STEAM_RUNTIME') and os.path.exists('/home/deck'): + return True + except Exception as e: + logger.debug(f"Error detecting Steam Deck: {e}") + return False + +def get_steam_processes() -> list: + """Return a list of psutil.Process objects for running Steam processes.""" + steam_procs = [] + for proc in psutil.process_iter(['pid', 'name', 'exe', 'cmdline']): + try: + name = proc.info['name'] + exe = proc.info['exe'] + cmdline = proc.info['cmdline'] + if name and 'steam' in name.lower(): + steam_procs.append(proc) + elif exe and 'steam' in exe.lower(): + steam_procs.append(proc) + elif cmdline and any('steam' in str(arg).lower() for arg in cmdline): + steam_procs.append(proc) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + return steam_procs + +def wait_for_steam_exit(timeout: int = 60, check_interval: float = 0.5) -> bool: + """Wait for all Steam processes to exit using pgrep (matching existing logic).""" + start = time.time() + env = _get_clean_subprocess_env() + while time.time() - start < timeout: + try: + result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env) + if result.returncode != 0: + return True + except Exception as e: + logger.debug(f"Error checking Steam processes: {e}") + time.sleep(check_interval) + return False + +def start_steam() -> bool: + """Attempt to start Steam using the exact methods from existing working logic.""" + env = _get_clean_subprocess_env() + try: + # Try systemd user service (Steam Deck) + if is_steam_deck(): + subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env) + return True + + # Use startup methods with only -silent flag (no -minimized or -no-browser) + start_methods = [ + {"name": "Popen", "cmd": ["steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}}, + {"name": "setsid", "cmd": ["setsid", "steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "env": env}}, + {"name": "nohup", "cmd": ["nohup", "steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "preexec_fn": os.setpgrp, "env": env}} + ] + + for method in start_methods: + method_name = method["name"] + logger.info(f"Attempting to start Steam using method: {method_name}") + try: + process = subprocess.Popen(method["cmd"], **method["kwargs"]) + if process is not None: + logger.info(f"Initiated Steam start with {method_name}.") + time.sleep(5) # Wait 5 seconds as in existing logic + check_result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env) + if check_result.returncode == 0: + logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.") + return True + else: + logger.warning(f"Steam process not detected after initiating with {method_name}. Trying next method.") + else: + logger.warning(f"Failed to start process with {method_name}. Trying next method.") + except FileNotFoundError: + logger.error(f"Command not found for method {method_name} (e.g., setsid, nohup). Trying next method.") + except Exception as e: + logger.error(f"Error starting Steam with {method_name}: {e}. Trying next method.") + + return False + except Exception as e: + logger.error(f"Error starting Steam: {e}") + return False + +def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = None, timeout: int = 60) -> bool: + """ + Robustly restart Steam across all distros. Returns True on success, False on failure. + Optionally accepts a progress_callback(message: str) for UI feedback. + Uses aggressive pkill approach for maximum reliability. + """ + env = _get_clean_subprocess_env() + + def report(msg): + logger.info(msg) + if progress_callback: + progress_callback(msg) + + report("Shutting down Steam...") + + # Steam Deck: Use systemctl for shutdown (special handling) + if is_steam_deck(): + try: + report("Steam Deck detected - using systemctl shutdown...") + subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'], + timeout=15, check=False, capture_output=True, env=env) + time.sleep(2) + except Exception as e: + logger.debug(f"systemctl stop failed on Steam Deck: {e}") + + # All systems: Use pkill approach (proven 15/16 test success rate) + try: + # Skip unreliable steam -shutdown, go straight to pkill + pkill_result = subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=env) + logger.debug(f"pkill steam result: {pkill_result.returncode}") + time.sleep(2) + + # Check if Steam is still running + check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env) + if check_result.returncode == 0: + # Force kill if still running + report("Steam still running - force terminating...") + force_result = subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=env) + logger.debug(f"pkill -9 steam result: {force_result.returncode}") + time.sleep(2) + + # Final check + final_check = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env) + if final_check.returncode != 0: + logger.info("Steam processes successfully force terminated.") + else: + report("Failed to terminate Steam processes.") + return False + else: + logger.info("Steam processes successfully terminated.") + except Exception as e: + logger.error(f"Error during Steam shutdown: {e}") + report("Failed to shut down Steam.") + return False + + report("Steam closed successfully.") + + # Start Steam using platform-specific logic + report("Starting Steam...") + + # Steam Deck: Use systemctl restart (keep existing working approach) + if is_steam_deck(): + try: + subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env) + logger.info("Steam Deck: Initiated systemctl restart") + except Exception as e: + logger.error(f"Steam Deck systemctl restart failed: {e}") + report("Failed to restart Steam on Steam Deck.") + return False + else: + # All other distros: Use proven steam -silent method + if not start_steam(): + report("Failed to start Steam.") + return False + + # Wait for Steam to fully initialize using existing logic + report("Waiting for Steam to fully start") + logger.info("Waiting up to 2 minutes for Steam to fully initialize...") + max_startup_wait = 120 + elapsed_wait = 0 + initial_wait_done = False + + while elapsed_wait < max_startup_wait: + try: + result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env) + if result.returncode == 0: + if not initial_wait_done: + logger.info("Steam process detected. Waiting additional time for full initialization...") + initial_wait_done = True + time.sleep(5) + elapsed_wait += 5 + if initial_wait_done and elapsed_wait >= 15: + final_check = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env) + if final_check.returncode == 0: + report("Steam started successfully.") + logger.info("Steam confirmed running after wait.") + return True + else: + logger.warning("Steam process disappeared during final initialization wait.") + break + else: + logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)") + time.sleep(5) + elapsed_wait += 5 + except Exception as e: + logger.warning(f"Error during Steam startup wait: {e}") + time.sleep(5) + elapsed_wait += 5 + + report("Steam did not start within timeout.") + logger.error("Steam failed to start/initialize within the allowed time.") + return False \ No newline at end of file diff --git a/jackify/engine/AES-CTR-Netstandard.dll b/jackify/engine/AES-CTR-Netstandard.dll new file mode 100755 index 0000000..5392442 Binary files /dev/null and b/jackify/engine/AES-CTR-Netstandard.dll differ diff --git a/jackify/engine/BCnEncoder.NET.ImageSharp.dll b/jackify/engine/BCnEncoder.NET.ImageSharp.dll new file mode 100755 index 0000000..61b6253 Binary files /dev/null and b/jackify/engine/BCnEncoder.NET.ImageSharp.dll differ diff --git a/jackify/engine/BCnEncoder.dll b/jackify/engine/BCnEncoder.dll new file mode 100755 index 0000000..a6fa052 Binary files /dev/null and b/jackify/engine/BCnEncoder.dll differ diff --git a/jackify/engine/Crc32.NET.dll b/jackify/engine/Crc32.NET.dll new file mode 100644 index 0000000..6d29e9f Binary files /dev/null and b/jackify/engine/Crc32.NET.dll differ diff --git a/jackify/engine/DeviceId.dll b/jackify/engine/DeviceId.dll new file mode 100755 index 0000000..7b34922 Binary files /dev/null and b/jackify/engine/DeviceId.dll differ diff --git a/jackify/engine/Extractors/linux-x64/7zz b/jackify/engine/Extractors/linux-x64/7zz new file mode 100755 index 0000000..9d7c38f Binary files /dev/null and b/jackify/engine/Extractors/linux-x64/7zz differ diff --git a/jackify/engine/Extractors/linux-x64/innoextract b/jackify/engine/Extractors/linux-x64/innoextract new file mode 100755 index 0000000..a8443f0 Binary files /dev/null and b/jackify/engine/Extractors/linux-x64/innoextract differ diff --git a/jackify/engine/Extractors/mac/7zz b/jackify/engine/Extractors/mac/7zz new file mode 100644 index 0000000..e84f105 Binary files /dev/null and b/jackify/engine/Extractors/mac/7zz differ diff --git a/jackify/engine/Extractors/windows-x64/7z.dll b/jackify/engine/Extractors/windows-x64/7z.dll new file mode 100644 index 0000000..1533be1 Binary files /dev/null and b/jackify/engine/Extractors/windows-x64/7z.dll differ diff --git a/jackify/engine/Extractors/windows-x64/7z.exe b/jackify/engine/Extractors/windows-x64/7z.exe new file mode 100644 index 0000000..c48554a Binary files /dev/null and b/jackify/engine/Extractors/windows-x64/7z.exe differ diff --git a/jackify/engine/Extractors/windows-x64/innoextract.exe b/jackify/engine/Extractors/windows-x64/innoextract.exe new file mode 100644 index 0000000..22c33dc Binary files /dev/null and b/jackify/engine/Extractors/windows-x64/innoextract.exe differ diff --git a/jackify/engine/F23.StringSimilarity.dll b/jackify/engine/F23.StringSimilarity.dll new file mode 100644 index 0000000..ca41b8c Binary files /dev/null and b/jackify/engine/F23.StringSimilarity.dll differ diff --git a/jackify/engine/FluentFTP.dll b/jackify/engine/FluentFTP.dll new file mode 100644 index 0000000..478da7a Binary files /dev/null and b/jackify/engine/FluentFTP.dll differ diff --git a/jackify/engine/FluentResults.dll b/jackify/engine/FluentResults.dll new file mode 100755 index 0000000..ad348bb Binary files /dev/null and b/jackify/engine/FluentResults.dll differ diff --git a/jackify/engine/GameFinder.Common.dll b/jackify/engine/GameFinder.Common.dll new file mode 100755 index 0000000..cd2c2dd Binary files /dev/null and b/jackify/engine/GameFinder.Common.dll differ diff --git a/jackify/engine/GameFinder.RegistryUtils.dll b/jackify/engine/GameFinder.RegistryUtils.dll new file mode 100755 index 0000000..a28a11d Binary files /dev/null and b/jackify/engine/GameFinder.RegistryUtils.dll differ diff --git a/jackify/engine/GameFinder.StoreHandlers.EADesktop.dll b/jackify/engine/GameFinder.StoreHandlers.EADesktop.dll new file mode 100755 index 0000000..5ae1702 Binary files /dev/null and b/jackify/engine/GameFinder.StoreHandlers.EADesktop.dll differ diff --git a/jackify/engine/GameFinder.StoreHandlers.EGS.dll b/jackify/engine/GameFinder.StoreHandlers.EGS.dll new file mode 100755 index 0000000..f3f5b56 Binary files /dev/null and b/jackify/engine/GameFinder.StoreHandlers.EGS.dll differ diff --git a/jackify/engine/GameFinder.StoreHandlers.GOG.dll b/jackify/engine/GameFinder.StoreHandlers.GOG.dll new file mode 100755 index 0000000..a771931 Binary files /dev/null and b/jackify/engine/GameFinder.StoreHandlers.GOG.dll differ diff --git a/jackify/engine/GameFinder.StoreHandlers.Origin.dll b/jackify/engine/GameFinder.StoreHandlers.Origin.dll new file mode 100755 index 0000000..c1705a1 Binary files /dev/null and b/jackify/engine/GameFinder.StoreHandlers.Origin.dll differ diff --git a/jackify/engine/GameFinder.StoreHandlers.Steam.dll b/jackify/engine/GameFinder.StoreHandlers.Steam.dll new file mode 100755 index 0000000..2043e60 Binary files /dev/null and b/jackify/engine/GameFinder.StoreHandlers.Steam.dll differ diff --git a/jackify/engine/GameFinder.Wine.dll b/jackify/engine/GameFinder.Wine.dll new file mode 100755 index 0000000..db67e4b Binary files /dev/null and b/jackify/engine/GameFinder.Wine.dll differ diff --git a/jackify/engine/HtmlAgilityPack.dll b/jackify/engine/HtmlAgilityPack.dll new file mode 100755 index 0000000..c70703e Binary files /dev/null and b/jackify/engine/HtmlAgilityPack.dll differ diff --git a/jackify/engine/ICSharpCode.SharpZipLib.dll b/jackify/engine/ICSharpCode.SharpZipLib.dll new file mode 100644 index 0000000..52fd182 Binary files /dev/null and b/jackify/engine/ICSharpCode.SharpZipLib.dll differ diff --git a/jackify/engine/INIFileParser.dll b/jackify/engine/INIFileParser.dll new file mode 100644 index 0000000..a4cc544 Binary files /dev/null and b/jackify/engine/INIFileParser.dll differ diff --git a/jackify/engine/K4os.Compression.LZ4.Streams.dll b/jackify/engine/K4os.Compression.LZ4.Streams.dll new file mode 100644 index 0000000..db1a982 Binary files /dev/null and b/jackify/engine/K4os.Compression.LZ4.Streams.dll differ diff --git a/jackify/engine/K4os.Compression.LZ4.dll b/jackify/engine/K4os.Compression.LZ4.dll new file mode 100644 index 0000000..d869427 Binary files /dev/null and b/jackify/engine/K4os.Compression.LZ4.dll differ diff --git a/jackify/engine/K4os.Hash.xxHash.dll b/jackify/engine/K4os.Hash.xxHash.dll new file mode 100644 index 0000000..7217ea7 Binary files /dev/null and b/jackify/engine/K4os.Hash.xxHash.dll differ diff --git a/jackify/engine/Markdig.dll b/jackify/engine/Markdig.dll new file mode 100644 index 0000000..7a29eec Binary files /dev/null and b/jackify/engine/Markdig.dll differ diff --git a/jackify/engine/MegaApiClient.dll b/jackify/engine/MegaApiClient.dll new file mode 100644 index 0000000..a117a37 Binary files /dev/null and b/jackify/engine/MegaApiClient.dll differ diff --git a/jackify/engine/Microsoft.AspNetCore.Http.Abstractions.dll b/jackify/engine/Microsoft.AspNetCore.Http.Abstractions.dll new file mode 100755 index 0000000..6c03778 Binary files /dev/null and b/jackify/engine/Microsoft.AspNetCore.Http.Abstractions.dll differ diff --git a/jackify/engine/Microsoft.AspNetCore.Http.Extensions.dll b/jackify/engine/Microsoft.AspNetCore.Http.Extensions.dll new file mode 100755 index 0000000..6fdd574 Binary files /dev/null and b/jackify/engine/Microsoft.AspNetCore.Http.Extensions.dll differ diff --git a/jackify/engine/Microsoft.AspNetCore.Http.Features.dll b/jackify/engine/Microsoft.AspNetCore.Http.Features.dll new file mode 100755 index 0000000..be4d1cd Binary files /dev/null and b/jackify/engine/Microsoft.AspNetCore.Http.Features.dll differ diff --git a/jackify/engine/Microsoft.CSharp.dll b/jackify/engine/Microsoft.CSharp.dll new file mode 100644 index 0000000..24d16df Binary files /dev/null and b/jackify/engine/Microsoft.CSharp.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.Abstractions.dll b/jackify/engine/Microsoft.Extensions.Configuration.Abstractions.dll new file mode 100644 index 0000000..805cec2 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Configuration.Abstractions.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.Binder.dll b/jackify/engine/Microsoft.Extensions.Configuration.Binder.dll new file mode 100644 index 0000000..a856e4a Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Configuration.Binder.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.CommandLine.dll b/jackify/engine/Microsoft.Extensions.Configuration.CommandLine.dll new file mode 100644 index 0000000..b41cdec Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Configuration.CommandLine.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.EnvironmentVariables.dll b/jackify/engine/Microsoft.Extensions.Configuration.EnvironmentVariables.dll new file mode 100644 index 0000000..a45424d Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Configuration.EnvironmentVariables.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.FileExtensions.dll b/jackify/engine/Microsoft.Extensions.Configuration.FileExtensions.dll new file mode 100644 index 0000000..07d1727 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Configuration.FileExtensions.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.Json.dll b/jackify/engine/Microsoft.Extensions.Configuration.Json.dll new file mode 100644 index 0000000..29ce3d6 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Configuration.Json.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.UserSecrets.dll b/jackify/engine/Microsoft.Extensions.Configuration.UserSecrets.dll new file mode 100644 index 0000000..ef9753d Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Configuration.UserSecrets.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Configuration.dll b/jackify/engine/Microsoft.Extensions.Configuration.dll new file mode 100644 index 0000000..e2968a8 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Configuration.dll differ diff --git a/jackify/engine/Microsoft.Extensions.DependencyInjection.Abstractions.dll b/jackify/engine/Microsoft.Extensions.DependencyInjection.Abstractions.dll new file mode 100644 index 0000000..a9427e6 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.DependencyInjection.Abstractions.dll differ diff --git a/jackify/engine/Microsoft.Extensions.DependencyInjection.dll b/jackify/engine/Microsoft.Extensions.DependencyInjection.dll new file mode 100644 index 0000000..45c1b54 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.DependencyInjection.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Diagnostics.Abstractions.dll b/jackify/engine/Microsoft.Extensions.Diagnostics.Abstractions.dll new file mode 100644 index 0000000..41a0e3c Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Diagnostics.Abstractions.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Diagnostics.dll b/jackify/engine/Microsoft.Extensions.Diagnostics.dll new file mode 100644 index 0000000..790cbd4 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Diagnostics.dll differ diff --git a/jackify/engine/Microsoft.Extensions.FileProviders.Abstractions.dll b/jackify/engine/Microsoft.Extensions.FileProviders.Abstractions.dll new file mode 100644 index 0000000..c3648d6 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.FileProviders.Abstractions.dll differ diff --git a/jackify/engine/Microsoft.Extensions.FileProviders.Physical.dll b/jackify/engine/Microsoft.Extensions.FileProviders.Physical.dll new file mode 100644 index 0000000..4a4c320 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.FileProviders.Physical.dll differ diff --git a/jackify/engine/Microsoft.Extensions.FileSystemGlobbing.dll b/jackify/engine/Microsoft.Extensions.FileSystemGlobbing.dll new file mode 100644 index 0000000..eb00503 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.FileSystemGlobbing.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Hosting.Abstractions.dll b/jackify/engine/Microsoft.Extensions.Hosting.Abstractions.dll new file mode 100644 index 0000000..1fa0eaf Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Hosting.Abstractions.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Hosting.dll b/jackify/engine/Microsoft.Extensions.Hosting.dll new file mode 100644 index 0000000..a5e4e5b Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Hosting.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Http.dll b/jackify/engine/Microsoft.Extensions.Http.dll new file mode 100644 index 0000000..c98cf7d Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Http.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.Abstractions.dll b/jackify/engine/Microsoft.Extensions.Logging.Abstractions.dll new file mode 100644 index 0000000..90d2231 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Logging.Abstractions.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.Configuration.dll b/jackify/engine/Microsoft.Extensions.Logging.Configuration.dll new file mode 100644 index 0000000..88e315f Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Logging.Configuration.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.Console.dll b/jackify/engine/Microsoft.Extensions.Logging.Console.dll new file mode 100644 index 0000000..805570b Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Logging.Console.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.Debug.dll b/jackify/engine/Microsoft.Extensions.Logging.Debug.dll new file mode 100644 index 0000000..1fcbf07 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Logging.Debug.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.EventLog.dll b/jackify/engine/Microsoft.Extensions.Logging.EventLog.dll new file mode 100644 index 0000000..ed07e3b Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Logging.EventLog.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.EventSource.dll b/jackify/engine/Microsoft.Extensions.Logging.EventSource.dll new file mode 100644 index 0000000..b67516c Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Logging.EventSource.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Logging.dll b/jackify/engine/Microsoft.Extensions.Logging.dll new file mode 100644 index 0000000..7559ed4 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Logging.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Options.ConfigurationExtensions.dll b/jackify/engine/Microsoft.Extensions.Options.ConfigurationExtensions.dll new file mode 100644 index 0000000..8e02081 Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Options.ConfigurationExtensions.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Options.dll b/jackify/engine/Microsoft.Extensions.Options.dll new file mode 100644 index 0000000..a9358be Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Options.dll differ diff --git a/jackify/engine/Microsoft.Extensions.Primitives.dll b/jackify/engine/Microsoft.Extensions.Primitives.dll new file mode 100644 index 0000000..d6b5bfc Binary files /dev/null and b/jackify/engine/Microsoft.Extensions.Primitives.dll differ diff --git a/jackify/engine/Microsoft.Net.Http.Headers.dll b/jackify/engine/Microsoft.Net.Http.Headers.dll new file mode 100755 index 0000000..3e76103 Binary files /dev/null and b/jackify/engine/Microsoft.Net.Http.Headers.dll differ diff --git a/jackify/engine/Microsoft.Toolkit.HighPerformance.dll b/jackify/engine/Microsoft.Toolkit.HighPerformance.dll new file mode 100755 index 0000000..376a1bc Binary files /dev/null and b/jackify/engine/Microsoft.Toolkit.HighPerformance.dll differ diff --git a/jackify/engine/Microsoft.VisualBasic.Core.dll b/jackify/engine/Microsoft.VisualBasic.Core.dll new file mode 100755 index 0000000..cac4ffe Binary files /dev/null and b/jackify/engine/Microsoft.VisualBasic.Core.dll differ diff --git a/jackify/engine/Microsoft.VisualBasic.dll b/jackify/engine/Microsoft.VisualBasic.dll new file mode 100755 index 0000000..0e606c5 Binary files /dev/null and b/jackify/engine/Microsoft.VisualBasic.dll differ diff --git a/jackify/engine/Microsoft.Win32.Primitives.dll b/jackify/engine/Microsoft.Win32.Primitives.dll new file mode 100755 index 0000000..8feaa22 Binary files /dev/null and b/jackify/engine/Microsoft.Win32.Primitives.dll differ diff --git a/jackify/engine/Microsoft.Win32.Registry.dll b/jackify/engine/Microsoft.Win32.Registry.dll new file mode 100755 index 0000000..f306d2d Binary files /dev/null and b/jackify/engine/Microsoft.Win32.Registry.dll differ diff --git a/jackify/engine/Microsoft.Win32.SystemEvents.dll b/jackify/engine/Microsoft.Win32.SystemEvents.dll new file mode 100755 index 0000000..9cb672f Binary files /dev/null and b/jackify/engine/Microsoft.Win32.SystemEvents.dll differ diff --git a/jackify/engine/NLog.Extensions.Logging.dll b/jackify/engine/NLog.Extensions.Logging.dll new file mode 100644 index 0000000..32c7828 Binary files /dev/null and b/jackify/engine/NLog.Extensions.Logging.dll differ diff --git a/jackify/engine/NLog.dll b/jackify/engine/NLog.dll new file mode 100644 index 0000000..2a6a2fd Binary files /dev/null and b/jackify/engine/NLog.dll differ diff --git a/jackify/engine/Nettle.dll b/jackify/engine/Nettle.dll new file mode 100644 index 0000000..c74ff96 Binary files /dev/null and b/jackify/engine/Nettle.dll differ diff --git a/jackify/engine/Newtonsoft.Json.dll b/jackify/engine/Newtonsoft.Json.dll new file mode 100644 index 0000000..eebef3c Binary files /dev/null and b/jackify/engine/Newtonsoft.Json.dll differ diff --git a/jackify/engine/NexusMods.Paths.dll b/jackify/engine/NexusMods.Paths.dll new file mode 100755 index 0000000..151a376 Binary files /dev/null and b/jackify/engine/NexusMods.Paths.dll differ diff --git a/jackify/engine/OMODFramework.dll b/jackify/engine/OMODFramework.dll new file mode 100644 index 0000000..6ef9f14 Binary files /dev/null and b/jackify/engine/OMODFramework.dll differ diff --git a/jackify/engine/Octodiff.dll b/jackify/engine/Octodiff.dll new file mode 100644 index 0000000..0ee51df Binary files /dev/null and b/jackify/engine/Octodiff.dll differ diff --git a/jackify/engine/Octokit.dll b/jackify/engine/Octokit.dll new file mode 100644 index 0000000..104a709 Binary files /dev/null and b/jackify/engine/Octokit.dll differ diff --git a/jackify/engine/OneOf.dll b/jackify/engine/OneOf.dll new file mode 100755 index 0000000..8267305 Binary files /dev/null and b/jackify/engine/OneOf.dll differ diff --git a/jackify/engine/Reloaded.Memory.dll b/jackify/engine/Reloaded.Memory.dll new file mode 100755 index 0000000..3fb16d7 Binary files /dev/null and b/jackify/engine/Reloaded.Memory.dll differ diff --git a/jackify/engine/SHA3.Net.dll b/jackify/engine/SHA3.Net.dll new file mode 100755 index 0000000..9230fe2 Binary files /dev/null and b/jackify/engine/SHA3.Net.dll differ diff --git a/jackify/engine/SQLite.Interop.dll b/jackify/engine/SQLite.Interop.dll new file mode 100755 index 0000000..6cc6b46 Binary files /dev/null and b/jackify/engine/SQLite.Interop.dll differ diff --git a/jackify/engine/SharpCompress.dll b/jackify/engine/SharpCompress.dll new file mode 100644 index 0000000..13dced8 Binary files /dev/null and b/jackify/engine/SharpCompress.dll differ diff --git a/jackify/engine/Shipwreck.Phash.dll b/jackify/engine/Shipwreck.Phash.dll new file mode 100644 index 0000000..ffc70df Binary files /dev/null and b/jackify/engine/Shipwreck.Phash.dll differ diff --git a/jackify/engine/SixLabors.ImageSharp.dll b/jackify/engine/SixLabors.ImageSharp.dll new file mode 100644 index 0000000..3850cf8 Binary files /dev/null and b/jackify/engine/SixLabors.ImageSharp.dll differ diff --git a/jackify/engine/System.AppContext.dll b/jackify/engine/System.AppContext.dll new file mode 100755 index 0000000..1d44a7b Binary files /dev/null and b/jackify/engine/System.AppContext.dll differ diff --git a/jackify/engine/System.Buffers.dll b/jackify/engine/System.Buffers.dll new file mode 100755 index 0000000..44b42a7 Binary files /dev/null and b/jackify/engine/System.Buffers.dll differ diff --git a/jackify/engine/System.CodeDom.dll b/jackify/engine/System.CodeDom.dll new file mode 100755 index 0000000..81713c8 Binary files /dev/null and b/jackify/engine/System.CodeDom.dll differ diff --git a/jackify/engine/System.Collections.Concurrent.dll b/jackify/engine/System.Collections.Concurrent.dll new file mode 100644 index 0000000..131282d Binary files /dev/null and b/jackify/engine/System.Collections.Concurrent.dll differ diff --git a/jackify/engine/System.Collections.Immutable.dll b/jackify/engine/System.Collections.Immutable.dll new file mode 100644 index 0000000..e5959af Binary files /dev/null and b/jackify/engine/System.Collections.Immutable.dll differ diff --git a/jackify/engine/System.Collections.NonGeneric.dll b/jackify/engine/System.Collections.NonGeneric.dll new file mode 100644 index 0000000..0f4ea51 Binary files /dev/null and b/jackify/engine/System.Collections.NonGeneric.dll differ diff --git a/jackify/engine/System.Collections.Specialized.dll b/jackify/engine/System.Collections.Specialized.dll new file mode 100644 index 0000000..77760d0 Binary files /dev/null and b/jackify/engine/System.Collections.Specialized.dll differ diff --git a/jackify/engine/System.Collections.dll b/jackify/engine/System.Collections.dll new file mode 100644 index 0000000..8f98b94 Binary files /dev/null and b/jackify/engine/System.Collections.dll differ diff --git a/jackify/engine/System.CommandLine.NamingConventionBinder.dll b/jackify/engine/System.CommandLine.NamingConventionBinder.dll new file mode 100644 index 0000000..7c873b8 Binary files /dev/null and b/jackify/engine/System.CommandLine.NamingConventionBinder.dll differ diff --git a/jackify/engine/System.CommandLine.dll b/jackify/engine/System.CommandLine.dll new file mode 100644 index 0000000..eb30bc4 Binary files /dev/null and b/jackify/engine/System.CommandLine.dll differ diff --git a/jackify/engine/System.ComponentModel.Annotations.dll b/jackify/engine/System.ComponentModel.Annotations.dll new file mode 100755 index 0000000..91f4157 Binary files /dev/null and b/jackify/engine/System.ComponentModel.Annotations.dll differ diff --git a/jackify/engine/System.ComponentModel.DataAnnotations.dll b/jackify/engine/System.ComponentModel.DataAnnotations.dll new file mode 100755 index 0000000..d5b63a4 Binary files /dev/null and b/jackify/engine/System.ComponentModel.DataAnnotations.dll differ diff --git a/jackify/engine/System.ComponentModel.EventBasedAsync.dll b/jackify/engine/System.ComponentModel.EventBasedAsync.dll new file mode 100644 index 0000000..cf9bb89 Binary files /dev/null and b/jackify/engine/System.ComponentModel.EventBasedAsync.dll differ diff --git a/jackify/engine/System.ComponentModel.Primitives.dll b/jackify/engine/System.ComponentModel.Primitives.dll new file mode 100644 index 0000000..da2b9e2 Binary files /dev/null and b/jackify/engine/System.ComponentModel.Primitives.dll differ diff --git a/jackify/engine/System.ComponentModel.TypeConverter.dll b/jackify/engine/System.ComponentModel.TypeConverter.dll new file mode 100644 index 0000000..2145f55 Binary files /dev/null and b/jackify/engine/System.ComponentModel.TypeConverter.dll differ diff --git a/jackify/engine/System.ComponentModel.dll b/jackify/engine/System.ComponentModel.dll new file mode 100644 index 0000000..70b3fe6 Binary files /dev/null and b/jackify/engine/System.ComponentModel.dll differ diff --git a/jackify/engine/System.Configuration.dll b/jackify/engine/System.Configuration.dll new file mode 100755 index 0000000..64ad31e Binary files /dev/null and b/jackify/engine/System.Configuration.dll differ diff --git a/jackify/engine/System.Console.dll b/jackify/engine/System.Console.dll new file mode 100644 index 0000000..31143df Binary files /dev/null and b/jackify/engine/System.Console.dll differ diff --git a/jackify/engine/System.Core.dll b/jackify/engine/System.Core.dll new file mode 100755 index 0000000..40c7865 Binary files /dev/null and b/jackify/engine/System.Core.dll differ diff --git a/jackify/engine/System.Data.Common.dll b/jackify/engine/System.Data.Common.dll new file mode 100644 index 0000000..ad9052a Binary files /dev/null and b/jackify/engine/System.Data.Common.dll differ diff --git a/jackify/engine/System.Data.DataSetExtensions.dll b/jackify/engine/System.Data.DataSetExtensions.dll new file mode 100755 index 0000000..de65dfa Binary files /dev/null and b/jackify/engine/System.Data.DataSetExtensions.dll differ diff --git a/jackify/engine/System.Data.SQLite.dll b/jackify/engine/System.Data.SQLite.dll new file mode 100644 index 0000000..e9c50fe Binary files /dev/null and b/jackify/engine/System.Data.SQLite.dll differ diff --git a/jackify/engine/System.Data.dll b/jackify/engine/System.Data.dll new file mode 100755 index 0000000..afdea17 Binary files /dev/null and b/jackify/engine/System.Data.dll differ diff --git a/jackify/engine/System.Diagnostics.Contracts.dll b/jackify/engine/System.Diagnostics.Contracts.dll new file mode 100755 index 0000000..84d21e5 Binary files /dev/null and b/jackify/engine/System.Diagnostics.Contracts.dll differ diff --git a/jackify/engine/System.Diagnostics.Debug.dll b/jackify/engine/System.Diagnostics.Debug.dll new file mode 100755 index 0000000..0c1cd28 Binary files /dev/null and b/jackify/engine/System.Diagnostics.Debug.dll differ diff --git a/jackify/engine/System.Diagnostics.DiagnosticSource.dll b/jackify/engine/System.Diagnostics.DiagnosticSource.dll new file mode 100644 index 0000000..d1ae9cf Binary files /dev/null and b/jackify/engine/System.Diagnostics.DiagnosticSource.dll differ diff --git a/jackify/engine/System.Diagnostics.EventLog.dll b/jackify/engine/System.Diagnostics.EventLog.dll new file mode 100644 index 0000000..f29dbdc Binary files /dev/null and b/jackify/engine/System.Diagnostics.EventLog.dll differ diff --git a/jackify/engine/System.Diagnostics.FileVersionInfo.dll b/jackify/engine/System.Diagnostics.FileVersionInfo.dll new file mode 100644 index 0000000..53db889 Binary files /dev/null and b/jackify/engine/System.Diagnostics.FileVersionInfo.dll differ diff --git a/jackify/engine/System.Diagnostics.Process.dll b/jackify/engine/System.Diagnostics.Process.dll new file mode 100644 index 0000000..3a85c55 Binary files /dev/null and b/jackify/engine/System.Diagnostics.Process.dll differ diff --git a/jackify/engine/System.Diagnostics.StackTrace.dll b/jackify/engine/System.Diagnostics.StackTrace.dll new file mode 100644 index 0000000..77217bf Binary files /dev/null and b/jackify/engine/System.Diagnostics.StackTrace.dll differ diff --git a/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll b/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll new file mode 100755 index 0000000..f865d28 Binary files /dev/null and b/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll differ diff --git a/jackify/engine/System.Diagnostics.Tools.dll b/jackify/engine/System.Diagnostics.Tools.dll new file mode 100755 index 0000000..0b72c7b Binary files /dev/null and b/jackify/engine/System.Diagnostics.Tools.dll differ diff --git a/jackify/engine/System.Diagnostics.TraceSource.dll b/jackify/engine/System.Diagnostics.TraceSource.dll new file mode 100644 index 0000000..cb3fb76 Binary files /dev/null and b/jackify/engine/System.Diagnostics.TraceSource.dll differ diff --git a/jackify/engine/System.Diagnostics.Tracing.dll b/jackify/engine/System.Diagnostics.Tracing.dll new file mode 100755 index 0000000..143ca11 Binary files /dev/null and b/jackify/engine/System.Diagnostics.Tracing.dll differ diff --git a/jackify/engine/System.Drawing.Common.dll b/jackify/engine/System.Drawing.Common.dll new file mode 100755 index 0000000..dc2ec68 Binary files /dev/null and b/jackify/engine/System.Drawing.Common.dll differ diff --git a/jackify/engine/System.Drawing.Primitives.dll b/jackify/engine/System.Drawing.Primitives.dll new file mode 100644 index 0000000..e7e88e1 Binary files /dev/null and b/jackify/engine/System.Drawing.Primitives.dll differ diff --git a/jackify/engine/System.Drawing.dll b/jackify/engine/System.Drawing.dll new file mode 100644 index 0000000..c2e5e65 Binary files /dev/null and b/jackify/engine/System.Drawing.dll differ diff --git a/jackify/engine/System.Dynamic.Runtime.dll b/jackify/engine/System.Dynamic.Runtime.dll new file mode 100755 index 0000000..a4c1595 Binary files /dev/null and b/jackify/engine/System.Dynamic.Runtime.dll differ diff --git a/jackify/engine/System.Formats.Asn1.dll b/jackify/engine/System.Formats.Asn1.dll new file mode 100644 index 0000000..35d9e40 Binary files /dev/null and b/jackify/engine/System.Formats.Asn1.dll differ diff --git a/jackify/engine/System.Formats.Tar.dll b/jackify/engine/System.Formats.Tar.dll new file mode 100755 index 0000000..051253b Binary files /dev/null and b/jackify/engine/System.Formats.Tar.dll differ diff --git a/jackify/engine/System.Globalization.Calendars.dll b/jackify/engine/System.Globalization.Calendars.dll new file mode 100755 index 0000000..bdab2aa Binary files /dev/null and b/jackify/engine/System.Globalization.Calendars.dll differ diff --git a/jackify/engine/System.Globalization.Extensions.dll b/jackify/engine/System.Globalization.Extensions.dll new file mode 100755 index 0000000..fc51823 Binary files /dev/null and b/jackify/engine/System.Globalization.Extensions.dll differ diff --git a/jackify/engine/System.Globalization.dll b/jackify/engine/System.Globalization.dll new file mode 100755 index 0000000..e6292db Binary files /dev/null and b/jackify/engine/System.Globalization.dll differ diff --git a/jackify/engine/System.IO.Compression.Brotli.dll b/jackify/engine/System.IO.Compression.Brotli.dll new file mode 100644 index 0000000..f062a88 Binary files /dev/null and b/jackify/engine/System.IO.Compression.Brotli.dll differ diff --git a/jackify/engine/System.IO.Compression.FileSystem.dll b/jackify/engine/System.IO.Compression.FileSystem.dll new file mode 100755 index 0000000..6de208d Binary files /dev/null and b/jackify/engine/System.IO.Compression.FileSystem.dll differ diff --git a/jackify/engine/System.IO.Compression.ZipFile.dll b/jackify/engine/System.IO.Compression.ZipFile.dll new file mode 100644 index 0000000..7a47b78 Binary files /dev/null and b/jackify/engine/System.IO.Compression.ZipFile.dll differ diff --git a/jackify/engine/System.IO.Compression.dll b/jackify/engine/System.IO.Compression.dll new file mode 100644 index 0000000..965d05b Binary files /dev/null and b/jackify/engine/System.IO.Compression.dll differ diff --git a/jackify/engine/System.IO.FileSystem.AccessControl.dll b/jackify/engine/System.IO.FileSystem.AccessControl.dll new file mode 100755 index 0000000..d4dfcf8 Binary files /dev/null and b/jackify/engine/System.IO.FileSystem.AccessControl.dll differ diff --git a/jackify/engine/System.IO.FileSystem.DriveInfo.dll b/jackify/engine/System.IO.FileSystem.DriveInfo.dll new file mode 100644 index 0000000..51f189e Binary files /dev/null and b/jackify/engine/System.IO.FileSystem.DriveInfo.dll differ diff --git a/jackify/engine/System.IO.FileSystem.Primitives.dll b/jackify/engine/System.IO.FileSystem.Primitives.dll new file mode 100755 index 0000000..af57f82 Binary files /dev/null and b/jackify/engine/System.IO.FileSystem.Primitives.dll differ diff --git a/jackify/engine/System.IO.FileSystem.Watcher.dll b/jackify/engine/System.IO.FileSystem.Watcher.dll new file mode 100644 index 0000000..7817832 Binary files /dev/null and b/jackify/engine/System.IO.FileSystem.Watcher.dll differ diff --git a/jackify/engine/System.IO.FileSystem.dll b/jackify/engine/System.IO.FileSystem.dll new file mode 100755 index 0000000..8117602 Binary files /dev/null and b/jackify/engine/System.IO.FileSystem.dll differ diff --git a/jackify/engine/System.IO.IsolatedStorage.dll b/jackify/engine/System.IO.IsolatedStorage.dll new file mode 100755 index 0000000..607c448 Binary files /dev/null and b/jackify/engine/System.IO.IsolatedStorage.dll differ diff --git a/jackify/engine/System.IO.MemoryMappedFiles.dll b/jackify/engine/System.IO.MemoryMappedFiles.dll new file mode 100644 index 0000000..4273291 Binary files /dev/null and b/jackify/engine/System.IO.MemoryMappedFiles.dll differ diff --git a/jackify/engine/System.IO.Pipelines.dll b/jackify/engine/System.IO.Pipelines.dll new file mode 100644 index 0000000..c7941db Binary files /dev/null and b/jackify/engine/System.IO.Pipelines.dll differ diff --git a/jackify/engine/System.IO.Pipes.AccessControl.dll b/jackify/engine/System.IO.Pipes.AccessControl.dll new file mode 100755 index 0000000..f858379 Binary files /dev/null and b/jackify/engine/System.IO.Pipes.AccessControl.dll differ diff --git a/jackify/engine/System.IO.Pipes.dll b/jackify/engine/System.IO.Pipes.dll new file mode 100644 index 0000000..dbf6583 Binary files /dev/null and b/jackify/engine/System.IO.Pipes.dll differ diff --git a/jackify/engine/System.IO.UnmanagedMemoryStream.dll b/jackify/engine/System.IO.UnmanagedMemoryStream.dll new file mode 100755 index 0000000..d76f481 Binary files /dev/null and b/jackify/engine/System.IO.UnmanagedMemoryStream.dll differ diff --git a/jackify/engine/System.IO.dll b/jackify/engine/System.IO.dll new file mode 100755 index 0000000..3fdb611 Binary files /dev/null and b/jackify/engine/System.IO.dll differ diff --git a/jackify/engine/System.Linq.Expressions.dll b/jackify/engine/System.Linq.Expressions.dll new file mode 100644 index 0000000..a41cd33 Binary files /dev/null and b/jackify/engine/System.Linq.Expressions.dll differ diff --git a/jackify/engine/System.Linq.Parallel.dll b/jackify/engine/System.Linq.Parallel.dll new file mode 100644 index 0000000..ec227a0 Binary files /dev/null and b/jackify/engine/System.Linq.Parallel.dll differ diff --git a/jackify/engine/System.Linq.Queryable.dll b/jackify/engine/System.Linq.Queryable.dll new file mode 100755 index 0000000..f7b71e7 Binary files /dev/null and b/jackify/engine/System.Linq.Queryable.dll differ diff --git a/jackify/engine/System.Linq.dll b/jackify/engine/System.Linq.dll new file mode 100644 index 0000000..fc17c85 Binary files /dev/null and b/jackify/engine/System.Linq.dll differ diff --git a/jackify/engine/System.Management.dll b/jackify/engine/System.Management.dll new file mode 100755 index 0000000..e67a923 Binary files /dev/null and b/jackify/engine/System.Management.dll differ diff --git a/jackify/engine/System.Memory.dll b/jackify/engine/System.Memory.dll new file mode 100644 index 0000000..b771144 Binary files /dev/null and b/jackify/engine/System.Memory.dll differ diff --git a/jackify/engine/System.Net.Http.Json.dll b/jackify/engine/System.Net.Http.Json.dll new file mode 100644 index 0000000..bf9845a Binary files /dev/null and b/jackify/engine/System.Net.Http.Json.dll differ diff --git a/jackify/engine/System.Net.Http.dll b/jackify/engine/System.Net.Http.dll new file mode 100644 index 0000000..57051b1 Binary files /dev/null and b/jackify/engine/System.Net.Http.dll differ diff --git a/jackify/engine/System.Net.HttpListener.dll b/jackify/engine/System.Net.HttpListener.dll new file mode 100755 index 0000000..d05ff20 Binary files /dev/null and b/jackify/engine/System.Net.HttpListener.dll differ diff --git a/jackify/engine/System.Net.Mail.dll b/jackify/engine/System.Net.Mail.dll new file mode 100644 index 0000000..a603ef9 Binary files /dev/null and b/jackify/engine/System.Net.Mail.dll differ diff --git a/jackify/engine/System.Net.NameResolution.dll b/jackify/engine/System.Net.NameResolution.dll new file mode 100644 index 0000000..a4441e6 Binary files /dev/null and b/jackify/engine/System.Net.NameResolution.dll differ diff --git a/jackify/engine/System.Net.NetworkInformation.dll b/jackify/engine/System.Net.NetworkInformation.dll new file mode 100644 index 0000000..60c8237 Binary files /dev/null and b/jackify/engine/System.Net.NetworkInformation.dll differ diff --git a/jackify/engine/System.Net.Ping.dll b/jackify/engine/System.Net.Ping.dll new file mode 100755 index 0000000..a6f5592 Binary files /dev/null and b/jackify/engine/System.Net.Ping.dll differ diff --git a/jackify/engine/System.Net.Primitives.dll b/jackify/engine/System.Net.Primitives.dll new file mode 100644 index 0000000..a149b83 Binary files /dev/null and b/jackify/engine/System.Net.Primitives.dll differ diff --git a/jackify/engine/System.Net.Quic.dll b/jackify/engine/System.Net.Quic.dll new file mode 100644 index 0000000..385db03 Binary files /dev/null and b/jackify/engine/System.Net.Quic.dll differ diff --git a/jackify/engine/System.Net.Requests.dll b/jackify/engine/System.Net.Requests.dll new file mode 100644 index 0000000..c09c472 Binary files /dev/null and b/jackify/engine/System.Net.Requests.dll differ diff --git a/jackify/engine/System.Net.Security.dll b/jackify/engine/System.Net.Security.dll new file mode 100644 index 0000000..6a345ca Binary files /dev/null and b/jackify/engine/System.Net.Security.dll differ diff --git a/jackify/engine/System.Net.ServicePoint.dll b/jackify/engine/System.Net.ServicePoint.dll new file mode 100644 index 0000000..b385f86 Binary files /dev/null and b/jackify/engine/System.Net.ServicePoint.dll differ diff --git a/jackify/engine/System.Net.Sockets.dll b/jackify/engine/System.Net.Sockets.dll new file mode 100644 index 0000000..fb10e47 Binary files /dev/null and b/jackify/engine/System.Net.Sockets.dll differ diff --git a/jackify/engine/System.Net.WebClient.dll b/jackify/engine/System.Net.WebClient.dll new file mode 100755 index 0000000..2ecf3ef Binary files /dev/null and b/jackify/engine/System.Net.WebClient.dll differ diff --git a/jackify/engine/System.Net.WebHeaderCollection.dll b/jackify/engine/System.Net.WebHeaderCollection.dll new file mode 100644 index 0000000..0fe13b7 Binary files /dev/null and b/jackify/engine/System.Net.WebHeaderCollection.dll differ diff --git a/jackify/engine/System.Net.WebProxy.dll b/jackify/engine/System.Net.WebProxy.dll new file mode 100644 index 0000000..2c9a2e3 Binary files /dev/null and b/jackify/engine/System.Net.WebProxy.dll differ diff --git a/jackify/engine/System.Net.WebSockets.Client.dll b/jackify/engine/System.Net.WebSockets.Client.dll new file mode 100755 index 0000000..0e789a8 Binary files /dev/null and b/jackify/engine/System.Net.WebSockets.Client.dll differ diff --git a/jackify/engine/System.Net.WebSockets.dll b/jackify/engine/System.Net.WebSockets.dll new file mode 100755 index 0000000..3cc38f6 Binary files /dev/null and b/jackify/engine/System.Net.WebSockets.dll differ diff --git a/jackify/engine/System.Net.dll b/jackify/engine/System.Net.dll new file mode 100755 index 0000000..0fc2106 Binary files /dev/null and b/jackify/engine/System.Net.dll differ diff --git a/jackify/engine/System.Numerics.Vectors.dll b/jackify/engine/System.Numerics.Vectors.dll new file mode 100755 index 0000000..212325d Binary files /dev/null and b/jackify/engine/System.Numerics.Vectors.dll differ diff --git a/jackify/engine/System.Numerics.dll b/jackify/engine/System.Numerics.dll new file mode 100755 index 0000000..db9839c Binary files /dev/null and b/jackify/engine/System.Numerics.dll differ diff --git a/jackify/engine/System.ObjectModel.dll b/jackify/engine/System.ObjectModel.dll new file mode 100644 index 0000000..d6f23eb Binary files /dev/null and b/jackify/engine/System.ObjectModel.dll differ diff --git a/jackify/engine/System.Private.CoreLib.dll b/jackify/engine/System.Private.CoreLib.dll new file mode 100644 index 0000000..2e5270a Binary files /dev/null and b/jackify/engine/System.Private.CoreLib.dll differ diff --git a/jackify/engine/System.Private.DataContractSerialization.dll b/jackify/engine/System.Private.DataContractSerialization.dll new file mode 100755 index 0000000..312ae47 Binary files /dev/null and b/jackify/engine/System.Private.DataContractSerialization.dll differ diff --git a/jackify/engine/System.Private.Uri.dll b/jackify/engine/System.Private.Uri.dll new file mode 100644 index 0000000..3676227 Binary files /dev/null and b/jackify/engine/System.Private.Uri.dll differ diff --git a/jackify/engine/System.Private.Xml.Linq.dll b/jackify/engine/System.Private.Xml.Linq.dll new file mode 100644 index 0000000..9a07627 Binary files /dev/null and b/jackify/engine/System.Private.Xml.Linq.dll differ diff --git a/jackify/engine/System.Private.Xml.dll b/jackify/engine/System.Private.Xml.dll new file mode 100644 index 0000000..4b7d1ff Binary files /dev/null and b/jackify/engine/System.Private.Xml.dll differ diff --git a/jackify/engine/System.Reactive.dll b/jackify/engine/System.Reactive.dll new file mode 100644 index 0000000..d4bbc17 Binary files /dev/null and b/jackify/engine/System.Reactive.dll differ diff --git a/jackify/engine/System.Reflection.DispatchProxy.dll b/jackify/engine/System.Reflection.DispatchProxy.dll new file mode 100755 index 0000000..b55b0f5 Binary files /dev/null and b/jackify/engine/System.Reflection.DispatchProxy.dll differ diff --git a/jackify/engine/System.Reflection.Emit.ILGeneration.dll b/jackify/engine/System.Reflection.Emit.ILGeneration.dll new file mode 100755 index 0000000..aa77156 Binary files /dev/null and b/jackify/engine/System.Reflection.Emit.ILGeneration.dll differ diff --git a/jackify/engine/System.Reflection.Emit.Lightweight.dll b/jackify/engine/System.Reflection.Emit.Lightweight.dll new file mode 100755 index 0000000..c1623f8 Binary files /dev/null and b/jackify/engine/System.Reflection.Emit.Lightweight.dll differ diff --git a/jackify/engine/System.Reflection.Emit.dll b/jackify/engine/System.Reflection.Emit.dll new file mode 100755 index 0000000..da51862 Binary files /dev/null and b/jackify/engine/System.Reflection.Emit.dll differ diff --git a/jackify/engine/System.Reflection.Extensions.dll b/jackify/engine/System.Reflection.Extensions.dll new file mode 100755 index 0000000..6e62dc1 Binary files /dev/null and b/jackify/engine/System.Reflection.Extensions.dll differ diff --git a/jackify/engine/System.Reflection.Metadata.dll b/jackify/engine/System.Reflection.Metadata.dll new file mode 100644 index 0000000..2689851 Binary files /dev/null and b/jackify/engine/System.Reflection.Metadata.dll differ diff --git a/jackify/engine/System.Reflection.Primitives.dll b/jackify/engine/System.Reflection.Primitives.dll new file mode 100755 index 0000000..3bbada8 Binary files /dev/null and b/jackify/engine/System.Reflection.Primitives.dll differ diff --git a/jackify/engine/System.Reflection.TypeExtensions.dll b/jackify/engine/System.Reflection.TypeExtensions.dll new file mode 100755 index 0000000..6efce2f Binary files /dev/null and b/jackify/engine/System.Reflection.TypeExtensions.dll differ diff --git a/jackify/engine/System.Reflection.dll b/jackify/engine/System.Reflection.dll new file mode 100755 index 0000000..3bfc7d8 Binary files /dev/null and b/jackify/engine/System.Reflection.dll differ diff --git a/jackify/engine/System.Resources.Reader.dll b/jackify/engine/System.Resources.Reader.dll new file mode 100755 index 0000000..4590d46 Binary files /dev/null and b/jackify/engine/System.Resources.Reader.dll differ diff --git a/jackify/engine/System.Resources.ResourceManager.dll b/jackify/engine/System.Resources.ResourceManager.dll new file mode 100755 index 0000000..7d315be Binary files /dev/null and b/jackify/engine/System.Resources.ResourceManager.dll differ diff --git a/jackify/engine/System.Resources.Writer.dll b/jackify/engine/System.Resources.Writer.dll new file mode 100755 index 0000000..1601422 Binary files /dev/null and b/jackify/engine/System.Resources.Writer.dll differ diff --git a/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll b/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll new file mode 100755 index 0000000..8fa1b03 Binary files /dev/null and b/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll differ diff --git a/jackify/engine/System.Runtime.CompilerServices.VisualC.dll b/jackify/engine/System.Runtime.CompilerServices.VisualC.dll new file mode 100755 index 0000000..ad6c740 Binary files /dev/null and b/jackify/engine/System.Runtime.CompilerServices.VisualC.dll differ diff --git a/jackify/engine/System.Runtime.Extensions.dll b/jackify/engine/System.Runtime.Extensions.dll new file mode 100755 index 0000000..aa27e9b Binary files /dev/null and b/jackify/engine/System.Runtime.Extensions.dll differ diff --git a/jackify/engine/System.Runtime.Handles.dll b/jackify/engine/System.Runtime.Handles.dll new file mode 100755 index 0000000..166ca6b Binary files /dev/null and b/jackify/engine/System.Runtime.Handles.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.JavaScript.dll b/jackify/engine/System.Runtime.InteropServices.JavaScript.dll new file mode 100755 index 0000000..6830d38 Binary files /dev/null and b/jackify/engine/System.Runtime.InteropServices.JavaScript.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll b/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll new file mode 100755 index 0000000..e8a3c69 Binary files /dev/null and b/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.dll b/jackify/engine/System.Runtime.InteropServices.dll new file mode 100755 index 0000000..c044074 Binary files /dev/null and b/jackify/engine/System.Runtime.InteropServices.dll differ diff --git a/jackify/engine/System.Runtime.Intrinsics.dll b/jackify/engine/System.Runtime.Intrinsics.dll new file mode 100755 index 0000000..9150906 Binary files /dev/null and b/jackify/engine/System.Runtime.Intrinsics.dll differ diff --git a/jackify/engine/System.Runtime.Loader.dll b/jackify/engine/System.Runtime.Loader.dll new file mode 100755 index 0000000..d95abc1 Binary files /dev/null and b/jackify/engine/System.Runtime.Loader.dll differ diff --git a/jackify/engine/System.Runtime.Numerics.dll b/jackify/engine/System.Runtime.Numerics.dll new file mode 100644 index 0000000..7770937 Binary files /dev/null and b/jackify/engine/System.Runtime.Numerics.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Formatters.dll b/jackify/engine/System.Runtime.Serialization.Formatters.dll new file mode 100644 index 0000000..3812d7c Binary files /dev/null and b/jackify/engine/System.Runtime.Serialization.Formatters.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Json.dll b/jackify/engine/System.Runtime.Serialization.Json.dll new file mode 100755 index 0000000..cf9b28f Binary files /dev/null and b/jackify/engine/System.Runtime.Serialization.Json.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Primitives.dll b/jackify/engine/System.Runtime.Serialization.Primitives.dll new file mode 100644 index 0000000..3195524 Binary files /dev/null and b/jackify/engine/System.Runtime.Serialization.Primitives.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Xml.dll b/jackify/engine/System.Runtime.Serialization.Xml.dll new file mode 100755 index 0000000..00be54c Binary files /dev/null and b/jackify/engine/System.Runtime.Serialization.Xml.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.dll b/jackify/engine/System.Runtime.Serialization.dll new file mode 100755 index 0000000..bfa3cc2 Binary files /dev/null and b/jackify/engine/System.Runtime.Serialization.dll differ diff --git a/jackify/engine/System.Runtime.dll b/jackify/engine/System.Runtime.dll new file mode 100644 index 0000000..4759b4e Binary files /dev/null and b/jackify/engine/System.Runtime.dll differ diff --git a/jackify/engine/System.Security.AccessControl.dll b/jackify/engine/System.Security.AccessControl.dll new file mode 100755 index 0000000..323d184 Binary files /dev/null and b/jackify/engine/System.Security.AccessControl.dll differ diff --git a/jackify/engine/System.Security.Claims.dll b/jackify/engine/System.Security.Claims.dll new file mode 100644 index 0000000..8bd6275 Binary files /dev/null and b/jackify/engine/System.Security.Claims.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Algorithms.dll b/jackify/engine/System.Security.Cryptography.Algorithms.dll new file mode 100755 index 0000000..ba1a829 Binary files /dev/null and b/jackify/engine/System.Security.Cryptography.Algorithms.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Cng.dll b/jackify/engine/System.Security.Cryptography.Cng.dll new file mode 100755 index 0000000..fe3039b Binary files /dev/null and b/jackify/engine/System.Security.Cryptography.Cng.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Csp.dll b/jackify/engine/System.Security.Cryptography.Csp.dll new file mode 100755 index 0000000..aa06329 Binary files /dev/null and b/jackify/engine/System.Security.Cryptography.Csp.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Encoding.dll b/jackify/engine/System.Security.Cryptography.Encoding.dll new file mode 100755 index 0000000..b0ed8a6 Binary files /dev/null and b/jackify/engine/System.Security.Cryptography.Encoding.dll differ diff --git a/jackify/engine/System.Security.Cryptography.OpenSsl.dll b/jackify/engine/System.Security.Cryptography.OpenSsl.dll new file mode 100755 index 0000000..38bb632 Binary files /dev/null and b/jackify/engine/System.Security.Cryptography.OpenSsl.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Primitives.dll b/jackify/engine/System.Security.Cryptography.Primitives.dll new file mode 100755 index 0000000..992c5a5 Binary files /dev/null and b/jackify/engine/System.Security.Cryptography.Primitives.dll differ diff --git a/jackify/engine/System.Security.Cryptography.X509Certificates.dll b/jackify/engine/System.Security.Cryptography.X509Certificates.dll new file mode 100755 index 0000000..4c2921e Binary files /dev/null and b/jackify/engine/System.Security.Cryptography.X509Certificates.dll differ diff --git a/jackify/engine/System.Security.Cryptography.dll b/jackify/engine/System.Security.Cryptography.dll new file mode 100644 index 0000000..b07a3fc Binary files /dev/null and b/jackify/engine/System.Security.Cryptography.dll differ diff --git a/jackify/engine/System.Security.Principal.Windows.dll b/jackify/engine/System.Security.Principal.Windows.dll new file mode 100644 index 0000000..2ce552c Binary files /dev/null and b/jackify/engine/System.Security.Principal.Windows.dll differ diff --git a/jackify/engine/System.Security.Principal.dll b/jackify/engine/System.Security.Principal.dll new file mode 100755 index 0000000..ae34b1b Binary files /dev/null and b/jackify/engine/System.Security.Principal.dll differ diff --git a/jackify/engine/System.Security.SecureString.dll b/jackify/engine/System.Security.SecureString.dll new file mode 100755 index 0000000..610ec22 Binary files /dev/null and b/jackify/engine/System.Security.SecureString.dll differ diff --git a/jackify/engine/System.Security.dll b/jackify/engine/System.Security.dll new file mode 100755 index 0000000..0b2be17 Binary files /dev/null and b/jackify/engine/System.Security.dll differ diff --git a/jackify/engine/System.ServiceModel.Web.dll b/jackify/engine/System.ServiceModel.Web.dll new file mode 100755 index 0000000..bd74d5d Binary files /dev/null and b/jackify/engine/System.ServiceModel.Web.dll differ diff --git a/jackify/engine/System.ServiceProcess.dll b/jackify/engine/System.ServiceProcess.dll new file mode 100755 index 0000000..a48f9a9 Binary files /dev/null and b/jackify/engine/System.ServiceProcess.dll differ diff --git a/jackify/engine/System.Text.Encoding.CodePages.dll b/jackify/engine/System.Text.Encoding.CodePages.dll new file mode 100644 index 0000000..a5ed4e3 Binary files /dev/null and b/jackify/engine/System.Text.Encoding.CodePages.dll differ diff --git a/jackify/engine/System.Text.Encoding.Extensions.dll b/jackify/engine/System.Text.Encoding.Extensions.dll new file mode 100755 index 0000000..4773b0f Binary files /dev/null and b/jackify/engine/System.Text.Encoding.Extensions.dll differ diff --git a/jackify/engine/System.Text.Encoding.dll b/jackify/engine/System.Text.Encoding.dll new file mode 100755 index 0000000..e2dfb8b Binary files /dev/null and b/jackify/engine/System.Text.Encoding.dll differ diff --git a/jackify/engine/System.Text.Encodings.Web.dll b/jackify/engine/System.Text.Encodings.Web.dll new file mode 100644 index 0000000..5f5a85f Binary files /dev/null and b/jackify/engine/System.Text.Encodings.Web.dll differ diff --git a/jackify/engine/System.Text.Json.dll b/jackify/engine/System.Text.Json.dll new file mode 100644 index 0000000..6f459c4 Binary files /dev/null and b/jackify/engine/System.Text.Json.dll differ diff --git a/jackify/engine/System.Text.RegularExpressions.dll b/jackify/engine/System.Text.RegularExpressions.dll new file mode 100644 index 0000000..1ab0715 Binary files /dev/null and b/jackify/engine/System.Text.RegularExpressions.dll differ diff --git a/jackify/engine/System.Threading.Channels.dll b/jackify/engine/System.Threading.Channels.dll new file mode 100644 index 0000000..6d5b284 Binary files /dev/null and b/jackify/engine/System.Threading.Channels.dll differ diff --git a/jackify/engine/System.Threading.Overlapped.dll b/jackify/engine/System.Threading.Overlapped.dll new file mode 100755 index 0000000..7c56bd3 Binary files /dev/null and b/jackify/engine/System.Threading.Overlapped.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Dataflow.dll b/jackify/engine/System.Threading.Tasks.Dataflow.dll new file mode 100755 index 0000000..81e5504 Binary files /dev/null and b/jackify/engine/System.Threading.Tasks.Dataflow.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Extensions.dll b/jackify/engine/System.Threading.Tasks.Extensions.dll new file mode 100755 index 0000000..26a9b3c Binary files /dev/null and b/jackify/engine/System.Threading.Tasks.Extensions.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Parallel.dll b/jackify/engine/System.Threading.Tasks.Parallel.dll new file mode 100644 index 0000000..2131444 Binary files /dev/null and b/jackify/engine/System.Threading.Tasks.Parallel.dll differ diff --git a/jackify/engine/System.Threading.Tasks.dll b/jackify/engine/System.Threading.Tasks.dll new file mode 100755 index 0000000..2cf8204 Binary files /dev/null and b/jackify/engine/System.Threading.Tasks.dll differ diff --git a/jackify/engine/System.Threading.Thread.dll b/jackify/engine/System.Threading.Thread.dll new file mode 100755 index 0000000..4db6b29 Binary files /dev/null and b/jackify/engine/System.Threading.Thread.dll differ diff --git a/jackify/engine/System.Threading.ThreadPool.dll b/jackify/engine/System.Threading.ThreadPool.dll new file mode 100755 index 0000000..20e2fa1 Binary files /dev/null and b/jackify/engine/System.Threading.ThreadPool.dll differ diff --git a/jackify/engine/System.Threading.Timer.dll b/jackify/engine/System.Threading.Timer.dll new file mode 100755 index 0000000..257db6c Binary files /dev/null and b/jackify/engine/System.Threading.Timer.dll differ diff --git a/jackify/engine/System.Threading.dll b/jackify/engine/System.Threading.dll new file mode 100644 index 0000000..d425b98 Binary files /dev/null and b/jackify/engine/System.Threading.dll differ diff --git a/jackify/engine/System.Transactions.Local.dll b/jackify/engine/System.Transactions.Local.dll new file mode 100644 index 0000000..2a8ad7c Binary files /dev/null and b/jackify/engine/System.Transactions.Local.dll differ diff --git a/jackify/engine/System.Transactions.dll b/jackify/engine/System.Transactions.dll new file mode 100755 index 0000000..af18248 Binary files /dev/null and b/jackify/engine/System.Transactions.dll differ diff --git a/jackify/engine/System.ValueTuple.dll b/jackify/engine/System.ValueTuple.dll new file mode 100755 index 0000000..ae19f6f Binary files /dev/null and b/jackify/engine/System.ValueTuple.dll differ diff --git a/jackify/engine/System.Web.HttpUtility.dll b/jackify/engine/System.Web.HttpUtility.dll new file mode 100644 index 0000000..c798cad Binary files /dev/null and b/jackify/engine/System.Web.HttpUtility.dll differ diff --git a/jackify/engine/System.Web.dll b/jackify/engine/System.Web.dll new file mode 100755 index 0000000..576ffb9 Binary files /dev/null and b/jackify/engine/System.Web.dll differ diff --git a/jackify/engine/System.Windows.dll b/jackify/engine/System.Windows.dll new file mode 100755 index 0000000..c2ddce8 Binary files /dev/null and b/jackify/engine/System.Windows.dll differ diff --git a/jackify/engine/System.Xml.Linq.dll b/jackify/engine/System.Xml.Linq.dll new file mode 100644 index 0000000..c1ffd10 Binary files /dev/null and b/jackify/engine/System.Xml.Linq.dll differ diff --git a/jackify/engine/System.Xml.ReaderWriter.dll b/jackify/engine/System.Xml.ReaderWriter.dll new file mode 100755 index 0000000..614b6b7 Binary files /dev/null and b/jackify/engine/System.Xml.ReaderWriter.dll differ diff --git a/jackify/engine/System.Xml.Serialization.dll b/jackify/engine/System.Xml.Serialization.dll new file mode 100755 index 0000000..cf1cddc Binary files /dev/null and b/jackify/engine/System.Xml.Serialization.dll differ diff --git a/jackify/engine/System.Xml.XDocument.dll b/jackify/engine/System.Xml.XDocument.dll new file mode 100755 index 0000000..c65061c Binary files /dev/null and b/jackify/engine/System.Xml.XDocument.dll differ diff --git a/jackify/engine/System.Xml.XPath.XDocument.dll b/jackify/engine/System.Xml.XPath.XDocument.dll new file mode 100755 index 0000000..b0737c5 Binary files /dev/null and b/jackify/engine/System.Xml.XPath.XDocument.dll differ diff --git a/jackify/engine/System.Xml.XPath.dll b/jackify/engine/System.Xml.XPath.dll new file mode 100755 index 0000000..19f1b5c Binary files /dev/null and b/jackify/engine/System.Xml.XPath.dll differ diff --git a/jackify/engine/System.Xml.XmlDocument.dll b/jackify/engine/System.Xml.XmlDocument.dll new file mode 100755 index 0000000..414a849 Binary files /dev/null and b/jackify/engine/System.Xml.XmlDocument.dll differ diff --git a/jackify/engine/System.Xml.XmlSerializer.dll b/jackify/engine/System.Xml.XmlSerializer.dll new file mode 100755 index 0000000..5d751ea Binary files /dev/null and b/jackify/engine/System.Xml.XmlSerializer.dll differ diff --git a/jackify/engine/System.Xml.dll b/jackify/engine/System.Xml.dll new file mode 100755 index 0000000..fd21f3c Binary files /dev/null and b/jackify/engine/System.Xml.dll differ diff --git a/jackify/engine/System.dll b/jackify/engine/System.dll new file mode 100644 index 0000000..b82f502 Binary files /dev/null and b/jackify/engine/System.dll differ diff --git a/jackify/engine/Tools/texconv.exe b/jackify/engine/Tools/texconv.exe new file mode 100755 index 0000000..6ff2033 Binary files /dev/null and b/jackify/engine/Tools/texconv.exe differ diff --git a/jackify/engine/Tools/texdiag.exe b/jackify/engine/Tools/texdiag.exe new file mode 100644 index 0000000..604c5d3 Binary files /dev/null and b/jackify/engine/Tools/texdiag.exe differ diff --git a/jackify/engine/ValveKeyValue.dll b/jackify/engine/ValveKeyValue.dll new file mode 100755 index 0000000..edbfea9 Binary files /dev/null and b/jackify/engine/ValveKeyValue.dll differ diff --git a/jackify/engine/Wabbajack.CLI.Builder.dll b/jackify/engine/Wabbajack.CLI.Builder.dll new file mode 100644 index 0000000..14bc2e4 Binary files /dev/null and b/jackify/engine/Wabbajack.CLI.Builder.dll differ diff --git a/jackify/engine/Wabbajack.Common.dll b/jackify/engine/Wabbajack.Common.dll new file mode 100644 index 0000000..ea7197d Binary files /dev/null and b/jackify/engine/Wabbajack.Common.dll differ diff --git a/jackify/engine/Wabbajack.Compiler.dll b/jackify/engine/Wabbajack.Compiler.dll new file mode 100644 index 0000000..6c70d0f Binary files /dev/null and b/jackify/engine/Wabbajack.Compiler.dll differ diff --git a/jackify/engine/Wabbajack.Compression.BSA.dll b/jackify/engine/Wabbajack.Compression.BSA.dll new file mode 100644 index 0000000..3346388 Binary files /dev/null and b/jackify/engine/Wabbajack.Compression.BSA.dll differ diff --git a/jackify/engine/Wabbajack.Compression.Zip.dll b/jackify/engine/Wabbajack.Compression.Zip.dll new file mode 100644 index 0000000..86eca20 Binary files /dev/null and b/jackify/engine/Wabbajack.Compression.Zip.dll differ diff --git a/jackify/engine/Wabbajack.Configuration.dll b/jackify/engine/Wabbajack.Configuration.dll new file mode 100644 index 0000000..d21ec14 Binary files /dev/null and b/jackify/engine/Wabbajack.Configuration.dll differ diff --git a/jackify/engine/Wabbajack.DTOs.dll b/jackify/engine/Wabbajack.DTOs.dll new file mode 100644 index 0000000..ff69a67 Binary files /dev/null and b/jackify/engine/Wabbajack.DTOs.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll new file mode 100644 index 0000000..ea3576a Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll new file mode 100644 index 0000000..a9612e1 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.GameFile.dll b/jackify/engine/Wabbajack.Downloaders.GameFile.dll new file mode 100644 index 0000000..1b07e36 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.GameFile.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll new file mode 100644 index 0000000..a58d978 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Http.dll b/jackify/engine/Wabbajack.Downloaders.Http.dll new file mode 100644 index 0000000..5c06301 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.Http.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll new file mode 100644 index 0000000..68ad669 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll new file mode 100644 index 0000000..05b2ee1 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Manual.dll b/jackify/engine/Wabbajack.Downloaders.Manual.dll new file mode 100644 index 0000000..16589f1 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.Manual.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll new file mode 100644 index 0000000..d754bbb Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Mega.dll b/jackify/engine/Wabbajack.Downloaders.Mega.dll new file mode 100644 index 0000000..e21a295 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.Mega.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.ModDB.dll b/jackify/engine/Wabbajack.Downloaders.ModDB.dll new file mode 100644 index 0000000..88b79a0 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.ModDB.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Nexus.dll b/jackify/engine/Wabbajack.Downloaders.Nexus.dll new file mode 100644 index 0000000..adc782d Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.Nexus.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll new file mode 100644 index 0000000..0f79b0a Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll new file mode 100644 index 0000000..6fab3b8 Binary files /dev/null and b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll differ diff --git a/jackify/engine/Wabbajack.FileExtractor.dll b/jackify/engine/Wabbajack.FileExtractor.dll new file mode 100644 index 0000000..aba226a Binary files /dev/null and b/jackify/engine/Wabbajack.FileExtractor.dll differ diff --git a/jackify/engine/Wabbajack.Hashing.PHash.dll b/jackify/engine/Wabbajack.Hashing.PHash.dll new file mode 100644 index 0000000..3c8fd44 Binary files /dev/null and b/jackify/engine/Wabbajack.Hashing.PHash.dll differ diff --git a/jackify/engine/Wabbajack.Hashing.xxHash64.dll b/jackify/engine/Wabbajack.Hashing.xxHash64.dll new file mode 100644 index 0000000..19bfe0d Binary files /dev/null and b/jackify/engine/Wabbajack.Hashing.xxHash64.dll differ diff --git a/jackify/engine/Wabbajack.IO.Async.dll b/jackify/engine/Wabbajack.IO.Async.dll new file mode 100644 index 0000000..1154cdf Binary files /dev/null and b/jackify/engine/Wabbajack.IO.Async.dll differ diff --git a/jackify/engine/Wabbajack.Installer.dll b/jackify/engine/Wabbajack.Installer.dll new file mode 100644 index 0000000..457fdd1 Binary files /dev/null and b/jackify/engine/Wabbajack.Installer.dll differ diff --git a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll new file mode 100644 index 0000000..1fd459e Binary files /dev/null and b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Discord.dll b/jackify/engine/Wabbajack.Networking.Discord.dll new file mode 100644 index 0000000..d4a9a4b Binary files /dev/null and b/jackify/engine/Wabbajack.Networking.Discord.dll differ diff --git a/jackify/engine/Wabbajack.Networking.GitHub.dll b/jackify/engine/Wabbajack.Networking.GitHub.dll new file mode 100644 index 0000000..c1ef00d Binary files /dev/null and b/jackify/engine/Wabbajack.Networking.GitHub.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll new file mode 100644 index 0000000..db75358 Binary files /dev/null and b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Http.dll b/jackify/engine/Wabbajack.Networking.Http.dll new file mode 100644 index 0000000..9fcbab4 Binary files /dev/null and b/jackify/engine/Wabbajack.Networking.Http.dll differ diff --git a/jackify/engine/Wabbajack.Networking.NexusApi.dll b/jackify/engine/Wabbajack.Networking.NexusApi.dll new file mode 100644 index 0000000..b135342 Binary files /dev/null and b/jackify/engine/Wabbajack.Networking.NexusApi.dll differ diff --git a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll new file mode 100644 index 0000000..9405ba2 Binary files /dev/null and b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll differ diff --git a/jackify/engine/Wabbajack.Paths.IO.dll b/jackify/engine/Wabbajack.Paths.IO.dll new file mode 100644 index 0000000..dceb0fa Binary files /dev/null and b/jackify/engine/Wabbajack.Paths.IO.dll differ diff --git a/jackify/engine/Wabbajack.Paths.dll b/jackify/engine/Wabbajack.Paths.dll new file mode 100644 index 0000000..9423f72 Binary files /dev/null and b/jackify/engine/Wabbajack.Paths.dll differ diff --git a/jackify/engine/Wabbajack.RateLimiter.dll b/jackify/engine/Wabbajack.RateLimiter.dll new file mode 100644 index 0000000..05f2c2a Binary files /dev/null and b/jackify/engine/Wabbajack.RateLimiter.dll differ diff --git a/jackify/engine/Wabbajack.Server.Lib.dll b/jackify/engine/Wabbajack.Server.Lib.dll new file mode 100644 index 0000000..3f9d903 Binary files /dev/null and b/jackify/engine/Wabbajack.Server.Lib.dll differ diff --git a/jackify/engine/Wabbajack.Services.OSIntegrated.dll b/jackify/engine/Wabbajack.Services.OSIntegrated.dll new file mode 100644 index 0000000..d9da7ae Binary files /dev/null and b/jackify/engine/Wabbajack.Services.OSIntegrated.dll differ diff --git a/jackify/engine/Wabbajack.VFS.Interfaces.dll b/jackify/engine/Wabbajack.VFS.Interfaces.dll new file mode 100644 index 0000000..49b9b3d Binary files /dev/null and b/jackify/engine/Wabbajack.VFS.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.VFS.dll b/jackify/engine/Wabbajack.VFS.dll new file mode 100644 index 0000000..d1f6a6f Binary files /dev/null and b/jackify/engine/Wabbajack.VFS.dll differ diff --git a/jackify/engine/WindowsBase.dll b/jackify/engine/WindowsBase.dll new file mode 100755 index 0000000..d5bcaf6 Binary files /dev/null and b/jackify/engine/WindowsBase.dll differ diff --git a/jackify/engine/YamlDotNet.dll b/jackify/engine/YamlDotNet.dll new file mode 100644 index 0000000..b18e55b Binary files /dev/null and b/jackify/engine/YamlDotNet.dll differ diff --git a/jackify/engine/createdump b/jackify/engine/createdump new file mode 100755 index 0000000..532ec4b Binary files /dev/null and b/jackify/engine/createdump differ diff --git a/jackify/engine/cs/System.CommandLine.resources.dll b/jackify/engine/cs/System.CommandLine.resources.dll new file mode 100755 index 0000000..77a35a6 Binary files /dev/null and b/jackify/engine/cs/System.CommandLine.resources.dll differ diff --git a/jackify/engine/de/System.CommandLine.resources.dll b/jackify/engine/de/System.CommandLine.resources.dll new file mode 100755 index 0000000..61ab752 Binary files /dev/null and b/jackify/engine/de/System.CommandLine.resources.dll differ diff --git a/jackify/engine/es/System.CommandLine.resources.dll b/jackify/engine/es/System.CommandLine.resources.dll new file mode 100755 index 0000000..cdd0618 Binary files /dev/null and b/jackify/engine/es/System.CommandLine.resources.dll differ diff --git a/jackify/engine/fr/System.CommandLine.resources.dll b/jackify/engine/fr/System.CommandLine.resources.dll new file mode 100755 index 0000000..5839cd6 Binary files /dev/null and b/jackify/engine/fr/System.CommandLine.resources.dll differ diff --git a/jackify/engine/it/System.CommandLine.resources.dll b/jackify/engine/it/System.CommandLine.resources.dll new file mode 100755 index 0000000..bdca685 Binary files /dev/null and b/jackify/engine/it/System.CommandLine.resources.dll differ diff --git a/jackify/engine/ja/System.CommandLine.resources.dll b/jackify/engine/ja/System.CommandLine.resources.dll new file mode 100755 index 0000000..be146d9 Binary files /dev/null and b/jackify/engine/ja/System.CommandLine.resources.dll differ diff --git a/jackify/engine/jackify-engine b/jackify/engine/jackify-engine new file mode 100755 index 0000000..6c345bd Binary files /dev/null and b/jackify/engine/jackify-engine differ diff --git a/jackify/engine/jackify-engine.deps.json b/jackify/engine/jackify-engine.deps.json new file mode 100644 index 0000000..a08e564 --- /dev/null +++ b/jackify/engine/jackify-engine.deps.json @@ -0,0 +1,3276 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v8.0/linux-x64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v8.0": {}, + ".NETCoreApp,Version=v8.0/linux-x64": { + "jackify-engine/0.3.10": { + "dependencies": { + "Markdig": "0.40.0", + "Microsoft.Extensions.Configuration.Json": "9.0.1", + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Hosting": "9.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "NLog": "5.3.4", + "NLog.Extensions.Logging": "5.3.15", + "Newtonsoft.Json": "13.0.3", + "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.10", + "Wabbajack.Downloaders.Bethesda": "0.3.10", + "Wabbajack.Downloaders.Dispatcher": "0.3.10", + "Wabbajack.Hashing.xxHash64": "0.3.10", + "Wabbajack.Networking.Discord": "0.3.10", + "Wabbajack.Networking.GitHub": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10", + "Wabbajack.Server.Lib": "0.3.10", + "Wabbajack.Services.OSIntegrated": "0.3.10", + "Wabbajack.VFS": "0.3.10", + "MegaApiClient": "1.0.0.0", + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.19" + }, + "runtime": { + "jackify-engine.dll": {} + } + }, + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.19": { + "runtime": { + "Microsoft.CSharp.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "Microsoft.VisualBasic.Core.dll": { + "assemblyVersion": "13.0.0.0", + "fileVersion": "13.0.1925.36514" + }, + "Microsoft.VisualBasic.dll": { + "assemblyVersion": "10.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "Microsoft.Win32.Primitives.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "Microsoft.Win32.Registry.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.AppContext.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Buffers.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Collections.Concurrent.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Collections.Immutable.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Collections.NonGeneric.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Collections.Specialized.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Collections.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ComponentModel.Annotations.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ComponentModel.DataAnnotations.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ComponentModel.EventBasedAsync.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ComponentModel.Primitives.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ComponentModel.TypeConverter.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ComponentModel.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Configuration.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Console.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Core.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Data.Common.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Data.DataSetExtensions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Data.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.Contracts.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.Debug.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.FileVersionInfo.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.Process.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.StackTrace.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.TextWriterTraceListener.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.Tools.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.TraceSource.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Diagnostics.Tracing.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Drawing.Primitives.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Drawing.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Dynamic.Runtime.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Formats.Asn1.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Formats.Tar.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Globalization.Calendars.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Globalization.Extensions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Globalization.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.Compression.Brotli.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.Compression.FileSystem.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.Compression.ZipFile.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.Compression.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.FileSystem.AccessControl.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.FileSystem.DriveInfo.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.FileSystem.Primitives.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.FileSystem.Watcher.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.FileSystem.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.IsolatedStorage.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.MemoryMappedFiles.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.Pipes.AccessControl.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.Pipes.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.UnmanagedMemoryStream.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.IO.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Linq.Expressions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Linq.Parallel.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Linq.Queryable.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Linq.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Memory.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Http.Json.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Http.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.HttpListener.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Mail.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.NameResolution.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.NetworkInformation.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Ping.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Primitives.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Quic.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Requests.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Security.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.ServicePoint.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.Sockets.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.WebClient.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.WebHeaderCollection.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.WebProxy.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.WebSockets.Client.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.WebSockets.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Net.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Numerics.Vectors.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Numerics.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ObjectModel.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Private.CoreLib.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Private.DataContractSerialization.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Private.Uri.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Private.Xml.Linq.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Private.Xml.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.DispatchProxy.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.Emit.ILGeneration.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.Emit.Lightweight.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.Emit.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.Extensions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.Metadata.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.Primitives.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.TypeExtensions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Reflection.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Resources.Reader.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Resources.ResourceManager.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Resources.Writer.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.CompilerServices.Unsafe.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.CompilerServices.VisualC.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Extensions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Handles.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.InteropServices.JavaScript.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.InteropServices.RuntimeInformation.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.InteropServices.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Intrinsics.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Loader.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Numerics.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Serialization.Formatters.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Serialization.Json.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Serialization.Primitives.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Serialization.Xml.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.Serialization.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Runtime.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.AccessControl.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Claims.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Cryptography.Algorithms.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Cryptography.Cng.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Cryptography.Csp.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Cryptography.Encoding.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Cryptography.OpenSsl.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Cryptography.Primitives.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Cryptography.X509Certificates.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Cryptography.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Principal.Windows.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.Principal.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.SecureString.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Security.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ServiceModel.Web.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ServiceProcess.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Text.Encoding.CodePages.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Text.Encoding.Extensions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Text.Encoding.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Text.RegularExpressions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.Channels.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.Overlapped.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.Tasks.Dataflow.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.Tasks.Extensions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.Tasks.Parallel.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.Tasks.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.Thread.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.ThreadPool.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.Timer.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Threading.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Transactions.Local.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Transactions.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.ValueTuple.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Web.HttpUtility.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Web.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Windows.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.Linq.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.ReaderWriter.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.Serialization.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.XDocument.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.XPath.XDocument.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.XPath.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.XmlDocument.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.XmlSerializer.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.Xml.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "System.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "WindowsBase.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "mscorlib.dll": { + "assemblyVersion": "4.0.0.0", + "fileVersion": "8.0.1925.36514" + }, + "netstandard.dll": { + "assemblyVersion": "2.1.0.0", + "fileVersion": "8.0.1925.36514" + } + }, + "native": { + "createdump": { + "fileVersion": "0.0.0.0" + }, + "libSystem.Globalization.Native.so": { + "fileVersion": "0.0.0.0" + }, + "libSystem.IO.Compression.Native.so": { + "fileVersion": "0.0.0.0" + }, + "libSystem.Native.so": { + "fileVersion": "0.0.0.0" + }, + "libSystem.Net.Security.Native.so": { + "fileVersion": "0.0.0.0" + }, + "libSystem.Security.Cryptography.Native.OpenSsl.so": { + "fileVersion": "0.0.0.0" + }, + "libclrgc.so": { + "fileVersion": "0.0.0.0" + }, + "libclrjit.so": { + "fileVersion": "0.0.0.0" + }, + "libcoreclr.so": { + "fileVersion": "0.0.0.0" + }, + "libcoreclrtraceptprovider.so": { + "fileVersion": "0.0.0.0" + }, + "libhostfxr.so": { + "fileVersion": "0.0.0.0" + }, + "libhostpolicy.so": { + "fileVersion": "0.0.0.0" + }, + "libmscordaccore.so": { + "fileVersion": "0.0.0.0" + }, + "libmscordbi.so": { + "fileVersion": "0.0.0.0" + } + } + }, + "BCnEncoder.Net/2.1.0": { + "dependencies": { + "Microsoft.Toolkit.HighPerformance": "7.1.2" + }, + "runtime": { + "lib/netstandard2.1/BCnEncoder.dll": { + "assemblyVersion": "2.1.0.0", + "fileVersion": "2.1.0.0" + } + } + }, + "BCnEncoder.Net.ImageSharp/1.1.1": { + "dependencies": { + "BCnEncoder.Net": "2.1.0", + "Microsoft.Toolkit.HighPerformance": "7.1.2", + "SixLabors.ImageSharp": "3.1.6" + }, + "runtime": { + "lib/netstandard2.1/BCnEncoder.NET.ImageSharp.dll": { + "assemblyVersion": "1.1.1.0", + "fileVersion": "1.1.1.0" + } + } + }, + "Crc32.NET/1.2.0": { + "dependencies": { + "NETStandard.Library": "2.0.0" + }, + "runtime": { + "lib/netstandard2.0/Crc32.NET.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.2.0.5" + } + } + }, + "DeviceId/6.8.0": { + "runtime": { + "lib/net8.0/DeviceId.dll": { + "assemblyVersion": "6.8.0.0", + "fileVersion": "6.8.0.0" + } + } + }, + "F23.StringSimilarity/6.0.0": { + "dependencies": { + "System.Memory": "4.5.4" + }, + "runtime": { + "lib/netstandard2.0/F23.StringSimilarity.dll": { + "assemblyVersion": "6.0.0.0", + "fileVersion": "6.0.0.0" + } + } + }, + "FluentFTP/52.0.0": { + "runtime": { + "lib/net7.0/FluentFTP.dll": { + "assemblyVersion": "52.0.0.0", + "fileVersion": "52.0.0.0" + } + } + }, + "FluentResults/3.15.2": { + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "System.Threading.Tasks.Extensions": "4.5.4" + }, + "runtime": { + "lib/netstandard2.1/FluentResults.dll": { + "assemblyVersion": "3.15.2.0", + "fileVersion": "3.15.2.0" + } + } + }, + "GameFinder.Common/4.5.0": { + "dependencies": { + "FluentResults": "3.15.2", + "NexusMods.Paths": "0.10.0", + "OneOf": "3.0.271" + }, + "runtime": { + "lib/net8.0/GameFinder.Common.dll": { + "assemblyVersion": "4.5.0.0", + "fileVersion": "4.5.0.0" + } + } + }, + "GameFinder.RegistryUtils/4.5.0": { + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0" + }, + "runtime": { + "lib/net8.0/GameFinder.RegistryUtils.dll": { + "assemblyVersion": "4.5.0.0", + "fileVersion": "4.5.0.0" + } + } + }, + "GameFinder.StoreHandlers.EADesktop/4.5.0": { + "dependencies": { + "GameFinder.Common": "4.5.0", + "SHA3.Net": "2.0.0", + "System.Management": "8.0.0" + }, + "runtime": { + "lib/net8.0/GameFinder.StoreHandlers.EADesktop.dll": { + "assemblyVersion": "4.5.0.0", + "fileVersion": "4.5.0.0" + } + } + }, + "GameFinder.StoreHandlers.EGS/4.5.0": { + "dependencies": { + "GameFinder.Common": "4.5.0", + "GameFinder.RegistryUtils": "4.5.0" + }, + "runtime": { + "lib/net8.0/GameFinder.StoreHandlers.EGS.dll": { + "assemblyVersion": "4.5.0.0", + "fileVersion": "4.5.0.0" + } + } + }, + "GameFinder.StoreHandlers.GOG/4.5.0": { + "dependencies": { + "GameFinder.Common": "4.5.0", + "GameFinder.RegistryUtils": "4.5.0" + }, + "runtime": { + "lib/net8.0/GameFinder.StoreHandlers.GOG.dll": { + "assemblyVersion": "4.5.0.0", + "fileVersion": "4.5.0.0" + } + } + }, + "GameFinder.StoreHandlers.Origin/4.5.0": { + "dependencies": { + "GameFinder.Common": "4.5.0", + "GameFinder.RegistryUtils": "4.5.0" + }, + "runtime": { + "lib/net8.0/GameFinder.StoreHandlers.Origin.dll": { + "assemblyVersion": "4.5.0.0", + "fileVersion": "4.5.0.0" + } + } + }, + "GameFinder.StoreHandlers.Steam/4.5.0": { + "dependencies": { + "GameFinder.Common": "4.5.0", + "GameFinder.RegistryUtils": "4.5.0", + "GameFinder.Wine": "4.5.0", + "ValveKeyValue": "0.10.0.360" + }, + "runtime": { + "lib/net8.0/GameFinder.StoreHandlers.Steam.dll": { + "assemblyVersion": "4.5.0.0", + "fileVersion": "4.5.0.0" + } + } + }, + "GameFinder.Wine/4.5.0": { + "dependencies": { + "GameFinder.Common": "4.5.0", + "GameFinder.RegistryUtils": "4.5.0" + }, + "runtime": { + "lib/net8.0/GameFinder.Wine.dll": { + "assemblyVersion": "4.5.0.0", + "fileVersion": "4.5.0.0" + } + } + }, + "HtmlAgilityPack/1.11.72": { + "runtime": { + "lib/netstandard2.0/HtmlAgilityPack.dll": { + "assemblyVersion": "1.11.72.0", + "fileVersion": "1.11.72.0" + } + } + }, + "ini-parser-netstandard/2.5.2": { + "runtime": { + "lib/netstandard2.0/INIFileParser.dll": { + "assemblyVersion": "2.5.2.0", + "fileVersion": "2.5.2.0" + } + } + }, + "K4os.Compression.LZ4/1.3.8": { + "runtime": { + "lib/net6.0/K4os.Compression.LZ4.dll": { + "assemblyVersion": "1.3.8.0", + "fileVersion": "1.3.8.0" + } + } + }, + "K4os.Compression.LZ4.Streams/1.3.8": { + "dependencies": { + "K4os.Compression.LZ4": "1.3.8", + "K4os.Hash.xxHash": "1.0.8", + "System.IO.Pipelines": "9.0.1" + }, + "runtime": { + "lib/net6.0/K4os.Compression.LZ4.Streams.dll": { + "assemblyVersion": "1.3.8.0", + "fileVersion": "1.3.8.0" + } + } + }, + "K4os.Hash.xxHash/1.0.8": { + "runtime": { + "lib/net6.0/K4os.Hash.xxHash.dll": { + "assemblyVersion": "1.0.8.0", + "fileVersion": "1.0.8.0" + } + } + }, + "LibAES-CTR/1.1.0": { + "runtime": { + "lib/net8.0/AES-CTR-Netstandard.dll": { + "assemblyVersion": "1.1.0.0", + "fileVersion": "1.1.0.0" + } + } + }, + "Markdig/0.40.0": { + "runtime": { + "lib/net8.0/Markdig.dll": { + "assemblyVersion": "0.40.0.0", + "fileVersion": "0.40.0.0" + } + } + }, + "Microsoft.AspNetCore.Http.Abstractions/2.3.0": { + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.3.0", + "System.Text.Encodings.Web": "9.0.1" + }, + "runtime": { + "lib/netstandard2.0/Microsoft.AspNetCore.Http.Abstractions.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.25014" + } + } + }, + "Microsoft.AspNetCore.Http.Extensions/2.3.0": { + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "2.3.0", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", + "Microsoft.Net.Http.Headers": "2.3.0", + "System.Buffers": "4.6.0" + }, + "runtime": { + "lib/netstandard2.0/Microsoft.AspNetCore.Http.Extensions.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.25014" + } + } + }, + "Microsoft.AspNetCore.Http.Features/2.3.0": { + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1" + }, + "runtime": { + "lib/netstandard2.0/Microsoft.AspNetCore.Http.Features.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.25014" + } + } + }, + "Microsoft.CSharp/4.7.0": {}, + "Microsoft.Extensions.Configuration/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Configuration.Abstractions/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.Abstractions.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Configuration.Binder/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.Binder.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Configuration.CommandLine/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.CommandLine.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.EnvironmentVariables.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Configuration.FileExtensions/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", + "Microsoft.Extensions.FileProviders.Physical": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.FileExtensions.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Configuration.Json/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", + "System.Text.Json": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.Json.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Configuration.UserSecrets/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Configuration.Json": "9.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", + "Microsoft.Extensions.FileProviders.Physical": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.UserSecrets.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.DependencyInjection/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.DependencyInjection.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions/9.0.1": { + "runtime": { + "lib/net8.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Diagnostics/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Diagnostics.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1", + "System.Diagnostics.DiagnosticSource": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Diagnostics.Abstractions.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.FileProviders.Abstractions/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.FileProviders.Abstractions.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.FileProviders.Physical/9.0.1": { + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", + "Microsoft.Extensions.FileSystemGlobbing": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.FileProviders.Physical.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.FileSystemGlobbing/9.0.1": { + "runtime": { + "lib/net8.0/Microsoft.Extensions.FileSystemGlobbing.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Hosting/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Configuration.Binder": "9.0.1", + "Microsoft.Extensions.Configuration.CommandLine": "9.0.1", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", + "Microsoft.Extensions.Configuration.Json": "9.0.1", + "Microsoft.Extensions.Configuration.UserSecrets": "9.0.1", + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Diagnostics": "9.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", + "Microsoft.Extensions.FileProviders.Physical": "9.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging.Configuration": "9.0.1", + "Microsoft.Extensions.Logging.Console": "9.0.1", + "Microsoft.Extensions.Logging.Debug": "9.0.1", + "Microsoft.Extensions.Logging.EventLog": "9.0.1", + "Microsoft.Extensions.Logging.EventSource": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Hosting.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Hosting.Abstractions/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Hosting.Abstractions.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Http/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Diagnostics": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Http.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Logging/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Logging.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Logging.Abstractions/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "System.Diagnostics.DiagnosticSource": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Logging.Abstractions.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Logging.Configuration/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Configuration.Binder": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Logging.Configuration.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Logging.Console/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging.Configuration": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1", + "System.Text.Json": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Logging.Console.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Logging.Debug/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Logging.Debug.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Logging.EventLog/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1", + "System.Diagnostics.EventLog": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Logging.EventLog.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Logging.EventSource/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1", + "System.Text.Json": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Logging.EventSource.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Options/9.0.1": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Options.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions/9.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Configuration.Binder": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Extensions.Primitives/9.0.1": { + "runtime": { + "lib/net8.0/Microsoft.Extensions.Primitives.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "Microsoft.Net.Http.Headers/2.3.0": { + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1", + "System.Buffers": "4.6.0" + }, + "runtime": { + "lib/netstandard2.0/Microsoft.Net.Http.Headers.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.25014" + } + } + }, + "Microsoft.NETCore.Platforms/5.0.0": {}, + "Microsoft.Toolkit.HighPerformance/7.1.2": { + "runtime": { + "lib/net5.0/Microsoft.Toolkit.HighPerformance.dll": { + "assemblyVersion": "7.1.0.0", + "fileVersion": "7.1.2.1" + } + } + }, + "Microsoft.Win32.Registry/5.0.0": { + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Microsoft.Win32.SystemEvents/5.0.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0" + }, + "runtime": { + "lib/netstandard2.0/Microsoft.Win32.SystemEvents.dll": { + "assemblyVersion": "5.0.0.0", + "fileVersion": "5.0.20.51904" + } + } + }, + "NETStandard.Library/2.0.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0" + } + }, + "Nettle/3.0.0": { + "dependencies": { + "Microsoft.CSharp": "4.7.0" + }, + "runtime": { + "lib/net7.0/Nettle.dll": { + "assemblyVersion": "3.0.0.0", + "fileVersion": "3.0.0.0" + } + } + }, + "Newtonsoft.Json/13.0.3": { + "runtime": { + "lib/net6.0/Newtonsoft.Json.dll": { + "assemblyVersion": "13.0.0.0", + "fileVersion": "13.0.3.27908" + } + } + }, + "NexusMods.Paths/0.10.0": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Reloaded.Memory": "9.4.1" + }, + "runtime": { + "lib/net7.0/NexusMods.Paths.dll": { + "assemblyVersion": "0.10.0.0", + "fileVersion": "0.10.0.0" + } + } + }, + "NLog/5.3.4": { + "runtime": { + "lib/netstandard2.0/NLog.dll": { + "assemblyVersion": "5.0.0.0", + "fileVersion": "5.3.4.2778" + } + } + }, + "NLog.Extensions.Logging/5.3.15": { + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "NLog": "5.3.4" + }, + "runtime": { + "lib/net8.0/NLog.Extensions.Logging.dll": { + "assemblyVersion": "5.0.0.0", + "fileVersion": "5.3.15.614" + } + } + }, + "Octokit/14.0.0": { + "runtime": { + "lib/netstandard2.0/Octokit.dll": { + "assemblyVersion": "14.0.0.0", + "fileVersion": "14.0.0.0" + } + } + }, + "Octopus.Octodiff/2.0.548": { + "runtime": { + "lib/netstandard2.0/Octodiff.dll": { + "assemblyVersion": "2.0.548.0", + "fileVersion": "2.0.548.0" + } + } + }, + "OMODFramework/3.0.1": { + "dependencies": { + "Crc32.NET": "1.2.0", + "K4os.Compression.LZ4": "1.3.8", + "K4os.Compression.LZ4.Streams": "1.3.8", + "SharpCompress": "0.28.1", + "System.Drawing.Common": "5.0.2", + "System.Text.Encoding.CodePages": "5.0.0" + }, + "runtime": { + "lib/net5.0/OMODFramework.dll": { + "assemblyVersion": "3.0.1.0", + "fileVersion": "3.0.1.0" + } + } + }, + "OneOf/3.0.271": { + "runtime": { + "lib/netstandard2.0/OneOf.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "Reloaded.Memory/9.4.1": { + "runtime": { + "lib/net8.0/Reloaded.Memory.dll": { + "assemblyVersion": "9.4.1.0", + "fileVersion": "9.4.1.0" + } + } + }, + "SHA3.Net/2.0.0": { + "runtime": { + "lib/net6.0/SHA3.Net.dll": { + "assemblyVersion": "2.0.0.0", + "fileVersion": "2.0.0.0" + } + } + }, + "SharpCompress/0.28.1": { + "runtime": { + "lib/net5.0/SharpCompress.dll": { + "assemblyVersion": "0.28.1.0", + "fileVersion": "0.28.1.0" + } + } + }, + "SharpZipLib/1.4.2": { + "runtime": { + "lib/net6.0/ICSharpCode.SharpZipLib.dll": { + "assemblyVersion": "1.4.2.13", + "fileVersion": "1.4.2.13" + } + } + }, + "Shipwreck.Phash/0.5.0": { + "runtime": { + "lib/netcoreapp3.0/Shipwreck.Phash.dll": { + "assemblyVersion": "0.5.0.0", + "fileVersion": "0.5.0.0" + } + } + }, + "shortid/4.0.0": { + "runtime": { + "lib/netstandard1.0/shortid.dll": { + "assemblyVersion": "3.0.1.0", + "fileVersion": "3.0.1.0" + } + } + }, + "SixLabors.ImageSharp/3.1.6": { + "runtime": { + "lib/net6.0/SixLabors.ImageSharp.dll": { + "assemblyVersion": "3.0.0.0", + "fileVersion": "3.1.6.0" + } + } + }, + "Stub.System.Data.SQLite.Core.NetStandard/1.0.119": { + "runtime": { + "lib/netstandard2.1/System.Data.SQLite.dll": { + "assemblyVersion": "1.0.119.0", + "fileVersion": "1.0.119.0" + } + }, + "native": { + "runtimes/linux-x64/native/SQLite.Interop.dll": { + "fileVersion": "0.0.0.0" + } + } + }, + "System.Buffers/4.6.0": {}, + "System.CodeDom/8.0.0": { + "runtime": { + "lib/net8.0/System.CodeDom.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.23.53103" + } + } + }, + "System.CommandLine/2.0.0-beta4.22272.1": { + "runtime": { + "lib/net6.0/System.CommandLine.dll": { + "assemblyVersion": "2.0.0.0", + "fileVersion": "2.0.22.27201" + } + }, + "resources": { + "lib/net6.0/cs/System.CommandLine.resources.dll": { + "locale": "cs" + }, + "lib/net6.0/de/System.CommandLine.resources.dll": { + "locale": "de" + }, + "lib/net6.0/es/System.CommandLine.resources.dll": { + "locale": "es" + }, + "lib/net6.0/fr/System.CommandLine.resources.dll": { + "locale": "fr" + }, + "lib/net6.0/it/System.CommandLine.resources.dll": { + "locale": "it" + }, + "lib/net6.0/ja/System.CommandLine.resources.dll": { + "locale": "ja" + }, + "lib/net6.0/ko/System.CommandLine.resources.dll": { + "locale": "ko" + }, + "lib/net6.0/pl/System.CommandLine.resources.dll": { + "locale": "pl" + }, + "lib/net6.0/pt-BR/System.CommandLine.resources.dll": { + "locale": "pt-BR" + }, + "lib/net6.0/ru/System.CommandLine.resources.dll": { + "locale": "ru" + }, + "lib/net6.0/tr/System.CommandLine.resources.dll": { + "locale": "tr" + }, + "lib/net6.0/zh-Hans/System.CommandLine.resources.dll": { + "locale": "zh-Hans" + }, + "lib/net6.0/zh-Hant/System.CommandLine.resources.dll": { + "locale": "zh-Hant" + } + } + }, + "System.CommandLine.NamingConventionBinder/2.0.0-beta4.22272.1": { + "dependencies": { + "System.CommandLine": "2.0.0-beta4.22272.1" + }, + "runtime": { + "lib/netstandard2.0/System.CommandLine.NamingConventionBinder.dll": { + "assemblyVersion": "2.0.0.0", + "fileVersion": "2.0.22.27201" + } + } + }, + "System.Data.SQLite.Core/1.0.119": { + "dependencies": { + "Stub.System.Data.SQLite.Core.NetStandard": "1.0.119" + } + }, + "System.Diagnostics.DiagnosticSource/9.0.1": { + "runtime": { + "lib/net8.0/System.Diagnostics.DiagnosticSource.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "System.Diagnostics.EventLog/9.0.1": { + "runtime": { + "lib/net8.0/System.Diagnostics.EventLog.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "System.Drawing.Common/5.0.2": { + "dependencies": { + "Microsoft.Win32.SystemEvents": "5.0.0" + }, + "runtime": { + "runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll": { + "assemblyVersion": "5.0.0.2", + "fileVersion": "5.0.421.11614" + } + } + }, + "System.IO.Pipelines/9.0.1": { + "runtime": { + "lib/net8.0/System.IO.Pipelines.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "System.Management/8.0.0": { + "dependencies": { + "System.CodeDom": "8.0.0" + }, + "runtime": { + "lib/net8.0/System.Management.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.23.53103" + } + } + }, + "System.Memory/4.5.4": {}, + "System.Reactive/6.0.1": { + "runtime": { + "lib/net6.0/System.Reactive.dll": { + "assemblyVersion": "6.0.0.0", + "fileVersion": "6.0.1.7420" + } + } + }, + "System.Security.AccessControl/5.0.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Principal.Windows/5.0.0": {}, + "System.Text.Encoding.CodePages/5.0.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0" + } + }, + "System.Text.Encodings.Web/9.0.1": { + "runtime": { + "lib/net8.0/System.Text.Encodings.Web.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "System.Text.Json/9.0.1": { + "dependencies": { + "System.IO.Pipelines": "9.0.1", + "System.Text.Encodings.Web": "9.0.1" + }, + "runtime": { + "lib/net8.0/System.Text.Json.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.124.61010" + } + } + }, + "System.Threading.Tasks.Extensions/4.5.4": {}, + "ValveKeyValue/0.10.0.360": { + "runtime": { + "lib/netstandard2.1/ValveKeyValue.dll": { + "assemblyVersion": "0.10.0.0", + "fileVersion": "0.10.0.360" + } + } + }, + "YamlDotNet/16.3.0": { + "runtime": { + "lib/net8.0/YamlDotNet.dll": { + "assemblyVersion": "16.0.0.0", + "fileVersion": "16.3.0.0" + } + } + }, + "Wabbajack.CLI.Builder/0.3.10": { + "dependencies": { + "Microsoft.Extensions.Configuration.Json": "9.0.1", + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Hosting": "9.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", + "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.10" + }, + "runtime": { + "Wabbajack.CLI.Builder.dll": {} + } + }, + "Wabbajack.Common/0.3.10": { + "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.10", + "Wabbajack.Networking.Http": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10" + }, + "runtime": { + "Wabbajack.Common.dll": {} + } + }, + "Wabbajack.Compiler/0.3.10": { + "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.10", + "Wabbajack.Installer": "0.3.10", + "Wabbajack.VFS": "0.3.10", + "ini-parser-netstandard": "2.5.2" + }, + "runtime": { + "Wabbajack.Compiler.dll": {} + } + }, + "Wabbajack.Compression.BSA/0.3.10": { + "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.10", + "Wabbajack.DTOs": "0.3.10" + }, + "runtime": { + "Wabbajack.Compression.BSA.dll": {} + } + }, + "Wabbajack.Compression.Zip/0.3.10": { + "dependencies": { + "Wabbajack.IO.Async": "0.3.10" + }, + "runtime": { + "Wabbajack.Compression.Zip.dll": {} + } + }, + "Wabbajack.Configuration/0.3.10": { + "runtime": { + "Wabbajack.Configuration.dll": {} + } + }, + "Wabbajack.Downloaders.Bethesda/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Networking.BethesdaNet": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.Bethesda.dll": {} + } + }, + "Wabbajack.Downloaders.Dispatcher/0.3.10": { + "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.10", + "Wabbajack.Downloaders.GameFile": "0.3.10", + "Wabbajack.Downloaders.GoogleDrive": "0.3.10", + "Wabbajack.Downloaders.Http": "0.3.10", + "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.3.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Downloaders.Manual": "0.3.10", + "Wabbajack.Downloaders.MediaFire": "0.3.10", + "Wabbajack.Downloaders.Mega": "0.3.10", + "Wabbajack.Downloaders.ModDB": "0.3.10", + "Wabbajack.Downloaders.Nexus": "0.3.10", + "Wabbajack.Downloaders.VerificationCache": "0.3.10", + "Wabbajack.Downloaders.WabbajackCDN": "0.3.10", + "Wabbajack.Networking.WabbajackClientApi": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.Dispatcher.dll": {} + } + }, + "Wabbajack.Downloaders.GameFile/0.3.10": { + "dependencies": { + "GameFinder.StoreHandlers.EADesktop": "4.5.0", + "GameFinder.StoreHandlers.EGS": "4.5.0", + "GameFinder.StoreHandlers.GOG": "4.5.0", + "GameFinder.StoreHandlers.Origin": "4.5.0", + "GameFinder.StoreHandlers.Steam": "4.5.0", + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "SixLabors.ImageSharp": "3.1.6", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.VFS": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.GameFile.dll": {} + } + }, + "Wabbajack.Downloaders.GoogleDrive/0.3.10": { + "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.10", + "Wabbajack.DTOs": "0.3.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Networking.Http": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.GoogleDrive.dll": {} + } + }, + "Wabbajack.Downloaders.Http/0.3.10": { + "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.10", + "Wabbajack.DTOs": "0.3.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Networking.BethesdaNet": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.Http.dll": {} + } + }, + "Wabbajack.Downloaders.Interfaces/0.3.10": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Wabbajack.Compression.Zip": "0.3.10", + "Wabbajack.DTOs": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.Interfaces.dll": {} + } + }, + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Networking.Http": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {} + } + }, + "Wabbajack.Downloaders.Manual/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.Manual.dll": {} + } + }, + "Wabbajack.Downloaders.MediaFire/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.MediaFire.dll": {} + } + }, + "Wabbajack.Downloaders.Mega/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.Mega.dll": {} + } + }, + "Wabbajack.Downloaders.ModDB/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Networking.Http": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.ModDB.dll": {} + } + }, + "Wabbajack.Downloaders.Nexus/0.3.10": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Wabbajack.DTOs": "0.3.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Hashing.xxHash64": "0.3.10", + "Wabbajack.Networking.Http": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10", + "Wabbajack.Networking.NexusApi": "0.3.10", + "Wabbajack.Paths": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.Nexus.dll": {} + } + }, + "Wabbajack.Downloaders.VerificationCache/0.3.10": { + "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.10", + "Wabbajack.Paths.IO": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.VerificationCache.dll": {} + } + }, + "Wabbajack.Downloaders.WabbajackCDN/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Networking.Http": "0.3.10", + "Wabbajack.RateLimiter": "0.3.10" + }, + "runtime": { + "Wabbajack.Downloaders.WabbajackCDN.dll": {} + } + }, + "Wabbajack.DTOs/0.3.10": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Wabbajack.Hashing.xxHash64": "0.3.10", + "Wabbajack.Paths": "0.3.10" + }, + "runtime": { + "Wabbajack.DTOs.dll": {} + } + }, + "Wabbajack.FileExtractor/0.3.10": { + "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.10", + "Wabbajack.Compression.BSA": "0.3.10", + "Wabbajack.Hashing.PHash": "0.3.10", + "Wabbajack.Paths": "0.3.10" + }, + "runtime": { + "Wabbajack.FileExtractor.dll": {} + } + }, + "Wabbajack.Hashing.PHash/0.3.10": { + "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.10", + "Wabbajack.DTOs": "0.3.10", + "Wabbajack.Paths": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10" + }, + "runtime": { + "Wabbajack.Hashing.PHash.dll": {} + } + }, + "Wabbajack.Hashing.xxHash64/0.3.10": { + "dependencies": { + "Wabbajack.Paths": "0.3.10", + "Wabbajack.RateLimiter": "0.3.10" + }, + "runtime": { + "Wabbajack.Hashing.xxHash64.dll": {} + } + }, + "Wabbajack.Installer/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Dispatcher": "0.3.10", + "Wabbajack.Downloaders.GameFile": "0.3.10", + "Wabbajack.FileExtractor": "0.3.10", + "Wabbajack.Networking.WabbajackClientApi": "0.3.10", + "Wabbajack.Paths": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10", + "Wabbajack.VFS": "0.3.10", + "ini-parser-netstandard": "2.5.2" + }, + "runtime": { + "Wabbajack.Installer.dll": {} + } + }, + "Wabbajack.IO.Async/0.3.10": { + "runtime": { + "Wabbajack.IO.Async.dll": {} + } + }, + "Wabbajack.Networking.BethesdaNet/0.3.10": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Wabbajack.DTOs": "0.3.10", + "Wabbajack.Networking.Http": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.Networking.BethesdaNet.dll": {} + } + }, + "Wabbajack.Networking.Discord/0.3.10": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Wabbajack.Networking.Http.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.Networking.Discord.dll": {} + } + }, + "Wabbajack.Networking.GitHub/0.3.10": { + "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.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.Networking.GitHub.dll": {} + } + }, + "Wabbajack.Networking.Http/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Interfaces": "0.3.10", + "Wabbajack.Hashing.xxHash64": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10", + "Wabbajack.Paths": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10" + }, + "runtime": { + "Wabbajack.Networking.Http.dll": {} + } + }, + "Wabbajack.Networking.Http.Interfaces/0.3.10": { + "dependencies": { + "Wabbajack.Hashing.xxHash64": "0.3.10" + }, + "runtime": { + "Wabbajack.Networking.Http.Interfaces.dll": {} + } + }, + "Wabbajack.Networking.NexusApi/0.3.10": { + "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.10", + "Wabbajack.Networking.Http": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10", + "Wabbajack.Networking.WabbajackClientApi": "0.3.10" + }, + "runtime": { + "Wabbajack.Networking.NexusApi.dll": {} + } + }, + "Wabbajack.Networking.WabbajackClientApi/0.3.10": { + "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.10", + "Wabbajack.DTOs": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10", + "Wabbajack.VFS.Interfaces": "0.3.10", + "YamlDotNet": "16.3.0" + }, + "runtime": { + "Wabbajack.Networking.WabbajackClientApi.dll": {} + } + }, + "Wabbajack.Paths/0.3.10": { + "runtime": { + "Wabbajack.Paths.dll": {} + } + }, + "Wabbajack.Paths.IO/0.3.10": { + "dependencies": { + "Wabbajack.Paths": "0.3.10", + "shortid": "4.0.0" + }, + "runtime": { + "Wabbajack.Paths.IO.dll": {} + } + }, + "Wabbajack.RateLimiter/0.3.10": { + "runtime": { + "Wabbajack.RateLimiter.dll": {} + } + }, + "Wabbajack.Server.Lib/0.3.10": { + "dependencies": { + "FluentFTP": "52.0.0", + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Nettle": "3.0.0", + "Newtonsoft.Json": "13.0.3", + "SixLabors.ImageSharp": "3.1.6", + "Wabbajack.Common": "0.3.10", + "Wabbajack.Networking.Http.Interfaces": "0.3.10", + "Wabbajack.Services.OSIntegrated": "0.3.10" + }, + "runtime": { + "Wabbajack.Server.Lib.dll": {} + } + }, + "Wabbajack.Services.OSIntegrated/0.3.10": { + "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.10", + "Wabbajack.Downloaders.Dispatcher": "0.3.10", + "Wabbajack.Installer": "0.3.10", + "Wabbajack.Networking.BethesdaNet": "0.3.10", + "Wabbajack.Networking.Discord": "0.3.10", + "Wabbajack.VFS": "0.3.10" + }, + "runtime": { + "Wabbajack.Services.OSIntegrated.dll": {} + } + }, + "Wabbajack.VFS/0.3.10": { + "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.10", + "Wabbajack.FileExtractor": "0.3.10", + "Wabbajack.Hashing.PHash": "0.3.10", + "Wabbajack.Hashing.xxHash64": "0.3.10", + "Wabbajack.Paths": "0.3.10", + "Wabbajack.Paths.IO": "0.3.10", + "Wabbajack.VFS.Interfaces": "0.3.10" + }, + "runtime": { + "Wabbajack.VFS.dll": {} + } + }, + "Wabbajack.VFS.Interfaces/0.3.10": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Wabbajack.DTOs": "0.3.10", + "Wabbajack.Hashing.xxHash64": "0.3.10", + "Wabbajack.Paths": "0.3.10" + }, + "runtime": { + "Wabbajack.VFS.Interfaces.dll": {} + } + }, + "MegaApiClient/1.0.0.0": { + "runtime": { + "MegaApiClient.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + } + } + }, + "libraries": { + "jackify-engine/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.19": { + "type": "runtimepack", + "serviceable": false, + "sha512": "" + }, + "BCnEncoder.Net/2.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-bBKYUDhpYenmp8vy5BVUHmS2LFym0NMga9V+S1eMJ+4zQLBwDw63Bs83AKOyqBKC2AqqgxXdTFA2a2V+SmGoIw==", + "path": "bcnencoder.net/2.1.0", + "hashPath": "bcnencoder.net.2.1.0.nupkg.sha512" + }, + "BCnEncoder.Net.ImageSharp/1.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-JL/woctP6UucxcLKUruMI+TIWTbSV7KO1RWY9/xMDuQqwo1BFXAIrShllMGHYIJ4B9bwGVGZNNbZUyM7rPLMTA==", + "path": "bcnencoder.net.imagesharp/1.1.1", + "hashPath": "bcnencoder.net.imagesharp.1.1.1.nupkg.sha512" + }, + "Crc32.NET/1.2.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-wNW/huzolu8MNKUnwCVKxjfAlCFpeI8AZVfF46iAWJ1+P6bTU1AZct7VAkDDEjgeeTJCVTkGZaD6jSd/fOiUkA==", + "path": "crc32.net/1.2.0", + "hashPath": "crc32.net.1.2.0.nupkg.sha512" + }, + "DeviceId/6.8.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ucVESw96iKjkf4whbY52j8yAzqNk2AnLrPCgRt28Xpuo+dA2GtwXAtqTW3nfK2B3se3jg5aEoaIulHIg591stA==", + "path": "deviceid/6.8.0", + "hashPath": "deviceid.6.8.0.nupkg.sha512" + }, + "F23.StringSimilarity/6.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-kzEjznD9nAOep+E3LGIMD6mIAc+xEAJ91ds633+PxrBHs6i0ly6AI7v98B8ueTkKnYufFt/W0lytkoa+k7/d8w==", + "path": "f23.stringsimilarity/6.0.0", + "hashPath": "f23.stringsimilarity.6.0.0.nupkg.sha512" + }, + "FluentFTP/52.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-8wR9CCB++2eM4Y1b+HV6heWv5EDSOk3wiBWz4G9bNKjsEnTq4UUH9/g4RjmcmdN2mszIPclWS0cEuVMdqEuu1g==", + "path": "fluentftp/52.0.0", + "hashPath": "fluentftp.52.0.0.nupkg.sha512" + }, + "FluentResults/3.15.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-yR3PD1SWKirYNpQ/t9TjJrsp1adazMmozLDPeamvSIljLAIjS0EQyyGvoupWsbZEryqW9SHbKXVL8YaJwURk8w==", + "path": "fluentresults/3.15.2", + "hashPath": "fluentresults.3.15.2.nupkg.sha512" + }, + "GameFinder.Common/4.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ybAKnLnE7Dw7KnoblLko8KlTZR3B+fKoB/og7qWHtZvpwCU1LEnQyN7hF+v6ENB1Hgph6AnK7I9TNdYRFfFyFg==", + "path": "gamefinder.common/4.5.0", + "hashPath": "gamefinder.common.4.5.0.nupkg.sha512" + }, + "GameFinder.RegistryUtils/4.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-6CmRpMUlRF0Dr5yjKXXUM2as79YahccPsWsnfQTQ5uoGOI7REakQwEnep7ArogyI+vZ+rxX8UwMHrizuefTqkg==", + "path": "gamefinder.registryutils/4.5.0", + "hashPath": "gamefinder.registryutils.4.5.0.nupkg.sha512" + }, + "GameFinder.StoreHandlers.EADesktop/4.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Mzqva+/Jv8oBQP+fvseV5V3hkfE0kBYZvuD4W9iKOKyBeDW/Cu2MKMz/A4qefzuYnZn/yC5pt/1xc0IvTpUjDA==", + "path": "gamefinder.storehandlers.eadesktop/4.5.0", + "hashPath": "gamefinder.storehandlers.eadesktop.4.5.0.nupkg.sha512" + }, + "GameFinder.StoreHandlers.EGS/4.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-wpqN2mkjYzJHNTchAwJ1H2mG7+IQ0+Fhc631gL5nWOF2XQz8ld9Z8+NYl45tllXgzTohAZiduDFmJkedZ2p4VQ==", + "path": "gamefinder.storehandlers.egs/4.5.0", + "hashPath": "gamefinder.storehandlers.egs.4.5.0.nupkg.sha512" + }, + "GameFinder.StoreHandlers.GOG/4.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-4Du2F2HMekVDcT1beq4OoER6wIpKOAg1D/9qhkSefclEYCoBvmYPsfn20xeEm2ebv86q7UWSZFJOtQBbrv41iQ==", + "path": "gamefinder.storehandlers.gog/4.5.0", + "hashPath": "gamefinder.storehandlers.gog.4.5.0.nupkg.sha512" + }, + "GameFinder.StoreHandlers.Origin/4.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-H3zVo2ArEh2j0ZJCNyXLpiDajkH2GEjEMBypS/7oSY/hDuOkPOFPeNy6nKjJg2mjUT3rAgFYDwYiXLthWODQdA==", + "path": "gamefinder.storehandlers.origin/4.5.0", + "hashPath": "gamefinder.storehandlers.origin.4.5.0.nupkg.sha512" + }, + "GameFinder.StoreHandlers.Steam/4.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-shGE0ibk4bhdyTIxfLD1s7/M9GYgdbGDuyvQ0WnVpCibKs7kA8b09NEjRoxHuGwDbeEKJwTaly5odeBYCARWHA==", + "path": "gamefinder.storehandlers.steam/4.5.0", + "hashPath": "gamefinder.storehandlers.steam.4.5.0.nupkg.sha512" + }, + "GameFinder.Wine/4.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-MoWh6zwFrqI4PZD219CSYWgIe8E23ORoYdXRZKC+oLsSOUl6YpvwsT8mwl3E83czLOxE+ZgqbpuCgntKs2FBAg==", + "path": "gamefinder.wine/4.5.0", + "hashPath": "gamefinder.wine.4.5.0.nupkg.sha512" + }, + "HtmlAgilityPack/1.11.72": { + "type": "package", + "serviceable": true, + "sha512": "sha512-RNLgXxTFdIGFI+o5l8c2aJ2L5StIRn9Uv8HKR76p7QP4ZUL26wzpWUCWh08xWUdkL2kocl+Xhv6VUu0rA1npVg==", + "path": "htmlagilitypack/1.11.72", + "hashPath": "htmlagilitypack.1.11.72.nupkg.sha512" + }, + "ini-parser-netstandard/2.5.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-rTyGzgT/a+mD2HxopOGpb/yWWhu5TTPgF59XUlHjhlNv7dbt9DME/qozIR7HreVAVpNnZSqP9A8TeViK5s949g==", + "path": "ini-parser-netstandard/2.5.2", + "hashPath": "ini-parser-netstandard.2.5.2.nupkg.sha512" + }, + "K4os.Compression.LZ4/1.3.8": { + "type": "package", + "serviceable": true, + "sha512": "sha512-LhwlPa7c1zs1OV2XadMtAWdImjLIsqFJPoRcIWAadSRn0Ri1DepK65UbWLPmt4riLqx2d40xjXRk0ogpqNtK7g==", + "path": "k4os.compression.lz4/1.3.8", + "hashPath": "k4os.compression.lz4.1.3.8.nupkg.sha512" + }, + "K4os.Compression.LZ4.Streams/1.3.8": { + "type": "package", + "serviceable": true, + "sha512": "sha512-P15qr8dZAeo9GvYbUIPEYFQ0MEJ0i5iqr37wsYeRC3la2uCldOoeCa6to0CZ1taiwxIV+Mk8NGuZi+4iWivK9w==", + "path": "k4os.compression.lz4.streams/1.3.8", + "hashPath": "k4os.compression.lz4.streams.1.3.8.nupkg.sha512" + }, + "K4os.Hash.xxHash/1.0.8": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Wp2F7BamQ2Q/7Hk834nV9vRQapgcr8kgv9Jvfm8J3D0IhDqZMMl+a2yxUq5ltJitvXvQfB8W6K4F4fCbw/P6YQ==", + "path": "k4os.hash.xxhash/1.0.8", + "hashPath": "k4os.hash.xxhash.1.0.8.nupkg.sha512" + }, + "LibAES-CTR/1.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-qyHN/VQPA+MyoEr2pzcUcF41Hxh7GCtcmeGDNIEcnZP5A41ZfCke89dPd9xrxywuy6BJd/QleHFKNeZY2QUO9A==", + "path": "libaes-ctr/1.1.0", + "hashPath": "libaes-ctr.1.1.0.nupkg.sha512" + }, + "Markdig/0.40.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-4ve14zs+gt1irldTQE3y5FLAHuzmhW7T99lAAvVipe/q2LWT/nUCO0iICb9TXGvMX6n7Z1OZroFXkdSy91rO8w==", + "path": "markdig/0.40.0", + "hashPath": "markdig.0.40.0.nupkg.sha512" + }, + "Microsoft.AspNetCore.Http.Abstractions/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-39r9PPrjA6s0blyFv5qarckjNkaHRA5B+3b53ybuGGNTXEj1/DStQJ4NWjFL6QTRQpL9zt7nDyKxZdJOlcnq+Q==", + "path": "microsoft.aspnetcore.http.abstractions/2.3.0", + "hashPath": "microsoft.aspnetcore.http.abstractions.2.3.0.nupkg.sha512" + }, + "Microsoft.AspNetCore.Http.Extensions/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-EY2u/wFF5jsYwGXXswfQWrSsFPmiXsniAlUWo3rv/MGYf99ZFsENDnZcQP6W3c/+xQmQXq0NauzQ7jyy+o1LDQ==", + "path": "microsoft.aspnetcore.http.extensions/2.3.0", + "hashPath": "microsoft.aspnetcore.http.extensions.2.3.0.nupkg.sha512" + }, + "Microsoft.AspNetCore.Http.Features/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-f10WUgcsKqrkmnz6gt8HeZ7kyKjYN30PO7cSic1lPtH7paPtnQqXPOveul/SIPI43PhRD4trttg4ywnrEmmJpA==", + "path": "microsoft.aspnetcore.http.features/2.3.0", + "hashPath": "microsoft.aspnetcore.http.features.2.3.0.nupkg.sha512" + }, + "Microsoft.CSharp/4.7.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==", + "path": "microsoft.csharp/4.7.0", + "hashPath": "microsoft.csharp.4.7.0.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "path": "microsoft.extensions.configuration/9.0.1", + "hashPath": "microsoft.extensions.configuration.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Abstractions/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "path": "microsoft.extensions.configuration.abstractions/9.0.1", + "hashPath": "microsoft.extensions.configuration.abstractions.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Binder/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==", + "path": "microsoft.extensions.configuration.binder/9.0.1", + "hashPath": "microsoft.extensions.configuration.binder.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.CommandLine/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==", + "path": "microsoft.extensions.configuration.commandline/9.0.1", + "hashPath": "microsoft.extensions.configuration.commandline.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==", + "path": "microsoft.extensions.configuration.environmentvariables/9.0.1", + "hashPath": "microsoft.extensions.configuration.environmentvariables.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.FileExtensions/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==", + "path": "microsoft.extensions.configuration.fileextensions/9.0.1", + "hashPath": "microsoft.extensions.configuration.fileextensions.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Json/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==", + "path": "microsoft.extensions.configuration.json/9.0.1", + "hashPath": "microsoft.extensions.configuration.json.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.UserSecrets/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==", + "path": "microsoft.extensions.configuration.usersecrets/9.0.1", + "hashPath": "microsoft.extensions.configuration.usersecrets.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.DependencyInjection/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==", + "path": "microsoft.extensions.dependencyinjection/9.0.1", + "hashPath": "microsoft.extensions.dependencyinjection.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA==", + "path": "microsoft.extensions.dependencyinjection.abstractions/9.0.1", + "hashPath": "microsoft.extensions.dependencyinjection.abstractions.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Diagnostics/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==", + "path": "microsoft.extensions.diagnostics/9.0.1", + "hashPath": "microsoft.extensions.diagnostics.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Diagnostics.Abstractions/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==", + "path": "microsoft.extensions.diagnostics.abstractions/9.0.1", + "hashPath": "microsoft.extensions.diagnostics.abstractions.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.FileProviders.Abstractions/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==", + "path": "microsoft.extensions.fileproviders.abstractions/9.0.1", + "hashPath": "microsoft.extensions.fileproviders.abstractions.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.FileProviders.Physical/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==", + "path": "microsoft.extensions.fileproviders.physical/9.0.1", + "hashPath": "microsoft.extensions.fileproviders.physical.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.FileSystemGlobbing/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug==", + "path": "microsoft.extensions.filesystemglobbing/9.0.1", + "hashPath": "microsoft.extensions.filesystemglobbing.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Hosting/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-3wZNcVvC8RW44HDqqmIq+BqF5pgmTQdbNdR9NyYw33JSMnJuclwoJ2PEkrJ/KvD1U/hmqHVL3l5If+Hn3D1fWA==", + "path": "microsoft.extensions.hosting/9.0.1", + "hashPath": "microsoft.extensions.hosting.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Hosting.Abstractions/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==", + "path": "microsoft.extensions.hosting.abstractions/9.0.1", + "hashPath": "microsoft.extensions.hosting.abstractions.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Http/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-j1UmqmTRIc0OJhv8feVFmXhPS/Z+82o/JLF3WKlydC3esolPVVJPJ0oq/MSECXFZMBKVVpxUBJnR6dJH1hTWzQ==", + "path": "microsoft.extensions.http/9.0.1", + "hashPath": "microsoft.extensions.http.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Logging/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==", + "path": "microsoft.extensions.logging/9.0.1", + "hashPath": "microsoft.extensions.logging.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.Abstractions/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==", + "path": "microsoft.extensions.logging.abstractions/9.0.1", + "hashPath": "microsoft.extensions.logging.abstractions.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.Configuration/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==", + "path": "microsoft.extensions.logging.configuration/9.0.1", + "hashPath": "microsoft.extensions.logging.configuration.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.Console/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==", + "path": "microsoft.extensions.logging.console/9.0.1", + "hashPath": "microsoft.extensions.logging.console.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.Debug/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==", + "path": "microsoft.extensions.logging.debug/9.0.1", + "hashPath": "microsoft.extensions.logging.debug.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.EventLog/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==", + "path": "microsoft.extensions.logging.eventlog/9.0.1", + "hashPath": "microsoft.extensions.logging.eventlog.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.EventSource/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==", + "path": "microsoft.extensions.logging.eventsource/9.0.1", + "hashPath": "microsoft.extensions.logging.eventsource.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Options/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", + "path": "microsoft.extensions.options/9.0.1", + "hashPath": "microsoft.extensions.options.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Options.ConfigurationExtensions/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==", + "path": "microsoft.extensions.options.configurationextensions/9.0.1", + "hashPath": "microsoft.extensions.options.configurationextensions.9.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Primitives/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==", + "path": "microsoft.extensions.primitives/9.0.1", + "hashPath": "microsoft.extensions.primitives.9.0.1.nupkg.sha512" + }, + "Microsoft.Net.Http.Headers/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-/M0wVg6tJUOHutWD3BMOUVZAioJVXe0tCpFiovzv0T9T12TBf4MnaHP0efO8TCr1a6O9RZgQeZ9Gdark8L9XdA==", + "path": "microsoft.net.http.headers/2.3.0", + "hashPath": "microsoft.net.http.headers.2.3.0.nupkg.sha512" + }, + "Microsoft.NETCore.Platforms/5.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==", + "path": "microsoft.netcore.platforms/5.0.0", + "hashPath": "microsoft.netcore.platforms.5.0.0.nupkg.sha512" + }, + "Microsoft.Toolkit.HighPerformance/7.1.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-cezzRky0BUJyYmSrcQUcX8qAv90JfUwCqWEbqfWZLHyeANo9/LWgW6y50pqbyc8r8SPXVsu2GNH98fB3VxrnvA==", + "path": "microsoft.toolkit.highperformance/7.1.2", + "hashPath": "microsoft.toolkit.highperformance.7.1.2.nupkg.sha512" + }, + "Microsoft.Win32.Registry/5.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "path": "microsoft.win32.registry/5.0.0", + "hashPath": "microsoft.win32.registry.5.0.0.nupkg.sha512" + }, + "Microsoft.Win32.SystemEvents/5.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Bh6blKG8VAKvXiLe2L+sEsn62nc1Ij34MrNxepD2OCrS5cpCwQa9MeLyhVQPQ/R4Wlzwuy6wMK8hLb11QPDRsQ==", + "path": "microsoft.win32.systemevents/5.0.0", + "hashPath": "microsoft.win32.systemevents.5.0.0.nupkg.sha512" + }, + "NETStandard.Library/2.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-7jnbRU+L08FXKMxqUflxEXtVymWvNOrS8yHgu9s6EM8Anr6T/wIX4nZ08j/u3Asz+tCufp3YVwFSEvFTPYmBPA==", + "path": "netstandard.library/2.0.0", + "hashPath": "netstandard.library.2.0.0.nupkg.sha512" + }, + "Nettle/3.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-4L4Ci6STNLPufw58JrA0s2YlGj+MWwo5UVEpYsUiG5d7ctCYe1xbWcB7gnAfUijHEB1MVAbcgGbUsfE+MMfCgA==", + "path": "nettle/3.0.0", + "hashPath": "nettle.3.0.0.nupkg.sha512" + }, + "Newtonsoft.Json/13.0.3": { + "type": "package", + "serviceable": true, + "sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==", + "path": "newtonsoft.json/13.0.3", + "hashPath": "newtonsoft.json.13.0.3.nupkg.sha512" + }, + "NexusMods.Paths/0.10.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5pv6XROhpTAMcSNdJncAZmY9q7xuevKLPO1wMOw0vWQBEB2Po9vcwAnxUkNzv4LFzprlXAHa+hKVa+rXoq5P7g==", + "path": "nexusmods.paths/0.10.0", + "hashPath": "nexusmods.paths.0.10.0.nupkg.sha512" + }, + "NLog/5.3.4": { + "type": "package", + "serviceable": true, + "sha512": "sha512-gLy7+O1hEYJXIlcTr1/VWjGXrZTQFZzYNO18IWasD64pNwz0BreV+nHLxWKXWZzERRzoKnsk2XYtwLkTVk7J1A==", + "path": "nlog/5.3.4", + "hashPath": "nlog.5.3.4.nupkg.sha512" + }, + "NLog.Extensions.Logging/5.3.15": { + "type": "package", + "serviceable": true, + "sha512": "sha512-BSCkfWjaTesw4/k8Ujj3YdqtDzd2iCRYIOwoHume7aaJFz4/gLbemA5MLgkAwtCxcd8nGBGfaEkeIzQPGxArLg==", + "path": "nlog.extensions.logging/5.3.15", + "hashPath": "nlog.extensions.logging.5.3.15.nupkg.sha512" + }, + "Octokit/14.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw==", + "path": "octokit/14.0.0", + "hashPath": "octokit.14.0.0.nupkg.sha512" + }, + "Octopus.Octodiff/2.0.548": { + "type": "package", + "serviceable": true, + "sha512": "sha512-kbT0ympzmcpwf2/OxZKzL3My+j4HdZpc0cDWX60t1iaNtJXzZl2S/shEmDZxBlsfQieb8esH2GOAQuAZ6knD3w==", + "path": "octopus.octodiff/2.0.548", + "hashPath": "octopus.octodiff.2.0.548.nupkg.sha512" + }, + "OMODFramework/3.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-jpo14v6B0Mq35cunVbQM7PXaZvtI3Q0IE7u1WFylcW5U4B7s8K4OAnciZQRz14MsRMmw4qpyyQ0qWvZxKtNXMg==", + "path": "omodframework/3.0.1", + "hashPath": "omodframework.3.0.1.nupkg.sha512" + }, + "OneOf/3.0.271": { + "type": "package", + "serviceable": true, + "sha512": "sha512-pqpqeK8xQGggExhr4tesVgJkjdn+9HQAO0QgrYV2hFjE3y90okzk1kQMntMiUOGfV7FrCUfKPaVvPBD4IANqKg==", + "path": "oneof/3.0.271", + "hashPath": "oneof.3.0.271.nupkg.sha512" + }, + "Reloaded.Memory/9.4.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-wld6juhxdyvC5e5Bls39M6kb/cFYgNy3zD88f+PEedp6Tx1ASc4eZxff45fyp5H91zqDDnVNerC/FFjUK74inQ==", + "path": "reloaded.memory/9.4.1", + "hashPath": "reloaded.memory.9.4.1.nupkg.sha512" + }, + "SHA3.Net/2.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ryLRgwVNnWLNW8fkl1qfr13eBkNhiRl2dPIksfEgcQghxrOt21PAEBxg/ofwW2l0FKanYpWT9WzD27O3PGHqpw==", + "path": "sha3.net/2.0.0", + "hashPath": "sha3.net.2.0.0.nupkg.sha512" + }, + "SharpCompress/0.28.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-EM0NUv9givYJXAVCLrC5DXWT+EA+69N3DppwO1OTROU5ZIIOLlnHu6JfGt81mgi5Q8xQFQJdHmEDbisiLgpfzg==", + "path": "sharpcompress/0.28.1", + "hashPath": "sharpcompress.0.28.1.nupkg.sha512" + }, + "SharpZipLib/1.4.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==", + "path": "sharpziplib/1.4.2", + "hashPath": "sharpziplib.1.4.2.nupkg.sha512" + }, + "Shipwreck.Phash/0.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-FMeI3ZkPAjr/KlIIyagMoFCdUVADj654njRVcUC172sBGUAqdc6LRBD/3Dj9SZxbeSL1iJU4iHCTakVgva9ukQ==", + "path": "shipwreck.phash/0.5.0", + "hashPath": "shipwreck.phash.0.5.0.nupkg.sha512" + }, + "shortid/4.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-iiOsFHiZLfmUxViNKWAmLYi6xCJanKBYlxe163pkK4C+k9vnkWdCNCPouNWukn/F147k3bLq0tEFEQhlGMLqjA==", + "path": "shortid/4.0.0", + "hashPath": "shortid.4.0.0.nupkg.sha512" + }, + "SixLabors.ImageSharp/3.1.6": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dHQ5jugF9v+5/LCVHCWVzaaIL6WOehqJy6eju/0VFYFPEj2WtqkGPoEV9EVQP83dHsdoqYaTuWpZdwAd37UwfA==", + "path": "sixlabors.imagesharp/3.1.6", + "hashPath": "sixlabors.imagesharp.3.1.6.nupkg.sha512" + }, + "Stub.System.Data.SQLite.Core.NetStandard/1.0.119": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dI7ngiCNgdm+n00nQvFTa+LbHvE9MIQXwMSLRzJI/KAJ7G1WmCachsvfE1CD6xvb3OXJvYYEfv3+S/LHyhN0Rg==", + "path": "stub.system.data.sqlite.core.netstandard/1.0.119", + "hashPath": "stub.system.data.sqlite.core.netstandard.1.0.119.nupkg.sha512" + }, + "System.Buffers/4.6.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-lN6tZi7Q46zFzAbRYXTIvfXcyvQQgxnY7Xm6C6xQ9784dEL1amjM6S6Iw4ZpsvesAKnRVsM4scrDQaDqSClkjA==", + "path": "system.buffers/4.6.0", + "hashPath": "system.buffers.4.6.0.nupkg.sha512" + }, + "System.CodeDom/8.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-WTlRjL6KWIMr/pAaq3rYqh0TJlzpouaQ/W1eelssHgtlwHAH25jXTkUphTYx9HaIIf7XA6qs/0+YhtLEQRkJ+Q==", + "path": "system.codedom/8.0.0", + "hashPath": "system.codedom.8.0.0.nupkg.sha512" + }, + "System.CommandLine/2.0.0-beta4.22272.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg==", + "path": "system.commandline/2.0.0-beta4.22272.1", + "hashPath": "system.commandline.2.0.0-beta4.22272.1.nupkg.sha512" + }, + "System.CommandLine.NamingConventionBinder/2.0.0-beta4.22272.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ux2eUA/syF+JtlpMDc/Lsd6PBIBuwjH3AvHnestoh5uD0WKT5b+wkQxDWVCqp9qgVjMBTLNhX19ZYFtenunt9A==", + "path": "system.commandline.namingconventionbinder/2.0.0-beta4.22272.1", + "hashPath": "system.commandline.namingconventionbinder.2.0.0-beta4.22272.1.nupkg.sha512" + }, + "System.Data.SQLite.Core/1.0.119": { + "type": "package", + "serviceable": true, + "sha512": "sha512-bhQB8HVtRA+OOYw8UTD1F1kU+nGJ0/OZvH1JmlVUI4bGvgVEWeX1NcHjA765NvUoRVuCPlt8PrEpZ1thSsk1jg==", + "path": "system.data.sqlite.core/1.0.119", + "hashPath": "system.data.sqlite.core.1.0.119.nupkg.sha512" + }, + "System.Diagnostics.DiagnosticSource/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA==", + "path": "system.diagnostics.diagnosticsource/9.0.1", + "hashPath": "system.diagnostics.diagnosticsource.9.0.1.nupkg.sha512" + }, + "System.Diagnostics.EventLog/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==", + "path": "system.diagnostics.eventlog/9.0.1", + "hashPath": "system.diagnostics.eventlog.9.0.1.nupkg.sha512" + }, + "System.Drawing.Common/5.0.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-rvr/M1WPf24ljpvvrVd74+NdjRUJu1bBkspkZcnzSZnmAUQWSvanlQ0k/hVHk+cHufZbZfu7vOh/vYc0q5Uu/A==", + "path": "system.drawing.common/5.0.2", + "hashPath": "system.drawing.common.5.0.2.nupkg.sha512" + }, + "System.IO.Pipelines/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==", + "path": "system.io.pipelines/9.0.1", + "hashPath": "system.io.pipelines.9.0.1.nupkg.sha512" + }, + "System.Management/8.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-jrK22i5LRzxZCfGb+tGmke2VH7oE0DvcDlJ1HAKYU8cPmD8XnpUT0bYn2Gy98GEhGjtfbR/sxKTVb+dE770pfA==", + "path": "system.management/8.0.0", + "hashPath": "system.management.8.0.0.nupkg.sha512" + }, + "System.Memory/4.5.4": { + "type": "package", + "serviceable": true, + "sha512": "sha512-1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==", + "path": "system.memory/4.5.4", + "hashPath": "system.memory.4.5.4.nupkg.sha512" + }, + "System.Reactive/6.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-rHaWtKDwCi9qJ3ObKo8LHPMuuwv33YbmQi7TcUK1C264V3MFnOr5Im7QgCTdLniztP3GJyeiSg5x8NqYJFqRmg==", + "path": "system.reactive/6.0.1", + "hashPath": "system.reactive.6.0.1.nupkg.sha512" + }, + "System.Security.AccessControl/5.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "path": "system.security.accesscontrol/5.0.0", + "hashPath": "system.security.accesscontrol.5.0.0.nupkg.sha512" + }, + "System.Security.Principal.Windows/5.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==", + "path": "system.security.principal.windows/5.0.0", + "hashPath": "system.security.principal.windows.5.0.0.nupkg.sha512" + }, + "System.Text.Encoding.CodePages/5.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-NyscU59xX6Uo91qvhOs2Ccho3AR2TnZPomo1Z0K6YpyztBPM/A5VbkzOO19sy3A3i1TtEnTxA7bCe3Us+r5MWg==", + "path": "system.text.encoding.codepages/5.0.0", + "hashPath": "system.text.encoding.codepages.5.0.0.nupkg.sha512" + }, + "System.Text.Encodings.Web/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==", + "path": "system.text.encodings.web/9.0.1", + "hashPath": "system.text.encodings.web.9.0.1.nupkg.sha512" + }, + "System.Text.Json/9.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==", + "path": "system.text.json/9.0.1", + "hashPath": "system.text.json.9.0.1.nupkg.sha512" + }, + "System.Threading.Tasks.Extensions/4.5.4": { + "type": "package", + "serviceable": true, + "sha512": "sha512-zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "path": "system.threading.tasks.extensions/4.5.4", + "hashPath": "system.threading.tasks.extensions.4.5.4.nupkg.sha512" + }, + "ValveKeyValue/0.10.0.360": { + "type": "package", + "serviceable": true, + "sha512": "sha512-APiKxT2Qyg/Kpgylpd/Oylta9xL076qj5cWkN+bpDPrpVhsM6FKQG5nELBao2eGXeqIeGI8LUQQ9Q4Sppix6Cw==", + "path": "valvekeyvalue/0.10.0.360", + "hashPath": "valvekeyvalue.0.10.0.360.nupkg.sha512" + }, + "YamlDotNet/16.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==", + "path": "yamldotnet/16.3.0", + "hashPath": "yamldotnet.16.3.0.nupkg.sha512" + }, + "Wabbajack.CLI.Builder/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Common/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Compiler/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Compression.BSA/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Compression.Zip/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Configuration/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.Bethesda/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.Dispatcher/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.GameFile/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.GoogleDrive/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.Http/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.Interfaces/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.Manual/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.MediaFire/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.Mega/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.ModDB/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.Nexus/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.VerificationCache/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Downloaders.WabbajackCDN/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.DTOs/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.FileExtractor/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Hashing.PHash/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Hashing.xxHash64/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Installer/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.IO.Async/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Networking.BethesdaNet/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Networking.Discord/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Networking.GitHub/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Networking.Http/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Networking.Http.Interfaces/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Networking.NexusApi/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Networking.WabbajackClientApi/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Paths/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Paths.IO/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.RateLimiter/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Server.Lib/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.Services.OSIntegrated/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.VFS/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Wabbajack.VFS.Interfaces/0.3.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "MegaApiClient/1.0.0.0": { + "type": "reference", + "serviceable": false, + "sha512": "" + } + }, + "runtimes": { + "android-x64": [ + "android", + "linux-bionic-x64", + "linux-bionic", + "linux-x64", + "linux", + "unix-x64", + "unix", + "any", + "base" + ], + "linux-bionic-x64": [ + "linux-bionic", + "linux-x64", + "linux", + "unix-x64", + "unix", + "any", + "base" + ], + "linux-musl-x64": [ + "linux-musl", + "linux-x64", + "linux", + "unix-x64", + "unix", + "any", + "base" + ], + "linux-x64": [ + "linux", + "unix-x64", + "unix", + "any", + "base" + ], + "fedora.42-x64": [ + "linux-x64", + "linux", + "unix-x64", + "unix", + "any", + "base" + ] + } +} \ No newline at end of file diff --git a/jackify/engine/jackify-engine.dll b/jackify/engine/jackify-engine.dll new file mode 100644 index 0000000..66018eb Binary files /dev/null and b/jackify/engine/jackify-engine.dll differ diff --git a/jackify/engine/jackify-engine.runtimeconfig.json b/jackify/engine/jackify-engine.runtimeconfig.json new file mode 100644 index 0000000..0110e65 --- /dev/null +++ b/jackify/engine/jackify-engine.runtimeconfig.json @@ -0,0 +1,16 @@ +{ + "runtimeOptions": { + "tfm": "net8.0", + "includedFrameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "8.0.19" + } + ], + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, + "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": true + } + } +} \ No newline at end of file diff --git a/jackify/engine/ko/System.CommandLine.resources.dll b/jackify/engine/ko/System.CommandLine.resources.dll new file mode 100755 index 0000000..910483d Binary files /dev/null and b/jackify/engine/ko/System.CommandLine.resources.dll differ diff --git a/jackify/engine/libSystem.Globalization.Native.so b/jackify/engine/libSystem.Globalization.Native.so new file mode 100755 index 0000000..f0b2e2b Binary files /dev/null and b/jackify/engine/libSystem.Globalization.Native.so differ diff --git a/jackify/engine/libSystem.IO.Compression.Native.so b/jackify/engine/libSystem.IO.Compression.Native.so new file mode 100755 index 0000000..0fdb27c Binary files /dev/null and b/jackify/engine/libSystem.IO.Compression.Native.so differ diff --git a/jackify/engine/libSystem.Native.so b/jackify/engine/libSystem.Native.so new file mode 100755 index 0000000..190bf83 Binary files /dev/null and b/jackify/engine/libSystem.Native.so differ diff --git a/jackify/engine/libSystem.Net.Security.Native.so b/jackify/engine/libSystem.Net.Security.Native.so new file mode 100755 index 0000000..ae56834 Binary files /dev/null and b/jackify/engine/libSystem.Net.Security.Native.so differ diff --git a/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so b/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so new file mode 100755 index 0000000..362d66a Binary files /dev/null and b/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so differ diff --git a/jackify/engine/libclrgc.so b/jackify/engine/libclrgc.so new file mode 100755 index 0000000..94fc2d7 Binary files /dev/null and b/jackify/engine/libclrgc.so differ diff --git a/jackify/engine/libclrjit.so b/jackify/engine/libclrjit.so new file mode 100755 index 0000000..4fcf810 Binary files /dev/null and b/jackify/engine/libclrjit.so differ diff --git a/jackify/engine/libcoreclr.so b/jackify/engine/libcoreclr.so new file mode 100755 index 0000000..40f2c65 Binary files /dev/null and b/jackify/engine/libcoreclr.so differ diff --git a/jackify/engine/libcoreclrtraceptprovider.so b/jackify/engine/libcoreclrtraceptprovider.so new file mode 100755 index 0000000..001b79d Binary files /dev/null and b/jackify/engine/libcoreclrtraceptprovider.so differ diff --git a/jackify/engine/libhostfxr.so b/jackify/engine/libhostfxr.so new file mode 100755 index 0000000..339827e Binary files /dev/null and b/jackify/engine/libhostfxr.so differ diff --git a/jackify/engine/libhostpolicy.so b/jackify/engine/libhostpolicy.so new file mode 100755 index 0000000..e3e1f7b Binary files /dev/null and b/jackify/engine/libhostpolicy.so differ diff --git a/jackify/engine/libmscordaccore.so b/jackify/engine/libmscordaccore.so new file mode 100755 index 0000000..b20c15a Binary files /dev/null and b/jackify/engine/libmscordaccore.so differ diff --git a/jackify/engine/libmscordbi.so b/jackify/engine/libmscordbi.so new file mode 100755 index 0000000..7b200d3 Binary files /dev/null and b/jackify/engine/libmscordbi.so differ diff --git a/jackify/engine/mscorlib.dll b/jackify/engine/mscorlib.dll new file mode 100755 index 0000000..af3b4a4 Binary files /dev/null and b/jackify/engine/mscorlib.dll differ diff --git a/jackify/engine/netstandard.dll b/jackify/engine/netstandard.dll new file mode 100755 index 0000000..bd0b985 Binary files /dev/null and b/jackify/engine/netstandard.dll differ diff --git a/jackify/engine/pl/System.CommandLine.resources.dll b/jackify/engine/pl/System.CommandLine.resources.dll new file mode 100755 index 0000000..bfee4aa Binary files /dev/null and b/jackify/engine/pl/System.CommandLine.resources.dll differ diff --git a/jackify/engine/pt-BR/System.CommandLine.resources.dll b/jackify/engine/pt-BR/System.CommandLine.resources.dll new file mode 100755 index 0000000..8fc42f2 Binary files /dev/null and b/jackify/engine/pt-BR/System.CommandLine.resources.dll differ diff --git a/jackify/engine/ru/System.CommandLine.resources.dll b/jackify/engine/ru/System.CommandLine.resources.dll new file mode 100755 index 0000000..e98f737 Binary files /dev/null and b/jackify/engine/ru/System.CommandLine.resources.dll differ diff --git a/jackify/engine/runtimes/browser/lib/net8.0/System.Text.Encodings.Web.dll b/jackify/engine/runtimes/browser/lib/net8.0/System.Text.Encodings.Web.dll new file mode 100755 index 0000000..faef332 Binary files /dev/null and b/jackify/engine/runtimes/browser/lib/net8.0/System.Text.Encodings.Web.dll differ diff --git a/jackify/engine/runtimes/linux-x64/native/SQLite.Interop.dll b/jackify/engine/runtimes/linux-x64/native/SQLite.Interop.dll new file mode 100755 index 0000000..6cc6b46 Binary files /dev/null and b/jackify/engine/runtimes/linux-x64/native/SQLite.Interop.dll differ diff --git a/jackify/engine/runtimes/osx-x64/native/SQLite.Interop.dll b/jackify/engine/runtimes/osx-x64/native/SQLite.Interop.dll new file mode 100755 index 0000000..e320817 Binary files /dev/null and b/jackify/engine/runtimes/osx-x64/native/SQLite.Interop.dll differ diff --git a/jackify/engine/runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll b/jackify/engine/runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll new file mode 100755 index 0000000..8b95164 Binary files /dev/null and b/jackify/engine/runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll differ diff --git a/jackify/engine/runtimes/win-x64/native/SQLite.Interop.dll b/jackify/engine/runtimes/win-x64/native/SQLite.Interop.dll new file mode 100755 index 0000000..884b2d4 Binary files /dev/null and b/jackify/engine/runtimes/win-x64/native/SQLite.Interop.dll differ diff --git a/jackify/engine/runtimes/win-x86/native/SQLite.Interop.dll b/jackify/engine/runtimes/win-x86/native/SQLite.Interop.dll new file mode 100755 index 0000000..98e74c4 Binary files /dev/null and b/jackify/engine/runtimes/win-x86/native/SQLite.Interop.dll differ diff --git a/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.Messages.dll b/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.Messages.dll new file mode 100755 index 0000000..457aaad Binary files /dev/null and b/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.Messages.dll differ diff --git a/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.dll b/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.dll new file mode 100755 index 0000000..4c50999 Binary files /dev/null and b/jackify/engine/runtimes/win/lib/net8.0/System.Diagnostics.EventLog.dll differ diff --git a/jackify/engine/runtimes/win/lib/net8.0/System.Management.dll b/jackify/engine/runtimes/win/lib/net8.0/System.Management.dll new file mode 100755 index 0000000..c7505b3 Binary files /dev/null and b/jackify/engine/runtimes/win/lib/net8.0/System.Management.dll differ diff --git a/jackify/engine/runtimes/win/lib/netcoreapp3.0/Microsoft.Win32.SystemEvents.dll b/jackify/engine/runtimes/win/lib/netcoreapp3.0/Microsoft.Win32.SystemEvents.dll new file mode 100755 index 0000000..b5aa0c4 Binary files /dev/null and b/jackify/engine/runtimes/win/lib/netcoreapp3.0/Microsoft.Win32.SystemEvents.dll differ diff --git a/jackify/engine/runtimes/win/lib/netcoreapp3.0/System.Drawing.Common.dll b/jackify/engine/runtimes/win/lib/netcoreapp3.0/System.Drawing.Common.dll new file mode 100755 index 0000000..b80b430 Binary files /dev/null and b/jackify/engine/runtimes/win/lib/netcoreapp3.0/System.Drawing.Common.dll differ diff --git a/jackify/engine/shortid.dll b/jackify/engine/shortid.dll new file mode 100644 index 0000000..31fe7d7 Binary files /dev/null and b/jackify/engine/shortid.dll differ diff --git a/jackify/engine/tr/System.CommandLine.resources.dll b/jackify/engine/tr/System.CommandLine.resources.dll new file mode 100755 index 0000000..6e29e3e Binary files /dev/null and b/jackify/engine/tr/System.CommandLine.resources.dll differ diff --git a/jackify/engine/zh-Hans/System.CommandLine.resources.dll b/jackify/engine/zh-Hans/System.CommandLine.resources.dll new file mode 100755 index 0000000..06e46a1 Binary files /dev/null and b/jackify/engine/zh-Hans/System.CommandLine.resources.dll differ diff --git a/jackify/engine/zh-Hant/System.CommandLine.resources.dll b/jackify/engine/zh-Hant/System.CommandLine.resources.dll new file mode 100755 index 0000000..afb03e9 Binary files /dev/null and b/jackify/engine/zh-Hant/System.CommandLine.resources.dll differ diff --git a/jackify/frontends/__init__.py b/jackify/frontends/__init__.py new file mode 100644 index 0000000..27e12e3 --- /dev/null +++ b/jackify/frontends/__init__.py @@ -0,0 +1,5 @@ +""" +Jackify Frontends + +User interface layers for CLI and GUI. +""" \ No newline at end of file diff --git a/jackify/frontends/cli/__init__.py b/jackify/frontends/cli/__init__.py new file mode 100644 index 0000000..45a5df4 --- /dev/null +++ b/jackify/frontends/cli/__init__.py @@ -0,0 +1,5 @@ +""" +Jackify CLI Frontend + +Command-line interface for Jackify that uses the backend services. +""" \ No newline at end of file diff --git a/jackify/frontends/cli/__main__.py b/jackify/frontends/cli/__main__.py new file mode 100644 index 0000000..75112f7 --- /dev/null +++ b/jackify/frontends/cli/__main__.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Jackify CLI Frontend Entry Point + +New entry point for the CLI frontend that uses the refactored structure. +""" + +import sys +import signal +import logging + +from .main import JackifyCLI + +# Set up logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +def terminate_children(signum, frame): + """Signal handler to terminate child processes on exit""" + print("Received signal, shutting down...") + sys.exit(0) + +def main(): + """Main entry point for the CLI frontend""" + # Set up signal handlers + signal.signal(signal.SIGTERM, terminate_children) + signal.signal(signal.SIGINT, terminate_children) + + try: + cli = JackifyCLI() + exit_code = cli.run() + sys.exit(exit_code or 0) + except KeyboardInterrupt: + print("\nOperation cancelled by user") + sys.exit(130) # Standard exit code for SIGINT + except Exception as e: + print(f"Fatal error: {e}") + logging.exception("Fatal error in CLI frontend") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/jackify/frontends/cli/commands/__init__.py b/jackify/frontends/cli/commands/__init__.py new file mode 100644 index 0000000..9379a5c --- /dev/null +++ b/jackify/frontends/cli/commands/__init__.py @@ -0,0 +1,5 @@ +""" +CLI Commands + +Individual command implementations for the CLI interface. +""" \ No newline at end of file diff --git a/jackify/frontends/cli/commands/configure_modlist.py b/jackify/frontends/cli/commands/configure_modlist.py new file mode 100644 index 0000000..2329910 --- /dev/null +++ b/jackify/frontends/cli/commands/configure_modlist.py @@ -0,0 +1,159 @@ +""" +Configure Modlist Command + +CLI command for configuring a modlist post-install. +Extracted from the original jackify-cli.py. +""" + +import os +import logging +from typing import Optional + +# Import the backend services we'll need +from jackify.backend.models.configuration import ConfigurationContext +from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET + +logger = logging.getLogger(__name__) + + +class ConfigureModlistCommand: + """Handler for the configure-modlist CLI command.""" + + def __init__(self, backend_services): + """Initialize with backend services. + + Args: + backend_services: Dictionary of backend service instances + """ + self.backend_services = backend_services + self.test_mode = False # TODO: Get from global config + + def add_parser(self, subparsers): + """Add the configure-modlist subcommand parser. + + Args: + subparsers: The ArgumentParser subparsers object + """ + configure_modlist_parser = subparsers.add_parser( + "configure-modlist", + help="Configure a modlist post-install (for GUI integration)" + ) + configure_modlist_parser.add_argument( + "--modlist-name", + type=str, + required=True, + help="Name of the modlist to configure (Steam shortcut name)" + ) + configure_modlist_parser.add_argument( + "--install-dir", + type=str, + required=True, + help="Install directory of the modlist" + ) + configure_modlist_parser.add_argument( + "--download-dir", + type=str, + help="Downloads directory (optional)" + ) + configure_modlist_parser.add_argument( + "--nexus-api-key", + type=str, + help="Nexus API key (optional)" + ) + configure_modlist_parser.add_argument( + "--mo2-exe-path", + type=str, + help="Path to ModOrganizer.exe (for AppID lookup)" + ) + configure_modlist_parser.add_argument( + "--resolution", + type=str, + help="Resolution to set (optional)" + ) + configure_modlist_parser.add_argument( + "--skip-confirmation", + action='store_true', + help="Skip confirmation prompts" + ) + return configure_modlist_parser + + def execute(self, args) -> int: + """Execute the configure-modlist command. + + Args: + args: Parsed command-line arguments + + Returns: + Exit code (0 for success, 1 for failure) + """ + logger.info("Starting non-interactive modlist configuration (CLI mode)") + + try: + # Build configuration context from args + context = self._build_context_from_args(args) + + # Use legacy implementation for now - will migrate to backend services later + result = self._execute_legacy_configuration(context) + + logger.info("Finished non-interactive modlist configuration") + return 0 if result is not True else 1 + + except Exception as e: + logger.error(f"Failed to configure modlist: {e}") + print(f"{COLOR_ERROR}Configuration failed: {e}{COLOR_RESET}") + return 1 + + def _build_context_from_args(self, args) -> dict: + """Build context dictionary from command arguments. + + Args: + args: Parsed command-line arguments + + Returns: + Context dictionary + """ + return { + 'modlist_name': getattr(args, 'modlist_name', None), + 'install_dir': getattr(args, 'install_dir', None), + 'download_dir': getattr(args, 'download_dir', None), + 'nexus_api_key': getattr(args, 'nexus_api_key', os.environ.get('NEXUS_API_KEY')), + 'mo2_exe_path': getattr(args, 'mo2_exe_path', None), + 'resolution': getattr(args, 'resolution', None), + 'skip_confirmation': getattr(args, 'skip_confirmation', False), + 'modlist_value': getattr(args, 'modlist_value', None), + 'modlist_source': getattr(args, 'modlist_source', None), + } + + def _execute_legacy_configuration(self, context: dict): + """Execute configuration using legacy implementation. + + This is a temporary bridge - will be replaced with backend service calls. + + Args: + context: Configuration context dictionary + + Returns: + Result from legacy configuration + """ + # Import backend services + from jackify.backend.handlers.menu_handler import ModlistMenuHandler + from jackify.backend.handlers.config_handler import ConfigHandler + + # Create legacy handler instances + config_handler = ConfigHandler() + modlist_menu = ModlistMenuHandler( + config_handler=config_handler, + test_mode=self.test_mode + ) + + # Execute legacy configuration workflow + # The _configure_new_modlist method already handles Steam restart, manual steps, and configuration + result = modlist_menu._configure_new_modlist( + default_modlist_dir=context['install_dir'], + default_modlist_name=context['modlist_name'] + ) + + # The _configure_new_modlist method already calls run_modlist_configuration_phase internally + # So we don't need to call it again here + + return result \ No newline at end of file diff --git a/jackify/frontends/cli/commands/install_modlist.py b/jackify/frontends/cli/commands/install_modlist.py new file mode 100644 index 0000000..8c1a3ef --- /dev/null +++ b/jackify/frontends/cli/commands/install_modlist.py @@ -0,0 +1,363 @@ +""" +Install Modlist Command + +CLI command for installing modlists. +Extracted from the original jackify-cli.py. +""" + +import os +import logging +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 InstallModlistCommand: + """Handler for the install-modlist 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_top_level_args(self, parser): + """Add top-level install-modlist arguments to the main parser. + + Args: + parser: The main ArgumentParser + """ + parser.add_argument( + "--install-modlist", + action="store_true", + help="Enable modlist install/list feature (for GUI integration)" + ) + parser.add_argument( + "--list-modlists", + action="store_true", + help="List available modlists for a game type (with --install-modlist)" + ) + parser.add_argument( + "--install", + action="store_true", + help="Install a modlist non-interactively (with --install-modlist)" + ) + parser.add_argument( + "--game-type", + type=str, + default=None, + help="Game type to filter modlists (skyrim, fallout4, falloutnv, oblivion, starfield, oblivion_remastered, other)" + ) + parser.add_argument( + "--modlist-value", + type=str, + help="Modlist identifier for online modlists" + ) + + def add_parser(self, subparsers): + """Add the install-modlist subcommand parser. + + Args: + subparsers: The ArgumentParser subparsers object + """ + install_modlist_parser = subparsers.add_parser( + "install-modlist", + help="Install or list available modlists" + ) + install_modlist_parser.add_argument( + "--list", + action="store_true", + help="List available modlists for a game type" + ) + install_modlist_parser.add_argument( + "--game-type", + type=str, + default=None, + help="Game type to filter modlists (skyrim, fallout4, falloutnv, oblivion, starfield, oblivion_remastered, other)" + ) + return install_modlist_parser + + def execute_top_level(self, args) -> int: + """Execute top-level install-modlist functionality. + + Args: + args: Parsed command-line arguments + + Returns: + Exit code (0 for success, 1 for failure) + """ + if getattr(args, 'list_modlists', False): + return self.list_modlists(args) + elif getattr(args, 'install', False): + return self.install_modlist_auto(args) + else: + print(f"{COLOR_ERROR}No valid install-modlist operation specified{COLOR_RESET}") + return 1 + + def execute_subcommand(self, args) -> int: + """Execute the install-modlist subcommand. + + Args: + args: Parsed command-line arguments + + Returns: + Exit code (0 for success, 1 for failure) + """ + if getattr(args, 'list', False): + return self.list_modlists(args) + else: + # Default behavior: run interactive modlist installation + logger.info("Starting interactive modlist installation via subcommand") + + try: + # Use the working ModlistInstallCLI for interactive installation + from jackify.backend.core.modlist_operations import ModlistInstallCLI + + # Use new SystemInfo pattern + modlist_cli = ModlistInstallCLI(self.system_info) + + # Run interactive discovery phase + context = modlist_cli.run_discovery_phase() + if context: + # Run configuration phase (installation + Steam setup) + modlist_cli.configuration_phase() + logger.info("Interactive modlist installation completed successfully") + return 0 + else: + logger.info("Modlist installation cancelled by user") + return 1 + + except Exception as e: + logger.error(f"Failed to install modlist: {e}") + print(f"{COLOR_ERROR}Installation failed: {e}{COLOR_RESET}") + return 1 + + def list_modlists(self, args) -> int: + """List available modlists for a game type. + + Args: + args: Parsed command-line arguments + + Returns: + Exit code (0 for success, 1 for failure) + """ + logger.info("Listing available modlists") + + try: + # Use legacy implementation for now - will migrate to backend services later + result = self._execute_legacy_list_modlists(args) + return 0 + + except Exception as e: + logger.error(f"Failed to list modlists: {e}") + print(f"{COLOR_ERROR}Failed to list modlists: {e}{COLOR_RESET}") + return 1 + + def install_modlist_auto(self, args) -> int: + """Install a modlist non-interactively. + + Args: + args: Parsed command-line arguments + + Returns: + Exit code (0 for success, 1 for failure) + """ + logger.info("Starting non-interactive modlist installation") + + try: + # Build context from args + context = self._build_install_context_from_args(args) + + # Validate required fields + if not self._validate_install_context(context): + return 1 + + # Use legacy implementation for now - will migrate to backend services later + result = self._execute_legacy_install(context) + + logger.info("Finished non-interactive modlist installation") + return result + + except Exception as e: + logger.error(f"Failed to install modlist: {e}") + print(f"{COLOR_ERROR}Installation failed: {e}{COLOR_RESET}") + return 1 + + def _build_install_context_from_args(self, args) -> dict: + """Build installation context from command arguments. + + Args: + args: Parsed command-line arguments + + Returns: + Context dictionary + """ + return { + 'modlist_name': getattr(args, 'modlist_name', None), + 'install_dir': getattr(args, 'install_dir', None), + 'download_dir': getattr(args, 'download_dir', None), + 'nexus_api_key': os.environ.get('NEXUS_API_KEY'), + 'game_type': getattr(args, 'game_type', None), + 'modlist_value': getattr(args, 'modlist_value', None), + 'skip_confirmation': True, + 'resolution': getattr(args, 'resolution', None), + } + + def _validate_install_context(self, context: dict) -> bool: + """Validate installation context. + + Args: + context: Installation context dictionary + + Returns: + True if valid, False otherwise + """ + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type'] + missing = [k for k in required_keys if not context.get(k)] + + if is_gui_mode and missing: + print(f"ERROR: Missing required arguments for GUI workflow: {', '.join(missing)}") + print("This workflow must be fully non-interactive. Please report this as a bug if you see this message.") + return False + + return True + + def _execute_legacy_list_modlists(self, args): + """Execute list modlists using backend implementation. + + Args: + args: Parsed command-line arguments + """ + # Import backend services + from jackify.backend.core.modlist_operations import ModlistInstallCLI + + # Use new SystemInfo pattern + modlist_cli = ModlistInstallCLI(self.system_info) + + # Get all modlists from engine + raw_modlists = modlist_cli.get_all_modlists_from_engine() + + # Group by game type as in original CLI + game_type_map = { + 'skyrim': ['Skyrim', 'Skyrim Special Edition'], + 'fallout4': ['Fallout 4'], + 'falloutnv': ['Fallout New Vegas'], + 'oblivion': ['Oblivion'], + 'starfield': ['Starfield'], + 'oblivion_remastered': ['Oblivion Remastered', 'OblivionRemastered'], + 'other': None + } + + grouped_modlists = {k: [] for k in game_type_map} + + for m_info in raw_modlists: # m_info is like {'id': ..., 'game': ...} + found_category = False + for cat_key, cat_keywords in game_type_map.items(): + if cat_key == 'other': + continue + if cat_keywords: + for keyword in cat_keywords: + if keyword.lower() in m_info.get('game', '').lower(): + grouped_modlists[cat_key].append(m_info) + found_category = True + break + if found_category: + break + if not found_category: + grouped_modlists['other'].append(m_info) + + # Output modlists for the requested game type + game_type = (getattr(args, 'game_type', '') or '').lower() + if game_type and game_type in grouped_modlists: + for m in grouped_modlists[game_type]: + print(m.get('id', '')) + else: + # Output all modlists + for cat_key in ['skyrim', 'fallout4', 'falloutnv', 'oblivion', 'starfield', 'oblivion_remastered', 'other']: + for m in grouped_modlists[cat_key]: + print(m.get('id', '')) + + def _execute_legacy_install(self, context: dict) -> int: + """Execute installation using backend implementation. + + Args: + context: Installation context dictionary + + Returns: + Exit code + """ + # Import backend services + from jackify.backend.core.modlist_operations import ModlistInstallCLI + from jackify.shared.colors import COLOR_WARNING, COLOR_PROMPT + + # Use new SystemInfo pattern + modlist_cli = ModlistInstallCLI(self.system_info) + + # Detect game type and check support + game_type = None + wabbajack_file_path = context.get('wabbajack_file_path') + modlist_info = context.get('modlist_info') + + if wabbajack_file_path: + game_type = modlist_cli.detect_game_type(wabbajack_file_path=wabbajack_file_path) + elif modlist_info: + game_type = modlist_cli.detect_game_type(modlist_info=modlist_info) + elif context.get('game_type'): + game_type = context['game_type'] + + # Check if game is supported + if game_type and not modlist_cli.check_game_support(game_type): + # Show unsupported game warning + supported_games = modlist_cli.wabbajack_parser.get_supported_games_display_names() + supported_games_str = ", ".join(supported_games) + + print(f"\n{COLOR_WARNING}Game Support Notice{COLOR_RESET}") + print(f"{COLOR_WARNING}While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to: {supported_games_str}.{COLOR_RESET}") + print(f"{COLOR_WARNING}We are working to add more automated support in future releases!{COLOR_RESET}") + + # Ask for confirmation to continue + response = input(f"{COLOR_PROMPT}Click Enter to continue with the modlist installation, or type 'cancel' to abort: {COLOR_RESET}").strip().lower() + if response == 'cancel': + print("[INFO] Modlist installation cancelled by user.") + return 1 + + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + + if is_gui_mode: + confirmed_context = modlist_cli.run_discovery_phase(context_override=context) + if confirmed_context: + # For unsupported games, skip post-install configuration + if game_type and not modlist_cli.check_game_support(game_type): + print(f"{COLOR_WARNING}Modlist installation completed successfully.{COLOR_RESET}") + print(f"{COLOR_WARNING}Note: Post-install configuration was skipped for unsupported game type: {game_type}{COLOR_RESET}") + return 0 + else: + modlist_cli.configuration_phase() + return 0 + else: + print("[INFO] Modlist installation cancelled or not confirmed.") + return 1 + else: + # CLI mode: allow interactive prompts as before + confirmed_context = modlist_cli.run_discovery_phase(context_override=context) + if confirmed_context: + # For unsupported games, skip post-install configuration + if game_type and not modlist_cli.check_game_support(game_type): + print(f"{COLOR_WARNING}Modlist installation completed successfully.{COLOR_RESET}") + print(f"{COLOR_WARNING}Note: Post-install configuration was skipped for unsupported game type: {game_type}{COLOR_RESET}") + return 0 + else: + modlist_cli.configuration_phase() + return 0 + else: + print("[INFO] Modlist installation cancelled or not confirmed.") + return 1 \ No newline at end of file diff --git a/jackify/frontends/cli/commands/tuxborn.py b/jackify/frontends/cli/commands/tuxborn.py new file mode 100644 index 0000000..a313965 --- /dev/null +++ b/jackify/frontends/cli/commands/tuxborn.py @@ -0,0 +1,247 @@ +""" +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 \ No newline at end of file diff --git a/jackify/frontends/cli/main.py b/jackify/frontends/cli/main.py new file mode 100755 index 0000000..7c2ff3d --- /dev/null +++ b/jackify/frontends/cli/main.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Jackify CLI Frontend - Main Entry Point + +Command-line interface for Jackify that uses the backend services. +Extracted and refactored from the original jackify-cli.py. +""" + +import sys +import os +import argparse +import logging + +# Import from our new backend structure +from jackify.backend.models.configuration import SystemInfo +from jackify.backend.services.modlist_service import ModlistService +from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET +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 + +# Import backend handlers for legacy compatibility +from jackify.backend.handlers.config_handler import ConfigHandler +from jackify.backend.handlers.filesystem_handler import FileSystemHandler +from jackify.backend.handlers.path_handler import PathHandler +from jackify.backend.handlers.shortcut_handler import ShortcutHandler +from jackify.backend.handlers.menu_handler import MenuHandler +from jackify.backend.handlers.mo2_handler import MO2Handler + +logger = logging.getLogger(__name__) + + +class JackifyCLI: + """Main application class for Jackify CLI Frontend""" + + def __init__(self, test_mode=False, dev_mode=False): + """Initialize the JackifyCLI frontend. + + Args: + test_mode (bool): If True, run in test mode with minimal side effects + dev_mode (bool): If True, enable development features + """ + # Initialize early (debug flag not yet available) + self._debug_mode = False + + # Set test mode flag + self.test_mode = test_mode + self.dev_mode = dev_mode + self.verbose = False + + # Configure logging to be quiet by default - will be adjusted after arg parsing + self._configure_logging_early() + + # Determine system info + self.system_info = SystemInfo(is_steamdeck=self._is_steamdeck()) + + # Apply resource limits for optimal operation + self._apply_resource_limits() + + # Initialize backend services + self.backend_services = self._initialize_backend_services() + + # Initialize command handlers + self.commands = self._initialize_command_handlers() + + # Initialize menu handlers with dev_mode + self.menus = self._initialize_menu_handlers() + + # Initialize legacy compatibility attributes for menu bridge + self._initialize_legacy_compatibility() + + # Initialize state variables + self.parser = None + self.subparsers = None + self.args = None + self.selected_modlist = None + self.setup_complete = False + + def _debug_print(self, message): + """Print debug message only if debug mode is enabled""" + if hasattr(self, '_debug_mode') and self._debug_mode: + logger.debug(message) + + def _configure_logging_early(self): + """Configure logging to be quiet during initialization, will be adjusted after arg parsing""" + # Set root logger to WARNING level initially to suppress INFO messages during init + logging.getLogger().setLevel(logging.WARNING) + + # Configure basic logging format + if not logging.getLogger().handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + logging.getLogger().addHandler(handler) + + def _configure_logging_final(self): + """Configure final logging level based on parsed arguments""" + # Use the existing LoggingHandler for proper log rotation + from jackify.backend.handlers.logging_handler import LoggingHandler + + # Set up CLI-specific logging with rotation + logging_handler = LoggingHandler() + logging_handler.rotate_log_for_logger('jackify-cli', 'Modlist_Install_workflow_cli.log') + cli_logger = logging_handler.setup_logger('jackify-cli', 'Modlist_Install_workflow_cli.log') + + # Configure logging level + if self.args.debug: + cli_logger.setLevel(logging.DEBUG) + print("Debug logging enabled for console and file") + elif self.args.verbose: + cli_logger.setLevel(logging.INFO) + print("Verbose logging enabled for console and file") + else: + # Keep it at WARNING level for clean startup + cli_logger.setLevel(logging.WARNING) + + def _is_steamdeck(self): + """Check if running on Steam Deck""" + try: + if os.path.exists("/etc/os-release"): + with open("/etc/os-release", "r") as f: + content = f.read() + if "steamdeck" in content: + logger.info("Running on Steam Deck") + return True + logger.info("Not running on Steam Deck") + return False + except Exception as e: + logger.error(f"Error detecting Steam Deck: {e}") + return False + + def _apply_resource_limits(self): + """Apply recommended resource limits for optimal Jackify operation""" + try: + from jackify.backend.services.resource_manager import ResourceManager + + resource_manager = ResourceManager() + success = resource_manager.apply_recommended_limits() + + if success: + status = resource_manager.get_limit_status() + if status['target_achieved']: + logger.info(f"Resource limits optimized: file descriptors set to {status['current_soft']}") + else: + logger.info(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})") + else: + # Log the issue but don't block startup + status = resource_manager.get_limit_status() + logger.warning(f"Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}") + + # If we can't increase automatically, provide manual instructions in debug mode + if hasattr(self, '_debug_mode') and self._debug_mode: + instructions = resource_manager.get_manual_increase_instructions() + logger.debug(f"Manual increase instructions available for {instructions['distribution']}") + + except Exception as e: + # Don't block startup on resource management errors + logger.warning(f"Error applying resource limits: {e}") + + def _initialize_backend_services(self): + """Initialize backend services. + + Returns: + Dictionary of backend service instances + """ + # For now, create a basic modlist service + # TODO: Add other services as needed + services = { + 'modlist_service': ModlistService(self.system_info) + } + return services + + def _initialize_command_handlers(self): + """Initialize command handler instances. + + Returns: + Dictionary of command handler instances + """ + 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 + + def _initialize_menu_handlers(self): + """Initialize menu handler instances. + + Returns: + Dictionary of menu handler instances + """ + menus = { + 'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)), + 'tuxborn': TuxbornMenuHandler(), + 'wabbajack': WabbajackMenuHandler(), + 'hoolamike': HoolamikeMenuHandler(), + 'additional': AdditionalMenuHandler() + } + + # Set up logging for menu handlers + for menu in menus.values(): + menu.logger = logger + + return menus + + def _initialize_legacy_compatibility(self): + """ + Initialize legacy compatibility attributes for menu bridge. + + This provides the legacy attributes that menu handlers expect from cli_instance + until the backend migration is complete. + """ + # LEGACY BRIDGE: Add legacy imports to access original handlers + # Backend handlers are now imported directly from backend package + + try: + # Initialize legacy handlers for compatibility + self.config_handler = ConfigHandler() + self.filesystem_handler = FileSystemHandler() + self.path_handler = PathHandler() + self.shortcut_handler = ShortcutHandler(self.config_handler.settings) + self.menu = MenuHandler() # Original menu handler for fallback + self.menu_handler = self.menu # Alias for backend compatibility + + # Add MO2 handler to the menu handler for additional tasks menu + self.menu.mo2_handler = MO2Handler(self.menu) + + # Set steamdeck attribute that menus expect + self.steamdeck = self.system_info.is_steamdeck + + # Initialize settings that legacy code expects + if not hasattr(self.config_handler, 'settings'): + self.config_handler.settings = {} + self.config_handler.settings['steamdeck'] = self.steamdeck + + logger.info("Legacy compatibility layer initialized successfully") + + except Exception as e: + logger.error(f"Failed to initialize legacy compatibility layer: {e}") + # Continue anyway - some functionality might still work + self.config_handler = None + self.filesystem_handler = None + self.path_handler = None + self.shortcut_handler = None + self.menu = None + self.steamdeck = self.system_info.is_steamdeck + + def run(self): + self.parser, self.subparsers, self.args = self._parse_args() + self._debug_mode = self.args.debug + self.verbose = self.args.verbose or self.args.debug + self.dev_mode = getattr(self.args, 'dev', False) + # Re-initialize menus with dev_mode after parsing args + self.menus = self._initialize_menu_handlers() + + # Now that we have args, configure logging properly + self._configure_logging_final() + + self._debug_print('Initializing Jackify CLI Frontend') + self._debug_print('JackifyCLI.run() called') + self._debug_print(f'Parsed args: {self.args}') + + # 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): + self._debug_print('Entering install_modlist workflow') + return self.commands['install_modlist'].execute_top_level(self.args) + + # Handle subcommands + if getattr(self.args, 'command', None): + return self._run_command(self.args.command, self.args) + + # Run interactive mode (legacy for now) + self._run_interactive() + + def _parse_args(self): + """Parse command-line arguments using command handlers""" + parser = argparse.ArgumentParser(description="Jackify: Wabbajack Modlist Manager for Linux/Steam Deck") + parser.add_argument("-V", "--version", action="store_true", help="Show Jackify version and exit") + parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging (implies verbose)") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable informational console output") + parser.add_argument("--cli", action="store_true", help="Run in CLI mode (default if no GUI available)") + 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)') + + # Add command-specific arguments + self.commands['tuxborn'].add_args(parser) + self.commands['install_modlist'].add_top_level_args(parser) + + # Add subcommands + subparsers = parser.add_subparsers(dest="command", help="Command to run") + self.commands['configure_modlist'].add_parser(subparsers) + self.commands['install_modlist'].add_parser(subparsers) + + args = parser.parse_args() + if args.version: + print(f"Jackify version {jackify_version}") + sys.exit(0) + + return parser, subparsers, args + + def _run_command(self, command, args): + """Run a specific command using command handlers""" + if command == "install-modlist": + return self.commands['install_modlist'].execute_subcommand(args) + elif command == "configure-modlist": + return self.commands['configure_modlist'].execute(args) + elif command == "install-wabbajack": + # Legacy functionality - TODO: extract to command handler + return self._handle_legacy_install_wabbajack() + elif command == "hoolamike": + # Legacy functionality - TODO: extract to command handler + return self._handle_legacy_hoolamike() + elif command == "install-mo2": + print("MO2 installation not yet implemented") + print("This functionality is coming soon!") + return 1 + elif command == "configure-nxm": + print("NXM configuration not yet implemented") + print("This functionality is coming soon!") + return 1 + elif command == "recovery": + return self._handle_legacy_recovery(args) + elif command == "test-protontricks": + return self._handle_legacy_protontricks_test() + else: + print(f"Unknown command: {command}") + return 1 + + def _run_interactive(self): + """Run the CLI interface interactively using the new menu system""" + try: + while True: + # Show main menu and get user's choice + choice = self.menus['main'].show_main_menu(self) + + if choice == "exit": + print(f"{COLOR_INFO}Thank you for using Jackify!{COLOR_RESET}") + return 0 + elif choice == "wabbajack": + self.menus['wabbajack'].show_wabbajack_tasks_menu(self) + elif choice == "tuxborn": + self.menus['tuxborn'].show_tuxborn_installer_menu(self) + # HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY + # elif choice == "hoolamike": + # self.menus['hoolamike'].show_hoolamike_menu(self) + # elif choice == "additional": + # self.menus['additional'].show_additional_tasks_menu(self) + else: + logger.warning(f"Invalid choice '{choice}' received from show_main_menu.") + + except KeyboardInterrupt: + print(f"\n{COLOR_INFO}Exiting Jackify...{COLOR_RESET}") + return 0 + except Exception as e: + logger.error(f"Error in interactive mode: {e}") + print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}") + return 1 + + def _handle_restart_steam(self): + """Handle restart-steam command - now properly implemented""" + print("[Jackify] Attempting to restart Steam...") + logger.debug("About to call secure_steam_restart()") + + try: + # Use the already initialized shortcut_handler + if self.shortcut_handler: + success = self.shortcut_handler.secure_steam_restart() + logger.debug(f"secure_steam_restart() returned: {success}") + + if success: + print("[Jackify] Steam restart completed successfully.") + return 0 + else: + print("[Jackify] Failed to restart Steam.") + return 1 + else: + print("[Jackify] ERROR: ShortcutHandler not initialized") + return 1 + + except Exception as e: + print(f"[Jackify] ERROR: Exception during Steam restart: {e}") + logger.error(f"Steam restart failed with exception: {e}") + return 1 + + def _handle_legacy_install_wabbajack(self): + """Handle install-wabbajack command (legacy functionality)""" + print("Install Wabbajack functionality not yet migrated to new structure") + return 1 + + def _handle_legacy_hoolamike(self): + """Handle hoolamike command (legacy functionality)""" + print("Hoolamike functionality not yet migrated to new structure") + return 1 + + def _handle_legacy_recovery(self, args): + """Handle recovery command (legacy functionality)""" + print("Recovery functionality not yet migrated to new structure") + return 1 + + def _handle_legacy_protontricks_test(self): + """Handle test-protontricks command (legacy functionality)""" + print("Protontricks test functionality not yet migrated to new structure") + return 1 + + # LEGACY BRIDGE: Methods that menu handlers expect to find on cli_instance + def _cmd_install_wabbajack(self, args): + """LEGACY BRIDGE: Install Wabbajack application""" + return self._handle_legacy_install_wabbajack() + + +def main(): + """Legacy main function (not used in new structure)""" + pass + + +if __name__ == "__main__": + # This should not be called directly - use __main__.py instead + print("Please use: python -m jackify.frontends.cli") + sys.exit(1) diff --git a/jackify/frontends/cli/menus/__init__.py b/jackify/frontends/cli/menus/__init__.py new file mode 100644 index 0000000..caba4f0 --- /dev/null +++ b/jackify/frontends/cli/menus/__init__.py @@ -0,0 +1,20 @@ +""" +CLI Menu Components for Jackify Frontend +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 +from .recovery_menu import RecoveryMenuHandler + +__all__ = [ + 'MainMenuHandler', + 'TuxbornMenuHandler', + 'WabbajackMenuHandler', + 'HoolamikeMenuHandler', + 'AdditionalMenuHandler', + 'RecoveryMenuHandler' +] \ No newline at end of file diff --git a/jackify/frontends/cli/menus/additional_menu.py b/jackify/frontends/cli/menus/additional_menu.py new file mode 100644 index 0000000..d8c9bfd --- /dev/null +++ b/jackify/frontends/cli/menus/additional_menu.py @@ -0,0 +1,73 @@ +""" +Additional Tasks Menu Handler for Jackify CLI Frontend +Extracted from src.modules.menu_handler.MenuHandler.show_additional_tasks_menu() +""" + +import time + +from jackify.shared.colors import ( + COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED +) +from jackify.shared.ui_utils import print_jackify_banner, print_section_header + +class AdditionalMenuHandler: + """ + Handles the Additional Tasks menu (MO2, NXM Handling & Recovery) + Extracted from legacy MenuHandler class + """ + + def __init__(self): + self.logger = None # Will be set by CLI when needed + + def _clear_screen(self): + """Clear the terminal screen""" + import os + os.system('cls' if os.name == 'nt' else 'clear') + + def show_additional_tasks_menu(self, cli_instance): + """Show the MO2, NXM Handling & Recovery submenu""" + while True: + self._clear_screen() + print_jackify_banner() + print_section_header("Additional Utilities") # Broader title + + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install Mod Organizer 2 (Base Setup)") + print(f" {COLOR_ACTION}→ Proton setup for a standalone MO2 instance{COLOR_RESET}") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure NXM Handling {COLOR_DISABLED}(Not Implemented){COLOR_RESET}") + print(f"{COLOR_SELECTION}3.{COLOR_RESET} Jackify Recovery Tools") + print(f" {COLOR_ACTION}→ Restore files modified or backed up by Jackify{COLOR_RESET}") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") + selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip() + + if selection.lower() == 'q': # Allow 'q' to re-display menu + continue + if selection == "1": + self._execute_legacy_install_mo2(cli_instance) + elif selection == "2": + print(f"{COLOR_INFO}Configure NXM Handling is not yet implemented.{COLOR_RESET}") + input("\nPress Enter to return to the Utilities menu...") + elif selection == "3": + self._execute_legacy_recovery_menu(cli_instance) + elif selection == "0": + break + else: + print("Invalid selection. Please try again.") + time.sleep(1) + + def _execute_legacy_install_mo2(self, cli_instance): + """LEGACY BRIDGE: Execute MO2 installation""" + # LEGACY BRIDGE: Use legacy imports until backend migration complete + if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'mo2_handler'): + cli_instance.menu.mo2_handler.install_mo2() + else: + print(f"{COLOR_INFO}MO2 handler not available - this will be implemented in Phase 2.3{COLOR_RESET}") + input("\nPress Enter to continue...") + + def _execute_legacy_recovery_menu(self, cli_instance): + """LEGACY BRIDGE: Execute recovery menu""" + # This will be handled by the RecoveryMenuHandler + from .recovery_menu import RecoveryMenuHandler + + recovery_handler = RecoveryMenuHandler() + recovery_handler.logger = self.logger + recovery_handler.show_recovery_menu(cli_instance) \ No newline at end of file diff --git a/jackify/frontends/cli/menus/hoolamike_menu.py b/jackify/frontends/cli/menus/hoolamike_menu.py new file mode 100644 index 0000000..f5446bf --- /dev/null +++ b/jackify/frontends/cli/menus/hoolamike_menu.py @@ -0,0 +1,32 @@ +""" +Hoolamike Menu Handler for Jackify CLI Frontend +Extracted from src.modules.menu_handler.MenuHandler.show_hoolamike_menu() +""" + +from jackify.shared.colors import COLOR_INFO, COLOR_PROMPT, COLOR_RESET + +class HoolamikeMenuHandler: + """ + Handles the Hoolamike Tasks menu + Extracted from legacy MenuHandler class + """ + + def __init__(self): + self.logger = None # Will be set by CLI when needed + + def show_hoolamike_menu(self, cli_instance): + """ + LEGACY BRIDGE: Delegate to legacy menu handler until full backend migration + + Args: + cli_instance: Reference to main CLI instance for access to handlers + """ + print(f"{COLOR_INFO}Hoolamike menu functionality has been extracted but needs migration to backend services.{COLOR_RESET}") + print(f"{COLOR_INFO}This will be implemented in Phase 2.3 (Menu Backend Integration).{COLOR_RESET}") + + # LEGACY BRIDGE: Use the original menu handler's method + if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'show_hoolamike_menu'): + cli_instance.menu.show_hoolamike_menu(cli_instance) + else: + print(f"{COLOR_INFO}Legacy menu handler not available - returning to main menu.{COLOR_RESET}") + input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") \ No newline at end of file diff --git a/jackify/frontends/cli/menus/main_menu.py b/jackify/frontends/cli/menus/main_menu.py new file mode 100644 index 0000000..794552f --- /dev/null +++ b/jackify/frontends/cli/menus/main_menu.py @@ -0,0 +1,74 @@ +""" +Main Menu Handler for Jackify CLI Frontend +Extracted from src.modules.menu_handler.MenuHandler.show_main_menu() +""" + +import time +from typing import Optional + +from jackify.shared.colors import ( + COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_ERROR +) +from jackify.shared.ui_utils import print_jackify_banner + +class MainMenuHandler: + """ + Handles the main interactive menu display and user input routing + Extracted from legacy MenuHandler class + """ + + def __init__(self, dev_mode=False): + self.logger = None # Will be set by CLI when needed + self.dev_mode = dev_mode + + def _clear_screen(self): + """Clear the terminal screen""" + import os + os.system('cls' if os.name == 'nt' else 'clear') + + def show_main_menu(self, cli_instance) -> str: + """ + Show the main menu and return user selection + + Args: + cli_instance: Reference to main CLI instance for access to handlers + + Returns: + str: Menu choice ("wabbajack", "hoolamike", "additional", "exit", "tuxborn") + """ + while True: + self._clear_screen() + print_jackify_banner() + print(f"{COLOR_SELECTION}Main Menu{COLOR_RESET}") + print(f"{COLOR_SELECTION}{'-'*22}{COLOR_RESET}") # Standard separator + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks") + print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Tuxborn Automatic Installer") + print(f" {COLOR_ACTION}→ Simple, fully automated Tuxborn installation{COLOR_RESET}") + if self.dev_mode: + print(f"{COLOR_SELECTION}3.{COLOR_RESET} Hoolamike Tasks") + print(f" {COLOR_ACTION}→ Wabbajack alternative: Install Modlists, TTW, etc{COLOR_RESET}") + print(f"{COLOR_SELECTION}4.{COLOR_RESET} Additional Tasks") + print(f" {COLOR_ACTION}→ Install Wabbajack (via WINE), MO2, NXM Handling, Jackify Recovery{COLOR_RESET}") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify") + if self.dev_mode: + choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip() + else: + choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() + + if choice.lower() == 'q': # Allow 'q' to re-display menu + continue + if choice == "1": + return "wabbajack" + elif choice == "2": + return "tuxborn" # Will be handled by TuxbornMenuHandler + if self.dev_mode: + if choice == "3": + return "hoolamike" + elif choice == "4": + return "additional" + elif choice == "0": + return "exit" + else: + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + time.sleep(1) # Brief pause for readability \ No newline at end of file diff --git a/jackify/frontends/cli/menus/recovery_menu.py b/jackify/frontends/cli/menus/recovery_menu.py new file mode 100644 index 0000000..3e390e3 --- /dev/null +++ b/jackify/frontends/cli/menus/recovery_menu.py @@ -0,0 +1,174 @@ +""" +Recovery Menu Handler for Jackify CLI Frontend +Extracted from src.modules.menu_handler.MenuHandler._show_recovery_menu() +""" + +import logging +from pathlib import Path + +from jackify.shared.colors import ( + COLOR_SELECTION, COLOR_RESET, COLOR_PROMPT, COLOR_INFO, COLOR_ERROR +) +from jackify.shared.ui_utils import print_jackify_banner, print_section_header + +class RecoveryMenuHandler: + """ + Handles the Recovery Tools menu + Extracted from legacy MenuHandler class + """ + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def _clear_screen(self): + """Clear the terminal screen""" + import os + os.system('cls' if os.name == 'nt' else 'clear') + + def show_recovery_menu(self, cli_instance): + """Show the recovery tools menu.""" + while True: + self._clear_screen() + print_jackify_banner() + print_section_header('Recovery Tools') + print(f"{COLOR_INFO}This allows restoring original Steam configuration files from backups created by Jackify.{COLOR_RESET}") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Restore all backups") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Restore config.vdf only") + print(f"{COLOR_SELECTION}3.{COLOR_RESET} Restore libraryfolders.vdf only") + print(f"{COLOR_SELECTION}4.{COLOR_RESET} Restore shortcuts.vdf only") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") + + choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip() + + if choice == "1": + self._restore_all_backups(cli_instance) + elif choice == "2": + self._restore_config_vdf(cli_instance) + elif choice == "3": + self._restore_libraryfolders_vdf(cli_instance) + elif choice == "4": + self._restore_shortcuts_vdf(cli_instance) + elif choice == "0": + break + else: + print("Invalid selection. Please try again.") + input("\nPress Enter to continue...") + + def _restore_all_backups(self, cli_instance): + """Restore all supported Steam config files""" + self.logger.info("Recovery selected: Restore all Steam config files") + print("\nAttempting to restore all supported Steam config files...") + + # LEGACY BRIDGE: Use legacy handlers until backend migration complete + paths_to_check = { + "libraryfolders": self._get_library_vdf_path(cli_instance), + "config": self._get_config_vdf_path(cli_instance), + "shortcuts": self._get_shortcuts_vdf_path(cli_instance) + } + + restored_count = 0 + for file_type, file_path in paths_to_check.items(): + if file_path: + print(f"Restoring {file_type} ({file_path})...") + latest_backup = self._find_latest_backup(cli_instance, Path(file_path)) + if latest_backup: + if self._restore_backup(cli_instance, latest_backup, Path(file_path)): + print(f"Successfully restored {file_type}.") + restored_count += 1 + else: + print(f"{COLOR_ERROR}Failed to restore {file_type} from {latest_backup}.{COLOR_RESET}") + else: + print(f"No backup found for {file_type}.") + else: + print(f"Could not locate original file for {file_type} to restore.") + + print(f"\nRestore process completed. {restored_count}/{len(paths_to_check)} files potentially restored.") + input("\nPress Enter to continue...") + + def _restore_config_vdf(self, cli_instance): + """Restore config.vdf only""" + self.logger.info("Recovery selected: Restore config.vdf only") + print("\nAttempting to restore config.vdf...") + + file_path = self._get_config_vdf_path(cli_instance) + if file_path: + latest_backup = self._find_latest_backup(cli_instance, Path(file_path)) + if latest_backup: + if self._restore_backup(cli_instance, latest_backup, Path(file_path)): + print(f"Successfully restored config.vdf from {latest_backup}.") + else: + print(f"{COLOR_ERROR}Failed to restore config.vdf from {latest_backup}.{COLOR_RESET}") + else: + print("No backup found for config.vdf.") + else: + print("Could not locate config.vdf.") + input("\nPress Enter to continue...") + + def _restore_libraryfolders_vdf(self, cli_instance): + """Restore libraryfolders.vdf only""" + self.logger.info("Recovery selected: Restore libraryfolders.vdf only") + print("\nAttempting to restore libraryfolders.vdf...") + + file_path = self._get_library_vdf_path(cli_instance) + if file_path: + latest_backup = self._find_latest_backup(cli_instance, Path(file_path)) + if latest_backup: + if self._restore_backup(cli_instance, latest_backup, Path(file_path)): + print(f"Successfully restored libraryfolders.vdf from {latest_backup}.") + else: + print(f"{COLOR_ERROR}Failed to restore libraryfolders.vdf from {latest_backup}.{COLOR_RESET}") + else: + print("No backup found for libraryfolders.vdf.") + else: + print("Could not locate libraryfolders.vdf.") + input("\nPress Enter to continue...") + + def _restore_shortcuts_vdf(self, cli_instance): + """Restore shortcuts.vdf only""" + self.logger.info("Recovery selected: Restore shortcuts.vdf only") + print("\nAttempting to restore shortcuts.vdf...") + + file_path = self._get_shortcuts_vdf_path(cli_instance) + if file_path: + latest_backup = self._find_latest_backup(cli_instance, Path(file_path)) + if latest_backup: + if self._restore_backup(cli_instance, latest_backup, Path(file_path)): + print(f"Successfully restored shortcuts.vdf from {latest_backup}.") + else: + print(f"{COLOR_ERROR}Failed to restore shortcuts.vdf from {latest_backup}.{COLOR_RESET}") + else: + print("No backup found for shortcuts.vdf.") + else: + print("Could not locate shortcuts.vdf.") + input("\nPress Enter to continue...") + + # LEGACY BRIDGE methods - delegate to existing handlers + def _get_library_vdf_path(self, cli_instance): + """LEGACY BRIDGE: Get libraryfolders.vdf path""" + if hasattr(cli_instance, 'path_handler'): + return cli_instance.path_handler.find_steam_library_vdf_path() + return None + + def _get_config_vdf_path(self, cli_instance): + """LEGACY BRIDGE: Get config.vdf path""" + if hasattr(cli_instance, 'path_handler'): + return cli_instance.path_handler.find_steam_config_vdf() + return None + + def _get_shortcuts_vdf_path(self, cli_instance): + """LEGACY BRIDGE: Get shortcuts.vdf path""" + if hasattr(cli_instance, 'shortcut_handler'): + return cli_instance.shortcut_handler._find_shortcuts_vdf() + return None + + def _find_latest_backup(self, cli_instance, file_path: Path): + """LEGACY BRIDGE: Find latest backup file""" + if hasattr(cli_instance, 'filesystem_handler'): + return cli_instance.filesystem_handler.find_latest_backup(file_path) + return None + + def _restore_backup(self, cli_instance, backup_path, target_path: Path) -> bool: + """LEGACY BRIDGE: Restore backup file""" + if hasattr(cli_instance, 'filesystem_handler'): + return cli_instance.filesystem_handler.restore_backup(backup_path, target_path) + return False \ No newline at end of file diff --git a/jackify/frontends/cli/menus/tuxborn_menu.py b/jackify/frontends/cli/menus/tuxborn_menu.py new file mode 100644 index 0000000..2544ac8 --- /dev/null +++ b/jackify/frontends/cli/menus/tuxborn_menu.py @@ -0,0 +1,194 @@ +""" +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) \ No newline at end of file diff --git a/jackify/frontends/cli/menus/wabbajack_menu.py b/jackify/frontends/cli/menus/wabbajack_menu.py new file mode 100644 index 0000000..db0c3e0 --- /dev/null +++ b/jackify/frontends/cli/menus/wabbajack_menu.py @@ -0,0 +1,115 @@ +""" +Wabbajack Tasks Menu Handler for Jackify CLI Frontend +Extracted from src.modules.menu_handler.MenuHandler.show_wabbajack_tasks_menu() +""" + +import time + +from jackify.shared.colors import ( + COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO +) +from jackify.shared.ui_utils import print_jackify_banner, print_section_header + +class WabbajackMenuHandler: + """ + Handles the Modlist and Wabbajack Tasks menu + Extracted from legacy MenuHandler class + """ + + def __init__(self): + self.logger = None # Will be set by CLI when needed + + def _clear_screen(self): + """Clear the terminal screen""" + import os + os.system('cls' if os.name == 'nt' else 'clear') + + def show_wabbajack_tasks_menu(self, cli_instance): + """Show the Modlist and Wabbajack Tasks menu""" + while True: + self._clear_screen() + print_jackify_banner() + # Use print_section_header for consistency + 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_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_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 + # print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application") + # print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via WINE){COLOR_RESET}") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") + selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip() + + if selection.lower() == 'q': # Allow 'q' to re-display menu + continue + + if selection == "1": + self._execute_legacy_install_modlist(cli_instance) + elif selection == "2": + self._execute_legacy_configure_new_modlist(cli_instance) + elif selection == "3": + self._execute_legacy_configure_existing_modlist(cli_instance) + # HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY + # elif selection == "4": + # self._execute_legacy_install_wabbajack(cli_instance) + elif selection == "0": + break + else: + print("Invalid selection. Please try again.") + time.sleep(1) + + def _execute_legacy_install_modlist(self, cli_instance): + """LEGACY BRIDGE: Execute modlist installation workflow""" + # Import backend services + from jackify.backend.core.modlist_operations import ModlistInstallCLI + from jackify.backend.handlers.menu_handler import MenuHandler + + # Create a proper MenuHandler instance with the required methods + menu_handler = MenuHandler() + + # Pass the MenuHandler instance and steamdeck status + steamdeck_status = getattr(cli_instance, 'steamdeck', False) + installer = ModlistInstallCLI(menu_handler, steamdeck_status) + if self.logger: + self.logger.debug("MenuHandler: ModlistInstallCLI instance created for Install a Modlist.") + context = installer.run_discovery_phase() + if context: + if self.logger: + self.logger.info("MenuHandler: Discovery phase complete, proceeding to configuration phase.") + installer.configuration_phase() + else: + if self.logger: + self.logger.info("MenuHandler: Discovery phase did not return context. Skipping configuration.") + input("\nPress Enter to return to the Modlist Tasks menu...") # Standard return prompt + + def _execute_legacy_install_wabbajack(self, cli_instance): + """LEGACY BRIDGE: Execute Wabbajack application installation""" + if self.logger: + self.logger.info("User selected 'Install Wabbajack' from Modlist Tasks menu.") + # Add introductory text before calling the Wabbajack installation workflow + self._clear_screen() + print_jackify_banner() + print_section_header("Install Wabbajack Application") + print(f"{COLOR_INFO}This process will guide you through downloading and setting up\nthe Wabbajack application itself.{COLOR_RESET}") + print("\n") # Spacer + cli_instance._cmd_install_wabbajack(None) # Pass the cli_instance itself + + def _execute_legacy_configure_new_modlist(self, cli_instance): + """LEGACY BRIDGE: Execute new modlist configuration""" + # Import backend service + from jackify.backend.handlers.menu_handler import ModlistMenuHandler + + modlist_menu = ModlistMenuHandler(cli_instance.config_handler) + modlist_menu._configure_new_modlist() + + def _execute_legacy_configure_existing_modlist(self, cli_instance): + """LEGACY BRIDGE: Execute existing modlist configuration""" + # Import backend service + from jackify.backend.handlers.menu_handler import ModlistMenuHandler + + modlist_menu = ModlistMenuHandler(cli_instance.config_handler) + modlist_menu._configure_existing_modlist() \ No newline at end of file diff --git a/jackify/frontends/cli/ui/__init__.py b/jackify/frontends/cli/ui/__init__.py new file mode 100644 index 0000000..6391836 --- /dev/null +++ b/jackify/frontends/cli/ui/__init__.py @@ -0,0 +1,9 @@ +""" +CLI UI Components for Jackify Frontend +Shared UI utilities and components for command-line interface +""" + +# Currently empty - will be populated with UI helpers as needed +# Examples: input validators, progress indicators, etc. + +__all__ = [] \ No newline at end of file diff --git a/jackify/frontends/gui/__init__.py b/jackify/frontends/gui/__init__.py new file mode 100644 index 0000000..cdb5ea6 --- /dev/null +++ b/jackify/frontends/gui/__init__.py @@ -0,0 +1,6 @@ +""" +GUI Frontend for Jackify +PyQt-based graphical user interface that uses backend services directly +""" + +__all__ = [] \ No newline at end of file diff --git a/jackify/frontends/gui/__main__.py b/jackify/frontends/gui/__main__.py new file mode 100644 index 0000000..04cd755 --- /dev/null +++ b/jackify/frontends/gui/__main__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +""" +Entry point for Jackify GUI Frontend + +Usage: python -m jackify.frontends.gui +""" + +from jackify.frontends.gui.main import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/__init__.py b/jackify/frontends/gui/dialogs/__init__.py new file mode 100644 index 0000000..773e549 --- /dev/null +++ b/jackify/frontends/gui/dialogs/__init__.py @@ -0,0 +1,10 @@ +""" +GUI Dialogs Package + +Custom dialogs for the Jackify GUI application. +""" + +from .completion_dialog import NextStepsDialog +from .success_dialog import SuccessDialog + +__all__ = ['NextStepsDialog', 'SuccessDialog'] \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/completion_dialog.py b/jackify/frontends/gui/dialogs/completion_dialog.py new file mode 100644 index 0000000..a4ecd01 --- /dev/null +++ b/jackify/frontends/gui/dialogs/completion_dialog.py @@ -0,0 +1,200 @@ +""" +Completion Dialog + +Custom completion dialog that shows the same detailed completion message +as the CLI frontend, formatted for GUI display. +""" + +import logging +from pathlib import Path +from typing import Optional + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit, + QWidget, QSpacerItem, QSizePolicy +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap, QIcon + +logger = logging.getLogger(__name__) + + +class NextStepsDialog(QDialog): + """ + Custom completion dialog showing detailed next steps after modlist configuration. + + Displays the same information as the CLI completion message but in a proper GUI format. + """ + + def __init__(self, modlist_name: str, parent=None): + """ + Initialize the Next Steps dialog. + + Args: + modlist_name: Name of the configured modlist + parent: Parent widget + """ + super().__init__(parent) + self.modlist_name = modlist_name + self.setWindowTitle("Next Steps") + self.setModal(True) + self.setFixedSize(600, 400) + + # Set the Wabbajack icon if available + self._set_dialog_icon() + + self._setup_ui() + + logger.info(f"NextStepsDialog created for modlist: {modlist_name}") + + def _set_dialog_icon(self): + """Set the dialog icon to Wabbajack icon if available""" + try: + # Try to use the same icon as the main application + icon_path = Path(__file__).parent.parent.parent.parent.parent / "Files" / "wabbajack-icon.png" + if icon_path.exists(): + icon = QIcon(str(icon_path)) + self.setWindowIcon(icon) + except Exception as e: + logger.debug(f"Could not set dialog icon: {e}") + + def _setup_ui(self): + """Set up the dialog user interface""" + layout = QVBoxLayout(self) + layout.setSpacing(16) + layout.setContentsMargins(20, 20, 20, 20) + + # Header with icon and title + self._setup_header(layout) + + # Main content area + self._setup_content(layout) + + # Action buttons + self._setup_buttons(layout) + + def _setup_header(self, layout): + """Set up the dialog header with title""" + header_layout = QHBoxLayout() + + # Title + title_label = QLabel("Next Steps:") + title_label.setStyleSheet( + "QLabel { " + " font-size: 18px; " + " font-weight: bold; " + " color: #2c3e50; " + " margin-bottom: 10px; " + "}" + ) + header_layout.addWidget(title_label) + + # Add some space + header_layout.addStretch() + + layout.addLayout(header_layout) + + def _setup_content(self, layout): + """Set up the main content area with next steps""" + # Create content area + content_widget = QWidget() + content_layout = QVBoxLayout(content_widget) + content_layout.setSpacing(12) + + # Add the detailed next steps text (matching CLI completion message) + steps_text = self._build_completion_text() + + content_text = QTextEdit() + content_text.setPlainText(steps_text) + content_text.setReadOnly(True) + content_text.setStyleSheet( + "QTextEdit { " + " background-color: #f8f9fa; " + " border: 1px solid #dee2e6; " + " border-radius: 6px; " + " padding: 12px; " + " font-family: 'Segoe UI', Arial, sans-serif; " + " font-size: 12px; " + " line-height: 1.5; " + "}" + ) + content_layout.addWidget(content_text) + + layout.addWidget(content_widget) + + def _setup_buttons(self, layout): + """Set up the action buttons""" + button_layout = QHBoxLayout() + button_layout.setSpacing(12) + + # Add stretch to center buttons + button_layout.addStretch() + + # Return button (goes back to menu) + return_btn = QPushButton("Return") + return_btn.setFixedSize(100, 35) + return_btn.clicked.connect(self.accept) # This will close dialog and return to menu + return_btn.setStyleSheet( + "QPushButton { " + " background-color: #3498db; " + " color: white; " + " border: none; " + " border-radius: 4px; " + " font-weight: bold; " + " padding: 8px 16px; " + "} " + "QPushButton:hover { " + " background-color: #2980b9; " + "} " + "QPushButton:pressed { " + " background-color: #21618c; " + "}" + ) + button_layout.addWidget(return_btn) + + button_layout.addSpacing(10) + + # Exit button (closes the application) + exit_btn = QPushButton("Exit") + exit_btn.setFixedSize(100, 35) + exit_btn.clicked.connect(self.reject) # This will close dialog and potentially exit app + exit_btn.setStyleSheet( + "QPushButton { " + " background-color: #95a5a6; " + " color: white; " + " border: none; " + " border-radius: 4px; " + " font-weight: bold; " + " padding: 8px 16px; " + "} " + "QPushButton:hover { " + " background-color: #7f8c8d; " + "} " + "QPushButton:pressed { " + " background-color: #6c7b7d; " + "}" + ) + button_layout.addWidget(exit_btn) + + button_layout.addStretch() + + layout.addLayout(button_layout) + + def _build_completion_text(self) -> str: + """ + Build the completion text matching the CLI version from menu_handler.py. + + Returns: + Formatted completion text string + """ + # Match the CLI completion text from menu_handler.py lines 627-631 + completion_text = f"""✓ Configuration completed successfully! + +Modlist Install and Configuration complete!: + + • You should now be able to Launch '{self.modlist_name}' through Steam. + • Congratulations and enjoy the game! + +Detailed log available at: ~/Jackify/logs/Configure_New_Modlist_workflow.log""" + + return completion_text \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/protontricks_error_dialog.py b/jackify/frontends/gui/dialogs/protontricks_error_dialog.py new file mode 100644 index 0000000..1dc7245 --- /dev/null +++ b/jackify/frontends/gui/dialogs/protontricks_error_dialog.py @@ -0,0 +1,328 @@ +""" +Protontricks Error Dialog + +Dialog shown when protontricks is not found, with options to install via Flatpak or get native installation guidance. +""" + +from pathlib import Path +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QSizePolicy, QTextEdit, QProgressBar +) +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QPixmap, QIcon, QFont +from .. import shared_theme + + +class FlatpakInstallThread(QThread): + """Thread for installing Flatpak protontricks""" + finished = Signal(bool, str) # success, message + + def __init__(self, detection_service): + super().__init__() + self.detection_service = detection_service + + def run(self): + success, message = self.detection_service.install_flatpak_protontricks() + self.finished.emit(success, message) + + +class ProtontricksErrorDialog(QDialog): + """ + Dialog shown when protontricks is not found + Provides options to install via Flatpak or get native installation guidance + """ + + def __init__(self, detection_service, parent=None): + super().__init__(parent) + self.detection_service = detection_service + self.setWindowTitle("Protontricks Required") + self.setModal(True) + self.setFixedSize(550, 520) + self.install_thread = None + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + # Card background + card = QFrame(self) + card.setObjectName("protontricksCard") + card.setFrameShape(QFrame.StyledPanel) + card.setFrameShadow(QFrame.Raised) + card.setMinimumWidth(500) + card.setMinimumHeight(400) + card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + card_layout = QVBoxLayout(card) + card_layout.setSpacing(16) + card_layout.setContentsMargins(28, 28, 28, 28) + card.setStyleSheet( + "QFrame#protontricksCard { " + " background: #2d2323; " + " border-radius: 12px; " + " border: 2px solid #e74c3c; " + "}" + ) + + # Error icon + icon_label = QLabel() + icon_label.setAlignment(Qt.AlignCenter) + icon_label.setText("!") + icon_label.setStyleSheet( + "QLabel { " + " font-size: 36px; " + " font-weight: bold; " + " color: #e74c3c; " + " margin-bottom: 4px; " + "}" + ) + card_layout.addWidget(icon_label) + + # Error title + title_label = QLabel("Protontricks Not Found") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet( + "QLabel { " + " font-size: 20px; " + " font-weight: 600; " + " color: #e74c3c; " + " margin-bottom: 2px; " + "}" + ) + card_layout.addWidget(title_label) + + # Error message + message_text = QTextEdit() + message_text.setReadOnly(True) + message_text.setPlainText( + "Protontricks is required for Jackify to function properly. " + "It manages Wine prefixes for Steam games and is essential for modlist installation and configuration.\n\n" + "Choose an installation method below:" + ) + message_text.setMinimumHeight(100) + message_text.setMaximumHeight(120) + message_text.setStyleSheet( + "QTextEdit { " + " font-size: 15px; " + " color: #e0e0e0; " + " background: transparent; " + " border: none; " + " line-height: 1.3; " + " margin-bottom: 6px; " + "}" + ) + card_layout.addWidget(message_text) + + # Progress bar (initially hidden) + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + self.progress_bar.setStyleSheet( + "QProgressBar { " + " border: 1px solid #555; " + " border-radius: 4px; " + " background: #23272e; " + " text-align: center; " + "} " + "QProgressBar::chunk { " + " background-color: #4fc3f7; " + " border-radius: 3px; " + "}" + ) + card_layout.addWidget(self.progress_bar) + + # Status label (initially hidden) + self.status_label = QLabel() + self.status_label.setVisible(False) + self.status_label.setAlignment(Qt.AlignCenter) + self.status_label.setStyleSheet( + "QLabel { " + " font-size: 14px; " + " color: #4fc3f7; " + " margin: 8px 0; " + "}" + ) + card_layout.addWidget(self.status_label) + + # Button layout + button_layout = QVBoxLayout() + button_layout.setSpacing(12) + + # Flatpak install button + self.flatpak_btn = QPushButton("Install via Flatpak (Recommended)") + self.flatpak_btn.setFixedHeight(40) + self.flatpak_btn.clicked.connect(self._install_flatpak) + self.flatpak_btn.setStyleSheet( + "QPushButton { " + " background-color: #4fc3f7; " + " color: white; " + " border: none; " + " border-radius: 6px; " + " font-weight: bold; " + " font-size: 14px; " + " padding: 8px 16px; " + "} " + "QPushButton:hover { " + " background-color: #3498db; " + "} " + "QPushButton:pressed { " + " background-color: #2980b9; " + "} " + "QPushButton:disabled { " + " background-color: #555; " + " color: #888; " + "}" + ) + button_layout.addWidget(self.flatpak_btn) + + # Native install guidance button + self.native_btn = QPushButton("Show Native Installation Instructions") + self.native_btn.setFixedHeight(40) + self.native_btn.clicked.connect(self._show_native_guidance) + self.native_btn.setStyleSheet( + "QPushButton { " + " background-color: #95a5a6; " + " color: white; " + " border: none; " + " border-radius: 6px; " + " font-weight: bold; " + " font-size: 14px; " + " padding: 8px 16px; " + "} " + "QPushButton:hover { " + " background-color: #7f8c8d; " + "} " + "QPushButton:pressed { " + " background-color: #6c7b7d; " + "}" + ) + button_layout.addWidget(self.native_btn) + + card_layout.addLayout(button_layout) + + # Bottom button layout + bottom_layout = QHBoxLayout() + bottom_layout.setSpacing(12) + + # Re-detect button + self.redetect_btn = QPushButton("Re-detect") + self.redetect_btn.setFixedSize(120, 36) + self.redetect_btn.clicked.connect(self._redetect) + self.redetect_btn.setStyleSheet( + "QPushButton { " + " background-color: #27ae60; " + " color: white; " + " border: none; " + " border-radius: 4px; " + " font-weight: bold; " + " padding: 8px 16px; " + "} " + "QPushButton:hover { " + " background-color: #229954; " + "} " + "QPushButton:pressed { " + " background-color: #1e8449; " + "}" + ) + bottom_layout.addWidget(self.redetect_btn) + + bottom_layout.addStretch() + + # Exit button + exit_btn = QPushButton("Exit Jackify") + exit_btn.setFixedSize(120, 36) + exit_btn.clicked.connect(self._exit_app) + exit_btn.setStyleSheet( + "QPushButton { " + " background-color: #e74c3c; " + " color: white; " + " border: none; " + " border-radius: 4px; " + " font-weight: bold; " + " padding: 8px 16px; " + "} " + "QPushButton:hover { " + " background-color: #c0392b; " + "} " + "QPushButton:pressed { " + " background-color: #a93226; " + "}" + ) + bottom_layout.addWidget(exit_btn) + + card_layout.addLayout(bottom_layout) + + layout.addStretch() + layout.addWidget(card, alignment=Qt.AlignCenter) + layout.addStretch() + + def _install_flatpak(self): + """Install protontricks via Flatpak""" + # Disable buttons during installation + self.flatpak_btn.setEnabled(False) + self.native_btn.setEnabled(False) + self.redetect_btn.setEnabled(False) + + # Show progress + self.progress_bar.setVisible(True) + self.progress_bar.setRange(0, 0) # Indeterminate progress + self.status_label.setVisible(True) + self.status_label.setText("Installing Flatpak protontricks...") + + # Start installation thread + self.install_thread = FlatpakInstallThread(self.detection_service) + self.install_thread.finished.connect(self._on_install_finished) + self.install_thread.start() + + def _on_install_finished(self, success, message): + """Handle installation completion""" + # Hide progress + self.progress_bar.setVisible(False) + + # Re-enable buttons + self.flatpak_btn.setEnabled(True) + self.native_btn.setEnabled(True) + self.redetect_btn.setEnabled(True) + + if success: + self.status_label.setText("✓ Installation successful!") + self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }") + # Auto-redetect after successful installation + self._redetect() + else: + self.status_label.setText(f"✗ Installation failed: {message}") + self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }") + + def _show_native_guidance(self): + """Show native installation guidance""" + from ..services.message_service import MessageService + guidance = self.detection_service.get_installation_guidance() + MessageService.information(self, "Native Installation", guidance, safety_level="low") + + def _redetect(self): + """Re-detect protontricks""" + self.detection_service.clear_cache() + is_installed, installation_type, details = self.detection_service.detect_protontricks(use_cache=False) + + if is_installed: + self.status_label.setText("✓ Protontricks found!") + self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }") + self.status_label.setVisible(True) + self.accept() # Close dialog successfully + else: + self.status_label.setText("✗ Protontricks still not found") + self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }") + self.status_label.setVisible(True) + + def _exit_app(self): + """Exit the application""" + self.reject() + import sys + sys.exit(1) + + def closeEvent(self, event): + """Handle dialog close event""" + if self.install_thread and self.install_thread.isRunning(): + self.install_thread.terminate() + self.install_thread.wait() + event.accept() \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/success_dialog.py b/jackify/frontends/gui/dialogs/success_dialog.py new file mode 100644 index 0000000..6c8b1ac --- /dev/null +++ b/jackify/frontends/gui/dialogs/success_dialog.py @@ -0,0 +1,239 @@ +""" +Success Dialog + +Celebration dialog shown when workflows complete successfully. +Features trophy icon, personalized messaging, and time tracking. +""" + +import logging +from pathlib import Path +from typing import Optional + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QWidget, + QSpacerItem, QSizePolicy, QFrame, QApplication +) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QPixmap, QIcon, QFont + +logger = logging.getLogger(__name__) + + +class SuccessDialog(QDialog): + """ + Celebration dialog shown when workflows complete successfully. + + Features: + - Trophy icon + - Personalized success message + - Time taken display + - Next steps guidance + - Return and Exit buttons + """ + + def __init__(self, modlist_name: str, workflow_type: str, time_taken: str, game_name: str = None, parent=None): + super().__init__(parent) + self.modlist_name = modlist_name + self.workflow_type = workflow_type + self.time_taken = time_taken + self.game_name = game_name + self.setWindowTitle("Success!") + self.setWindowModality(Qt.NonModal) + self.setAttribute(Qt.WA_ShowWithoutActivating, True) + self.setFixedSize(500, 420) + self.setWindowFlag(Qt.WindowDoesNotAcceptFocus, True) + self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" ) + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + # --- Card background for content --- + card = QFrame(self) + card.setObjectName("successCard") + card.setFrameShape(QFrame.StyledPanel) + card.setFrameShadow(QFrame.Raised) + card.setFixedWidth(440) + card_layout = QVBoxLayout(card) + card_layout.setSpacing(12) + card_layout.setContentsMargins(28, 28, 28, 28) + card.setStyleSheet( + "QFrame#successCard { " + " background: #23272e; " + " border-radius: 12px; " + " border: 1px solid #353a40; " + "}" + ) + card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # Trophy icon (smaller, more subtle) + trophy_label = QLabel() + trophy_label.setAlignment(Qt.AlignCenter) + trophy_icon_path = Path(__file__).parent.parent.parent.parent.parent / "Files" / "trophy.png" + if trophy_icon_path.exists(): + pixmap = QPixmap(str(trophy_icon_path)).scaled(36, 36, Qt.KeepAspectRatio, Qt.SmoothTransformation) + trophy_label.setPixmap(pixmap) + else: + trophy_label.setText("✓") + trophy_label.setStyleSheet( + "QLabel { " + " font-size: 28px; " + " margin-bottom: 4px; " + "}" + ) + card_layout.addWidget(trophy_label) + + # Success title (less saturated green) + title_label = QLabel("Success!") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet( + "QLabel { " + " font-size: 22px; " + " font-weight: 600; " + " color: #2ecc71; " + " margin-bottom: 2px; " + "}" + ) + card_layout.addWidget(title_label) + + # Personalized success message (modlist name in Jackify Blue, but less bold) + message_text = self._build_success_message() + modlist_name_html = f'{self.modlist_name}' + if self.workflow_type == "install": + message_html = f"{modlist_name_html} installed successfully!" + else: + message_html = message_text + message_label = QLabel(message_html) + message_label.setAlignment(Qt.AlignCenter) + message_label.setWordWrap(True) + message_label.setStyleSheet( + "QLabel { " + " font-size: 15px; " + " color: #e0e0e0; " + " line-height: 1.3; " + " margin-bottom: 6px; " + " max-width: 400px; " + " min-width: 200px; " + " word-wrap: break-word; " + "}" + ) + message_label.setTextFormat(Qt.RichText) + card_layout.addWidget(message_label) + + # Time taken + time_label = QLabel(f"Completed in {self.time_taken}") + time_label.setAlignment(Qt.AlignCenter) + time_label.setStyleSheet( + "QLabel { " + " font-size: 12px; " + " color: #b0b0b0; " + " font-style: italic; " + " margin-bottom: 10px; " + "}" + ) + card_layout.addWidget(time_label) + + # Next steps guidance + next_steps_text = self._build_next_steps() + next_steps_label = QLabel(next_steps_text) + next_steps_label.setAlignment(Qt.AlignCenter) + next_steps_label.setWordWrap(True) + next_steps_label.setStyleSheet( + "QLabel { " + " font-size: 13px; " + " color: #b0b0b0; " + " line-height: 1.2; " + " padding: 6px; " + " background-color: transparent; " + " border-radius: 6px; " + " border: none; " + "}" + ) + card_layout.addWidget(next_steps_label) + + layout.addStretch() + layout.addWidget(card, alignment=Qt.AlignCenter) + layout.addStretch() + + # Action buttons + btn_row = QHBoxLayout() + self.return_btn = QPushButton("Return") + self.exit_btn = QPushButton("Exit") + btn_row.addWidget(self.return_btn) + btn_row.addWidget(self.exit_btn) + layout.addLayout(btn_row) + # Now set up the timer/countdown logic AFTER buttons are created + self.return_btn.setEnabled(False) + self.exit_btn.setEnabled(False) + self._countdown = 3 + self._orig_return_text = self.return_btn.text() + self._timer = QTimer(self) + self._timer.timeout.connect(self._update_countdown) + self._update_countdown() + self._timer.start(1000) + self.return_btn.clicked.connect(self.accept) + self.exit_btn.clicked.connect(QApplication.quit) + + # Set the Wabbajack icon if available + self._set_dialog_icon() + + logger.info(f"SuccessDialog created for {workflow_type}: {modlist_name} (completed in {time_taken})") + + def _set_dialog_icon(self): + """Set the dialog icon to Wabbajack icon if available""" + try: + # Try to use the same icon as the main application + icon_path = Path(__file__).parent.parent.parent.parent.parent / "Files" / "wabbajack-icon.png" + if icon_path.exists(): + icon = QIcon(str(icon_path)) + self.setWindowIcon(icon) + except Exception as e: + logger.debug(f"Could not set dialog icon: {e}") + + def _setup_ui(self): + """Set up the dialog user interface""" + pass # This method is no longer needed as __init__ handles UI setup + + def _setup_buttons(self, layout): + """Set up the action buttons""" + pass # This method is no longer needed as __init__ handles button setup + + def _build_success_message(self) -> str: + """ + Build the personalized success message based on workflow type. + + Returns: + Formatted success message string + """ + workflow_messages = { + "install": f"{self.modlist_name} installed successfully!", + "configure_new": f"{self.modlist_name} configured successfully!", + "configure_existing": f"{self.modlist_name} configuration updated successfully!", + "tuxborn": f"Tuxborn installation completed successfully!", + } + + return workflow_messages.get(self.workflow_type, f"{self.modlist_name} completed successfully!") + + def _build_next_steps(self) -> str: + """ + Build the next steps guidance based on workflow type. + + Returns: + Formatted next steps string + """ + game_display = self.game_name or self.modlist_name + if self.workflow_type == "tuxborn": + return f"You can now launch Tuxborn from Steam and enjoy your modded {game_display} experience!" + else: + return f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!" + + def _update_countdown(self): + if self._countdown > 0: + self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)") + self.return_btn.setEnabled(False) + self.exit_btn.setEnabled(False) + self._countdown -= 1 + else: + self.return_btn.setText(self._orig_return_text) + self.return_btn.setEnabled(True) + self.exit_btn.setEnabled(True) + self._timer.stop() \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py b/jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py new file mode 100644 index 0000000..60e1dad --- /dev/null +++ b/jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Ulimit Guidance Dialog + +Provides guidance for manually increasing file descriptor limits when automatic +increase fails. Offers distribution-specific instructions and commands. +""" + +import logging +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QTextEdit, QGroupBox, QTabWidget, QWidget, QScrollArea, + QFrame, QSizePolicy +) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QFont, QIcon + +logger = logging.getLogger(__name__) + + +class UlimitGuidanceDialog(QDialog): + """Dialog to provide manual ulimit increase guidance when automatic methods fail""" + + def __init__(self, resource_manager=None, parent=None): + super().__init__(parent) + self.resource_manager = resource_manager + self.setWindowTitle("File Descriptor Limit Guidance") + self.setModal(True) + self.setMinimumSize(800, 600) + self.resize(900, 700) + + # Get current status and instructions + if self.resource_manager: + self.status = self.resource_manager.get_limit_status() + self.instructions = self.resource_manager.get_manual_increase_instructions() + else: + # Fallback if no resource manager provided + from jackify.backend.services.resource_manager import ResourceManager + temp_manager = ResourceManager() + self.status = temp_manager.get_limit_status() + self.instructions = temp_manager.get_manual_increase_instructions() + + self._setup_ui() + + # Auto-refresh status every few seconds + self.refresh_timer = QTimer() + self.refresh_timer.timeout.connect(self._refresh_status) + self.refresh_timer.start(3000) # Refresh every 3 seconds + + def _setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout() + self.setLayout(layout) + + # Title and current status + self._create_header(layout) + + # Main content with tabs + self._create_content_tabs(layout) + + # Action buttons + self._create_action_buttons(layout) + + # Apply styling + self._apply_styling() + + def _create_header(self, layout): + """Create header with current status""" + header_frame = QFrame() + header_frame.setFrameStyle(QFrame.StyledPanel) + header_layout = QVBoxLayout() + header_frame.setLayout(header_layout) + + # Title + title_label = QLabel("File Descriptor Limit Configuration") + title_font = QFont() + title_font.setPointSize(14) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignCenter) + header_layout.addWidget(title_label) + + # Status information + self._create_status_section(header_layout) + + layout.addWidget(header_frame) + + def _create_status_section(self, layout): + """Create current status display""" + status_layout = QHBoxLayout() + + # Current limits + current_label = QLabel(f"Current Limit: {self.status['current_soft']}") + target_label = QLabel(f"Target Limit: {self.status['target_limit']}") + max_label = QLabel(f"Maximum Possible: {self.status['max_possible']}") + + # Status indicator + if self.status['target_achieved']: + status_text = "✓ Optimal" + status_color = "#4caf50" # Green + elif self.status['can_increase']: + status_text = "⚠ Can Improve" + status_color = "#ff9800" # Orange + else: + status_text = "✗ Needs Manual Fix" + status_color = "#f44336" # Red + + self.status_label = QLabel(f"Status: {status_text}") + self.status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;") + + status_layout.addWidget(current_label) + status_layout.addWidget(target_label) + status_layout.addWidget(max_label) + status_layout.addStretch() + status_layout.addWidget(self.status_label) + + layout.addLayout(status_layout) + + def _create_content_tabs(self, layout): + """Create tabbed content with different guidance types""" + self.tab_widget = QTabWidget() + + # Quick Fix tab + self._create_quick_fix_tab() + + # Permanent Fix tab + self._create_permanent_fix_tab() + + # Troubleshooting tab + self._create_troubleshooting_tab() + + layout.addWidget(self.tab_widget) + + def _create_quick_fix_tab(self): + """Create quick/temporary fix tab""" + widget = QWidget() + layout = QVBoxLayout() + widget.setLayout(layout) + + # Explanation + explanation = QLabel( + "Quick fixes apply only to the current terminal session. " + "You'll need to run these commands each time you start Jackify from a new terminal." + ) + explanation.setWordWrap(True) + explanation.setStyleSheet("color: #666; font-style: italic; margin-bottom: 10px;") + layout.addWidget(explanation) + + # Commands group + commands_group = QGroupBox("Commands to Run") + commands_layout = QVBoxLayout() + commands_group.setLayout(commands_layout) + + # Command text + if 'temporary' in self.instructions['methods']: + temp_method = self.instructions['methods']['temporary'] + + commands_text = QTextEdit() + commands_text.setPlainText('\n'.join(temp_method['commands'])) + commands_text.setMaximumHeight(120) + commands_text.setFont(QFont("monospace")) + commands_layout.addWidget(commands_text) + + # Note + if 'note' in temp_method: + note_label = QLabel(f"Note: {temp_method['note']}") + note_label.setWordWrap(True) + note_label.setStyleSheet("color: #666; font-style: italic;") + commands_layout.addWidget(note_label) + + layout.addWidget(commands_group) + + # Current session test + test_group = QGroupBox("Test Current Session") + test_layout = QVBoxLayout() + test_group.setLayout(test_layout) + + test_label = QLabel("You can test if the commands worked by running:") + test_layout.addWidget(test_label) + + test_command = QTextEdit() + test_command.setPlainText("ulimit -n") + test_command.setMaximumHeight(40) + test_command.setFont(QFont("monospace")) + test_layout.addWidget(test_command) + + expected_label = QLabel(f"Expected result: {self.instructions['target_limit']} or higher") + expected_label.setStyleSheet("color: #666;") + test_layout.addWidget(expected_label) + + layout.addWidget(test_group) + + layout.addStretch() + + self.tab_widget.addTab(widget, "Quick Fix") + + def _create_permanent_fix_tab(self): + """Create permanent fix tab""" + widget = QWidget() + layout = QVBoxLayout() + widget.setLayout(layout) + + # Explanation + explanation = QLabel( + "Permanent fixes modify system configuration files and require administrator privileges. " + "Changes take effect after logout/login or system reboot." + ) + explanation.setWordWrap(True) + explanation.setStyleSheet("color: #666; font-style: italic; margin-bottom: 10px;") + layout.addWidget(explanation) + + # Distribution detection + distro_label = QLabel(f"Detected Distribution: {self.instructions['distribution'].title()}") + distro_label.setStyleSheet("font-weight: bold; color: #333;") + layout.addWidget(distro_label) + + # Commands group + commands_group = QGroupBox("System Configuration Commands") + commands_layout = QVBoxLayout() + commands_group.setLayout(commands_layout) + + # Warning + warning_label = QLabel( + "⚠️ 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) + warning_label.setStyleSheet("background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 8px; border-radius: 4px; color: #856404;") + commands_layout.addWidget(warning_label) + + # Command text + if 'permanent' in self.instructions['methods']: + perm_method = self.instructions['methods']['permanent'] + + commands_text = QTextEdit() + commands_text.setPlainText('\n'.join(perm_method['commands'])) + commands_text.setMinimumHeight(200) + commands_text.setFont(QFont("monospace")) + commands_layout.addWidget(commands_text) + + # Note + if 'note' in perm_method: + note_label = QLabel(f"Note: {perm_method['note']}") + note_label.setWordWrap(True) + note_label.setStyleSheet("color: #666; font-style: italic;") + commands_layout.addWidget(note_label) + + layout.addWidget(commands_group) + + # Verification group + verify_group = QGroupBox("Verification After Reboot/Re-login") + verify_layout = QVBoxLayout() + verify_group.setLayout(verify_layout) + + verify_label = QLabel("After rebooting or logging out and back in, verify the change:") + verify_layout.addWidget(verify_label) + + verify_command = QTextEdit() + verify_command.setPlainText("ulimit -n") + verify_command.setMaximumHeight(40) + verify_command.setFont(QFont("monospace")) + verify_layout.addWidget(verify_command) + + expected_label = QLabel(f"Expected result: {self.instructions['target_limit']} or higher") + expected_label.setStyleSheet("color: #666;") + verify_layout.addWidget(expected_label) + + layout.addWidget(verify_group) + + layout.addStretch() + + self.tab_widget.addTab(widget, "Permanent Fix") + + def _create_troubleshooting_tab(self): + """Create troubleshooting tab""" + widget = QWidget() + layout = QVBoxLayout() + widget.setLayout(layout) + + # Create scrollable area for troubleshooting content + scroll = QScrollArea() + scroll_widget = QWidget() + scroll_layout = QVBoxLayout() + scroll_widget.setLayout(scroll_layout) + + # Common issues + issues_group = QGroupBox("Common Issues and Solutions") + issues_layout = QVBoxLayout() + issues_group.setLayout(issues_layout) + + issues_text = """ +Issue: "Operation not permitted" when trying to increase limits
+Solution: You may need root privileges or the hard limit may be too low. Try the permanent fix method. + +Issue: Changes don't persist after closing terminal
+Solution: Use the permanent fix method to modify system configuration files. + +Issue: Still getting "too many open files" errors after increasing limits
+Solution: Some applications may need to be restarted to pick up the new limits. Try restarting Jackify. + +Issue: Can't increase above a certain number
+Solution: The hard limit may be set by system administrator or systemd. Check systemd service limits if applicable. + """ + + issues_label = QLabel(issues_text) + issues_label.setWordWrap(True) + issues_label.setTextFormat(Qt.RichText) + issues_layout.addWidget(issues_label) + + scroll_layout.addWidget(issues_group) + + # System information + sysinfo_group = QGroupBox("System Information") + sysinfo_layout = QVBoxLayout() + sysinfo_group.setLayout(sysinfo_layout) + + sysinfo_text = f""" +Current Soft Limit: {self.status['current_soft']}
+Current Hard Limit: {self.status['current_hard']}
+Target Limit: {self.status['target_limit']}
+Detected Distribution: {self.instructions['distribution']}
+Can Increase Automatically: {"Yes" if self.status['can_increase'] else "No"}
+Target Achieved: {"Yes" if self.status['target_achieved'] else "No"} + """ + + sysinfo_label = QLabel(sysinfo_text) + sysinfo_label.setWordWrap(True) + sysinfo_label.setTextFormat(Qt.RichText) + sysinfo_label.setFont(QFont("monospace", 9)) + sysinfo_layout.addWidget(sysinfo_label) + + scroll_layout.addWidget(sysinfo_group) + + # Additional resources + resources_group = QGroupBox("Additional Resources") + resources_layout = QVBoxLayout() + resources_group.setLayout(resources_layout) + + resources_text = """ +For more help:
+• Check your distribution's documentation for ulimit configuration
+• Search for "increase file descriptor limit [your_distribution]"
+• Consider asking on your distribution's support forums
+• Jackify documentation and issue tracker on GitHub + """ + + resources_label = QLabel(resources_text) + resources_label.setWordWrap(True) + resources_label.setTextFormat(Qt.RichText) + resources_layout.addWidget(resources_label) + + scroll_layout.addWidget(resources_group) + + scroll_layout.addStretch() + + scroll.setWidget(scroll_widget) + scroll.setWidgetResizable(True) + layout.addWidget(scroll) + + self.tab_widget.addTab(widget, "Troubleshooting") + + def _create_action_buttons(self, layout): + """Create action buttons""" + button_layout = QHBoxLayout() + + # Try Again button + self.try_again_btn = QPushButton("Try Automatic Fix Again") + self.try_again_btn.clicked.connect(self._try_automatic_fix) + self.try_again_btn.setEnabled(self.status['can_increase'] and not self.status['target_achieved']) + + # Refresh Status button + refresh_btn = QPushButton("Refresh Status") + refresh_btn.clicked.connect(self._refresh_status) + + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.accept) + close_btn.setDefault(True) + + button_layout.addWidget(self.try_again_btn) + button_layout.addWidget(refresh_btn) + button_layout.addStretch() + button_layout.addWidget(close_btn) + + layout.addLayout(button_layout) + + def _apply_styling(self): + """Apply dialog styling""" + self.setStyleSheet(""" + QDialog { + background-color: #f5f5f5; + } + QGroupBox { + font-weight: bold; + border: 2px solid #cccccc; + border-radius: 5px; + margin-top: 1ex; + padding-top: 10px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + } + QTextEdit { + background-color: #ffffff; + border: 1px solid #cccccc; + border-radius: 3px; + padding: 5px; + } + QPushButton { + background-color: #007acc; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #005a9e; + } + QPushButton:pressed { + background-color: #004175; + } + QPushButton:disabled { + background-color: #cccccc; + color: #666666; + } + """) + + def _try_automatic_fix(self): + """Try automatic fix again""" + if self.resource_manager: + success = self.resource_manager.apply_recommended_limits() + if success: + self._refresh_status() + from jackify.frontends.gui.services.message_service import MessageService + MessageService.information( + self, + "Success", + "File descriptor limits have been increased successfully!", + safety_level="low" + ) + else: + from jackify.frontends.gui.services.message_service import MessageService + MessageService.warning( + self, + "Fix Failed", + "Automatic fix failed. Please try the manual methods shown in the tabs above.", + safety_level="medium" + ) + + def _refresh_status(self): + """Refresh current status display""" + try: + if self.resource_manager: + self.status = self.resource_manager.get_limit_status() + else: + from jackify.backend.services.resource_manager import ResourceManager + temp_manager = ResourceManager() + self.status = temp_manager.get_limit_status() + + # Update status display in header + header_frame = self.layout().itemAt(0).widget() + if header_frame: + # Find and update status section + header_layout = header_frame.layout() + status_layout = header_layout.itemAt(1).layout() + + # Update individual labels + status_layout.itemAt(0).widget().setText(f"Current Limit: {self.status['current_soft']}") + status_layout.itemAt(1).widget().setText(f"Target Limit: {self.status['target_limit']}") + status_layout.itemAt(2).widget().setText(f"Maximum Possible: {self.status['max_possible']}") + + # Update status indicator + if self.status['target_achieved']: + status_text = "✓ Optimal" + status_color = "#4caf50" # Green + elif self.status['can_increase']: + status_text = "⚠ Can Improve" + status_color = "#ff9800" # Orange + else: + status_text = "✗ Needs Manual Fix" + status_color = "#f44336" # Red + + self.status_label.setText(f"Status: {status_text}") + self.status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;") + + # Update try again button + self.try_again_btn.setEnabled(self.status['can_increase'] and not self.status['target_achieved']) + + except Exception as e: + logger.warning(f"Error refreshing status: {e}") + + def closeEvent(self, event): + """Handle dialog close event""" + if hasattr(self, 'refresh_timer'): + self.refresh_timer.stop() + event.accept() + + +# Convenience function for easy use +def show_ulimit_guidance(parent=None, resource_manager=None): + """ + Show the ulimit guidance dialog + + Args: + parent: Parent widget for the dialog + resource_manager: Optional ResourceManager instance + + Returns: + Dialog result (QDialog.Accepted or QDialog.Rejected) + """ + dialog = UlimitGuidanceDialog(resource_manager, parent) + return dialog.exec() + + +if __name__ == "__main__": + # Test the dialog + import sys + from PySide6.QtWidgets import QApplication + + app = QApplication(sys.argv) + + # Create and show dialog + result = show_ulimit_guidance() + + sys.exit(result) \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/warning_dialog.py b/jackify/frontends/gui/dialogs/warning_dialog.py new file mode 100644 index 0000000..a91f261 --- /dev/null +++ b/jackify/frontends/gui/dialogs/warning_dialog.py @@ -0,0 +1,188 @@ +""" +Warning Dialog + +Custom warning dialog for destructive actions (e.g., deleting directory contents). +Matches Jackify theming and requires explicit user confirmation. +""" + +from pathlib import Path +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, QFrame, QSizePolicy, QTextEdit +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap, QIcon, QFont +from .. import shared_theme + +class WarningDialog(QDialog): + """ + Jackify-themed warning dialog for dangerous/destructive actions. + Requires user to type 'DELETE' to confirm. + """ + def __init__(self, warning_message: str, parent=None): + super().__init__(parent) + self.setWindowTitle("Warning!") + self.setModal(True) + # Increased height for better text display, scalable for 800p screens + self.setFixedSize(500, 440) + self.confirmed = False + self._setup_ui(warning_message) + + def _setup_ui(self, warning_message): + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + # Card background + card = QFrame(self) + card.setObjectName("warningCard") + card.setFrameShape(QFrame.StyledPanel) + card.setFrameShadow(QFrame.Raised) + card.setMinimumWidth(440) + card.setMinimumHeight(320) + card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + card_layout = QVBoxLayout(card) + card_layout.setSpacing(16) + card_layout.setContentsMargins(28, 28, 28, 28) + card.setStyleSheet( + "QFrame#warningCard { " + " background: #2d2323; " + " border-radius: 12px; " + " border: 2px solid #e67e22; " + "}" + ) + + # Warning icon + icon_label = QLabel() + icon_label.setAlignment(Qt.AlignCenter) + icon_label.setText("!") + icon_label.setStyleSheet( + "QLabel { " + " font-size: 36px; " + " font-weight: bold; " + " color: #e67e22; " + " margin-bottom: 4px; " + "}" + ) + card_layout.addWidget(icon_label) + + # Warning title + title_label = QLabel("Potentially Destructive Action!") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet( + "QLabel { " + " font-size: 20px; " + " font-weight: 600; " + " color: #e67e22; " + " margin-bottom: 2px; " + "}" + ) + card_layout.addWidget(title_label) + + # Warning message (use a scrollable text area for long messages) + message_text = QTextEdit() + message_text.setReadOnly(True) + message_text.setPlainText(warning_message) + message_text.setMinimumHeight(80) + message_text.setMaximumHeight(160) + message_text.setStyleSheet( + "QTextEdit { " + " font-size: 15px; " + " color: #e0e0e0; " + " background: transparent; " + " border: none; " + " line-height: 1.3; " + " margin-bottom: 6px; " + " max-width: 400px; " + " min-width: 200px; " + "}" + ) + card_layout.addWidget(message_text) + + # Confirmation entry + confirm_label = QLabel("Type 'DELETE' to confirm:") + confirm_label.setAlignment(Qt.AlignCenter) + confirm_label.setStyleSheet( + "QLabel { " + " font-size: 13px; " + " color: #e67e22; " + " margin-bottom: 2px; " + "}" + ) + card_layout.addWidget(confirm_label) + + self.confirm_edit = QLineEdit() + self.confirm_edit.setAlignment(Qt.AlignCenter) + self.confirm_edit.setPlaceholderText("DELETE") + self.confirm_edit.setStyleSheet( + "QLineEdit { " + " font-size: 15px; " + " border: 1px solid #e67e22; " + " border-radius: 6px; " + " padding: 6px; " + " background: #23272e; " + " color: #e67e22; " + "}" + ) + card_layout.addWidget(self.confirm_edit) + + # Action buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(12) + button_layout.addStretch() + + cancel_btn = QPushButton("Cancel") + cancel_btn.setFixedSize(120, 36) + cancel_btn.clicked.connect(self.reject) + cancel_btn.setStyleSheet( + "QPushButton { " + " background-color: #95a5a6; " + " color: white; " + " border: none; " + " border-radius: 4px; " + " font-weight: bold; " + " padding: 8px 16px; " + "} " + "QPushButton:hover { " + " background-color: #7f8c8d; " + "} " + "QPushButton:pressed { " + " background-color: #6c7b7d; " + "}" + ) + button_layout.addWidget(cancel_btn) + + confirm_btn = QPushButton("Proceed") + confirm_btn.setFixedSize(120, 36) + confirm_btn.clicked.connect(self._on_confirm) + confirm_btn.setStyleSheet( + "QPushButton { " + " background-color: #e67e22; " + " color: white; " + " border: none; " + " border-radius: 4px; " + " font-weight: bold; " + " padding: 8px 16px; " + "} " + "QPushButton:hover { " + " background-color: #d35400; " + "} " + "QPushButton:pressed { " + " background-color: #b34700; " + "}" + ) + button_layout.addWidget(confirm_btn) + button_layout.addStretch() + card_layout.addLayout(button_layout) + + layout.addStretch() + layout.addWidget(card, alignment=Qt.AlignCenter) + layout.addStretch() + + def _on_confirm(self): + if self.confirm_edit.text().strip().upper() == "DELETE": + self.confirmed = True + self.accept() + else: + self.confirm_edit.setText("") + self.confirm_edit.setPlaceholderText("Type DELETE to confirm") + self.confirm_edit.setStyleSheet(self.confirm_edit.styleSheet() + "QLineEdit { background: #3b2323; }") \ No newline at end of file diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py new file mode 100644 index 0000000..e0d54f9 --- /dev/null +++ b/jackify/frontends/gui/main.py @@ -0,0 +1,819 @@ +""" +Jackify GUI Frontend Main Application + +Main entry point for the Jackify GUI application using PySide6. +This replaces the legacy jackify_gui implementation with a refactored architecture. +""" + +import sys +import os +from pathlib import Path + +# Suppress xkbcommon locale errors (harmless but annoying) +os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.qpa.*=false;*.warning=false' +os.environ['QT_ENABLE_GLYPH_CACHE_WORKAROUND'] = '1' + +# Hidden diagnostic flag for debugging PyInstaller environment issues - must be first +if '--env-diagnostic' in sys.argv: + import json + from datetime import datetime + + print("🔍 PyInstaller Environment Diagnostic") + print("=" * 50) + + # Check if we're in PyInstaller + is_frozen = getattr(sys, 'frozen', False) + meipass = getattr(sys, '_MEIPASS', None) + + print(f"Frozen: {is_frozen}") + print(f"_MEIPASS: {meipass}") + + # Capture environment data + env_data = { + 'timestamp': datetime.now().isoformat(), + 'context': 'pyinstaller_internal', + 'frozen': is_frozen, + 'meipass': meipass, + 'python_executable': sys.executable, + 'working_directory': os.getcwd(), + 'sys_path': sys.path, + } + + # PyInstaller-specific environment variables + pyinstaller_vars = {} + for key, value in os.environ.items(): + if any(term in key.lower() for term in ['mei', 'pyinstaller', 'tmp']): + pyinstaller_vars[key] = value + + env_data['pyinstaller_vars'] = pyinstaller_vars + + # Check LD_LIBRARY_PATH + ld_path = os.environ.get('LD_LIBRARY_PATH', '') + if ld_path: + suspicious = [p for p in ld_path.split(':') if 'mei' in p.lower() or 'tmp' in p.lower()] + env_data['ld_library_path'] = ld_path + env_data['ld_library_path_suspicious'] = suspicious + + # Try to find jackify-engine from PyInstaller context + engine_paths = [] + if meipass: + meipass_path = Path(meipass) + potential_engine = meipass_path / "jackify" / "engine" / "jackify-engine" + if potential_engine.exists(): + engine_paths.append(str(potential_engine)) + + env_data['engine_paths_found'] = engine_paths + + # Output the results + print("\n📊 Environment Data:") + print(json.dumps(env_data, indent=2)) + + # Save to file + try: + output_file = Path.cwd() / "pyinstaller_env_capture.json" + with open(output_file, 'w') as f: + json.dump(env_data, f, indent=2) + print(f"\n💾 Data saved to: {output_file}") + except Exception as e: + print(f"\n❌ Could not save data: {e}") + + sys.exit(0) + +from jackify import __version__ as jackify_version + +if '--help' in sys.argv or '-h' in sys.argv: + print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n\nIf no options are given, the GUI will launch by default.\n""") + sys.exit(0) + +if '-v' in sys.argv or '--version' in sys.argv or '-V' in sys.argv: + print(f"Jackify version {jackify_version}") + sys.exit(0) + + +from jackify import __version__ + +# Add src directory to Python path +src_dir = Path(__file__).parent.parent.parent.parent +sys.path.insert(0, str(src_dir)) + +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QPushButton, + QStackedWidget, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QCheckBox, QSpinBox, QMessageBox, QGroupBox, QGridLayout, QFileDialog, QToolButton, QStyle +) +from PySide6.QtCore import Qt, QEvent +from PySide6.QtGui import QIcon +import json + +# Import backend services and models +from jackify.backend.models.configuration import SystemInfo +from jackify.backend.services.modlist_service import ModlistService +from jackify.frontends.gui.services.message_service import MessageService +from jackify.frontends.gui.shared_theme import DEBUG_BORDERS + +def debug_print(message): + """Print debug message only if debug mode is enabled""" + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if config_handler.get('debug_mode', False): + print(message) + +# Constants for styling and disclaimer +DISCLAIMER_TEXT = ( + "Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, " + "without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. " + "The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. " + "Please back up your data and use caution." +) + +MENU_ITEMS = [ + ("Modlist Tasks", "modlist_tasks"), + ("Tuxborn Automatic Installer", "tuxborn_installer"), + ("Hoolamike Tasks", "hoolamike_tasks"), + ("Additional Tasks", "additional_tasks"), + ("Exit Jackify", "exit_jackify"), +] + + +class FeaturePlaceholder(QWidget): + """Placeholder widget for features not yet implemented""" + + def __init__(self, stacked_widget=None): + super().__init__() + layout = QVBoxLayout() + + label = QLabel("[Feature screen placeholder]") + label.setAlignment(Qt.AlignCenter) + layout.addWidget(label) + + back_btn = QPushButton("Back to Main Menu") + if stacked_widget: + back_btn.clicked.connect(lambda: stacked_widget.setCurrentIndex(0)) + layout.addWidget(back_btn) + + self.setLayout(layout) + + +class SettingsDialog(QDialog): + def __init__(self, parent=None): + try: + super().__init__(parent) + from jackify.backend.handlers.config_handler import ConfigHandler + self.config_handler = ConfigHandler() + self._original_debug_mode = self.config_handler.get('debug_mode', False) + self.setWindowTitle("Settings") + self.setModal(True) + self.setMinimumWidth(750) + self.setStyleSheet("QDialog { background-color: #232323; color: #eee; } QPushButton:hover { background-color: #333; }") + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + # --- Resource Limits Section --- + resource_group = QGroupBox("Resource Limits") + resource_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + resource_layout = QGridLayout() + resource_group.setLayout(resource_layout) + resource_layout.setVerticalSpacing(4) + resource_layout.setHorizontalSpacing(8) + resource_layout.addWidget(self._bold_label("Resource"), 0, 0, 1, 1, Qt.AlignLeft) + resource_layout.addWidget(self._bold_label("Max Tasks"), 0, 1, 1, 1, Qt.AlignLeft) + self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json") + self.resource_settings = self._load_json(self.resource_settings_path) + self.resource_edits = {} + resource_row_index = 0 + for resource_row_index, (k, v) in enumerate(self.resource_settings.items(), start=1): + # Create resource label with optional inline checkbox for File Extractor + if k == "File Extractor": + # Create horizontal layout for File Extractor with inline checkbox + resource_row = QHBoxLayout() + resource_label = QLabel(f"{k}:", parent=self) + resource_row.addWidget(resource_label) + resource_row.addSpacing(10) # Add some spacing + + multithreading_checkbox = QCheckBox("Multithreading (Experimental)") + multithreading_checkbox.setChecked(v.get('_7zzMultiThread', 'off') == 'on') + multithreading_checkbox.setToolTip("Enables multithreaded file extraction using 7-Zip. May improve extraction speed on multi-core systems but could be less stable.") + multithreading_checkbox.setStyleSheet("color: #fff;") + resource_row.addWidget(multithreading_checkbox) + resource_row.addStretch() # Push checkbox to the left + + # Add the horizontal layout to the grid + resource_layout.addLayout(resource_row, resource_row_index, 0) + else: + resource_layout.addWidget(QLabel(f"{k}:", parent=self), resource_row_index, 0, 1, 1, Qt.AlignLeft) + + max_tasks_spin = QSpinBox() + max_tasks_spin.setMinimum(1) + max_tasks_spin.setMaximum(128) + max_tasks_spin.setValue(v.get('MaxTasks', 16)) + max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.") + max_tasks_spin.setFixedWidth(160) + resource_layout.addWidget(max_tasks_spin, resource_row_index, 1) + + # Store the widgets (checkbox for File Extractor, None for others) + if k == "File Extractor": + self.resource_edits[k] = (multithreading_checkbox, max_tasks_spin) + else: + self.resource_edits[k] = (None, max_tasks_spin) + # Bandwidth limiter row + self.app_settings_path = os.path.expanduser("~/.config/jackify/app_settings.json") + self.app_settings = self._load_json(self.app_settings_path) + self.bandwidth_spin = QSpinBox() + self.bandwidth_spin.setMinimum(0) + self.bandwidth_spin.setMaximum(1000000) + self.bandwidth_spin.setValue(self.app_settings.get("MaxDownloadSpeedKBps", 0)) + self.bandwidth_spin.setSuffix(" KB/s") + self.bandwidth_spin.setFixedWidth(160) + self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.") + bandwidth_note = QLabel("(0 = unlimited)") + bandwidth_note.setStyleSheet("color: #aaa; font-size: 10pt;") + # Create horizontal layout for bandwidth row + bandwidth_row = QHBoxLayout() + bandwidth_row.addWidget(self.bandwidth_spin) + bandwidth_row.addWidget(bandwidth_note) + bandwidth_row.addStretch() # Push to the left + + resource_layout.addWidget(QLabel("Bandwidth Limit:", parent=self), resource_row_index+1, 0, 1, 1, Qt.AlignLeft) + resource_layout.addLayout(bandwidth_row, resource_row_index+1, 1) + main_layout.addWidget(resource_group) + main_layout.addSpacing(12) + + # --- Debug & Diagnostics Section --- + debug_group = QGroupBox("Debug & Diagnostics") + debug_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + debug_layout = QVBoxLayout() + debug_group.setLayout(debug_layout) + self.debug_checkbox = QCheckBox("Enable debug mode (requires restart)") + # Load debug_mode from config + self.debug_checkbox.setChecked(self.config_handler.get('debug_mode', False)) + self.debug_checkbox.setToolTip("Enable verbose debug logging. Requires Jackify restart to take effect.") + self.debug_checkbox.setStyleSheet("color: #fff;") + debug_layout.addWidget(self.debug_checkbox) + main_layout.addWidget(debug_group) + main_layout.addSpacing(12) + + # --- Nexus API Key Section --- + api_group = QGroupBox("Nexus API Key") + api_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + api_layout = QHBoxLayout() + api_group.setLayout(api_layout) + self.api_key_edit = QLineEdit() + self.api_key_edit.setEchoMode(QLineEdit.Password) + api_key = self.config_handler.get_api_key() + if api_key: + self.api_key_edit.setText(api_key) + else: + self.api_key_edit.setText("") + self.api_key_edit.setToolTip("Your Nexus API Key (obfuscated by default, click Show to reveal)") + # Connect for immediate saving when text changes + self.api_key_edit.textChanged.connect(self._on_api_key_changed) + self.api_show_btn = QToolButton() + self.api_show_btn.setCheckable(True) + self.api_show_btn.setIcon(QIcon.fromTheme("view-visible")) + self.api_show_btn.setToolTip("Show or hide your API key") + self.api_show_btn.toggled.connect(self._toggle_api_key_visibility) + self.api_show_btn.setStyleSheet("") + clear_api_btn = QPushButton("Clear API Key") + clear_api_btn.clicked.connect(self._clear_api_key) + api_layout.addWidget(QLabel("Nexus API Key:")) + api_layout.addWidget(self.api_key_edit) + api_layout.addWidget(self.api_show_btn) + api_layout.addWidget(clear_api_btn) + main_layout.addWidget(api_group) + main_layout.addSpacing(12) + + # --- Directories & Paths Section --- + dir_group = QGroupBox("Directories & Paths") + dir_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + dir_layout = QFormLayout() + dir_group.setLayout(dir_layout) + self.install_dir_edit = QLineEdit(self.config_handler.get("modlist_install_base_dir", "")) + self.install_dir_edit.setToolTip("Default directory for modlist installations.") + self.install_dir_btn = QPushButton() + self.install_dir_btn.setIcon(QIcon.fromTheme("folder-open")) + self.install_dir_btn.setToolTip("Browse for directory") + self.install_dir_btn.setFixedWidth(32) + self.install_dir_btn.clicked.connect(lambda: self._pick_directory(self.install_dir_edit)) + install_dir_row = QHBoxLayout() + install_dir_row.addWidget(self.install_dir_edit) + install_dir_row.addWidget(self.install_dir_btn) + dir_layout.addRow(QLabel("Install Base Dir:"), install_dir_row) + self.download_dir_edit = QLineEdit(self.config_handler.get("modlist_downloads_base_dir", "")) + self.download_dir_edit.setToolTip("Default directory for modlist downloads.") + self.download_dir_btn = QPushButton() + self.download_dir_btn.setIcon(QIcon.fromTheme("folder-open")) + self.download_dir_btn.setToolTip("Browse for directory") + self.download_dir_btn.setFixedWidth(32) + self.download_dir_btn.clicked.connect(lambda: self._pick_directory(self.download_dir_edit)) + download_dir_row = QHBoxLayout() + download_dir_row.addWidget(self.download_dir_edit) + download_dir_row.addWidget(self.download_dir_btn) + dir_layout.addRow(QLabel("Downloads Base Dir:"), download_dir_row) + main_layout.addWidget(dir_group) + main_layout.addSpacing(12) + + # --- Save/Close/Help Buttons --- + btn_layout = QHBoxLayout() + self.help_btn = QPushButton("Help") + self.help_btn.setToolTip("Help/documentation coming soon!") + self.help_btn.clicked.connect(self._show_help) + btn_layout.addWidget(self.help_btn) + btn_layout.addStretch(1) + save_btn = QPushButton("Save") + close_btn = QPushButton("Close") + save_btn.clicked.connect(self._save) + close_btn.clicked.connect(self.reject) + btn_layout.addWidget(save_btn) + btn_layout.addWidget(close_btn) + main_layout.addSpacing(10) + main_layout.addLayout(btn_layout) + + # Set tab order for accessibility + # Get the first resource's widgets + first_resource_key = list(self.resource_edits.keys())[0] + first_multithreading, first_max_tasks = self.resource_edits[first_resource_key] + + # Set tab order starting with the first max tasks spinner + self.setTabOrder(first_max_tasks, self.bandwidth_spin) + self.setTabOrder(self.bandwidth_spin, self.debug_checkbox) + self.setTabOrder(self.debug_checkbox, self.api_key_edit) + self.setTabOrder(self.api_key_edit, self.api_show_btn) + self.setTabOrder(self.api_show_btn, clear_api_btn) + self.setTabOrder(clear_api_btn, self.install_dir_edit) + self.setTabOrder(self.install_dir_edit, self.install_dir_btn) + self.setTabOrder(self.install_dir_btn, self.download_dir_edit) + self.setTabOrder(self.download_dir_edit, self.download_dir_btn) + self.setTabOrder(self.download_dir_btn, save_btn) + self.setTabOrder(save_btn, close_btn) + + self.error_label = QLabel("") + self.error_label.setStyleSheet("color: #f55; font-weight: bold;") + main_layout.insertWidget(0, self.error_label) + except Exception as e: + print(f"[ERROR] Exception in SettingsDialog __init__: {e}") + import traceback + traceback.print_exc() + raise + + def _toggle_api_key_visibility(self, checked): + # Always use the same eyeball icon, only change color when toggled + eye_icon = QIcon.fromTheme("view-visible") + if not eye_icon.isNull(): + self.api_show_btn.setIcon(eye_icon) + self.api_show_btn.setText("") + else: + self.api_show_btn.setIcon(QIcon()) + self.api_show_btn.setText("\U0001F441") # 👁 + if checked: + self.api_key_edit.setEchoMode(QLineEdit.Normal) + self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }") # Jackify blue + else: + self.api_key_edit.setEchoMode(QLineEdit.Password) + self.api_show_btn.setStyleSheet("") + + def _pick_directory(self, line_edit): + dir_path = QFileDialog.getExistingDirectory(self, "Select Directory", line_edit.text() or os.path.expanduser("~")) + if dir_path: + line_edit.setText(dir_path) + + def _show_help(self): + from jackify.frontends.gui.services.message_service import MessageService + MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low") + + def _load_json(self, path): + if os.path.exists(path): + try: + with open(path, 'r') as f: + return json.load(f) + except Exception: + return {} + return {} + + def _save_json(self, path, data): + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + MessageService.warning(self, "Error", f"Failed to save {path}: {e}", safety_level="medium") + + def _clear_api_key(self): + self.api_key_edit.setText("") + self.config_handler.clear_api_key() + MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low") + + def _on_api_key_changed(self, text): + """Handle immediate API key saving when text changes""" + api_key = text.strip() + self.config_handler.save_api_key(api_key) + + def _save(self): + # Validate values + for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items(): + if max_tasks_spin.value() > 128: + self.error_label.setText(f"Invalid value for {k}: Max Tasks must be <= 128.") + return + if self.bandwidth_spin.value() > 1000000: + self.error_label.setText("Bandwidth limit must be <= 1,000,000 KB/s.") + return + self.error_label.setText("") + # Save resource settings + for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items(): + resource_data = self.resource_settings.get(k, {}) + resource_data['MaxTasks'] = max_tasks_spin.value() + # Only add multithreading setting for File Extractor + if k == "File Extractor" and multithreading_checkbox: + if multithreading_checkbox.isChecked(): + resource_data['_7zzMultiThread'] = 'on' + else: + # Remove the setting if unchecked (don't add 'off') + resource_data.pop('_7zzMultiThread', None) + self.resource_settings[k] = resource_data + self._save_json(self.resource_settings_path, self.resource_settings) + # Save debug mode to config + self.config_handler.set('debug_mode', self.debug_checkbox.isChecked()) + # Save bandwidth limit + self.app_settings["MaxDownloadSpeedKBps"] = self.bandwidth_spin.value() + self._save_json(self.app_settings_path, self.app_settings) + # Save API key + api_key = self.api_key_edit.text().strip() + self.config_handler.save_api_key(api_key) + # Save modlist base dirs + self.config_handler.set("modlist_install_base_dir", self.install_dir_edit.text().strip()) + self.config_handler.set("modlist_downloads_base_dir", self.download_dir_edit.text().strip()) + self.config_handler.save_config() + # Check if debug mode changed and prompt for restart + new_debug_mode = self.debug_checkbox.isChecked() + if new_debug_mode != self._original_debug_mode: + reply = MessageService.question(self, "Restart Required", "Debug mode change requires a restart. Restart Jackify now?", safety_level="low") + if reply == QMessageBox.Yes: + import os, sys + if getattr(sys, 'frozen', False): + # PyInstaller bundle: safe to restart + self.accept() + os.execv(sys.executable, [sys.executable] + sys.argv) + return + else: + # Dev mode: show message instead of auto-restart + MessageService.information(self, "Manual Restart Required", "Please restart Jackify manually to apply debug mode changes.", safety_level="low") + self.accept() + return + MessageService.information(self, "Settings Saved", "Settings have been saved successfully.", safety_level="low") + self.accept() + + def _bold_label(self, text): + label = QLabel(text) + label.setStyleSheet("font-weight: bold; color: #fff;") + return label + + +class JackifyMainWindow(QMainWindow): + """Main window for Jackify GUI application""" + + def __init__(self, dev_mode=False): + super().__init__() + self.setWindowTitle("Jackify") + self.setMinimumSize(1400, 950) + self.resize(1400, 900) + + # Initialize backend services + self._initialize_backend() + + # Set up UI + self._setup_ui(dev_mode=dev_mode) + + # Set up cleanup + QApplication.instance().aboutToQuit.connect(self.cleanup_processes) + + def _initialize_backend(self): + """Initialize backend services for direct use (no subprocess)""" + # Determine system info + self.system_info = SystemInfo(is_steamdeck=self._is_steamdeck()) + + # Apply resource limits for optimal operation + self._apply_resource_limits() + + # Initialize backend services + self.backend_services = { + 'modlist_service': ModlistService(self.system_info) + } + + # Initialize GUI services + self.gui_services = {} + + # Initialize protontricks detection service + from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService + self.protontricks_service = ProtontricksDetectionService(steamdeck=self.system_info.is_steamdeck) + + debug_print(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}") + + def _is_steamdeck(self): + """Check if running on Steam Deck""" + try: + if os.path.exists("/etc/os-release"): + with open("/etc/os-release", "r") as f: + content = f.read() + if "steamdeck" in content: + return True + return False + except Exception: + return False + + def _apply_resource_limits(self): + """Apply recommended resource limits for optimal Jackify operation""" + try: + from jackify.backend.services.resource_manager import ResourceManager + + resource_manager = ResourceManager() + success = resource_manager.apply_recommended_limits() + + if success: + status = resource_manager.get_limit_status() + if status['target_achieved']: + debug_print(f"Resource limits optimized: file descriptors set to {status['current_soft']}") + else: + print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})") + else: + # Log the issue but don't block startup + status = resource_manager.get_limit_status() + print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}") + + # Check if debug mode is enabled for additional info + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if config_handler.get('debug_mode', False): + instructions = resource_manager.get_manual_increase_instructions() + print(f"Manual increase instructions available for {instructions['distribution']}") + + except Exception as e: + # Don't block startup on resource management errors + print(f"Warning: Error applying resource limits: {e}") + + def _setup_ui(self, dev_mode=False): + """Set up the user interface""" + # Create stacked widget for screen navigation + self.stacked_widget = QStackedWidget() + + # Create screens using refactored codebase + from jackify.frontends.gui.screens import ( + MainMenu, TuxbornInstallerScreen, ModlistTasksScreen, + InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen + ) + + self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode) + self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget) + + self.modlist_tasks_screen = ModlistTasksScreen( + stacked_widget=self.stacked_widget, + main_menu_index=0, + dev_mode=dev_mode + ) + self.tuxborn_screen = TuxbornInstallerScreen( + stacked_widget=self.stacked_widget, + main_menu_index=0 + ) + self.install_modlist_screen = InstallModlistScreen( + stacked_widget=self.stacked_widget, + main_menu_index=3 + ) + self.configure_new_modlist_screen = ConfigureNewModlistScreen( + stacked_widget=self.stacked_widget, + main_menu_index=3 + ) + self.configure_existing_modlist_screen = ConfigureExistingModlistScreen( + stacked_widget=self.stacked_widget, + main_menu_index=3 + ) + + # Add screens to stacked widget + self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu + self.stacked_widget.addWidget(self.tuxborn_screen) # Index 1: Tuxborn Installer + self.stacked_widget.addWidget(self.feature_placeholder) # Index 2: Placeholder + self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 3: Modlist Tasks + self.stacked_widget.addWidget(self.install_modlist_screen) # Index 4: Install Modlist + self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 5: Configure New + self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 6: Configure Existing + + # Add debug tracking for screen changes + self.stacked_widget.currentChanged.connect(self._debug_screen_change) + + # --- Persistent Bottom Bar --- + bottom_bar = QWidget() + bottom_bar_layout = QHBoxLayout() + bottom_bar_layout.setContentsMargins(10, 2, 10, 2) + bottom_bar_layout.setSpacing(0) + bottom_bar.setLayout(bottom_bar_layout) + bottom_bar.setFixedHeight(32) + bottom_bar_style = "background-color: #181818; border-top: 1px solid #222;" + if DEBUG_BORDERS: + bottom_bar_style += " border: 2px solid lime;" + bottom_bar.setStyleSheet(bottom_bar_style) + + # Version label (left) + version_label = QLabel(f"Jackify v{__version__}") + version_label.setStyleSheet("color: #bbb; font-size: 13px;") + bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft) + + # Spacer + bottom_bar_layout.addStretch(1) + + # Settings button (right) + settings_btn = QLabel('Settings') + settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;") + settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction) + settings_btn.setOpenExternalLinks(False) + settings_btn.linkActivated.connect(self.open_settings_dialog) + bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight) + + # --- Main Layout --- + central_widget = QWidget() + main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(self.stacked_widget, stretch=1) # Screen takes all available space + main_layout.addWidget(bottom_bar) # Bottom bar stays at bottom + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + + # Start with main menu + self.stacked_widget.setCurrentIndex(0) + + # Check for protontricks after UI is set up + self._check_protontricks_on_startup() + + def _debug_screen_change(self, index): + """Debug method to track screen changes""" + # Only show debug info if debug mode is enabled + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if not config_handler.get('debug_mode', False): + return + + screen_names = { + 0: "Main Menu", + 1: "Tuxborn Installer", + 2: "Feature Placeholder", + 3: "Modlist Tasks Menu", + 4: "Install Modlist Screen", + 5: "Configure New Modlist", + 6: "Configure Existing Modlist" + } + screen_name = screen_names.get(index, f"Unknown Screen (Index {index})") + widget = self.stacked_widget.widget(index) + widget_class = widget.__class__.__name__ if widget else "None" + # Only print screen change debug to stderr to avoid workflow log pollution + import sys + print(f"[DEBUG] Screen changed to Index {index}: {screen_name} (Widget: {widget_class})", file=sys.stderr) + + # Additional debug for the install modlist screen + if index == 4: + print(f" Install Modlist Screen details:", file=sys.stderr) + print(f" - Widget type: {type(widget)}", file=sys.stderr) + print(f" - Widget file: {widget.__class__.__module__}", file=sys.stderr) + if hasattr(widget, 'windowTitle'): + print(f" - Window title: {widget.windowTitle()}", file=sys.stderr) + if hasattr(widget, 'layout'): + layout = widget.layout() + if layout: + print(f" - Layout type: {type(layout)}", file=sys.stderr) + print(f" - Layout children count: {layout.count()}", file=sys.stderr) + + def _check_protontricks_on_startup(self): + """Check for protontricks installation on startup""" + try: + is_installed, installation_type, details = self.protontricks_service.detect_protontricks() + + if not is_installed: + print(f"Protontricks not found: {details}") + # Show error dialog + from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog + dialog = ProtontricksErrorDialog(self.protontricks_service, self) + result = dialog.exec() + + if result == QDialog.Rejected: + # User chose to exit + print("User chose to exit due to missing protontricks") + sys.exit(1) + else: + debug_print(f"Protontricks detected: {details}") + + except Exception as e: + print(f"Error checking protontricks: {e}") + # Continue anyway - don't block startup on detection errors + + def cleanup_processes(self): + """Clean up any running processes before closing""" + try: + # Clean up GUI services + for service in self.gui_services.values(): + if hasattr(service, 'cleanup'): + service.cleanup() + + # Clean up screen processes + screens = [ + self.modlist_tasks_screen, self.tuxborn_screen, self.install_modlist_screen, + self.configure_new_modlist_screen, self.configure_existing_modlist_screen + ] + for screen in screens: + if hasattr(screen, 'cleanup_processes'): + screen.cleanup_processes() + elif hasattr(screen, 'cleanup'): + screen.cleanup() + + # Final safety net: kill any remaining jackify-engine processes + try: + import subprocess + subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True) + except Exception: + pass # pkill might fail if no processes found, which is fine + + except Exception as e: + print(f"Error during cleanup: {e}") + + def closeEvent(self, event): + """Handle window close event""" + self.cleanup_processes() + event.accept() + + def open_settings_dialog(self): + try: + dlg = SettingsDialog(self) + dlg.exec() + except Exception as e: + print(f"[ERROR] Exception in open_settings_dialog: {e}") + import traceback + traceback.print_exc() + + +def resource_path(relative_path): + if hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS, relative_path) + return os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) + + +def main(): + """Main entry point for the GUI application""" + # Check for CLI mode argument + if len(sys.argv) > 1 and '--cli' in sys.argv: + # Launch CLI frontend instead of GUI + try: + from jackify.frontends.cli.__main__ import main as cli_main + print("CLI mode detected - switching to CLI frontend") + return cli_main() + except ImportError as e: + print(f"Error importing CLI frontend: {e}") + print("CLI mode not available. Falling back to GUI mode.") + + # Load config and set debug mode if needed + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + # Command-line --debug always takes precedence + if '--debug' in sys.argv or '-d' in sys.argv: + debug_mode = True + import logging + if debug_mode: + logging.getLogger().setLevel(logging.DEBUG) + print("[Jackify] Debug mode enabled (from config or CLI)") + else: + logging.getLogger().setLevel(logging.WARNING) + + dev_mode = '--dev' in sys.argv + + # Launch GUI application + from PySide6.QtGui import QIcon + app = QApplication(sys.argv) + + # Global cleanup function for signal handling + def emergency_cleanup(): + debug_print("Cleanup: terminating jackify-engine processes") + try: + import subprocess + subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True) + except Exception: + pass + + # Set up signal handlers for graceful shutdown + import signal + def signal_handler(sig, frame): + print(f"Received signal {sig}, cleaning up...") + emergency_cleanup() + app.quit() + + signal.signal(signal.SIGINT, signal_handler) # Ctrl+C + signal.signal(signal.SIGTERM, signal_handler) # System shutdown + + # Set the application icon + icon_path = resource_path('assets/JackifyLogo_256.png') + app.setWindowIcon(QIcon(icon_path)) + window = JackifyMainWindow(dev_mode=dev_mode) + window.show() + + # Ensure cleanup on exit + import atexit + atexit.register(emergency_cleanup) + + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/jackify/frontends/gui/screens/__init__.py b/jackify/frontends/gui/screens/__init__.py new file mode 100644 index 0000000..6486cf1 --- /dev/null +++ b/jackify/frontends/gui/screens/__init__.py @@ -0,0 +1,21 @@ +""" +GUI Screens Module + +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 +from .configure_existing_modlist import ConfigureExistingModlistScreen + +__all__ = [ + 'MainMenu', + 'TuxbornInstallerScreen', + 'ModlistTasksScreen', + 'InstallModlistScreen', + 'ConfigureNewModlistScreen', + 'ConfigureExistingModlistScreen' +] \ No newline at end of file diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py new file mode 100644 index 0000000..9ba67da --- /dev/null +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -0,0 +1,710 @@ +# Copy of ConfigureNewModlistScreen, adapted for existing modlists +from PySide6.QtWidgets import * +from PySide6.QtCore import * +from PySide6.QtGui import * +from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS +from ..utils import ansi_to_html +import os +import subprocess +import sys +import threading +import time +from jackify.backend.handlers.shortcut_handler import ShortcutHandler +import traceback +import signal +from jackify.backend.core.modlist_operations import get_jackify_engine_path +from jackify.backend.handlers.subprocess_utils import ProcessManager +from jackify.backend.services.api_key_service import APIKeyService +from jackify.backend.services.resolution_service import ResolutionService +from jackify.backend.handlers.config_handler import ConfigHandler +from ..dialogs import SuccessDialog + +def debug_print(message): + """Print debug message only if debug mode is enabled""" + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if config_handler.get('debug_mode', False): + print(message) + +class ConfigureExistingModlistScreen(QWidget): + steam_restart_finished = Signal(bool, str) + def __init__(self, stacked_widget=None, main_menu_index=0): + super().__init__() + debug_print("DEBUG: ConfigureExistingModlistScreen __init__ called") + 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) + + # --- Detect Steam Deck --- + steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower() + self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck) + + # Initialize services early + from jackify.backend.services.api_key_service import APIKeyService + from jackify.backend.services.resolution_service import ResolutionService + from jackify.backend.handlers.config_handler import ConfigHandler + self.api_key_service = APIKeyService() + self.resolution_service = ResolutionService() + self.config_handler = ConfigHandler() + + # --- Fetch shortcuts for ModOrganizer.exe using existing backend functionality --- + # Use existing discover_executable_shortcuts which already filters by protontricks availability + from jackify.backend.handlers.modlist_handler import ModlistHandler + + # Initialize modlist handler with empty config dict to use default initialization + modlist_handler = ModlistHandler({}) + discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe") + + # Convert to shortcut_handler format for UI compatibility + self.mo2_shortcuts = [] + for modlist in discovered_modlists: + # Convert discovered modlist format to shortcut format + shortcut = { + 'AppName': modlist.get('name', 'Unknown'), + 'AppID': modlist.get('appid', ''), + 'StartDir': modlist.get('path', ''), + 'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe" + } + self.mo2_shortcuts.append(shortcut) + # --- UI Layout --- + main_overall_vbox = QVBoxLayout(self) + main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + # --- Header (title, description) --- + header_layout = QVBoxLayout() + header_layout.setSpacing(1) # Reduce spacing between title and description + title = QLabel("Configure Existing Modlist") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") + title.setAlignment(Qt.AlignHCenter) + title.setMaximumHeight(30) # Force compact height + header_layout.addWidget(title) + desc = QLabel( + "This screen allows you to configure an existing modlist in Jackify. " + "Select your Steam shortcut for ModOrganizer.exe, set your resolution, and complete post-install configuration." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(40) # Force compact height for description + header_layout.addWidget(desc) + header_widget = QWidget() + header_widget.setLayout(header_layout) + header_widget.setMaximumHeight(75) # Match other screens + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + header_widget.setToolTip("HEADER_SECTION") + main_overall_vbox.addWidget(header_widget) + # --- Upper section: shortcut selector (left) + process monitor (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + # --- [Options] header (moved here for alignment) --- + options_header = QLabel("[Options]") + options_header.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;") + options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + user_config_vbox.addWidget(options_header) + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) # Reduced from 8 to 6 for better readability + form_grid.setContentsMargins(0, 0, 0, 0) + # --- Shortcut selector --- + shortcut_label = QLabel("Select Modlist:") + self.shortcut_combo = QComboBox() + self.shortcut_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.shortcut_combo.addItem("Please Select...") + self.shortcut_map = [] + for shortcut in self.mo2_shortcuts: + display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})" + self.shortcut_combo.addItem(display) + self.shortcut_map.append(shortcut) + + # Add refresh button next to dropdown + refresh_btn = QPushButton("↻") + refresh_btn.setToolTip("Refresh modlist list") + refresh_btn.setFixedSize(30, 30) + refresh_btn.clicked.connect(self.refresh_modlist_list) + + # Create horizontal layout for dropdown and refresh button + shortcut_hbox = QHBoxLayout() + shortcut_hbox.addWidget(self.shortcut_combo) + shortcut_hbox.addWidget(refresh_btn) + shortcut_hbox.setSpacing(4) + shortcut_hbox.setStretch(0, 1) # Make dropdown expand + + form_grid.addWidget(shortcut_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(shortcut_hbox, 0, 1) + # --- Info message under shortcut selector --- + info_label = QLabel("If you don't see your modlist entry in this list, please ensure you have added it to Steam as a non-steam game, set a proton version in properties, and have started the modlist Steam entry at least once. You can also click the refresh button (↻) to update the list.") + info_label.setWordWrap(True) + form_grid.addWidget(info_label, 1, 0, 1, 2) + # --- Resolution selector --- + resolution_label = QLabel("Resolution:") + self.resolution_combo = QComboBox() + self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.resolution_combo.addItem("Leave unchanged") + self.resolution_combo.addItems([ + "1280x720", + "1280x800 (Steam Deck)", + "1366x768", + "1440x900", + "1600x900", + "1600x1200", + "1680x1050", + "1920x1080", + "1920x1200", + "2048x1152", + "2560x1080", + "2560x1440", + "2560x1600", + "3440x1440", + "3840x1600", + "3840x2160", + "3840x2400", + "5120x1440", + "5120x2160", + "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() + is_steam_deck = False + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + is_steam_deck = True + except Exception: + pass + 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) + debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + elif is_steam_deck: + # Set default to 1280x800 (Steam Deck) + combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] + if "1280x800 (Steam Deck)" in combo_items: + self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) + else: + self.resolution_combo.setCurrentIndex(0) + # Otherwise, default is 'Leave unchanged' (index 0) + form_section_widget = QWidget() + form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + form_section_widget.setLayout(form_grid) + form_section_widget.setMinimumHeight(160) # Reduced to match compact form + form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown + if self.debug: + form_section_widget.setStyleSheet("border: 2px solid blue;") + form_section_widget.setToolTip("FORM_SECTION") + user_config_vbox.addWidget(form_section_widget) + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + self.start_btn = QPushButton("Start Configuration") + btn_row.addWidget(self.start_btn) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.go_back) + btn_row.addWidget(cancel_btn) + user_config_widget = QWidget() + user_config_widget.setLayout(user_config_vbox) + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + user_config_widget.setToolTip("USER_CONFIG_WIDGET") + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") + self.process_monitor_heading = QLabel("[Process Monitor]") + self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") + self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor_heading) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + process_monitor_widget.setToolTip("PROCESS_MONITOR") + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(process_monitor_widget, stretch=9) + upper_hbox.setAlignment(Qt.AlignTop) + upper_section_widget = QWidget() + upper_section_widget.setLayout(upper_hbox) + upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown + if self.debug: + upper_section_widget.setStyleSheet("border: 2px solid green;") + upper_section_widget.setToolTip("UPPER_SECTION") + main_overall_vbox.addWidget(upper_section_widget) + # Remove spacing - console should expand to fill available space + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing + self.console.setMaximumHeight(1000) # Allow growth when space available + self.console.setFontFamily('monospace') + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + self.console.setToolTip("CONSOLE") + + # Set up scroll tracking for professional auto-scroll behavior + self._setup_scroll_tracking() + + # Wrap button row in widget for debug borders + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + btn_row_widget.setToolTip("BUTTON_ROW") + + # Create a container that holds console + button row with proper spacing + console_and_buttons_widget = QWidget() + console_and_buttons_layout = QVBoxLayout() + console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) + console_and_buttons_layout.setSpacing(8) # Small gap between console and buttons + + console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space + console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container + + console_and_buttons_widget.setLayout(console_and_buttons_layout) + if self.debug: + console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") + console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") + main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space + self.setLayout(main_overall_vbox) + self.process = None + self.log_timer = None + self.last_log_pos = 0 + self.top_timer = QTimer(self) + self.top_timer.timeout.connect(self.update_top_panel) + self.top_timer.start(2000) + self.start_btn.clicked.connect(self.validate_and_start_configure) + self.steam_restart_finished.connect(self._on_steam_restart_finished) + + # Scroll tracking for professional auto-scroll behavior + self._user_manually_scrolled = False + self._was_at_bottom = True + + # Time tracking for workflow completion + self._workflow_start_time = None + + def resizeEvent(self, event): + """Handle window resize to prioritize form over console""" + super().resizeEvent(event) + self._adjust_console_for_form_priority() + + def _adjust_console_for_form_priority(self): + """Console now dynamically fills available space with stretch=1, no manual calculation needed""" + # The console automatically fills remaining space due to stretch=1 in the layout + # Remove any fixed height constraints to allow natural stretching + self.console.setMaximumHeight(16777215) # Reset to default maximum + self.console.setMinimumHeight(50) # Keep minimum height for usability + + def _setup_scroll_tracking(self): + """Set up scroll tracking for professional auto-scroll behavior""" + scrollbar = self.console.verticalScrollBar() + scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) + scrollbar.sliderReleased.connect(self._on_scrollbar_released) + scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) + + def _on_scrollbar_pressed(self): + """User started manually scrolling""" + self._user_manually_scrolled = True + + def _on_scrollbar_released(self): + """User finished manually scrolling""" + self._user_manually_scrolled = False + + def _on_scrollbar_value_changed(self): + """Track if user is at bottom of scroll area""" + scrollbar = self.console.verticalScrollBar() + # Use tolerance to account for rounding and rapid updates + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + # If user manually scrolls to bottom, reset manual scroll flag + if self._was_at_bottom and self._user_manually_scrolled: + # Small delay to allow user to scroll away if they want + from PySide6.QtCore import QTimer + QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) + + def _reset_manual_scroll_if_at_bottom(self): + """Reset manual scroll flag if user is still at bottom after delay""" + scrollbar = self.console.verticalScrollBar() + if scrollbar.value() >= scrollbar.maximum() - 1: + self._user_manually_scrolled = False + + def _safe_append_text(self, text): + """Append text with professional auto-scroll behavior""" + # Write all messages to log file + self._write_to_log_file(text) + + scrollbar = self.console.verticalScrollBar() + # Check if user was at bottom BEFORE adding text + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + + # Add the text + self.console.append(text) + + # Auto-scroll if user was at bottom and hasn't manually scrolled + # Re-check bottom state after text addition for better reliability + if (was_at_bottom and not self._user_manually_scrolled) or \ + (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): + scrollbar.setValue(scrollbar.maximum()) + # Ensure user can still manually scroll up during rapid updates + if scrollbar.value() == scrollbar.maximum(): + self._was_at_bottom = True + + def _write_to_log_file(self, message): + """Write message to workflow log file with timestamp""" + try: + from datetime import datetime + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(self.modlist_log_path, 'a', encoding='utf-8') as f: + f.write(f"[{timestamp}] {message}\n") + except Exception: + # Logging should never break the workflow + pass + + def validate_and_start_configure(self): + # Rotate log file at start of each workflow run (keep 5 backups) + from jackify.backend.handlers.logging_handler import LoggingHandler + from pathlib import Path + log_handler = LoggingHandler() + log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) + + # 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") + return + shortcut = self.shortcut_map[idx] + modlist_name = shortcut.get('AppName', '') + install_dir = 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") + return + resolution = self.resolution_combo.currentText() + # Handle resolution saving + if resolution and resolution != "Leave unchanged": + success = self.resolution_service.save_resolution(resolution) + if success: + debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + else: + debug_print("DEBUG: Failed to save resolution") + else: + # Clear saved resolution if "Leave unchanged" is selected + if self.resolution_service.has_saved_resolution(): + self.resolution_service.clear_saved_resolution() + debug_print("DEBUG: Saved resolution cleared") + # Start the workflow (no shortcut creation needed) + self.start_workflow(modlist_name, install_dir, resolution) + + def start_workflow(self, modlist_name, install_dir, resolution): + """Start the configuration workflow using backend service directly""" + try: + # Start time tracking + self._workflow_start_time = time.time() + + self._safe_append_text("[Jackify] Starting post-install configuration...") + + # Create configuration thread using backend service + from PySide6.QtCore import QThread, Signal + + class ConfigurationThread(QThread): + progress_update = Signal(str) + configuration_complete = Signal(bool, str, str) + error_occurred = Signal(str) + + def __init__(self, modlist_name, install_dir, resolution): + super().__init__() + self.modlist_name = modlist_name + self.install_dir = install_dir + self.resolution = resolution + + def run(self): + try: + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + import os + + # Initialize backend service + system_info = SystemInfo(is_steamdeck=False) # TODO: Detect Steam Deck + modlist_service = ModlistService(system_info) + + # Create modlist context for existing modlist configuration + mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe") + modlist_context = ModlistContext( + name=self.modlist_name, + install_dir=Path(self.install_dir), + download_dir=Path(self.install_dir).parent / 'Downloads', # Default + game_type='skyrim', # Default for now - TODO: detect from modlist + nexus_api_key='', # Not needed for configuration-only + modlist_value='', # Not needed for existing modlist + modlist_source='existing', + skip_confirmation=True + ) + + # For existing modlists, add resolution if specified + if self.resolution != "Leave unchanged": + modlist_context.resolution = self.resolution.split()[0] + + # Define callbacks + def progress_callback(message): + self.progress_update.emit(message) + + def completion_callback(success, message, modlist_name): + self.configuration_complete.emit(success, message, modlist_name) + + def manual_steps_callback(modlist_name, retry_count): + # Existing modlists shouldn't need manual steps, but handle gracefully + self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})") + + # Call the working configuration service method + self.progress_update.emit("Starting existing modlist configuration...") + + # For existing modlists, call configure_modlist_post_steam directly + # since Steam setup and manual steps should already be done + success = modlist_service.configure_modlist_post_steam( + context=modlist_context, + progress_callback=progress_callback, + manual_steps_callback=manual_steps_callback, + completion_callback=completion_callback + ) + + if not success: + self.error_occurred.emit("Configuration failed - check logs for details") + + except Exception as e: + import traceback + error_msg = f"Configuration error: {e}\n{traceback.format_exc()}" + self.error_occurred.emit(error_msg) + + # Create and start the configuration thread + self.config_thread = ConfigurationThread(modlist_name, install_dir, resolution) + self.config_thread.progress_update.connect(self._safe_append_text) + self.config_thread.configuration_complete.connect(self.on_configuration_complete) + self.config_thread.error_occurred.connect(self.on_configuration_error) + self.config_thread.start() + + except Exception as e: + self._safe_append_text(f"[ERROR] Failed to start configuration: {e}") + MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium") + + def on_configuration_complete(self, success, message, modlist_name): + """Handle configuration completion""" + if success: + # Calculate time taken + time_taken = self._calculate_time_taken() + + # Show success dialog with celebration + success_dialog = SuccessDialog( + modlist_name=modlist_name, + workflow_type="configure_existing", + time_taken=time_taken, + game_name=getattr(self, '_current_game_name', None), + parent=self + ) + success_dialog.show() + else: + self._safe_append_text(f"Configuration failed: {message}") + MessageService.critical(self, "Configuration Failed", + f"Configuration failed: {message}", safety_level="medium") + + def on_configuration_error(self, error_message): + """Handle configuration error""" + self._safe_append_text(f"Configuration error: {error_message}") + MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") + + def show_manual_steps_dialog(self, extra_warning=""): + modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist" + msg = ( + f"Manual Proton Setup Required for {modlist_name}
" + "After Steam restarts, complete the following steps in Steam:
" + f"1. Locate the '{modlist_name}' entry in your Steam Library
" + "2. Right-click and select 'Properties'
" + "3. Switch to the 'Compatibility' tab
" + "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" + "5. Select 'Proton - Experimental' from the dropdown menu
" + "6. Close the Properties window
" + f"7. Launch '{modlist_name}' from your Steam Library
" + "8. Wait for Wabbajack to download its files and fully load
" + "9. Once Wabbajack has fully loaded, CLOSE IT completely and return here
" + "
Once you have completed ALL the steps above, click OK to continue." + f"{extra_warning}" + ) + reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") + if reply == QMessageBox.Yes: + if self.config_process and self.config_process.state() == QProcess.Running: + self.config_process.write(b'\n') + self.config_process.waitForBytesWritten(1000) + self._config_prompt_state = None + self._manual_steps_buffer = [] + else: + # User clicked Cancel or closed the dialog - cancel the workflow + self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.") + # Terminate the configuration process + 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) + self.cancel_btn.setVisible(True) + + def show_next_steps_dialog(self, message): + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication + dlg = QDialog(self) + dlg.setWindowTitle("Next Steps") + dlg.setModal(True) + layout = QVBoxLayout(dlg) + label = QLabel(message) + label.setWordWrap(True) + layout.addWidget(label) + btn_row = QHBoxLayout() + btn_return = QPushButton("Return") + btn_exit = QPushButton("Exit") + btn_row.addWidget(btn_return) + btn_row.addWidget(btn_exit) + layout.addLayout(btn_row) + def on_return(): + dlg.accept() + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(0) + def on_exit(): + QApplication.quit() + btn_return.clicked.connect(on_return) + btn_exit.clicked.connect(on_exit) + dlg.exec() + + def go_back(self): + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(self.main_menu_index) + + def update_top_panel(self): + try: + result = subprocess.run([ + "ps", "-eo", "pcpu,pmem,comm,args" + ], stdout=subprocess.PIPE, text=True, timeout=2) + lines = result.stdout.splitlines() + header = "CPU%\tMEM%\tCOMMAND" + filtered = [header] + process_rows = [] + for line in lines[1:]: + line_lower = line.lower() + # Include jackify-engine and related heavy processes + heavy_processes = ( + "jackify-engine" in line_lower or "7zz" in line_lower or + "compressonator" in line_lower or "wine" in line_lower or + "wine64" in line_lower or "protontricks" in line_lower + ) + # Include Python processes running configure-modlist command + configure_processes = ( + "python" in line_lower and "configure-modlist" in line_lower + ) + # Include configuration threads that might be running + config_threads = ( + hasattr(self, 'config_thread') and + self.config_thread and + self.config_thread.isRunning() and + ("python" in line_lower or "jackify" in line_lower) + ) + + if (heavy_processes or configure_processes or config_threads) and "jackify-gui.py" not in line_lower: + cols = line.strip().split(None, 3) + if len(cols) >= 3: + process_rows.append(cols) + process_rows.sort(key=lambda x: float(x[0]), reverse=True) + for cols in process_rows: + filtered.append('\t'.join(cols)) + if len(filtered) == 1: + filtered.append("[No Jackify-related processes found]") + self.process_monitor.setPlainText('\n'.join(filtered)) + except Exception as e: + self.process_monitor.setPlainText(f"[process info unavailable: {e}]") + + def _on_steam_restart_finished(self, success, message): + pass + + def refresh_modlist_list(self): + """Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts""" + try: + # Re-detect shortcuts using existing backend functionality + from jackify.backend.handlers.modlist_handler import ModlistHandler + + # Initialize modlist handler with empty config dict to use default initialization + modlist_handler = ModlistHandler({}) + discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe") + + # Convert to shortcut_handler format for UI compatibility + self.mo2_shortcuts = [] + for modlist in discovered_modlists: + # Convert discovered modlist format to shortcut format + shortcut = { + 'AppName': modlist.get('name', 'Unknown'), + 'AppID': modlist.get('appid', ''), + 'StartDir': modlist.get('path', ''), + 'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe" + } + self.mo2_shortcuts.append(shortcut) + + # Clear and repopulate the combo box + self.shortcut_combo.clear() + self.shortcut_combo.addItem("Please Select...") + self.shortcut_map.clear() + + for shortcut in self.mo2_shortcuts: + display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})" + self.shortcut_combo.addItem(display) + self.shortcut_map.append(shortcut) + + # Show feedback to user in UI only (don't write to log before workflow starts) + # Feedback is shown by the updated dropdown items + + except Exception as e: + # Don't write to log file before workflow starts - just show error in UI + MessageService.warning(self, "Refresh Error", f"Failed to refresh modlist list: {e}", safety_level="low") + + def _calculate_time_taken(self) -> str: + """Calculate and format the time taken for the workflow""" + if self._workflow_start_time is None: + return "unknown time" + + elapsed_seconds = time.time() - self._workflow_start_time + elapsed_minutes = int(elapsed_seconds // 60) + elapsed_seconds_remainder = int(elapsed_seconds % 60) + + if elapsed_minutes > 0: + if elapsed_minutes == 1: + return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_seconds_remainder} seconds" + + def cleanup(self): + """Clean up any running threads when the screen is closed""" + debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread") + + # Clean up config thread if running + if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): + debug_print("DEBUG: Terminating ConfigurationThread") + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except: + pass + self.config_thread.terminate() + self.config_thread.wait(2000) # Wait up to 2 seconds \ No newline at end of file diff --git a/jackify/frontends/gui/screens/configure_new_modlist.py b/jackify/frontends/gui/screens/configure_new_modlist.py new file mode 100644 index 0000000..6e4e28a --- /dev/null +++ b/jackify/frontends/gui/screens/configure_new_modlist.py @@ -0,0 +1,1223 @@ +""" +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.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject +from PySide6.QtGui import QPixmap, QTextCursor +from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS +from ..utils import ansi_to_html +import os +import subprocess +import sys +import threading +import time +from jackify.backend.handlers.shortcut_handler import ShortcutHandler +import traceback +import signal +from jackify.backend.core.modlist_operations import get_jackify_engine_path +from jackify.backend.handlers.subprocess_utils import ProcessManager +from jackify.backend.services.api_key_service import APIKeyService +from jackify.backend.services.resolution_service import ResolutionService +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 + +def debug_print(message): + """Print debug message only if debug mode is enabled""" + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if config_handler.get('debug_mode', False): + print(message) + +class ModlistFetchThread(QThread): + result = Signal(list, str) + def __init__(self, cli_path, game_type, project_root, log_path, mode='list-modlists', modlist_name=None, install_dir=None, download_dir=None): + super().__init__() + self.cli_path = cli_path + self.game_type = game_type + self.project_root = project_root + self.log_path = log_path + self.mode = mode + self.modlist_name = modlist_name + self.install_dir = install_dir + self.download_dir = download_dir + def run(self): + if self.mode == 'list-modlists': + cmd = [sys.executable, self.cli_path, '--install-modlist', '--list-modlists', '--game-type', self.game_type] + elif self.mode == 'install': + cmd = [sys.executable, self.cli_path, '--install-modlist', '--install', '--modlist-name', self.modlist_name, '--install-dir', self.install_dir, '--download-dir', self.download_dir, '--game-type', self.game_type] + else: + self.result.emit([], '[ModlistFetchThread] Unknown mode') + return + try: + with open(self.log_path, 'a') as logf: + logf.write(f"\n[Modlist Fetch CMD] {cmd}\n") + proc = subprocess.Popen(cmd, cwd=self.project_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + stdout, stderr = proc.communicate() + logf.write(f"[stdout]\n{stdout}\n[stderr]\n{stderr}\n") + if proc.returncode == 0: + modlist_ids = [line.strip() for line in stdout.splitlines() if line.strip()] + self.result.emit(modlist_ids, '') + else: + self.result.emit([], stderr) + except Exception as e: + self.result.emit([], str(e)) + +class SelectionDialog(QDialog): + def __init__(self, title, items, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setMinimumWidth(350) + self.setMinimumHeight(300) + layout = QVBoxLayout(self) + self.list_widget = QListWidget() + self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + for item in items: + QListWidgetItem(item, self.list_widget) + layout.addWidget(self.list_widget) + self.selected_item = None + self.list_widget.itemClicked.connect(self.on_item_clicked) + def on_item_clicked(self, item): + self.selected_item = item.text() + self.accept() + +class ConfigureNewModlistScreen(QWidget): + steam_restart_finished = Signal(bool, str) + def __init__(self, stacked_widget=None, main_menu_index=0): + super().__init__() + debug_print("DEBUG: ConfigureNewModlistScreen __init__ called") + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + self.debug = DEBUG_BORDERS + self.online_modlists = {} # {game_type: [modlist_dict, ...]} + self.modlist_details = {} # {modlist_name: modlist_dict} + + # Initialize services early + from jackify.backend.services.api_key_service import APIKeyService + from jackify.backend.services.resolution_service import ResolutionService + from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService + from jackify.backend.handlers.config_handler import ConfigHandler + self.api_key_service = APIKeyService() + self.resolution_service = ResolutionService() + self.config_handler = ConfigHandler() + 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) + + # Scroll tracking for professional auto-scroll behavior + self._user_manually_scrolled = False + self._was_at_bottom = True + + # Time tracking for workflow completion + self._workflow_start_time = None + + main_overall_vbox = QVBoxLayout(self) + main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + + # --- Header (title, description) --- + header_layout = QVBoxLayout() + header_layout.setSpacing(1) # Reduce spacing between title and description + # Title (no logo) + title = QLabel("Configure New Modlist") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") + title.setAlignment(Qt.AlignHCenter) + title.setMaximumHeight(30) # Force compact height + header_layout.addWidget(title) + # Description + desc = QLabel( + "This screen allows you to configure a newly installed modlist in Jackify. " + "Set up your Steam shortcut, restart Steam, and complete post-install configuration." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(40) # Force compact height for description + header_layout.addWidget(desc) + header_widget = QWidget() + header_widget.setLayout(header_layout) + header_widget.setMaximumHeight(75) # Match other screens + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + header_widget.setToolTip("HEADER_SECTION") + main_overall_vbox.addWidget(header_widget) + + # --- Upper section: user-configurables (left) + process monitor (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + # Left: user-configurables (form and controls) + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + # --- [Options] header (moved here for alignment) --- + options_header = QLabel("[Options]") + options_header.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;") + options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + user_config_vbox.addWidget(options_header) + # --- Install/Downloads Dir/API Key (reuse Tuxborn style) --- + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) # Reduced from 8 to 6 for better readability + form_grid.setContentsMargins(0, 0, 0, 0) + # Modlist Name (NEW FIELD) + modlist_name_label = QLabel("Modlist Name:") + self.modlist_name_edit = QLineEdit() + self.modlist_name_edit.setMaximumHeight(25) # Force compact height + form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addWidget(self.modlist_name_edit, 0, 1) + # Install Dir + install_dir_label = QLabel("ModOrganizer.exe Path:") + self.install_dir_edit = QLineEdit("/path/to/Modlist/ModOrganizer.exe") + self.install_dir_edit.setMaximumHeight(25) # Force compact height + browse_install_btn = QPushButton("Browse") + 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) + form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(install_dir_hbox, 1, 1) + # --- Resolution Dropdown --- + resolution_label = QLabel("Resolution:") + self.resolution_combo = QComboBox() + self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.resolution_combo.addItem("Leave unchanged") + self.resolution_combo.addItems([ + "1280x720", + "1280x800 (Steam Deck)", + "1366x768", + "1440x900", + "1600x900", + "1600x1200", + "1680x1050", + "1920x1080", + "1920x1200", + "2048x1152", + "2560x1080", + "2560x1440", + "2560x1600", + "3440x1440", + "3840x1600", + "3840x2160", + "3840x2400", + "5120x1440", + "5120x2160", + "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() + is_steam_deck = False + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + is_steam_deck = True + except Exception: + pass + 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) + debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + elif is_steam_deck: + # Set default to 1280x800 (Steam Deck) + combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] + if "1280x800 (Steam Deck)" in combo_items: + self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) + else: + self.resolution_combo.setCurrentIndex(0) + # Otherwise, default is 'Leave unchanged' (index 0) + form_section_widget = QWidget() + form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + form_section_widget.setLayout(form_grid) + form_section_widget.setMinimumHeight(120) # Reduced to match compact form + form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown + if self.debug: + form_section_widget.setStyleSheet("border: 2px solid blue;") + form_section_widget.setToolTip("FORM_SECTION") + user_config_vbox.addWidget(form_section_widget) + # --- Buttons --- + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + self.start_btn = QPushButton("Start Configuration") + btn_row.addWidget(self.start_btn) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.go_back) + btn_row.addWidget(cancel_btn) + user_config_widget = QWidget() + user_config_widget.setLayout(user_config_vbox) + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + user_config_widget.setToolTip("USER_CONFIG_WIDGET") + # Right: process monitor (as before) + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") + self.process_monitor_heading = QLabel("[Process Monitor]") + self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") + self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor_heading) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + process_monitor_widget.setToolTip("PROCESS_MONITOR") + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(process_monitor_widget, stretch=9) + upper_hbox.setAlignment(Qt.AlignTop) + upper_section_widget = QWidget() + upper_section_widget.setLayout(upper_hbox) + upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown + if self.debug: + upper_section_widget.setStyleSheet("border: 2px solid green;") + upper_section_widget.setToolTip("UPPER_SECTION") + main_overall_vbox.addWidget(upper_section_widget) + # Remove spacing - console should expand to fill available space + # --- Console output area (full width, placeholder for now) --- + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing + self.console.setMaximumHeight(1000) # Allow growth when space available + self.console.setFontFamily('monospace') + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + self.console.setToolTip("CONSOLE") + + # Set up scroll tracking for professional auto-scroll behavior + self._setup_scroll_tracking() + + # Wrap button row in widget for debug borders + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + btn_row_widget.setToolTip("BUTTON_ROW") + + # Create a container that holds console + button row with proper spacing + console_and_buttons_widget = QWidget() + console_and_buttons_layout = QVBoxLayout() + console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) + console_and_buttons_layout.setSpacing(8) # Small gap between console and buttons + + console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space + console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container + + console_and_buttons_widget.setLayout(console_and_buttons_layout) + if self.debug: + console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") + console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") + main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space + self.setLayout(main_overall_vbox) + + # --- Process Monitor (right) --- + self.process = None + self.log_timer = None + self.last_log_pos = 0 + # --- Process Monitor Timer --- + self.top_timer = QTimer(self) + self.top_timer.timeout.connect(self.update_top_panel) + self.top_timer.start(2000) + # --- Start Configuration button --- + 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) + + def resizeEvent(self, event): + """Handle window resize to prioritize form over console""" + super().resizeEvent(event) + self._adjust_console_for_form_priority() + + def _adjust_console_for_form_priority(self): + """Console now dynamically fills available space with stretch=1, no manual calculation needed""" + # The console automatically fills remaining space due to stretch=1 in the layout + # Remove any fixed height constraints to allow natural stretching + self.console.setMaximumHeight(16777215) # Reset to default maximum + self.console.setMinimumHeight(50) # Keep minimum height for usability + + def _setup_scroll_tracking(self): + """Set up scroll tracking for professional auto-scroll behavior""" + scrollbar = self.console.verticalScrollBar() + scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) + scrollbar.sliderReleased.connect(self._on_scrollbar_released) + scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) + + def _on_scrollbar_pressed(self): + """User started manually scrolling""" + self._user_manually_scrolled = True + + def _on_scrollbar_released(self): + """User finished manually scrolling""" + self._user_manually_scrolled = False + + def _on_scrollbar_value_changed(self): + """Track if user is at bottom of scroll area""" + scrollbar = self.console.verticalScrollBar() + # Use tolerance to account for rounding and rapid updates + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + # If user manually scrolls to bottom, reset manual scroll flag + if self._was_at_bottom and self._user_manually_scrolled: + # Small delay to allow user to scroll away if they want + from PySide6.QtCore import QTimer + QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) + + def _reset_manual_scroll_if_at_bottom(self): + """Reset manual scroll flag if user is still at bottom after delay""" + scrollbar = self.console.verticalScrollBar() + if scrollbar.value() >= scrollbar.maximum() - 1: + self._user_manually_scrolled = False + + def _safe_append_text(self, text): + """Append text with professional auto-scroll behavior""" + # Write all messages to log file + self._write_to_log_file(text) + + scrollbar = self.console.verticalScrollBar() + # Check if user was at bottom BEFORE adding text + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + + # Add the text + self.console.append(text) + + # Auto-scroll if user was at bottom and hasn't manually scrolled + # Re-check bottom state after text addition for better reliability + if (was_at_bottom and not self._user_manually_scrolled) or \ + (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): + scrollbar.setValue(scrollbar.maximum()) + # Ensure user can still manually scroll up during rapid updates + if scrollbar.value() == scrollbar.maximum(): + self._was_at_bottom = True + + def _write_to_log_file(self, message): + """Write message to workflow log file with timestamp""" + try: + from datetime import datetime + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(self.modlist_log_path, 'a', encoding='utf-8') as f: + f.write(f"[{timestamp}] {message}\n") + except Exception: + # Logging should never break the workflow + pass + + def browse_install_dir(self): + file, _ = QFileDialog.getOpenFileName(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)") + if file: + self.install_dir_edit.setText(file) + + def go_back(self): + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(3) # Return to Modlist Tasks menu + + def update_top_panel(self): + try: + result = subprocess.run([ + "ps", "-eo", "pcpu,pmem,comm,args" + ], stdout=subprocess.PIPE, text=True, timeout=2) + lines = result.stdout.splitlines() + header = "CPU%\tMEM%\tCOMMAND" + filtered = [header] + process_rows = [] + for line in lines[1:]: + line_lower = line.lower() + # Include jackify-engine and related heavy processes + heavy_processes = ( + "jackify-engine" in line_lower or "7zz" in line_lower or + "compressonator" in line_lower or "wine" in line_lower or + "wine64" in line_lower or "protontricks" in line_lower + ) + # Include Python processes running configure-modlist command + configure_processes = ( + "python" in line_lower and "configure-modlist" in line_lower + ) + # Include QProcess processes that might be configuration-related + qprocess_config = ( + hasattr(self, 'config_process') and + self.config_process and + self.config_process.state() == QProcess.Running and + ("python" in line_lower or "jackify" in line_lower) + ) + + if (heavy_processes or configure_processes or qprocess_config) and "jackify-gui.py" not in line_lower: + cols = line.strip().split(None, 3) + if len(cols) >= 3: + process_rows.append(cols) + process_rows.sort(key=lambda x: float(x[0]), reverse=True) + for cols in process_rows: + filtered.append('\t'.join(cols)) + if len(filtered) == 1: + filtered.append("[No Jackify-related processes found]") + self.process_monitor.setPlainText('\n'.join(filtered)) + except Exception as e: + self.process_monitor.setPlainText(f"[process info unavailable: {e}]") + + def _check_protontricks(self): + """Check if protontricks is available before critical operations""" + try: + is_installed, installation_type, details = self.protontricks_service.detect_protontricks() + + if not is_installed: + # Show protontricks error dialog + from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog + dialog = ProtontricksErrorDialog(self.protontricks_service, self) + result = dialog.exec() + + if result == QDialog.Rejected: + return False + + # Re-check after dialog + is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False) + return is_installed + + return True + + except Exception as e: + print(f"Error checking protontricks: {e}") + from jackify.frontends.gui.services.message_service import MessageService + MessageService.warning(self, "Protontricks Check Failed", + f"Unable to verify protontricks installation: {e}\n\n" + "Continuing anyway, but some features may not work correctly.") + return True # Continue anyway + + def validate_and_start_configure(self): + # Check protontricks before proceeding + if not self._check_protontricks(): + return + + # Rotate log file at start of each workflow run (keep 5 backups) + from jackify.backend.handlers.logging_handler import LoggingHandler + from pathlib import Path + log_handler = LoggingHandler() + log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) + + # Validate ModOrganizer.exe path + mo2_path = self.install_dir_edit.text().strip() + from jackify.frontends.gui.services.message_service import MessageService + if not mo2_path: + MessageService.warning(self, "Missing Path", "Please specify the path to ModOrganizer.exe", safety_level="low") + return + if not os.path.isfile(mo2_path): + MessageService.warning(self, "Invalid Path", "The specified path does not point to a valid file", safety_level="low") + return + if not mo2_path.endswith('ModOrganizer.exe'): + MessageService.warning(self, "Invalid File", "The specified file is not ModOrganizer.exe", safety_level="low") + return + + # Start time tracking + self._workflow_start_time = time.time() + + # 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") + 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() + 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}") + if reply not in (QMessageBox.Yes, QMessageBox.Ok, QMessageBox.AcceptRole): + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(0) + return + # Handle resolution saving + resolution = self.resolution_combo.currentText() + if resolution and resolution != "Leave unchanged": + success = self.resolution_service.save_resolution(resolution) + if success: + debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + else: + debug_print("DEBUG: Failed to save resolution") + else: + # Clear saved resolution if "Leave unchanged" is selected + if self.resolution_service.has_saved_resolution(): + self.resolution_service.clear_saved_resolution() + debug_print("DEBUG: Saved resolution cleared") + # --- Steam Configuration (progress dialog, thread, and signal) --- + progress = QProgressDialog("Steam Configuration...", None, 0, 0, self) + progress.setWindowTitle("Steam Configuration") + progress.setWindowModality(Qt.WindowModal) + progress.setMinimumDuration(0) + progress.setValue(0) + progress.show() + self.setEnabled(False) + def do_restart(): + try: + ok = shortcut_handler.secure_steam_restart() + out = '' + except Exception as e: + ok = False + out = str(e) + self._safe_append_text(f"[ERROR] Exception during Steam restart: {e}") + self.steam_restart_finished.emit(ok, out) + threading.Thread(target=do_restart, daemon=True).start() + self._steam_restart_progress = progress + + def _on_steam_restart_finished(self, success, out): + if hasattr(self, '_steam_restart_progress'): + self._steam_restart_progress.close() + del self._steam_restart_progress + self.setEnabled(True) + if success: + self._safe_append_text("Steam restarted successfully.") + + # Start configuration immediately - the CLI will handle any manual steps + self._safe_append_text("Starting modlist configuration...") + self.configure_modlist() + else: + self._safe_append_text("Failed to restart Steam.\n" + str(out)) + MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.", safety_level="medium") + + def configure_modlist(self): + install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip() + modlist_name = self.modlist_name_edit.text().strip() + mo2_exe_path = self.install_dir_edit.text().strip() + resolution = self.resolution_combo.currentText() + if not install_dir or not modlist_name: + MessageService.warning(self, "Missing Info", "Install directory or modlist name is missing.", safety_level="low") + return + + # Use automated prefix service instead of manual steps + self._safe_append_text("") + self._safe_append_text("=== Steam Integration Phase ===") + self._safe_append_text("Starting automated Steam setup workflow...") + self._safe_append_text("This will automatically configure Steam integration without manual steps.") + + # Start automated prefix workflow + self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path, resolution) + + def _start_automated_prefix_workflow(self, modlist_name, install_dir, mo2_exe_path, resolution): + """Start the automated prefix workflow using AutomatedPrefixService""" + try: + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + + self._safe_append_text(f"Initializing automated Steam setup for '{modlist_name}'...") + + # Initialize the automated prefix service + prefix_service = AutomatedPrefixService() + + # Define progress callback for GUI updates + def progress_callback(message): + self._safe_append_text(message) + + # Run the automated workflow + self._safe_append_text("Starting automated Steam shortcut creation and configuration...") + # Detect Steam Deck once and pass through + 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 + result = prefix_service.run_working_workflow( + modlist_name, install_dir, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck + ) + + # Handle the result - check for conflicts + if isinstance(result, tuple) and len(result) == 4: + if result[0] == "CONFLICT": + # Conflict detected - show conflict resolution dialog + conflicts = result[1] + self.show_shortcut_conflict_dialog(conflicts) + return + else: + # Normal result + success, prefix_path, new_appid, last_timestamp = result + if success: + self._safe_append_text(f"Automated Steam setup completed successfully!") + self._safe_append_text(f"New AppID assigned: {new_appid}") + + # Continue with post-Steam configuration, passing the last timestamp + self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp) + else: + self._safe_append_text(f"Automated Steam setup failed") + self._safe_append_text("Please check the logs for details.") + self.start_btn.setEnabled(True) + elif isinstance(result, tuple) and len(result) == 3: + # Fallback for old format (backward compatibility) + success, prefix_path, new_appid = result + if success: + self._safe_append_text(f"Automated Steam setup completed successfully!") + self._safe_append_text(f"New AppID assigned: {new_appid}") + + # Continue with post-Steam configuration + self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir) + else: + self._safe_append_text(f"Automated Steam setup failed") + self._safe_append_text("Please check the logs for details.") + self.start_btn.setEnabled(True) + else: + # Handle unexpected result format + self._safe_append_text(f"Automated Steam setup failed - unexpected result format") + self._safe_append_text("Please check the logs for details.") + self.start_btn.setEnabled(True) + + except Exception as e: + self._safe_append_text(f"Error during automated Steam setup: {str(e)}") + self._safe_append_text("Please check the logs for details.") + self.start_btn.setEnabled(True) + + def show_shortcut_conflict_dialog(self, conflicts): + """Show dialog to resolve shortcut name conflicts""" + conflict_names = [c['name'] for c in conflicts] + conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" + + modlist_name = self.modlist_name_edit.text().strip() + + # Create dialog with Jackify styling + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout + from PySide6.QtCore import Qt + + dialog = QDialog(self) + dialog.setWindowTitle("Steam Shortcut Conflict") + dialog.setModal(True) + dialog.resize(450, 180) + + # Apply Jackify dark theme styling + dialog.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + color: #ffffff; + } + QLabel { + color: #ffffff; + font-size: 14px; + padding: 10px 0px; + } + QLineEdit { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px; + font-size: 14px; + selection-background-color: #3fd0ea; + } + QLineEdit:focus { + border-color: #3fd0ea; + } + QPushButton { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + min-width: 120px; + } + QPushButton:hover { + background-color: #505050; + border-color: #3fd0ea; + } + QPushButton:pressed { + background-color: #303030; + } + """) + + layout = QVBoxLayout(dialog) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Conflict message + conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") + layout.addWidget(conflict_label) + + # Text input for new name + name_input = QLineEdit(modlist_name) + name_input.selectAll() + layout.addWidget(name_input) + + # Buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + create_button = QPushButton("Create with New Name") + cancel_button = QPushButton("Cancel") + + button_layout.addStretch() + button_layout.addWidget(cancel_button) + button_layout.addWidget(create_button) + layout.addLayout(button_layout) + + # Connect signals + def on_create(): + new_name = name_input.text().strip() + if new_name and new_name != modlist_name: + dialog.accept() + # Retry workflow with new name + self.retry_automated_workflow_with_new_name(new_name) + elif new_name == modlist_name: + # Same name - show warning + from jackify.backend.services.message_service import MessageService + MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") + else: + # Empty name + from jackify.backend.services.message_service import MessageService + MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + + def on_cancel(): + dialog.reject() + self._safe_append_text("Shortcut creation cancelled by user") + + create_button.clicked.connect(on_create) + cancel_button.clicked.connect(on_cancel) + + # Make Enter key work + name_input.returnPressed.connect(on_create) + + dialog.exec() + + def retry_automated_workflow_with_new_name(self, new_name): + """Retry the automated workflow with a new shortcut name""" + # Update the modlist name field temporarily + original_name = self.modlist_name_edit.text() + self.modlist_name_edit.setText(new_name) + + # Restart the automated workflow + self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") + self._start_automated_prefix_workflow(new_name, os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip(), self.install_dir_edit.text().strip(), self.resolution_combo.currentText()) + + # Old CLI-based handlers removed - now using backend service directly + + # Manual steps methods removed - now using automated prefix service + """Validate that manual steps were actually completed and handle retry logic""" + modlist_name = self.modlist_name_edit.text().strip() + install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip() + mo2_exe_path = self.install_dir_edit.text().strip() + + # CRITICAL: Re-detect the AppID after Steam restart and manual steps + # 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) + current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) + + if not current_appid or not current_appid.isdigit(): + self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") + self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") + self.handle_validation_failure("Could not find Steam shortcut") + return + + self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}") + self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}") + + # Check manual steps completion (same validation as Tuxborn) + validation_passed = True + missing_details = [] + + # Check 1: Proton version + proton_ok = False + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + from jackify.backend.handlers.path_handler import PathHandler + + # Initialize ModlistHandler with correct parameters + path_handler = PathHandler() + modlist_handler = ModlistHandler(steamdeck=False, verbose=False) + + # Set required properties manually after initialization + modlist_handler.modlist_dir = install_dir + modlist_handler.appid = current_appid # Use the re-detected AppID + modlist_handler.game_var = "skyrimspecialedition" # Default for now + + # Set compat_data_path for Proton detection using the re-detected AppID + compat_data_path_str = path_handler.find_compat_data(current_appid) + if compat_data_path_str: + from pathlib import Path + modlist_handler.compat_data_path = Path(compat_data_path_str) + + # Check Proton version using the re-detected AppID + self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...") + if modlist_handler._detect_proton_version(): + self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'") + + if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower(): + self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}") + proton_ok = True + else: + self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") + else: + self._safe_append_text("Error: Could not detect Proton version from any source") + + except Exception as e: + self._safe_append_text(f"Error checking Proton version: {e}") + + if not proton_ok: + validation_passed = False + missing_details.append("Error: Proton version not set to 'Proton - Experimental'") + + # Check 2: Compatdata directory exists + compatdata_ok = False + try: + from jackify.backend.handlers.path_handler import PathHandler + path_handler = PathHandler() + self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...") + prefix_path_str = path_handler.find_compat_data(current_appid) + self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'") + + if prefix_path_str: + from pathlib import Path + prefix_path = Path(prefix_path_str) + if prefix_path.exists() and prefix_path.is_dir(): + self._safe_append_text(f"Compatdata directory found: {prefix_path_str}") + compatdata_ok = True + elif prefix_path.exists(): + self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") + else: + self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") + else: + self._safe_append_text(f"ERROR: No compatdata directory found for AppID {current_appid}") + except Exception as e: + self._safe_append_text(f"Error checking compatdata: {e}") + + if not compatdata_ok: + validation_passed = False + missing_details.append("Error: Modlist was not launched from Steam (no compatdata directory)") + + if validation_passed: + self._safe_append_text("Manual steps validation passed!") + self._safe_append_text("Continuing configuration with updated AppID...") + + # Continue with configuration (same as Tuxborn) + self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) + else: + missing_text = "\n".join(missing_details) + self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") + self.handle_validation_failure(missing_text) + + def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None): + """Continue the configuration process with the new AppID after automated prefix creation""" + # Headers are now shown at start of Steam Integration + # No need to show them again here + debug_print("Configuration phase continues after Steam Integration") + + debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") + try: + # Get resolution from UI + resolution = self.resolution_combo.currentText() + resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else '2560x1600' + + # Update the context with the new AppID (same format as manual steps) + mo2_exe_path = self.install_dir_edit.text().strip() + updated_context = { + 'name': modlist_name, + 'path': install_dir, + 'mo2_exe_path': mo2_exe_path, + 'modlist_value': None, + 'modlist_source': None, + 'resolution': resolution_value, + '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 + 'game_name': 'Skyrim Special Edition' # Default for new modlist + } + self.context = updated_context # Ensure context is always set + debug_print(f"Updated context with new AppID: {new_appid}") + + # 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): + super().__init__() + self.context = context + + def run(self): + try: + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + + # Initialize backend service + system_info = SystemInfo(is_steamdeck=False) + modlist_service = ModlistService(system_info) + + # Convert context to ModlistContext for service + modlist_context = ModlistContext( + name=self.context['name'], + install_dir=Path(self.context['path']), + download_dir=Path(self.context['path']).parent / 'Downloads', # Default + game_type='skyrim', # Default for now + 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'), + skip_confirmation=True + ) + + # Add app_id to context + modlist_context.app_id = self.context['appid'] + + # Define callbacks + def progress_callback(message): + self.progress_update.emit(message) + + def completion_callback(success, message, modlist_name): + self.configuration_complete.emit(success, message, modlist_name) + + def manual_steps_callback(modlist_name, retry_count): + # 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 + self.progress_update.emit("") + self.progress_update.emit("=== Configuration Phase ===") + self.progress_update.emit("") + self.progress_update.emit("Starting modlist configuration...") + result = modlist_service.configure_modlist_post_steam( + context=modlist_context, + progress_callback=progress_callback, + manual_steps_callback=manual_steps_callback, + completion_callback=completion_callback + ) + + if not result: + self.progress_update.emit("Configuration failed to start") + self.error_occurred.emit("Configuration failed to start") + + except Exception as e: + self.error_occurred.emit(str(e)) + + # Start configuration thread + self.config_thread = ConfigThread(updated_context) + self.config_thread.progress_update.connect(self._safe_append_text) + self.config_thread.configuration_complete.connect(self.on_configuration_complete) + self.config_thread.error_occurred.connect(self.on_configuration_error) + self.config_thread.start() + + except Exception as e: + self._safe_append_text(f"Error continuing configuration: {e}") + import traceback + self._safe_append_text(f"Full traceback: {traceback.format_exc()}") + self.on_configuration_error(str(e)) + + def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir): + """Continue the configuration process with the corrected AppID after manual steps validation""" + try: + # Update the context with the new AppID + mo2_exe_path = self.install_dir_edit.text().strip() + resolution = self.resolution_combo.currentText() + + updated_context = { + 'name': modlist_name, + 'path': install_dir, + 'mo2_exe_path': mo2_exe_path, + 'resolution': resolution.split()[0] if resolution != "Leave unchanged" else None, + 'skip_confirmation': True, + 'manual_steps_completed': True, # Mark as completed + 'appid': new_appid, # Use the NEW AppID from Steam + 'game_name': 'Skyrim Special Edition' # Default for new modlist + } + debug_print(f"Updated context with new AppID: {new_appid}") + + # Create new config thread with updated context (same as Tuxborn) + from PySide6.QtCore import QThread, Signal + + class ConfigThread(QThread): + progress_update = Signal(str) + configuration_complete = Signal(bool, str, str) + error_occurred = Signal(str) + + def __init__(self, context): + super().__init__() + self.context = context + + def run(self): + try: + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + + # Initialize backend service + system_info = SystemInfo(is_steamdeck=False) + modlist_service = ModlistService(system_info) + + # Convert context to ModlistContext for service + modlist_context = ModlistContext( + name=self.context['name'], + install_dir=Path(self.context['path']), + download_dir=Path(self.context['path']).parent / 'Downloads', # Default + game_type='skyrim', # Default for configure new + nexus_api_key='', # Not needed for configuration + modlist_value='', # Not needed for existing modlist + modlist_source='existing', + resolution=self.context.get('resolution'), + skip_confirmation=True + ) + + # Add app_id to context + if 'appid' in self.context: + modlist_context.app_id = self.context['appid'] + + # Define callbacks + def progress_callback(message): + self.progress_update.emit(message) + + def completion_callback(success, message, modlist_name): + self.configuration_complete.emit(success, message, modlist_name) + + def manual_steps_callback(modlist_name, retry_count): + # This shouldn't happen since manual steps should be done + self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") + + # Call the working configuration service method + self.progress_update.emit("Starting configuration with backend service...") + + success = modlist_service.configure_modlist_post_steam( + context=modlist_context, + progress_callback=progress_callback, + manual_steps_callback=manual_steps_callback, + completion_callback=completion_callback + ) + + if not success: + self.error_occurred.emit("Configuration failed - check logs for details") + + except Exception as e: + import traceback + error_msg = f"Configuration error: {e}\n{traceback.format_exc()}" + self.error_occurred.emit(error_msg) + + # Create and start the configuration thread + self.config_thread = ConfigThread(updated_context) + self.config_thread.progress_update.connect(self._safe_append_text) + self.config_thread.configuration_complete.connect(self.on_configuration_complete) + self.config_thread.error_occurred.connect(self.on_configuration_error) + self.config_thread.start() + + except Exception as e: + self._safe_append_text(f"Error continuing configuration: {e}") + MessageService.critical(self, "Configuration Error", f"Failed to continue configuration: {e}", safety_level="medium") + + def on_configuration_complete(self, success, message, modlist_name): + """Handle configuration completion (same as Tuxborn)""" + if success: + # Calculate time taken + time_taken = self._calculate_time_taken() + + # Show success dialog with celebration + success_dialog = SuccessDialog( + modlist_name=modlist_name, + workflow_type="configure_new", + time_taken=time_taken, + game_name=getattr(self, '_current_game_name', None), + parent=self + ) + success_dialog.show() + else: + self._safe_append_text(f"Configuration failed: {message}") + MessageService.critical(self, "Configuration Failed", + f"Configuration failed: {message}", safety_level="medium") + + def on_configuration_error(self, error_message): + """Handle configuration error""" + self._safe_append_text(f"Configuration error: {error_message}") + MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") + + def handle_validation_failure(self, missing_text): + """Handle manual steps validation failure with retry logic""" + self._manual_steps_retry_count += 1 + + if self._manual_steps_retry_count < 3: + # Show retry dialog + MessageService.critical(self, "Manual Steps Incomplete", + f"Manual steps validation failed:\n\n{missing_text}\n\n" + "Please complete the manual steps and try again.", safety_level="medium") + # Show manual steps dialog again + extra_warning = "" + if self._manual_steps_retry_count >= 2: + extra_warning = "
It looks like you have not completed the manual steps yet. Please try again." + self.show_manual_steps_dialog(extra_warning) + else: + # Max retries reached + MessageService.critical(self, "Manual Steps Failed", + "Manual steps validation failed after multiple attempts.", safety_level="medium") + self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self.modlist_name_edit.text().strip()) + + # Old CLI-based process finished handler removed - now using backend service callbacks + + def _calculate_time_taken(self) -> str: + """Calculate and format the time taken for the workflow""" + if self._workflow_start_time is None: + return "unknown time" + + elapsed_seconds = time.time() - self._workflow_start_time + elapsed_minutes = int(elapsed_seconds // 60) + elapsed_seconds_remainder = int(elapsed_seconds % 60) + + if elapsed_minutes > 0: + if elapsed_minutes == 1: + return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_seconds_remainder} seconds" + + def show_next_steps_dialog(self, message): + dlg = QDialog(self) + dlg.setWindowTitle("Next Steps") + dlg.setModal(True) + layout = QVBoxLayout(dlg) + label = QLabel(message) + label.setWordWrap(True) + layout.addWidget(label) + btn_row = QHBoxLayout() + btn_return = QPushButton("Return") + btn_exit = QPushButton("Exit") + btn_row.addWidget(btn_return) + btn_row.addWidget(btn_exit) + layout.addLayout(btn_row) + def on_return(): + dlg.accept() + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(0) + def on_exit(): + QApplication.quit() + btn_return.clicked.connect(on_return) + btn_exit.clicked.connect(on_exit) + dlg.exec() + + def cleanup(self): + """Clean up any running threads when the screen is closed""" + debug_print("DEBUG: cleanup called - cleaning up ConfigThread") + + # Clean up config thread if running + if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): + debug_print("DEBUG: Terminating ConfigThread") + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except: + pass + self.config_thread.terminate() + self.config_thread.wait(2000) # Wait up to 2 seconds \ No newline at end of file diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py new file mode 100644 index 0000000..ca0babd --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -0,0 +1,2528 @@ +""" +InstallModlistScreen for Jackify GUI +""" +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QTableWidget, QTableWidgetItem, QHeaderView +from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl +from PySide6.QtGui import QPixmap, QTextCursor, QColor, QPainter, QFont +from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS +from ..utils import ansi_to_html +from ..widgets.unsupported_game_dialog import UnsupportedGameDialog +import os +import subprocess +import sys +import threading +from jackify.backend.handlers.shortcut_handler import ShortcutHandler +from jackify.backend.handlers.wabbajack_parser import WabbajackParser +import traceback +from jackify.backend.core.modlist_operations import get_jackify_engine_path +import signal +import re +import time +from jackify.backend.handlers.subprocess_utils import ProcessManager +from jackify.backend.handlers.config_handler import ConfigHandler +from ..dialogs import SuccessDialog +from jackify.backend.handlers.validation_handler import ValidationHandler +from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog +from jackify.frontends.gui.services.message_service import MessageService + +def debug_print(message): + """Print debug message only if debug mode is enabled""" + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if config_handler.get('debug_mode', False): + print(message) + +class ModlistFetchThread(QThread): + result = Signal(list, str) + def __init__(self, game_type, log_path, mode='list-modlists'): + super().__init__() + self.game_type = game_type + self.log_path = log_path + self.mode = mode + + def run(self): + try: + # Use proper backend service - NOT the misnamed CLI class + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.configuration import SystemInfo + + # Initialize backend service + # Detect if we're on Steam Deck + is_steamdeck = False + try: + 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: + pass + + system_info = SystemInfo(is_steamdeck=is_steamdeck) + modlist_service = ModlistService(system_info) + + # Get modlists using proper backend service + modlist_infos = modlist_service.list_modlists(game_type=self.game_type) + + # Return full modlist objects instead of just IDs to preserve enhanced metadata + self.result.emit(modlist_infos, '') + + except Exception as e: + error_msg = f"Backend service error: {str(e)}" + # Don't write to log file before workflow starts - just return error + self.result.emit([], error_msg) + + +class SelectionDialog(QDialog): + def __init__(self, title, items, parent=None, show_search=True, placeholder_text="Search modlists...", show_legend=False): + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setMinimumWidth(600) + self.setMinimumHeight(300) + layout = QVBoxLayout(self) + + self.show_search = show_search + if self.show_search: + # Search box with clear button + search_layout = QHBoxLayout() + self.search_box = QLineEdit() + self.search_box.setPlaceholderText(placeholder_text) + # Make placeholder text lighter + self.search_box.setStyleSheet("QLineEdit { color: #ccc; } QLineEdit:placeholder { color: #aaa; }") + self.clear_btn = QPushButton("Clear") + self.clear_btn.setFixedWidth(50) + search_layout.addWidget(self.search_box) + search_layout.addWidget(self.clear_btn) + layout.addLayout(search_layout) + + if show_legend: + # Use table for modlist selection with proper columns + self.table_widget = QTableWidget() + self.table_widget.setColumnCount(4) + self.table_widget.setHorizontalHeaderLabels(["Modlist Name", "Download", "Install", "Total"]) + + # Configure table appearance + self.table_widget.setSelectionBehavior(QTableWidget.SelectRows) + self.table_widget.setSelectionMode(QTableWidget.SingleSelection) + self.table_widget.verticalHeader().setVisible(False) + self.table_widget.setAlternatingRowColors(True) + + # Set column widths + header = self.table_widget.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) # Modlist name takes remaining space + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Download size + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Install size + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Total size + + + self._all_items = list(items) + self._populate_table(self._all_items) + layout.addWidget(self.table_widget) + + # Apply initial NSFW filter since checkbox starts unchecked + self._filter_nsfw(False) + else: + # Use list for non-modlist dialogs (backward compatibility) + self.list_widget = QListWidget() + self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._all_items = list(items) + self._populate_list(self._all_items) + layout.addWidget(self.list_widget) + + # Add interactive legend bar only for modlist selection dialogs + if show_legend: + legend_layout = QHBoxLayout() + legend_layout.setContentsMargins(10, 5, 10, 5) + + # Status indicator explanation (far left) + status_label = QLabel('[DOWN] Unavailable') + status_label.setStyleSheet("color: #bbb;") + legend_layout.addWidget(status_label) + + # Spacer after DOWN legend + legend_layout.addSpacing(15) + + # No need for size format explanation since we have table headers now + # Just add some spacing + + # Main spacer to push NSFW checkbox to far right + legend_layout.addStretch() + + # NSFW filter checkbox (far right) + self.nsfw_checkbox = QCheckBox("Show NSFW") + self.nsfw_checkbox.setStyleSheet("color: #bbb; font-size: 11px;") + self.nsfw_checkbox.setChecked(False) # Default to hiding NSFW content + self.nsfw_checkbox.toggled.connect(self._filter_nsfw) + legend_layout.addWidget(self.nsfw_checkbox) + + # Legend container + legend_widget = QWidget() + legend_widget.setLayout(legend_layout) + legend_widget.setStyleSheet("background-color: #333; border-radius: 3px; margin: 2px;") + layout.addWidget(legend_widget) + + self.selected_item = None + + # Connect appropriate signals based on widget type + if show_legend: + self.table_widget.itemClicked.connect(self.on_table_item_clicked) + if self.show_search: + self.search_box.textChanged.connect(self._filter_table) + self.clear_btn.clicked.connect(self._clear_search) + self.search_box.returnPressed.connect(self._focus_table) + self.search_box.installEventFilter(self) + else: + self.list_widget.itemClicked.connect(self.on_item_clicked) + if self.show_search: + self.search_box.textChanged.connect(self._filter_list) + self.clear_btn.clicked.connect(self._clear_search) + self.search_box.returnPressed.connect(self._focus_list) + self.search_box.installEventFilter(self) + + def _populate_list(self, items): + self.list_widget.clear() + for item in items: + # Create list item - custom delegate handles all styling + QListWidgetItem(item, self.list_widget) + + def _populate_table(self, items): + self.table_widget.setRowCount(len(items)) + for row, item in enumerate(items): + # Parse the item string to extract components + # Format: "[STATUS] Modlist Name Download|Install|Total" + + # Extract status indicators + status_down = '[DOWN]' in item + status_nsfw = '[NSFW]' in item + + # Clean the item string + clean_item = item.replace('[DOWN]', '').replace('[NSFW]', '').strip() + + # Split into name and sizes + # The format should be "Name Download|Install|Total" + parts = clean_item.rsplit(' ', 1) # Split from right to separate name from sizes + if len(parts) == 2: + name = parts[0].strip() + sizes = parts[1].strip() + size_parts = sizes.split('|') + if len(size_parts) == 3: + download_size, install_size, total_size = [s.strip() for s in size_parts] + else: + # Fallback if format is unexpected + download_size = install_size = total_size = sizes + else: + # Fallback if format is unexpected + name = clean_item + download_size = install_size = total_size = "" + + # Create table items + name_item = QTableWidgetItem(name) + download_item = QTableWidgetItem(download_size) + install_item = QTableWidgetItem(install_size) + total_item = QTableWidgetItem(total_size) + + # Apply styling + if status_down: + # Gray out and strikethrough for DOWN items + for item_widget in [name_item, download_item, install_item, total_item]: + item_widget.setForeground(QColor('#999999')) + font = item_widget.font() + font.setStrikeOut(True) + item_widget.setFont(font) + elif status_nsfw: + # Red text for NSFW items - but only the name, sizes stay white + name_item.setForeground(QColor('#ff4444')) + for item_widget in [download_item, install_item, total_item]: + item_widget.setForeground(QColor('#ffffff')) + else: + # White text for normal items + for item_widget in [name_item, download_item, install_item, total_item]: + item_widget.setForeground(QColor('#ffffff')) + + # Add status indicators to name if present + if status_nsfw: + name_item.setText(f"[NSFW] {name}") + if status_down: + # For DOWN items, we want [DOWN] normal and the name strikethrough + # Since we can't easily mix fonts in a single QTableWidgetItem, + # we'll style the whole item but the visual effect will be clear + name_item.setText(f"[DOWN] {name_item.text()}") + + # Right-align size columns + download_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + install_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + total_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + + # Add items to table + self.table_widget.setItem(row, 0, name_item) + self.table_widget.setItem(row, 1, download_item) + self.table_widget.setItem(row, 2, install_item) + self.table_widget.setItem(row, 3, total_item) + + # Store original item text as data for filtering + name_item.setData(Qt.UserRole, item) + + def _filter_list(self, text): + text = text.strip().lower() + if not text: + filtered = self._all_items + else: + filtered = [item for item in self._all_items if text in item.lower()] + self._populate_list(filtered) + if filtered: + self.list_widget.setCurrentRow(0) + + def _clear_search(self): + self.search_box.clear() + self.search_box.setFocus() + + def _focus_list(self): + self.list_widget.setFocus() + self.list_widget.setCurrentRow(0) + + def _focus_table(self): + self.table_widget.setFocus() + self.table_widget.setCurrentCell(0, 0) + + def _filter_table(self, text): + text = text.strip().lower() + if not text: + # Show all rows + for row in range(self.table_widget.rowCount()): + self.table_widget.setRowHidden(row, False) + else: + # Filter rows based on modlist name + for row in range(self.table_widget.rowCount()): + name_item = self.table_widget.item(row, 0) + if name_item: + # Search in the modlist name + match = text in name_item.text().lower() + self.table_widget.setRowHidden(row, not match) + + def on_table_item_clicked(self, item): + # Get the original item text from the name column + row = item.row() + name_item = self.table_widget.item(row, 0) + if name_item: + original_item = name_item.data(Qt.UserRole) + self.selected_item = original_item + self.accept() + + def _filter_nsfw(self, show_nsfw): + """Filter NSFW modlists based on checkbox state""" + if show_nsfw: + # Show all items + filtered_items = self._all_items + else: + # Hide NSFW items + filtered_items = [item for item in self._all_items if '[NSFW]' not in item] + + # Use appropriate populate method based on widget type + if hasattr(self, 'table_widget'): + self._populate_table(filtered_items) + # Apply search filter if there's search text + if hasattr(self, 'search_box') and self.search_box.text().strip(): + self._filter_table(self.search_box.text()) + else: + self._populate_list(filtered_items) + # Apply search filter if there's search text + if hasattr(self, 'search_box') and self.search_box.text().strip(): + self._filter_list(self.search_box.text()) + + def eventFilter(self, obj, event): + if self.show_search and obj == self.search_box and event.type() == event.Type.KeyPress: + if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Tab): + # Focus appropriate widget + if hasattr(self, 'table_widget'): + self._focus_table() + else: + self._focus_list() + return True + return super().eventFilter(obj, event) + + def on_item_clicked(self, item): + self.selected_item = item.text() + self.accept() + +class InstallModlistScreen(QWidget): + steam_restart_finished = Signal(bool, str) + def __init__(self, stacked_widget=None, main_menu_index=0): + super().__init__() + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + self.debug = DEBUG_BORDERS + 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 services early + from jackify.backend.services.api_key_service import APIKeyService + from jackify.backend.services.resolution_service import ResolutionService + from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService + from jackify.backend.handlers.config_handler import ConfigHandler + self.api_key_service = APIKeyService() + self.resolution_service = ResolutionService() + self.config_handler = ConfigHandler() + self.protontricks_service = ProtontricksDetectionService() + + # Scroll tracking for professional auto-scroll behavior + self._user_manually_scrolled = False + self._was_at_bottom = True + + # Initialize Wabbajack parser for game detection + self.wabbajack_parser = WabbajackParser() + + main_overall_vbox = QVBoxLayout(self) + main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + + # --- Header (title, description) --- + header_layout = QVBoxLayout() + header_layout.setSpacing(1) # Reduce spacing between title and description + # Title (no logo) + title = QLabel("Install a Modlist (Automated)") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") + title.setAlignment(Qt.AlignHCenter) + title.setMaximumHeight(30) # Force compact height + header_layout.addWidget(title) + # Description + desc = QLabel( + "This screen allows you to install a Wabbajack modlist using Jackify's native Linux tools. " + "Configure your options and start the installation." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(40) # Force compact height for description + header_layout.addWidget(desc) + header_widget = QWidget() + header_widget.setLayout(header_layout) + header_widget.setMaximumHeight(75) # Increase header height by 25% (60 + 15) + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + header_widget.setToolTip("HEADER_SECTION") + main_overall_vbox.addWidget(header_widget) + + # --- Upper section: user-configurables (left) + process monitor (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + # Left: user-configurables (form and controls) + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + user_config_vbox.setSpacing(4) # Reduce spacing between major form sections + # --- Tabs for source selection --- + self.source_tabs = QTabWidget() + self.source_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; }") + if self.debug: + self.source_tabs.setStyleSheet("border: 2px solid cyan;") + self.source_tabs.setToolTip("SOURCE_TABS") + # --- Online List Tab --- + online_tab = QWidget() + online_tab_vbox = QVBoxLayout() + online_tab_vbox.setAlignment(Qt.AlignTop) + # Online List Controls + self.online_group = QWidget() + online_layout = QHBoxLayout() + online_layout.setContentsMargins(0, 0, 0, 0) + # --- Game Type Selection --- + self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Other"] + self.game_type_btn = QPushButton("Please Select...") + self.game_type_btn.setMinimumWidth(200) + self.game_type_btn.clicked.connect(self.open_game_type_dialog) + # --- Modlist Selection --- + self.modlist_btn = QPushButton("Select Modlist") + self.modlist_btn.setMinimumWidth(300) + self.modlist_btn.clicked.connect(self.open_modlist_dialog) + self.modlist_btn.setEnabled(False) + online_layout.addWidget(QLabel("Game Type:")) + online_layout.addWidget(self.game_type_btn) + online_layout.addSpacing(4) # Reduced from 16 to 4 + online_layout.addWidget(QLabel("Modlist:")) + online_layout.addWidget(self.modlist_btn) + self.online_group.setLayout(online_layout) + online_tab_vbox.addWidget(self.online_group) + online_tab.setLayout(online_tab_vbox) + self.source_tabs.addTab(online_tab, "Select Modlist") + # --- File Picker Tab --- + file_tab = QWidget() + file_tab_vbox = QVBoxLayout() + file_tab_vbox.setAlignment(Qt.AlignTop) + self.file_group = QWidget() + file_layout = QHBoxLayout() + 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) + file_layout.addWidget(QLabel(".wabbajack File:")) + file_layout.addWidget(self.file_edit) + file_layout.addWidget(file_btn) + self.file_group.setLayout(file_layout) + file_tab_vbox.addWidget(self.file_group) + file_tab.setLayout(file_tab_vbox) + self.source_tabs.addTab(file_tab, "Use .wabbajack File") + user_config_vbox.addWidget(self.source_tabs) + # --- Install/Downloads Dir/API Key (reuse Tuxborn style) --- + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) # Increased from 1 to 6 for better readability + form_grid.setContentsMargins(0, 0, 0, 0) + # Modlist Name (NEW FIELD) + modlist_name_label = QLabel("Modlist Name:") + self.modlist_name_edit = QLineEdit() + self.modlist_name_edit.setMaximumHeight(25) # Force compact height + form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addWidget(self.modlist_name_edit, 0, 1) + # Install Dir + 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) + install_dir_hbox = QHBoxLayout() + install_dir_hbox.addWidget(self.install_dir_edit) + install_dir_hbox.addWidget(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) + downloads_dir_hbox = QHBoxLayout() + downloads_dir_hbox.addWidget(self.downloads_dir_edit) + downloads_dir_hbox.addWidget(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 + api_key_label = QLabel("Nexus API Key:") + self.api_key_edit = QLineEdit() + self.api_key_edit.setMaximumHeight(25) # Force compact height + # Services already initialized above + # Set up obfuscation timer and state + self.api_key_obfuscation_timer = QTimer(self) + self.api_key_obfuscation_timer.setSingleShot(True) + self.api_key_obfuscation_timer.timeout.connect(self._obfuscate_api_key) + self.api_key_original_text = "" + self.api_key_is_obfuscated = False + # Connect events for obfuscation + self.api_key_edit.textChanged.connect(self._on_api_key_text_changed) + self.api_key_edit.focusInEvent = self._on_api_key_focus_in + self.api_key_edit.focusOutEvent = self._on_api_key_focus_out + # Load saved API key if available + saved_key = self.api_key_service.get_saved_api_key() + if saved_key: + self.api_key_original_text = saved_key # Set original text first + self.api_key_edit.setText(saved_key) + self._obfuscate_api_key() # Immediately obfuscate saved keys + form_grid.addWidget(api_key_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addWidget(self.api_key_edit, 3, 1) + # API Key save checkbox and info (row 4) + api_save_layout = QHBoxLayout() + api_save_layout.setContentsMargins(0, 0, 0, 0) + api_save_layout.setSpacing(8) + self.save_api_key_checkbox = QCheckBox("Save API Key") + self.save_api_key_checkbox.setChecked(self.api_key_service.has_saved_api_key()) + self.save_api_key_checkbox.toggled.connect(self._on_api_key_save_toggled) + api_save_layout.addWidget(self.save_api_key_checkbox, alignment=Qt.AlignTop) + + # Validate button removed - validation now happens silently on save checkbox toggle + api_info = QLabel( + 'Storing your API Key locally is done so at your own risk.
' + 'You can get your API key at: ' + 'https://www.nexusmods.com/users/myaccount?tab=api
' + ) + api_info.setOpenExternalLinks(False) + api_info.linkActivated.connect(self._open_url_safe) + api_info.setWordWrap(True) + api_info.setAlignment(Qt.AlignLeft) + api_save_layout.addWidget(api_info, stretch=1) + api_save_widget = QWidget() + api_save_widget.setLayout(api_save_layout) + # Remove height constraint to prevent text truncation + if self.debug: + api_save_widget.setStyleSheet("border: 2px solid blue;") + api_save_widget.setToolTip("API_KEY_SECTION") + form_grid.addWidget(api_save_widget, 4, 1) + # --- Resolution Dropdown --- + resolution_label = QLabel("Resolution:") + self.resolution_combo = QComboBox() + self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.resolution_combo.addItem("Leave unchanged") + self.resolution_combo.addItems([ + "1280x720", + "1280x800 (Steam Deck)", + "1366x768", + "1440x900", + "1600x900", + "1600x1200", + "1680x1050", + "1920x1080", + "1920x1200", + "2048x1152", + "2560x1080", + "2560x1440", + "2560x1600", + "3440x1440", + "3840x1600", + "3840x2160", + "3840x2400", + "5120x1440", + "5120x2160", + "7680x4320" + ]) + # Load saved resolution if available + saved_resolution = self.resolution_service.get_saved_resolution() + is_steam_deck = False + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + is_steam_deck = True + except Exception: + pass + 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) + debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + elif is_steam_deck: + # Set default to 1280x800 (Steam Deck) + combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] + if "1280x800 (Steam Deck)" in combo_items: + self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) + else: + 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) + form_section_widget = QWidget() + form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + form_section_widget.setLayout(form_grid) + form_section_widget.setMinimumHeight(220) # Increased to allow RED API key box proper height + form_section_widget.setMaximumHeight(240) # Increased to allow RED API key box proper height + if self.debug: + form_section_widget.setStyleSheet("border: 2px solid blue;") + form_section_widget.setToolTip("FORM_SECTION") + user_config_vbox.addWidget(form_section_widget) + # --- Buttons --- + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + self.start_btn = QPushButton("Start Installation") + btn_row.addWidget(self.start_btn) + + + + # Cancel button (goes back to menu) + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.cancel_and_cleanup) + btn_row.addWidget(self.cancel_btn) + + # Cancel Installation button (appears during installation) + self.cancel_install_btn = QPushButton("Cancel Installation") + self.cancel_install_btn.clicked.connect(self.cancel_installation) + self.cancel_install_btn.setVisible(False) # Hidden by default + btn_row.addWidget(self.cancel_install_btn) + + # Wrap button row in widget for debug borders + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + btn_row_widget.setToolTip("BUTTON_ROW") + user_config_widget = QWidget() + user_config_widget.setLayout(user_config_vbox) + user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) # Allow vertical expansion to fill space + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + user_config_widget.setToolTip("USER_CONFIG_WIDGET") + # Right: process monitor (as before) + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") + self.process_monitor_heading = QLabel("[Process Monitor]") + self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") + self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor_heading) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + process_monitor_widget.setToolTip("PROCESS_MONITOR") + upper_hbox.addWidget(user_config_widget, stretch=1) + upper_hbox.addWidget(process_monitor_widget, stretch=3) + upper_hbox.setAlignment(Qt.AlignTop) + upper_section_widget = QWidget() + upper_section_widget.setLayout(upper_hbox) + upper_section_widget.setMaximumHeight(320) # Increased to ensure resolution dropdown is visible + if self.debug: + upper_section_widget.setStyleSheet("border: 2px solid green;") + upper_section_widget.setToolTip("UPPER_SECTION") + main_overall_vbox.addWidget(upper_section_widget) + # Remove spacing - console should expand to fill available space + # --- Console output area (full width, placeholder for now) --- + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing + self.console.setMaximumHeight(1000) # Allow growth when space available + self.console.setFontFamily('monospace') + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + self.console.setToolTip("CONSOLE") + + # Set up scroll tracking for professional auto-scroll behavior + self._setup_scroll_tracking() + + # Create a container that holds console + button row with proper spacing + console_and_buttons_widget = QWidget() + console_and_buttons_layout = QVBoxLayout() + console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) + console_and_buttons_layout.setSpacing(8) # Small gap between console and buttons + + console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space + console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container + + console_and_buttons_widget.setLayout(console_and_buttons_layout) + if self.debug: + console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") + console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") + main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space + self.setLayout(main_overall_vbox) + + self.current_modlists = [] + + # --- Process Monitor (right) --- + self.process = None + self.log_timer = None + self.last_log_pos = 0 + # --- Process Monitor Timer --- + self.top_timer = QTimer(self) + self.top_timer.timeout.connect(self.update_top_panel) + self.top_timer.start(2000) + # --- Start Installation button --- + self.start_btn.clicked.connect(self.validate_and_start_install) + self.steam_restart_finished.connect(self._on_steam_restart_finished) + + + + # Initialize process tracking + self.process = None + + def _open_url_safe(self, url): + """Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller""" + import subprocess + try: + subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + print(f"Warning: Could not open URL {url}: {e}") + + def resizeEvent(self, event): + """Handle window resize to prioritize form over console""" + super().resizeEvent(event) + self._adjust_console_for_form_priority() + + def _adjust_console_for_form_priority(self): + """Console now dynamically fills available space with stretch=1, no manual calculation needed""" + # The console automatically fills remaining space due to stretch=1 in the layout + # Remove any fixed height constraints to allow natural stretching + self.console.setMaximumHeight(16777215) # Reset to default maximum + self.console.setMinimumHeight(50) # Keep minimum height for usability + + def showEvent(self, event): + """Called when the widget becomes visible - reload saved API key only""" + 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) + # Do NOT load saved parent directories + + def _load_saved_parent_directories(self): + """No-op: do not pre-populate install/download directories from saved values.""" + pass + + def _update_directory_suggestions(self, modlist_name): + """Update directory suggestions based on modlist name""" + try: + if not modlist_name: + return + + # Update install directory suggestion with modlist name + saved_install_parent = self.config_handler.get_default_install_parent_dir() + if saved_install_parent: + suggested_install_dir = os.path.join(saved_install_parent, modlist_name) + self.install_dir_edit.setText(suggested_install_dir) + debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") + + # Update download directory suggestion + saved_download_parent = self.config_handler.get_default_download_parent_dir() + if saved_download_parent: + suggested_download_dir = os.path.join(saved_download_parent, "Downloads") + self.downloads_dir_edit.setText(suggested_download_dir) + debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") + + except Exception as e: + debug_print(f"DEBUG: Error updating directory suggestions: {e}") + + def _save_parent_directories(self, install_dir, downloads_dir): + """Removed automatic saving - user should set defaults in settings""" + pass + + def _on_api_key_text_changed(self, text): + """Handle API key text changes for obfuscation timing""" + if not self.api_key_is_obfuscated: + self.api_key_original_text = text + # Restart the obfuscation timer (3 seconds after last change) + self.api_key_obfuscation_timer.stop() + if text.strip(): # Only start timer if there's actual text + self.api_key_obfuscation_timer.start(3000) # 3 seconds + else: + # If currently obfuscated and user is typing/pasting, un-obfuscate + if text != self.api_key_service.get_api_key_display(self.api_key_original_text): + self.api_key_is_obfuscated = False + self.api_key_original_text = text + if text.strip(): + self.api_key_obfuscation_timer.start(3000) + + def _on_api_key_focus_in(self, event): + """Handle API key field gaining focus - de-obfuscate if needed""" + # Call the original focusInEvent first + QLineEdit.focusInEvent(self.api_key_edit, event) + if self.api_key_is_obfuscated: + self.api_key_edit.blockSignals(True) + self.api_key_edit.setText(self.api_key_original_text) + self.api_key_is_obfuscated = False + self.api_key_edit.blockSignals(False) + self.api_key_obfuscation_timer.stop() + + def _on_api_key_focus_out(self, event): + """Handle API key field losing focus - immediately obfuscate""" + QLineEdit.focusOutEvent(self.api_key_edit, event) + self._obfuscate_api_key() + + def _obfuscate_api_key(self): + """Obfuscate the API key text field""" + if not self.api_key_is_obfuscated and self.api_key_original_text.strip(): + # Block signals to prevent recursion + self.api_key_edit.blockSignals(True) + # Show masked version + masked_text = self.api_key_service.get_api_key_display(self.api_key_original_text) + self.api_key_edit.setText(masked_text) + self.api_key_is_obfuscated = True + # Re-enable signals + self.api_key_edit.blockSignals(False) + + def _get_actual_api_key(self): + """Get the actual API key value (not the obfuscated version)""" + if self.api_key_is_obfuscated: + return self.api_key_original_text + else: + return self.api_key_edit.text() + + def open_game_type_dialog(self): + dlg = SelectionDialog("Select Game Type", self.game_types, self, show_search=False) + if dlg.exec() == QDialog.Accepted and dlg.selected_item: + self.game_type_btn.setText(dlg.selected_item) + self.fetch_modlists_for_game_type(dlg.selected_item) + + def fetch_modlists_for_game_type(self, game_type): + self.current_game_type = game_type # Store for display formatting + self.modlist_btn.setText("Fetching modlists...") + self.modlist_btn.setEnabled(False) + game_type_map = { + "Skyrim": "skyrim", + "Fallout 4": "fallout4", + "Fallout New Vegas": "falloutnv", + "Oblivion": "oblivion", + "Starfield": "starfield", + "Oblivion Remastered": "oblivion_remastered", + "Enderal": "enderal", + "Other": "other" + } + cli_game_type = game_type_map.get(game_type, "other") + log_path = self.modlist_log_path + # Use backend service directly - NO CLI CALLS + self.fetch_thread = ModlistFetchThread( + cli_game_type, log_path, mode='list-modlists') + self.fetch_thread.result.connect(self.on_modlists_fetched) + self.fetch_thread.start() + + def on_modlists_fetched(self, modlist_infos, error): + # Handle the case where modlist_infos might be strings (backward compatibility) + if modlist_infos and isinstance(modlist_infos[0], str): + # Old format - just IDs as strings + filtered = [m for m in modlist_infos if m and not m.startswith('DEBUG:')] + self.current_modlists = filtered + self.current_modlist_display = filtered + else: + # New format - full modlist objects with enhanced metadata + filtered_modlists = [m for m in modlist_infos if m and hasattr(m, 'id')] + filtered = filtered_modlists # Set filtered for the condition check below + self.current_modlists = [m.id for m in filtered_modlists] # Keep IDs for selection + + # Create enhanced display strings with size info and status indicators + display_strings = [] + for modlist in filtered_modlists: + # Get enhanced metadata + download_size = getattr(modlist, 'download_size', '') + install_size = getattr(modlist, 'install_size', '') + total_size = getattr(modlist, 'total_size', '') + status_down = getattr(modlist, 'status_down', False) + status_nsfw = getattr(modlist, 'status_nsfw', False) + + # Format display string without redundant game type: "Modlist Name - Download|Install|Total" + # For "Other" category, include game type in brackets for clarity + # Use padding to create alignment: left-aligned name, right-aligned sizes + if hasattr(self, 'current_game_type') and self.current_game_type == "Other": + name_part = f"{modlist.name} [{modlist.game}]" + else: + name_part = modlist.name + size_part = f"{download_size}|{install_size}|{total_size}" + + # Create aligned display using string formatting (approximate alignment) + display_str = f"{name_part:<50} {size_part:>15}" + + # Add status indicators at the beginning if present + if status_down or status_nsfw: + status_parts = [] + if status_down: + status_parts.append("[DOWN]") + if status_nsfw: + status_parts.append("[NSFW]") + display_str = " ".join(status_parts) + " " + display_str + + display_strings.append(display_str) + + self.current_modlist_display = display_strings + + # Create mapping from display string back to modlist ID for selection + self._modlist_id_map = {} + if len(self.current_modlist_display) == len(self.current_modlists): + self._modlist_id_map = {display: modlist_id for display, modlist_id in + zip(self.current_modlist_display, self.current_modlists)} + else: + # Fallback for backward compatibility + self._modlist_id_map = {mid: mid for mid in self.current_modlists} + if error: + self.modlist_btn.setText("Error fetching modlists.") + self.modlist_btn.setEnabled(False) + # Don't write to log file before workflow starts - just show error in UI + elif filtered: + self.modlist_btn.setText("Select Modlist") + self.modlist_btn.setEnabled(True) + else: + self.modlist_btn.setText("No modlists found.") + self.modlist_btn.setEnabled(False) + + def open_modlist_dialog(self): + if not self.current_modlist_display: + return + dlg = SelectionDialog("Select Modlist", self.current_modlist_display, self, show_search=True, placeholder_text="Search modlists...", show_legend=True) + if dlg.exec() == QDialog.Accepted and dlg.selected_item: + modlist_id = self._modlist_id_map.get(dlg.selected_item, dlg.selected_item) + self.modlist_btn.setText(modlist_id) + # Fetch and store the full ModlistInfo for unsupported game detection + try: + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.configuration import SystemInfo + 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 + system_info = SystemInfo(is_steamdeck=is_steamdeck) + modlist_service = ModlistService(system_info) + all_modlists = modlist_service.list_modlists() + selected_info = next((m for m in all_modlists if m.id == modlist_id), None) + self.selected_modlist_info = selected_info.to_dict() if selected_info else None + + # Auto-populate the Modlist Name field with the display name (user can still modify) + if selected_info and selected_info.name: + self.modlist_name_edit.setText(selected_info.name) + except Exception as e: + self.selected_modlist_info = None + + def browse_wabbajack_file(self): + file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)") + if file: + self.file_edit.setText(file) + + def browse_install_dir(self): + dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text()) + if dir: + self.install_dir_edit.setText(dir) + + def browse_downloads_dir(self): + dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text()) + if dir: + self.downloads_dir_edit.setText(dir) + + def go_back(self): + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(3) # Return to Modlist Tasks menu + + def update_top_panel(self): + try: + result = subprocess.run([ + "ps", "-eo", "pcpu,pmem,comm,args" + ], stdout=subprocess.PIPE, text=True, timeout=2) + lines = result.stdout.splitlines() + header = "CPU%\tMEM%\tCOMMAND" + filtered = [header] + process_rows = [] + for line in lines[1:]: + line_lower = line.lower() + if ( + ("jackify-engine" in line_lower or "7zz" in line_lower or "compressonator" in line_lower or + "wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower) + and "jackify-gui.py" not in line_lower + ): + cols = line.strip().split(None, 3) + if len(cols) >= 3: + process_rows.append(cols) + process_rows.sort(key=lambda x: float(x[0]), reverse=True) + for cols in process_rows: + filtered.append('\t'.join(cols)) + if len(filtered) == 1: + filtered.append("[No Jackify-related processes found]") + self.process_monitor.setPlainText('\n'.join(filtered)) + except Exception as e: + self.process_monitor.setPlainText(f"[process info unavailable: {e}]") + + def _check_protontricks(self): + """Check if protontricks is available before critical operations""" + try: + is_installed, installation_type, details = self.protontricks_service.detect_protontricks() + + if not is_installed: + # Show protontricks error dialog + from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog + dialog = ProtontricksErrorDialog(self.protontricks_service, self) + result = dialog.exec() + + if result == QDialog.Rejected: + return False + + # Re-check after dialog + is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False) + return is_installed + + return True + + except Exception as e: + print(f"Error checking protontricks: {e}") + MessageService.warning(self, "Protontricks Check Failed", + f"Unable to verify protontricks installation: {e}\n\n" + "Continuing anyway, but some features may not work correctly.") + return True # Continue anyway + + def _on_api_key_save_toggled(self, checked): + """Handle immediate API key saving with silent validation when checkbox is toggled""" + try: + if checked: + # Save API key if one is entered + api_key = self._get_actual_api_key().strip() + if api_key: + # Silently validate API key first + is_valid, validation_message = self.api_key_service.validate_api_key_works(api_key) + if not is_valid: + # Show error dialog for invalid API key + from jackify.frontends.gui.services.message_service import MessageService + MessageService.critical( + self, + "Invalid API Key", + f"The API key is invalid and cannot be saved.\n\nError: {validation_message}", + safety_level="low" + ) + self.save_api_key_checkbox.setChecked(False) # Uncheck on validation failure + return + + # API key is valid, proceed with saving + success = self.api_key_service.save_api_key(api_key) + if success: + self._show_api_key_feedback("✓ API key saved successfully", is_success=True) + debug_print("DEBUG: API key validated and saved immediately on checkbox toggle") + else: + self._show_api_key_feedback("✗ Failed to save API key - check permissions", is_success=False) + # Uncheck the checkbox since save failed + 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) + # Uncheck the checkbox since no key to save + self.save_api_key_checkbox.setChecked(False) + else: + # Clear saved API key when unchecked + if self.api_key_service.has_saved_api_key(): + success = self.api_key_service.clear_saved_api_key() + if success: + self._show_api_key_feedback("✓ API key cleared", is_success=True) + debug_print("DEBUG: Saved API key cleared immediately on checkbox toggle") + else: + self._show_api_key_feedback("✗ Failed to clear API key", is_success=False) + debug_print("DEBUG: Failed to clear API key") + except Exception as e: + self._show_api_key_feedback(f"✗ Error: {str(e)}", is_success=False) + self.save_api_key_checkbox.setChecked(False) + debug_print(f"DEBUG: Error in _on_api_key_save_toggled: {e}") + + def _show_api_key_feedback(self, message, is_success=True): + """Show temporary feedback message for API key operations""" + # Use tooltip for immediate feedback + color = "#22c55e" if is_success else "#ef4444" # Green for success, red for error + self.save_api_key_checkbox.setToolTip(message) + + # Temporarily change checkbox style to show feedback + original_style = self.save_api_key_checkbox.styleSheet() + feedback_style = f"QCheckBox {{ color: {color}; font-weight: bold; }}" + self.save_api_key_checkbox.setStyleSheet(feedback_style) + + # Reset style and tooltip after 3 seconds + from PySide6.QtCore import QTimer + def reset_feedback(): + self.save_api_key_checkbox.setStyleSheet(original_style) + self.save_api_key_checkbox.setToolTip("") + + QTimer.singleShot(3000, reset_feedback) + + + def validate_and_start_install(self): + import time + self._install_workflow_start_time = time.time() + debug_print('DEBUG: validate_and_start_install called') + + # Check protontricks before proceeding + if not self._check_protontricks(): + return + + try: + tab_index = self.source_tabs.currentIndex() + install_mode = 'online' + if tab_index == 1: # .wabbajack File tab + 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.") + 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.") + return + + # For online modlists, use machine_url instead of display name + if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: + machine_url = self.selected_modlist_info.get('machine_url') + if machine_url: + modlist = machine_url # Use machine URL for installation + debug_print(f"DEBUG: Using machine_url for installation: {machine_url}") + else: + debug_print("DEBUG: No machine_url found in selected_modlist_info, using display name") + install_dir = self.install_dir_edit.text().strip() + downloads_dir = self.downloads_dir_edit.text().strip() + api_key = self._get_actual_api_key().strip() + modlist_name = self.modlist_name_edit.text().strip() + missing_fields = [] + if not modlist_name: + missing_fields.append("Modlist Name") + if not install_dir: + missing_fields.append("Install Directory") + if not downloads_dir: + missing_fields.append("Downloads Directory") + if not api_key: + 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)) + return + validation_handler = ValidationHandler() + from pathlib import Path + is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir)) + if not is_safe: + dlg = WarningDialog(reason, parent=self) + if not dlg.exec() or not dlg.confirmed: + return + if not os.path.isdir(install_dir): + create = MessageService.question(self, "Create Directory?", + f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?", + critical=False # Non-critical, won't steal focus + ) + if create == QMessageBox.Yes: + try: + os.makedirs(install_dir, exist_ok=True) + except Exception as e: + MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}") + return + else: + return + if not os.path.isdir(downloads_dir): + create = MessageService.question(self, "Create Directory?", + f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?", + critical=False # Non-critical, won't steal focus + ) + if create == QMessageBox.Yes: + try: + os.makedirs(downloads_dir, exist_ok=True) + except Exception as e: + MessageService.critical(self, "Error", f"Failed to create downloads directory:\n{e}") + return + else: + return + # Handle API key saving BEFORE validation (to match settings dialog behavior) + if self.save_api_key_checkbox.isChecked(): + if api_key: + success = self.api_key_service.save_api_key(api_key) + if success: + debug_print("DEBUG: API key saved successfully") + else: + debug_print("DEBUG: Failed to save API key") + else: + # If checkbox is unchecked, clear any saved API key + if self.api_key_service.has_saved_api_key(): + self.api_key_service.clear_saved_api_key() + debug_print("DEBUG: Saved API key cleared") + + # Validate API key for installation purposes + if not api_key or not self.api_key_service._validate_api_key_format(api_key): + MessageService.warning(self, "Invalid API Key", "Please enter a valid Nexus API Key.") + return + + # Handle resolution saving + resolution = self.resolution_combo.currentText() + if resolution and resolution != "Leave unchanged": + success = self.resolution_service.save_resolution(resolution) + if success: + debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + else: + debug_print("DEBUG: Failed to save resolution") + else: + # Clear saved resolution if "Leave unchanged" is selected + if self.resolution_service.has_saved_resolution(): + self.resolution_service.clear_saved_resolution() + debug_print("DEBUG: Saved resolution cleared") + + # Handle parent directory saving + self._save_parent_directories(install_dir, downloads_dir) + + # Detect game type and check support + game_type = None + game_name = None + + if install_mode == 'file': + # Parse .wabbajack file to get game type + from pathlib import Path + wabbajack_path = Path(modlist) + result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path) + if result: + if isinstance(result, tuple): + game_type, raw_game_type = result + # Get display name for the game + display_names = { + 'skyrim': 'Skyrim', + 'fallout4': 'Fallout 4', + 'falloutnv': 'Fallout New Vegas', + 'oblivion': 'Oblivion', + 'starfield': 'Starfield', + 'oblivion_remastered': 'Oblivion Remastered', + 'enderal': 'Enderal' + } + if game_type == 'unknown' and raw_game_type: + game_name = raw_game_type + else: + game_name = display_names.get(game_type, game_type) + else: + game_type = result + 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(game_type, game_type) + else: + # For online modlists, try to get game type from selected modlist + if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: + game_name = self.selected_modlist_info.get('game', '') + debug_print(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'") + + # Map game name to game type + game_mapping = { + 'skyrim special edition': 'skyrim', + 'skyrim': 'skyrim', + 'fallout 4': 'fallout4', + 'fallout new vegas': 'falloutnv', + 'oblivion': 'oblivion', + 'starfield': 'starfield', + 'oblivion_remastered': 'oblivion_remastered', + 'enderal': 'enderal' + } + game_type = game_mapping.get(game_name.lower()) + debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") + if not game_type: + game_type = 'unknown' + debug_print(f"DEBUG: Game type not found in mapping, setting to 'unknown'") + else: + debug_print(f"DEBUG: No selected_modlist_info found") + game_type = 'unknown' + + # Store game type and name for later use + self._current_game_type = game_type + self._current_game_name = game_name + + # Check if game is supported + debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported") + 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}") + + if game_type and not is_supported: + debug_print(f"DEBUG: Game '{game_type}' is not supported, showing dialog") + # Show unsupported game dialog + dialog = UnsupportedGameDialog(self, game_name) + if not dialog.show_dialog(self, game_name): + # User cancelled + return + + self.console.clear() + self.process_monitor.clear() + + # Update button states for installation + self.start_btn.setEnabled(False) + self.cancel_btn.setVisible(False) + self.cancel_install_btn.setVisible(True) + + debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, api_key={api_key[:6]}..., install_mode={install_mode}') + self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode) + except Exception as e: + 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) + 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 + + 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') + + # Rotate log file at start of each workflow run (keep 5 backups) + from jackify.backend.handlers.logging_handler import LoggingHandler + from pathlib import Path + log_handler = LoggingHandler() + log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) + + # Clear console for fresh installation output + self.console.clear() + self._safe_append_text("Starting modlist installation with custom progress handling...") + + # Update UI state for installation + self.start_btn.setEnabled(False) + self.cancel_btn.setVisible(False) + self.cancel_install_btn.setVisible(True) + + # Create installation thread + from PySide6.QtCore import QThread, Signal + + class InstallationThread(QThread): + output_received = Signal(str) + progress_received = Signal(str) + installation_finished = Signal(bool, str) + + def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name, install_mode='online'): + super().__init__() + self.modlist = modlist + self.install_dir = install_dir + self.downloads_dir = downloads_dir + self.api_key = api_key + self.modlist_name = modlist_name + self.install_mode = install_mode + self.cancelled = False + self.process_manager = None + + def cancel(self): + self.cancelled = True + if self.process_manager: + self.process_manager.cancel() + + def run(self): + try: + engine_path = get_jackify_engine_path() + if self.install_mode == 'file': + cmd = [engine_path, "install", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] + else: + cmd = [engine_path, "install", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] + + # Check for debug mode and add --debug flag + 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') + debug_print("DEBUG: Added --debug flag to jackify-engine command") + env = os.environ.copy() + env['NEXUS_API_KEY'] = self.api_key + self.process_manager = ProcessManager(cmd, env=env, text=False) + ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') + buffer = b'' + last_was_blank = False + while True: + if self.cancelled: + self.cancel() + break + char = self.process_manager.read_stdout_char() + if not char: + break + buffer += char + while b'\n' in buffer or b'\r' in buffer: + if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True): + line, buffer = buffer.split(b'\r', 1) + line = ansi_escape.sub(b'', line) + decoded = line.decode('utf-8', errors='replace') + self.progress_received.emit(decoded) + elif b'\n' in buffer: + line, buffer = buffer.split(b'\n', 1) + line = ansi_escape.sub(b'', line) + decoded = line.decode('utf-8', errors='replace') + # Collapse multiple blank lines to one + if decoded.strip() == '': + if not last_was_blank: + self.output_received.emit('') + last_was_blank = True + else: + self.output_received.emit(decoded) + last_was_blank = False + if buffer: + line = ansi_escape.sub(b'', buffer) + decoded = line.decode('utf-8', errors='replace') + self.output_received.emit(decoded) + self.process_manager.wait() + if self.cancelled: + self.installation_finished.emit(False, "Installation cancelled by user") + elif self.process_manager.proc.returncode == 0: + self.installation_finished.emit(True, "Installation completed successfully") + else: + self.installation_finished.emit(False, "Installation failed") + except Exception as e: + self.installation_finished.emit(False, f"Installation error: {str(e)}") + finally: + if self.cancelled and self.process_manager: + self.process_manager.cancel() + + # After the InstallationThread class definition, add: + self.install_thread = InstallationThread( + modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode + ) + self.install_thread.output_received.connect(self.on_installation_output) + self.install_thread.progress_received.connect(self.on_installation_progress) + self.install_thread.installation_finished.connect(self.on_installation_finished) + self.install_thread.start() + + def on_installation_output(self, message): + """Handle regular output from installation thread""" + # Filter out internal status messages from user console + if message.strip().startswith('[Jackify]'): + # Log internal messages to file but don't show in console + self._write_to_log_file(message) + return + self._safe_append_text(message) + + def on_installation_progress(self, progress_message): + """Replace the last line in the console for progress updates""" + cursor = self.console.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.insertText(progress_message) + # Don't force scroll for progress updates - let user control + + def on_installation_finished(self, success, message): + """Handle installation completion""" + debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}") + if success: + self._safe_append_text(f"\nSuccess: {message}") + self.process_finished(0, QProcess.NormalExit) # Simulate successful completion + else: + self._safe_append_text(f"\nError: {message}") + self.process_finished(1, QProcess.CrashExit) # Simulate error + + def process_finished(self, exit_code, exit_status): + debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") + # Reset button states + self.start_btn.setEnabled(True) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + debug_print("DEBUG: Button states reset in process_finished") + + + if exit_code == 0: + # Check if this was an unsupported game + game_type = getattr(self, '_current_game_type', None) + game_name = getattr(self, '_current_game_name', None) + + if game_type and not self.wabbajack_parser.is_supported_game(game_type): + # Show success message for unsupported games without post-install configuration + MessageService.information( + self, "Modlist Install Complete!", + f"Modlist installation completed successfully!\n\n" + f"Note: Post-install configuration was skipped for unsupported game type: {game_name or game_type}\n\n" + f"You will need to manually configure Steam shortcuts and other post-install steps." + ) + 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 + ) + if reply == QMessageBox.Yes: + # --- Create Steam shortcut BEFORE restarting Steam --- + # Proceed directly to automated prefix creation + self.start_automated_prefix_workflow() + else: + # User selected "No" - show completion message and keep GUI open + self._safe_append_text("\nModlist installation completed successfully!") + self._safe_append_text("Note: You can manually configure Steam integration later if needed.") + MessageService.information( + self, "Installation Complete", + "Modlist installation completed successfully!\n\n" + "The modlist has been installed but Steam integration was skipped.\n" + "You can manually add the modlist to Steam later if desired.", + safety_level="medium" + ) + else: + # Check for user cancellation first + last_output = self.console.toPlainText() + if "cancelled by user" in last_output.lower(): + MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") + else: + MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.") + self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") + self.console.moveCursor(QTextCursor.End) + + def _setup_scroll_tracking(self): + """Set up scroll tracking for professional auto-scroll behavior""" + scrollbar = self.console.verticalScrollBar() + scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) + scrollbar.sliderReleased.connect(self._on_scrollbar_released) + scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) + + def _on_scrollbar_pressed(self): + """User started manually scrolling""" + self._user_manually_scrolled = True + + def _on_scrollbar_released(self): + """User finished manually scrolling""" + self._user_manually_scrolled = False + + def _on_scrollbar_value_changed(self): + """Track if user is at bottom of scroll area""" + scrollbar = self.console.verticalScrollBar() + # Use tolerance to account for rounding and rapid updates + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + # If user manually scrolls to bottom, reset manual scroll flag + if self._was_at_bottom and self._user_manually_scrolled: + # Small delay to allow user to scroll away if they want + from PySide6.QtCore import QTimer + QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) + + def _reset_manual_scroll_if_at_bottom(self): + """Reset manual scroll flag if user is still at bottom after delay""" + scrollbar = self.console.verticalScrollBar() + if scrollbar.value() >= scrollbar.maximum() - 1: + self._user_manually_scrolled = False + + def _safe_append_text(self, text): + """Append text with professional auto-scroll behavior""" + # Write all messages to log file (including internal messages) + self._write_to_log_file(text) + + # Filter out internal status messages from user console display + if text.strip().startswith('[Jackify]'): + # Internal messages are logged but not shown in user console + return + + scrollbar = self.console.verticalScrollBar() + # Check if user was at bottom BEFORE adding text + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + + # Add the text + self.console.append(text) + + # Auto-scroll if user was at bottom and hasn't manually scrolled + # Re-check bottom state after text addition for better reliability + if (was_at_bottom and not self._user_manually_scrolled) or \ + (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): + scrollbar.setValue(scrollbar.maximum()) + # Ensure user can still manually scroll up during rapid updates + if scrollbar.value() == scrollbar.maximum(): + self._was_at_bottom = True + + def _write_to_log_file(self, message): + """Write message to workflow log file with timestamp""" + try: + from datetime import datetime + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(self.modlist_log_path, 'a', encoding='utf-8') as f: + f.write(f"[{timestamp}] {message}\n") + except Exception: + # Logging should never break the workflow + pass + + def restart_steam_and_configure(self): + """Restart Steam using backend service directly - DECOUPLED FROM CLI""" + debug_print("DEBUG: restart_steam_and_configure called - using direct backend service") + progress = QProgressDialog("Restarting Steam...", None, 0, 0, self) + progress.setWindowTitle("Restarting Steam") + progress.setWindowModality(Qt.WindowModal) + 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") + try: + from jackify.backend.handlers.shortcut_handler import ShortcutHandler + + # Use backend service directly instead of CLI subprocess + shortcut_handler = ShortcutHandler(steamdeck=False) # TODO: Use proper system info + + debug_print("DEBUG: About to call secure_steam_restart()") + success = shortcut_handler.secure_steam_restart() + debug_print(f"DEBUG: secure_steam_restart() returned: {success}") + + out = "Steam restart completed successfully." if success else "Steam restart failed." + + except Exception as e: + debug_print(f"DEBUG: Exception in do_restart: {e}") + success = False + out = str(e) + + self.steam_restart_finished.emit(success, out) + + threading.Thread(target=do_restart, daemon=True).start() + self._steam_restart_progress = progress # Store to close later + + def _on_steam_restart_finished(self, success, out): + debug_print("DEBUG: _on_steam_restart_finished called") + # Safely cleanup progress dialog on main thread + if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress: + try: + self._steam_restart_progress.close() + self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup + except Exception as e: + debug_print(f"DEBUG: Error closing progress dialog: {e}") + 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 + if success: + self._safe_append_text("Steam restarted successfully.") + + # Save context for later use in configuration + self._manual_steps_retry_count = 0 + self._current_modlist_name = self.modlist_name_edit.text().strip() + + # Save resolution for later use in configuration + resolution = self.resolution_combo.currentText() + self._current_resolution = resolution.split()[0] if resolution != "Leave unchanged" else "2560x1600" + + # Use automated prefix creation instead of manual steps + debug_print("DEBUG: Starting automated prefix creation workflow") + self._safe_append_text("Starting automated prefix creation workflow...") + self.start_automated_prefix_workflow() + else: + self._safe_append_text("Failed to restart Steam.\n" + out) + MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.") + + def start_automated_prefix_workflow(self): + """Start the automated prefix creation workflow""" + try: + 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 + + # Run automated prefix creation in separate thread + from PySide6.QtCore import QThread, Signal + + class AutomatedPrefixThread(QThread): + finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp + progress = Signal(str) # progress messages + error = Signal(str) # error messages + show_progress_dialog = Signal(str) # show progress dialog with message + hide_progress_dialog = Signal() # hide progress dialog + conflict_detected = Signal(list) # conflicts list + + def __init__(self, modlist_name, install_dir, final_exe_path): + super().__init__() + self.modlist_name = modlist_name + self.install_dir = install_dir + self.final_exe_path = final_exe_path + + def run(self): + try: + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + + def progress_callback(message): + self.progress.emit(message) + # Show progress dialog during Steam restart + if "Steam restarted successfully" in message: + self.hide_progress_dialog.emit() + elif "Restarting Steam..." in message: + self.show_progress_dialog.emit("Restarting Steam...") + + prefix_service = AutomatedPrefixService() + # Determine Steam Deck once and pass through the workflow + 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 + result = prefix_service.run_working_workflow( + self.modlist_name, self.install_dir, self.final_exe_path, progress_callback, steamdeck=_is_steamdeck + ) + + # Handle the result - check for conflicts + if isinstance(result, tuple) and len(result) == 4: + if result[0] == "CONFLICT": + # Conflict detected - emit signal to main GUI + conflicts = result[1] + self.hide_progress_dialog.emit() + self.conflict_detected.emit(conflicts) + return + else: + # Normal result with timestamp + success, prefix_path, new_appid, last_timestamp = result + elif isinstance(result, tuple) and len(result) == 3: + # Fallback for old format (backward compatibility) + if result[0] == "CONFLICT": + # Conflict detected - emit signal to main GUI + conflicts = result[1] + self.hide_progress_dialog.emit() + self.conflict_detected.emit(conflicts) + return + else: + # Normal result (old format) + success, prefix_path, new_appid = result + last_timestamp = None + else: + # Handle non-tuple result + success = result + prefix_path = "" + new_appid = "0" + last_timestamp = None + + # Ensure progress dialog is hidden when workflow completes + self.hide_progress_dialog.emit() + self.finished.emit(success, prefix_path or "", str(new_appid) if new_appid else "0", last_timestamp) + + except Exception as e: + # Ensure progress dialog is hidden on error + self.hide_progress_dialog.emit() + self.error.emit(str(e)) + + # Create and start thread + self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path) + self.prefix_thread.finished.connect(self.on_automated_prefix_finished) + self.prefix_thread.error.connect(self.on_automated_prefix_error) + self.prefix_thread.progress.connect(self.on_automated_prefix_progress) + self.prefix_thread.show_progress_dialog.connect(self.show_steam_restart_progress) + self.prefix_thread.hide_progress_dialog.connect(self.hide_steam_restart_progress) + self.prefix_thread.conflict_detected.connect(self.show_shortcut_conflict_dialog) + self.prefix_thread.start() + + except Exception as e: + debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}") + import traceback + debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") + self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}") + + 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.") + + 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}") + + def on_automated_prefix_progress(self, progress_msg): + """Handle progress updates from automated prefix creation""" + self._safe_append_text(progress_msg) + + def on_configuration_progress(self, progress_msg): + """Handle progress updates from modlist configuration""" + self._safe_append_text(progress_msg) + + def show_steam_restart_progress(self, message): + """Show Steam restart progress dialog""" + from PySide6.QtWidgets import QProgressDialog + from PySide6.QtCore import Qt + + self.steam_restart_progress = QProgressDialog(message, None, 0, 0, self) + self.steam_restart_progress.setWindowTitle("Restarting Steam") + self.steam_restart_progress.setWindowModality(Qt.WindowModal) + 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""" + if hasattr(self, 'steam_restart_progress') and self.steam_restart_progress: + try: + self.steam_restart_progress.close() + self.steam_restart_progress.deleteLater() + except Exception: + pass + finally: + self.steam_restart_progress = None + self.setEnabled(True) + + 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.") + # 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 + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except: + pass # Ignore errors if already disconnected + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) # Wait up to 5 seconds + self.config_thread.deleteLater() + self.config_thread = None + + def on_configuration_error(self, error_message): + """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}") + + # 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 + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except: + pass # Ignore errors if already disconnected + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) # Wait up to 5 seconds + self.config_thread.deleteLater() + self.config_thread = None + + def show_manual_steps_dialog(self, extra_warning=""): + modlist_name = self.modlist_name_edit.text().strip() or "your modlist" + msg = ( + f"Manual Proton Setup Required for {modlist_name}
" + "After Steam restarts, complete the following steps in Steam:
" + f"1. Locate the '{modlist_name}' entry in your Steam Library
" + "2. Right-click and select 'Properties'
" + "3. Switch to the 'Compatibility' tab
" + "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" + "5. Select 'Proton - Experimental' from the dropdown menu
" + "6. Close the Properties window
" + f"7. Launch '{modlist_name}' from your Steam Library
" + "8. Wait for Mod Organizer 2 to fully open
" + "9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here
" + "
Once you have completed ALL the steps above, click OK to continue." + f"{extra_warning}" + ) + reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") + if reply == QMessageBox.Yes: + self.validate_manual_steps_completion() + 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) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + 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") + + # Add delay to allow Steam filesystem updates to complete + self._safe_append_text("Waiting for Steam filesystem updates to complete...") + import time + time.sleep(2) + + # CRITICAL: Re-detect the AppID after Steam restart and manual steps + # 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) + current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) + + if not current_appid or not current_appid.isdigit(): + self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") + self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") + self._safe_append_text("Suggestion: Check that Steam is running and shortcuts are visible in library") + self.handle_validation_failure("Could not find Steam shortcut") + return + + self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}") + self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}") + + # Check 1: Proton version + proton_ok = False + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + from jackify.backend.handlers.path_handler import PathHandler + + # Initialize ModlistHandler with correct parameters + path_handler = PathHandler() + modlist_handler = ModlistHandler(steamdeck=False, verbose=False) + + # Set required properties manually after initialization + modlist_handler.modlist_dir = install_dir + modlist_handler.appid = current_appid + modlist_handler.game_var = "skyrimspecialedition" # Default for now + + # Set compat_data_path for Proton detection + compat_data_path_str = path_handler.find_compat_data(current_appid) + if compat_data_path_str: + from pathlib import Path + modlist_handler.compat_data_path = Path(compat_data_path_str) + + # Check Proton version + self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...") + if modlist_handler._detect_proton_version(): + self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'") + if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower(): + proton_ok = True + self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}") + else: + self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") + else: + self._safe_append_text("Error: Could not detect Proton version from any source") + + except Exception as e: + self._safe_append_text(f"Error checking Proton version: {e}") + proton_ok = False + + # Check 2: Compatdata directory exists + compatdata_ok = False + try: + from jackify.backend.handlers.path_handler import PathHandler + path_handler = PathHandler() + + self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...") + self._safe_append_text("Checking standard Steam locations and Flatpak Steam...") + prefix_path_str = path_handler.find_compat_data(current_appid) + self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'") + + if prefix_path_str and os.path.isdir(prefix_path_str): + compatdata_ok = True + self._safe_append_text(f"Compatdata directory found: {prefix_path_str}") + else: + if prefix_path_str: + self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") + else: + self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") + self._safe_append_text("Suggestion: Ensure you launched the shortcut from Steam at least once") + self._safe_append_text("Suggestion: Check if Steam is using Flatpak (different file paths)") + + except Exception as e: + self._safe_append_text(f"Error checking compatdata: {e}") + compatdata_ok = False + + # Handle validation results + if proton_ok and compatdata_ok: + self._safe_append_text("Manual steps validation passed!") + self._safe_append_text("Continuing configuration with updated AppID...") + + # Continue configuration with the corrected AppID and context + self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) + else: + # Validation failed - handle retry logic + missing_items = [] + if not proton_ok: + missing_items.append("• Proton - Experimental not set") + if not compatdata_ok: + missing_items.append("• Shortcut not launched from Steam (no compatdata)") + + missing_text = "\n".join(missing_items) + self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") + self.handle_validation_failure(missing_text) + + def show_shortcut_conflict_dialog(self, conflicts): + """Show dialog to resolve shortcut name conflicts""" + conflict_names = [c['name'] for c in conflicts] + conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" + + modlist_name = self.modlist_name_edit.text().strip() + + # Create dialog with Jackify styling + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout + from PySide6.QtCore import Qt + + dialog = QDialog(self) + dialog.setWindowTitle("Steam Shortcut Conflict") + dialog.setModal(True) + dialog.resize(450, 180) + + # Apply Jackify dark theme styling + dialog.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + color: #ffffff; + } + QLabel { + color: #ffffff; + font-size: 14px; + padding: 10px 0px; + } + QLineEdit { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px; + font-size: 14px; + selection-background-color: #3fd0ea; + } + QLineEdit:focus { + border-color: #3fd0ea; + } + QPushButton { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + min-width: 120px; + } + QPushButton:hover { + background-color: #505050; + border-color: #3fd0ea; + } + QPushButton:pressed { + background-color: #303030; + } + """) + + layout = QVBoxLayout(dialog) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Conflict message + conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") + layout.addWidget(conflict_label) + + # Text input for new name + name_input = QLineEdit(modlist_name) + name_input.selectAll() + layout.addWidget(name_input) + + # Buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + create_button = QPushButton("Create with New Name") + cancel_button = QPushButton("Cancel") + + button_layout.addStretch() + button_layout.addWidget(cancel_button) + button_layout.addWidget(create_button) + layout.addLayout(button_layout) + + # Connect signals + def on_create(): + new_name = name_input.text().strip() + if new_name and new_name != modlist_name: + dialog.accept() + # Retry workflow with new name + self.retry_automated_workflow_with_new_name(new_name) + elif new_name == modlist_name: + # Same name - show warning + from jackify.backend.services.message_service import MessageService + MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") + else: + # Empty name + from jackify.backend.services.message_service import MessageService + MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + + def on_cancel(): + dialog.reject() + self._safe_append_text("Shortcut creation cancelled by user") + + create_button.clicked.connect(on_create) + cancel_button.clicked.connect(on_cancel) + + # Make Enter key work + name_input.returnPressed.connect(on_create) + + dialog.exec() + + def retry_automated_workflow_with_new_name(self, new_name): + """Retry the automated workflow with a new shortcut name""" + # Update the modlist name field temporarily + original_name = self.modlist_name_edit.text() + self.modlist_name_edit.setText(new_name) + + # Restart the automated workflow + self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") + self.start_automated_prefix_workflow() + + def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None): + """Continue the configuration process with the new AppID after automated prefix creation""" + # Headers are now shown at start of Steam Integration + # No need to show them again here + debug_print("Configuration phase continues after Steam Integration") + + debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") + try: + # Update the context with the new AppID (same format as manual steps) + updated_context = { + 'name': modlist_name, + 'path': install_dir, + 'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"), + 'modlist_value': None, + 'modlist_source': None, + 'resolution': getattr(self, '_current_resolution', '2560x1600'), + '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 + 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition' + } + self.context = updated_context # Ensure context is always set + debug_print(f"Updated context with new AppID: {new_appid}") + + # 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): + super().__init__() + self.context = context + + def run(self): + try: + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + + # Initialize backend service + system_info = SystemInfo(is_steamdeck=False) + modlist_service = ModlistService(system_info) + + # Convert context to ModlistContext for service + modlist_context = ModlistContext( + name=self.context['name'], + install_dir=Path(self.context['path']), + download_dir=Path(self.context['path']).parent / 'Downloads', # Default + game_type='skyrim', # Default for now + 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'), + skip_confirmation=True, + engine_installed=True # Skip path manipulation for engine workflows + ) + + # Add app_id to context + modlist_context.app_id = self.context['appid'] + + # Define callbacks + def progress_callback(message): + self.progress_update.emit(message) + + def completion_callback(success, message, modlist_name): + self.configuration_complete.emit(success, message, modlist_name) + + def manual_steps_callback(modlist_name, retry_count): + # 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 + result = modlist_service.configure_modlist_post_steam( + context=modlist_context, + progress_callback=progress_callback, + manual_steps_callback=manual_steps_callback, + completion_callback=completion_callback + ) + + if not result: + self.progress_update.emit("Configuration failed to start") + self.error_occurred.emit("Configuration failed to start") + + except Exception as e: + self.error_occurred.emit(str(e)) + + # Start configuration thread + self.config_thread = ConfigThread(updated_context) + 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) + self.config_thread.start() + + except Exception as e: + self._safe_append_text(f"Error continuing configuration: {e}") + import traceback + self._safe_append_text(f"Full traceback: {traceback.format_exc()}") + self.on_configuration_error(str(e)) + + + + def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir): + """Continue the configuration process with the corrected AppID after manual steps validation""" + try: + # Update the context with the new AppID + updated_context = { + 'name': modlist_name, + 'path': install_dir, + 'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"), + 'modlist_value': None, + 'modlist_source': None, + 'resolution': getattr(self, '_current_resolution', '2560x1600'), + 'skip_confirmation': True, + 'manual_steps_completed': True, # Mark as completed + 'appid': new_appid # Use the NEW AppID from Steam + } + + debug_print(f"Updated context with new AppID: {new_appid}") + + # Clean up old thread if exists and wait for it to finish + if hasattr(self, 'config_thread') and self.config_thread is not None: + # Disconnect all signals to prevent "Internal C++ object already deleted" errors + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except: + pass # Ignore errors if already disconnected + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) # Wait up to 5 seconds + self.config_thread.deleteLater() + self.config_thread = None + + # Start new config thread + self.config_thread = self._create_config_thread(updated_context) + 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) + self.config_thread.start() + + except Exception as e: + self._safe_append_text(f"Error continuing configuration: {e}") + self.on_configuration_error(str(e)) + + def _create_config_thread(self, context): + """Create a new ConfigThread with proper lifecycle management""" + from PySide6.QtCore import QThread, Signal + + class ConfigThread(QThread): + progress_update = Signal(str) + configuration_complete = Signal(bool, str, str) + error_occurred = Signal(str) + + def __init__(self, context, parent=None): + super().__init__(parent) + self.context = context + + def run(self): + try: + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + + # Initialize backend service + system_info = SystemInfo(is_steamdeck=False) + modlist_service = ModlistService(system_info) + + # Convert context to ModlistContext for service + modlist_context = ModlistContext( + name=self.context['name'], + install_dir=Path(self.context['path']), + download_dir=Path(self.context['path']).parent / 'Downloads', # Default + game_type='skyrim', # Default for now + 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'), # Pass resolution from GUI + skip_confirmation=True, + engine_installed=True # Skip path manipulation for engine workflows + ) + + # Add app_id to context + if 'appid' in self.context: + modlist_context.app_id = self.context['appid'] + + # Define callbacks + def progress_callback(message): + self.progress_update.emit(message) + + def completion_callback(success, message, modlist_name): + self.configuration_complete.emit(success, message, modlist_name) + + def manual_steps_callback(modlist_name, retry_count): + # This shouldn't happen since manual steps should be done + self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") + + # Call the new service method for post-Steam configuration + result = modlist_service.configure_modlist_post_steam( + context=modlist_context, + progress_callback=progress_callback, + manual_steps_callback=manual_steps_callback, + completion_callback=completion_callback + ) + + if not result: + self.progress_update.emit("WARNING: configure_modlist_post_steam returned False") + + except Exception as e: + import traceback + error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}" + self.progress_update.emit(f"DEBUG: {error_details}") + self.error_occurred.emit(str(e)) + + return ConfigThread(context, parent=self) + + def handle_validation_failure(self, missing_text): + """Handle failed validation with retry logic""" + self._manual_steps_retry_count += 1 + + if self._manual_steps_retry_count < 3: + # Show retry dialog with increasingly detailed guidance + retry_guidance = "" + if self._manual_steps_retry_count == 1: + retry_guidance = "\n\nTip: Make sure Steam is fully restarted before trying again." + elif self._manual_steps_retry_count == 2: + retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location." + + MessageService.critical(self, "Manual Steps Incomplete", + f"Manual steps validation failed:\n\n{missing_text}\n\n" + f"Please complete the missing steps and try again.{retry_guidance}") + # Show manual steps dialog again + extra_warning = "" + if self._manual_steps_retry_count >= 2: + extra_warning = "
It looks like you have not completed the manual steps yet. Please try again." + self.show_manual_steps_dialog(extra_warning) + else: + # Max retries reached + MessageService.critical(self, "Manual Steps Failed", + "Manual steps validation failed after multiple attempts.\n\n" + "Common issues:\n" + "• Steam not fully restarted\n" + "• Shortcut not launched from Steam\n" + "• Flatpak Steam using different file paths\n" + "• Proton - Experimental not selected") + self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) + + def show_next_steps_dialog(self, message): + # EXACT LEGACY show_next_steps_dialog + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication + dlg = QDialog(self) + dlg.setWindowTitle("Next Steps") + dlg.setModal(True) + layout = QVBoxLayout(dlg) + label = QLabel(message) + label.setWordWrap(True) + layout.addWidget(label) + btn_row = QHBoxLayout() + btn_return = QPushButton("Return") + btn_exit = QPushButton("Exit") + btn_row.addWidget(btn_return) + btn_row.addWidget(btn_exit) + layout.addLayout(btn_row) + def on_return(): + dlg.accept() + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(0) # Main menu + def on_exit(): + QApplication.quit() + btn_return.clicked.connect(on_return) + btn_exit.clicked.connect(on_exit) + dlg.exec() + + def cleanup_processes(self): + """Clean up any running processes when the window closes or is cancelled""" + debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") + + # Clean up InstallationThread if running + if hasattr(self, 'install_thread') and self.install_thread.isRunning(): + debug_print("DEBUG: Cancelling running InstallationThread") + self.install_thread.cancel() + self.install_thread.wait(3000) # Wait up to 3 seconds + if self.install_thread.isRunning(): + self.install_thread.terminate() + + # Clean up other threads + threads = [ + 'prefix_thread', 'config_thread', 'fetch_thread' + ] + for thread_name in threads: + if hasattr(self, thread_name): + thread = getattr(self, thread_name) + if thread and thread.isRunning(): + debug_print(f"DEBUG: Terminating {thread_name}") + thread.terminate() + thread.wait(1000) # Wait up to 1 second + + def cancel_installation(self): + """Cancel the currently running installation""" + reply = MessageService.question( + self, "Cancel Installation", + "Are you sure you want to cancel the installation?", + critical=False # Non-critical, won't steal focus + ) + + if reply == QMessageBox.Yes: + self._safe_append_text("\n🛑 Cancelling installation...") + + # Cancel the installation thread if it exists + if hasattr(self, 'install_thread') and self.install_thread.isRunning(): + self.install_thread.cancel() + self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown + if self.install_thread.isRunning(): + self.install_thread.terminate() # Force terminate if needed + self.install_thread.wait(1000) + + # Cancel the automated prefix thread if it exists + if hasattr(self, 'prefix_thread') and self.prefix_thread.isRunning(): + self.prefix_thread.terminate() + self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown + if self.prefix_thread.isRunning(): + self.prefix_thread.terminate() # Force terminate if needed + self.prefix_thread.wait(1000) + + # Cancel the configuration thread if it exists + if hasattr(self, 'config_thread') and self.config_thread.isRunning(): + self.config_thread.terminate() + self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown + if self.config_thread.isRunning(): + self.config_thread.terminate() # Force terminate if needed + self.config_thread.wait(1000) + + # Cleanup any remaining processes + self.cleanup_processes() + + # Reset button states + self.start_btn.setEnabled(True) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + self._safe_append_text("Installation cancelled by user.") + + def cancel_and_cleanup(self): + """Handle Cancel button - clean up processes and go back""" + self.cleanup_processes() + self.go_back() + + def closeEvent(self, event): + """Handle window close event - clean up processes""" + self.cleanup_processes() + event.accept() \ No newline at end of file diff --git a/jackify/frontends/gui/screens/main_menu.py b/jackify/frontends/gui/screens/main_menu.py new file mode 100644 index 0000000..59eb744 --- /dev/null +++ b/jackify/frontends/gui/screens/main_menu.py @@ -0,0 +1,128 @@ +""" +MainMenu screen for Jackify GUI (Refactored) +""" +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton +from PySide6.QtGui import QPixmap, QFont +from PySide6.QtCore import Qt +import os +from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT + +class MainMenu(QWidget): + def __init__(self, stacked_widget=None, dev_mode=False): + super().__init__() + self.stacked_widget = stacked_widget + self.dev_mode = dev_mode + layout = QVBoxLayout() + layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + layout.setContentsMargins(50, 50, 50, 50) + layout.setSpacing(20) + + # Title + title = QLabel("Jackify") + title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};") + title.setAlignment(Qt.AlignHCenter) + layout.addWidget(title) + + # Description + desc = QLabel( + "Manage your modlists with native Linux tools. " + "Choose from the options below to install, " + "configure, or manage modlists." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc;") + desc.setAlignment(Qt.AlignHCenter) + layout.addWidget(desc) + + # Separator + layout.addSpacing(16) + sep = QLabel() + sep.setFixedHeight(2) + sep.setStyleSheet("background: #fff;") + layout.addWidget(sep) + layout.addSpacing(16) + + # Menu buttons + button_width = 400 + button_height = 60 + MENU_ITEMS = [ + ("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"), + ("Coming Soon...", "coming_soon", "More features coming soon!"), + ] + if self.dev_mode: + MENU_ITEMS.append(("Hoolamike Tasks", "hoolamike_tasks", "Manage Hoolamike modding tools")) + MENU_ITEMS.append(("Additional Tasks", "additional_tasks", "Additional utilities and tools")) + MENU_ITEMS.append(("Exit Jackify", "exit_jackify", "Close the application")) + + for label, action_id, description in MENU_ITEMS: + # Main button + btn = QPushButton(label) + btn.setFixedSize(button_width, 50) + btn.setStyleSheet(f""" + QPushButton {{ + background-color: #4a5568; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + text-align: center; + }} + QPushButton:hover {{ + background-color: #5a6578; + }} + QPushButton:pressed {{ + background-color: {JACKIFY_COLOR_BLUE}; + }} + """) + btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a)) + + # Button container with proper alignment + btn_container = QWidget() + btn_layout = QVBoxLayout() + btn_layout.setContentsMargins(0, 0, 0, 0) + btn_layout.setSpacing(4) + btn_layout.setAlignment(Qt.AlignHCenter) + btn_layout.addWidget(btn) + + # Description label with proper alignment + desc_label = QLabel(description) + desc_label.setAlignment(Qt.AlignHCenter) + desc_label.setStyleSheet("color: #999; font-size: 12px;") + desc_label.setWordWrap(True) + desc_label.setFixedWidth(button_width) # Match button width for proper alignment + btn_layout.addWidget(desc_label) + + btn_container.setLayout(btn_layout) + layout.addWidget(btn_container) + + # Disclaimer + layout.addSpacing(20) + disclaimer = QLabel(DISCLAIMER_TEXT) + disclaimer.setWordWrap(True) + disclaimer.setAlignment(Qt.AlignCenter) + disclaimer.setStyleSheet("color: #666; font-size: 10px;") + disclaimer.setFixedWidth(button_width) + layout.addWidget(disclaimer, alignment=Qt.AlignHCenter) + + self.setLayout(layout) + + def menu_action(self, action_id): + if action_id == "exit_jackify": + from PySide6.QtWidgets import QApplication + QApplication.quit() + elif action_id == "coming_soon": + # Show a friendly message about upcoming features + from PySide6.QtWidgets import QMessageBox + msg = QMessageBox(self) + msg.setWindowTitle("Coming Soon") + msg.setText("More features are coming in future releases!\n\nFor now, you can install and configure any modlist using the 'Modlist Tasks' button.") + msg.setIcon(QMessageBox.Information) + msg.exec() + elif action_id == "modlist_tasks" and self.stacked_widget: + self.stacked_widget.setCurrentIndex(3) + elif action_id == "return_main_menu": + # This is the main menu, so do nothing + pass + elif self.stacked_widget: + self.stacked_widget.setCurrentIndex(2) # Placeholder for now \ No newline at end of file diff --git a/jackify/frontends/gui/screens/modlist_tasks.py b/jackify/frontends/gui/screens/modlist_tasks.py new file mode 100644 index 0000000..b640106 --- /dev/null +++ b/jackify/frontends/gui/screens/modlist_tasks.py @@ -0,0 +1,214 @@ +""" +Migrated Modlist Tasks Screen + +This is a migrated version of the original modlist tasks menu that uses backend services +directly instead of subprocess calls to jackify-cli.py. + +Key changes: +- Uses backend services directly instead of subprocess.Popen() +- Direct backend service integration +- Maintains same UI and workflow +- Improved error handling and progress reporting +""" + +import os +import sys +import logging +from pathlib import Path +from typing import List, Optional + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QGridLayout, QSizePolicy, QApplication, QFrame, QMessageBox +) +from PySide6.QtCore import Qt, QThread, Signal, QTimer +from PySide6.QtGui import QFont, QPalette, QColor, QPixmap + +# Import our GUI services +from jackify.backend.models.configuration import SystemInfo +from ..shared_theme import JACKIFY_COLOR_BLUE + +# Constants +DEBUG_BORDERS = False + +logger = logging.getLogger(__name__) + + +class ModlistTasksScreen(QWidget): + """ + Migrated Modlist Tasks screen that uses backend services directly. + + This replaces the original ModlistTasksMenu's subprocess calls with + direct navigation to existing automated workflows. + """ + + def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None, dev_mode=False): + super().__init__() + logger.info("ModlistTasksScreen initializing (migrated version)") + + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + self.debug = DEBUG_BORDERS + self.dev_mode = dev_mode + + # Initialize backend services + if system_info is None: + system_info = SystemInfo(is_steamdeck=self._is_steamdeck()) + self.system_info = system_info + + # Setup UI + self._setup_ui() + + logger.info("ModlistTasksScreen initialized (migrated version)") + + def _is_steamdeck(self) -> bool: + """Check if running on Steam Deck""" + try: + if os.path.exists("/etc/os-release"): + with open("/etc/os-release", "r") as f: + content = f.read() + if "steamdeck" in content: + return True + return False + except Exception: + return False + + def _setup_ui(self): + """Set up the user interface""" + main_layout = QVBoxLayout(self) + main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_layout.setContentsMargins(50, 50, 50, 50) + + if self.debug: + self.setStyleSheet("border: 2px solid green;") + + # Header section + self._setup_header(main_layout) + + # Menu buttons section + self._setup_menu_buttons(main_layout) + + # Bottom navigation + self._setup_navigation(main_layout) + + def _setup_header(self, layout): + """Set up the header section""" + header_layout = QVBoxLayout() + header_layout.setSpacing(2) + + # Title + title = QLabel("Modlist Tasks") + title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};") + title.setAlignment(Qt.AlignHCenter) + header_layout.addWidget(title) + + # Add a spacer to match main menu vertical spacing + header_layout.addSpacing(16) + + # Description + desc = QLabel( + "Manage your modlists with native Linux tools. Choose " + "from the options below to install or configure modlists.
 " + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc;") + desc.setAlignment(Qt.AlignHCenter) + header_layout.addWidget(desc) + + header_layout.addSpacing(24) + + # Separator + sep = QLabel() + sep.setFixedHeight(2) + sep.setStyleSheet("background: #fff;") + header_layout.addWidget(sep) + + header_layout.addSpacing(16) + layout.addLayout(header_layout) + + def _setup_menu_buttons(self, layout): + """Set up the menu buttons section""" + # Menu options + MENU_ITEMS = [ + ("Install a Modlist (Automated)", "install_modlist", "Download and install modlists automatically"), + ("Configure New Modlist (Post-Download)", "configure_new_modlist", "Configure a newly downloaded modlist"), + ("Configure Existing Modlist (In Steam)", "configure_existing_modlist", "Reconfigure an existing Steam modlist"), + ] + if self.dev_mode: + MENU_ITEMS.append(("Install Wabbajack Application", "install_wabbajack", "Set up the Wabbajack application")) + MENU_ITEMS.append(("Return to Main Menu", "return_main_menu", "Go back to the main menu")) + + # Create grid layout for buttons + button_grid = QGridLayout() + button_grid.setSpacing(16) + button_grid.setAlignment(Qt.AlignHCenter) + + button_width = 400 + button_height = 50 + + for i, (label, action_id, description) in enumerate(MENU_ITEMS): + # Create button + btn = QPushButton(label) + btn.setFixedSize(button_width, button_height) + btn.setStyleSheet(f""" + QPushButton {{ + background-color: #4a5568; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + text-align: center; + }} + QPushButton:hover {{ + background-color: #5a6578; + }} + QPushButton:pressed {{ + background-color: {JACKIFY_COLOR_BLUE}; + }} + """) + btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a)) + + # Create description label + desc_label = QLabel(description) + desc_label.setAlignment(Qt.AlignHCenter) + desc_label.setStyleSheet("color: #999; font-size: 12px;") + desc_label.setWordWrap(True) + desc_label.setFixedWidth(button_width) + + # Add to grid + button_grid.addWidget(btn, i * 2, 0, Qt.AlignHCenter) + button_grid.addWidget(desc_label, i * 2 + 1, 0, Qt.AlignHCenter) + + layout.addLayout(button_grid) + + def _setup_navigation(self, layout): + """Set up the navigation section""" + # Remove the bottom navigation bar entirely (no gray Back to Main Menu button) + pass + + def menu_action(self, action_id): + """Handle menu button clicks""" + logger.info(f"Modlist tasks menu action: {action_id}") + + if not self.stacked_widget: + return + + # Navigate to different screens based on action + if action_id == "return_main_menu": + self.stacked_widget.setCurrentIndex(0) + elif action_id == "install_modlist": + self.stacked_widget.setCurrentIndex(4) + elif action_id == "configure_new_modlist": + self.stacked_widget.setCurrentIndex(5) + elif action_id == "configure_existing_modlist": + self.stacked_widget.setCurrentIndex(6) + + def go_back(self): + """Return to main menu""" + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(self.main_menu_index) + + def cleanup(self): + """Clean up resources when the screen is closed""" + pass \ No newline at end of file diff --git a/jackify/frontends/gui/screens/tuxborn_installer.py b/jackify/frontends/gui/screens/tuxborn_installer.py new file mode 100644 index 0000000..7ee124a --- /dev/null +++ b/jackify/frontends/gui/screens/tuxborn_installer.py @@ -0,0 +1,1829 @@ +""" +InstallModlistScreen for Jackify GUI +""" +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, QUrl +from PySide6.QtGui import QPixmap, QTextCursor +from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS +from ..utils import ansi_to_html +import os +import subprocess +import sys +import threading +import time +from jackify.backend.handlers.shortcut_handler import ShortcutHandler +import traceback +import signal +from jackify.backend.core.modlist_operations import get_jackify_engine_path +import re +from jackify.backend.handlers.subprocess_utils import ProcessManager +from jackify.backend.services.api_key_service import APIKeyService +from jackify.backend.services.resolution_service import ResolutionService +from jackify.backend.handlers.config_handler import ConfigHandler +from ..dialogs import SuccessDialog +from jackify.backend.handlers.validation_handler import ValidationHandler +from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog +from jackify.frontends.gui.services.message_service import MessageService + +def debug_print(message): + """Print debug message only if debug mode is enabled""" + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if config_handler.get('debug_mode', False): + print(message) + +class ModlistFetchThread(QThread): + result = Signal(list, str) + def __init__(self, game_type, log_path, mode='list-modlists'): + super().__init__() + self.game_type = game_type + self.log_path = log_path + self.mode = mode + + def run(self): + try: + # Use proper backend service - NOT the misnamed CLI class + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.configuration import SystemInfo + + # Initialize backend service + # Detect if we're on Steam Deck + is_steamdeck = False + try: + 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: + pass + + system_info = SystemInfo(is_steamdeck=is_steamdeck) + modlist_service = ModlistService(system_info) + + # Get modlists using proper backend service + modlist_infos = modlist_service.list_modlists(game_type=self.game_type) + # Return full modlist objects instead of just IDs to preserve enhanced metadata + # Only log on success, not on every call + with open(self.log_path, 'a') as logf: + logf.write(f"[Backend Success] Found {len(modlist_infos)} modlists for {self.game_type}\n") + self.result.emit(modlist_infos, '') + + except Exception as e: + error_msg = f"Backend service error: {str(e)}" + with open(self.log_path, 'a') as logf: + logf.write(f"[Backend Error] {error_msg}\n") + self.result.emit([], error_msg) + +class SelectionDialog(QDialog): + def __init__(self, title, items, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setMinimumWidth(350) + self.setMinimumHeight(300) + layout = QVBoxLayout(self) + self.list_widget = QListWidget() + self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + for item in items: + QListWidgetItem(item, self.list_widget) + layout.addWidget(self.list_widget) + self.selected_item = None + self.list_widget.itemClicked.connect(self.on_item_clicked) + def on_item_clicked(self, item): + self.selected_item = item.text() + self.accept() + +class TuxbornInstallerScreen(QWidget): + steam_restart_finished = Signal(bool, str) + def __init__(self, stacked_widget=None, main_menu_index=0): + super().__init__() + debug_print("DEBUG: TuxbornInstallerScreen __init__ called") + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + self.debug = DEBUG_BORDERS + 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/Tuxborn_Installer_workflow.log') + os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) + + # Initialize services early + from jackify.backend.services.api_key_service import APIKeyService + from jackify.backend.services.resolution_service import ResolutionService + from jackify.backend.handlers.config_handler import ConfigHandler + self.api_key_service = APIKeyService() + self.resolution_service = ResolutionService() + self.config_handler = ConfigHandler() + + # Scroll tracking for professional auto-scroll behavior + self._user_manually_scrolled = False + self._was_at_bottom = True + + # Time tracking for workflow completion + self._workflow_start_time = None + + # Manual steps retry counter (legacy - should not be used in automated workflow) + self._manual_steps_retry_count = 0 + + main_overall_vbox = QVBoxLayout(self) + main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_overall_vbox.setContentsMargins(50, 25, 50, 0) # Reduce top margin to move header closer to top + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + + # --- Header (title, description) --- + header_layout = QVBoxLayout() + header_layout.setSpacing(2) + # Title (no logo) + title = QLabel("Tuxborn Automatic Installer") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") + title.setAlignment(Qt.AlignHCenter) + header_layout.addWidget(title) + # Description + desc = QLabel( + "This screen allows you to install the Tuxborn modlist using Jackify's native Linux tools. " + "Configure your options and start the installation." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc;") + desc.setAlignment(Qt.AlignHCenter) + header_layout.addWidget(desc) + header_widget = QWidget() + header_widget.setLayout(header_layout) + header_widget.setMaximumHeight(75) # Prevent expansion, match Install a Modlist screen + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + header_widget.setToolTip("HEADER_SECTION") + main_overall_vbox.addWidget(header_widget) + + # --- Upper section: user-configurables (left) + process monitor (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + # Left: user-configurables (form and controls) + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + # --- Tabs for source selection --- + # self.source_tabs = QTabWidget() # REMOVE + # --- Online List Tab --- + # online_tab = QWidget() + # online_tab_vbox = QVBoxLayout() + # online_tab_vbox.setAlignment(Qt.AlignTop) +# Game selection removed - Tuxborn is pre-selected + # online_tab_vbox.addWidget(self.online_group) + # online_tab.setLayout(online_tab_vbox) + # self.source_tabs.addTab(online_tab, "Select Modlist") + # --- File Picker Tab --- + # file_tab = QWidget() + # file_tab_vbox = QVBoxLayout() + # file_tab_vbox.setAlignment(Qt.AlignTop) + # self.file_group = QWidget() + # file_layout = QHBoxLayout() + # 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) + # file_layout.addWidget(QLabel(".wabbajack File:")) + # file_layout.addWidget(self.file_edit) + # file_layout.addWidget(file_btn) + # self.file_group.setLayout(file_layout) + # file_tab_vbox.addWidget(self.file_group) + # file_tab.setLayout(file_tab_vbox) + # self.source_tabs.addTab(file_tab, "Use .wabbajack File") + # user_config_vbox.addWidget(self.source_tabs) + # --- Install/Downloads Dir/API Key (reuse Tuxborn style) --- + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) # Match Install a Modlist screen spacing + form_grid.setContentsMargins(0, 0, 0, 0) + # Modlist Name (NEW FIELD) + modlist_name_label = QLabel("Modlist Name:") + self.modlist_name_edit = QLineEdit("Tuxborn") + self.modlist_name_edit.setMaximumHeight(25) # Force compact height + form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addWidget(self.modlist_name_edit, 0, 1) + # Install Dir + 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) + install_dir_hbox = QHBoxLayout() + install_dir_hbox.addWidget(self.install_dir_edit) + install_dir_hbox.addWidget(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) + downloads_dir_hbox = QHBoxLayout() + downloads_dir_hbox.addWidget(self.downloads_dir_edit) + downloads_dir_hbox.addWidget(browse_downloads_btn) + form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(downloads_dir_hbox, 2, 1) + # API Key + api_key_label = QLabel("Nexus API Key:") + self.api_key_edit = QLineEdit() + self.api_key_edit.setMaximumHeight(25) # Force compact height + # Services already initialized above + # Set up obfuscation timer and state + self.api_key_obfuscation_timer = QTimer(self) + self.api_key_obfuscation_timer.setSingleShot(True) + self.api_key_obfuscation_timer.timeout.connect(self._obfuscate_api_key) + self.api_key_original_text = "" + self.api_key_is_obfuscated = False + # Connect events for obfuscation + self.api_key_edit.textChanged.connect(self._on_api_key_text_changed) + self.api_key_edit.focusInEvent = self._on_api_key_focus_in + self.api_key_edit.focusOutEvent = self._on_api_key_focus_out + # Load saved API key if available + saved_key = self.api_key_service.get_saved_api_key() + if saved_key: + self.api_key_original_text = saved_key # Set original text first + self.api_key_edit.setText(saved_key) + self._obfuscate_api_key() # Immediately obfuscate saved keys + form_grid.addWidget(api_key_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addWidget(self.api_key_edit, 3, 1) + # API Key save checkbox and info (row 4) + api_save_layout = QHBoxLayout() + api_save_layout.setContentsMargins(0, 0, 0, 0) + api_save_layout.setSpacing(8) + self.save_api_key_checkbox = QCheckBox("Save API Key") + self.save_api_key_checkbox.setChecked(self.api_key_service.has_saved_api_key()) + self.save_api_key_checkbox.toggled.connect(self._on_api_key_save_toggled) + api_save_layout.addWidget(self.save_api_key_checkbox, alignment=Qt.AlignTop) + + # Validate button removed - validation now happens silently on save checkbox toggle + api_info = QLabel( + 'Storing your API Key locally is done so at your own risk.
' + 'You can get your API key at: ' + 'https://www.nexusmods.com/users/myaccount?tab=api
' + ) + api_info.setOpenExternalLinks(False) + api_info.linkActivated.connect(self._open_url_safe) + api_info.setWordWrap(True) + api_info.setAlignment(Qt.AlignLeft) + api_save_layout.addWidget(api_info, stretch=1) + api_save_widget = QWidget() + api_save_widget.setLayout(api_save_layout) + # Set reasonable maximum height to prevent excessive size while allowing natural height + api_save_widget.setMaximumHeight(55) # Increase by another 2px for better fit + if self.debug: + api_save_widget.setStyleSheet("border: 2px solid lightblue;") + api_save_widget.setToolTip("API_SAVE_SECTION") + form_grid.addWidget(api_save_widget, 4, 1) + # --- Resolution Dropdown --- + resolution_label = QLabel("Resolution:") + self.resolution_combo = QComboBox() + self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.resolution_combo.addItem("Leave unchanged") + self.resolution_combo.addItems([ + "1280x720", + "1280x800 (Steam Deck)", + "1366x768", + "1440x900", + "1600x900", + "1600x1200", + "1680x1050", + "1920x1080", + "1920x1200", + "2048x1152", + "2560x1080", + "2560x1440", + "2560x1600", + "3440x1440", + "3840x1600", + "3840x2160", + "3840x2400", + "5120x1440", + "5120x2160", + "7680x4320" + ]) + # Load saved resolution if available + saved_resolution = self.resolution_service.get_saved_resolution() + is_steam_deck = False + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + is_steam_deck = True + except Exception: + pass + 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) + debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + elif is_steam_deck: + # Set default to 1280x800 (Steam Deck) + combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] + if "1280x800 (Steam Deck)" in combo_items: + self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) + else: + 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) + form_section_widget = QWidget() + form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + form_section_widget.setLayout(form_grid) + form_section_widget.setMinimumHeight(220) # Match Install a Modlist screen + form_section_widget.setMaximumHeight(240) # Match Install a Modlist screen + if self.debug: + form_section_widget.setStyleSheet("border: 2px solid blue;") + form_section_widget.setToolTip("FORM_SECTION") + user_config_vbox.addWidget(form_section_widget) + user_config_widget = QWidget() + user_config_widget.setLayout(user_config_vbox) + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + user_config_widget.setToolTip("USER_CONFIG_WIDGET") + # Right: process monitor (as before) + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") + self.process_monitor_heading = QLabel("[Process Monitor]") + self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") + self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor_heading) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + process_monitor_widget.setToolTip("PROCESS_MONITOR") + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(process_monitor_widget, stretch=9) + upper_hbox.setAlignment(Qt.AlignTop) + upper_section_widget = QWidget() + upper_section_widget.setLayout(upper_hbox) + upper_section_widget.setMaximumWidth(1300) + upper_section_widget.setMaximumHeight(235) # Increase by another 15px for better fit + if self.debug: + upper_section_widget.setStyleSheet("border: 2px solid green;") + upper_section_widget.setToolTip("UPPER_SECTION") + main_overall_vbox.addWidget(upper_section_widget) + # Remove spacing - console should expand to fill available space + + # --- Buttons (moved BEFORE console creation) --- + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + self.start_btn = QPushButton("Start Installation") + btn_row.addWidget(self.start_btn) + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.go_back) + btn_row.addWidget(self.cancel_btn) + self.cancel_install_btn = QPushButton("Cancel Installation") + self.cancel_install_btn.clicked.connect(self.cancel_installation) + self.cancel_install_btn.setVisible(False) # Hidden until installation starts + btn_row.addWidget(self.cancel_install_btn) + + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + btn_row_widget.setToolTip("BUTTON_ROW") + + # --- Console output area (full width, placeholder for now) --- + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing + self.console.setMaximumHeight(1000) # Allow growth when space available + self.console.setFontFamily('monospace') + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + self.console.setToolTip("CONSOLE") + + # Set up scroll tracking for professional auto-scroll behavior + self._setup_scroll_tracking() + + # Create a container that holds console + button row with proper spacing + console_and_buttons_widget = QWidget() + console_and_buttons_layout = QVBoxLayout() + console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) + console_and_buttons_layout.setSpacing(8) # Small gap between console and buttons + + console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space + console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container + + console_and_buttons_widget.setLayout(console_and_buttons_layout) + if self.debug: + console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") + console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") + main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space + self.setLayout(main_overall_vbox) + + self.current_modlists = [] + + # --- Process Monitor (right) --- + self.process = None + self.log_timer = None + self.last_log_pos = 0 + # --- Process Monitor Timer --- + self.top_timer = QTimer(self) + self.top_timer.timeout.connect(self.update_top_panel) + self.top_timer.start(2000) + # --- Start Installation button --- + self.start_btn.clicked.connect(self.validate_and_start_install) + self.steam_restart_finished.connect(self._on_steam_restart_finished) + + def _open_url_safe(self, url): + """Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller""" + import subprocess + try: + subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + print(f"Warning: Could not open URL {url}: {e}") + + def resizeEvent(self, event): + """Handle window resize to prioritize form over console""" + super().resizeEvent(event) + self._adjust_console_for_form_priority() + + def _adjust_console_for_form_priority(self): + """Console now dynamically fills available space with stretch=1, no manual calculation needed""" + # The console automatically fills remaining space due to stretch=1 in the layout + # Remove any fixed height constraints to allow natural stretching + self.console.setMaximumHeight(16777215) # Reset to default maximum + self.console.setMinimumHeight(50) # Keep minimum height for usability + + def showEvent(self, event): + """Called when the widget becomes visible - reload saved API key and parent directories""" + 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) + + # Load saved parent directories and pre-populate fields + self._load_saved_parent_directories() + + def _load_saved_parent_directories(self): + """Load standard Settings menu defaults and pre-populate directory fields""" + try: + # Use the same Settings menu defaults as other workflows + install_base_dir = self.config_handler.get("modlist_install_base_dir", os.path.expanduser("~/Games")) + if install_base_dir: + # Pre-populate with standard base + Skyrim + Tuxborn + suggested_install_dir = os.path.join(install_base_dir, "Skyrim", "Tuxborn") + self.install_dir_edit.setText(suggested_install_dir) + debug_print(f"DEBUG: Pre-populated install directory with Settings default: {suggested_install_dir}") + + # Load standard download base directory + downloads_base_dir = self.config_handler.get("modlist_downloads_base_dir", os.path.expanduser("~/Games/Modlist_Downloads")) + if downloads_base_dir: + # Pre-populate with standard downloads base + self.downloads_dir_edit.setText(downloads_base_dir) + debug_print(f"DEBUG: Pre-populated download directory with Settings default: {downloads_base_dir}") + + except Exception as e: + print(f"DEBUG: Error loading Settings menu defaults: {e}") + + def _save_parent_directories(self, install_dir, downloads_dir): + """Removed automatic saving - user should set defaults in settings""" + pass + + def _on_api_key_text_changed(self, text): + """Handle API key text changes for obfuscation timing""" + if not self.api_key_is_obfuscated: + self.api_key_original_text = text + # Restart the obfuscation timer (3 seconds after last change) + self.api_key_obfuscation_timer.stop() + if text.strip(): # Only start timer if there's actual text + self.api_key_obfuscation_timer.start(3000) # 3 seconds + else: + # If currently obfuscated and user is typing/pasting, un-obfuscate + if text != self.api_key_service.get_api_key_display(self.api_key_original_text): + self.api_key_is_obfuscated = False + self.api_key_original_text = text + if text.strip(): + self.api_key_obfuscation_timer.start(3000) + + def _on_api_key_focus_out(self, event): + """Handle API key field losing focus - immediately obfuscate""" + QLineEdit.focusOutEvent(self.api_key_edit, event) + self._obfuscate_api_key() + + def _on_api_key_focus_in(self, event): + """Handle API key field gaining focus - de-obfuscate if needed""" + # Call the original focusInEvent first + QLineEdit.focusInEvent(self.api_key_edit, event) + if self.api_key_is_obfuscated: + self.api_key_edit.blockSignals(True) + self.api_key_edit.setText(self.api_key_original_text) + self.api_key_is_obfuscated = False + self.api_key_edit.blockSignals(False) + self.api_key_obfuscation_timer.stop() + + def _obfuscate_api_key(self): + """Obfuscate the API key text field""" + if not self.api_key_is_obfuscated and self.api_key_original_text.strip(): + self.api_key_edit.blockSignals(True) + masked_text = self.api_key_service.get_api_key_display(self.api_key_original_text) + self.api_key_edit.setText(masked_text) + self.api_key_is_obfuscated = True + self.api_key_edit.blockSignals(False) + + def _get_actual_api_key(self): + """Get the actual API key value (not the obfuscated version)""" + if self.api_key_is_obfuscated: + return self.api_key_original_text + else: + return self.api_key_edit.text() + + def open_game_type_dialog(self): + dlg = SelectionDialog("Select Game Type", self.game_types, self) + if dlg.exec() == QDialog.Accepted and dlg.selected_item: + self.game_type_btn.setText(dlg.selected_item) + self.fetch_modlists_for_game_type(dlg.selected_item) + + def fetch_modlists_for_game_type(self, game_type): + self.modlist_btn.setText("Fetching modlists...") + self.modlist_btn.setEnabled(False) + game_type_map = { + "Skyrim": "skyrim", + "Fallout 4": "fallout4", + "Fallout New Vegas": "falloutnv", + "Oblivion": "oblivion", + "Starfield": "starfield", + "Oblivion Remastered": "oblivion_remastered", + "Other": "other" + } + cli_game_type = game_type_map.get(game_type, "other") + log_path = self.modlist_log_path + # Use backend service for listing modlists + self.fetch_thread = ModlistFetchThread( + cli_game_type, log_path, mode='list-modlists') + self.fetch_thread.result.connect(self.on_modlists_fetched) + self.fetch_thread.start() + + def on_modlists_fetched(self, modlist_infos, error): + # Handle both new format (modlist objects) and old format (string IDs) for backward compatibility + if modlist_infos and isinstance(modlist_infos[0], str): + # Old format - just IDs as strings + filtered = [m for m in modlist_infos if m and not m.startswith('DEBUG:')] + self.current_modlists = filtered + else: + # New format - full modlist objects with enhanced metadata + filtered_modlists = [m for m in modlist_infos if m and hasattr(m, 'id')] + self.current_modlists = [m.id for m in filtered_modlists] # Keep IDs for selection + if error: + self.modlist_btn.setText("Error fetching modlists.") + self.modlist_btn.setEnabled(False) + self._safe_append_text(f"[Modlist Fetch Error]\n{error}") + elif self.current_modlists: + self.modlist_btn.setText("Select Modlist") + self.modlist_btn.setEnabled(True) + else: + self.modlist_btn.setText("No modlists found.") + self.modlist_btn.setEnabled(False) + + def open_modlist_dialog(self): + if not self.current_modlists: + return + dlg = SelectionDialog("Select Modlist", self.current_modlists, self) + if dlg.exec() == QDialog.Accepted and dlg.selected_item: + self.modlist_btn.setText(dlg.selected_item) + # Store selection as needed + + def browse_wabbajack_file(self): + file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)") + if file: + self.file_edit.setText(file) + + def browse_install_dir(self): + dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text()) + if dir: + self.install_dir_edit.setText(dir) + + def browse_downloads_dir(self): + dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text()) + if dir: + self.downloads_dir_edit.setText(dir) + + def go_back(self): + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(0) # Return to Main Menu + + def update_top_panel(self): + try: + result = subprocess.run([ + "ps", "-eo", "pcpu,pmem,comm,args" + ], stdout=subprocess.PIPE, text=True, timeout=2) + lines = result.stdout.splitlines() + header = "CPU%\tMEM%\tCOMMAND" + filtered = [header] + process_rows = [] + for line in lines[1:]: + line_lower = line.lower() + if ( + ("jackify-engine" in line_lower or "7zz" in line_lower or "compressonator" in line_lower or + "wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower) + and "jackify-gui.py" not in line_lower + ): + cols = line.strip().split(None, 3) + if len(cols) >= 3: + process_rows.append(cols) + process_rows.sort(key=lambda x: float(x[0]), reverse=True) + for cols in process_rows: + filtered.append('\t'.join(cols)) + if len(filtered) == 1: + filtered.append("[No Jackify-related processes found]") + self.process_monitor.setPlainText('\n'.join(filtered)) + except Exception as e: + self.process_monitor.setPlainText(f"[process info unavailable: {e}]") + + def _on_api_key_save_toggled(self, checked): + """Handle immediate API key saving with silent validation when checkbox is toggled""" + try: + if checked: + # Save API key if one is entered + api_key = self._get_actual_api_key().strip() + if api_key: + # Silently validate API key first + is_valid, validation_message = self.api_key_service.validate_api_key_works(api_key) + if not is_valid: + # Show error dialog for invalid API key + from jackify.frontends.gui.services.message_service import MessageService + MessageService.critical( + self, + "Invalid API Key", + f"The API key is invalid and cannot be saved.\n\nError: {validation_message}", + safety_level="low" + ) + self.save_api_key_checkbox.setChecked(False) # Uncheck on validation failure + return + + # API key is valid, proceed with saving + success = self.api_key_service.save_api_key(api_key) + if success: + self._show_api_key_feedback("✓ API key saved successfully", is_success=True) + print("DEBUG: API key validated and saved immediately on checkbox toggle") + else: + self._show_api_key_feedback("✗ Failed to save API key - check permissions", is_success=False) + # Uncheck the checkbox since save failed + self.save_api_key_checkbox.setChecked(False) + print("DEBUG: Failed to save API key immediately") + else: + 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: + # Clear saved API key when unchecked + if self.api_key_service.has_saved_api_key(): + success = self.api_key_service.clear_saved_api_key() + if success: + self._show_api_key_feedback("✓ API key cleared", is_success=True) + print("DEBUG: Saved API key cleared immediately on checkbox toggle") + else: + self._show_api_key_feedback("✗ Failed to clear API key", is_success=False) + print("DEBUG: Failed to clear API key") + except Exception as e: + self._show_api_key_feedback(f"✗ Error: {str(e)}", is_success=False) + self.save_api_key_checkbox.setChecked(False) + print(f"DEBUG: Error in _on_api_key_save_toggled: {e}") + + def _show_api_key_feedback(self, message, is_success=True): + """Show temporary feedback message for API key operations""" + # Use tooltip for immediate feedback + color = "#22c55e" if is_success else "#ef4444" # Green for success, red for error + self.save_api_key_checkbox.setToolTip(message) + + # Temporarily change checkbox style to show feedback + original_style = self.save_api_key_checkbox.styleSheet() + feedback_style = f"QCheckBox {{ color: {color}; font-weight: bold; }}" + self.save_api_key_checkbox.setStyleSheet(feedback_style) + + # Reset style and tooltip after 3 seconds + from PySide6.QtCore import QTimer + def reset_feedback(): + self.save_api_key_checkbox.setStyleSheet(original_style) + self.save_api_key_checkbox.setToolTip("") + + QTimer.singleShot(3000, reset_feedback) + + + def validate_and_start_install(self): + try: + debug_print('DEBUG: validate_and_start_install called') + + # Rotate log file at start of each workflow run (keep 5 backups) + from jackify.backend.handlers.logging_handler import LoggingHandler + from pathlib import Path + log_handler = LoggingHandler() + log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) + + # Start time tracking + self._workflow_start_time = time.time() + + # Hardcode Tuxborn values + modlist = 'Tuxborn/Tuxborn' + install_mode = 'online' + install_dir = self.install_dir_edit.text().strip() + downloads_dir = self.downloads_dir_edit.text().strip() + # Get the actual API key (not obfuscated version) + api_key = self._get_actual_api_key().strip() + validation_handler = ValidationHandler() + from pathlib import Path + is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir)) + if not is_safe: + dlg = WarningDialog(reason, parent=self) + if not dlg.exec() or not dlg.confirmed: + return + if not os.path.isdir(install_dir): + create = MessageService.question(self, "Create Directory?", + f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?", + safety_level="low") + if create == QMessageBox.Yes: + try: + os.makedirs(install_dir, exist_ok=True) + except Exception as e: + MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}", safety_level="medium") + return + else: + return + if not os.path.isdir(downloads_dir): + create = MessageService.question(self, "Create Directory?", + f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?", + safety_level="low") + if create == QMessageBox.Yes: + try: + os.makedirs(downloads_dir, exist_ok=True) + except Exception as e: + MessageService.critical(self, "Error", f"Failed to create downloads directory:\n{e}", safety_level="medium") + return + else: + return + # Handle API key saving BEFORE validation (to match settings dialog behavior) + if self.save_api_key_checkbox.isChecked(): + if api_key: + success = self.api_key_service.save_api_key(api_key) + if success: + debug_print("DEBUG: API key saved successfully") + else: + debug_print("DEBUG: Failed to save API key") + else: + # If checkbox is unchecked, clear any saved API key + if self.api_key_service.has_saved_api_key(): + self.api_key_service.clear_saved_api_key() + debug_print("DEBUG: Saved API key cleared") + + # Validate API key for installation purposes + if not api_key or not self.api_key_service._validate_api_key_format(api_key): + MessageService.warning(self, "Invalid API Key", "Please enter a valid Nexus API Key.", safety_level="low") + return + + # Handle resolution saving + resolution = self.resolution_combo.currentText() + if resolution and resolution != "Leave unchanged": + success = self.resolution_service.save_resolution(resolution) + if success: + debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + else: + debug_print("DEBUG: Failed to save resolution") + else: + # Clear saved resolution if "Leave unchanged" is selected + if self.resolution_service.has_saved_resolution(): + self.resolution_service.clear_saved_resolution() + debug_print("DEBUG: Saved resolution cleared") + + # Handle parent directory saving + self._save_parent_directories(install_dir, downloads_dir) + + self.console.clear() + self.process_monitor.clear() + self.start_btn.setEnabled(False) + debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, api_key={api_key[:6]}..., install_mode={install_mode}') + self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode) + except Exception as e: + debug_print(f"DEBUG: Exception in validate_and_start_install: {e}") + + 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') + + # Clear console for fresh installation output + self.console.clear() + self._safe_append_text("Starting Tuxborn installation with custom progress handling...") + + # Update UI state for installation + self.start_btn.setEnabled(False) + self.cancel_btn.setVisible(False) + self.cancel_install_btn.setVisible(True) + + # Create installation thread + from PySide6.QtCore import QThread, Signal + + class InstallationThread(QThread): + output_received = Signal(str) + progress_received = Signal(str) + installation_finished = Signal(bool, str) + + def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name): + super().__init__() + self.modlist = modlist + self.install_dir = install_dir + self.downloads_dir = downloads_dir + self.api_key = api_key + self.modlist_name = modlist_name + self.cancelled = False + self.process_manager = None + + def cancel(self): + self.cancelled = True + if self.process_manager: + self.process_manager.cancel() + + def run(self): + import re + try: + engine_path = get_jackify_engine_path() + cmd = [engine_path, "install", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] + + # Check for debug mode and add --debug flag + 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') + debug_print("DEBUG: Added --debug flag to jackify-engine command") + env = os.environ.copy() + env['NEXUS_API_KEY'] = self.api_key + self.process_manager = ProcessManager(cmd, env=env, text=False, bufsize=0) + ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') + buffer = b'' + while True: + if self.cancelled: + self.cancel() + break + char = self.process_manager.read_stdout_char() + if not char: + break + buffer += char + while b'\n' in buffer or b'\r' in buffer: + if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True): + line, buffer = buffer.split(b'\r', 1) + line = ansi_escape.sub(b'', line) + self.progress_received.emit(line.decode('utf-8', errors='replace')) + elif b'\n' in buffer: + line, buffer = buffer.split(b'\n', 1) + line = ansi_escape.sub(b'', line) + self.output_received.emit(line.decode('utf-8', errors='replace')) + if buffer: + line = ansi_escape.sub(b'', buffer) + self.output_received.emit(line.decode('utf-8', errors='replace')) + self.process_manager.wait() + if self.cancelled: + self.installation_finished.emit(False, "Installation cancelled by user") + elif self.process_manager.proc.returncode == 0: + self.installation_finished.emit(True, "Installation completed successfully") + else: + self.installation_finished.emit(False, "Installation failed") + except Exception as e: + import traceback + error_msg = f"Installation error: {e}\n{traceback.format_exc()}" + self.installation_finished.emit(False, error_msg) + finally: + if self.cancelled and self.process_manager: + self.process_manager.cancel() + + # Create and start thread + modlist_name = self.modlist_name_edit.text().strip() + self.install_thread = InstallationThread(modlist, install_dir, downloads_dir, api_key, modlist_name) + + # Connect signals for proper GUI updates + self.install_thread.output_received.connect(self.on_installation_output) + self.install_thread.progress_received.connect(self.on_installation_progress) + self.install_thread.installation_finished.connect(self.on_installation_finished) + + # Start installation + self.install_thread.start() + + self._safe_append_text("Installation thread started...") + + def on_installation_output(self, message): + """Handle regular output from installation thread""" + self._safe_append_text(message) + + def on_installation_progress(self, progress_message): + """Replace the last line in the console for progress updates""" + cursor = self.console.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.insertText(progress_message) + # Don't force scroll for progress updates - let user control + + def on_installation_finished(self, success, message): + """Handle installation completion""" + if success: + self._safe_append_text(f"\nSuccess: {message}") + self.process_finished(0, QProcess.NormalExit, message) # Simulate successful completion + else: + self._safe_append_text(f"\nError: {message}") + self.process_finished(1, QProcess.CrashExit, message) # Simulate error + + def process_finished(self, exit_code, exit_status, message=None): + # Reset button states + self.start_btn.setEnabled(True) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + # Import MessageService at the top of the method for all code paths + from jackify.frontends.gui.services.message_service import MessageService + + if exit_code == 0: + # Only show the install complete dialog here + 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!", + safety_level="medium" + ) + if reply == QMessageBox.Yes: + # Proceed directly to restart Steam - automated workflow will handle shortcut creation + self.restart_steam_and_configure() + else: + # User selected "No" - show completion message and keep GUI open + self._safe_append_text("\nModlist installation completed successfully!") + self._safe_append_text("Note: You can manually configure Steam integration later if needed.") + MessageService.information( + self, "Installation Complete", + "Modlist installation completed successfully!\n\n" + "The modlist has been installed but Steam integration was skipped.\n" + "You can manually add the modlist to Steam later if desired.", + safety_level="medium" + ) + else: + # Check for user cancellation + if message and "cancelled by user" in message.lower(): + MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") + else: + MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.", safety_level="medium") + self.console.moveCursor(QTextCursor.End) + + def _setup_scroll_tracking(self): + """Set up scroll tracking for professional auto-scroll behavior""" + scrollbar = self.console.verticalScrollBar() + scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) + scrollbar.sliderReleased.connect(self._on_scrollbar_released) + scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) + + def _on_scrollbar_pressed(self): + """User started manually scrolling""" + self._user_manually_scrolled = True + + def _on_scrollbar_released(self): + """User finished manually scrolling""" + self._user_manually_scrolled = False + + def _on_scrollbar_value_changed(self): + """Track if user is at bottom of scroll area""" + scrollbar = self.console.verticalScrollBar() + # Use tolerance to account for rounding and rapid updates + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + # If user manually scrolls to bottom, reset manual scroll flag + if self._was_at_bottom and self._user_manually_scrolled: + # Small delay to allow user to scroll away if they want + from PySide6.QtCore import QTimer + QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) + + def _reset_manual_scroll_if_at_bottom(self): + """Reset manual scroll flag if user is still at bottom after delay""" + scrollbar = self.console.verticalScrollBar() + if scrollbar.value() >= scrollbar.maximum() - 1: + self._user_manually_scrolled = False + + def _safe_append_text(self, text): + """Append text with professional auto-scroll behavior""" + # Write all messages to log file + self._write_to_log_file(text) + + scrollbar = self.console.verticalScrollBar() + # Check if user was at bottom BEFORE adding text + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + + # Add the text + self.console.append(text) + + # Auto-scroll if user was at bottom and hasn't manually scrolled + # Re-check bottom state after text addition for better reliability + if (was_at_bottom and not self._user_manually_scrolled) or \ + (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): + scrollbar.setValue(scrollbar.maximum()) + # Ensure user can still manually scroll up during rapid updates + if scrollbar.value() == scrollbar.maximum(): + self._was_at_bottom = True + + def _write_to_log_file(self, message): + """Write message to workflow log file with timestamp""" + try: + from datetime import datetime + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(self.modlist_log_path, 'a', encoding='utf-8') as f: + f.write(f"[{timestamp}] {message}\n") + except Exception: + # Logging should never break the workflow + pass + + def restart_steam_and_configure(self): + """Restart Steam using backend service directly - DECOUPLED FROM CLI""" + print("DEBUG: restart_steam_and_configure called - using direct backend service") + progress = QProgressDialog("Restarting Steam...", None, 0, 0, self) + progress.setWindowTitle("Restarting Steam") + progress.setWindowModality(Qt.WindowModal) + progress.setMinimumDuration(0) + progress.setValue(0) + progress.show() + self.setEnabled(False) + + def do_restart(): + print("DEBUG: do_restart thread started - using direct backend service") + try: + from jackify.backend.handlers.shortcut_handler import ShortcutHandler + + # Use backend service directly instead of CLI subprocess + shortcut_handler = ShortcutHandler(steamdeck=False) # TODO: Use proper system info + + print("DEBUG: About to call secure_steam_restart()") + success = shortcut_handler.secure_steam_restart() + print(f"DEBUG: secure_steam_restart() returned: {success}") + + out = "Steam restart completed successfully." if success else "Steam restart failed." + + except Exception as e: + print(f"DEBUG: Exception in do_restart: {e}") + success = False + out = str(e) + + self.steam_restart_finished.emit(success, out) + + threading.Thread(target=do_restart, daemon=True).start() + self._steam_restart_progress = progress # Store to close later + + def _on_steam_restart_finished(self, success, out): + print("DEBUG: _on_steam_restart_finished called") + # Safely cleanup progress dialog on main thread + if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress: + try: + self._steam_restart_progress.close() + self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup + except Exception as e: + print(f"DEBUG: Error closing progress dialog: {e}") + finally: + self._steam_restart_progress = None + + self.setEnabled(True) + if success: + self._safe_append_text("Steam restarted successfully.") + + # Use automated prefix service instead of manual steps + self._safe_append_text("Starting automated Steam setup workflow...") + self._safe_append_text("This will automatically configure Steam integration without manual steps.") + + # Start automated prefix workflow + 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") + self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path) + else: + self._safe_append_text("Failed to restart Steam.\n" + out) + MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.", safety_level="medium") + + def _start_automated_prefix_workflow(self, modlist_name, install_dir, mo2_exe_path): + """Start the automated prefix workflow using AutomatedPrefixService""" + try: + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + + self._safe_append_text(f"Initializing automated Steam setup for '{modlist_name}'...") + + # Initialize the automated prefix service + prefix_service = AutomatedPrefixService() + + # Define progress callback for GUI updates + def progress_callback(message): + self._safe_append_text(f"{message}") + + # Run the automated workflow + self._safe_append_text("Starting automated Steam shortcut creation and configuration...") + result = prefix_service.run_working_workflow( + modlist_name, install_dir, mo2_exe_path, progress_callback + ) + + # Handle the result - check for conflicts + if isinstance(result, tuple) and len(result) == 4: + if result[0] == "CONFLICT": + # Conflict detected - show conflict resolution dialog + conflicts = result[1] + self.show_shortcut_conflict_dialog(conflicts) + return + else: + # Normal result + success, prefix_path, new_appid, last_timestamp = result + if success: + self._safe_append_text(f"Automated Steam setup completed successfully!") + self._safe_append_text(f"New AppID assigned: {new_appid}") + + # Continue with post-Steam configuration, passing the last timestamp + self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp) + else: + self._safe_append_text(f"Automated Steam setup failed") + self._safe_append_text("Please check the logs for details.") + self.start_btn.setEnabled(True) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + elif isinstance(result, tuple) and len(result) == 3: + # Fallback for old format (backward compatibility) + success, prefix_path, new_appid = result + if success: + self._safe_append_text(f"Automated Steam setup completed successfully!") + self._safe_append_text(f"New AppID assigned: {new_appid}") + + # Continue with post-Steam configuration + self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir) + else: + self._safe_append_text(f"Automated Steam setup failed") + self._safe_append_text("Please check the logs for details.") + self.start_btn.setEnabled(True) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + else: + # Handle unexpected result format + self._safe_append_text(f"Automated Steam setup failed - unexpected result format") + self._safe_append_text("Please check the logs for details.") + self.start_btn.setEnabled(True) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + except Exception as e: + self._safe_append_text(f"Error during automated Steam setup: {str(e)}") + self._safe_append_text("Please check the logs for details.") + self.start_btn.setEnabled(True) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + def show_shortcut_conflict_dialog(self, conflicts): + """Show dialog to resolve shortcut name conflicts""" + conflict_names = [c['name'] for c in conflicts] + conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" + + modlist_name = self.modlist_name_edit.text().strip() + + # Create dialog with Jackify styling + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout + from PySide6.QtCore import Qt + + dialog = QDialog(self) + dialog.setWindowTitle("Steam Shortcut Conflict") + dialog.setModal(True) + dialog.resize(450, 180) + + # Apply Jackify dark theme styling + dialog.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + color: #ffffff; + } + QLabel { + color: #ffffff; + font-size: 14px; + padding: 10px 0px; + } + QLineEdit { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px; + font-size: 14px; + selection-background-color: #3fd0ea; + } + QLineEdit:focus { + border-color: #3fd0ea; + } + QPushButton { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + min-width: 120px; + } + QPushButton:hover { + background-color: #505050; + border-color: #3fd0ea; + } + QPushButton:pressed { + background-color: #303030; + } + """) + + layout = QVBoxLayout(dialog) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Conflict message + conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") + layout.addWidget(conflict_label) + + # Text input for new name + name_input = QLineEdit(modlist_name) + name_input.selectAll() + layout.addWidget(name_input) + + # Buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + create_button = QPushButton("Create with New Name") + cancel_button = QPushButton("Cancel") + + button_layout.addStretch() + button_layout.addWidget(cancel_button) + button_layout.addWidget(create_button) + layout.addLayout(button_layout) + + # Connect signals + def on_create(): + new_name = name_input.text().strip() + if new_name and new_name != modlist_name: + dialog.accept() + # Retry workflow with new name + self.retry_automated_workflow_with_new_name(new_name) + elif new_name == modlist_name: + # Same name - show warning + from jackify.backend.services.message_service import MessageService + MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") + else: + # Empty name + from jackify.backend.services.message_service import MessageService + MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + + def on_cancel(): + dialog.reject() + self._safe_append_text("Shortcut creation cancelled by user") + + create_button.clicked.connect(on_create) + cancel_button.clicked.connect(on_cancel) + + # Make Enter key work + name_input.returnPressed.connect(on_create) + + dialog.exec() + + def retry_automated_workflow_with_new_name(self, new_name): + """Retry the automated workflow with a new shortcut name""" + # Update the modlist name field temporarily + original_name = self.modlist_name_edit.text() + self.modlist_name_edit.setText(new_name) + + # Restart the automated workflow + self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") + 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") + self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path) + + def show_manual_steps_dialog(self, extra_warning=""): + modlist_name = self.modlist_name_edit.text().strip() or "your modlist" + msg = ( + f"Manual Proton Setup Required for {modlist_name}
" + "After Steam restarts, complete the following steps in Steam:
" + f"1. Locate the '{modlist_name}' entry in your Steam Library
" + "2. Right-click and select 'Properties'
" + "3. Switch to the 'Compatibility' tab
" + "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" + "5. Select 'Proton - Experimental' from the dropdown menu
" + "6. Close the Properties window
" + f"7. Launch '{modlist_name}' from your Steam Library
" + "8. Wait for Mod Organizer 2 to fully open
" + "9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here
" + "
Once you have completed ALL the steps above, click OK to continue." + f"{extra_warning}" + ) + reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") + if reply == QMessageBox.Yes: + self.validate_manual_steps_completion() + 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) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + 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") + + # CRITICAL: Re-detect the AppID after Steam restart and manual steps + # 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) + current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) + + if not current_appid or not current_appid.isdigit(): + self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") + self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") + self.handle_validation_failure("Could not find Steam shortcut") + return + + self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}") + self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}") + + # Check 1: Proton version + proton_ok = False + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + from jackify.backend.handlers.path_handler import PathHandler + + # Initialize ModlistHandler with correct parameters + path_handler = PathHandler() + modlist_handler = ModlistHandler(steamdeck=False, verbose=False) + + # Set required properties manually after initialization + modlist_handler.modlist_dir = install_dir + modlist_handler.appid = current_appid + modlist_handler.game_var = "skyrimspecialedition" # Tuxborn is always Skyrim + + # Set compat_data_path for Proton detection + compat_data_path_str = path_handler.find_compat_data(current_appid) + if compat_data_path_str: + from pathlib import Path + modlist_handler.compat_data_path = Path(compat_data_path_str) + + # Check Proton version + self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...") + if modlist_handler._detect_proton_version(): + self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'") + if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower(): + proton_ok = True + self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}") + else: + self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") + else: + self._safe_append_text("Error: Could not detect Proton version from any source") + + except Exception as e: + self._safe_append_text(f"Error checking Proton version: {e}") + proton_ok = False + + # Check 2: Compatdata directory exists + compatdata_ok = False + try: + from jackify.backend.handlers.path_handler import PathHandler + path_handler = PathHandler() + + self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...") + prefix_path_str = path_handler.find_compat_data(current_appid) + self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'") + + if prefix_path_str and os.path.isdir(prefix_path_str): + compatdata_ok = True + self._safe_append_text(f"Compatdata directory found: {prefix_path_str}") + else: + if prefix_path_str: + self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") + else: + self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") + + except Exception as e: + self._safe_append_text(f"Error checking compatdata: {e}") + compatdata_ok = False + + # Handle validation results + if proton_ok and compatdata_ok: + self._safe_append_text("Manual steps validation passed!") + self._safe_append_text("Continuing configuration with updated AppID...") + + # Continue configuration with the corrected AppID and context + self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) + else: + # Validation failed - handle retry logic + missing_items = [] + if not proton_ok: + missing_items.append("• Proton - Experimental not set") + if not compatdata_ok: + missing_items.append("• Shortcut not launched from Steam (no compatdata)") + + missing_text = "\n".join(missing_items) + self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") + self.handle_validation_failure(missing_text) + + def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None): + """Continue the configuration process with the new AppID after automated prefix creation""" + if last_timestamp: + # Initialize timing to continue from the last timestamp + from jackify.shared.timing import continue_from_timestamp + continue_from_timestamp(last_timestamp) + debug_print(f"Timing continued from {last_timestamp}") + + debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") + try: + # Update the context with the new AppID (same format as manual steps) + updated_context = { + 'name': modlist_name, + 'path': install_dir, + 'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"), + 'modlist_value': 'Tuxborn/Tuxborn', # Hardcoded for Tuxborn + 'modlist_source': 'identifier', + 'resolution': getattr(self, '_current_resolution', '2560x1600'), + '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 + 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition' + } + self.context = updated_context # Ensure context is always set + debug_print(f"Updated context with new AppID: {new_appid}") + + # 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): + super().__init__() + self.context = context + + def run(self): + try: + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + + # Initialize backend service + system_info = SystemInfo(is_steamdeck=False) + modlist_service = ModlistService(system_info) + + # Convert context to ModlistContext for service + modlist_context = ModlistContext( + name=self.context['name'], + install_dir=Path(self.context['path']), + download_dir=Path(self.context['path']).parent / 'Downloads', # Default + game_type='skyrim', # Default for now + nexus_api_key='', # Not needed for configuration + modlist_value=self.context['modlist_value'], + modlist_source=self.context['modlist_source'], + resolution=self.context.get('resolution', '2560x1600'), + skip_confirmation=True, + engine_installed=True # Skip path manipulation for engine workflows + ) + + # Add app_id to context + modlist_context.app_id = self.context['appid'] + + # Define callbacks + def progress_callback(message): + self.progress_update.emit(message) + + def completion_callback(success, message, modlist_name): + self.configuration_complete.emit(success, message, modlist_name) + + def manual_steps_callback(modlist_name, retry_count): + # 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 + self.progress_update.emit("") + self.progress_update.emit("=== Configuration Phase ===") + self.progress_update.emit("") + self.progress_update.emit("Starting modlist configuration...") + result = modlist_service.configure_modlist_post_steam( + context=modlist_context, + progress_callback=progress_callback, + manual_steps_callback=manual_steps_callback, + completion_callback=completion_callback + ) + + if not result: + self.progress_update.emit("Configuration failed to start") + self.error_occurred.emit("Configuration failed to start") + + except Exception as e: + self.error_occurred.emit(str(e)) + + # Start configuration thread + self.config_thread = ConfigThread(updated_context) + self.config_thread.progress_update.connect(self._safe_append_text) + self.config_thread.configuration_complete.connect(self.on_configuration_complete) + self.config_thread.error_occurred.connect(self.on_configuration_error) + self.config_thread.start() + + except Exception as e: + self._safe_append_text(f"Error continuing configuration: {e}") + import traceback + self._safe_append_text(f"Full traceback: {traceback.format_exc()}") + self.on_configuration_error(str(e)) + + def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir): + """Continue the configuration process with the corrected AppID after manual steps validation""" + try: + # Update the context with the new AppID + updated_context = { + 'name': modlist_name, + 'path': install_dir, + 'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"), + 'modlist_value': 'Tuxborn/Tuxborn', # Hardcoded for Tuxborn + 'modlist_source': 'identifier', + 'resolution': getattr(self, '_current_resolution', '2560x1600'), + 'skip_confirmation': True, + 'manual_steps_completed': True, # Mark as completed + 'appid': new_appid, # Use the NEW AppID from Steam + 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition' + } + self.context = updated_context # Ensure context is always set + debug_print(f"Updated context with new AppID: {new_appid}") + + # 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): + super().__init__() + self.context = context + + def run(self): + try: + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + + # Initialize backend service + system_info = SystemInfo(is_steamdeck=False) + modlist_service = ModlistService(system_info) + + # Convert context to ModlistContext for service + modlist_context = ModlistContext( + name=self.context['name'], + install_dir=Path(self.context['path']), + download_dir=Path(self.context['path']).parent / 'Downloads', # Default + game_type='skyrim', # Tuxborn is always Skyrim + nexus_api_key='', # Not needed for configuration + modlist_value=self.context.get('modlist_value', 'Tuxborn/Tuxborn'), + modlist_source=self.context.get('modlist_source', 'identifier'), + resolution=self.context.get('resolution'), # Pass resolution from GUI context + skip_confirmation=True, + engine_installed=True # Skip path manipulation for engine workflows + ) + + # Add app_id to context + if 'appid' in self.context: + modlist_context.app_id = self.context['appid'] + + # Define callbacks + def progress_callback(message): + self.progress_update.emit(message) + + def completion_callback(success, message, modlist_name): + self.configuration_complete.emit(success, message, modlist_name) + + def manual_steps_callback(modlist_name, retry_count): + # This shouldn't happen since manual steps should be done + self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") + + # Call the new service method for post-Steam configuration + self.progress_update.emit("Starting Tuxborn configuration (post-Steam setup)...") + result = modlist_service.configure_modlist_post_steam( + context=modlist_context, + progress_callback=progress_callback, + manual_steps_callback=manual_steps_callback, + completion_callback=completion_callback + ) + + except Exception as e: + self.error_occurred.emit(str(e)) + + # Clean up old thread if exists + if hasattr(self, 'config_thread') and self.config_thread is not None: + # Disconnect all signals to prevent "Internal C++ object already deleted" errors + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except: + pass # Ignore errors if already disconnected + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) # Wait up to 5 seconds + self.config_thread.deleteLater() + self.config_thread = None + + # Start new config thread + self.config_thread = ConfigThread(updated_context) + self.config_thread.progress_update.connect(self._safe_append_text) + self.config_thread.configuration_complete.connect(self.on_configuration_complete) + self.config_thread.error_occurred.connect(self.on_configuration_error) + self.config_thread.start() + + except Exception as e: + self._safe_append_text(f"Error continuing configuration: {e}") + self.on_configuration_error(str(e)) + + def on_configuration_complete(self, success, message, modlist_name): + """Handle configuration completion on main thread""" + if success: + # Calculate time taken + time_taken = self._calculate_time_taken() + + # Show success dialog with celebration + game_name = self.context.get('game_name', 'Skyrim Special Edition') + success_dialog = SuccessDialog( + modlist_name="Tuxborn", + workflow_type="tuxborn", + time_taken=time_taken, + game_name=game_name, + parent=self + ) + success_dialog.show() + elif 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.", safety_level="medium") + else: + # Configuration failed for other reasons + MessageService.critical(self, "Configuration Failed", + "Post-install configuration failed. Please check the console output.", safety_level="medium") + + # 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 + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except: + pass # Ignore errors if already disconnected + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) # Wait up to 5 seconds + self.config_thread.deleteLater() + self.config_thread = None + + def on_configuration_error(self, error_message): + """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}", safety_level="medium") + + # 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 + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except: + pass # Ignore errors if already disconnected + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) # Wait up to 5 seconds + self.config_thread.deleteLater() + self.config_thread = None + + def handle_validation_failure(self, missing_text): + """Handle failed validation with retry logic""" + self._manual_steps_retry_count += 1 + + if self._manual_steps_retry_count < 3: + # Show retry dialog + MessageService.critical(self, "Manual Steps Incomplete", + f"Manual steps validation failed:\n\n{missing_text}\n\n" + "Please complete the missing steps and try again.", safety_level="medium") + # Show manual steps dialog again + extra_warning = "" + if self._manual_steps_retry_count >= 2: + extra_warning = "
It looks like you have not completed the manual steps yet. Please try again." + self.show_manual_steps_dialog(extra_warning) + else: + # Max retries reached + MessageService.critical(self, "Manual Steps Failed", + "Manual steps validation failed after multiple attempts.", safety_level="medium") + self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) + + def show_next_steps_dialog(self, message): + # EXACT LEGACY show_next_steps_dialog + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication + dlg = QDialog(self) + dlg.setWindowTitle("Next Steps") + dlg.setModal(True) + layout = QVBoxLayout(dlg) + label = QLabel(message) + label.setWordWrap(True) + layout.addWidget(label) + btn_row = QHBoxLayout() + btn_return = QPushButton("Return") + btn_exit = QPushButton("Exit") + btn_row.addWidget(btn_return) + btn_row.addWidget(btn_exit) + layout.addLayout(btn_row) + def on_return(): + dlg.accept() + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(0) # Main menu + def on_exit(): + QApplication.quit() + btn_return.clicked.connect(on_return) + btn_exit.clicked.connect(on_exit) + dlg.exec() + + def cleanup_processes(self): + """Clean up any running processes when the window closes or is cancelled""" + debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") + + # Clean up InstallationThread if running + if hasattr(self, 'install_thread') and self.install_thread.isRunning(): + debug_print("DEBUG: Cancelling running InstallationThread") + self.install_thread.cancel() + self.install_thread.wait(3000) # Wait up to 3 seconds + if self.install_thread.isRunning(): + self.install_thread.terminate() + + # Clean up other threads + threads = [ + 'config_thread', 'fetch_thread' + ] + for thread_name in threads: + if hasattr(self, thread_name): + thread = getattr(self, thread_name) + if thread and thread.isRunning(): + debug_print(f"DEBUG: Terminating {thread_name}") + thread.terminate() + thread.wait(1000) # Wait up to 1 second + + def cancel_installation(self): + """Cancel the currently running installation""" + reply = MessageService.question( + self, "Cancel Installation", + "Are you sure you want to cancel the installation?", + safety_level="low" + ) + + if reply == QMessageBox.Yes: + self._safe_append_text("\n🛑 Cancelling installation...") + + # Cancel the installation thread if it exists + if hasattr(self, 'install_thread') and self.install_thread.isRunning(): + self.install_thread.cancel() + self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown + if self.install_thread.isRunning(): + self.install_thread.terminate() # Force terminate if needed + self.install_thread.wait(1000) + + # Cleanup any remaining processes + self.cleanup_processes() + + # Reset button states + self.start_btn.setEnabled(True) + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + self._safe_append_text("Installation cancelled by user.") + + def closeEvent(self, event): + """Handle window close event - clean up processes""" + self.cleanup_processes() + event.accept() + + def _calculate_time_taken(self) -> str: + """Calculate and format the time taken for the workflow""" + if self._workflow_start_time is None: + return "unknown time" + + elapsed_seconds = time.time() - self._workflow_start_time + elapsed_minutes = int(elapsed_seconds // 60) + elapsed_seconds_remainder = int(elapsed_seconds % 60) + + if elapsed_minutes > 0: + if elapsed_minutes == 1: + return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_seconds_remainder} seconds" \ No newline at end of file diff --git a/jackify/frontends/gui/services/__init__.py b/jackify/frontends/gui/services/__init__.py new file mode 100644 index 0000000..40c0451 --- /dev/null +++ b/jackify/frontends/gui/services/__init__.py @@ -0,0 +1,8 @@ +""" +GUI Services for Jackify Frontend + +Service layer that provides GUI-friendly interfaces to backend services, +including progress callbacks, error handling, and Qt signal integration. +""" + +__all__ = [] \ No newline at end of file diff --git a/jackify/frontends/gui/services/message_service.py b/jackify/frontends/gui/services/message_service.py new file mode 100644 index 0000000..2b56945 --- /dev/null +++ b/jackify/frontends/gui/services/message_service.py @@ -0,0 +1,287 @@ +""" +Non-Focus-Stealing Message Service for Jackify +Provides message boxes that don't steal focus from the current application +""" + +import random +import string +from typing import Optional +from PySide6.QtWidgets import QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QCheckBox +from PySide6.QtCore import Qt, QTimer + + +class NonFocusMessageBox(QMessageBox): + """Custom QMessageBox that prevents focus stealing""" + + def __init__(self, parent=None, critical=False, safety_level="low"): + super().__init__(parent) + self.safety_level = safety_level + self._setup_no_focus_attributes(critical, safety_level) + + def _setup_no_focus_attributes(self, critical, safety_level): + """Configure the message box to not steal focus""" + # Set modality based on criticality and safety level + if critical or safety_level == "high": + self.setWindowModality(Qt.ApplicationModal) + elif safety_level == "medium": + self.setWindowModality(Qt.NonModal) + else: + self.setWindowModality(Qt.NonModal) + + # Prevent focus stealing + self.setAttribute(Qt.WA_ShowWithoutActivating, True) + self.setWindowFlags( + self.windowFlags() | + Qt.WindowStaysOnTopHint | + Qt.WindowDoesNotAcceptFocus + ) + + # Set focus policy to prevent taking focus + self.setFocusPolicy(Qt.NoFocus) + + # Make sure child widgets don't steal focus either + for child in self.findChildren(QWidget): + child.setFocusPolicy(Qt.NoFocus) + + def showEvent(self, event): + """Override to ensure no focus stealing on show""" + super().showEvent(event) + # Ensure we don't steal focus + self.activateWindow() + self.raise_() + + +class SafeMessageBox(NonFocusMessageBox): + """Enhanced message box with safety features""" + + def __init__(self, parent=None, safety_level="low"): + super().__init__(parent, critical=(safety_level == "high"), safety_level=safety_level) + self.safety_level = safety_level + self.countdown_remaining = 0 + self.confirmation_code = None + self.countdown_timer = None + self.code_input = None + self.understanding_checkbox = None + + def setup_safety_features(self, title: str, message: str, + danger_action: str = "OK", + safe_action: str = "Cancel", + is_question: bool = False): + self.setWindowTitle(title) + self.setText(message) + if self.safety_level == "high": + self.setIcon(QMessageBox.Warning) + self._setup_high_safety(danger_action, safe_action) + elif self.safety_level == "medium": + self.setIcon(QMessageBox.Information) + self._setup_medium_safety(danger_action, safe_action) + else: + self.setIcon(QMessageBox.Information) + self._setup_low_safety(danger_action, safe_action) + # --- Fix: For question dialogs, set proceed/cancel button return values, but do NOT call setStandardButtons --- + if is_question and hasattr(self, 'proceed_btn'): + self.proceed_btn.setText(danger_action) + self.proceed_btn.setProperty('role', QMessageBox.YesRole) + self.proceed_btn.clicked.disconnect() + self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes)) + self.cancel_btn.setText(safe_action) + self.cancel_btn.setProperty('role', QMessageBox.NoRole) + self.cancel_btn.clicked.disconnect() + self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No)) + + def _setup_high_safety(self, danger_action: str, safe_action: str): + """High safety: requires typing confirmation code""" + # Generate random confirmation code + self.confirmation_code = ''.join(random.choices(string.ascii_uppercase, k=6)) + + # Create custom buttons + self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole) + self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole) + + # Make cancel the default (Enter key) + self.setDefaultButton(self.cancel_btn) + + # Initially disable proceed button + self.proceed_btn.setEnabled(False) + + # Add confirmation code input + widget = QWidget() + layout = QVBoxLayout(widget) + + instruction = QLabel(f"Type '{self.confirmation_code}' to confirm:") + instruction.setStyleSheet("font-weight: bold; color: red;") + layout.addWidget(instruction) + + self.code_input = QLineEdit() + self.code_input.setPlaceholderText("Enter confirmation code...") + self.code_input.textChanged.connect(self._check_code_input) + layout.addWidget(self.code_input) + + self.layout().addWidget(widget, 1, 0, 1, self.layout().columnCount()) + + # Start countdown + self._start_countdown(3) + + def _setup_medium_safety(self, danger_action: str, safe_action: str): + """Medium safety: requires wait period""" + # Create custom buttons + self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole) + self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole) + + # Make cancel the default (Enter key) + self.setDefaultButton(self.cancel_btn) + + # Initially disable proceed button + self.proceed_btn.setEnabled(False) + + # Start countdown + self._start_countdown(3) + + def _setup_low_safety(self, danger_action: str, safe_action: str): + """Low safety: no additional features needed""" + # Create standard buttons + self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole) + self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole) + + # Make proceed the default for low safety + self.setDefaultButton(self.proceed_btn) + + def _start_countdown(self, seconds: int): + self.countdown_timer = QTimer() + self.countdown_timer.timeout.connect(self._update_countdown) + self.countdown_remaining = seconds + self._update_countdown() + self.countdown_timer.start(1000) # Update every second + + def _update_countdown(self): + if self.countdown_remaining > 0: + if hasattr(self, 'proceed_btn'): + if self.safety_level == "high": + self.proceed_btn.setText(f"Please wait {self.countdown_remaining}s...") + else: + self.proceed_btn.setText(f"OK ({self.countdown_remaining}s)") + self.proceed_btn.setEnabled(False) + if hasattr(self, 'cancel_btn'): + self.cancel_btn.setEnabled(False) + self.countdown_remaining -= 1 + else: + self.countdown_timer.stop() + if hasattr(self, 'proceed_btn'): + if self.safety_level == "high": + self.proceed_btn.setText("Proceed") + else: + self.proceed_btn.setText("OK") + self.proceed_btn.setEnabled(True) + if hasattr(self, 'cancel_btn'): + self.cancel_btn.setEnabled(True) + self._check_all_requirements() + + def _check_code_input(self): + """Check if typed code matches""" + if self.countdown_remaining <= 0: + self._check_all_requirements() + + def _check_all_requirements(self): + """Check if all requirements are met""" + can_proceed = self.countdown_remaining <= 0 + + if self.safety_level == "high": + can_proceed = can_proceed and ( + self.code_input.text().upper() == self.confirmation_code + ) + + self.proceed_btn.setEnabled(can_proceed) + + +class MessageService: + """Service class for creating non-focus-stealing message boxes""" + + @staticmethod + def _create_base_message_box(parent: Optional[QWidget] = None, critical: bool = False, safety_level: str = "low") -> NonFocusMessageBox: + """Create a base message box with no focus stealing""" + if safety_level in ["medium", "high"]: + return SafeMessageBox(parent, safety_level) + else: + return NonFocusMessageBox(parent, critical) + + @staticmethod + def information(parent: Optional[QWidget] = None, + title: str = "Information", + message: str = "", + buttons: QMessageBox.StandardButtons = QMessageBox.Ok, + default_button: QMessageBox.StandardButton = QMessageBox.Ok, + critical: bool = False, + safety_level: str = "low") -> int: + """Show information message without stealing focus""" + if safety_level in ["medium", "high"]: + msg_box = SafeMessageBox(parent, safety_level) + msg_box.setup_safety_features(title, message, "OK", "Cancel") + else: + msg_box = MessageService._create_base_message_box(parent, critical, safety_level) + msg_box.setIcon(QMessageBox.Information) + msg_box.setWindowTitle(title) + msg_box.setText(message) + msg_box.setStandardButtons(buttons) + msg_box.setDefaultButton(default_button) + + return msg_box.exec() + + @staticmethod + def warning(parent: Optional[QWidget] = None, + title: str = "Warning", + message: str = "", + buttons: QMessageBox.StandardButtons = QMessageBox.Ok, + default_button: QMessageBox.StandardButton = QMessageBox.Ok, + critical: bool = False, + safety_level: str = "low") -> int: + """Show warning message without stealing focus""" + if safety_level in ["medium", "high"]: + msg_box = SafeMessageBox(parent, safety_level) + msg_box.setup_safety_features(title, message, "OK", "Cancel") + else: + msg_box = MessageService._create_base_message_box(parent, critical, safety_level) + msg_box.setIcon(QMessageBox.Warning) + msg_box.setWindowTitle(title) + msg_box.setText(message) + msg_box.setStandardButtons(buttons) + msg_box.setDefaultButton(default_button) + + return msg_box.exec() + + @staticmethod + def critical(parent: Optional[QWidget] = None, + title: str = "Critical Error", + message: str = "", + buttons: QMessageBox.StandardButtons = QMessageBox.Ok, + default_button: QMessageBox.StandardButton = QMessageBox.Ok, + safety_level: str = "medium") -> int: + """Show critical error message (always requires attention)""" + msg_box = MessageService._create_base_message_box(parent, critical=True, safety_level=safety_level) + msg_box.setIcon(QMessageBox.Critical) + msg_box.setWindowTitle(title) + msg_box.setText(message) + msg_box.setStandardButtons(buttons) + msg_box.setDefaultButton(default_button) + return msg_box.exec() + + @staticmethod + def question(parent: Optional[QWidget] = None, + title: str = "Question", + message: str = "", + buttons: QMessageBox.StandardButtons = QMessageBox.Yes | QMessageBox.No, + default_button: QMessageBox.StandardButton = QMessageBox.No, + critical: bool = False, + safety_level: str = "low") -> int: + """Show question dialog without stealing focus""" + if safety_level in ["medium", "high"]: + msg_box = SafeMessageBox(parent, safety_level) + msg_box.setup_safety_features(title, message, "Yes", "No", is_question=True) + else: + msg_box = MessageService._create_base_message_box(parent, critical, safety_level) + msg_box.setIcon(QMessageBox.Question) + msg_box.setWindowTitle(title) + msg_box.setText(message) + msg_box.setStandardButtons(buttons) + msg_box.setDefaultButton(default_button) + + return msg_box.exec() \ No newline at end of file diff --git a/jackify/frontends/gui/shared_theme.py b/jackify/frontends/gui/shared_theme.py new file mode 100644 index 0000000..86456d2 --- /dev/null +++ b/jackify/frontends/gui/shared_theme.py @@ -0,0 +1,15 @@ +""" +Jackify GUI theme and shared constants +""" +import os + +JACKIFY_COLOR_BLUE = "#3fd0ea" # Official Jackify blue +DEBUG_BORDERS = False +ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'assets') +LOGO_PATH = os.path.join(ASSETS_DIR, 'jackify_logo.png') +DISCLAIMER_TEXT = ( + "Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, " + "without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. " + "The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. " + "Please back up your data and use caution." +) \ No newline at end of file diff --git a/jackify/frontends/gui/utils.py b/jackify/frontends/gui/utils.py new file mode 100644 index 0000000..80999cf --- /dev/null +++ b/jackify/frontends/gui/utils.py @@ -0,0 +1,38 @@ +""" +GUI Utilities for Jackify Frontend +""" +import re + +ANSI_COLOR_MAP = { + '30': 'black', '31': 'red', '32': 'green', '33': 'yellow', '34': 'blue', '35': 'magenta', '36': 'cyan', '37': 'white', + '90': 'gray', '91': 'lightcoral', '92': 'lightgreen', '93': 'khaki', '94': 'lightblue', '95': 'violet', '96': 'lightcyan', '97': 'white' +} +ANSI_RE = re.compile(r'\x1b\[(\d+)(;\d+)?m') + +def ansi_to_html(text): + """Convert ANSI color codes to HTML""" + result = '' + last_end = 0 + color = None + for match in ANSI_RE.finditer(text): + start, end = match.span() + code = match.group(1) + if start > last_end: + chunk = text[last_end:start] + if color: + result += f'{chunk}' + else: + result += chunk + if code == '0': + color = None + elif code in ANSI_COLOR_MAP: + color = ANSI_COLOR_MAP[code] + last_end = end + if last_end < len(text): + chunk = text[last_end:] + if color: + result += f'{chunk}' + else: + result += chunk + result = result.replace('\n', '
') + return result \ No newline at end of file diff --git a/jackify/frontends/gui/widgets/unsupported_game_dialog.py b/jackify/frontends/gui/widgets/unsupported_game_dialog.py new file mode 100644 index 0000000..48b8570 --- /dev/null +++ b/jackify/frontends/gui/widgets/unsupported_game_dialog.py @@ -0,0 +1,201 @@ +""" +Unsupported Game Dialog Widget + +This module provides a popup dialog to warn users when they're about to install +a modlist for a game that doesn't support automated post-install configuration. +""" + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTextEdit, QFrame +) +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont, QPixmap, QIcon + + +class UnsupportedGameDialog(QDialog): + """ + Dialog to warn users about unsupported games for post-install configuration. + + This dialog informs users that while any modlist can be downloaded with Jackify, + only certain games support automated post-install configuration. + """ + + # Signal emitted when user clicks OK to continue + continue_installation = Signal() + + def __init__(self, parent=None, game_name: str = None): + super().__init__(parent) + self.game_name = game_name + self.setup_ui() + self.setup_connections() + + def setup_ui(self): + """Set up the dialog UI.""" + self.setWindowTitle("Game Support Notice") + self.setModal(True) + self.setFixedSize(500, 500) + + # Main layout + layout = QVBoxLayout() + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + # Icon and title (smaller, less vertical space) + title_layout = QHBoxLayout() + icon_label = QLabel("!") + icon_label.setFont(QFont("Arial", 18, QFont.Weight.Bold)) + icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + icon_label.setFixedSize(32, 32) + icon_label.setStyleSheet("color: #e67e22;") + title_layout.addWidget(icon_label) + title_label = QLabel("Game Support Notice") + title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #3fd0ea;") + title_layout.addWidget(title_label) + title_layout.addStretch() + layout.addLayout(title_layout) + # Reduce space after title + layout.addSpacing(4) + # Separator line + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setFrameShadow(QFrame.Shadow.Sunken) + separator.setStyleSheet("background: #444; max-height: 1px;") + layout.addWidget(separator) + # Reduce space after separator + layout.addSpacing(4) + # Message text + message_text = QTextEdit() + message_text.setReadOnly(True) + message_text.setMaximumHeight(340) + message_text.setStyleSheet(""" + QTextEdit { + background-color: #23272e; + color: #f8f9fa; + border: 1px solid #444; + border-radius: 6px; + padding: 12px; + font-size: 12px; + font-family: 'Segoe UI', Arial, sans-serif; + } + """) + + # Create the message content + if self.game_name: + message = f"""

You are about to install a modlist for {self.game_name}.

+ +

While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to:

+ +
    +
  • Skyrim Special Edition
  • +
  • Fallout 4
  • +
  • Fallout New Vegas
  • +
  • Oblivion
  • +
  • Starfield
  • +
  • Oblivion Remastered
  • +
+ +

For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.

+ +

We are working to add more automated support in future releases!

+ +

Click Continue to proceed with the modlist installation, or Cancel to go back.

""" + else: + message = f"""

You are about to install a modlist for an unsupported game.

+ +

While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to:

+ +
    +
  • Skyrim Special Edition
  • +
  • Fallout 4
  • +
  • Fallout New Vegas
  • +
  • Oblivion
  • +
  • Starfield
  • +
  • Oblivion Remastered
  • +
+ +

For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.

+ +

We are working to add more automated support in future releases!

+ +

Click Continue to proceed with the modlist installation, or Cancel to go back.

""" + + message_text.setHtml(message) + layout.addWidget(message_text) + + # Button layout (Continue left, Cancel right) + button_layout = QHBoxLayout() + button_layout.addStretch() + continue_button = QPushButton("Continue") + continue_button.setFixedSize(100, 35) + continue_button.setDefault(True) + continue_button.setStyleSheet(""" + QPushButton { + background-color: #3fd0ea; + color: #23272e; + border: none; + border-radius: 5px; + font-weight: bold; + } + QPushButton:hover { + background-color: #2bb8d6; + } + QPushButton:pressed { + background-color: #1a7e99; + } + """) + button_layout.addWidget(continue_button) + cancel_button = QPushButton("Cancel") + cancel_button.setFixedSize(100, 35) + cancel_button.setStyleSheet(""" + QPushButton { + background-color: #6c757d; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + } + QPushButton:hover { + background-color: #5a6268; + } + QPushButton:pressed { + background-color: #545b62; + } + """) + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + self.setLayout(layout) + self.cancel_button = cancel_button + self.continue_button = continue_button + self.setStyleSheet(""" + QDialog { + background-color: #23272e; + color: #f8f9fa; + } + QLabel { + color: #f8f9fa; + } + """) + + def setup_connections(self): + """Set up signal connections.""" + self.cancel_button.clicked.connect(self.reject) + self.continue_button.clicked.connect(self.accept) + self.accepted.connect(self.continue_installation.emit) + + @staticmethod + def show_dialog(parent=None, game_name: str = None) -> bool: + """ + Show the unsupported game dialog and return the user's choice. + + Args: + parent: Parent widget + game_name: Name of the unsupported game (optional) + + Returns: + True if user clicked Continue, False if Cancel + """ + dialog = UnsupportedGameDialog(parent, game_name) + result = dialog.exec() + return result == QDialog.DialogCode.Accepted \ No newline at end of file diff --git a/jackify/shared/__init__.py b/jackify/shared/__init__.py new file mode 100644 index 0000000..14f0cf0 --- /dev/null +++ b/jackify/shared/__init__.py @@ -0,0 +1,5 @@ +""" +Shared Utilities + +Common utilities used by both CLI and GUI frontends. +""" \ No newline at end of file diff --git a/jackify/shared/colors.py b/jackify/shared/colors.py new file mode 100644 index 0000000..204df1f --- /dev/null +++ b/jackify/shared/colors.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +UI Color Constants +""" + +COLOR_PROMPT = '\033[93m' # Yellow +COLOR_SELECTION = '\033[96m' # Cyan +COLOR_RESET = '\033[0m' +COLOR_INFO = '\033[94m' # Blue +COLOR_ERROR = '\033[91m' # Red +COLOR_SUCCESS = '\033[92m' # Green +COLOR_WARNING = '\033[93m' # Yellow (reusing prompt color) +COLOR_DISABLED = '\033[90m' # Grey + +COLOR_ACTION = '\033[97m' # Bright White for action/descriptions +COLOR_INPUT = '\033[97m' # Bright White for input prompts \ No newline at end of file diff --git a/jackify/shared/logging.py b/jackify/shared/logging.py new file mode 100644 index 0000000..8b6dc76 --- /dev/null +++ b/jackify/shared/logging.py @@ -0,0 +1,201 @@ +""" +LoggingHandler module for managing logging operations. +This module handles log file creation, rotation, and management. +""" + +import os +import logging +import logging.handlers +from pathlib import Path +from typing import Optional, Dict, List +from datetime import datetime +import shutil + +class LoggingHandler: + """ + Central logging handler for Jackify. + - Uses ~/Jackify/logs/ as the log directory. + - 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" + self.ensure_log_directory() + + def ensure_log_directory(self) -> None: + """Ensure the log directory exists.""" + try: + self.log_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + print(f"Failed to create log directory: {e}") + + def rotate_log_file_per_run(self, log_file_path: Path, backup_count: int = 5): + """Rotate the log file on every run, keeping up to backup_count backups.""" + if log_file_path.exists(): + # Remove the oldest backup if it exists + oldest = log_file_path.with_suffix(log_file_path.suffix + f'.{backup_count}') + if oldest.exists(): + oldest.unlink() + # Shift backups + for i in range(backup_count - 1, 0, -1): + src = log_file_path.with_suffix(log_file_path.suffix + f'.{i}') + dst = log_file_path.with_suffix(log_file_path.suffix + f'.{i+1}') + if src.exists(): + src.rename(dst) + # Move current log to .1 + log_file_path.rename(log_file_path.with_suffix(log_file_path.suffix + '.1')) + + def rotate_log_for_logger(self, name: str, log_file: Optional[str] = None, backup_count: int = 5): + """ + Rotate the log file for a logger before any logging occurs. + Must be called BEFORE any log is written or file handler is attached. + """ + file_path = self.log_dir / (log_file if log_file else "jackify-cli.log") + self.rotate_log_file_per_run(file_path, backup_count=backup_count) + + def setup_logger(self, name: str, log_file: Optional[str] = None, is_general: bool = False) -> logging.Logger: + """Set up a logger with file and console handlers. Call rotate_log_for_logger before this if you want per-run rotation.""" + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + logger.propagate = False + + # Create formatters + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + console_formatter = logging.Formatter( + '%(levelname)s: %(message)s' + ) + + # Add console handler - check debug mode from config + console_handler = logging.StreamHandler() + + # Check if debug mode is enabled + try: + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + if debug_mode: + console_handler.setLevel(logging.DEBUG) + else: + console_handler.setLevel(logging.ERROR) + except Exception: + # Fallback to ERROR level if config can't be loaded + console_handler.setLevel(logging.ERROR) + console_handler.setFormatter(console_formatter) + if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers): + logger.addHandler(console_handler) + + # Add file handler if log_file is specified, or use default for general + if log_file or is_general: + file_path = self.log_dir / (log_file if log_file else "jackify-cli.log") + file_handler = logging.handlers.RotatingFileHandler( + file_path, mode='a', encoding='utf-8', maxBytes=1024*1024, backupCount=5 + ) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(file_formatter) + if not any(isinstance(h, logging.handlers.RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(file_path) for h in logger.handlers): + logger.addHandler(file_handler) + + return logger + + def rotate_logs(self, max_bytes: int = 1024 * 1024, backup_count: int = 5) -> None: + """Rotate log files based on size.""" + for log_file in self.get_log_files(): + try: + if log_file.stat().st_size > max_bytes: + # Create backup + backup_path = log_file.with_suffix(f'.{datetime.now().strftime("%Y%m%d_%H%M%S")}.log') + log_file.rename(backup_path) + + # Clean up old backups + backups = sorted(log_file.parent.glob(f"{log_file.stem}.*.log")) + if len(backups) > backup_count: + for old_backup in backups[:-backup_count]: + old_backup.unlink() + except Exception as e: + print(f"Failed to rotate log file {log_file}: {e}") + + def cleanup_old_logs(self, days: int = 30) -> None: + """Clean up log files older than specified days.""" + cutoff = datetime.now().timestamp() - (days * 24 * 60 * 60) + for log_file in self.get_log_files(): + try: + if log_file.stat().st_mtime < cutoff: + log_file.unlink() + except Exception as e: + print(f"Failed to clean up log file {log_file}: {e}") + + def get_log_files(self) -> List[Path]: + """Get a list of all log files.""" + return list(self.log_dir.glob("*.log")) + + def get_log_content(self, log_file: Path, lines: int = 100) -> List[str]: + """Get the last N lines of a log file.""" + try: + with open(log_file, 'r') as f: + return f.readlines()[-lines:] + except Exception as e: + print(f"Failed to read log file {log_file}: {e}") + return [] + + def search_logs(self, pattern: str) -> Dict[Path, List[str]]: + """Search all log files for a pattern.""" + results = {} + for log_file in self.get_log_files(): + try: + with open(log_file, 'r') as f: + matches = [line for line in f if pattern in line] + if matches: + results[log_file] = matches + except Exception as e: + print(f"Failed to search log file {log_file}: {e}") + return results + + def export_logs(self, output_dir: Path) -> bool: + """Export all logs to a directory.""" + try: + output_dir.mkdir(parents=True, exist_ok=True) + for log_file in self.get_log_files(): + shutil.copy2(log_file, output_dir / log_file.name) + return True + except Exception as e: + print(f"Failed to export logs: {e}") + return False + + def set_log_level(self, level: int) -> None: + """Set the logging level for all loggers.""" + for logger_name in logging.root.manager.loggerDict: + logger = logging.getLogger(logger_name) + logger.setLevel(level) + + def get_log_stats(self) -> Dict: + """Get statistics about log files.""" + stats = { + 'total_files': 0, + 'total_size': 0, + 'largest_file': None, + 'oldest_file': None, + 'newest_file': None + } + + try: + log_files = self.get_log_files() + stats['total_files'] = len(log_files) + + if log_files: + stats['total_size'] = sum(f.stat().st_size for f in log_files) + stats['largest_file'] = max(log_files, key=lambda x: x.stat().st_size) + stats['oldest_file'] = min(log_files, key=lambda x: x.stat().st_mtime) + stats['newest_file'] = max(log_files, key=lambda x: x.stat().st_mtime) + + except Exception as e: + print(f"Failed to get log stats: {e}") + + return stats + + def get_general_logger(self): + """Get the general CLI logger (~/Jackify/logs/jackify-cli.log).""" + return self.setup_logger('jackify_cli', is_general=True) \ No newline at end of file diff --git a/jackify/shared/paths.py b/jackify/shared/paths.py new file mode 100644 index 0000000..bbe9a8c --- /dev/null +++ b/jackify/shared/paths.py @@ -0,0 +1,958 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Path Handler Module +Handles path-related operations for ModOrganizer.ini and other configuration files +""" + +import os +import re +import logging +import shutil +from pathlib import Path +from typing import Optional, Union, Dict, Any, List, Tuple +from datetime import datetime + +# Initialize logger +logger = logging.getLogger(__name__) + +# --- Configuration (Adapted from Proposal) --- +# Define known script extender executables (lowercase for comparisons) +TARGET_EXECUTABLES_LOWER = ["skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe"] +# Define known stock game folder names (case-sensitive, as they appear on disk) +STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"] +# Define the SD card path prefix on Steam Deck/Linux +SDCARD_PREFIX = '/run/media/mmcblk0p1/' + +class PathHandler: + """ + Handles path-related operations for ModOrganizer.ini and other configuration files + """ + + @staticmethod + def _strip_sdcard_path_prefix(path_obj: Path) -> str: + """ + Removes the '/run/media/mmcblk0p1/' prefix if present. + 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 + return path_str + + @staticmethod + def update_mo2_ini_paths( + modlist_ini_path: Path, + modlist_dir_path: Path, + modlist_sdcard: bool, + steam_library_common_path: Optional[Path] = None, + basegame_dir_name: Optional[str] = None, + basegame_sdcard: bool = False # Default to False if not provided + ) -> bool: + logger.info(f"[DEBUG] update_mo2_ini_paths called with: modlist_ini_path={modlist_ini_path}, modlist_dir_path={modlist_dir_path}, modlist_sdcard={modlist_sdcard}, steam_library_common_path={steam_library_common_path}, basegame_dir_name={basegame_dir_name}, basegame_sdcard={basegame_sdcard}") + if not modlist_ini_path.is_file(): + logger.error(f"ModOrganizer.ini not found at specified path: {modlist_ini_path}") + # Attempt to create a minimal INI + try: + logger.warning("Creating minimal ModOrganizer.ini with [General] section.") + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.write('[General]\n') + # Continue as if file existed + except Exception as e: + logger.critical(f"Failed to create minimal ModOrganizer.ini: {e}") + return False + if not modlist_dir_path.is_dir(): + logger.error(f"Modlist directory not found or not a directory: {modlist_dir_path}") + # Warn but continue + + # --- Bulletproof game directory detection --- + # 1. Get all Steam libraries and log them + all_steam_libraries = PathHandler.get_all_steam_library_paths() + logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") + import sys + if hasattr(sys, 'argv') and any(arg in ('--debug', '-d') for arg in sys.argv): + # Debug logging for Steam libraries detection - use logger if available + if hasattr(globals(), 'logger') and logger: + logger.debug(f"Detected Steam libraries: {all_steam_libraries}") + # If no logger available, this debug info is not critical for user operation + + # 2. For each library, check for the canonical vanilla game directory + GAME_DIR_NAMES = { + "Skyrim Special Edition": "Skyrim Special Edition", + "Fallout 4": "Fallout 4", + "Fallout New Vegas": "Fallout New Vegas", + "Oblivion": "Oblivion" + } + canonical_name = None + if basegame_dir_name and basegame_dir_name in GAME_DIR_NAMES: + canonical_name = GAME_DIR_NAMES[basegame_dir_name] + elif basegame_dir_name: + canonical_name = basegame_dir_name # fallback, but should match above + gamepath_target_dir = None + gamepath_target_is_sdcard = modlist_sdcard + checked_candidates = [] + if canonical_name: + for lib in all_steam_libraries: + candidate = lib / "steamapps" / "common" / canonical_name + checked_candidates.append(str(candidate)) + logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") + if candidate.is_dir(): + gamepath_target_dir = candidate + logger.info(f"Found vanilla game directory: {candidate}") + break + if not gamepath_target_dir: + logger.error(f"Could not find vanilla game directory '{canonical_name}' in any Steam library. Checked: {checked_candidates}") + # 4. Prompt the user for the path + print("\nCould not automatically detect a Stock Game or vanilla game directory.") + print("Please enter the full path to your vanilla game directory (e.g., /path/to/Skyrim Special Edition):") + while True: + user_input = input("Game directory path: ").strip() + user_path = Path(user_input) + logger.info(f"[DEBUG] User entered: {user_input}") + if user_path.is_dir(): + exe_candidates = list(user_path.glob('*.exe')) + logger.info(f"[DEBUG] .exe files in user path: {exe_candidates}") + if exe_candidates: + gamepath_target_dir = user_path + logger.info(f"User provided valid vanilla game directory: {gamepath_target_dir}") + break + else: + print("Directory exists but does not appear to contain the game executable. Please check and try again.") + logger.warning("User path exists but no .exe files found.") + else: + print("Directory not found. Please enter a valid path.") + logger.warning("User path does not exist.") + if not gamepath_target_dir: + logger.critical("[FATAL] Could not determine a valid target directory for gamePath. Check configuration and paths. Aborting update.") + return False + + # 3. Update gamePath, binary, and workingDirectory entries in the INI + logger.debug(f"Determined gamePath target directory: {gamepath_target_dir}") + logger.debug(f"gamePath target is on SD card: {gamepath_target_is_sdcard}") + try: + logger.debug(f"Reading original INI file: {modlist_ini_path}") + with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f: + original_lines = f.readlines() + + # --- Find and robustly update gamePath line --- + gamepath_line_num = -1 + general_section_line = -1 + for i, line in enumerate(original_lines): + if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE): + general_section_line = i + if re.match(r'^\s*gamepath\s*=\s*', line, re.IGNORECASE): + gamepath_line_num = i + break + processed_str = PathHandler._strip_sdcard_path_prefix(gamepath_target_dir) + windows_style_single = processed_str.replace('/', '\\') + gamepath_drive_letter = "D:" if gamepath_target_is_sdcard else "Z:" + # Use robust formatter + formatted_gamepath = PathHandler._format_gamepath_for_mo2(f'{gamepath_drive_letter}{windows_style_single}') + new_gamepath_line = f'gamePath = @ByteArray({formatted_gamepath})\n' + if gamepath_line_num != -1: + logger.info(f"Updating existing gamePath line: {original_lines[gamepath_line_num].strip()} -> {new_gamepath_line.strip()}") + original_lines[gamepath_line_num] = new_gamepath_line + else: + insert_at = general_section_line + 1 if general_section_line != -1 else 0 + logger.info(f"Adding missing gamePath line at line {insert_at+1}: {new_gamepath_line.strip()}") + original_lines.insert(insert_at, new_gamepath_line) + + # --- Update customExecutables binaries and workingDirectories --- + TARGET_EXECUTABLES_LOWER = [ + "skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe" + ] + in_custom_exec = False + for i, line in enumerate(original_lines): + if re.match(r'^\s*\[customExecutables\]\s*$', line, re.IGNORECASE): + in_custom_exec = True + continue + if in_custom_exec and re.match(r'^\s*\[.*\]\s*$', line): + in_custom_exec = False + if in_custom_exec: + m = re.match(r'^(\d+)\\binary\s*=\s*(.*)$', line.strip(), re.IGNORECASE) + if m: + idx, old_path = m.group(1), m.group(2) + exe_name = os.path.basename(old_path).lower() + if exe_name in TARGET_EXECUTABLES_LOWER: + new_path = f'{gamepath_drive_letter}/{PathHandler._strip_sdcard_path_prefix(gamepath_target_dir)}/{exe_name}' + # Use robust formatter + new_path = PathHandler._format_binary_for_mo2(new_path) + logger.info(f"Updating binary for entry {idx}: {old_path} -> {new_path}") + original_lines[i] = f'{idx}\\binary = {new_path}\n' + m_wd = re.match(r'^(\d+)\\workingDirectory\s*=\s*(.*)$', line.strip(), re.IGNORECASE) + if m_wd: + idx, old_wd = m_wd.group(1), m_wd.group(2) + new_wd = f'{gamepath_drive_letter}{windows_style_single}' + # Use robust formatter + new_wd = PathHandler._format_workingdir_for_mo2(new_wd) + logger.info(f"Updating workingDirectory for entry {idx}: {old_wd} -> {new_wd}") + original_lines[i] = f'{idx}\\workingDirectory = {new_wd}\n' + + # --- Backup and Write New INI --- + backup_path = modlist_ini_path.with_suffix(f".{datetime.now().strftime('%Y%m%d_%H%M%S')}.bak") + try: + shutil.copy2(modlist_ini_path, backup_path) + logger.info(f"Backed up original INI to: {backup_path}") + except Exception as bak_err: + logger.error(f"Failed to backup original INI file: {bak_err}") + return False + try: + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(original_lines) + logger.info(f"Successfully wrote updated paths to {modlist_ini_path}") + return True + except Exception as write_err: + logger.error(f"Failed to write updated INI file {modlist_ini_path}: {write_err}", exc_info=True) + logger.error("Attempting to restore from backup...") + try: + shutil.move(backup_path, modlist_ini_path) + logger.info("Successfully restored original INI from backup.") + except Exception as restore_err: + logger.critical(f"CRITICAL FAILURE: Could not write new INI and failed to restore backup {backup_path}. Manual intervention required at {modlist_ini_path}! Error: {restore_err}") + return False + except Exception as e: + logger.error(f"An unexpected error occurred during INI path update: {e}", exc_info=True) + return False + + @staticmethod + def edit_resolution(modlist_ini, resolution): + """ + Edit resolution settings in ModOrganizer.ini + + Args: + modlist_ini (str): Path to ModOrganizer.ini + resolution (str): Resolution in the format "1920x1080" + + Returns: + bool: True on success, False on failure + """ + try: + logger.info(f"Editing resolution settings to {resolution}...") + + # Parse resolution + width, height = resolution.split('x') + + # Read the current ModOrganizer.ini + with open(modlist_ini, 'r') as f: + content = f.read() + + # Replace width and height settings + content = re.sub(r'^width\s*=\s*\d+$', f'width = {width}', content, flags=re.MULTILINE) + content = re.sub(r'^height\s*=\s*\d+$', f'height = {height}', content, flags=re.MULTILINE) + + # Write the updated content back to the file + with open(modlist_ini, 'w') as f: + f.write(content) + + logger.info("Resolution settings edited successfully") + return True + + except Exception as e: + logger.error(f"Error editing resolution settings: {e}") + return False + + @staticmethod + def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full): + """ + Create dxvk.conf file in the appropriate location + + Args: + modlist_dir (str): Path to the modlist directory + modlist_sdcard (bool): Whether the modlist is on an SD card + 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") + + Returns: + bool: True on success, False on failure + """ + try: + logger.info("Creating dxvk.conf file...") + + # Determine the location for dxvk.conf + dxvk_conf_path = None + + # Check for common stock game directories + 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 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) + ] + + 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 + + # Create dxvk.conf content + dxvk_conf_content = "dxvk.enableGraphicsPipelineLibrary = False\n" + + # Write dxvk.conf to the appropriate location + with open(dxvk_conf_path, 'w') as f: + f.write(dxvk_conf_content) + + logger.info(f"dxvk.conf created successfully at {dxvk_conf_path}") + return True + + except Exception as e: + logger.error(f"Error creating dxvk.conf: {e}") + return False + + @staticmethod + def find_steam_config_vdf() -> Optional[Path]: + """Finds the active Steam config.vdf file.""" + logger.debug("Searching for Steam config.vdf...") + possible_steam_paths = [ + Path.home() / ".steam/steam", + Path.home() / ".local/share/Steam", + Path.home() / ".steam/root" + ] + for steam_path in possible_steam_paths: + potential_path = steam_path / "config/config.vdf" + if potential_path.is_file(): + logger.info(f"Found config.vdf at: {potential_path}") + return potential_path # Return Path object + + logger.warning("Could not locate Steam's config.vdf file in standard locations.") + return None + + @staticmethod + def find_steam_library() -> Optional[Path]: + """Find the primary Steam library common directory containing games.""" + logger.debug("Attempting to find Steam library...") + + # Potential locations for libraryfolders.vdf + libraryfolders_vdf_paths = [ + os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"), + os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"), + # Add other potential standard locations if necessary + ] + + # Simple backup mechanism (optional but good practice) + for path in libraryfolders_vdf_paths: + if os.path.exists(path): + backup_dir = os.path.join(os.path.dirname(path), "backups") + if not os.path.exists(backup_dir): + try: + os.makedirs(backup_dir) + except OSError as e: + logger.warning(f"Could not create backup directory {backup_dir}: {e}") + + # Create timestamped backup if it doesn't exist for today + timestamp = datetime.now().strftime("%Y%m%d") + backup_filename = f"libraryfolders_{timestamp}.vdf.bak" + backup_path = os.path.join(backup_dir, backup_filename) + + if not os.path.exists(backup_path): + try: + import shutil + shutil.copy2(path, backup_path) + logger.debug(f"Created backup of libraryfolders.vdf at {backup_path}") + except Exception as e: + logger.error(f"Failed to create backup of libraryfolders.vdf: {e}") + # Continue anyway, as we're only reading the file + pass + + libraryfolders_vdf_path_obj = None # Will hold the Path object + found_path_str = None + for path_str in libraryfolders_vdf_paths: + if os.path.exists(path_str): + found_path_str = path_str # Keep the string path for logging/opening + libraryfolders_vdf_path_obj = Path(path_str) # Convert to Path object here + logger.debug(f"Found libraryfolders.vdf at: {path_str}") + break + + # Check using the Path object's is_file() method + if not libraryfolders_vdf_path_obj or not libraryfolders_vdf_path_obj.is_file(): + logger.warning("libraryfolders.vdf not found or is not a file. Cannot automatically detect Steam Library.") + return None + + # Parse the VDF file to extract library paths + library_paths = [] + try: + # Open using the original string path is fine, or use the Path object + with open(found_path_str, 'r') as f: # Or use libraryfolders_vdf_path_obj + content = f.read() + + # Use regex to find all path entries + path_matches = re.finditer(r'"path"\s*"([^"]+)"', content) + for match in path_matches: + library_path_str = match.group(1).replace('\\\\', '\\') # Fix potential double escapes + common_path = os.path.join(library_path_str, "steamapps", "common") + if os.path.isdir(common_path): # Verify the common path exists + library_paths.append(Path(common_path)) + logger.debug(f"Found potential common path: {common_path}") + else: + logger.debug(f"Skipping non-existent common path derived from VDF: {common_path}") + + logger.debug(f"Found {len(library_paths)} valid library common paths from VDF.") + + # Return the first valid path found + if library_paths: + logger.info(f"Using Steam library common path: {library_paths[0]}") + return library_paths[0] + + # If no valid paths found in VDF, try the default structure + logger.debug("No valid common paths found in VDF, checking default location...") + default_common_path = Path.home() / ".steam/steam/steamapps/common" + if default_common_path.is_dir(): + logger.info(f"Using default Steam library common path: {default_common_path}") + return default_common_path + + default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common" + if default_common_path_local.is_dir(): + logger.info(f"Using default local Steam library common path: {default_common_path_local}") + return default_common_path_local + + logger.error("No valid Steam library common path found in VDF or default locations.") + return None + + except Exception as e: + logger.error(f"Error parsing libraryfolders.vdf or finding Steam library: {e}", exc_info=True) + return None + + @staticmethod + def find_compat_data(appid: str) -> Optional[Path]: + """Find the compatdata directory for a given AppID.""" + if not appid or not appid.isdigit(): + logger.error(f"Invalid AppID provided for compatdata search: {appid}") + return None + + logger.debug(f"Searching for compatdata directory for AppID: {appid}") + + # Prefer standard Steam locations + possible_bases = [ + Path.home() / ".steam/steam/steamapps/compatdata", + Path.home() / ".local/share/Steam/steamapps/compatdata", + # Add likely SD card mount points if applicable + # Path("/run/media/mmcblk0p1/steamapps/compatdata") + ] + + # Check user's Steam Library path if available (more reliable) + # Assuming PathHandler might store or be passed the library path + # steam_lib_path = self.find_steam_library() # Or get from instance var if stored + # if steam_lib_path and (steam_lib_path / "steamapps/compatdata").is_dir(): + # possible_bases.insert(0, steam_lib_path / "steamapps/compatdata") # Prioritize + + for base_path in possible_bases: + if not base_path.is_dir(): + logger.debug(f"Compatdata base path does not exist or is not a directory: {base_path}") + continue + + potential_path = base_path / appid + if potential_path.is_dir(): + logger.info(f"Found compatdata directory: {potential_path}") + return potential_path # Return Path object + else: + logger.debug(f"Compatdata for {appid} not found in {base_path}") + + # Fallback: Broad search (can be slow, consider if needed) + # try: + # logger.debug(f"Compatdata not found in standard locations, attempting wider search...") + # # This can be very slow and resource-intensive + # # find_output = subprocess.check_output(['find', '/', '-type', 'd', '-name', appid, '-path', '*/compatdata/*', '-print', '-quit', '2>/dev/null'], text=True).strip() + # # if find_output: + # # logger.info(f"Found compatdata via find command: {find_output}") + # # return Path(find_output) + # except Exception as e: + # logger.warning(f"Error during 'find' command for compatdata: {e}") + + logger.warning(f"Compatdata directory for AppID {appid} not found.") + return None + + @staticmethod + def detect_stock_game_path(game_type: str, steam_library: Path) -> Optional[Path]: + """ + Detect the stock game path for a given game type and Steam library + Returns the path if found, None otherwise + """ + try: + # Map of game types to their Steam App IDs + game_app_ids = { + 'skyrim': '489830', # Skyrim Special Edition + 'fallout4': '377160', # Fallout 4 + 'fnv': '22380', # Fallout: New Vegas + 'oblivion': '22330' # The Elder Scrolls IV: Oblivion + } + + if game_type not in game_app_ids: + return None + + app_id = game_app_ids[game_type] + game_path = steam_library / 'steamapps' / 'common' + + # List of possible game directory names + possible_names = { + 'skyrim': ['Skyrim Special Edition', 'Skyrim'], + 'fallout4': ['Fallout 4'], + 'fnv': ['Fallout New Vegas', 'FalloutNV'], + 'oblivion': ['Oblivion'] + } + + if game_type not in possible_names: + return None + + # Check each possible directory name + for name in possible_names[game_type]: + potential_path = game_path / name + if potential_path.exists(): + return potential_path + + return None + + except Exception as e: + logging.error(f"Error detecting stock game path: {e}") + return None + + @staticmethod + def get_steam_library_path(steam_path: str) -> Optional[str]: + """Get the Steam library path from libraryfolders.vdf.""" + try: + libraryfolders_path = os.path.join(steam_path, 'steamapps', 'libraryfolders.vdf') + if not os.path.exists(libraryfolders_path): + return None + + with open(libraryfolders_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Parse the VDF content + libraries = {} + current_library = None + for line in content.split('\n'): + line = line.strip() + if line.startswith('"path"'): + current_library = line.split('"')[3].replace('\\\\', '\\') + elif line.startswith('"apps"') and current_library: + libraries[current_library] = True + + # Return the first library path that exists + for library_path in libraries: + if os.path.exists(library_path): + return library_path + + return None + except Exception as e: + logger.error(f"Error getting Steam library path: {str(e)}") + return None + + @staticmethod + def get_all_steam_library_paths() -> List[Path]: + """Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak).""" + logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...") + vdf_paths = [ + Path.home() / ".steam/steam/config/libraryfolders.vdf", + Path.home() / ".local/share/Steam/config/libraryfolders.vdf", + Path.home() / ".steam/root/config/libraryfolders.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", # Flatpak + ] + library_paths = set() + for vdf_path in vdf_paths: + if vdf_path.is_file(): + logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}") + try: + with open(vdf_path) as f: + for line in f: + m = re.search(r'"path"\s*"([^"]+)"', line) + if m: + lib_path = Path(m.group(1)) + library_paths.add(lib_path) + except Exception as e: + logger.error(f"[DEBUG] Failed to parse {vdf_path}: {e}") + logger.info(f"[DEBUG] All detected Steam libraries: {library_paths}") + return list(library_paths) + + # 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. + + Iterates through userdata directories and returns the path to the + first found shortcuts.vdf file. + + 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 + + @staticmethod + def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]: + """ + Find installation paths for multiple specified games using Steam app IDs. + + Args: + target_appids: Dictionary mapping game names to app IDs + + Returns: + Dictionary mapping game names to their installation paths + """ + # Get all Steam library paths + library_paths = PathHandler.get_all_steam_library_paths() + if not library_paths: + logger.warning("Failed to find any Steam library paths") + return {} + + results = {} + + # For each library path, look for each target game + for library_path in library_paths: + # Check if the common directory exists + common_dir = library_path / "common" + if not common_dir.is_dir(): + logger.debug(f"No 'common' directory in library: {library_path}") + continue + + # Get subdirectories in common dir + try: + game_dirs = [d for d in common_dir.iterdir() if d.is_dir()] + except (PermissionError, OSError) as e: + logger.warning(f"Cannot access directory {common_dir}: {e}") + continue + + # For each app ID, check if we find its directory + for game_name, app_id in target_appids.items(): + if game_name in results: + continue # Already found this game + + # Try to find by appmanifest + appmanifest_path = library_path / f"appmanifest_{app_id}.acf" + if appmanifest_path.is_file(): + # Find the installdir value + try: + with open(appmanifest_path, 'r', encoding='utf-8') as f: + content = f.read() + match = re.search(r'"installdir"\s+"([^"]+)"', content) + if match: + install_dir_name = match.group(1) + install_path = common_dir / install_dir_name + if install_path.is_dir(): + results[game_name] = install_path + logger.info(f"Found {game_name} at {install_path}") + continue + except Exception as e: + logger.warning(f"Error reading appmanifest for {game_name}: {e}") + + return results + + def replace_gamepath(self, modlist_ini_path: Path, new_game_path: Path, modlist_sdcard: bool = False) -> bool: + """ + Updates the gamePath value in ModOrganizer.ini to the specified path. + Strictly matches the bash script: only replaces an existing gamePath line. + If the file or line does not exist, logs error and aborts. + """ + logger.info(f"Replacing gamePath in {modlist_ini_path} with {new_game_path}") + if not modlist_ini_path.is_file(): + logger.error(f"ModOrganizer.ini not found at: {modlist_ini_path}") + return False + 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:" + processed_path = self._strip_sdcard_path_prefix(new_game_path) + windows_style = processed_path.replace('/', '\\') + windows_style_double = windows_style.replace('\\', '\\\\') + new_gamepath_line = f'gamePath=@ByteArray({drive_letter}{windows_style_double})\n' + gamepath_found = False + for i, line in enumerate(lines): + # Make the check case-insensitive and robust to whitespace + if re.match(r'^\s*gamepath\s*=.*$', line, re.IGNORECASE): + lines[i] = new_gamepath_line + gamepath_found = True + break + if not gamepath_found: + logger.error("No gamePath line found in ModOrganizer.ini") + return False + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info(f"Successfully updated gamePath to {new_game_path}") + return True + except Exception as e: + logger.error(f"Error replacing gamePath: {e}", exc_info=True) + return False + + # ===================================================================================== + # CRITICAL: DO NOT CHANGE THIS FUNCTION WITHOUT UPDATING TESTS AND CONSULTING PROJECT LEAD + # This function implements the exact path rewriting logic required for ModOrganizer.ini + # to match the original, robust bash script. Any change here risks breaking modlist + # configuration for users. If you must change this, update all relevant tests and + # consult the Project Lead for Jackify. See also omni-guides.sh for reference logic. + # ===================================================================================== + def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool, steam_libraries: Optional[List[Path]] = None) -> bool: + """ + Update all binary paths and working directories in a ModOrganizer.ini file. + Handles various ModOrganizer.ini formats (single or double backslashes in keys). + When updating gamePath, binary, and workingDirectory, retain the original stock folder (Stock Game, Game Root, etc) if present in the current value. + steam_libraries: Optional[List[Path]] - already-discovered Steam library paths to use for vanilla detection. + + # DO NOT CHANGE THIS LOGIC WITHOUT UPDATING TESTS AND CONSULTING THE PROJECT LEAD + # This is a critical, regression-prone area. See omni-guides.sh for reference. + """ + try: + logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}") + if not modlist_ini_path.is_file(): + logger.error(f"INI file {modlist_ini_path} does not exist") + return False + with open(modlist_ini_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + game_path_updated = False + binary_paths_updated = 0 + working_dirs_updated = 0 + binary_lines = [] + working_dir_lines = [] + for i, line in enumerate(lines): + stripped = line.strip() + binary_match = re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE) + if binary_match: + index = binary_match.group(1) + backslash_style = binary_match.group(2) + binary_lines.append((i, stripped, index, backslash_style)) + wd_match = re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE) + if wd_match: + index = wd_match.group(1) + 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: + steam_libraries = PathHandler.get_all_steam_library_paths() + for i, line, index, backslash_style in binary_lines: + parts = line.split('=', 1) + if len(parts) != 2: + logger.error(f"Malformed binary line: {line}") + continue + key_part, value_part = parts + exe_name = os.path.basename(value_part) + drive_prefix = "D:" if modlist_sdcard else "Z:" + rel_path = None + # --- BEGIN: FULL PARITY LOGIC --- + if 'steamapps' in value_part: + idx = value_part.index('steamapps') + 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 + break + if not correct_steam_lib and steam_libraries: + correct_steam_lib = steam_libraries[0].parent + if correct_steam_lib: + new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/') + else: + logger.error("Could not determine correct Steam library for vanilla game path.") + continue + else: + found_stock = None + for folder in STOCK_GAME_FOLDERS: + folder_pattern = f"/{folder.replace(' ', '')}".lower() + value_part_lower = value_part.replace(' ', '').lower() + if folder_pattern in value_part_lower: + idx = value_part_lower.index(folder_pattern) + rel_path = value_part[idx:].lstrip('/') + found_stock = folder + break + if not rel_path: + mods_pattern = "/mods/" + if mods_pattern in value_part: + idx = value_part.index(mods_pattern) + rel_path = value_part[idx:].lstrip('/') + else: + rel_path = exe_name + new_binary_path = f"{drive_prefix}/{modlist_dir_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}" + logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}") + lines[i] = new_binary_line + "\n" + binary_paths_updated += 1 + binary_paths_by_index[index] = formatted_binary_path + for j, wd_line, index, backslash_style in working_dir_lines: + if index in binary_paths_by_index: + binary_path = binary_paths_by_index[index] + wd_path = os.path.dirname(binary_path) + drive_prefix = "D:" if modlist_sdcard else "Z:" + if wd_path.startswith("D:") or wd_path.startswith("Z:"): + wd_path = wd_path[2:] + 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}" + logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}") + lines[j] = new_wd_line + "\n" + working_dirs_updated += 1 + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info(f"edit_binary_working_paths completed: Game path updated: {game_path_updated}, Binary paths updated: {binary_paths_updated}, Working directories updated: {working_dirs_updated}") + return True + except Exception as e: + logger.error(f"Error updating binary paths in {modlist_ini_path}: {str(e)}") + return False + + def _format_path_for_mo2(self, path: str) -> str: + """Format a path for MO2's ModOrganizer.ini file (working directories).""" + # Replace forward slashes with double backslashes + formatted = path.replace('/', '\\') + # Ensure we have a Windows drive letter format + if not re.match(r'^[A-Za-z]:', formatted): + formatted = 'D:' + formatted + # Double the backslashes for the INI file format + formatted = formatted.replace('\\', '\\\\') + return formatted + + def _format_binary_path_for_mo2(self, path_str): + """Format a binary path for MO2 config file. + + Binary paths need forward slashes (/) in the path portion. + """ + # Replace backslashes with forward slashes + return path_str.replace('\\', '/') + + def _format_working_dir_for_mo2(self, path_str): + """ + Format a working directory path for MO2 config file. + Ensures double backslashes throughout, as required by ModOrganizer.ini. + """ + import re + path = path_str.replace('/', '\\') + path = path.replace('\\', '\\\\') # Double all backslashes + # Ensure only one double backslash after drive letter + path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path) + return path + + @staticmethod + def find_vanilla_game_paths(game_names=None) -> Dict[str, Path]: + """ + For each known game, iterate all Steam libraries and look for the canonical game directory name in steamapps/common. + Returns a dict of found games and their paths. + Args: + game_names: Optional list of game names to check. If None, uses default supported games. + Returns: + Dict[str, Path]: Mapping of game name to found install Path. + """ + # Canonical game directory names (allow list for Fallout 3) + GAME_DIR_NAMES = { + "Skyrim Special Edition": ["Skyrim Special Edition"], + "Fallout 4": ["Fallout 4"], + "Fallout New Vegas": ["Fallout New Vegas"], + "Oblivion": ["Oblivion"], + "Fallout 3": ["Fallout 3", "Fallout 3 goty"] + } + if game_names is None: + game_names = list(GAME_DIR_NAMES.keys()) + all_steam_libraries = PathHandler.get_all_steam_library_paths() + logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") + found_games = {} + for game in game_names: + possible_names = GAME_DIR_NAMES.get(game, [game]) + for lib in all_steam_libraries: + for name in possible_names: + candidate = lib / "steamapps" / "common" / name + logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") + if candidate.is_dir(): + found_games[game] = candidate + logger.info(f"Found vanilla game directory for {game}: {candidate}") + break # Stop after first found location + if game in found_games: + break + return found_games + + def _detect_stock_game_path(self): + """Detects common 'Stock Game' or 'Game Root' directories within the modlist path.""" + self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...") + if not self.modlist_dir: + self.logger.error("Modlist directory not set, cannot detect stock game path.") + return False + + modlist_path = Path(self.modlist_dir) + # Always prefer 'Stock Game' if it exists, then fallback to others + preferred_order = [ + "Stock Game", + "STOCK GAME", + "Skyrim Stock", + "Stock Game Folder", + "Stock Folder", + Path("root/Skyrim Special Edition"), + "Game Root" # 'Game Root' is now last + ] + + found_path = None + for name in preferred_order: + potential_path = modlist_path / name + if potential_path.is_dir(): + found_path = str(potential_path) + self.logger.info(f"Found potential stock game directory: {found_path}") + break # Found the first match + if found_path: + self.stock_game_path = found_path + return True + else: + self.stock_game_path = None + self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.") + return True + + # --- Add robust path formatters for INI fields --- + @staticmethod + def _format_gamepath_for_mo2(path: str) -> str: + import re + path = path.replace('/', '\\') + path = re.sub(r'\\+', r'\\', path) # Collapse multiple backslashes + # Ensure only one double backslash after drive letter + path = re.sub(r'^([A-Z]:)\\+', r'\1\\', path) + return path + + @staticmethod + def _format_binary_for_mo2(path: str) -> str: + import re + path = path.replace('\\', '/') + # Collapse multiple forward slashes after drive letter + path = re.sub(r'^([A-Z]:)//+', r'\1/', path) + return path + + @staticmethod + def _format_workingdir_for_mo2(path: str) -> str: + import re + path = path.replace('/', '\\') + path = path.replace('\\', '\\\\') # Double all backslashes + # Ensure only one double backslash after drive letter + path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path) + return path + +# --- End of PathHandler --- \ No newline at end of file diff --git a/jackify/shared/timing.py b/jackify/shared/timing.py new file mode 100644 index 0000000..661a783 --- /dev/null +++ b/jackify/shared/timing.py @@ -0,0 +1,84 @@ +""" +Simple shared timing for consistent progress timestamps across all Jackify services. +""" +import time +import re + +# Global state for shared timing +_start_time = None +_base_offset = 0 + +def initialize_from_console_output(console_text: str = None): + """Initialize timing, optionally continuing from jackify-engine output""" + global _start_time, _base_offset + + if _start_time is not None: + return # Already initialized + + if console_text: + # Parse last timestamp from jackify-engine + timestamp_pattern = r'\[(\d{2}):(\d{2}):(\d{2})\]' + matches = list(re.finditer(timestamp_pattern, console_text)) + + if matches: + last_match = matches[-1] + hours = int(last_match.group(1)) + minutes = int(last_match.group(2)) + seconds = int(last_match.group(3)) + _base_offset = hours * 3600 + minutes * 60 + seconds + 1 + + _start_time = time.time() + +def continue_from_timestamp(timestamp_str: str): + """Continue timing from a specific timestamp string like '[00:00:31]'""" + global _start_time, _base_offset + + # Parse timestamp like [00:00:31] + timestamp_pattern = r'\[(\d{2}):(\d{2}):(\d{2})\]' + match = re.match(timestamp_pattern, timestamp_str) + + if match: + hours = int(match.group(1)) + minutes = int(match.group(2)) + seconds = int(match.group(3)) + _base_offset = hours * 3600 + minutes * 60 + seconds + 1 + _start_time = time.time() + else: + # Fallback to normal initialization + initialize_from_console_output() + +def start_new_phase(): + """Start a new phase with timing reset to [00:00:00]""" + global _start_time, _base_offset + _start_time = time.time() + _base_offset = 0 + +def set_base_offset_from_installation_end(): + """Set base offset to continue from where Installation phase typically ends""" + global _start_time, _base_offset + + # Installation phase typically ends around 1-2 minutes, so start from 1:30 + _base_offset = 90 # 1 minute 30 seconds + _start_time = time.time() + +def get_timestamp(): + """Get current timestamp in [HH:MM:SS] format""" + global _start_time, _base_offset + + if _start_time is None: + initialize_from_console_output() + + elapsed = int(time.time() - _start_time) + total_seconds = _base_offset + elapsed + + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + + return f"[{hours:02d}:{minutes:02d}:{seconds:02d}]" + +def reset(): + """Reset timing (for testing)""" + global _start_time, _base_offset + _start_time = None + _base_offset = 0 \ No newline at end of file diff --git a/jackify/shared/ui_utils.py b/jackify/shared/ui_utils.py new file mode 100644 index 0000000..e7bdd70 --- /dev/null +++ b/jackify/shared/ui_utils.py @@ -0,0 +1,23 @@ +""" +UI Utilities for Jackify +Shared UI components and utilities used across frontend interfaces +""" + +def print_jackify_banner(): + """Print the Jackify application banner""" + print(""" +╔════════════════════════════════════════════════════════════════════════╗ +║ Jackify CLI (pre-alpha) ║ +║ ║ +║ A tool for installing and configuring modlists ║ +║ & associated utilities on Linux ║ +╚════════════════════════════════════════════════════════════════════════╝ +""") + +def print_section_header(title): + """Print a section header with formatting""" + print(f"\n{'='*30}\n{title}\n{'='*30}\n") + +def print_subsection_header(title): + """Print a subsection header with formatting""" + print(f"[ {title} ]\n") \ No newline at end of file diff --git a/jackify/shared/validation.py b/jackify/shared/validation.py new file mode 100644 index 0000000..193d489 --- /dev/null +++ b/jackify/shared/validation.py @@ -0,0 +1,283 @@ +""" +ValidationHandler module for managing validation operations. +This module handles input validation, path validation, and configuration validation. +""" + +import os +import logging +import re +import shutil +import vdf +from pathlib import Path +from typing import Optional, Dict, List, Tuple, Any + +class ValidationHandler: + def __init__(self): + self.logger = logging.getLogger(__name__) + + def validate_path(self, path: Path, must_exist: bool = True) -> Tuple[bool, str]: + """Validate a path.""" + try: + if not isinstance(path, Path): + return False, "Path must be a Path object" + + if must_exist and not path.exists(): + return False, f"Path does not exist: {path}" + + if not os.access(path, os.R_OK | os.W_OK): + return False, f"Path is not accessible: {path}" + + return True, "Path is valid" + except Exception as e: + self.logger.error(f"Failed to validate path {path}: {e}") + return False, str(e) + + def validate_input(self, value: Any, rules: Dict) -> Tuple[bool, str]: + """Validate user input against rules.""" + try: + # Check required + if rules.get('required', False) and not value: + return False, "Value is required" + + # Check type + if 'type' in rules and not isinstance(value, rules['type']): + return False, f"Value must be of type {rules['type'].__name__}" + + # Check min/max length for strings + if isinstance(value, str): + if 'min_length' in rules and len(value) < rules['min_length']: + return False, f"Value must be at least {rules['min_length']} characters" + if 'max_length' in rules and len(value) > rules['max_length']: + return False, f"Value must be at most {rules['max_length']} characters" + + # Check min/max value for numbers + if isinstance(value, (int, float)): + if 'min_value' in rules and value < rules['min_value']: + return False, f"Value must be at least {rules['min_value']}" + if 'max_value' in rules and value > rules['max_value']: + return False, f"Value must be at most {rules['max_value']}" + + # Check pattern for strings + if isinstance(value, str) and 'pattern' in rules: + if not re.match(rules['pattern'], value): + return False, f"Value must match pattern: {rules['pattern']}" + + # Check custom validation function + if 'validate' in rules and callable(rules['validate']): + result = rules['validate'](value) + if isinstance(result, tuple): + return result + elif not result: + return False, "Custom validation failed" + + return True, "Input is valid" + except Exception as e: + self.logger.error(f"Failed to validate input: {e}") + return False, str(e) + + def validate_config(self, config: Dict, schema: Dict) -> Tuple[bool, List[str]]: + """Validate configuration against a schema.""" + try: + errors = [] + + # Check required fields + for field, rules in schema.items(): + if rules.get('required', False) and field not in config: + errors.append(f"Missing required field: {field}") + + # Check field types and values + for field, value in config.items(): + if field not in schema: + errors.append(f"Unknown field: {field}") + continue + + rules = schema[field] + if 'type' in rules and not isinstance(value, rules['type']): + errors.append(f"Invalid type for {field}: expected {rules['type'].__name__}") + + if isinstance(value, str): + if 'min_length' in rules and len(value) < rules['min_length']: + errors.append(f"{field} must be at least {rules['min_length']} characters") + if 'max_length' in rules and len(value) > rules['max_length']: + errors.append(f"{field} must be at most {rules['max_length']} characters") + if 'pattern' in rules and not re.match(rules['pattern'], value): + errors.append(f"{field} must match pattern: {rules['pattern']}") + + if isinstance(value, (int, float)): + if 'min_value' in rules and value < rules['min_value']: + errors.append(f"{field} must be at least {rules['min_value']}") + if 'max_value' in rules and value > rules['max_value']: + errors.append(f"{field} must be at most {rules['max_value']}") + + if 'validate' in rules and callable(rules['validate']): + result = rules['validate'](value) + if isinstance(result, tuple): + if not result[0]: + errors.append(f"{field}: {result[1]}") + elif not result: + errors.append(f"Custom validation failed for {field}") + + return len(errors) == 0, errors + except Exception as e: + self.logger.error(f"Failed to validate config: {e}") + return False, [str(e)] + + def validate_dependencies(self, dependencies: List[str]) -> Tuple[bool, List[str]]: + """Validate system dependencies.""" + try: + missing = [] + for dep in dependencies: + if not shutil.which(dep): + missing.append(dep) + return len(missing) == 0, missing + except Exception as e: + self.logger.error(f"Failed to validate dependencies: {e}") + return False, [str(e)] + + def validate_game_installation(self, game_type: str, path: Path) -> Tuple[bool, str]: + """Validate a game installation.""" + try: + # Check if path exists + if not path.exists(): + return False, f"Game path does not exist: {path}" + + # Check if path is accessible + if not os.access(path, os.R_OK | os.W_OK): + return False, f"Game path is not accessible: {path}" + + # Check for game-specific files + if game_type == 'skyrim': + if not (path / 'SkyrimSE.exe').exists(): + return False, "SkyrimSE.exe not found" + elif game_type == 'fallout4': + if not (path / 'Fallout4.exe').exists(): + return False, "Fallout4.exe not found" + elif game_type == 'falloutnv': + if not (path / 'FalloutNV.exe').exists(): + return False, "FalloutNV.exe not found" + elif game_type == 'oblivion': + if not (path / 'Oblivion.exe').exists(): + return False, "Oblivion.exe not found" + else: + return False, f"Unknown game type: {game_type}" + + return True, "Game installation is valid" + except Exception as e: + self.logger.error(f"Failed to validate game installation: {e}") + return False, str(e) + + def validate_modlist(self, modlist_path: Path) -> Tuple[bool, List[str]]: + """Validate a modlist installation.""" + try: + errors = [] + + # Check if path exists + if not modlist_path.exists(): + errors.append(f"Modlist path does not exist: {modlist_path}") + return False, errors + + # Check if path is accessible + if not os.access(modlist_path, os.R_OK | os.W_OK): + errors.append(f"Modlist path is not accessible: {modlist_path}") + return False, errors + + # Check for ModOrganizer.ini + if not (modlist_path / 'ModOrganizer.ini').exists(): + errors.append("ModOrganizer.ini not found") + + # Check for mods directory + if not (modlist_path / 'mods').exists(): + errors.append("mods directory not found") + + # Check for profiles directory + if not (modlist_path / 'profiles').exists(): + errors.append("profiles directory not found") + + return len(errors) == 0, errors + except Exception as e: + self.logger.error(f"Failed to validate modlist: {e}") + return False, [str(e)] + + def validate_wine_prefix(self, app_id: str) -> Tuple[bool, str]: + """Validate a Wine prefix.""" + try: + # Check if prefix exists + prefix_path = Path.home() / '.steam' / 'steam' / 'steamapps' / 'compatdata' / app_id / 'pfx' + if not prefix_path.exists(): + return False, f"Wine prefix does not exist: {prefix_path}" + + # Check if prefix is accessible + if not os.access(prefix_path, os.R_OK | os.W_OK): + return False, f"Wine prefix is not accessible: {prefix_path}" + + # Check for system.reg + if not (prefix_path / 'system.reg').exists(): + return False, "system.reg not found" + + return True, "Wine prefix is valid" + except Exception as e: + self.logger.error(f"Failed to validate Wine prefix: {e}") + return False, str(e) + + 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' + 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) + + # Check if shortcut exists + for shortcut in shortcuts_data.get('shortcuts', {}).values(): + if str(shortcut.get('appid')) == app_id: + return True, "Steam shortcut is valid" + + return False, f"Steam shortcut not found: {app_id}" + except Exception as e: + self.logger.error(f"Failed to validate Steam shortcut: {e}") + return False, str(e) + + def validate_resolution(self, resolution: str) -> Tuple[bool, str]: + """Validate a resolution string.""" + try: + # Check format + if not re.match(r'^\d+x\d+$', resolution): + return False, "Resolution must be in format WIDTHxHEIGHT" + + # Parse dimensions + width, height = map(int, resolution.split('x')) + + # Check minimum dimensions + if width < 640 or height < 480: + return False, "Resolution must be at least 640x480" + + # Check maximum dimensions + if width > 7680 or height > 4320: + return False, "Resolution must be at most 7680x4320" + + return True, "Resolution is valid" + except Exception as e: + self.logger.error(f"Failed to validate resolution: {e}") + return False, str(e) + + def validate_permissions(self, path: Path, required_permissions: int) -> Tuple[bool, str]: + """Validate file or directory permissions.""" + try: + # Get current permissions + current_permissions = os.stat(path).st_mode & 0o777 + + # Check if current permissions include required permissions + if current_permissions & required_permissions != required_permissions: + return False, f"Missing required permissions: {required_permissions:o}" + + return True, "Permissions are valid" + except Exception as e: + self.logger.error(f"Failed to validate permissions: {e}") + return False, str(e) \ No newline at end of file diff --git a/requirements-packaging.txt b/requirements-packaging.txt new file mode 100644 index 0000000..b6bad72 --- /dev/null +++ b/requirements-packaging.txt @@ -0,0 +1,3 @@ +# Requirements for packaging and distribution +PyInstaller>=6.0.0 +pyinstaller-hooks-contrib>=2023.11 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6afa70d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +# Core GUI framework +PySide6>=6.5.0 + +# Process monitoring and system information +psutil>=5.8.0 + +# File downloads and HTTP requests +requests>=2.25.0 +tqdm>=4.65.0 + +# Configuration file handling +PyYAML>=6.0 + +# Steam VDF file parsing (latest available version) +vdf>=3.4 + +# Package version handling +packaging>=21.0 + +# Archive extraction (if needed) +# zipfile and tarfile are in stdlib + +# File system utilities (stdlib: os, pathlib, shutil, tempfile) +# Process management (stdlib: subprocess, threading) +# JSON handling (stdlib: json) +# Logging (stdlib: logging) +# Regular expressions (stdlib: re) +# Time handling (stdlib: time, datetime) +# Collections (stdlib: collections, itertools, functools) +# Configuration files (stdlib: configparser) +# Hashing (stdlib: hashlib) \ No newline at end of file diff --git a/testing/debug_install_dir.py b/testing/debug_install_dir.py new file mode 100644 index 0000000..897b4a5 --- /dev/null +++ b/testing/debug_install_dir.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 + +import sys +import os +from pathlib import Path + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +def simulate_install_dir_processing(): + """Simulate the exact path processing that happens in the Install a Modlist workflow.""" + + # Simulate the context that would be passed to the engine + context = { + 'install_dir': Path("/home/deck/Games/Fallout/WOD"), + 'download_dir': Path("/home/deck/Games/Fallout/Downloads") + } + + print("=== Simulating Install a Modlist workflow path processing ===") + print(f"Original context['install_dir']: {context['install_dir']}") + print(f"Original context['install_dir'] type: {type(context['install_dir'])}") + + # Simulate the path processing from modlist_operations.py lines 615-625 + install_dir_context = context['install_dir'] + print(f"install_dir_context: {install_dir_context}") + print(f"install_dir_context type: {type(install_dir_context)}") + + if isinstance(install_dir_context, tuple): + actual_install_path = Path(install_dir_context[0]) + if install_dir_context[1]: # Second element is True if creation was intended + print(f"Creating install directory as it was marked for creation: {actual_install_path}") + actual_install_path.mkdir(parents=True, exist_ok=True) + else: # Should be a Path object or string already + actual_install_path = Path(install_dir_context) + + print(f"actual_install_path: {actual_install_path}") + print(f"actual_install_path type: {type(actual_install_path)}") + + install_dir_str = str(actual_install_path) + print(f"install_dir_str: {install_dir_str}") + print(f"install_dir_str type: {type(install_dir_str)}") + + # Now simulate what gets passed to the configuration context + config_context = { + 'name': 'WOD', + 'appid': '12345', + 'path': install_dir_str, # This is the key line! + 'mo2_exe_path': '/path/to/mo2.exe', + 'resolution': '1920x1080', + 'skip_confirmation': True, + 'manual_steps_completed': False + } + + print(f"\nconfig_context['path']: {config_context['path']}") + print(f"config_context['path'] type: {type(config_context['path'])}") + + # Check if there's any corruption + if 'D' in config_context['path'] and '/WOD/D/' in config_context['path']: + print("🚨 FOUND THE BUG! The path contains the extra 'D' segment!") + print(f"Expected: /home/deck/Games/Fallout/WOD") + print(f"Actual: {config_context['path']}") + else: + print("✅ Path looks correct - no extra 'D' segment found") + + # Now simulate the Configure New Modlist workflow for comparison + print("\n=== Simulating Configure New Modlist workflow ===") + gui_context = { + 'modlist_name': 'WOD', + 'install_dir': '/home/deck/Games/Fallout/WOD', # Direct from GUI + 'mo2_exe_path': '/path/to/mo2.exe', + 'resolution': '1920x1080' + } + + gui_config_context = { + 'name': gui_context.get('modlist_name', ''), + 'path': gui_context.get('install_dir', ''), # Direct from context + 'mo2_exe_path': gui_context.get('mo2_exe_path', ''), + 'modlist_value': gui_context.get('modlist_value'), + 'modlist_source': gui_context.get('modlist_source'), + 'resolution': gui_context.get('resolution'), + 'skip_confirmation': True, + 'manual_steps_completed': False + } + + print(f"gui_config_context['path']: {gui_config_context['path']}") + print(f"gui_config_context['path'] type: {type(gui_config_context['path'])}") + + # Compare the two paths + print(f"\n=== Comparison ===") + print(f"Install a Modlist path: {config_context['path']}") + print(f"Configure New Modlist path: {gui_config_context['path']}") + print(f"Paths are {'DIFFERENT' if config_context['path'] != gui_config_context['path'] else 'IDENTICAL'}") + +if __name__ == "__main__": + simulate_install_dir_processing() + + + + diff --git a/testing/debug_path_issue.py b/testing/debug_path_issue.py new file mode 100644 index 0000000..ca262a4 --- /dev/null +++ b/testing/debug_path_issue.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +import sys +import os +from pathlib import Path + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +def test_path_construction(): + """Test the exact path construction that's happening in the bug.""" + + # Simulate the path that should be passed + install_dir_str = "/home/deck/Games/Fallout/WOD" + modlist_dir_path = Path(install_dir_str) + + print(f"Original install_dir_str: {install_dir_str}") + print(f"modlist_dir_path: {modlist_dir_path}") + print(f"modlist_dir_path type: {type(modlist_dir_path)}") + + # Simulate the path construction in edit_binary_working_paths + drive_prefix = "Z:" + rel_path = "Stock Game/f4se_loader.exe" + + new_binary_path = f"{drive_prefix}/{modlist_dir_path}/{rel_path}".replace('\\', '/').replace('//', '/') + + print(f"drive_prefix: {drive_prefix}") + print(f"rel_path: {rel_path}") + print(f"new_binary_path: {new_binary_path}") + + # Check if there's any string manipulation happening + print(f"modlist_dir_path string: '{str(modlist_dir_path)}'") + print(f"modlist_dir_path parts: {list(modlist_dir_path.parts)}") + + # Test with the exact path from the ModOrganizer.ini + print("\n--- Testing with actual ModOrganizer.ini path ---") + actual_path = "Z:/home/deck/Games/Fallout/WOD/D/Stock Game/f4se_loader.exe" + print(f"Actual path from ModOrganizer.ini: {actual_path}") + + # Try to reconstruct this path + parts = actual_path.split('/') + print(f"Path parts: {parts}") + + # The issue is that there's a "D" segment that shouldn't be there + if "D" in parts: + d_index = parts.index("D") + print(f"Found 'D' at index {d_index}: {parts[d_index]}") + print(f"Parts before 'D': {parts[:d_index]}") + print(f"Parts after 'D': {parts[d_index+1:]}") + + # Reconstruct without the D + correct_parts = parts[:d_index] + parts[d_index+1:] + correct_path = '/'.join(correct_parts) + print(f"Correct path should be: {correct_path}") + +if __name__ == "__main__": + test_path_construction() + + + +