44 Commits

Author SHA1 Message Date
Omni
411addeea2 Sync from development - prepare for v0.4.0 2026-02-25 21:16:02 +00:00
Omni
805718222a Sync from development - prepare for v0.4.0 2026-02-25 20:54:28 +00:00
Omni-guides
2eb54b9a36 Revise README for clarity and additional resources
Updated links and clarified instructions for modlist installation options.
2026-02-24 14:17:12 +00:00
Omni-guides
9cc5245db7 Revise README for clarity and updated features
Updated project description and features in README.
2026-02-24 13:41:18 +00:00
Omni-guides
69738e8e9e Add files via upload 2026-02-24 12:17:07 +00:00
Omni-guides
fdee639734 Delete assets/images/wiki/ModlistGuides/ConfigureNew/new.txt 2026-02-24 11:58:25 +00:00
Omni-guides
b123f6f509 Delete assets/images/wiki/ModlistGuides/ConfigureExisting/new.txt 2026-02-24 11:58:14 +00:00
Omni-guides
368e1bf5ef Add files via upload 2026-02-24 11:57:55 +00:00
Omni-guides
ebf61f67db Add files via upload 2026-02-24 11:57:27 +00:00
Omni-guides
15b90d823c Create new.txt 2026-02-24 11:57:04 +00:00
Omni-guides
309303f721 Create new.txt 2026-02-24 11:56:50 +00:00
Omni-guides
59e03eb38e Add files via upload 2026-02-22 21:30:42 +00:00
Omni-guides
f278b9a8b5 Add files via upload 2026-02-22 21:30:06 +00:00
Omni-guides
9a10812796 Delete assets/images/wiki/ModlistGuides/Jackify/new.txt 2026-02-22 21:29:26 +00:00
Omni-guides
61cfda5dac Add files via upload 2026-02-22 21:29:04 +00:00
Omni-guides
9560c1b72a Delete assets/images/wiki/ModlistGuides/AdditionalTools/new.txt 2026-02-22 21:26:29 +00:00
Omni-guides
5be65c25ac Add files via upload 2026-02-22 21:26:02 +00:00
Omni-guides
053aab04a9 Create new.txt 2026-02-22 21:19:40 +00:00
Omni-guides
f8cdd26d64 Create new.txt 2026-02-22 21:19:20 +00:00
Omni-guides
bc6c0f2e1f Create new.txt 2026-02-22 21:19:02 +00:00
Omni
12294d3186 Sync from development - prepare for v0.3.0 2026-02-07 18:26:54 +00:00
Omni
b55e1cf768 Sync from development - prepare for v0.2.2.2 2026-01-28 22:19:40 +00:00
Omni
8e49602714 Sync from development - prepare for v0.2.2.2 2026-01-28 22:16:55 +00:00
Omni
98a9a4c7c6 Sync from development - prepare for v0.2.2.2 2026-01-28 22:13:51 +00:00
Omni
286d51e6a1 Sync from development - prepare for v0.2.2.1 2026-01-24 22:02:29 +00:00
Omni
53af9f26a2 Sync from development - prepare for v0.2.2 2026-01-21 21:59:42 +00:00
Omni
9000b1e080 Sync from development - prepare for v0.2.1.1 2026-01-15 18:07:49 +00:00
Omni
02f3d71a82 Sync from development - prepare for v0.2.1.1 2026-01-15 18:06:02 +00:00
Omni
29e1800074 Sync from development - prepare for v0.2.1 2026-01-12 22:15:19 +00:00
Omni
9b5310c2f9 Sync from development - prepare for v0.2.0.10 2026-01-04 22:43:32 +00:00
Omni
0d84d2f2fe Sync from development - prepare for v0.2.0.9 2025-12-31 20:56:47 +00:00
Omni
2511c9334c Sync from development - prepare for v0.2.0.8 2025-12-29 19:55:38 +00:00
Omni
5869a896a8 Sync from development - prepare for v0.2.0.7 2025-12-28 22:17:44 +00:00
Omni
99fb369d5e Sync from development - prepare for v0.2.0.6 2025-12-28 18:52:07 +00:00
Omni
a813236e51 Sync from development - prepare for v0.2.0.5 2025-12-24 21:53:12 +00:00
Omni
a7ed4b2a1e Sync from development - prepare for v0.2.0.4 2025-12-23 21:49:18 +00:00
Omni
523681a254 Sync from development - prepare for v0.2.0.3 2025-12-21 21:11:04 +00:00
Omni
abfca5268f Sync from development - prepare for v0.2.0.3 2025-12-21 21:09:38 +00:00
Omni
4de5c7f55d Sync from development - prepare for v0.2.0.2 2025-12-19 22:57:22 +00:00
Omni
9c52c0434b Sync from development - prepare for v0.2.0.1 2025-12-19 19:42:31 +00:00
Omni
e3dc62fdac Sync from development - prepare for v0.2.0 2025-12-06 20:54:48 +00:00
Omni
ce969eba1b Sync from development - prepare for v0.2.0 2025-12-06 20:09:55 +00:00
Omni
fe14e4ecfb Sync from development - prepare for v0.1.7.1 2025-11-11 20:04:32 +00:00
Omni
9680814bbb Sync from development - prepare for v0.1.7 2025-11-04 12:54:15 +00:00
489 changed files with 43839 additions and 23764 deletions

View File

@@ -1,5 +1,442 @@
# Jackify Changelog
## v0.4.0 - Error Handling Rewrite
**Release Date:** 2026-02-25
### New Features
- Structured error handling across GUI and CLI with typed `JackifyError` dialogs (clear message, suggested action, numbered recovery steps, optional technical detail).
- Structured engine error receiver: stderr JSON errors are parsed and mapped to user-facing error types, with exit-code fallback.
- Nexus account tier indicator in Settings OAuth (`[Premium]` / `[Free]`) with cached status checks.
- Modlist metadata support via `.jackify_meta.json`, written after install and used by configure workflows.
- TTW eligibility workflow expanded:
- Configure New / Configure Existing can trigger TTW workflow when eligible.
- CLI `configure-modlist` now prompts TTW when eligible.
- FO3 support in configure workflows, including prefix/registry handling.
- Standalone MO2 setup in Additional Tasks (GUI and CLI).
### Bug Fixes
- Proton auto-detection reliability improved, including GE-Proton ranking and fallback behavior.
- Added detection support for system-packaged Proton layouts (Issue #162).
- Download stall false positives reduced by checking byte advancement instead of speed readout alone.
- Flatpak Steam access handling improved with install-directory override support.
- TTW installer output directory is pre-populated to the modlist location.
- Unknown game fallback behavior improved so Wine component installation can continue where appropriate.
### Improvements
- GUI debug log naming standardised to `jackify-debug.log`.
- Error reporting/logging flow cleaned up to improve user facing info and hopefully ease support.
- "Lazy" GUI screen initialization (main menu first, other screens on demand).
- Proton handling improved with Valve Proton fallback when GE-Proton is unavailable.
- FNV/FO3/Enderal registry injection now attempts canonical `C:\Program Files (x86)\Steam\steamapps\common\<Game>` paths via in-prefix symlink, with fallback to real `Z:/D:` paths if symlink creation fails. Looking forward to feedback on this one if anyone still has their FNV launcher only show "Install" instead of "Play".
### Engine Updates
- jackify-engine updated to `0.4.8`.
- Archive download progress improvements (remaining size + ETA).
- Download speed reporting reliability improvements on Linux.
- ZIP extraction fixes for Cyrillic filenames.
---
## v0.3.0 - Codebase Refactoring
**Release Date:** 2026-02-06
### Technical Improvements
- **Code Architecture**: Refactored 13 large files (1000-5000 lines each) into 50+ focused modules using mixin pattern. All main files now under 600 lines.
### Bug Fixes
- **Configure New Modlist GUI**: Fixed window not shrinking when Show Details unchecked
- **CLI Wabbajack Installer**: Added missing installation command to CLI menu
- **Wabbajack Installer**: Fixed installation to non-primary disk
### Improvements
- **Wabbajack Install - Honour Install Proton**: Wabbajack installer now uses the user's selected Install Proton from Settings (same as modlist install/configure). Previously hardcoded to Proton Experimental. Fallback to Proton Experimental when no selection or path invalid.
- **STEAM_COMPAT_MOUNTS (Issue #155)**: Launch options now include mountpoints for both the modlist install path and the download path when known, so MO2 can access game and downloads on different drives. Uses new mountpoint helper and passes install_dir/download_dir through the Install a Modlist workflow.
- **MO2 download_directory (Issue #154)**: When configuring after Install a Modlist, Jackify now sets `download_directory` in ModOrganizer.ini to the correct Wine path (Z: or D: on SD card) so MO2 finds the download folder. Configure New and Configure Existing continue to leave or blank the key as before.
- **Winetricks / Protontricks**: For Flatpak Steam, use protontricks only. Winetricks alone struggles with the flatpak sandbox.
- **Wine Component Animation**: Added pulser animation for individual wine component installation progress in Configure Existing and Install Modlist workflows
- **Wabbajack Installer Log Rotation**: Added log rotation for Wabbajack installer workflow logs
---
## v0.2.2.2 - ModOrganizer.ini Path Fixes for SD Card Installations
**Release Date:** 2026-01-28
### Bug Fixes
- **ModOrganizer.ini Path Mangling**: Fixed incorrect drive letter assignment when modlist is on SD card but vanilla game is on internal storage. Now uses gamePath drive letter as source of truth for vanilla game paths.
- **Proton Config Name Mismatch (Issues #150, #151)**: Fixed incorrect Proton names written to Steam config.vdf CompatToolMapping. Naive string conversion produced wrong names (e.g., `proton_9.0_(beta)` instead of `proton_9`). Now resolves correct internal names from `compatibilitytool.vdf` (third-party) or App ID mapping (Valve Proton). CachyOS and other community Proton builds in `compatibilitytools.d/` are now detected and selectable.
- **Removed Lorerim/Lost Legacy Proton Override**: No longer forces Proton 9 for specific modlists. ENB compatibility warnings are handled by the success dialog instead.
### Engine Updates
- **jackify-engine 0.4.7**: Fixed incorrect quoting/escaping of MO2 `customExecutables` by writing clean, unquoted Proton `Z:\...` paths in `ModOrganizer.ini`. This eliminates engine-side quote corruption that previously triggered SD card path mangling issues.
### Improvements
- **Improved Wine Component install debug log output**: Will now print the full command being used for winetricks and protontricks when debug mode is enabled, making it easier to reproduce issues manually.
---
## v0.2.2.1 - TTW Installer Pinning and Configure New Modlist CLI Fix
**Release Date:** 2026-01-24
### Bug Fixes
- **Configure New Modlist CLI**: Fixed manual Proton setup prompts appearing in CLI. Now uses automated prefix workflow like the install command.
- **TTW_Linux_Installer Version Pinning**: Pinned to v0.0.7. Will re-introduce latest version following more testing.
---
## v0.2.2 - VNV Automation and First-Launch Improvements
**Release Date:** 2026-01-21
### Major Features
- **Viva New Vegas Post-Install Automation (experimental)**: Full automated workflow for the Viva New Vegas modlist. Handles root files copying, 4GB patcher, and BSA decompression as per the VNV install guide. This is an initial pass at automating this, so considered experimental.
- **Game Directory Pre-Creation**: Automatically creates My Documents/My Games and AppData/Local directories for some. Prevents some first-launch failures where games can't initialize under Proton. Supports Skyrim SE, FNV, FO4, Oblivion, Oblivion Remastered, Enderal, and Starfield so far.
### Bug Fixes
- **Configure Existing Modlist**: Fixed AttributeError when VNV automation check runs after configuration completes
- **Enderal Directory Creation**: Fixed bug where Enderal My Documents directory was created for all modlists instead of only Enderal
### Improvements
- **Winetricks Bundling**: Implemented Wine wrapper scripts that replicate protontricks' environment setup for improved reliability
---
## v0.2.1.1 - Bug Fixes and Improvements
**Release Date:** 2026-01-15
### Critical Bug Fixes
- **AppImage Crash on Steam Deck**: Fixed `NameError: name 'Tuple' is not defined` that prevented AppImage from launching on Steam Deck. Added missing `Tuple` import to `progress_models.py`
### Bug Fixes
- **Menu Routing**: Fixed "Configure Existing Modlist (In Steam)" opening wrong section (was routing to Wabbajack Installer instead of Configure Existing screen)
- **TTW Install Dialogue**: Fixed incorrect account reference (changed "mod.db" to "ModPub" to match actual download source)
- **Duplicate Method**: Removed duplicate `_handle_missing_downloader_error` method in winetricks handler
- **Issue #142**: Removed sudo execution from modlist configuration - now auto-fixes permissions when possible, provides manual instructions only when sudo required
- **Issue #133**: Updated VDF library to 4.0 for improved Steam file format compatibility (protontricks 1.13.1+ support)
### Features
- **Wine Component Error Handling**: Enhanced error messages for missing downloaders with platform-specific installation instructions (SteamOS/Steam Deck vs other distros)
### Dependencies
- **VDF Library**: Updated from PyPI vdf 3.4 to actively maintained solsticegamestudios/vdf 4.0 (used by Gentoo)
- **Winetricks**: Removed bundled downloaders that caused segfaults on some systems - now uses system-provided downloaders (aria2c/wget/curl)
---
## v0.2.1 - Wabbajack Installer and ENB Support
**Release Date:** 2025-01-12
Y
### Major Features
- **Automated Wabbajack Installation**: While I work on Non-Premium support, there is still a call for Wabbajack via Proton. The existing legacy bash script has been proving troublesome for some users, so I've added this as a new feature within Jackify. My aim is still to not need this in future, once Jackify can cover Non-Premium accounts.
- **ENB Detection and Configuration**: Automatic detection and configuration of `enblocal.ini` with `LinuxVersion=true` for all supported games
- **ENB Proton Warning**: Dedicated dialog with Proton version recommendations when ENB is detected
### Critical Bug Fixes
- **OAuth Token Stale State**: Re-check authentication before engine launch to prevent stale token errors after revocation
- **FNV SD Card Registry**: Fixed launcher not recognizing game on SD cards (uses `D:` drive for SD, `Z:` for internal)
- **CLI FILE_PROGRESS Spam**: Filter verbose output to preserve single-line progress updates
- **Steam Double Restart**: Removed legacy code causing double restart during configuration
- **TTW Installer lz4**: Fixed bundled lz4 detection by setting correct working directory
### Improvements
- **Winetricks Bundling**: Bundled critical dependencies (wget, sha256sum, unzip, 7z) for improved reliability
- **UI/UX**: Removed per-file download speeds to match Wabbajack upstream
- **Code Cleanup**: Removed PyInstaller references, use AppImage detection only
- **Wabbajack Installer UI**: Removed unused Process Monitor tab, improved Activity window with detailed step information
- **Steam AppID Overflow Fix**: Changed AppID handling to string type to prevent overflow errors with large Steam AppIDs
---
## v0.2.0.10 - Registry & Hashing Fixes
**Release Date:** 2025-01-04
### Engine Updates
- **jackify-engine 0.4.5**: Fixed archive extraction with backslashes (including pattern matching), data directory path configuration, and removed post-download .wabbajack hash validation. Engine now auto-refreshes OAuth tokens during long installations via `NEXUS_OAUTH_INFO` environment variable.
### Critical Bug Fixes
- **InstallationThread Crash**: Fixed crash during installation with error "'InstallationThread' object has no attribute 'auth_service'". Premium detection diagnostics code assumed auth_service existed but it was never passed to the thread. Affects all users when Premium detection (including false positives) is triggered.
- **Install Start Hang**: Fixed missing `oauth_info` parameter that prevented modlist installs from starting (hung at "Starting modlist installation...")
- **OAuth Token Auto-Refresh**: Fixed OAuth tokens expiring during long modlist installations. Jackify now refreshes tokens with 15-minute buffer before passing to engine. Engine receives full OAuth state via `NEXUS_OAUTH_INFO` environment variable, enabling automatic token refresh during multi-hour downloads. Fixes "Token has expired" errors that occurred 60 minutes into installations.
- **ShowDotFiles Registry Format**: Fixed Wine registry format bug causing hidden files to remain hidden in prefixes. Python string escaping issue wrote single backslash instead of double backslash in `[Software\\Wine]` section header. Added auto-detection and fix for broken format from curated registry files.
- **Dotnet4 Registry Fixes**: Confirmed universal dotnet4.x registry fixes (`*mscoree=native` and `OnlyUseLatestCLR=1`) are applied in all three workflows (Install, Configure New, Configure Existing) across both CLI and GUI interfaces
- **Proton Path Configuration**: Fixed `proton_path` writing invalid "auto" string to config.json - now uses `null` instead, preventing jackify-engine from receiving invalid paths
### Improvements
- **Wine Binary Detection**: Enhanced detection with recursive fallback search within Proton directory when expected paths don't exist (handles different Proton version structures)
- Added Jackify version logging at workflow start
- Fixed GUI log file rotation to only run in debug mode
---
## v0.2.0.9 - Critical Configuration Fixes
**Release Date:** 2025-12-31
### Bug Fixes
- Fixed AppID conversion bug causing Configure Existing failures
- Fixed missing MessageService import crash in Configure Existing
- Fixed RecursionError in config_handler.py logger
- Fixed winetricks automatic fallback to protontricks (was silently failing)
### Improvements
- Added detailed progress indicators for configuration workflows
- Fixed progress bar completion showing 100% instead of 95%
- Removed debug logging noise from file progress widget
- Enhanced Premium detection diagnostics for Issue #111
- Flatpak protontricks now auto-granted cache access for faster subsequent installs
---
## v0.2.0.8 - Bug Fixes and Improvements
**Release Date:** 2025-12-29
### Bug Fixes
- Fixed Configure New/Existing/TTW screens missing Activity tab and progress updates
- Fixed cancel/back buttons crashing in Configure workflows
### Improvements
- Install directory now auto-appends modlist name when selected from gallery
### Known Issues
- Mod filter temporarily disabled in gallery due to technical issue (tag and game filters still work)
---
## v0.2.0.7 - Critical Auth Fix
**Release Date:** 2025-12-28
### Critical Bug Fixes
- **OAuth Token Loss**: Fixed version comparison bug that was deleting OAuth tokens every time settings were saved (affects users on v0.2.0.4+)
- Fixed internal import paths for improved stability
---
## v0.2.0.6 - Premium Detection and Engine Update
**Release Date:** 2025-12-28
**IMPORTANT:** If you are on v0.2.0.5, automatic updates will not work. You must manually download and install v0.2.0.6.
### Engine Updates
- **jackify-engine 0.4.4**: Latest engine version with improvements
### Critical Bug Fixes
- **Auto-Update System**: Fixed broken update dialog import that prevented automatic updates
- **Premium Detection**: Fixed false Premium errors caused by overly-broad detection pattern triggering on jackify-engine 0.4.3's userinfo JSON output
- **Custom Data Directory**: Fixed AppImage always creating ~/Jackify on startup, even when user configured a custom jackify_data_dir
- **Proton Auto-Selection**: Fixed auto-selection writing invalid "auto" string to config on detection failure
### Quality Improvements
- Added pre-build import validator to prevent broken imports from reaching production
---
## v0.2.0.5 - Emergency OAuth Fix
**Release Date:** 2025-12-24
### Critical Bug Fixes
- **OAuth Authentication**: Fixed regression in v0.2.0.4 that prevented OAuth token encryption/decryption, breaking Nexus authentication for users
---
## v0.2.0.4 - Bugfixes & Improvements
**Release Date:** 2025-12-23
### Engine Updates
- **jackify-engine 0.4.3**: Fixed case sensitivity issues, archive extraction crashes, and improved error messages
### Bug Fixes
- Fixed modlist gallery metadata showing outdated versions (now always fetches fresh data)
- Fixed hardcoded ~/Jackify paths preventing custom data directory settings
- Fixed update check blocking GUI startup
- Improved Steam restart reliability (3-minute timeout, better error handling)
- Fixed Protontricks Flatpak installation on Steam Deck
### Backend Changes
- GPU texture conversion now always enabled (config setting deprecated)
### UI Improvements
- Redesigned modlist detail view to show more of hero image
- Improved gallery loading with animated feedback and faster initial load
---
## v0.2.0.3 - Engine Bugfix & Settings Cleanup
**Release Date:** 2025-12-21
### Engine Updates
- **jackify-engine 0.4.3**: Bugfix release
### UI Improvements
- **Settings Dialog**: Removed GPU disable toggle - GPU usage is now always enabled (the disable option was non-functional)
---
## v0.2.0.2 - Emergency Engine Bugfix
**Release Date:** 2025-12-18
### Engine Updates
- **jackify-engine 0.4.2**: Fixed OOM issue with jackify-engine 0.4.1 due to array size
---
## v0.2.0.1 - Critical Bugfix Release
**Release Date:** 2025-12-15
### Critical Bug Fixes
- **Directory Safety Validation**: Fixed data loss bug where directories with only a `downloads/` folder were incorrectly identified as valid modlist directories
- **Flatpak Steam Restart**: Fixed Steam restart failures on Ubuntu/PopOS by removing incompatible `-foreground` flag and increasing startup wait
### Bug Fixes
- **External Links**: Fixed Ko-fi, GitHub, and Nexus links not opening on some distros using xdg-open with clean environment
- **TTW Console Output**: Filtered standalone "OK"/"DONE" noise messages from TTW installation console
- **Activity Window**: Fixed progress display updates in TTW Installer and other workflows
- **Wine Component Installation**: Added status feedback during component installation showing component list
- **Progress Parser**: Added defensive checks to prevent segfaults from malformed engine output
- **Progress Parser Speed Info**: Fixed 'OperationType' object has no attribute 'lower' error by converting enum to string value when extracting speed info from timestamp status patterns
### Improvements
- **Default Wine Components**: Added dxvk to default component list for better graphics compatibility
- **TTW Installer UI**: Show version numbers in status displays
### Engine Updates
- **jackify-engine 0.4.1**: Download reliability fixes, BSA case sensitivity handling, external drive I/O limiting, GPU detection caching, and texture processing performance improvements
---
## v0.2.0 - Modlist Gallery, OAuth Authentication & Performance Improvements
**Release Date:** 2025-12-06
### Major Features
#### Modlist Selection Gallery
Complete overhaul of modlist selection (First pass):
**Core Features:**
- Card-based Modlist Selection browser with modlist images, titles, authors and metadata
- Game-specific filtering automatically applied based on selected game type
- Details per card: download/install/total sizes, tags, version, badges
- Async image loading from GitHub with local 7-day caching
- Detail view with full descriptions, banner images, and external links
- Selected modlist automatically populates Install Modlist workflow
**Search and Filtering:**
- Text search across modlist names and descriptions
- Multi-select tag filtering with normalized tags
- Show Official Only, Show NSFW, Hide Unavailable toggles
- Mod search capability - find modlists containing specific Nexus mods
- Randomised card ordering
**Performance:**
- Gallery images loading from cache
- Background metadata and image preloading when Install Modlist screen opens
- Efficient rendering - cards created once, filters toggle visibility
- Non-blocking UI with concurrent image downloads
**Steam Deck Optimized:**
- Dynamic card sizing (e.g 250x270 on Steam Deck, larger on desktop)
- Responsive grid layout (up to 4 columns on large screens, 3 on Steam Deck)
- Optimized spacing and padding for 1280x800 displays
#### OAuth 2.0 Authentication
Modern authentication for Nexus Mods with secure token management:
- One-click browser-based authorization with PKCE security
- Automatic token refresh with encrypted storage
- Authorisation status indicator on Install Modlist screen
- Works in both GUI and CLI workflows
#### Compact Mode UI Redesign
Streamlined interface with dynamic window management:
- Default compact mode with optional Details view
- Activity window tab (default), across all workflow screens
- Process Monitor tab still available
- Show Details toggle for console output when needed
### Critical Fixes
### Replaced TTW Installer
- Replaced the previous TTW Installer due to complexities with its config file
#### GPU Texture Conversion (jackify-engine 0.4.0)
- Fixed GPU not being used for BC7/BC6H texture conversions
- Previous versions fell back to CPU-only despite GPU availability
- Added GPU toggle in Settings (enabled by default)
#### Winetricks Compatibility & Protontricks
- Fixed bundled winetricks path incompatibility
- Hopefully fixed winetricks in cases where it failed to download components
- For now, Jackify still defaults to bundled winetricks (Protontricks toggle in settings)
#### Steam Restart Reliability
- Enhanced Steam Restart so that is now hopefully works more reliably on all distros
- Fixed Flatpak detection blocking normal Steam start methods
### Technical Improvements
- Proton version usage clarified: Install Proton for installation/texture processing, Game Proton for shortcuts
- Centralised Steam detection in SystemInfo
- ConfigHandler refactored to always read fresh from disk
- Removed obsolete dotnet4.x code
- Enhanced Flatpak Steam compatdata detection with proper VDF parsing
### Bug Fixes
- TTW installation UI performance (batched output processing, non-blocking operations)
- Activity window animations (removed custom timers, Qt native rendering)
- Timer reset when returning from TTW screen
- Fixed bandwidth limit KB/s to bytes conversion
- Fixed AttributeError in AutomatedPrefixService.restart_steam()
### Engine Updates
- jackify-engine 0.4.0 with GPU texture conversion fixes and refactored file progress reporting
---
## v0.1.7.1 - Wine Component Verification & Flatpak Steam Fixes
**Release Date:** November 11, 2025
### Critical Bug Fixes
- **FIXED: Wine Component Installation Verification** - Jackify now verifies components are actually installed before reporting success
### Bug Fixes
- **Steam Deck SD Card Paths**: Fixed ModOrganizer.ini path corruption on SD card installs using regex-based stripping
- **Flatpak Steam Detection**: Fixed libraryfolders.vdf path detection for Flatpak Steam installations
- **Flatpak Steam Restart**: Steam restart service now properly detects and controls Flatpak Steam
- **Path Manipulation**: Fixed path corruption in Configure Existing/New Modlist (paths with spaces)
### Improvements
- Added network diagnostics before winetricks fallback to protontricks
- Enhanced component installation logging with verification status
- Added GE-Proton 10-14 recommendation to success message (ENB compatibility note for Valve's Proton 10)
### Engine Updates
- **jackify-engine 0.3.18**: Archive extraction fixes for Windows symlinks, bandwidth limiting fix, improved error messages
---
## v0.1.7 - TTW Automation & Bug Fixes
**Release Date:** November 1, 2025
### Major Features
- **TTW (Tale of Two Wastelands) Installation and Automation**
laf - TTW Installation function using Hoolamike application - https://github.com/Niedzwiedzw/hoolamike
- Automated workflow for TTW installation and integration into FNV modlists, where possible
- Automatic detection of TTW-compatible modlists
- User prompt after modlist installation with option to install TTW
- Automated integration: file copying, load order updates, modlist.txt updates
- Available in both CLI and GUI workflows
### Bug Fixes
- **Registry UTF-8 Decode Error**: Fixed crash during dotnet4.x installation when Wine outputs binary data
- **Python 3.10 Compatibility**: Fixed startup crash on Python 3.10 systems
- **TTW Steam Deck Layout**: Fixed window sizing issues on Steam Deck when entering/exiting TTW screen
- **TTW Integration Status**: Added visible status banner updates during modlist integration for collapsed mode
- **TTW Accidental Input Protection**: Added 3-second countdown to TTW installation prompt to prevent accidental dismissal
- **Settings Persistence**: Settings changes now persist correctly across workflows
- **Steam Deck Keyboard Input**: Fixed keyboard input failure on Steam Deck
- **Application Close Crash**: Fixed crash when closing application on Steam Deck
- **Winetricks Diagnostics**: Enhanced error detection with automatic fallback
---
## v0.1.6.6 - AppImage Bundling Fix
**Release Date:** October 29, 2025
@@ -432,6 +869,23 @@
- **Clean Architecture**: Removed obsolete service imports, initializations, and cleanup methods
- **Code Quality**: Eliminated "tombstone comments" and unused service references
### Deferred Features (Available in Future Release)
#### OAuth 2.0 Authentication for Nexus Mods
**Status:** Fully implemented but disabled pending Nexus Mods approval
The OAuth 2.0 authentication system has been fully developed and tested, but is temporarily disabled in v0.1.8 as we await approval from Nexus Mods for our OAuth application. The backend code remains intact and will be re-enabled immediately upon approval.
**Features (ready for deployment):**
- **Secure OAuth 2.0 + PKCE Flow**: Modern authentication to replace API key dependency
- **Encrypted Token Storage**: Tokens stored using Fernet encryption with automatic refresh
- **GUI Integration**: Clean status display on Install Modlist screen with authorize/revoke functionality
- **CLI Integration**: OAuth menu in Additional Tasks for command-line users
- **API Key Fallback**: Optional legacy API key support (configurable in Settings)
- **Unified Auth Service**: Single authentication layer supporting both OAuth and API key methods
**Current Limitation:** Awaiting Nexus approval for `jackify://oauth/callback` custom URI. Once approved, OAuth will be enabled as the primary authentication method with API key as optional fallback.
### 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
@@ -783,4 +1237,4 @@ This release completes the logging refactor that was blocking development workfl
- Modular handler architecture for extensibility.
## v0.0.09 and Earlier
See commit history for previous versions.
See commit history for previous versions.

170
README.md
View File

@@ -2,159 +2,119 @@
<div align="center">
[Wiki](https://github.com/Omni-guides/Jackify/wiki) | [Nexus](https://www.nexusmods.com/site/mods/1427) | [Download](https://www.nexusmods.com/site/mods/1427?tab=files) | [Wabbajack Discord](https://discord.gg/wabbajack) | [Jackify Issues](https://github.com/Omni-guides/Jackify/issues) | [Legacy Guides](https://github.com/Omni-guides/Jackify/tree/master/Legacy) | [Ko-fi](https://ko-fi.com/omni1)
[Wiki](https://github.com/Omni-guides/Jackify/wiki) | [Nexus](https://www.nexusmods.com/site/mods/1427) | [Download](https://www.nexusmods.com/site/mods/1427?tab=files) | [Wabbajack Discord](https://discord.gg/wabbajack) | [Jackify Issues](https://github.com/Omni-guides/Jackify/issues) | [Ko-fi](https://ko-fi.com/omni1)
</div>
---
# Jackify
A modlist installation and configuration tool for Wabbajack modlists on Linux
Jackify enables seamless installation and configuration of Wabbajack modlists on Linux systems, providing automated Steam shortcut creation and Proton prefix configuration.
### **Repository Migration Notice**
This repository has evolved from the original [Wabbajack-Modlist-Linux](https://github.com/Omni-guides/Wabbajack-Modlist-Linux) guides and bash scripts into **Jackify** - a comprehensive Linux application for Wabbajack modlist management.
**What changed?**
- **From**: Semi-automated bash scripts and step-by-step wiki guides
- **To**: A complete, automated Linux application with GUI and CLI interfaces
- **Why**: To provide a user-friendly application that removes the complexity of Wabbajack and modlist configuration
**Previous Content**: All original guides and scripts are preserved in the `Legacy/` directory. Jackify provides the same functionality with significantly improved automation and user experience.
---
## Introduction
Thank you for your interest in Jackify - the next step, and a giant leap forward from my automated Wabbajack and modlist post-install scripts. So, Jackify - What is it?
Jackify is an almost Linux-native application written in Python, with a GUI produced with PySide6, and a full featured CLI interface if preferred. More info on the "almost" can be found in the full Introduction Wiki page.
**Important Notes for Alpha Users:**
- This is the first alpha release - there WILL be bugs and issues that need to be resolved
- I am not a UI developer, so the current interface is functional but not polished
- Please report any issues you encounter to help improve the application
**Prefer Manual Installation?** If you'd rather use the proven bash scripts and manual guides that have been tested over many months, see the [Legacy Guides](https://github.com/Omni-guides/Jackify/wiki/Legacy-Wiki-Home) for the old installation methods.
Currently, there are two main functions that Jackify will perform at this stage of development:
- Install Wabbajack modlists using jackify-engine (more on jackify-engine in the full Introduction wiki linked above).
- Fully automate the configuration of the Steam shortcut, modlist paths, prefix components, launch options and various other tweaks required to run Wabbajack Modlists on Linux.
- With both of the above combined, Jackify provides an end-to-end modlist installation and configuration process, automatically.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/D1D8H8WBD)
Jackify is a Linux application for installing and configuring Wabbajack modlists on Linux and Steam Deck. It provides a complete end-to-end workflow — downloading, installing, Steam shortcut creation, Proton prefix setup, and post-install configuration — through both a GUI and a full-featured CLI.
## Features
- Linux-First Python Application: Designed specifically for Linux with minimal external dependencies
- Complete Modlist Workflow: Install from scratch, configure pre-downloaded modlists, or reconfigure existing modlists installations in Steam
- Comprehensive Modlist Support: Support for Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, Enderal and more
- Automated Steam Integration: Automatic Steam shortcut creation with complete Proton configuration
- Professional Interface: Both GUI and CLI interfaces with identical features
- **Complete Modlist Workflow**: Install from scratch with Nexus Premium, configure a pre-downloaded modlist, or reconfigure an existing modlist already in Steam
- **Game Support**: Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, Enderal, and more
- **Automated Steam Integration**: Steam shortcut creation with full Proton configuration
- **GUI and CLI**: Both interfaces provide identical functionality
## Disclaimer
**Jackify is a hobby project in early Alpha development stage. Use at your own risk.**
**Jackify is a hobby project in early Alpha development. Use at your own risk.**
- **No Warranty**: This software is provided "as is" without any warranty or guarantee of functionality
- **Best Effort Support**: Support is provided on a best-effort basis through community channels
- **System Compatibility**: Functionality on your specific system is not guaranteed
- **Data Safety**: Always backup your important data before using Jackify
- **Alpha Software**: Features may be incomplete, unstable, or change without notice
- **Best Effort Support**: Support is provided on a best-effort basis through community channels
- **Data Safety**: Always back up your important data before using Jackify
- **System Compatibility**: Functionality on your specific system is not guaranteed
- **A successful installation does not guarantee a working modlist**: Linux introduces hardware, driver, and system-specific variables that cannot be accounted for. If your modlist installs successfully but does not run correctly, seek help in [#unofficial-linux-help](https://discord.gg/wabbajack) on the Wabbajack Discord — do not contact the modlist author unless they explicitly support Linux
- **Not all modlists can be fully automated**: Some modlists (e.g. Fallout New Vegas lists) require manual steps that Jackify cannot automate (or I have not automated yet). Always check the Install Guide of the Modlist itself to see what could be needed.
- **Most Modlists are not officially supported on Linux**: Jackify makes a best effort to get modlists running, but compatibility is not guaranteed and will vary between modlists, hardware, and system configuration
## Quick Start
## Requirements
### Requirements
- Linux system (Most modern distributions supported)
- Python 3.8+ installed
- Steam installed and configured, Proton Experimental available
- Linux system (most modern distributions will work)
- Steam installed and configured
- **Protontricks** — required for modlist configuration
- See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-protontricks)
- **GE-Proton 10-14** — While other Proton versions may work, GE-Proton 10-14 is highly recommended for ENB compatibility
- See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-ge-proton)
- **Nexus Mods Premium subscription** (required for automated downloads)
- Non-premium support planned for future releases
- **FUSE** (required for AppImage execution)
- Pre-installed on most Linux distributions
- If AppImage fails to run, install FUSE using your distribution's package manager
- Non-Premium users can still install modlists via Wabbajack under Proton
- Native non-premium support planned for a future release
- See the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide) for full details on the options available
- **FUSE** (required for AppImage execution, pre-installed on most distributions)
- **Ubuntu/Debian-based distros only** (Ubuntu, Kubuntu, Linux Mint, Pop!_OS, Zorin OS, elementary OS, and others): Qt platform plugin library
- `sudo apt install libxcb-cursor-dev`
### Installation
## Installation Quick Start
1. Download the latest release from [Nexus Mods](https://www.nexusmods.com/site/mods/1427?tab=files)
2. Extract the AppImage from the 7z archive
3. Make it executable and run:
```bash
# Download latest release from Nexus Mods
# Extract the Jackify.AppImage from the 7z archive
chmod +x Jackify.AppImage
./Jackify.AppImage
```
## Usage
For CLI mode: `./Jackify.AppImage --cli`
For a complete step-by-step guide with screenshots, see the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide).
### Quick Start
1. **Download**: Get the latest release from [NexusMods](https://www.nexusmods.com/site/mods/1427?tab=files)
2. **Extract**: Unzip the .7z archive to get `Jackify.AppImage`
3. **Run**: `chmod +x Jackify.AppImage && ./Jackify.AppImage`
4. **Install**: Choose "Install a Modlist", select your game and modlist, configure directories and API key
**CLI Mode**: Run `./Jackify.AppImage --cli` for command-line interface
For a full step-by-step guide with screenshots, see the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide).
## Supported Games
- Skyrim Special Edition
- Fallout 4
- Fallout New Vegas
- Oblivion
- Starfield
- Enderal
- Other Games (Cyberpunk 2077, Baldur's Gate 3, and more - Download and Install only for now)
- Other games (Cyberpunk 2077, Baldur's Gate 3, and more — download and install support only for now - full automatioin coming in the future)
## 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 (custom fork of wabbajack-cli.exe) for optimal performance and compatibility
- Steam Integration: Direct Steam shortcuts.vdf manipulation for creating and modifying Steam shortcuts
- **Backend Services**: Pure business logic with no UI dependencies
- **Frontend Interfaces**: CLI and GUI implementations sharing the same backend
- **Native Engine**: Powered by jackify-engine (custom fork of wabbajack-cli) for optimal Linux performance and compatibility. Texconv for hash-matched texture conversion requires Proton.
- **Steam Integration**: Direct Steam shortcuts.vdf manipulation for shortcut creation and management
## Configuration
Configuration files are stored in:
- Jackify Related: ~/Jackify/
- jackify-engine config: ~/.config/jackify/
## Development
Development and contribution guidelines coming soon.
## License
This project is licensed under the GPLv3 License - see the LICENSE file for details.
All Jackify relted files and configuration data is are stored in `~/Jackify/` and `~/.config/jackify/`.
## Contributing
At this early stage of development, where basic functionality is the primary focus, I'd prefer to use GitHub Issues to suggest improvements, rather tha PRs. This will likely change in the future.
## Future Planned Features (not guaranteed)
At this early stage of development, I'd prefer GitHub Issues for bug reports and suggestions rather than PRs. This will likely change as the project matures. See the CONTRIBUTING document for more details.
- Continue to expand the supported games list for fully automated configuration
- Add full TTW+Modlist automation for TTW based modlists
- Replace the API Key requirement with a more secure OAuth based approach
- Add support for modding and modlist creation tools via a sister application or module
- Revise the GUI to be more refined
- Dark/Light theme support for the GUI
- Advanced logging and diagnostics - more detailed troubleshooting information
- Automatic dependency resolution - ensure all required tools and libraries are installed
## Future Plans (not guaranteed)
- Continue to expand supported games for fully automated configuration
- Non-Premium / manual download support
- GUI refinements
- Dark/Light theme support
## Legacy Guides
The original bash scripts and step-by-step manual installation guides are preserved in the [Legacy Guides](https://github.com/Omni-guides/Jackify/wiki/Legacy-Wiki-Home) for those who prefer them or need a fallback.
## License
This project is licensed under the GPLv3 License — see the LICENSE file for details.
## Support
- Issues: Report bugs and request features via GitHub Issues
- Documentation: See the Wiki for detailed guides
- Community: Join the community in the #unofficial-linux-help channel of the Official Wabbajack discord server - https://discord.gg/wabbajack
- **Bugs and feature requests**: [GitHub Issues](https://github.com/Omni-guides/Jackify/issues)
- **Documentation**: [Wiki](https://github.com/Omni-guides/Jackify/wiki)
- **Community**: [#unofficial-linux-help](https://discord.gg/wabbajack) on the Wabbajack Discord
## Acknowledgments
- Wabbajack team for the modlist ecosystem, and wabbajack-cli.exe
- Wabbajack team for the modlist ecosystem and wabbajack-cli
- Linux and Steam Deck gaming communities
- Modlist Authors for their tireless effort in creating modlists in the first place
- Modlist authors for their tireless work
---
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/D1D8H8WBD)
**Jackify** - Simplifying Wabbajack modlist installation and configuration on Linux

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,644 @@
"""CLI configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from ..handlers.ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
)
logger = logging.getLogger(__name__)
class ModlistOperationsConfigurationCLIMixin:
"""Mixin providing CLI configuration phase methods."""
def configuration_phase(self):
"""
Run the configuration phase: execute the Linux-native Jackify Install Engine.
"""
from .modlist_operations import get_jackify_engine_path
print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}")
start_time = time.time()
from jackify.shared.paths import get_jackify_logs_dir
log_dir = get_jackify_logs_dir()
log_dir.mkdir(parents=True, exist_ok=True)
workflow_log_path = log_dir / "Modlist_Install_workflow.log"
max_logs = 3
max_size = 1024 * 1024
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)
try:
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]:
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:
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]:
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:
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}")
modlist_arg = self.context.get('modlist_value') or self.context.get('machineid')
machineid = self.context.get('machineid')
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
api_key = current_api_key or self.context.get('nexus_api_key')
oauth_info = current_oauth_info or self.context.get('nexus_oauth_info')
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
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'):
self.logger.error("modlist_value is missing in context for GUI workflow!")
return
cmd = [engine_path, 'install', '--show-file-progress']
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]
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
cmd.append('--debug')
self.logger.info("Adding --debug flag to jackify-engine")
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)")
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)")
else:
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
if 'NEXUS_OAUTH_CLIENT_ID' in os.environ:
del os.environ['NEXUS_OAUTH_CLIENT_ID']
self.logger.debug(f"No Nexus auth available, cleared inherited env vars")
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]'}")
self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') 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}")
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}")
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
proc = self._current_process
buffer = b''
inline_progress_active = False
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 '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
buffer = b''
continue
clean_line = line.rstrip('\r\n')
if clean_line.startswith("Installing files "):
print(f"\r{clean_line}", end='')
sys.stdout.flush()
inline_progress_active = True
else:
if inline_progress_active:
print()
inline_progress_active = False
print(line, end='')
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
buffer = b''
continue
clean_line = line.rstrip('\r\n')
if clean_line.startswith("Installing files "):
print(f"\r{clean_line}", end='')
inline_progress_active = True
else:
if inline_progress_active:
print()
inline_progress_active = False
print(line, end='')
sys.stdout.flush()
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
line = ''
if line:
if inline_progress_active:
print()
inline_progress_active = False
print(line, end='')
if inline_progress_active:
print()
proc.wait()
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
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)
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
finally:
for key, original_value in original_env_values.items():
current_value_in_os_environ = os.environ.get(key)
display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'"
if original_value is not None:
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:
os.environ[key] = original_value
self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.")
else:
if key in os.environ:
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]
except Exception as e:
error_message = str(e)
print(f"{COLOR_ERROR}Error during installation workflow: {error_message}{COLOR_RESET}\n")
self.logger.error(f"Exception in installation workflow: {error_message}", exc_info=True)
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, "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:
sys.stdout = orig_stdout
sys.stderr = orig_stderr
workflow_log.close()
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...")
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"
elif not shortcut_name:
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}'")
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...")
print("\n" + "-" * 28)
print(
f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now? "
f"Steam will restart and close any running game.{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...")
if not is_gui_mode:
from jackify.backend.handlers.resolution_handler import ResolutionHandler
resolution_handler = ResolutionHandler()
is_steamdeck = self.steamdeck if hasattr(self, 'steamdeck') else False
selected_resolution = resolution_handler.select_resolution(steamdeck=is_steamdeck)
if selected_resolution:
self.context['resolution'] = selected_resolution
self.logger.info(f"Resolution set to: {selected_resolution}")
self.logger.info(f"Starting Steam configuration for '{shortcut_name}'")
mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe')
app_id = None
use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1'
if use_automated_prefix:
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
from ..services.automated_prefix_service import AutomatedPrefixService
prefix_service = AutomatedPrefixService()
start_time = time.time()
def progress_callback(message):
noisy_patterns = (
"using bundled tools directory",
"bundled tools available",
"checking winetricks dependencies",
"(bundled)",
"(system)",
"wget",
"curl",
"aria2c",
"sha256sum",
"cabextract",
)
message_lc = message.lower()
if any(pattern in message_lc for pattern in noisy_patterns):
# Keep dependency/tool chatter in logs only for CLI readability.
self.logger.debug("Automated prefix detail: %s", message)
return
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}]"
self.logger.info("Automated prefix progress: %s", message)
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
try:
_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
)
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "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:
result = prefix_service.continue_workflow_after_conflict_resolution(
shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback
)
if isinstance(result, tuple) and len(result) >= 3:
success, prefix_path, app_id = result[0], result[1], result[2]
else:
success, prefix_path, app_id = False, None, None
else:
success, prefix_path, app_id = False, None, None
elif choice == 'cancel':
print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}")
return
elif choice == 'skip':
print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}")
success, prefix_path, app_id = True, None, None
else:
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
return
else:
success, prefix_path, app_id, last_timestamp = result
elif isinstance(result, tuple) and len(result) == 3:
if result[0] == "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:
result = prefix_service.continue_workflow_after_conflict_resolution(
shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback
)
if isinstance(result, tuple) and len(result) >= 3:
success, prefix_path, app_id = result[0], result[1], result[2]
else:
success, prefix_path, app_id = False, None, None
else:
success, prefix_path, app_id = False, None, None
elif choice == 'cancel':
print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}")
return
elif choice == 'skip':
print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}")
success, prefix_path, app_id = True, None, None
else:
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
return
else:
success, prefix_path, app_id = result
else:
if result is True:
success, prefix_path, app_id = True, None, None
else:
success, prefix_path, app_id = False, None, None
if success:
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
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}")
else:
print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}")
print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}")
return
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
modlist_context = ModlistContext(
name=shortcut_name,
install_dir=Path(install_dir_str),
download_dir=Path(install_dir_str) / "downloads",
game_type=self.context.get('detected_game', 'Unknown'),
nexus_api_key='',
modlist_value=self.context.get('modlist_value', ''),
modlist_source=self.context.get('modlist_source', 'identifier'),
resolution=self.context.get('resolution'),
mo2_exe_path=Path(mo2_exe_path),
skip_confirmation=True,
engine_installed=True
)
modlist_context.app_id = app_id
modlist_service = ModlistService(self.system_info)
if 'progress_callback' in locals() and progress_callback:
progress_callback("")
progress_callback("=== Configuration Phase ===")
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
self.logger.info("Running post-installation configuration phase using ModlistService")
configuration_success = modlist_service.configure_modlist_post_steam(modlist_context)
if configuration_success:
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
self.logger.info("Post-installation configuration completed successfully")
try:
# Ensure CLI install flow gets the same VNV automation behavior as GUI.
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
modlist_name_for_automation = self.context.get('modlist_name') or shortcut_name or ""
def _confirm_vnv(description: str) -> bool:
print(f"\n{description}\n")
try:
user_input = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
def _manual_vnv_file(title: str, instructions: str):
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
print(instructions)
try:
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
except (EOFError, KeyboardInterrupt):
return None
if not file_input:
return None
selected = Path(file_input).expanduser().resolve()
return selected if selected.exists() else None
automation_ran, vnv_error = run_vnv_automation_if_applicable(
modlist_name=modlist_name_for_automation,
modlist_install_location=Path(install_dir_str),
game_root=None, # Auto-detect from modlist structure.
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=_manual_vnv_file,
confirmation_callback=_confirm_vnv,
)
if automation_ran and not vnv_error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if vnv_error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
except Exception as vnv_err:
self.logger.error("VNV post-install automation failed: %s", vnv_err, exc_info=True)
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
try:
# v0.4.0 contract: offer TTW flow for eligible FNV lists (e.g., Begin Again).
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
prompt_ttw_if_eligible(
install_dir_str,
self.context.get('modlist_name') or shortcut_name or "",
)
except Exception as ttw_err:
self.logger.error("TTW post-install prompt failed: %s", ttw_err, exc_info=True)
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
self.logger.warning("Post-installation configuration had issues")
else:
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}")

View File

@@ -0,0 +1,170 @@
"""GUI configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
logger = logging.getLogger(__name__)
class ModlistOperationsConfigurationGUIMixin:
"""Mixin providing GUI configuration phase methods."""
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)
"""
try:
from .modlist_operations import _get_user_proton_version
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
try:
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,
'manual_steps_completed': False
}
existing_app_id = context.get('app_id')
if existing_app_id:
config_context['appid'] = existing_app_id
if progress_callback:
progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...")
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
retry_count = 0
max_retries = 3
while retry_count < max_retries:
if progress_callback:
progress_callback("Running modlist 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:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
return True
else:
retry_count += 1
if retry_count < max_retries:
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)
config_context['manual_steps_completed'] = True
else:
if completion_callback:
completion_callback(False, "Manual steps failed after multiple attempts", config_context['name'])
return False
if completion_callback:
completion_callback(False, "Configuration failed", config_context['name'])
return False
else:
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
if progress_callback:
progress_callback("Creating Steam shortcut...")
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
proton_version = _get_user_proton_version()
success, app_id = steam_service.create_shortcut_with_proton(
app_name=config_context['name'],
exe_path=config_context['mo2_exe_path'],
start_dir=os.path.dirname(config_context['mo2_exe_path']),
launch_options="%command%",
tags=["Jackify"],
proton_version=proton_version
)
if not success or not app_id:
if completion_callback:
completion_callback(False, "Failed to create Steam shortcut", config_context['name'])
return False
config_context['appid'] = app_id
if progress_callback:
from jackify.shared.timing import get_timestamp
progress_callback(f"{get_timestamp()} Steam shortcut created successfully")
if progress_callback:
progress_callback("Running modlist 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:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
return True
else:
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")
manual_steps_callback(config_context['name'], 1)
if progress_callback:
progress_callback("manual_steps_callback completed")
return True
if completion_callback:
completion_callback(False, "Configuration failed", config_context['name'])
return False
finally:
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

View File

@@ -0,0 +1,368 @@
"""Discovery phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
from pathlib import Path
from typing import Optional, Dict
from ..handlers.ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
COLOR_SELECTION,
)
from ..handlers.config_handler import ConfigHandler
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
logger = logging.getLogger(__name__)
class ModlistOperationsDiscoveryMixin:
"""Mixin providing modlist discovery phase methods."""
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).
"""
from .modlist_operations import get_jackify_engine_path
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'
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:
self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}")
if not has_modlist:
self.logger.error("Missing modlist_value or machineid for GUI workflow.")
self.logger.error("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
engine_executable = get_jackify_engine_path()
self.logger.debug(f"Engine executable path: {engine_executable}")
if not os.path.exists(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)
if 'machineid' not in self.context:
print("\n" + "-" * 28)
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:
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)
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]
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):
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'
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",
no_header=True
)
if modlist_path is None:
self.logger.info("User cancelled .wabbajack file selection.")
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
return None
self.context['modlist_source'] = 'path'
self.context['modlist_value'] = str(modlist_path)
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()
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:
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']}")
if 'install_dir' not in self.context:
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']}")
if 'download_dir' not in self.context:
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']}")
if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
authenticated, method, username = auth_service.get_auth_status()
if authenticated:
if method == 'oauth':
print("\n" + "-" * 28)
print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print("\n" + "-" * 28)
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info
else:
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
authenticated = False
if not authenticated:
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}")
print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}")
print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}")
authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower()
if authorize in ('', 'y', 'yes'):
print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}")
print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}")
print(f"{COLOR_INFO}Note: You may see a security warning about a self-signed certificate.{COLOR_RESET}")
print(f"{COLOR_INFO}This is normal - click 'Advanced' and 'Proceed' to continue.{COLOR_RESET}")
def show_message(msg):
print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}")
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
if success:
print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}")
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}")
self.logger.info("User declined Nexus authorization.")
return None
self.logger.debug("Nexus authentication configured for engine.")
self._display_summary()
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':
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 game_type and not self.wabbajack_parser.is_supported_game(game_type):
print("\n" + "" * 46)
print(" 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.")
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]"
self.logger.info(f"Context: {context_for_logging}")
return self.context

View File

@@ -0,0 +1,67 @@
"""Game detection methods for ModlistInstallCLI (Mixin)."""
import logging
from pathlib import Path
from typing import Optional, Dict
logger = logging.getLogger(__name__)
class ModlistOperationsGameDetectionMixin:
"""Mixin providing game type detection methods."""
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:
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:
game_name = modlist_info['game'].lower()
self.logger.info(f"Detecting game type from modlist info: {game_name}")
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)

View File

@@ -0,0 +1,99 @@
"""Nexus and engine methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
from ..handlers.ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
logger = logging.getLogger(__name__)
class ModlistOperationsNexusMixin:
"""Mixin providing Nexus API and engine methods."""
def _get_nexus_api_key(self) -> Optional[str]:
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")
"""
from .modlist_operations import get_jackify_engine_path
engine_executable = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_executable)
if not os.path.exists(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 []
env = os.environ.copy()
env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"
command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url']
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
status_down = '[DOWN]' in line
status_nsfw = '[NSFW]' in line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
parts = clean_line.rsplit(' - ', 3)
if len(parts) != 4:
continue
modlist_name = parts[0].strip()
game_name = parts[1].strip()
sizes_str = parts[2].strip()
machine_url = parts[3].strip()
size_parts = sizes_str.split('|')
if len(size_parts) != 3:
continue
download_size = size_parts[0].strip()
install_size = size_parts[1].strip()
total_size = size_parts[2].strip()
if not modlist_name or not game_name or not machine_url:
continue
modlists.append({
'id': modlist_name,
'name': modlist_name,
'game': game_name,
'download_size': download_size,
'install_size': install_size,
'total_size': total_size,
'machine_url': machine_url,
'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 []

View File

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

View File

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

View File

@@ -5,17 +5,10 @@ Reusable tab completion functions for Jackify CLI, including bash-like path comp
import os
import readline
import logging # Added for debugging
import logging
# 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 = logging.getLogger(__name__)
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!
@@ -68,7 +61,6 @@ def path_completer(text, state):
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):

View File

@@ -6,28 +6,45 @@ Handles application settings and configuration
"""
import os
import sys
import json
import logging
import shutil
import re
import base64
from pathlib import Path
from typing import Optional
from .config_handler_encryption import ConfigEncryptionMixin
from .config_handler_directories import ConfigDirectoriesMixin
from .config_handler_proton import ConfigProtonMixin
# Initialize logger
logger = logging.getLogger(__name__)
class ConfigHandler:
class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonMixin):
"""
Handles application configuration and settings
Singleton pattern ensures all code shares the same instance
"""
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super(ConfigHandler, cls).__new__(cls)
return cls._instance
def __init__(self):
"""Initialize configuration handler with default settings"""
# Only initialize once (singleton pattern)
if ConfigHandler._initialized:
return
ConfigHandler._initialized = True
self.config_dir = os.path.expanduser("~/.config/jackify")
self.config_file = os.path.join(self.config_dir, "config.json")
self.settings = {
"version": "0.0.5",
"version": "0.2.0",
"last_selected_modlist": None,
"steam_libraries": [],
"resolution": None,
@@ -39,19 +56,29 @@ class ConfigHandler:
"modlist_install_base_dir": os.path.expanduser("~/Games"), # Configurable base directory for modlist installations
"modlist_downloads_base_dir": os.path.expanduser("~/Games/Modlist_Downloads"), # Configurable base directory for downloads
"jackify_data_dir": None, # Configurable Jackify data directory (default: ~/Jackify)
"use_winetricks_for_components": True, # True = use winetricks (faster), False = use protontricks for all (legacy)
"game_proton_path": None # Proton version for game shortcuts (can be any Proton 9+), separate from install proton
"use_winetricks_for_components": True, # DEPRECATED: Migrated to component_installation_method. Kept for backward compatibility.
"component_installation_method": "winetricks", # "winetricks" (default) or "system_protontricks"
"game_proton_path": None, # Proton version for game shortcuts (can be any Proton 9+), separate from install proton
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
"proton_version": None, # Install Proton version name - None means auto-detect
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
"window_width": None, # Saved window width (None = use dynamic sizing)
"window_height": None # Saved window height (None = use dynamic sizing)
}
# Load configuration if exists
self._load_config()
# Perform version migrations
self._migrate_config()
# If steam_path is not set, detect it
if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path()
# Auto-detect and set Proton version on first run
if not self.settings.get("proton_path"):
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
# Do NOT overwrite user's saved settings!
if not os.path.exists(self.config_file) and not self.settings.get("proton_path"):
self._auto_detect_proton()
# If jackify_data_dir is not set, initialize it to default
@@ -86,7 +113,8 @@ class ConfigHandler:
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")
os.path.expanduser("~/.steam/root/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf") # Flatpak
]
for vdf_path in libraryfolders_vdf_paths:
@@ -100,7 +128,10 @@ class ConfigHandler:
return None
def _load_config(self):
"""Load configuration from file"""
"""
Load configuration from file and update in-memory cache.
For legacy compatibility with initialization code.
"""
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r') as f:
@@ -113,6 +144,84 @@ class ConfigHandler:
self._create_config_dir()
except Exception as e:
logger.error(f"Error loading configuration: {e}")
def _migrate_config(self):
"""
Migrate configuration between versions
Handles breaking changes and data format updates
"""
current_version = self.settings.get("version", "0.0.0")
target_version = "0.2.0"
if current_version == target_version:
return
logger.info(f"Migrating config from {current_version} to {target_version}")
# Migration: v0.0.x -> v0.2.0
# Encryption changed from cryptography (Fernet) to pycryptodome (AES-GCM)
# Old encrypted API keys cannot be decrypted, must be re-entered
from packaging import version
if version.parse(current_version) < version.parse("0.2.0"):
# Clear old encrypted credentials
if self.settings.get("nexus_api_key"):
logger.warning("Clearing saved API key due to encryption format change")
logger.warning("Please re-enter your Nexus API key in Settings")
self.settings["nexus_api_key"] = None
# Clear OAuth token file (different encryption format)
oauth_token_file = Path(self.config_dir) / "nexus-oauth.json"
if oauth_token_file.exists():
logger.warning("Clearing saved OAuth token due to encryption format change")
logger.warning("Please re-authorize with Nexus Mods")
try:
oauth_token_file.unlink()
except Exception as e:
logger.error(f"Failed to remove old OAuth token: {e}")
# Remove obsolete keys
obsolete_keys = [
"hoolamike_install_path",
"hoolamike_version",
"api_key_fallback_enabled",
"proton_version", # Display string only, path stored in proton_path
"game_proton_version" # Display string only, path stored in game_proton_path
]
removed_count = 0
for key in obsolete_keys:
if key in self.settings:
del self.settings[key]
removed_count += 1
if removed_count > 0:
logger.info(f"Removed {removed_count} obsolete config keys")
# Update version
self.settings["version"] = target_version
self.save_config()
logger.info("Config migration completed")
def _read_config_from_disk(self):
"""
Read configuration directly from disk without caching.
Returns merged config (defaults + saved values).
"""
try:
config = self.settings.copy() # Start with defaults
if os.path.exists(self.config_file):
with open(self.config_file, 'r') as f:
saved_config = json.load(f)
config.update(saved_config)
return config
except Exception as e:
# Use logger.warning instead of print to stderr - logger is initialized before config access
logger.warning(f"Error reading configuration from disk: {e}")
return self.settings.copy()
def reload_config(self):
"""Reload configuration from disk to pick up external changes"""
self._load_config()
def _create_config_dir(self):
"""Create configuration directory if it doesn't exist"""
@@ -135,8 +244,12 @@ class ConfigHandler:
return False
def get(self, key, default=None):
"""Get a configuration value by key"""
return self.settings.get(key, default)
"""
Get a configuration value by key.
Always reads fresh from disk to avoid stale data.
"""
config = self._read_config_from_disk()
return config.get(key, default)
def set(self, key, value):
"""Set a configuration value"""
@@ -193,81 +306,8 @@ class ConfigHandler:
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
Always reads fresh from disk to pick up changes from other instances
Returns:
str: Decoded API key or None if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
encoded_key = self.settings.get("nexus_api_key")
if encoded_key:
# Decode the base64 encoded key
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
Always reads fresh from disk to pick up changes from other instances
Returns:
bool: True if API key exists, False otherwise
"""
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
return self.settings.get("nexus_api_key") is not None
def clear_api_key(self):
"""
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
return self.settings.get("protontricks_path")
def save_resolution(self, resolution):
"""
Save resolution setting to configuration
@@ -334,252 +374,6 @@ class ConfigHandler:
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
def get_proton_path(self):
"""
Retrieve the saved Install Proton path from configuration (for jackify-engine)
Always reads fresh from disk to pick up changes from Settings dialog
Returns:
str: Saved Install Proton path or 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
proton_path = self.settings.get("proton_path", "auto")
logger.debug(f"Retrieved fresh install proton_path from config: {proton_path}")
return proton_path
except Exception as e:
logger.error(f"Error retrieving install proton_path: {e}")
return "auto"
def get_game_proton_path(self):
"""
Retrieve the saved Game Proton path from configuration (for game shortcuts)
Falls back to install Proton path if game Proton not set
Always reads fresh from disk to pick up changes from Settings dialog
Returns:
str: Saved Game Proton path, Install Proton path, or 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
game_proton_path = self.settings.get("game_proton_path")
# If game proton not set or set to same_as_install, use install proton
if not game_proton_path or game_proton_path == "same_as_install":
game_proton_path = self.settings.get("proton_path", "auto")
logger.debug(f"Retrieved fresh game proton_path from config: {game_proton_path}")
return game_proton_path
except Exception as e:
logger.error(f"Error retrieving game proton_path: {e}")
return "auto"
def get_proton_version(self):
"""
Retrieve the saved Proton version from configuration
Always reads fresh from disk to pick up changes from Settings dialog
Returns:
str: Saved Proton version or 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
proton_version = self.settings.get("proton_version", "auto")
logger.debug(f"Retrieved fresh proton_version from config: {proton_version}")
return proton_version
except Exception as e:
logger.error(f"Error retrieving proton_version: {e}")
return "auto"
def _auto_detect_proton(self):
"""Auto-detect and set best Proton version (includes GE-Proton and Valve Proton)"""
try:
from .wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
self.settings["proton_path"] = str(best_proton['path'])
self.settings["proton_version"] = best_proton['name']
proton_type = best_proton.get('type', 'Unknown')
logger.info(f"Auto-detected Proton: {best_proton['name']} ({proton_type})")
self.save_config()
else:
# Fallback to auto-detect mode
self.settings["proton_path"] = "auto"
self.settings["proton_version"] = "auto"
logger.info("No compatible Proton versions found, using auto-detect mode")
self.save_config()
except Exception as e:
logger.error(f"Failed to auto-detect Proton: {e}")
self.settings["proton_path"] = "auto"
self.settings["proton_version"] = "auto"

View File

@@ -0,0 +1,108 @@
"""
Config handler directory paths: install/download parent and modlist base dirs.
"""
import os
import logging
logger = logging.getLogger(__name__)
class ConfigDirectoriesMixin:
"""Mixin providing directory path getters/setters for ConfigHandler."""
def set_default_install_parent_dir(self, path):
"""Save the parent directory for modlist installations."""
try:
if path and os.path.exists(path):
self.settings["default_install_parent_dir"] = path
logger.debug("Default install parent directory saved: %s", path)
return self.save_config()
logger.warning("Invalid or non-existent path for install parent directory: %s", path)
return False
except Exception as e:
logger.error("Error saving install parent directory: %s", e)
return False
def get_default_install_parent_dir(self):
"""Retrieve the saved parent directory for modlist installations."""
try:
path = self.settings.get("default_install_parent_dir")
if path and os.path.exists(path):
logger.debug("Retrieved default install parent directory: %s", path)
return path
logger.debug("No valid default install parent directory found")
return None
except Exception as e:
logger.error("Error retrieving install parent directory: %s", e)
return None
def set_default_download_parent_dir(self, path):
"""Save the parent directory for downloads."""
try:
if path and os.path.exists(path):
self.settings["default_download_parent_dir"] = path
logger.debug("Default download parent directory saved: %s", path)
return self.save_config()
logger.warning("Invalid or non-existent path for download parent directory: %s", path)
return False
except Exception as e:
logger.error("Error saving download parent directory: %s", e)
return False
def get_default_download_parent_dir(self):
"""Retrieve the saved parent directory for downloads."""
try:
path = self.settings.get("default_download_parent_dir")
if path and os.path.exists(path):
logger.debug("Retrieved default download parent directory: %s", path)
return path
logger.debug("No valid default download parent directory found")
return None
except Exception as e:
logger.error("Error retrieving download parent directory: %s", e)
return None
def has_saved_install_parent_dir(self):
"""Check if a default install parent directory is saved and valid."""
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 and valid."""
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."""
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."""
try:
if path:
self.settings["modlist_install_base_dir"] = path
logger.debug("Modlist install base directory saved: %s", path)
return self.save_config()
logger.warning("Invalid path for modlist install base directory")
return False
except Exception as e:
logger.error("Error saving modlist install base directory: %s", e)
return False
def get_modlist_downloads_base_dir(self):
"""Get the configurable base directory 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."""
try:
if path:
self.settings["modlist_downloads_base_dir"] = path
logger.debug("Modlist downloads base directory saved: %s", path)
return self.save_config()
logger.warning("Invalid path for modlist downloads base directory")
return False
except Exception as e:
logger.error("Error saving modlist downloads base directory: %s", e)
return False

View File

@@ -0,0 +1,137 @@
"""
Config handler API key encryption and storage.
"""
import os
import base64
import hashlib
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class ConfigEncryptionMixin:
"""Mixin providing encryption and API key storage for ConfigHandler."""
def _get_encryption_key(self) -> bytes:
"""Generate Fernet-compatible encryption key for API key storage."""
import socket
import getpass
try:
hostname = socket.gethostname()
username = getpass.getuser()
machine_id = None
try:
with open('/etc/machine-id', 'r') as f:
machine_id = f.read().strip()
except Exception:
try:
with open('/var/lib/dbus/machine-id', 'r') as f:
machine_id = f.read().strip()
except Exception:
pass
key_material = f"{hostname}:{username}:{machine_id}:jackify" if machine_id else f"{hostname}:{username}:jackify"
except Exception as e:
logger.warning("Failed to get machine info for encryption: %s", e)
key_material = "jackify:default:key"
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
return base64.urlsafe_b64encode(key_bytes)
def _encrypt_api_key(self, api_key: str) -> str:
"""Encrypt API key using AES-GCM."""
try:
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
key = base64.urlsafe_b64decode(self._get_encryption_key())
nonce = get_random_bytes(12)
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8'))
combined = nonce + ciphertext + tag
return base64.b64encode(combined).decode('utf-8')
except ImportError:
logger.warning("pycryptodome not available, using base64 encoding (less secure)")
return base64.b64encode(api_key.encode('utf-8')).decode('utf-8')
except Exception as e:
logger.error("Error encrypting API key: %s", e)
return ""
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
"""Decrypt API key using AES-GCM."""
try:
from Crypto.Cipher import AES
if not hasattr(AES, 'MODE_GCM'):
try:
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
except Exception:
return None
key = base64.urlsafe_b64decode(self._get_encryption_key())
combined = base64.b64decode(encrypted_key.encode('utf-8'))
nonce = combined[:12]
tag = combined[-16:]
ciphertext = combined[12:-16]
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
return plaintext.decode('utf-8')
except ImportError:
try:
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
except Exception:
return None
except (AttributeError, Exception):
try:
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
except Exception as e:
logger.error("Error decrypting API key: %s", e)
return None
def save_api_key(self, api_key):
"""Save Nexus API key with encryption."""
try:
if api_key:
encrypted_key = self._encrypt_api_key(api_key)
if not encrypted_key:
logger.error("Failed to encrypt API key")
return False
self.settings["nexus_api_key"] = encrypted_key
logger.debug("API key encrypted and saved successfully")
else:
self.settings["nexus_api_key"] = None
logger.debug("API key cleared")
result = self.save_config()
if result:
try:
os.chmod(self.config_file, 0o600)
except Exception as e:
logger.warning("Could not set restrictive permissions on config: %s", e)
return result
except Exception as e:
logger.error("Error saving API key: %s", e)
return False
def get_api_key(self):
"""Retrieve and decrypt the saved Nexus API key. Always reads fresh from disk."""
try:
config = self._read_config_from_disk()
encrypted_key = config.get("nexus_api_key")
if encrypted_key:
return self._decrypt_api_key(encrypted_key)
return None
except Exception as e:
logger.error("Error retrieving API key: %s", e)
return None
def has_saved_api_key(self):
"""Check if an API key is saved in configuration. Always reads fresh from disk."""
config = self._read_config_from_disk()
return config.get("nexus_api_key") is not None
def clear_api_key(self):
"""Clear the saved API key from configuration."""
try:
self.settings["nexus_api_key"] = None
logger.debug("API key cleared from configuration")
return self.save_config()
except Exception as e:
logger.error("Error clearing API key: %s", e)
return False

View File

@@ -0,0 +1,76 @@
"""
Config handler Proton path and version getters and auto-detect.
"""
import logging
logger = logging.getLogger(__name__)
class ConfigProtonMixin:
"""Mixin providing Proton path/version and auto-detect for ConfigHandler."""
def get_proton_path(self):
"""Retrieve the saved Install Proton path. Always reads fresh from disk."""
try:
config = self._read_config_from_disk()
proton_path = config.get("proton_path")
if not proton_path:
logger.debug("proton_path not set in config - will use auto-detection")
return None
logger.debug("Retrieved fresh install proton_path from config: %s", proton_path)
return proton_path
except Exception as e:
logger.error("Error retrieving install proton_path: %s", e)
return None
def get_game_proton_path(self):
"""Retrieve the saved Game Proton path. Falls back to install Proton. Always reads fresh from disk."""
try:
config = self._read_config_from_disk()
game_proton_path = config.get("game_proton_path")
if not game_proton_path or game_proton_path == "same_as_install":
game_proton_path = config.get("proton_path")
if not game_proton_path:
logger.debug("game_proton_path not set in config - will use auto-detection")
return None
logger.debug("Retrieved fresh game proton_path from config: %s", game_proton_path)
return game_proton_path
except Exception as e:
logger.error("Error retrieving game proton_path: %s", e)
return "auto"
def get_proton_version(self):
"""Retrieve the saved Proton version. Always reads fresh from disk."""
try:
config = self._read_config_from_disk()
proton_version = config.get("proton_version", "auto")
logger.debug("Retrieved fresh proton_version from config: %s", proton_version)
return proton_version
except Exception as e:
logger.error("Error retrieving proton_version: %s", e)
return "auto"
def _auto_detect_proton(self):
"""Auto-detect and set best Proton version (GE-Proton and Valve Proton)."""
try:
from .wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
self.settings["proton_path"] = str(best_proton['path'])
self.settings["proton_version"] = best_proton['name']
proton_type = best_proton.get('type', 'Unknown')
logger.info("Auto-detected Proton: %s (%s)", best_proton['name'], proton_type)
self.save_config()
else:
self.settings["proton_path"] = None
self.settings["proton_version"] = None
logger.warning("No compatible Proton versions found - proton_path set to null in config.json")
logger.info("Jackify will auto-detect Proton on each run until a valid version is found")
self.save_config()
except Exception as e:
logger.error("Failed to auto-detect Proton: %s", e)
self.settings["proton_path"] = None
self.settings["proton_version"] = None
logger.warning("proton_path set to null in config.json due to auto-detection failure")
self.save_config()

View File

@@ -73,7 +73,7 @@ def diagnose_stalled_engine(pid: int, duration: int = 60) -> Dict[str, Any]:
samples.append(sample)
# Real-time status
status_icon = "🟢" if sample['cpu_percent'] > 10 else "🟡" if sample['cpu_percent'] > 2 else "🔴"
status_icon = "[OK]" if sample['cpu_percent'] > 10 else "[WARN]" if sample['cpu_percent'] > 2 else "[CRIT]"
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']}")

View File

@@ -0,0 +1,317 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ENB Handler Module
Handles ENB detection and Linux compatibility configuration for modlists.
"""
import logging
import configparser
import shutil
from pathlib import Path
from typing import Dict, Any, Optional, Tuple
logger = logging.getLogger(__name__)
class ENBHandler:
"""
Handles ENB detection and configuration for Linux compatibility.
Detects ENB components in modlist installations and ensures enblocal.ini
has the required LinuxVersion=true setting in the [GLOBAL] section.
"""
def __init__(self):
"""Initialize ENB handler."""
self.logger = logger
def detect_enb_in_modlist(self, modlist_path: Path) -> Dict[str, Any]:
"""
Detect ENB components in modlist installation.
Searches for ENB configuration files:
- enbseries.ini, enblocal.ini (ENB configuration files)
Note: Does NOT check for DLL files (d3d9.dll, d3d11.dll, dxgi.dll) as these
are used by many other mods (ReShade, other graphics mods) and are not
reliable indicators of ENB presence.
Args:
modlist_path: Path to modlist installation directory
Returns:
Dict with detection results:
- has_enb: bool - True if ENB config files found
- enblocal_ini: str or None - Path to enblocal.ini if found
- enbseries_ini: str or None - Path to enbseries.ini if found
- d3d9_dll: str or None - Always None (not checked)
- d3d11_dll: str or None - Always None (not checked)
- dxgi_dll: str or None - Always None (not checked)
"""
enb_info = {
'has_enb': False,
'enblocal_ini': None,
'enbseries_ini': None,
'd3d9_dll': None,
'd3d11_dll': None,
'dxgi_dll': None
}
if not modlist_path.exists():
self.logger.warning(f"Modlist path does not exist: {modlist_path}")
return enb_info
# Search for ENB indicator files
# IMPORTANT: Only check for ENB config files (enbseries.ini, enblocal.ini)
# Do NOT check for DLL files (d3d9.dll, d3d11.dll, dxgi.dll) as these are used
# by many other mods (ReShade, other graphics mods) and are not reliable ENB indicators
enb_config_patterns = [
('**/enbseries.ini', 'enbseries_ini'),
('**/enblocal.ini', 'enblocal_ini')
]
for pattern, key in enb_config_patterns:
for file_path in modlist_path.glob(pattern):
# Skip backups and plugin data directories
if "Backup" in str(file_path) or "plugins/data" in str(file_path):
continue
enb_info['has_enb'] = True
if not enb_info[key]: # Store first match
enb_info[key] = str(file_path)
# If we detected ENB config but didn't find enblocal.ini via glob,
# use the priority-based finder
if enb_info['has_enb'] and not enb_info['enblocal_ini']:
found_ini = self.find_enblocal_ini(modlist_path)
if found_ini:
enb_info['enblocal_ini'] = str(found_ini)
return enb_info
def find_enblocal_ini(self, modlist_path: Path) -> Optional[Path]:
"""
Find enblocal.ini in modlist installation using priority-based search.
Search order (highest priority first):
1. Stock Game/Game Root directories (active locations)
2. Mods folder with Root/root subfolder (most common pattern)
3. Direct in mods/fixes folders
4. Fallback recursive search (excluding backups)
Args:
modlist_path: Path to modlist installation directory
Returns:
Path to enblocal.ini if found, None otherwise
"""
if not modlist_path.exists():
return None
# Priority 1: Stock Game/Game Root (active locations)
stock_game_names = [
"Stock Game",
"Game Root",
"STOCK GAME",
"Stock Game Folder",
"Stock Folder",
"Skyrim Stock"
]
for name in stock_game_names:
candidate = modlist_path / name / "enblocal.ini"
if candidate.exists():
self.logger.debug(f"Found enblocal.ini in Stock Game location: {candidate}")
return candidate
# Priority 2: Mods folder with Root/root subfolder
mods_dir = modlist_path / "mods"
if mods_dir.exists():
# Search for Root/root subfolders
for root_dir in mods_dir.rglob("Root"):
candidate = root_dir / "enblocal.ini"
if candidate.exists():
self.logger.debug(f"Found enblocal.ini in mods/Root: {candidate}")
return candidate
for root_dir in mods_dir.rglob("root"):
candidate = root_dir / "enblocal.ini"
if candidate.exists():
self.logger.debug(f"Found enblocal.ini in mods/root: {candidate}")
return candidate
# Priority 3: Direct in mods/fixes folders
for search_dir in [modlist_path / "mods", modlist_path / "fixes"]:
if search_dir.exists():
for enb_file in search_dir.rglob("enblocal.ini"):
# Skip backups and plugin data
if "Backup" not in str(enb_file) and "plugins/data" not in str(enb_file):
self.logger.debug(f"Found enblocal.ini in {search_dir.name}: {enb_file}")
return enb_file
# Priority 4: Fallback recursive search (exclude backups)
for enb_file in modlist_path.rglob("enblocal.ini"):
if "Backup" not in str(enb_file) and "plugins/data" not in str(enb_file):
self.logger.debug(f"Found enblocal.ini via recursive search: {enb_file}")
return enb_file
return None
def ensure_linux_version_setting(self, enblocal_ini_path: Path) -> bool:
"""
Safely ensure [GLOBAL] section exists with LinuxVersion=true in enblocal.ini.
Safety features:
- Verifies file exists before attempting modification
- Checks if [GLOBAL] section exists before adding (prevents duplicates)
- Creates backup before any write operation
- Only writes if changes are actually needed
- Handles encoding issues gracefully
- Preserves existing file structure and comments
Args:
enblocal_ini_path: Path to enblocal.ini file
Returns:
bool: True if successful or no changes needed, False on error
"""
try:
# Safety check: file must exist
if not enblocal_ini_path.exists():
self.logger.warning(f"enblocal.ini not found at: {enblocal_ini_path}")
return False
# Read existing INI with same settings as modlist_handler.py
config = configparser.ConfigParser(
allow_no_value=True,
delimiters=['=']
)
config.optionxform = str # Preserve case sensitivity
# Read with encoding handling (same pattern as modlist_handler.py)
try:
with open(enblocal_ini_path, 'r', encoding='utf-8-sig') as f:
config.read_file(f)
except UnicodeDecodeError:
with open(enblocal_ini_path, 'r', encoding='latin-1') as f:
config.read_file(f)
except configparser.DuplicateSectionError as e:
# If file has duplicate [GLOBAL] sections, log warning and skip
self.logger.warning(f"enblocal.ini has duplicate sections: {e}. Skipping modification.")
return False
# Check if [GLOBAL] section exists (case-insensitive check)
global_section_exists = False
global_section_name = None
# Find existing [GLOBAL] section (case-insensitive)
for section_name in config.sections():
if section_name.upper() == 'GLOBAL':
global_section_exists = True
global_section_name = section_name # Use actual case
break
# Check current LinuxVersion value
needs_update = False
if global_section_exists:
# Section exists - check if LinuxVersion needs updating
current_value = config.get(global_section_name, 'LinuxVersion', fallback=None)
if current_value is None or current_value.lower() != 'true':
needs_update = True
else:
# Section doesn't exist - we need to add it
needs_update = True
# If no changes needed, return success
if not needs_update:
self.logger.debug(f"enblocal.ini already has LinuxVersion=true in [GLOBAL] section")
return True
# Changes needed - create backup first
backup_path = enblocal_ini_path.with_suffix('.ini.jackify_backup')
try:
if not backup_path.exists():
shutil.copy2(enblocal_ini_path, backup_path)
self.logger.debug(f"Created backup: {backup_path}")
except Exception as e:
self.logger.warning(f"Failed to create backup: {e}. Proceeding anyway.")
# Make changes
if not global_section_exists:
# Add [GLOBAL] section (configparser will use exact case 'GLOBAL')
config.add_section('GLOBAL')
global_section_name = 'GLOBAL'
self.logger.debug("Added [GLOBAL] section to enblocal.ini")
# Set LinuxVersion=true
config.set(global_section_name, 'LinuxVersion', 'true')
self.logger.debug(f"Set LinuxVersion=true in [GLOBAL] section")
# Write back to file
with open(enblocal_ini_path, 'w', encoding='utf-8') as f:
config.write(f, space_around_delimiters=False)
self.logger.info(f"Successfully configured enblocal.ini: {enblocal_ini_path}")
return True
except configparser.DuplicateSectionError as e:
# Handle duplicate sections gracefully
self.logger.error(f"enblocal.ini has duplicate [GLOBAL] sections: {e}")
return False
except configparser.Error as e:
# Handle other configparser errors
self.logger.error(f"ConfigParser error reading enblocal.ini: {e}")
return False
except Exception as e:
# Handle any other errors
self.logger.error(f"Unexpected error configuring enblocal.ini: {e}", exc_info=True)
return False
def configure_enb_for_linux(self, modlist_path: Path) -> Tuple[bool, Optional[str], bool]:
"""
Main entry point: detect ENB and configure enblocal.ini.
Safe for modlists without ENB - returns success with no message.
Args:
modlist_path: Path to modlist installation directory
Returns:
Tuple[bool, Optional[str], bool]: (success, message, enb_detected)
- success: True if successful or no ENB detected, False on error
- message: Human-readable message (None if no action taken)
- enb_detected: True if ENB was detected, False otherwise
"""
try:
# Step 1: Detect ENB (safe - just searches for files)
enb_info = self.detect_enb_in_modlist(modlist_path)
enb_detected = enb_info.get('has_enb', False)
# Step 2: If no ENB detected, return success (no action needed)
if not enb_detected:
return (True, None, False) # Safe: no ENB, nothing to do
# Step 3: Find enblocal.ini
enblocal_path = enb_info.get('enblocal_ini')
if not enblocal_path:
# ENB detected but no enblocal.ini found - this is unusual but not an error
self.logger.warning("ENB detected but enblocal.ini not found - may be configured elsewhere")
return (True, None, True) # ENB detected but no config file
# Step 4: Configure enblocal.ini (safe method with all checks)
enblocal_path_obj = Path(enblocal_path)
success = self.ensure_linux_version_setting(enblocal_path_obj)
if success:
return (True, "ENB configured for Linux compatibility", True)
else:
# Non-blocking: log error but don't fail workflow
return (False, "Failed to configure ENB (see logs for details)", True)
except Exception as e:
# Catch-all error handling - never break the workflow
self.logger.error(f"Error in ENB configuration: {e}", exc_info=True)
return (False, "ENB configuration error (see logs)", False)

View File

@@ -92,7 +92,7 @@ class EnginePerformanceMonitor:
# Also monitor the parent Python process for comparison
try:
self._parent_process = psutil.Process(os.getpid())
except:
except Exception:
self._parent_process = None
self._monitoring = True
@@ -179,7 +179,7 @@ class EnginePerformanceMonitor:
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}% "
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})")
@@ -220,7 +220,7 @@ class EnginePerformanceMonitor:
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:
except Exception:
pass
# Get I/O info

View File

@@ -11,19 +11,20 @@ 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
import subprocess
import pwd
import grp
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET
# Initialize logger for the module
from .filesystem_handler_download import FilesystemDownloadMixin
from .filesystem_handler_ownership import FilesystemOwnershipMixin
from .filesystem_handler_steam import FilesystemSteamMixin
logger = logging.getLogger(__name__)
class FileSystemHandler:
class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin):
def __init__(self):
# Keep instance logger if needed, but static methods use module logger
self.logger = logging.getLogger(__name__)
@staticmethod
@@ -36,7 +37,7 @@ class FileSystemHandler:
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
return Path(path)
@staticmethod
def validate_path(path: Path) -> bool:
@@ -50,7 +51,6 @@ class FileSystemHandler:
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}")
@@ -60,7 +60,7 @@ class FileSystemHandler:
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
return True
except Exception as e:
logger.error(f"Failed to validate path {path}: {e}")
return False
@@ -192,16 +192,16 @@ class FileSystemHandler:
if recursive and path.is_dir():
for root, dirs, files in os.walk(path):
try:
os.chmod(root, 0o755) # Dirs typically 755
os.chmod(root, 0o755)
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
os.chmod(os.path.join(root, file), 0o644)
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
os.chmod(path, 0o644 if permissions == 0o755 else permissions)
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})")
@@ -239,12 +239,6 @@ class FileSystemHandler:
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
@@ -306,7 +300,7 @@ class FileSystemHandler:
FileSystemHandler.ensure_directory(destination.parent)
shutil.move(str(source), str(destination)) # shutil.move needs strings
shutil.move(str(source), str(destination))
logger.info(f"Moved directory {source} to {destination}")
return True
except Exception as e:
@@ -321,8 +315,6 @@ class FileSystemHandler:
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)
@@ -392,100 +384,6 @@ class FileSystemHandler:
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
@@ -604,6 +502,11 @@ class FileSystemHandler:
"""
Create required directories for a game modlist
This includes both Linux home directories and Wine prefix directories.
Creating the Wine prefix Documents directories is critical for USVFS
to work properly on first launch - USVFS needs the target directory
to exist before it can virtualize profile INI files.
Args:
game_name: Name of the game (e.g., skyrimse, fallout4)
appid: Steam AppID of the modlist
@@ -614,13 +517,24 @@ class FileSystemHandler:
try:
# Define base paths
home_dir = os.path.expanduser("~")
# Game-specific Documents directory names (for both Linux home and Wine prefix)
game_docs_dirs = {
"skyrimse": "Skyrim Special Edition",
"fallout4": "Fallout4",
"falloutnv": "FalloutNV",
"oblivion": "Oblivion",
"enderal": "Enderal Special Edition",
"enderalse": "Enderal Special Edition"
}
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
# Game-specific directories in Linux home (legacy, may not be needed)
"skyrimse": [
os.path.join(home_dir, "Documents", "My Games", "Skyrim Special Edition"),
],
@@ -635,266 +549,53 @@ class FileSystemHandler:
]
}
# Create common directories
# Create common directories (compatdata pfx paths)
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
# Create game-specific directories in Linux home (legacy support)
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}")
# CRITICAL: Create game-specific Documents directories in Wine prefix
# Required for USVFS to virtualize profile INIs on first launch
if game_name in game_docs_dirs:
docs_dir_name = game_docs_dirs[game_name]
# Find compatdata path for this AppID
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
compatdata_path = path_handler.find_compat_data(appid)
if compatdata_path:
# Create Documents/My Games/{GameName} in Wine prefix
wine_docs_path = os.path.join(
str(compatdata_path),
"pfx",
"drive_c",
"users",
"steamuser",
"Documents",
"My Games",
docs_dir_name
)
try:
os.makedirs(wine_docs_path, exist_ok=True)
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}")
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
except Exception as e:
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}")
# Don't fail completely - this is a first-launch optimization
else:
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation")
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time")
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) ...

View File

@@ -0,0 +1,55 @@
"""
Filesystem download operations: download_file.
"""
import logging
from pathlib import Path
import requests
logger = logging.getLogger(__name__)
class FilesystemDownloadMixin:
"""Mixin providing download_file for FileSystemHandler."""
def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool:
"""Download a file from a URL to a destination path."""
self.logger.info("Downloading %s to %s...", url, destination_path)
if not overwrite and destination_path.exists():
self.logger.info("File already exists, skipping download: %s", destination_path)
if not quiet:
self.logger.info("File %s already exists, skipping download.", destination_path.name)
return True
try:
destination_path.parent.mkdir(parents=True, exist_ok=True)
with requests.get(url, stream=True, timeout=300, verify=True) as r:
r.raise_for_status()
with open(destination_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
self.logger.info("Download complete.")
if not quiet:
self.logger.info("Download complete.")
return True
except requests.exceptions.RequestException as e:
self.logger.error("Download failed: %s", e)
self.logger.error("Download failed for %s. Check network connection and URL.", url)
if destination_path.exists():
try:
destination_path.unlink()
except OSError:
pass
return False
except Exception as e:
self.logger.error("Error during download or file writing: %s", e, exc_info=True)
self.logger.error("An unexpected error occurred during download.")
if destination_path.exists():
try:
destination_path.unlink()
except OSError:
pass
return False

View File

@@ -0,0 +1,89 @@
"""
Filesystem ownership and permissions: all_owned_by_user, verify_ownership_and_permissions, set_ownership_and_permissions_sudo.
"""
import os
import logging
import subprocess
import pwd
import grp
from pathlib import Path
logger = logging.getLogger(__name__)
class FilesystemOwnershipMixin:
"""Mixin providing ownership check and sudo-compatible fix for FileSystemHandler."""
@staticmethod
def all_owned_by_user(path: Path) -> bool:
"""Return 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 verify_ownership_and_permissions(path: Path) -> tuple:
"""
Verify and fix ownership/permissions for modlist directory.
Returns (success, error_message).
"""
if not path.exists():
logger.error("Path does not exist: %s", path)
return False, f"Path does not exist: {path}"
if not FilesystemOwnershipMixin.all_owned_by_user(path):
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, "Could not determine current user or group name."
logger.error("Ownership issue detected: Some files in %s are not owned by %s", path, user_name)
error_msg = (
f"\nOwnership Issue Detected\n"
f"Some files in the modlist directory are not owned by your user account.\n"
f"This can happen if the modlist was copied from another location or installed by a different user.\n\n"
f"To fix this, open a terminal and run:\n\n"
f" sudo chown -R {user_name}:{group_name} \"{path}\"\n"
f" sudo chmod -R 755 \"{path}\"\n\n"
f"After running these commands, retry the configuration process."
)
return False, error_msg
logger.info("Files in %s are owned by current user, verifying permissions...", path)
try:
result = subprocess.run(
['chmod', '-R', '755', str(path)],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
logger.info("Permissions set successfully for %s", path)
return True, ""
logger.warning("chmod returned non-zero but we'll continue: %s", result.stderr)
return True, ""
except Exception as e:
logger.warning("Error running chmod: %s, continuing anyway", e)
return True, ""
@staticmethod
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
"""Deprecated: use verify_ownership_and_permissions() instead. Kept for backwards compatibility."""
logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
success, error_msg = FilesystemOwnershipMixin.verify_ownership_and_permissions(path)
if not success:
logger.error("%s", error_msg)
return success

View File

@@ -0,0 +1,124 @@
"""
Steam path discovery for FileSystemHandler: find_steam_library, find_compat_data, find_steam_config_vdf.
"""
import logging
from pathlib import Path
from typing import Optional
import vdf
logger = logging.getLogger(__name__)
class FilesystemSteamMixin:
"""Mixin providing Steam library and compatdata path discovery for FileSystemHandler."""
@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...")
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",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"
]
libraryfolders_vdf_path: Optional[Path] = None
for path_obj in possible_vdf_paths:
current_path = Path(path_obj)
if current_path.is_file():
libraryfolders_vdf_path = current_path
logger.debug(f"Found libraryfolders.vdf at: {libraryfolders_vdf_path}")
break
if not libraryfolders_vdf_path:
logger.warning("libraryfolders.vdf not found...")
else:
try:
with open(libraryfolders_vdf_path, 'r') as f:
data = vdf.load(f)
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:
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
logger.warning("No valid library paths found within libraryfolders.vdf.")
except ImportError:
logger.error("Python 'vdf' library not found. Cannot parse libraryfolders.vdf.")
except Exception as e:
logger.error(f"Error parsing libraryfolders.vdf: {e}")
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}")
possible_bases = [
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata",
]
steam_lib_common_path: Optional[Path] = FilesystemSteamMixin.find_steam_library()
if steam_lib_common_path:
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)
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
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
logger.warning("Could not locate Steam's config.vdf file in standard locations.")
return None

View File

@@ -15,6 +15,7 @@ class GameDetector:
'skyrim': ['Skyrim Special Edition', 'Skyrim'],
'fallout4': ['Fallout 4'],
'falloutnv': ['Fallout New Vegas'],
'fallout3': ['Fallout 3'],
'oblivion': ['Oblivion'],
'starfield': ['Starfield'],
'oblivion_remastered': ['Oblivion Remastered']
@@ -34,6 +35,8 @@ class GameDetector:
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 ['fallout 3', 'fo3', 'fallout3', 'fose']):
return 'fallout3'
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']):
@@ -108,6 +111,12 @@ class GameDetector:
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks']
},
'fallout3': {
'launcher': 'FOSE',
'min_proton_version': '5.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks']
},
'oblivion': {
'launcher': 'OBSE',
'min_proton_version': '5.0',
@@ -173,6 +182,7 @@ class GameDetector:
'skyrim': 'SKSE',
'fallout4': 'F4SE',
'falloutnv': 'NVSE',
'fallout3': 'FOSE',
'oblivion': 'OBSE',
'starfield': 'SFSE',
'oblivion_remastered': 'OBSE'
@@ -205,6 +215,7 @@ class GameDetector:
'skyrim': ['vcrun2019', 'dotnet48', 'dxvk'],
'fallout4': ['vcrun2019', 'dotnet48', 'dxvk'],
'falloutnv': ['vcrun2019', 'dotnet48'],
'fallout3': ['vcrun2019', 'dotnet48'],
'oblivion': ['vcrun2019', 'dotnet48'],
'starfield': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'],
'oblivion_remastered': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk']
@@ -222,6 +233,7 @@ class GameDetector:
'skyrim': ['SkyrimSE.exe', 'Skyrim.exe'],
'fallout4': ['Fallout4.exe'],
'falloutnv': ['FalloutNV.exe'],
'fallout3': ['Fallout3.exe'],
'oblivion': ['Oblivion.exe']
}
@@ -250,6 +262,11 @@ class GameDetector:
'config_dirs': ['Data', 'Saves'],
'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\FalloutNV']
},
'fallout3': {
'ini_files': ['Fallout.ini', 'FalloutPrefs.ini'],
'config_dirs': ['Data', 'Saves'],
'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\Fallout3']
},
'oblivion': {
'ini_files': ['Oblivion.ini'],
'config_dirs': ['Data', 'Saves'],

View File

@@ -1,994 +0,0 @@
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()

File diff suppressed because it is too large Load Diff

View File

@@ -14,14 +14,15 @@ import shutil
class LoggingHandler:
"""
Central logging handler for Jackify.
- Uses ~/Jackify/logs/ as the log directory.
- Uses configured Jackify data directory for logs (default: ~/Jackify/logs/).
- Supports per-function log files (e.g., jackify-install-wabbajack.log).
- Handles log rotation and log directory creation.
Usage:
logger = LoggingHandler().setup_logger('install_wabbajack', 'jackify-install-wabbajack.log')
"""
def __init__(self):
self.log_dir = Path.home() / "Jackify" / "logs"
from jackify.shared.paths import get_jackify_logs_dir
self.log_dir = get_jackify_logs_dir()
self.ensure_log_directory()
def ensure_log_directory(self) -> None:
@@ -80,7 +81,7 @@ class LoggingHandler:
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_path, mode='a', encoding='utf-8', maxBytes=100*1024*1024, backupCount=5
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter)
@@ -89,7 +90,7 @@ class LoggingHandler:
return logger
def rotate_logs(self, max_bytes: int = 1024 * 1024, backup_count: int = 5) -> None:
def rotate_logs(self, max_bytes: int = 100 * 1024 * 1024, backup_count: int = 5) -> None:
"""Rotate log files based on size."""
for log_file in self.get_log_files():
try:
@@ -185,5 +186,5 @@ class LoggingHandler:
return stats
def get_general_logger(self):
"""Get the general CLI logger (~/Jackify/logs/jackify-cli.log)."""
"""Get the general CLI logger ({jackify_data_dir}/logs/jackify-cli.log)."""
return self.setup_logger('jackify_cli', is_general=True)

View File

@@ -10,7 +10,6 @@ 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
@@ -33,10 +32,14 @@ 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
try:
import readline
except ImportError:
readline = None
# Define exports for this module
__all__ = [
'MenuHandler',
@@ -47,614 +50,11 @@ __all__ = [
# 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 using centralized detection
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
# Create the resolution handler
self.resolution_handler = ResolutionHandler()
# 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()
# Use centralized detection even in fallback
try:
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
except:
self.steamdeck = False # Final fallback
self.modlist_handler = None
def show_modlist_menu(self):
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
from .menu_handler_input import (
basic_input_prompt, input_prompt, simple_path_completer,
READLINE_AVAILABLE, READLINE_HAS_PROMPT, READLINE_HAS_DISPLAY_HOOK,
)
from .menu_handler_modlist import ModlistMenuHandler
class MenuHandler:
"""
@@ -671,7 +71,6 @@ class MenuHandler:
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"""
@@ -859,60 +258,6 @@ class MenuHandler:
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)
@@ -973,8 +318,7 @@ class MenuHandler:
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}")
print(f"{COLOR_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:
@@ -1040,8 +384,8 @@ class MenuHandler:
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.")
print(f"{COLOR_ERROR}Path is not a valid '{extension_filter}' file or a directory: {file_path}{COLOR_RESET}")
print(f"{COLOR_INFO}Please check the path and try again, or press Ctrl+C or 'q' to cancel.{COLOR_RESET}")
if not self._ask_try_again():
print("")
return None
@@ -1050,65 +394,5 @@ class MenuHandler:
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
if READLINE_AVAILABLE and readline:
readline.set_completer(None)

View File

@@ -0,0 +1,98 @@
"""
Menu handler input and readline tab completion.
Exports: READLINE_* constants, basic_input_prompt, input_prompt, simple_path_completer, _shell_path_completer, _simple_path_completer.
"""
import logging
import os
import glob
from .ui_colors import COLOR_PROMPT, COLOR_RESET
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")
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")
try:
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.")
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):
print()
try:
import shutil
term_width = shutil.get_terminal_size().columns
except (ImportError, AttributeError):
term_width = 80
items_per_line = max(1, term_width // (longest_match_length + 2))
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()
current_input = readline.get_line_buffer()
print(f"{COLOR_PROMPT}> {COLOR_RESET}{current_input}", end='', flush=True)
try:
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:
logging.warning("readline not available. Tab completion for paths will be disabled.")
except Exception as e:
logging.warning(f"Error initializing readline: {e}. Tab completion for paths will be disabled.")
def basic_input_prompt(message, **kwargs):
return input(message)
input_prompt = basic_input_prompt
def _shell_path_completer(text, state):
"""Shell-like pathname completer for readline. Expands ~, handles absolute/relative paths."""
expanded = os.path.expanduser(os.path.expandvars(text))
if os.path.isdir(expanded):
pattern = os.path.join(expanded, '*')
else:
pattern = expanded + '*'
matches = glob.glob(pattern)
matches = [m + ('/' if os.path.isdir(m) else '') for m in matches]
if not text:
matches = glob.glob('*')
matches = [m + ('/' if os.path.isdir(m) else '') for m in matches]
try:
return matches[state]
except IndexError:
return None
def _simple_path_completer(text, state):
"""Simple pathname completer for readline. Prefix matching on path components."""
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

View File

@@ -0,0 +1,665 @@
"""
Modlist menu handler: modlist-specific CLI menu operations.
ModlistMenuHandler class. Lazy-imports MenuHandler to avoid circular import.
"""
import logging
import os
from pathlib import Path
from typing import List, Dict, Optional
from .ui_colors import (
COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR,
COLOR_SUCCESS, COLOR_WARNING, COLOR_ACTION, COLOR_INPUT
)
from .modlist_handler import ModlistHandler
from .filesystem_handler import FileSystemHandler
from .path_handler import PathHandler
from .vdf_handler import VDFHandler
from .resolution_handler import ResolutionHandler
from jackify.shared.ui_utils import print_section_header
logger = logging.getLogger(__name__)
class ModlistMenuHandler:
"""Handles modlist-specific menu operations."""
def __init__(self, config_handler, test_mode=False):
self.config_handler = config_handler
self.test_mode = test_mode
self.exit_flag = False
self.logger = logging.getLogger(__name__)
try:
self.filesystem_handler = FileSystemHandler()
self.path_handler = PathHandler()
self.vdf_handler = VDFHandler()
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
self.resolution_handler = ResolutionHandler()
from .menu_handler import MenuHandler
self.menu_handler = MenuHandler()
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
self.install_wabbajack_handler = None
except Exception as e:
self.logger.error(f"Error initializing ModlistMenuHandler: {e}")
self.filesystem_handler = FileSystemHandler()
try:
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
except Exception:
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 _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
# self.menu_handler is 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"{COLOR_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"{COLOR_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}ModOrganizer.exe not found in directory: {normalized_path}{COLOR_RESET}")
continue
if not normalized_path.lower().endswith('modorganizer.exe'):
print(f"{COLOR_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}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}Name cannot be empty.{COLOR_RESET}")
continue
if len(modlist_name) > 100:
print(f"{COLOR_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}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)}")
# --- Use automated prefix workflow (replaces old manual workflow) ---
try:
mo2_dir = os.path.dirname(mo2_path)
install_dir = mo2_dir
# Use automated prefix service for modern workflow
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
# CLI safety warning: this workflow will restart Steam as part of shortcut/prefix setup.
print("\n" + "-" * 28)
print(
f"{COLOR_PROMPT}Configure New Modlist will restart Steam and close any running game.{COLOR_RESET}"
)
continue_choice = input(f"{COLOR_PROMPT}Continue with Configure New now? (Y/n): {COLOR_RESET}").strip().lower()
if continue_choice == 'n':
print(f"{COLOR_INFO}Configuration cancelled before Steam restart.{COLOR_RESET}")
return True
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
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
# Handle the result
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
# Handle conflict - ask user what to do
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(" 1. Use existing shortcut (recommended)")
print(" 2. Create new shortcut anyway")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
# Use existing shortcut
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
elif choice == "2":
# Create new shortcut - would need to handle this, but for now just fail
print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}")
return True
else:
print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}")
return True
else:
# Success - get the results
success, prefix_path, appid_int, last_timestamp = result
if success and appid_int:
context = {
"name": modlist_name,
"appid": str(appid_int),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
return self.run_modlist_configuration_phase(context)
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
else:
# Unexpected result format
print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}")
self.logger.error(f"Unexpected result format from automated workflow: {result}")
return True
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:
logger.error("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,
"modlist_source": "existing" # Mark as existing modlist to skip manual steps
}
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).
"""
import os
self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}")
# Write nxmhandler.ini to suppress MO2's NXM Handling popup on first launch.
# This must happen before MO2 runs for the first time, so do it here rather than
# relying on callers to remember.
_mo2_exe = context.get('mo2_exe_path') or os.path.join(context.get('path', ''), 'ModOrganizer.exe')
_mo2_dir = os.path.dirname(_mo2_exe)
if _mo2_dir and os.path.isdir(_mo2_dir):
self.shortcut_handler.write_nxmhandler_ini(_mo2_dir, _mo2_exe)
# 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}")
# Check GUI mode early to avoid input() calls in GUI context
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
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')}")
if not gui_mode:
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return False
# --- Resolution selection logic for GUI mode ---
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 = ""
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
def update_status(msg):
nonlocal status_line
filtered_prefixes = (
"Using bundled tools directory (after system PATH):",
"Bundled tools available:",
)
msg_lc = msg.lower().strip()
if msg.startswith(filtered_prefixes):
return
# Suppress per-tool dependency detail lines like:
# " wget: /usr/bin/wget (system)" / " 7z: ... (bundled)".
if msg.startswith(" ") and (
"(system)" in msg_lc or "(bundled)" in msg_lc or "not found" in msg_lc
):
return
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)
skip_manual_for_existing = context.get("modlist_source") == "existing" # Existing modlists skip manual steps
if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed, skip_manual_for_existing=skip_manual_for_existing):
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()
# Configure ENB for Linux compatibility (non-blocking).
# In GUI mode, modlist_service.py handles ENB after this function returns,
# so skip here to avoid running it twice.
enb_detected = False
if not gui_mode:
try:
from ..handlers.enb_handler import ENBHandler
from pathlib import Path
enb_handler = ENBHandler()
install_dir = Path(context.get('path', ''))
if install_dir.exists():
enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(install_dir)
if enb_message:
if enb_success:
self.logger.info(enb_message)
update_status(enb_message)
else:
self.logger.warning(enb_message)
except Exception as e:
self.logger.warning(f"ENB configuration skipped due to error: {e}")
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
# Only in CLI mode - GUI handles this in install_modlist.py
if not gui_mode:
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from pathlib import Path
modlist_name = context.get('name', '')
modlist_path = Path(context.get('path', ''))
try:
def _confirm_vnv(description: str) -> bool:
print(f"\n{description}\n")
try:
user_input = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
def _manual_vnv_file(title: str, instructions: str):
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
print(instructions)
try:
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
except (EOFError, KeyboardInterrupt):
return None
if not file_input:
return None
selected = Path(file_input).expanduser().resolve()
return selected if selected.exists() else None
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=_manual_vnv_file,
confirmation_callback=_confirm_vnv
)
if automation_ran and not error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
except Exception as e:
self.logger.debug(f"VNV automation check skipped: {e}")
# Not an error - just means VNV automation wasn't applicable
if not gui_mode:
try:
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
prompt_ttw_if_eligible(
context.get('path', ''),
context.get('name', '') or '',
)
except Exception as ttw_err:
self.logger.error("TTW post-config prompt failed: %s", ttw_err, exc_info=True)
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
is_existing_flow = context.get("modlist_source") == "existing"
completion_title = "Modlist Configuration complete!" if is_existing_flow else "Modlist Install and Configuration complete!"
completion_log_file = "Configure_Existing_Modlist_workflow.log" if is_existing_flow else "Configure_New_Modlist_workflow.log"
print("")
print("")
print("") # Extra blank line before completion
print("=" * 35)
print("= Configuration phase complete =")
print("=" * 35)
print("")
print(completion_title)
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
print("• Congratulations and enjoy the game!")
print("")
# Show ENB-specific warning if ENB was detected (replaces generic note)
if enb_detected:
print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}")
print("")
print("If you plan on using ENB as part of this modlist, you will need to use")
print("one of the following Proton versions, otherwise you will have issues:")
print("")
print(" (in order of recommendation)")
print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}")
print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}")
print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}")
print("")
print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
print("")
else:
# No ENB detected - no warning needed
pass
from jackify.shared.paths import get_jackify_logs_dir
print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}")
# 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

View File

@@ -1,184 +0,0 @@
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

View File

@@ -0,0 +1,584 @@
"""Configuration workflow methods for ModlistHandler (Mixin)."""
from pathlib import Path
import os
import logging
import requests
import re
from typing import Optional
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR
from .resolution_handler import ResolutionHandler
logger = logging.getLogger(__name__)
class ModlistConfigurationMixin:
"""Mixin providing configuration workflow methods for ModlistHandler."""
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, skip_manual_for_existing=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).
skip_manual_for_existing (bool): If True, always skip manual steps (for existing modlists that are already configured).
"""
try:
# 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).")
self.logger.error("Missing required information to start configuration.")
return False
except Exception as e:
self.logger.error(f"Exception in _execute_configuration_steps initialization: {e}", exc_info=True)
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.")
self.logger.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 = skip_manual_for_existing # Existing modlists skip manual steps
if not manual_steps_completed and not skip_manual_for_existing:
# 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}")
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}")
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)
# All modlists now use their own AppID for wine components
target_appid = self.appid
# Use user's preferred component installation method (respects settings toggle)
self.logger.debug(f"Getting WINEPREFIX for AppID {target_appid}...")
wineprefix = self.protontricks_handler.get_wine_prefix_path(target_appid)
if not wineprefix:
self.logger.error("Failed to get WINEPREFIX path for component installation.")
self.logger.error("Could not determine wine prefix location.")
return False
self.logger.debug(f"WINEPREFIX obtained: {wineprefix}")
# Use the winetricks handler which respects the user's toggle setting
try:
self.logger.info("Installing Wine components using user's preferred method...")
self.logger.debug(f"Calling winetricks_handler.install_wine_components with wineprefix={wineprefix}, game_var={self.game_var_full}, components={components}")
success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components, status_callback=status_callback, appid=str(target_appid) if target_appid else None)
if success:
self.logger.info("Wine component installation completed successfully")
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Wine components verified and installed successfully")
else:
self.logger.error("Wine component installation failed")
self.logger.error("Failed to install necessary Wine components.")
return False
except Exception as e:
self.logger.error(f"Wine component installation failed with exception: {e}")
self.logger.error("Failed to install necessary Wine components.")
return False
self.logger.info("Step 4: Installing Wine components... Done")
# Step 4.5: Apply universal dotnet4.x compatibility registry fixes AFTER wine components
# Apply after components to avoid overwrite
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes")
self.logger.info("Step 4.5: Applying universal dotnet4.x compatibility registry fixes...")
registry_success = False
try:
registry_success = self._apply_universal_dotnet_fixes()
except Exception as e:
error_msg = f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}"
self.logger.error(error_msg)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} ERROR: {error_msg}")
registry_success = False
if not registry_success:
failure_msg = "WARNING: Universal dotnet4.x registry fixes FAILED! This modlist may experience .NET Framework compatibility issues."
self.logger.error("=" * 80)
self.logger.error(failure_msg)
self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.")
self.logger.error("=" * 80)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} {failure_msg}")
# Continue but user should be aware of potential issues
# Step 4.6: Enable dotfiles visibility for Wine prefix
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility")
self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...")
try:
if self.protontricks_handler.enable_dotfiles(self.appid):
self.logger.info("Dotfiles visibility enabled successfully")
else:
self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)")
except Exception as e:
self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)")
self.logger.info("Step 4.6: Enabling dotfiles visibility... Done")
# Step 4.7: Create Wine prefix Documents directories for USVFS
# Critical for USVFS profile INI virtualization on first launch
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...")
try:
if self.appid and self.game_var:
# Map game_var to game_name for create_required_dirs
game_name_map = {
"skyrimspecialedition": "skyrimse",
"fallout4": "fallout4",
"falloutnv": "falloutnv",
"oblivion": "oblivion",
"enderalspecialedition": "enderalse"
}
game_name = game_name_map.get(self.game_var.lower(), None)
if game_name:
appid_str = str(self.appid)
if self.filesystem_handler.create_required_dirs(game_name, appid_str):
self.logger.info("Wine prefix Documents directories created successfully for USVFS")
else:
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
else:
self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping")
else:
self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation")
except Exception as e:
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
# Step 5: Verify ownership of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
self.logger.info("Step 5: Verifying ownership of modlist directory...")
# Convert modlist_dir string to Path object for the method
modlist_path_obj = Path(self.modlist_dir)
success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj)
if not success:
self.logger.error("Ownership verification failed for modlist directory. Configuration aborted.")
print(f"\n{COLOR_ERROR}{error_msg}{COLOR_RESET}")
return False # Abort on failure
self.logger.info("Step 5: Ownership verification... 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.")
self.logger.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 6.5: Handle symlinked downloads directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
self.logger.info("Step 6.5: Checking for symlinked downloads directory...")
if not self._handle_symlinked_downloads():
self.logger.warning("Warning during symlink handling (non-critical)")
self.logger.info("Step 6.5: Checking for symlinked downloads directory... Done")
# Step 7a: Detect Stock Game/Game Root path
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Detecting stock game path")
# Sets self.stock_game_path if found
if not self._detect_stock_game_path():
self.logger.error("Failed during stock game path detection.")
self.logger.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.")
self.logger.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.")
self.logger.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
# Exception: Always run for SD card installs to fix Z:/run/media/... to D:/... paths
# DEBUG: Add comprehensive logging to identify Steam Deck SD card path manipulation issues
engine_installed = getattr(self, 'engine_installed', False)
self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler instance: id={id(self)}")
self.logger.debug(f"[SD_CARD_DEBUG] engine_installed: {engine_installed}")
self.logger.debug(f"[SD_CARD_DEBUG] modlist_sdcard: {self.modlist_sdcard}")
self.logger.debug(f"[SD_CARD_DEBUG] steamdeck parameter passed to constructor: {getattr(self, 'steamdeck', 'NOT_SET')}")
self.logger.debug(f"[SD_CARD_DEBUG] Path manipulation condition: not {engine_installed} or {self.modlist_sdcard} = {not engine_installed or self.modlist_sdcard}")
if not getattr(self, 'engine_installed', False) or self.modlist_sdcard:
# Convert steamapps/common path to library root path
steam_libraries = None
if self.steam_library:
# self.steam_library is steamapps/common, need to go up 2 levels to get library root
steam_library_root = Path(self.steam_library).parent.parent
steam_libraries = [steam_library_root]
self.logger.debug(f"Using Steam library root: {steam_library_root}")
if not self.path_handler.edit_binary_working_paths(
modlist_ini_path=modlist_ini_path_obj,
modlist_dir_path=modlist_dir_path_obj,
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.")
self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini.")
return False # Abort on failure
else:
self.logger.debug("[SD_CARD_DEBUG] Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini")
self.logger.debug(f"[SD_CARD_DEBUG] SKIPPED because: engine_installed={engine_installed} and modlist_sdcard={self.modlist_sdcard}")
if getattr(self, 'download_dir', None):
if self.path_handler.set_download_directory(
modlist_ini_path_obj, str(self.download_dir), self.modlist_sdcard
):
self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)")
else:
self.logger.warning("Could not set download_directory 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)
# Construct vanilla game directory path for fallback
vanilla_game_dir = None
if self.steam_library and self.game_var_full:
vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full)
if not ResolutionHandler.update_ini_resolution(
modlist_dir=self.modlist_dir,
game_var=self.game_var_full,
set_res=self.selected_resolution,
vanilla_game_dir=vanilla_game_dir
):
self.logger.warning("Failed to update resolution settings in some INI files.")
self.logger.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
# Construct vanilla game directory path for fallback
vanilla_game_dir = None
if self.steam_library and self.game_var_full:
vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full)
dxvk_created = 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,
vanilla_game_dir=vanilla_game_dir,
stock_game_path=self.stock_game_path
)
dxvk_verified = self.path_handler.verify_dxvk_conf_exists(
modlist_dir=self.modlist_dir,
steam_library=str(self.steam_library) if self.steam_library else None,
game_var_full=self.game_var_full,
vanilla_game_dir=vanilla_game_dir,
stock_game_path=self.stock_game_path
)
if not dxvk_created or not dxvk_verified:
self.logger.warning("DXVK configuration file is missing or incomplete after post-install steps.")
self.logger.warning("Failed to verify dxvk.conf file (required for AMD GPUs).")
self.logger.info("Step 10: Creating dxvk.conf... Done")
# Step 11a: Small Tasks - Delete Incompatible Plugins
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugins")
self.logger.info("Step 11a: Deleting incompatible MO2 plugins...")
# Delete FixGameRegKey.py plugin
fixgamereg_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
if fixgamereg_path.exists():
try:
fixgamereg_path.unlink()
self.logger.info("FixGameRegKey.py plugin deleted successfully.")
except Exception as e:
self.logger.warning(f"Failed to delete FixGameRegKey.py plugin: {e}")
self.logger.warning("Failed to delete FixGameRegKey.py plugin file.")
else:
self.logger.debug("FixGameRegKey.py plugin not found (this is normal).")
# Delete PageFileManager plugin directory (Linux has no PageFile)
pagefilemgr_path = Path(self.modlist_dir) / "plugins" / "PageFileManager"
if pagefilemgr_path.exists():
try:
import shutil
shutil.rmtree(pagefilemgr_path)
self.logger.info("PageFileManager plugin directory deleted successfully.")
except Exception as e:
self.logger.warning(f"Failed to delete PageFileManager plugin directory: {e}")
self.logger.warning("Failed to delete PageFileManager plugin directory.")
else:
self.logger.debug("PageFileManager plugin not found (this is normal).")
self.logger.info("Step 11a: Incompatible plugin deletion check complete.")
# Step 11b: Download Font
if status_callback:
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 / "pfx" / "drive_c" / "windows" / "Fonts"
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
font_dest_path = fonts_dir / "seguisym.ttf"
# 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}")
self.logger.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.")
self.logger.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...")
# Step 13: Launch options for special games are now set during automated prefix workflow (before Steam restart)
# Avoids 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.")
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
self._re_enforce_windows_10_mode()
return True # Return True on success
def 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 _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.")

View File

@@ -0,0 +1,386 @@
"""Detection and discovery methods for ModlistHandler (Mixin)."""
from pathlib import Path
from typing import Dict, List, Optional
import os
import re
import logging
import subprocess
logger = logging.getLogger(__name__)
class ModlistDetectionMixin:
"""Mixin providing detection and discovery methods for ModlistHandler.
These methods are separated for code organization but require
ModlistHandler's instance attributes (self.logger, self.path_handler, etc.)
"""
def _detect_modlists_from_shortcuts(self) -> bool:
"""
Detect modlists from Steam shortcuts.vdf entries
"""
self.logger.info("Detecting modlists from Steam shortcuts")
return False
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:
# Get shortcuts pointing to the executable from shortcuts.vdf
matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name)
if not matching_vdf_shortcuts:
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.")
return []
self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}")
# Process each matching shortcut and convert signed AppID to unsigned
for vdf_shortcut in matching_vdf_shortcuts:
app_name = vdf_shortcut.get('AppName')
start_dir = vdf_shortcut.get('StartDir')
signed_appid = vdf_shortcut.get('appid')
if not app_name or not start_dir:
self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}")
continue
if signed_appid is None:
self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}")
continue
# Convert signed AppID to unsigned AppID (the format used by Steam prefixes)
if signed_appid < 0:
unsigned_appid = signed_appid + (2**32)
else:
unsigned_appid = signed_appid
# Append dictionary with all necessary info using unsigned AppID
modlist_info = {
'name': app_name,
'appid': unsigned_appid,
'path': start_dir
}
discovered_modlists_info.append(modlist_info)
self.logger.info(f"Discovered shortcut: '{app_name}' (Signed: {signed_appid} -> Unsigned: {unsigned_appid}, Path: {start_dir})")
except Exception as e:
self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True)
return []
if not discovered_modlists_info:
self.logger.warning("No validated shortcuts found after correlation.")
return discovered_modlists_info
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"
}
# Short name lookup
short_name_lookup = {
"Skyrim Special Edition": "Skyrim",
"Fallout 4": "Fallout",
"Fallout New Vegas": "FNV",
"Oblivion": "Oblivion"
}
try:
with open(self.modlist_ini, 'r', encoding='utf-8', errors='ignore') as f:
ini_content = f.read().lower()
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():
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])
return True
else:
self.logger.warning(f"Could not detect game type from ModOrganizer.ini content. Check INI for known loaders (skse64, f4se, nvse, obse).")
self.game_var = "Unknown"
self.game_var_full = "Unknown"
return False
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:
config_vdf_path = self.path_handler.find_steam_config_vdf()
if config_vdf_path and config_vdf_path.exists():
import vdf
with open(config_vdf_path, 'r') as f:
data = vdf.load(f)
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
self.logger.info(f"Detected Proton tool from config.vdf: {self.proton_ver}")
return True
elif tool_name:
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.")
# 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.")
return False
# Method 1: Check system.reg
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()
match = re.search(r'"SteamClientProtonVersion"="([^"]+)"\r?', content)
if match:
version_str = match.group(1).strip()
if version_str:
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
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:
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.")
self.logger.warning(f"Could not detect Proton version for AppID {self.appid} from prefix files.")
return False
def _detect_steam_library_info(self) -> bool:
"""Detects Steam Library path and whether it's on an SD card."""
from .path_handler import PathHandler
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
return False
self.steam_library = steam_lib_path_str
self.logger.info(f"Detected Steam Library: {self.steam_library}")
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")
]
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
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
def _is_steam_deck(self):
"""Detect if running on Steam Deck."""
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 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
try:
mo2_ini = modlist_path / "ModOrganizer.ini"
if not mo2_ini.exists():
somnium_mo2_ini = modlist_path / "files" / "ModOrganizer.ini"
if somnium_mo2_ini.exists():
mo2_ini = somnium_mo2_ini
if mo2_ini.exists():
try:
content = mo2_ini.read_text(errors='ignore').lower()
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"
if 'fose' in content or 'fose_loader' in content or ('fallout 3' in content and 'fallout 4' not in content):
self.logger.info("Detected FO3 via ModOrganizer.ini markers")
return "fo3"
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 and Enderal launchers in common locations
candidates = [modlist_path]
try:
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:
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"
fose_loader = base / "fose_loader.exe"
if fose_loader.exists():
self.logger.info(f"Detected FO3 modlist: found fose_loader.exe in '{base}'")
return "fo3"
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"
# Final heuristic using game_var
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 'fallout 3' in gt or gt == 'fo3':
self.logger.info("Heuristic detection: game_var indicates FO3")
return "fo3"
if 'enderal' in gt:
self.logger.info("Heuristic detection: game_var indicates Enderal")
return "enderal"
except Exception:
pass
self.logger.debug("No special game type detected - standard workflow will be used")
return None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,540 @@
"""Configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from .engine_monitor import EnginePerformanceMonitor, create_stall_alert_callback
from .ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_WARNING,
)
logger = logging.getLogger(__name__)
class ModlistInstallCLIConfigurationMixin:
"""Mixin providing configuration phase methods."""
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
from .modlist_install_cli import get_jackify_engine_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 ---
from jackify.shared.paths import get_jackify_logs_dir
log_dir = get_jackify_logs_dir()
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')
# CRITICAL: Re-check authentication right before launching engine
# Use current auth state, not stale cached context
# (e.g., if user revoked OAuth after context was created)
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
# Use current auth state, fallback to context values only if current check failed
api_key = current_api_key or self.context.get('nexus_api_key')
oauth_info = current_oauth_info or self.context.get('nexus_oauth_info')
# 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'):
self.logger.error("modlist_value is missing in context for GUI workflow!")
return
# --- End Patch ---
# Build command
cmd = [engine_path, 'install', '--show-file-progress']
# 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
from jackify.shared.paths import get_jackify_downloads_dir
cached_wabbajack_path = get_jackify_downloads_dir() / f"{modlist_name}.wabbajack"
self.logger.debug(f"Checking for cached .wabbajack file: {cached_wabbajack_path}")
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
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'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
# Temporarily modify current process's environment
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)")
# Also set NEXUS_API_KEY for backward compatibility
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
# No OAuth info, use API key only (no auto-refresh support)
os.environ['NEXUS_API_KEY'] = api_key
self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)")
else:
# No auth available, clear any inherited values
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
if 'NEXUS_OAUTH_CLIENT_ID' in os.environ:
del os.environ['NEXUS_OAUTH_CLIENT_ID']
self.logger.debug(f"No Nexus auth available, cleared inherited env vars")
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]'}")
self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') 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}")
# Use cleaned environment to prevent AppImage variable inheritance
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, 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''
inline_progress_active = False
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')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
last_progress_time = time.time()
continue
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
clean_line = enhanced_line.rstrip('\r\n')
if clean_line.startswith("Installing files "):
print(f"\r{clean_line}", end='')
sys.stdout.flush()
inline_progress_active = True
else:
if inline_progress_active:
print()
inline_progress_active = False
print(enhanced_line, end='')
buffer = b''
last_progress_time = time.time()
elif chunk == b'\r':
# Carriage return - decode and print without newline
line = buffer.decode('utf-8', errors='replace')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
last_progress_time = time.time()
continue
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
clean_line = enhanced_line.rstrip('\r\n')
if clean_line.startswith("Installing files "):
print(f"\r{clean_line}", end='')
inline_progress_active = True
else:
if inline_progress_active:
print()
inline_progress_active = False
print(enhanced_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')
if inline_progress_active:
print()
inline_progress_active = False
print(line, end='')
if inline_progress_active:
print()
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? "
f"Steam will restart and close any running game.{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}'")
mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe')
from .shortcut_handler import ShortcutHandler
shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False)
shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path)
from ..services.automated_prefix_service import AutomatedPrefixService
prefix_service = AutomatedPrefixService()
def _cli_progress(message):
noisy_patterns = (
"using bundled tools directory",
"bundled tools available",
"checking winetricks dependencies",
"(bundled)",
"(system)",
"wget",
"curl",
"aria2c",
"sha256sum",
"cabextract",
)
message_lc = message.lower()
if any(pattern in message_lc for pattern in noisy_patterns):
self.logger.debug("Automated prefix detail: %s", message)
return
print(f"{COLOR_INFO}{message}{COLOR_RESET}")
try:
_result = prefix_service.run_working_workflow(
shortcut_name, install_dir_str, mo2_exe_path, _cli_progress, steamdeck=self.steamdeck
)
except Exception as _wf_err:
from jackify.shared.errors import JackifyError
if isinstance(_wf_err, JackifyError):
self.logger.error(f"Automated prefix setup failed: {_wf_err.message}")
print(f"{COLOR_ERROR}{_wf_err.message}{COLOR_RESET}")
if _wf_err.suggestion:
print(f"{COLOR_INFO}What to do: {_wf_err.suggestion}{COLOR_RESET}")
else:
self.logger.error(f"Automated prefix setup failed: {_wf_err}")
print(f"{COLOR_ERROR}Automated prefix setup failed. Check logs for details.{COLOR_RESET}")
return
if isinstance(_result, tuple) and len(_result) == 4:
success, _prefix_path, app_id, _last_ts = _result
else:
success, app_id = False, None
if not success:
self.logger.error("Automated prefix setup failed")
print(f"{COLOR_ERROR}Automated prefix setup failed. Check logs for details.{COLOR_RESET}")
return
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': True
}
from .menu_handler import ModlistMenuHandler
from .config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
configuration_success = modlist_menu.run_modlist_configuration_phase(config_context)
if configuration_success:
self.logger.info("Post-installation configuration completed successfully")
# Check for TTW integration eligibility
self._check_and_prompt_ttw_integration(install_dir_str, detected_game, shortcut_name)
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}")

View File

@@ -0,0 +1,451 @@
"""Discovery phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import subprocess
from pathlib import Path
from typing import Optional, Dict
from .config_handler import ConfigHandler
from .ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
COLOR_SELECTION,
)
logger = logging.getLogger(__name__)
class ModlistInstallCLIDiscoveryMixin:
"""Mixin providing discovery phase methods."""
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).")
from .modlist_install_cli import get_jackify_engine_path
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:
self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}")
if not has_modlist:
self.logger.error("Missing modlist_value or machineid for GUI workflow.")
self.logger.error("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):
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 ---
# 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. Get Nexus authentication (OAuth or API key)
if 'nexus_api_key' not in self.context:
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
# Get current auth status
authenticated, method, username = auth_service.get_auth_status()
if authenticated:
# Already authenticated - use existing auth
if method == 'oauth':
print("\n" + "-" * 28)
print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print("\n" + "-" * 28)
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
# Get valid token/key and OAuth state for engine auto-refresh
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
# Auth expired or invalid - prompt to set up
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
authenticated = False
if not authenticated:
# Not authenticated - offer to set up OAuth
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}")
print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}")
print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}")
authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower()
if authorize in ('', 'y', 'yes'):
# Launch OAuth authorization
print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}")
print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}")
print(f"{COLOR_INFO}Note: Your browser may ask permission to open 'xdg-open' or{COLOR_RESET}")
print(f"{COLOR_INFO}Jackify's protocol handler - please click 'Open' or 'Allow'.{COLOR_RESET}")
def show_message(msg):
print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}")
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
if success:
print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}")
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
return None
else:
# User declined OAuth - cancelled
print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}")
self.logger.info("User declined Nexus authorization.")
return None
self.logger.debug(f"Nexus authentication configured for engine.")
# 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

View File

@@ -0,0 +1,144 @@
"""Nexus and engine methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
logger = logging.getLogger(__name__)
class ModlistInstallCLINexusMixin:
"""Mixin providing Nexus API and engine methods."""
def _get_nexus_api_key(self) -> Optional[str]:
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")
"""
from .modlist_install_cli import get_jackify_engine_path
engine_executable = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_executable)
if not os.path.exists(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 []
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 from right to handle modlist names with dashes
# Format: "NAME - GAME - SIZES - MACHINE_URL"
parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts
if len(parts) != 4:
continue # Skip malformed lines
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 _enhance_nexus_error(self, line: str) -> str:
"""
Enhance Nexus download error messages by adding the mod URL for easier troubleshooting.
"""
import re
# Pattern to match Nexus download errors with ModID and FileID
nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):"
match = re.search(nexus_error_pattern, line)
if match:
game_name = match.group(1)
mod_id = match.group(2)
# Map game names to Nexus URL segments
game_url_map = {
'SkyrimSpecialEdition': 'skyrimspecialedition',
'Skyrim': 'skyrim',
'Fallout4': 'fallout4',
'FalloutNewVegas': 'newvegas',
'Oblivion': 'oblivion',
'Starfield': 'starfield'
}
game_url = game_url_map.get(game_name, game_name.lower())
mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}"
# Add URL on next line for easier debugging
return f"{line}\n Nexus URL: {mod_url}"
return line

View File

@@ -0,0 +1,361 @@
"""TTW integration methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import re
import signal
import shutil
from pathlib import Path
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
logger = logging.getLogger(__name__)
def _strip_ansi_control_codes(text: str) -> str:
"""Strip ANSI escape/control sequences from CLI output lines."""
return re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', text or '')
def prompt_ttw_if_eligible(install_dir: str, modlist_name: str) -> None:
"""Standalone TTW prompt usable outside the mixin context (e.g. CLI configure command).
Detects game type from ModOrganizer.ini, resolves the best available modlist name,
checks whitelist eligibility, and runs the interactive TTW prompt if applicable.
"""
try:
# Detect game type from ModOrganizer.ini
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
game_type = "skyrim"
if mo2_ini.exists():
content = mo2_ini.read_text(encoding="utf-8", errors="ignore").lower()
if "nvse_loader.exe" in content or "fallout new vegas" in content:
game_type = "falloutnv"
elif "fose_loader.exe" in content or "fallout 3" in content:
game_type = "fallout3"
if game_type not in ("falloutnv", "fallout_new_vegas"):
return
# Best available name: meta file, then selected_profile, then caller-supplied name
from jackify.backend.utils.modlist_meta import get_modlist_name
identified_name = get_modlist_name(install_dir) or modlist_name
if not identified_name:
return
class _Adapter(ModlistInstallCLITTWMixin):
def __init__(self):
self.logger = logging.getLogger(__name__)
self.verbose = False
self.filesystem_handler = None
self.config_handler = None
_Adapter()._check_and_prompt_ttw_integration(install_dir, game_type, identified_name)
except Exception as e:
logger.error("TTW post-configure check failed: %s", e, exc_info=True)
class ModlistInstallCLITTWMixin:
"""Mixin providing TTW integration methods."""
def _check_and_prompt_ttw_integration(self, install_dir: str, game_type: str, modlist_name: str):
"""Check if modlist is eligible for TTW integration and prompt user"""
try:
# Check eligibility: FNV game, TTW-compatible modlist, no existing TTW
if not self._is_ttw_eligible(install_dir, game_type, modlist_name):
return
# Prompt user for TTW installation
print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}")
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).")
print(f"TTW combines Fallout 3 and New Vegas into a single game.")
print(f"\nWould you like to install TTW now?")
# Some CLI entrypoint signal handlers currently call sys.exit(), which can interrupt
# this prompt unexpectedly. Temporarily convert SIGINT/SIGTERM to KeyboardInterrupt
# and keep prompting so users can answer explicitly.
original_sigint = signal.getsignal(signal.SIGINT)
original_sigterm = signal.getsignal(signal.SIGTERM)
def _prompt_signal_handler(signum, frame):
raise KeyboardInterrupt
try:
signal.signal(signal.SIGINT, _prompt_signal_handler)
signal.signal(signal.SIGTERM, _prompt_signal_handler)
while True:
try:
user_input = input(f"{COLOR_PROMPT}Install TTW now? (Y/n): {COLOR_RESET}").strip().lower()
except KeyboardInterrupt:
print(f"\n{COLOR_WARNING}TTW prompt interrupted. Please type yes or no.{COLOR_RESET}")
continue
except EOFError:
print(f"\n{COLOR_WARNING}No input available. Skipping TTW installation.{COLOR_RESET}")
return
if user_input == "":
user_input = "y"
if user_input in ['yes', 'y', 'no', 'n']:
break
print(f"{COLOR_WARNING}Please answer yes or no.{COLOR_RESET}")
finally:
signal.signal(signal.SIGINT, original_sigint)
signal.signal(signal.SIGTERM, original_sigterm)
if user_input in ['yes', 'y']:
self._launch_ttw_installation(modlist_name, install_dir)
else:
print(f"{COLOR_INFO}Skipping TTW installation. You can install it later from the main menu.{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW eligibility check: {e}", exc_info=True)
def _is_ttw_eligible(self, install_dir: str, game_type: str, modlist_name: str) -> bool:
"""Check if modlist is eligible for TTW integration"""
try:
from pathlib import Path
# Check 1: Must be Fallout New Vegas
if not game_type or game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
return False
# Check 2: Must be on TTW compatibility whitelist
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
if not is_ttw_compatible(modlist_name):
return False
# Check 3: TTW must not already be installed
if self._detect_existing_ttw(install_dir):
self.logger.info(f"TTW already installed in {install_dir}, skipping prompt")
return False
return True
except Exception as e:
self.logger.error(f"Error checking TTW eligibility: {e}")
return False
def _detect_existing_ttw(self, install_dir: str) -> bool:
"""Detect if TTW is already installed in the modlist"""
try:
from pathlib import Path
install_path = Path(install_dir)
# Search for TTW indicators in common locations
search_paths = [
install_path,
install_path / "mods",
install_path / "Stock Game",
install_path / "Game Root"
]
for search_path in search_paths:
if not search_path.exists():
continue
# Look for folders containing "tale" and "two" and "wastelands"
for folder in search_path.iterdir():
if not folder.is_dir():
continue
folder_name_lower = folder.name.lower()
if all(keyword in folder_name_lower for keyword in ['tale', 'two', 'wastelands']):
# Verify it has the TTW ESM file
for file in folder.rglob('*.esm'):
if 'taleoftwowastelands' in file.name.lower():
self.logger.info(f"Found existing TTW installation: {file}")
return True
return False
except Exception as e:
self.logger.error(f"Error detecting existing TTW: {e}")
return False
def _launch_ttw_installation(self, modlist_name: str, install_dir: str):
"""Launch TTW installation workflow"""
try:
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
# Import TTW installation handler
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.services.platform_detection_service import PlatformDetectionService
from pathlib import Path
is_steamdeck = bool(getattr(self, 'steamdeck', False))
if not is_steamdeck:
try:
is_steamdeck = PlatformDetectionService.get_instance().is_steamdeck
except Exception:
is_steamdeck = False
filesystem_handler = getattr(self, 'filesystem_handler', None) or FileSystemHandler()
config_handler = getattr(self, 'config_handler', None) or ConfigHandler()
ttw_installer_handler = TTWInstallerHandler(
steamdeck=is_steamdeck,
verbose=self.verbose if hasattr(self, 'verbose') else False,
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Check if TTW_Linux_Installer is installed
ttw_installer_handler._check_installation()
if not ttw_installer_handler.ttw_installer_installed:
print(f"{COLOR_INFO}TTW_Linux_Installer is not installed.{COLOR_RESET}")
user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (Y/n): {COLOR_RESET}").strip().lower()
if user_input == "":
user_input = "y"
if user_input not in ['yes', 'y']:
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
return
# Install TTW_Linux_Installer
print(f"{COLOR_INFO}Installing TTW_Linux_Installer...{COLOR_RESET}")
success, message = ttw_installer_handler.install_ttw_installer()
if not success:
print(f"{COLOR_ERROR}Failed to install TTW_Linux_Installer: {message}{COLOR_RESET}")
return
print(f"{COLOR_INFO}TTW_Linux_Installer installed successfully.{COLOR_RESET}")
# Prompt for TTW .mpi file
print(f"\n{COLOR_PROMPT}TTW Installer File (.mpi){COLOR_RESET}")
mpi_path = input(f"{COLOR_PROMPT}Path to TTW .mpi file: {COLOR_RESET}").strip()
if not mpi_path:
print(f"{COLOR_WARNING}No .mpi file specified. Cancelling.{COLOR_RESET}")
return
mpi_path = Path(mpi_path).expanduser()
if not mpi_path.exists() or not mpi_path.is_file():
print(f"{COLOR_ERROR}TTW .mpi file not found: {mpi_path}{COLOR_RESET}")
return
# Prompt for TTW installation directory
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
default_ttw_dir = os.path.join(install_dir, 'mods', '[NoDelete] Tale of Two Wastelands')
print(f"Default: {default_ttw_dir}")
ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip()
if not ttw_install_dir:
ttw_install_dir = default_ttw_dir
# Run TTW installation
print(f"\n{COLOR_INFO}Installing TTW using TTW_Linux_Installer...{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}")
phase_state = {"current": "Processing", "last_rendered": ""}
progress_line_active = {"value": False}
def _ttw_output_callback(line: str):
clean = _strip_ansi_control_codes(line or "").strip()
if not clean:
return
lower = clean.lower()
rendered = ""
# Match GUI behavior: explicit Loading manifest counter line
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower)
if manifest_match:
current = int(manifest_match.group(1))
total = int(manifest_match.group(2))
phase_state["current"] = "Loading manifest"
percent = int((current / total) * 100) if total > 0 else 0
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
else:
# Match GUI behavior: generic [X/Y] counters with current phase name.
progress_match = re.search(r'\[(\d+)/(\d+)\]', clean)
if progress_match:
current = int(progress_match.group(1))
total = int(progress_match.group(2))
percent = int((current / total) * 100) if total > 0 else 0
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
else:
# Update phase state from milestone-like lines, then echo milestones.
if 'manifest' in lower:
phase_state["current"] = "Loading manifest"
elif any(token in lower for token in ('extract', 'decompress', 'installing', 'copying', 'merge')):
phase_state["current"] = clean
is_milestone = any(token in lower for token in ('===', 'complete', 'finished', 'starting', 'valid'))
is_error = 'error:' in lower
is_warning = 'warning:' in lower
if is_milestone or is_error or is_warning:
rendered = f"[TTW] {clean}"
if not rendered or rendered == phase_state["last_rendered"]:
return
phase_state["last_rendered"] = rendered
if rendered.startswith("[TTW] Loading manifest:") or re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered):
# In-place progress updates for counters/phases.
print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True)
progress_line_active["value"] = True
else:
# Non-progress milestones/errors get normal line output.
if progress_line_active["value"]:
print()
progress_line_active["value"] = False
print(f"{COLOR_INFO}{rendered}{COLOR_RESET}")
success, message = ttw_installer_handler.install_ttw_backend_with_output_stream(
Path(mpi_path),
Path(ttw_install_dir),
output_callback=_ttw_output_callback,
)
if progress_line_active["value"]:
print()
if success:
ttw_output_path = Path(ttw_install_dir)
ttw_version = ""
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', Path(mpi_path).stem, re.IGNORECASE)
if version_match:
ttw_version = version_match.group(1)
skip_copy = False
mods_dir = Path(install_dir) / "mods"
if ttw_output_path.parent == mods_dir:
versioned_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}".strip() if ttw_version else "[NoDelete] Tale of Two Wastelands"
versioned_path = mods_dir / versioned_name
if ttw_output_path != versioned_path and ttw_output_path.exists():
if versioned_path.exists():
shutil.rmtree(versioned_path)
ttw_output_path.rename(versioned_path)
ttw_output_path = versioned_path
skip_copy = True
print(f"\n{COLOR_INFO}Integrating TTW into modlist load order...{COLOR_RESET}")
integration_success = TTWInstallerHandler.integrate_ttw_into_modlist(
ttw_output_path=ttw_output_path,
modlist_install_dir=Path(install_dir),
ttw_version=ttw_version,
skip_copy=skip_copy,
)
if not integration_success:
print(f"{COLOR_ERROR}TTW installed, but integration into modlist failed.{COLOR_RESET}")
print(f"{COLOR_ERROR}Please check TTW_Install_workflow.log for details.{COLOR_RESET}")
return
print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"{COLOR_INFO}TTW Installation Complete!{COLOR_RESET}")
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"\nTTW has been installed to: {ttw_output_path}")
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
else:
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")

View File

@@ -0,0 +1,546 @@
"""Wine/Proton operation methods for ModlistHandler (Mixin)."""
from pathlib import Path
from typing import Tuple, Optional, List
import os
import logging
import subprocess
import shutil
import time
import vdf
import json
import configparser
logger = logging.getLogger(__name__)
class ModlistWineOpsMixin:
"""Mixin providing Wine and Proton operation methods for ModlistHandler."""
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 ---
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:
# Found compatdata but no 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 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"),
("grid-tall.png", f"{appid}p.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 or "enderal" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
extras += ["d3dx9_43", "d3dx9"]
else:
# Unknown game type — install the union of all known component sets
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "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 _re_enforce_windows_10_mode(self):
"""
Re-enforce Windows 10 mode after modlist-specific configurations.
This matches the legacy script behavior (line 1333) where Windows 10 mode
is re-applied after modlist-specific steps to ensure consistency.
"""
try:
if not hasattr(self, 'appid') or not self.appid:
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
return
from ..handlers.winetricks_handler import WinetricksHandler
from ..handlers.path_handler import PathHandler
# Get prefix path for the AppID
prefix_path = PathHandler.find_compat_data(str(self.appid))
if not prefix_path:
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
return
# Use winetricks handler to set Windows 10 mode
winetricks_handler = WinetricksHandler()
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
if not wine_binary:
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
return
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations")
except Exception as e:
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
def _handle_symlinked_downloads(self) -> bool:
"""
Check if downloads_directory in ModOrganizer.ini points to a symlink.
If it does, comment out the line to force MO2 to use default behavior.
Returns:
bool: True on success or no action needed, False on error
"""
try:
if not self.modlist_ini or not os.path.exists(self.modlist_ini):
self.logger.warning("ModOrganizer.ini not found for symlink check")
return True # Non-critical
# Read the INI file
# Allow duplicate sections/keys since some ModOrganizer.ini variants repeat [General]
# Latest occurrence wins, which matches how we only need the final downloads_directory value.
config = configparser.ConfigParser(allow_no_value=True, delimiters=['='], strict=False)
config.optionxform = str # Preserve case sensitivity
try:
# Read file manually to handle BOM
with open(self.modlist_ini, 'r', encoding='utf-8-sig') as f:
config.read_file(f)
except UnicodeDecodeError:
with open(self.modlist_ini, 'r', encoding='latin-1') as f:
config.read_file(f)
# Check if downloads_directory or download_directory exists and is a symlink
downloads_key = None
downloads_path = None
if 'General' in config:
# Check for both possible key names
if 'downloads_directory' in config['General']:
downloads_key = 'downloads_directory'
downloads_path = config['General']['downloads_directory']
elif 'download_directory' in config['General']:
downloads_key = 'download_directory'
downloads_path = config['General']['download_directory']
if downloads_path:
if downloads_path and os.path.exists(downloads_path):
# Check if the path or any parent directory contains symlinks
def has_symlink_in_path(path):
"""Check if path or any parent directory is a symlink"""
current_path = Path(path).resolve()
check_path = Path(path)
# Walk up the path checking each component
for parent in [check_path] + list(check_path.parents):
if parent.is_symlink():
return True, str(parent)
return False, None
has_symlink, symlink_path = has_symlink_in_path(downloads_path)
if has_symlink:
self.logger.info(f"Detected symlink in downloads directory path: {symlink_path} -> {downloads_path}")
self.logger.info("Commenting out downloads_directory to avoid Wine symlink issues")
# Read the file manually to preserve comments and formatting
with open(self.modlist_ini, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Find and comment out the downloads directory line
modified = False
for i, line in enumerate(lines):
if line.strip().startswith(f'{downloads_key}='):
lines[i] = '#' + line # Comment out the line
modified = True
break
if modified:
# Write the modified file back
with open(self.modlist_ini, 'w', encoding='utf-8') as f:
f.writelines(lines)
self.logger.info(f"{downloads_key} line commented out successfully")
else:
self.logger.warning("downloads_directory line not found in file")
else:
self.logger.debug(f"downloads_directory is not a symlink: {downloads_path}")
else:
self.logger.debug("downloads_directory path does not exist or is empty")
else:
self.logger.debug("No downloads_directory found in ModOrganizer.ini")
return True
except Exception as e:
self.logger.error(f"Error handling symlinked downloads: {e}", exc_info=True)
return False
def _apply_universal_dotnet_fixes(self):
"""
Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
Now called AFTER wine component installation to prevent overwrites.
Includes wineserver shutdown/flush to ensure persistence.
"""
try:
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
if not os.path.exists(prefix_path):
self.logger.warning(f"Prefix path not found: {prefix_path}")
return False
self.logger.info("Applying universal dotnet4.x compatibility registry fixes (post-component installation)...")
# Find the appropriate Wine binary to use for registry operations
wine_binary = self._find_wine_binary_for_registry()
if not wine_binary:
self.logger.error("Could not find Wine binary for registry operations")
return False
# Find wineserver binary for flushing registry changes
wine_dir = os.path.dirname(wine_binary)
wineserver_binary = os.path.join(wine_dir, 'wineserver')
if not os.path.exists(wineserver_binary):
self.logger.warning(f"wineserver not found at {wineserver_binary}, registry flush may not work")
wineserver_binary = None
# Set environment for Wine registry operations
env = os.environ.copy()
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Shutdown any running wineserver processes to ensure clean slate
if wineserver_binary:
self.logger.debug("Shutting down wineserver before applying registry fixes...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Wineserver shutdown complete")
except Exception as e:
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
# Use native .NET runtime instead of Wine's
self.logger.debug("Setting *mscoree=native DLL override...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
self.logger.info(f"*mscoree registry command result: returncode={result1.returncode}, stdout={result1.stdout[:200]}, stderr={result1.stderr[:200]}")
if result1.returncode == 0:
self.logger.info("Successfully applied *mscoree=native DLL override")
else:
self.logger.error(f"Failed to set *mscoree DLL override: returncode={result1.returncode}, stderr={result1.stderr}")
# Registry fix 2: Set OnlyUseLatestCLR=1
# Use latest CLR to avoid .NET version conflicts
self.logger.debug("Setting OnlyUseLatestCLR=1 registry entry...")
cmd2 = [
wine_binary, 'reg', 'add',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
self.logger.info(f"OnlyUseLatestCLR registry command result: returncode={result2.returncode}, stdout={result2.stdout[:200]}, stderr={result2.stderr[:200]}")
if result2.returncode == 0:
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}")
# Force wineserver to flush registry changes to disk
if wineserver_binary:
self.logger.debug("Flushing registry changes to disk via wineserver shutdown...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Registry changes flushed to disk")
except Exception as e:
self.logger.warning(f"Registry flush failed (non-critical): {e}")
# VERIFICATION: Confirm the registry entries persisted
self.logger.info("Verifying registry entries were applied and persisted...")
verification_passed = True
# Verify *mscoree=native
verify_cmd1 = [
wine_binary, 'reg', 'query',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree'
]
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
self.logger.info("VERIFIED: *mscoree=native is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}")
verification_passed = False
# Verify OnlyUseLatestCLR=1
verify_cmd2 = [
wine_binary, 'reg', 'query',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR'
]
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
verification_passed = False
# Both fixes applied and verified
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
return True
else:
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
return False
except Exception as e:
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False
def _find_wine_binary_for_registry(self) -> Optional[str]:
"""Find wine binary from Install Proton path"""
try:
# Use Install Proton from config (used by jackify-engine)
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
proton_path = config_handler.get_proton_path()
if proton_path:
proton_path = Path(proton_path).expanduser()
# Check both GE-Proton and Valve Proton structures
wine_candidates = [
proton_path / "files" / "bin" / "wine", # GE-Proton
proton_path / "dist" / "bin" / "wine" # Valve Proton
]
for wine_bin in wine_candidates:
if wine_bin.exists() and wine_bin.is_file():
return str(wine_bin)
# Fallback: use best detected Proton
from ..handlers.wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if wine_binary:
return wine_binary
return None
except Exception as e:
self.logger.error(f"Error finding Wine binary: {e}")
return None
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
"""
Recursively search for wine binary within a Proton directory.
This handles cases where the directory structure might differ between Proton versions.
Args:
proton_path: Path to the Proton directory to search
Returns:
Path to wine binary if found, None otherwise
"""
try:
if not proton_path.exists() or not proton_path.is_dir():
return None
# Search for 'wine' executable (not 'wine64' or 'wine-preloader')
# Limit search depth to avoid scanning entire filesystem
max_depth = 5
for root, dirs, files in os.walk(proton_path, followlinks=False):
# Calculate depth relative to proton_path
depth = len(Path(root).relative_to(proton_path).parts)
if depth > max_depth:
dirs.clear() # Don't descend further
continue
# Check if 'wine' is in this directory
if 'wine' in files:
wine_path = Path(root) / 'wine'
# Verify it's actually an executable file
if wine_path.is_file() and os.access(wine_path, os.X_OK):
self.logger.debug(f"Found wine binary at: {wine_path}")
return str(wine_path)
return None
except Exception as e:
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None

View File

@@ -0,0 +1,442 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OAuth Token Handler
Handles encrypted storage and retrieval of OAuth tokens
"""
import os
import json
import base64
import hashlib
import logging
import time
from typing import Optional, Dict
from pathlib import Path
logger = logging.getLogger(__name__)
class OAuthTokenHandler:
"""
Handles OAuth token storage with simple encryption
Stores tokens in ~/.config/jackify/nexus-oauth.json
"""
def __init__(self, config_dir: Optional[str] = None):
"""
Initialize token handler
Args:
config_dir: Optional custom config directory (defaults to ~/.config/jackify)
"""
if config_dir:
self.config_dir = Path(config_dir)
else:
self.config_dir = Path.home() / ".config" / "jackify"
self.token_file = self.config_dir / "nexus-oauth.json"
# Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True)
# Generate encryption key based on machine-specific data
self._encryption_key = self._generate_encryption_key()
def _generate_encryption_key(self) -> bytes:
"""
Generate encryption key based on machine-specific data using Fernet
Uses hostname + username + machine ID as key material, similar to DPAPI approach.
This provides proper symmetric encryption while remaining machine-specific.
Returns:
Fernet-compatible 32-byte encryption key
"""
import socket
import getpass
try:
hostname = socket.gethostname()
username = getpass.getuser()
# Try to get machine ID for additional entropy
machine_id = None
try:
# Linux machine-id
with open('/etc/machine-id', 'r') as f:
machine_id = f.read().strip()
except (OSError, IOError):
try:
# Alternative locations
with open('/var/lib/dbus/machine-id', 'r') as f:
machine_id = f.read().strip()
except (OSError, IOError):
pass
# Combine multiple sources of machine-specific data
if machine_id:
key_material = f"{hostname}:{username}:{machine_id}:jackify"
else:
key_material = f"{hostname}:{username}:jackify"
except Exception as e:
logger.warning(f"Failed to get machine info for encryption: {e}")
key_material = "jackify:default:key"
# Generate 32-byte key using SHA256 for Fernet
# Fernet requires base64-encoded 32-byte key
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
return base64.urlsafe_b64encode(key_bytes)
def _encrypt_data(self, data: str) -> str:
"""
Encrypt data using AES-GCM (authenticated encryption)
Uses pycryptodome for cross-platform compatibility.
AES-GCM provides authenticated encryption similar to Fernet.
Args:
data: Plain text data
Returns:
Encrypted data as base64 string (nonce:ciphertext:tag format)
"""
try:
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
# Derive 32-byte AES key from encryption_key (which is base64-encoded)
key = base64.urlsafe_b64decode(self._encryption_key)
# Generate random nonce (12 bytes for GCM)
nonce = get_random_bytes(12)
# Create AES-GCM cipher
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
# Encrypt and get authentication tag
data_bytes = data.encode('utf-8')
ciphertext, tag = cipher.encrypt_and_digest(data_bytes)
# Combine nonce:ciphertext:tag and base64 encode
combined = nonce + ciphertext + tag
return base64.b64encode(combined).decode('utf-8')
except ImportError:
logger.error("pycryptodome package not available for token encryption")
return ""
except Exception as e:
logger.error(f"Failed to encrypt data: {e}")
return ""
def _decrypt_data(self, encrypted_data: str) -> Optional[str]:
"""
Decrypt data using AES-GCM (authenticated encryption)
Args:
encrypted_data: Encrypted data string (base64-encoded nonce:ciphertext:tag)
Returns:
Decrypted plain text or None on failure
"""
try:
from Crypto.Cipher import AES
# Check if MODE_GCM is available (pycryptodome has it, old pycrypto doesn't)
if not hasattr(AES, 'MODE_GCM'):
logger.error("pycryptodome required for token decryption (pycrypto doesn't support MODE_GCM)")
return None
# Derive 32-byte AES key from encryption_key
key = base64.urlsafe_b64decode(self._encryption_key)
# Decode base64 and split nonce:ciphertext:tag
combined = base64.b64decode(encrypted_data.encode('utf-8'))
nonce = combined[:12]
tag = combined[-16:]
ciphertext = combined[12:-16]
# Create AES-GCM cipher
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
# Decrypt and verify authentication tag
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
return plaintext.decode('utf-8')
except ImportError:
logger.error("pycryptodome package not available for token decryption")
return None
except AttributeError:
logger.error("pycryptodome required for token decryption (pycrypto doesn't support MODE_GCM)")
return None
except Exception as e:
logger.error(f"Failed to decrypt data: {e}")
return None
def save_token(self, token_data: Dict) -> bool:
"""
Save OAuth token to encrypted file with proper permissions
Args:
token_data: Token data dict from OAuth response
Returns:
True if saved successfully
"""
try:
# Add timestamp for tracking
token_data['_saved_at'] = int(time.time())
# Convert to JSON
json_data = json.dumps(token_data, indent=2)
# Encrypt using Fernet
encrypted = self._encrypt_data(json_data)
if not encrypted:
logger.error("Encryption failed, cannot save token")
return False
# Save to file with restricted permissions
# Write to temp file first, then move (atomic operation)
import tempfile
fd, temp_path = tempfile.mkstemp(dir=self.config_dir, prefix='.oauth_tmp_')
try:
with os.fdopen(fd, 'w') as f:
json.dump({'encrypted_data': encrypted}, f, indent=2)
# Set restrictive permissions (owner read/write only)
os.chmod(temp_path, 0o600)
# Atomic move
os.replace(temp_path, self.token_file)
logger.info(f"Saved encrypted OAuth token to {self.token_file}")
return True
except Exception as e:
# Clean up temp file on error
try:
os.unlink(temp_path)
except (OSError, IOError):
pass
raise e
except Exception as e:
logger.error(f"Failed to save OAuth token: {e}")
return False
def load_token(self) -> Optional[Dict]:
"""
Load OAuth token from encrypted file
Returns:
Token data dict or None if not found or invalid
"""
if not self.token_file.exists():
logger.debug("No OAuth token file found")
return None
try:
# Load encrypted data
with open(self.token_file, 'r') as f:
data = json.load(f)
encrypted = data.get('encrypted_data')
if not encrypted:
logger.error("Token file missing encrypted_data field")
return None
# Decrypt
decrypted = self._decrypt_data(encrypted)
if not decrypted:
logger.error("Failed to decrypt token data")
return None
# Parse JSON
token_data = json.loads(decrypted)
logger.debug("Successfully loaded OAuth token")
return token_data
except json.JSONDecodeError as e:
logger.error(f"Token file contains invalid JSON: {e}")
return None
except Exception as e:
logger.error(f"Failed to load OAuth token: {e}")
return None
def delete_token(self) -> bool:
"""
Delete OAuth token file
Returns:
True if deleted successfully
"""
try:
if self.token_file.exists():
self.token_file.unlink()
logger.info("Deleted OAuth token file")
return True
else:
logger.debug("No OAuth token file to delete")
return False
except Exception as e:
logger.error(f"Failed to delete OAuth token: {e}")
return False
def has_token(self) -> bool:
"""
Check if OAuth token file exists
Returns:
True if token file exists
"""
return self.token_file.exists()
def is_token_expired(self, token_data: Optional[Dict] = None, buffer_minutes: int = 5) -> bool:
"""
Check if token is expired or close to expiring
Args:
token_data: Optional token data dict (loads from file if not provided)
buffer_minutes: Minutes before expiry to consider token expired (default 5)
Returns:
True if token is expired or will expire within buffer_minutes
"""
if token_data is None:
token_data = self.load_token()
if not token_data:
return True
# Extract OAuth data if nested
oauth_data = token_data.get('oauth', token_data)
# Get expiry information
expires_in = oauth_data.get('expires_in')
saved_at = token_data.get('_saved_at')
if not expires_in or not saved_at:
logger.debug("Token missing expiry information, assuming valid")
return False # Assume token is valid if no expiry info
# Calculate expiry time
expires_at = saved_at + expires_in
buffer_seconds = buffer_minutes * 60
now = int(time.time())
# Check if expired or within buffer
is_expired = (expires_at - buffer_seconds) < now
if is_expired:
remaining = expires_at - now
if remaining < 0:
logger.debug(f"Token expired {-remaining} seconds ago")
else:
logger.debug(f"Token expires in {remaining} seconds (within buffer)")
return is_expired
def get_access_token(self) -> Optional[str]:
"""
Get access token from storage
Returns:
Access token string or None if not found or expired
"""
token_data = self.load_token()
if not token_data:
return None
# Check if expired
if self.is_token_expired(token_data):
logger.debug("Stored token is expired")
return None
# Extract access token from OAuth structure
oauth_data = token_data.get('oauth', token_data)
access_token = oauth_data.get('access_token')
if not access_token:
logger.error("Token data missing access_token field")
return None
return access_token
def get_refresh_token(self) -> Optional[str]:
"""
Get refresh token from storage
Returns:
Refresh token string or None if not found
"""
token_data = self.load_token()
if not token_data:
return None
# Extract refresh token from OAuth structure
oauth_data = token_data.get('oauth', token_data)
refresh_token = oauth_data.get('refresh_token')
return refresh_token
def get_token_info(self) -> Dict:
"""
Get diagnostic information about current token
Returns:
Dict with token status information
"""
token_data = self.load_token()
if not token_data:
return {
'has_token': False,
'error': 'No token file found'
}
oauth_data = token_data.get('oauth', token_data)
expires_in = oauth_data.get('expires_in')
saved_at = token_data.get('_saved_at')
# Check if refresh token is likely expired (30 days since last auth)
# Nexus doesn't provide refresh token expiry, so we estimate conservatively
REFRESH_TOKEN_LIFETIME_DAYS = 30
now = int(time.time())
refresh_token_age_days = (now - saved_at) / 86400 if saved_at else 0
refresh_token_likely_expired = refresh_token_age_days > REFRESH_TOKEN_LIFETIME_DAYS
if expires_in and saved_at:
expires_at = saved_at + expires_in
remaining_seconds = expires_at - now
return {
'has_token': True,
'has_refresh_token': bool(oauth_data.get('refresh_token')),
'expires_in_seconds': remaining_seconds,
'expires_in_minutes': remaining_seconds / 60,
'expires_in_hours': remaining_seconds / 3600,
'is_expired': remaining_seconds < 0,
'expires_soon_5min': remaining_seconds < 300,
'expires_soon_15min': remaining_seconds < 900,
'saved_at': saved_at,
'expires_at': expires_at,
'refresh_token_age_days': refresh_token_age_days,
'refresh_token_likely_expired': refresh_token_likely_expired,
}
else:
return {
'has_token': True,
'has_refresh_token': bool(oauth_data.get('refresh_token')),
'refresh_token_age_days': refresh_token_age_days,
'refresh_token_likely_expired': refresh_token_likely_expired,
'error': 'Token missing expiry information'
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DXVK config mixin for PathHandler.
Extracted from path_handler for file-size and domain separation.
"""
import os
import logging
from pathlib import Path
from typing import Optional, List
logger = logging.getLogger(__name__)
class PathHandlerDXVKMixin:
"""Mixin providing DXVK config creation and verification."""
@staticmethod
def _normalize_common_library_path(steam_library: Optional[str]) -> Optional[Path]:
if not steam_library:
return None
path = Path(steam_library)
parts_lower = [part.lower() for part in path.parts]
if len(parts_lower) >= 2 and parts_lower[-2:] == ['steamapps', 'common']:
return path
if parts_lower and parts_lower[-1] == 'common':
return path
if 'steamapps' in parts_lower:
idx = parts_lower.index('steamapps')
truncated = Path(*path.parts[:idx + 1])
return truncated / 'common'
return path / 'steamapps' / 'common'
@staticmethod
def _build_dxvk_candidate_dirs(modlist_dir, stock_game_path, steam_library, game_var_full, vanilla_game_dir) -> List[Path]:
candidates: List[Path] = []
seen = set()
def add_candidate(path_obj: Optional[Path]):
if not path_obj:
return
key = path_obj.resolve() if path_obj.exists() else path_obj
if key in seen:
return
seen.add(key)
candidates.append(path_obj)
if stock_game_path:
add_candidate(Path(stock_game_path))
if modlist_dir:
base_path = Path(modlist_dir)
common_names = [
"Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder",
"Stock Folder", "Skyrim Stock", os.path.join("root", "Skyrim Special Edition")
]
for name in common_names:
add_candidate(base_path / name)
steam_common = PathHandlerDXVKMixin._normalize_common_library_path(steam_library)
if steam_common and game_var_full:
add_candidate(steam_common / game_var_full)
if vanilla_game_dir:
add_candidate(Path(vanilla_game_dir))
if modlist_dir:
add_candidate(Path(modlist_dir))
return candidates
@staticmethod
def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full,
vanilla_game_dir=None, stock_game_path=None) -> bool:
"""Create dxvk.conf file in the appropriate location."""
try:
logger.info("Creating dxvk.conf file...")
candidate_dirs = PathHandlerDXVKMixin._build_dxvk_candidate_dirs(
modlist_dir=modlist_dir, stock_game_path=stock_game_path, steam_library=steam_library,
game_var_full=game_var_full, vanilla_game_dir=vanilla_game_dir
)
if not candidate_dirs:
logger.error("Could not determine location for dxvk.conf (no candidate directories found)")
return False
target_dir = None
for directory in candidate_dirs:
if directory.is_dir():
target_dir = directory
break
if target_dir is None:
fallback_dir = Path(modlist_dir) if modlist_dir and Path(modlist_dir).is_dir() else None
if fallback_dir:
logger.warning(f"No stock/vanilla directories found; falling back to modlist directory: {fallback_dir}")
target_dir = fallback_dir
else:
logger.error("All candidate directories for dxvk.conf are missing.")
return False
dxvk_conf_path = target_dir / "dxvk.conf"
required_line = "dxvk.enableGraphicsPipelineLibrary = False"
if dxvk_conf_path.exists():
try:
with open(dxvk_conf_path, 'r', encoding='utf-8') as f:
existing_content = f.read().strip()
existing_lines = existing_content.split('\n') if existing_content else []
has_required_line = any(line.strip() == required_line for line in existing_lines)
if has_required_line:
logger.info("Required DXVK setting already present in existing file")
return True
updated_content = existing_content + '\n' + required_line + '\n' if existing_content else required_line + '\n'
with open(dxvk_conf_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
logger.info(f"dxvk.conf updated successfully at {dxvk_conf_path}")
return True
except Exception as e:
logger.error(f"Error reading/updating existing dxvk.conf: {e}")
logger.info("Falling back to creating new dxvk.conf file")
dxvk_conf_content = required_line + '\n'
dxvk_conf_path.parent.mkdir(parents=True, exist_ok=True)
with open(dxvk_conf_path, 'w', encoding='utf-8') 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 verify_dxvk_conf_exists(modlist_dir, steam_library, game_var_full, vanilla_game_dir=None,
stock_game_path=None) -> bool:
"""Verify that dxvk.conf exists in at least one candidate directory and contains the required setting."""
required_line = "dxvk.enableGraphicsPipelineLibrary = False"
candidate_dirs = PathHandlerDXVKMixin._build_dxvk_candidate_dirs(
modlist_dir=modlist_dir, stock_game_path=stock_game_path, steam_library=steam_library,
game_var_full=game_var_full, vanilla_game_dir=vanilla_game_dir
)
for directory in candidate_dirs:
conf_path = directory / "dxvk.conf"
if conf_path.is_file():
try:
with open(conf_path, 'r', encoding='utf-8') as f:
content = f.read()
if required_line not in content:
logger.warning(f"dxvk.conf found at {conf_path} but missing required setting. Appending now.")
with open(conf_path, 'a', encoding='utf-8') as f:
if not content.endswith('\n'):
f.write('\n')
f.write(required_line + '\n')
logger.info(f"Verified dxvk.conf at {conf_path}")
return True
except Exception as e:
logger.warning(f"Failed to verify dxvk.conf at {conf_path}: {e}")
logger.warning("dxvk.conf verification failed - file not found in any candidate directory.")
return False

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Game path and compatdata mixin for PathHandler.
Extracted from path_handler for file-size and domain separation.
"""
import os
import re
import logging
from pathlib import Path
from typing import Optional, List, Dict
logger = logging.getLogger(__name__)
class PathHandlerGameMixin:
"""Mixin providing game install path and compatdata discovery."""
@classmethod
def find_compat_data(cls, 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
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}")
library_paths = cls.get_all_steam_library_paths()
if library_paths:
logger.debug(f"Checking compatdata in {len(library_paths)} Steam libraries")
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
logger.debug(f"Compatdata for AppID {appid} not found in {compatdata_base}")
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in library_paths) if library_paths else False
if not library_paths or is_flatpak_steam:
logger.debug("Checking fallback compatdata locations...")
if is_flatpak_steam:
fallback_locations = [
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata",
Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata",
]
else:
fallback_locations = [
Path.home() / ".local/share/Steam/steamapps/compatdata",
Path.home() / ".steam/steam/steamapps/compatdata",
]
for compatdata_base in fallback_locations:
if compatdata_base.is_dir():
potential_path = compatdata_base / appid
if potential_path.is_dir():
logger.warning(f"Found compatdata directory in fallback location: {potential_path}")
return potential_path
logger.warning(f"Compatdata directory for AppID {appid} not found in any Steam library or fallback location.")
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."""
try:
game_app_ids = {
'skyrim': '489830', 'fallout4': '377160', 'fnv': '22380', 'oblivion': '22330'
}
if game_type not in game_app_ids:
return None
app_id = game_app_ids[game_type]
game_path = steam_library / 'steamapps' / 'common'
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
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
@classmethod
def find_game_install_paths(cls, target_appids: Dict[str, str]) -> Dict[str, Path]:
"""Find installation paths for multiple specified games using Steam app IDs."""
library_paths = cls.get_all_steam_library_paths()
if not library_paths:
logger.warning("Failed to find any Steam library paths")
return {}
results = {}
for library_path in library_paths:
common_dir = library_path / "steamapps" / "common"
if not common_dir.is_dir():
logger.debug(f"No 'steamapps/common' directory in library: {library_path}")
continue
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 game_name, app_id in target_appids.items():
if game_name in results:
continue
appmanifest_path = library_path / "steamapps" / f"appmanifest_{app_id}.acf"
if appmanifest_path.is_file():
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
@classmethod
def find_vanilla_game_paths(cls, game_names=None) -> Dict[str, Path]:
"""For each known game, iterate all Steam libraries and look for the canonical game directory in steamapps/common."""
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 = cls.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
if game in found_games:
break
return found_games
def _detect_stock_game_path(self) -> bool:
"""Detects common Stock Game or Game Root directories within the modlist path. Expects self.logger, self.modlist_dir, self.stock_game_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)
preferred_order = [
"Stock Game", "STOCK GAME", "Skyrim Stock", "Stock Game Folder",
"Stock Folder", Path("root/Skyrim Special Edition"), "Game Root"
]
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
if found_path:
self.stock_game_path = found_path
return True
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

View File

@@ -0,0 +1,492 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MO2 INI and path formatting mixin for PathHandler.
Extracted from path_handler for file-size and domain separation.
"""
import os
import re
import shutil
import logging
from pathlib import Path
from typing import Optional, List
from datetime import datetime
from .wine_utils import WineUtils
logger = logging.getLogger(__name__)
TARGET_EXECUTABLES_LOWER = [
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe",
"sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"
]
STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"]
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
class PathHandlerMO2Mixin:
"""Mixin providing ModOrganizer.ini path updates and formatting."""
@staticmethod
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
"""Removes SD card mount prefix. Returns path as POSIX-style string."""
path_str = path_obj.as_posix()
stripped_path = WineUtils._strip_sdcard_path(path_str)
if stripped_path != path_str:
return stripped_path.lstrip('/') if stripped_path != '/' else '.'
return path_str
@classmethod
def update_mo2_ini_paths(
cls,
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
) -> bool:
"""Update gamePath, binary, and workingDirectory in ModOrganizer.ini."""
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}")
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')
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}")
all_steam_libraries = cls.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):
logger.debug(f"Detected Steam libraries: {all_steam_libraries}")
GAME_DIR_NAMES = {
"Skyrim Special Edition": "Skyrim Special Edition",
"Fallout 4": "Fallout 4",
"Fallout New Vegas": "Fallout New Vegas",
"Oblivion": "Oblivion"
}
canonical_name = GAME_DIR_NAMES.get(basegame_dir_name, basegame_dir_name) if basegame_dir_name else None
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}")
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
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
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()
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 = PathHandlerMO2Mixin._strip_sdcard_path_prefix(gamepath_target_dir)
windows_style_single = processed_str.replace('/', '\\')
gamepath_drive_letter = "D:" if gamepath_target_is_sdcard else "Z:"
formatted_gamepath = PathHandlerMO2Mixin._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)
TARGET_EXEC_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_EXEC_LOWER:
new_path = f'{gamepath_drive_letter}/{PathHandlerMO2Mixin._strip_sdcard_path_prefix(gamepath_target_dir)}/{exe_name}'
new_path = PathHandlerMO2Mixin._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}'
new_wd = PathHandlerMO2Mixin._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_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) -> bool:
"""Edit resolution settings in ModOrganizer.ini. resolution format: '1920x1080'."""
try:
logger.info(f"Editing resolution settings to {resolution}...")
width, height = resolution.split('x')
with open(modlist_ini, 'r') as f:
content = f.read()
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)
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
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."""
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):
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("gamePath line not found in ModOrganizer.ini. Aborting.")
return False
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info("gamePath updated successfully")
return True
except Exception as e:
logger.error(f"Error replacing gamePath: {e}")
return False
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 ModOrganizer.ini. Critical, regression-prone."""
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()
existing_game_path = None
gamepath_drive_letter = None
gamepath_line_index = -1
for i, line in enumerate(lines):
if re.match(r'^\s*gamepath\s*=.*@ByteArray\(([^)]+)\)', line, re.IGNORECASE):
match = re.search(r'@ByteArray\(([^)]+)\)', line)
if match:
raw_path = match.group(1)
gamepath_line_index = i
if raw_path.startswith('Z:'):
gamepath_drive_letter = 'Z:'
elif raw_path.startswith('D:'):
gamepath_drive_letter = 'D:'
if raw_path.startswith(('Z:', 'D:')):
linux_path = raw_path[2:].replace('\\\\', '/').replace('\\', '/')
existing_game_path = linux_path
logger.debug(f"Extracted existing gamePath: {existing_game_path}, drive letter: {gamepath_drive_letter}")
break
if modlist_sdcard and existing_game_path and existing_game_path.startswith('/run/media') and gamepath_line_index != -1:
sdcard_pattern = r'^/run/media/deck/[^/]+(/Games/.*)$'
match = re.match(sdcard_pattern, existing_game_path)
if match:
stripped_path = match.group(1)
windows_path = stripped_path.replace('/', '\\\\')
new_gamepath_value = f"D:\\\\{windows_path}"
new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n"
logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}")
lines[gamepath_line_index] = new_gamepath_line
else:
logger.warning(f"SD card path doesn't match expected pattern: {existing_game_path}")
game_path_updated = False
binary_paths_updated = 0
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:
binary_lines.append((i, stripped, binary_match.group(1), binary_match.group(2)))
wd_match = re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
if wd_match:
working_dir_lines.append((i, stripped, wd_match.group(1), wd_match.group(2)))
binary_paths_by_index = {}
if existing_game_path and '/steamapps/common/' in existing_game_path:
steamapps_index = existing_game_path.find('/steamapps/common/')
steam_lib_root = existing_game_path[:steamapps_index]
steam_libraries = [Path(steam_lib_root)]
logger.info(f"Using Steam library from existing gamePath: {steam_lib_root}")
elif steam_libraries is None or not steam_libraries:
steam_libraries = self.get_all_steam_library_paths()
logger.debug(f"Fallback to detected Steam libraries: {steam_libraries}")
for i, line, index, backslash_style in binary_lines:
parts = line.split('=', 1)
if len(parts) != 2:
logger.error(f"Malformed binary line: {line}")
continue
key_part, value_part = parts
cleaned_value = PathHandlerMO2Mixin._clean_malformed_binary_path(value_part)
exe_name = os.path.basename(cleaned_value).lower()
if exe_name not in TARGET_EXECUTABLES_LOWER:
logger.debug(f"Skipping non-target executable: {exe_name}")
continue
rel_path = None
if 'steamapps' in cleaned_value:
if not gamepath_drive_letter:
logger.warning("Vanilla game path detected but gamePath drive letter not found. Skipping binary path update.")
continue
is_malformed = '"' in cleaned_value or cleaned_value != value_part.strip().strip('"')
idx = cleaned_value.index('steamapps')
subpath = cleaned_value[idx:].lstrip('/')
correct_steam_lib = None
for lib in steam_libraries:
if len(subpath.split('/')) > 3 and (lib / subpath.split('/')[2] / subpath.split('/')[3]).exists():
correct_steam_lib = lib
break
if not correct_steam_lib and steam_libraries:
correct_steam_lib = steam_libraries[0]
if correct_steam_lib:
drive_prefix = gamepath_drive_letter
if is_malformed:
logger.info(f"Fixing malformed binary path for {exe_name}: {value_part.strip()}")
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:
drive_prefix = "D:" if modlist_sdcard else "Z:"
found_stock = None
for folder in STOCK_GAME_FOLDERS:
folder_pattern = f"/{folder}"
if folder_pattern in cleaned_value:
idx = cleaned_value.index(folder_pattern)
rel_path = cleaned_value[idx:].lstrip('/')
found_stock = folder
break
if not rel_path:
if "/mods/" in cleaned_value:
idx = cleaned_value.index("/mods/")
rel_path = cleaned_value[idx:].lstrip('/')
else:
rel_path = exe_name
processed_modlist_path = self._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
formatted_binary_path = PathHandlerMO2Mixin._format_binary_for_mo2(new_binary_path)
if '"' in formatted_binary_path:
formatted_binary_path = formatted_binary_path.replace('"', '')
new_binary_line = f"{index}{backslash_style}binary = {formatted_binary_path}"
logger.info(f"Updating binary path: {line.strip()} -> {new_binary_line}")
original_line = lines[i]
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 binary_path.startswith("D:") else "Z:" if binary_path.startswith("Z:") else ("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 = PathHandlerMO2Mixin._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}")
original_wd_line = lines[j]
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)."""
formatted = path.replace('/', '\\')
if not re.match(r'^[A-Za-z]:', formatted):
formatted = 'D:' + formatted
formatted = formatted.replace('\\', '\\\\')
return formatted
def _format_binary_path_for_mo2(self, path_str) -> str:
"""Format a binary path for MO2 config file. Binary paths need forward slashes."""
return path_str.replace('\\', '/')
def _format_working_dir_for_mo2(self, path_str) -> str:
"""Format a working directory path for MO2 config file. Ensures double backslashes."""
path = path_str.replace('/', '\\')
path = path.replace('\\', '\\\\')
path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path)
return path
@staticmethod
def _format_gamepath_for_mo2(path: str) -> str:
path = path.replace('/', '\\')
path = re.sub(r'\\+', r'\\', path)
path = re.sub(r'^([A-Z]:)\\+', r'\1\\', path)
return path
@staticmethod
def _clean_malformed_binary_path(value_part: str) -> str:
"""Clean up malformed binary paths from engine (e.g., quotes in wrong places)."""
cleaned = value_part.strip()
if cleaned.startswith('"') and '"' in cleaned[1:]:
quote_end = cleaned.find('"', 1)
if quote_end > 0:
after_quote = cleaned[quote_end + 1:].strip()
if after_quote.startswith('/') or after_quote:
path_part = cleaned[1:quote_end]
remaining = after_quote.lstrip('/')
cleaned = f"{path_part}/{remaining}" if remaining else path_part
logger.info(f"Cleaned malformed binary path: {value_part} -> {cleaned}")
cleaned = cleaned.strip('"')
cleaned = cleaned.replace('\\', '/')
return cleaned
@staticmethod
def _format_binary_for_mo2(path: str) -> str:
path = path.replace('\\', '/')
path = re.sub(r'^([A-Z]:)//+', r'\1/', path)
return path
@staticmethod
def _format_workingdir_for_mo2(path: str) -> str:
path = path.replace('/', '\\')
path = path.replace('\\', '\\\\')
path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path)
return path
def set_download_directory(self, modlist_ini_path: Path, download_dir_linux_path, modlist_sdcard: bool) -> bool:
"""
Set download_directory in ModOrganizer.ini to the correct Wine path (Z: or D: for SD card).
Use only when download dir is known (e.g. Install a Modlist flow). Configure New/Existing leave as-is.
"""
if not modlist_ini_path.is_file() or not download_dir_linux_path:
return False
try:
path_obj = Path(download_dir_linux_path)
if modlist_sdcard:
drive = "D:"
path_part = self._strip_sdcard_path_prefix(path_obj)
if path_part.startswith('/'):
path_part = path_part[1:]
path_part = path_part.replace('/', '\\')
else:
drive = "Z:"
path_part = str(path_obj).replace('/', '\\').lstrip('\\')
wine_path = drive + "\\" + path_part
formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path)
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
in_general = False
download_line_idx = -1
for i, line in enumerate(lines):
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
in_general = True
continue
if in_general and re.match(r'^\s*\[', line):
break
if in_general and re.match(r'^\s*download_directory\s*=', line, re.IGNORECASE):
download_line_idx = i
break
new_line = f"download_directory = {formatted}\n"
if download_line_idx >= 0:
lines[download_line_idx] = new_line
else:
if in_general:
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
if insert_idx >= 0:
insert_idx += 1
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
insert_idx += 1
lines.insert(insert_idx, new_line)
else:
lines.append("[General]\n")
lines.append(new_line)
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted}")
return True
except Exception as e:
logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}")
return False

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Steam path and library mixin for PathHandler.
Extracted from path_handler for file-size and domain separation.
"""
import os
import re
import logging
from pathlib import Path
from typing import Optional, List
from datetime import datetime
import vdf
logger = logging.getLogger(__name__)
class PathHandlerSteamMixin:
"""Mixin providing Steam config, library, and shortcuts path discovery."""
@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
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...")
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"),
]
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}")
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}")
libraryfolders_vdf_path_obj = None
found_path_str = None
for path_str in libraryfolders_vdf_paths:
if os.path.exists(path_str):
found_path_str = path_str
libraryfolders_vdf_path_obj = Path(path_str)
logger.debug(f"Found libraryfolders.vdf at: {path_str}")
break
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
library_paths = []
try:
with open(found_path_str, 'r') as f:
content = f.read()
path_matches = re.finditer(r'"path"\s*"([^"]+)"', content)
for match in path_matches:
library_path_str = match.group(1).replace('\\\\', '\\')
common_path = os.path.join(library_path_str, "steamapps", "common")
if os.path.isdir(common_path):
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.")
if library_paths:
logger.info(f"Using Steam library common path: {library_paths[0]}")
return library_paths[0]
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 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()
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
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_mountpoint(path) -> Optional[str]:
"""Return the mount point for the given path (Linux). Used for STEAM_COMPAT_MOUNTS."""
if not path:
return None
try:
p = Path(path).resolve()
if not p.exists():
p = p.parent
while p != p.parent:
if os.path.ismount(p):
return str(p)
p = p.parent
return str(p)
except (OSError, RuntimeError) as e:
logger.debug(f"Could not get mountpoint for {path}: {e}")
return None
def get_steam_compat_mount_paths(self, install_dir=None, download_dir=None) -> List[str]:
"""
Build list of mount paths for STEAM_COMPAT_MOUNTS: other Steam library roots plus
mountpoints of install_dir and download_dir so MO2 can access game and downloads.
"""
seen = set()
result = []
main_steam_lib_path_obj = self.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
main_resolved = str(main_steam_lib_path.resolve()) if main_steam_lib_path else None
for lib_path in self.get_all_steam_library_paths():
try:
r = str(lib_path.resolve())
except (OSError, RuntimeError):
r = str(lib_path)
if r not in seen and r != main_resolved:
seen.add(r)
result.append(r)
for extra in (install_dir, download_dir):
mp = self.get_mountpoint(extra) if extra else None
if mp and mp not in seen:
seen.add(mp)
result.append(mp)
return result
@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",
]
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, 'r', encoding='utf-8') as f:
data = vdf.load(f)
libraryfolders = data.get('libraryfolders', {})
for key, lib_data in libraryfolders.items():
if isinstance(lib_data, dict) and 'path' in lib_data:
lib_path = Path(lib_data['path'])
try:
resolved_path = lib_path.resolve()
library_paths.add(resolved_path)
logger.debug(f"[DEBUG] Found library path: {resolved_path}")
except (OSError, RuntimeError) as resolve_err:
logger.warning(f"[DEBUG] Could not resolve {lib_path}, using as-is: {resolve_err}")
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)
def _find_shortcuts_vdf(self) -> Optional[str]:
"""Helper to find the active shortcuts.vdf file for the current Steam user."""
try:
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
shortcuts_path = steam_service.get_shortcuts_vdf_path()
if shortcuts_path:
logger.info(f"Found shortcuts.vdf using multi-user detection: {shortcuts_path}")
return str(shortcuts_path)
logger.error("Could not determine shortcuts.vdf path using multi-user detection")
return None
except Exception as e:
logger.error(f"Error using multi-user detection for shortcuts.vdf: {e}")
return None

View File

@@ -0,0 +1,440 @@
"""
Progress Parser
Parses jackify-engine text output to extract structured progress information.
This is an R&D implementation - experimental and subject to change.
"""
import os
import re
from typing import Optional, Tuple
from dataclasses import dataclass
from jackify.shared.progress_models import (
InstallationProgress,
InstallationPhase,
FileProgress,
OperationType
)
from .progress_parser_phase import ProgressParserPhaseMixin
from .progress_parser_files import ProgressParserFilesMixin
from .progress_parser_extraction import ProgressParserExtractionMixin
from .progress_state_processing import ProgressStateProcessingMixin
from .progress_state_metrics import ProgressStateMetricsMixin
@dataclass
class ParsedLine:
"""Result of parsing a single line of output."""
has_progress: bool = False
phase: Optional[InstallationPhase] = None
phase_name: Optional[str] = None
file_progress: Optional[FileProgress] = None
completed_filename: Optional[str] = None # Filename that just completed
overall_percent: Optional[float] = None
step_info: Optional[Tuple[int, int]] = None # (current, total)
data_info: Optional[Tuple[int, int]] = None # (current_bytes, total_bytes)
speed_info: Optional[Tuple[str, float]] = None # (operation, speed_bytes_per_sec)
file_counter: Optional[Tuple[int, int]] = None # (current_file, total_files) for Extracting phase
message: str = ""
class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, ProgressParserExtractionMixin):
"""
Parses jackify-engine output to extract progress information.
This parser uses pattern matching to extract:
- Installation phases
- File-level progress
- Overall progress percentages
- Step counts
- Data sizes
- Operation speeds
"""
def __init__(self):
"""Initialize parser with pattern definitions."""
# Phase detection patterns
self.phase_patterns = [
(r'===?\s*(.+?)\s*===?', self._extract_phase_from_section),
(r'\[.*?\]\s*(?:Installing|Downloading|Extracting|Validating|Processing)', self._extract_phase_from_action),
(r'(?:Starting|Beginning)\s+(.+?)(?:\s+phase|\.|$)', re.IGNORECASE),
]
# File progress patterns
self.file_patterns = [
# Pattern: "Installing: filename.7z (42%)"
(r'(?:Installing|Downloading|Extracting|Validating):\s*(.+?)\s*\((\d+(?:\.\d+)?)%\)', self._parse_file_with_percent),
# Pattern: "filename.7z: 42%"
(r'(.+?\.(?:7z|zip|rar|bsa|dds)):\s*(\d+(?:\.\d+)?)%', self._parse_file_with_percent),
# Pattern: "filename.7z [45.2MB/s]"
(r'(.+?\.(?:7z|zip|rar|bsa|dds))\s*\[([^\]]+)\]', self._parse_file_with_speed),
]
# Overall progress patterns (stored as regex patterns, not tuples with callbacks)
# Wabbajack format: "[12/14] Installing files (1.1GB/56.3GB)"
self.overall_patterns = [
# Pattern: "Progress: 85%" or "85%"
(r'(?:Progress|Overall):\s*(\d+(?:\.\d+)?)%', re.IGNORECASE),
(r'^(\d+(?:\.\d+)?)%\s*(?:complete|done|progress)', re.IGNORECASE),
]
# Wabbajack status update format: "[12/14] StatusText (current/total)"
# Primary format
self.wabbajack_status_pattern = re.compile(
r'\[(\d+)/(\d+)\]\s+(.+?)\s+\(([^)]+)\)',
re.IGNORECASE
)
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
# Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining"
# Timestamp prefix is now optional — engine no longer emits [HH:MM:SS].
self.timestamp_status_pattern = re.compile(
r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
re.IGNORECASE
)
# Data size patterns
self.data_patterns = [
# Pattern: "1.1GB/56.3GB" or "(1.1GB/56.3GB)"
(r'\(?(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\)?', re.IGNORECASE),
# Pattern: "Processing 1.1GB of 56.3GB"
(r'Processing\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s+of\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', re.IGNORECASE),
]
# Speed patterns
self.speed_patterns = [
# Pattern: "267.3MB/s" or "45.2 MB/s"
(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', re.IGNORECASE),
# Pattern: "at 267.3MB/s" or "speed: 45.2 MB/s"
(r'(?:at|speed:?)\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', re.IGNORECASE),
]
# File filter - only display meaningful artifacts in the UI
self.allowed_extensions = {
'.7z', '.zip', '.rar', '.bsa', '.ba2', '.dds', '.wabbajack',
'.exe', '.esp', '.esm', '.esl', '.bin', '.dll', '.pak',
'.tar', '.gz', '.xz', '.bz2', '.z01', '.z02', '.cab', '.msi'
}
def should_display_file(self, filename: str) -> bool:
"""Public helper so other components can reuse the filter."""
return self._should_display_file(filename)
def _should_display_file(self, filename: str) -> bool:
"""Determine whether a filename is worth showing in the UI."""
if not filename:
return False
base = os.path.basename(filename.strip())
if not base:
return False
# Special case: allow ".wabbajack" and "Downloading .wabbajack file"
if base == ".wabbajack" or base == "Downloading .wabbajack file":
return True
# Skip temporary/generated files (e.g., #zcbe$123.txt)
if base.startswith('#'):
return False
name, ext = os.path.splitext(base)
if not ext:
return False
if ext.lower() not in self.allowed_extensions:
return False
# Also skip generic filenames that are clearly tooling artifacts
if name.lower() in {'empty', 'script', 'one', 'two', 'three'}:
return False
return True
def parse_line(self, line: str) -> ParsedLine:
"""
Parse a single line of output and extract progress information.
Args:
line: Raw line from jackify-engine output
Returns:
ParsedLine with extracted information
"""
result = ParsedLine(message=line.strip())
if not line.strip():
return result
# Try to extract phase information
phase_info = self._extract_phase(line)
if phase_info:
result.phase, result.phase_name = phase_info
result.has_progress = True
# Try to extract file progress
file_prog = self._extract_file_progress(line)
if file_prog:
result.file_progress = file_prog
result.has_progress = True
# Check if file counter was attached (for extraction or install phases)
if hasattr(file_prog, '_file_counter'):
result.file_counter = file_prog._file_counter
delattr(file_prog, '_file_counter') # Clean up temp attribute
# Try to extract overall progress
overall = self._extract_overall_progress(line)
if overall is not None:
result.overall_percent = overall
result.has_progress = True
# Try to extract Wabbajack status format first: "[12/14] StatusText (1.1GB/56.3GB)"
# BUT skip if this is a .wabbajack download line (handled by specific pattern below)
wabbajack_match = self.wabbajack_status_pattern.search(line)
if wabbajack_match:
status_text = wabbajack_match.group(3).strip().lower()
# Skip if this is a .wabbajack download - let the specific pattern handle it
if '.wabbajack' in status_text or 'downloading .wabbajack' in status_text:
# Don't process this as generic status - let .wabbajack pattern handle it
pass
else:
# Extract step info
current_step = int(wabbajack_match.group(1))
max_steps = int(wabbajack_match.group(2))
result.step_info = (current_step, max_steps)
# Extract status text (phase name)
phase_info = self._extract_phase_from_text(status_text)
if phase_info:
result.phase, result.phase_name = phase_info
# Extract data info from parentheses
data_str = wabbajack_match.group(4).strip()
data_info = self._parse_data_string(data_str)
if data_info:
result.data_info = data_info
result.has_progress = True
# Try alternative format: "[timestamp] StatusText (current/total) - speed"
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
timestamp_match = self.timestamp_status_pattern.search(line)
if timestamp_match:
# Extract status text (phase name)
status_text = timestamp_match.group(1).strip()
phase_info = self._extract_phase_from_text(status_text)
if phase_info:
result.phase, result.phase_name = phase_info
# Extract step info (current/total in parentheses)
current_step = int(timestamp_match.group(2))
max_steps = int(timestamp_match.group(3))
result.step_info = (current_step, max_steps)
# Extract speed
speed_str = timestamp_match.group(4).strip()
speed_info = self._parse_speed_from_string(speed_str)
if speed_info:
operation = self._detect_operation_from_line(status_text)
result.speed_info = (operation.value, speed_info)
# Extract remaining size if present (engine 0.4.8+: "- 23.1GB remaining")
remaining_val = timestamp_match.group(5)
remaining_unit = timestamp_match.group(6)
if remaining_val and remaining_unit:
remaining_bytes = self._convert_to_bytes(float(remaining_val), remaining_unit)
if remaining_bytes > 0 and max_steps > 0 and current_step < max_steps:
fraction_done = current_step / max_steps
# Estimate total from remaining and fraction; clamp denominator to avoid div/0 near completion
estimated_total = remaining_bytes / max(1.0 - fraction_done, 0.01)
data_processed = int(estimated_total - remaining_bytes)
result.data_info = (max(0, data_processed), int(estimated_total))
elif remaining_bytes > 0:
result.data_info = (0, int(remaining_bytes))
# Calculate overall percentage from step progress
if max_steps > 0:
result.overall_percent = (current_step / max_steps) * 100.0
result.has_progress = True
# Try .wabbajack download format: "[timestamp] Downloading .wabbajack (size/size) - speed"
# Example: "[00:02:08] Downloading .wabbajack (739.2/1947.2MB) - 6.0MB/s"
# Also handles: "[00:02:08] Downloading modlist.wabbajack (739.2/1947.2MB) - 6.0MB/s"
# Timestamp prefix is optional in newer engine output.
wabbajack_download_pattern = re.compile(
r'(?:\[[^\]]+\]\s+)?Downloading\s+([^\s]+\.wabbajack|\.wabbajack)\s+\(([^)]+)\)\s*-\s*([^\s]+)',
re.IGNORECASE
)
wabbajack_match = wabbajack_download_pattern.search(line)
if wabbajack_match:
# Extract filename (group 1)
filename = wabbajack_match.group(1).strip()
if filename == ".wabbajack":
# Try to extract actual filename from message if available
filename_match = re.search(r'([A-Za-z0-9_\-\.]+\.wabbajack)', line, re.IGNORECASE)
if filename_match:
filename = filename_match.group(1)
else:
# Use display message as filename
filename = "Downloading .wabbajack file"
# Extract data info from parentheses (e.g., "49.7/1947.2MB" or "739.2MB/1947.2MB")
# Format can be: "current/totalUnit" or "currentUnit/totalUnit"
data_str = wabbajack_match.group(2).strip()
data_info = None
# Try standard format first (both have units)
data_info = self._extract_data_info(f"({data_str})")
# If that fails, try format where only second number has unit: "49.7/1947.2MB"
if not data_info:
pattern = r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)'
match = re.search(pattern, data_str, re.IGNORECASE)
if match:
current_val = float(match.group(1))
current_unit = match.group(2) if match.group(2) else match.group(4) # Use second unit if first missing
total_val = float(match.group(3))
total_unit = match.group(4)
current_bytes = self._convert_to_bytes(current_val, current_unit)
total_bytes = self._convert_to_bytes(total_val, total_unit)
data_info = (current_bytes, total_bytes)
if data_info:
result.data_info = data_info
# Calculate percent from data
current_bytes, total_bytes = data_info
if total_bytes > 0:
result.overall_percent = (current_bytes / total_bytes) * 100.0
# Extract speed (group 3)
speed_str = wabbajack_match.group(3).strip()
speed_info = self._parse_speed_from_string(speed_str)
if speed_info:
result.speed_info = ("download", speed_info)
# Set phase
result.phase = InstallationPhase.DOWNLOAD
phase_target = filename
if phase_target.lower().startswith("downloading "):
phase_target = phase_target[len("downloading "):].strip()
result.phase_name = f"Downloading {phase_target}"
# Create FileProgress entry for .wabbajack file
if data_info:
current_bytes, total_bytes = data_info
percent = (current_bytes / total_bytes) * 100.0 if total_bytes > 0 else 0.0
file_progress = FileProgress(
filename=filename,
operation=OperationType.DOWNLOAD,
percent=percent,
current_size=current_bytes,
total_size=total_bytes,
speed=speed_info if speed_info else -1.0
)
result.file_progress = file_progress
result.has_progress = True
# Try to extract install progress format:
# "Installing files X/Y (GB/GB) - Converting textures: N/M"
install_match = re.match(
r'Installing files\s+(\d+)/(\d+)\s+\(([^)]+)\)(?:\s*-\s*Converting textures:\s*(\d+)/(\d+))?',
line.strip(), re.IGNORECASE)
if install_match:
result.phase = InstallationPhase.INSTALL
result.step_info = (int(install_match.group(1)), int(install_match.group(2)))
data_info = self._parse_data_string(install_match.group(3))
if data_info:
result.data_info = data_info
current_bytes, total_bytes = data_info
if total_bytes > 0:
result.overall_percent = (current_bytes / total_bytes) * 100.0
if install_match.group(4) and install_match.group(5):
fp = FileProgress(
filename='_tex',
operation=OperationType.INSTALL,
percent=0.0,
speed=-1.0
)
fp._texture_counter = (int(install_match.group(4)), int(install_match.group(5)))
fp._hidden = True
result.file_progress = fp
result.has_progress = True
# Conversion-only status line (without "Installing files ...")
conversion_match = re.search(r'Converting textures:\s*(\d+)/(\d+)', line, re.IGNORECASE)
if conversion_match and not install_match:
if not result.phase:
result.phase = InstallationPhase.INSTALL
if not result.phase_name:
result.phase_name = "Converting textures"
fp = FileProgress(
filename='_tex',
operation=OperationType.INSTALL,
percent=0.0,
speed=-1.0
)
fp._texture_counter = (int(conversion_match.group(1)), int(conversion_match.group(2)))
fp._hidden = True
result.file_progress = fp
result.has_progress = True
# Try to extract step information (fallback)
if not result.step_info:
step_info = self._extract_step_info(line)
if step_info:
result.step_info = step_info
result.has_progress = True
# Try to extract data size information (fallback)
if not result.data_info:
data_info = self._extract_data_info(line)
if data_info:
result.data_info = data_info
result.has_progress = True
# Try to extract speed information
speed_info = self._extract_speed_info(line)
if speed_info:
result.speed_info = speed_info
result.has_progress = True
# Try to detect file completion
completed_file = self._extract_completed_file(line)
if completed_file:
result.completed_filename = completed_file
result.has_progress = True
return result
class ProgressStateManager(ProgressStateProcessingMixin, ProgressStateMetricsMixin):
"""
Manages installation progress state by accumulating parsed information.
This class maintains the current state of installation progress and
updates it as new lines are parsed.
"""
def __init__(self):
"""Initialize state manager."""
self.state = InstallationProgress()
self.parser = ProgressParser()
self._file_history = {}
self._wabbajack_entry_name = None
self._synthetic_flag = "_synthetic_wabbajack"
self._previous_phase = None # Track phase changes to reset stale data
# Track total download size from all files seen during download phase
self._download_files_seen = {} # filename -> (total_size, max_current_size)
self._download_total_bytes = 0 # Running total of all file sizes seen
self._download_processed_bytes = 0 # Running total of bytes processed
self._has_real_wabbajack = False
def get_state(self) -> InstallationProgress:
"""Get current progress state."""
return self.state
def reset(self):
"""Reset progress state."""
self.state = InstallationProgress()
self._file_history = {}
self._wabbajack_entry_name = None
self._synthetic_flag = "_synthetic_wabbajack"
self._has_real_wabbajack = False

View File

@@ -0,0 +1,51 @@
"""
Example usage of ProgressParser
This file demonstrates how to use the progress parser to extract
structured information from jackify-engine output.
R&D NOTE: This is experimental code for investigation purposes.
"""
from jackify.backend.handlers.progress_parser import ProgressStateManager
def example_usage():
"""Example of how to use the progress parser."""
# Create state manager
state_manager = ProgressStateManager()
# Simulate processing lines from jackify-engine output
sample_lines = [
"[00:00:00] === Installing files ===",
"[00:00:05] [12/14] Installing files (1.1GB/56.3GB)",
"[00:00:10] Installing: Enderal Remastered Armory.7z (42%)",
"[00:00:15] Extracting: Mandragora Sprouts.7z (96%)",
"[00:00:20] Downloading at 45.2MB/s",
"[00:00:25] Extracting at 267.3MB/s",
"[00:00:30] Progress: 85%",
]
print("Processing sample output lines...\n")
for line in sample_lines:
updated = state_manager.process_line(line)
if updated:
state = state_manager.get_state()
print(f"Line: {line}")
print(f" Phase: {state.phase.value} - {state.phase_name}")
print(f" Progress: {state.overall_percent:.1f}%")
print(f" Step: {state.phase_progress_text}")
print(f" Data: {state.data_progress_text}")
print(f" Active Files: {len(state.active_files)}")
for file_prog in state.active_files:
print(f" - {file_prog.filename}: {file_prog.percent:.1f}%")
print(f" Speeds: {state.speeds}")
print(f" Display: {state.display_text}")
print()
if __name__ == "__main__":
example_usage()

View File

@@ -0,0 +1,149 @@
"""Progress/speed extraction methods for ProgressParser (Mixin)."""
import logging
import re
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
class ProgressParserExtractionMixin:
"""Mixin providing progress and speed extraction methods."""
def _extract_overall_progress(self, line: str) -> Optional[float]:
"""Extract overall progress percentage."""
match = re.search(r'(?:Progress|Overall):\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE)
if match:
return float(match.group(1))
match = re.search(r'^(\d+(?:\.\d+)?)%\s*(?:complete|done|progress)', line, re.IGNORECASE)
if match:
return float(match.group(1))
return None
def _extract_step_info(self, line: str) -> Optional[Tuple[int, int]]:
"""Extract step information like [12/14]."""
line_lower = line.lower()
# Texture conversion counters are tracked separately; don't let generic
# step parsing overwrite the primary install counter.
if 'converting textures' in line_lower and 'installing files' not in line_lower:
return None
match = self.wabbajack_status_pattern.search(line)
if match:
current = int(match.group(1))
total = int(match.group(2))
return (current, total)
match = re.search(r'\[(\d+)/(\d+)\]', line)
if match:
current = int(match.group(1))
total = int(match.group(2))
return (current, total)
return None
def _extract_data_info(self, line: str) -> Optional[Tuple[int, int]]:
"""Extract data size information like 1.1GB/56.3GB."""
match = re.search(r'\(?(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\)?', line, re.IGNORECASE)
if match:
current_val = float(match.group(1))
current_unit = match.group(2).upper()
total_val = float(match.group(3))
total_unit = match.group(4).upper()
current_bytes = self._convert_to_bytes(current_val, current_unit)
total_bytes = self._convert_to_bytes(total_val, total_unit)
return (current_bytes, total_bytes)
return None
def _parse_data_string(self, data_str: str) -> Optional[Tuple[int, int]]:
"""Parse data string like '1.1GB/56.3GB' or '1234/5678'."""
match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', data_str, re.IGNORECASE)
if match:
current_val = float(match.group(1))
current_unit = match.group(2).upper()
total_val = float(match.group(3))
total_unit = match.group(4).upper()
current_bytes = self._convert_to_bytes(current_val, current_unit)
total_bytes = self._convert_to_bytes(total_val, total_unit)
return (current_bytes, total_bytes)
match = re.search(r'(\d+)\s*/\s*(\d+)', data_str)
if match:
current = int(match.group(1))
total = int(match.group(2))
return (current, total)
return None
def _extract_speed_info(self, line: str) -> Optional[Tuple[str, float]]:
"""Extract speed information."""
match = re.search(r'-\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE)
if match:
speed_val = float(match.group(1))
speed_unit = match.group(2).upper()
speed_bytes = self._convert_to_bytes(speed_val, speed_unit)
operation = "unknown"
line_lower = line.lower()
if 'download' in line_lower:
operation = "download"
elif 'extract' in line_lower:
operation = "extract"
elif 'validat' in line_lower or 'hash' in line_lower:
operation = "validate"
return (operation, speed_bytes)
match = re.search(r'(?:at|speed:?)\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE)
if match:
speed_val = float(match.group(1))
speed_unit = match.group(2).upper()
speed_bytes = self._convert_to_bytes(speed_val, speed_unit)
operation = "unknown"
line_lower = line.lower()
if 'download' in line_lower:
operation = "download"
elif 'extract' in line_lower:
operation = "extract"
elif 'validat' in line_lower:
operation = "validate"
return (operation, speed_bytes)
return None
def _parse_speed(self, speed_str: str) -> float:
"""Parse speed string to bytes per second."""
match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', speed_str, re.IGNORECASE)
if match:
value = float(match.group(1))
unit = match.group(2).upper()
return self._convert_to_bytes(value, unit)
return 0.0
def _parse_speed_from_string(self, speed_str: str) -> float:
"""Parse speed string like '6.8MB/s' to bytes per second."""
match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s(?:ec)?', speed_str, re.IGNORECASE)
if match:
value = float(match.group(1))
unit = match.group(2).upper()
return self._convert_to_bytes(value, unit)
return 0.0
def _convert_to_bytes(self, value: float, unit: str) -> int:
"""Convert value with unit to bytes."""
multipliers = {
'B': 1,
'KB': 1024,
'MB': 1024 * 1024,
'GB': 1024 * 1024 * 1024,
'TB': 1024 * 1024 * 1024 * 1024
}
return int(value * multipliers.get(unit, 1))

View File

@@ -0,0 +1,235 @@
"""File progress parsing methods for ProgressParser (Mixin)."""
import logging
import re
from typing import Optional
from jackify.shared.progress_models import FileProgress, OperationType
logger = logging.getLogger(__name__)
class ProgressParserFilesMixin:
"""Mixin providing file progress parsing methods."""
def _extract_file_progress(self, line: str) -> Optional[FileProgress]:
"""Extract file-level progress information."""
if not line or not isinstance(line, str):
return None
if len(line) > 10000:
return None
if '\x00' in line:
line = line.replace('\x00', '')
file_progress_match = re.search(
r'\[FILE_PROGRESS\]\s+(Downloading|Extracting|Validating|Installing|Converting|Building|Writing|Verifying|Completed|Checking existing):\s+(.+?)\s+\((\d+(?:\.\d+)?)%\)\s*(?:\[(.+?)\])?\s*(?:\((\d+)/(\d+)\))?',
line,
re.IGNORECASE
)
if file_progress_match:
operation_str = file_progress_match.group(1).strip()
filename = file_progress_match.group(2).strip()
percent = float(file_progress_match.group(3))
speed_str = file_progress_match.group(4).strip() if file_progress_match.group(4) else None
counter_current = int(file_progress_match.group(5)) if file_progress_match.group(5) else None
counter_total = int(file_progress_match.group(6)) if file_progress_match.group(6) else None
operation_map = {
'downloading': OperationType.DOWNLOAD,
'extracting': OperationType.EXTRACT,
'validating': OperationType.VALIDATE,
'installing': OperationType.INSTALL,
'building': OperationType.INSTALL,
'writing': OperationType.INSTALL,
'verifying': OperationType.VALIDATE,
'checking existing': OperationType.VALIDATE,
'converting': OperationType.INSTALL,
'compiling': OperationType.INSTALL,
'hashing': OperationType.VALIDATE,
'completed': OperationType.UNKNOWN,
}
operation = operation_map.get(operation_str.lower(), OperationType.UNKNOWN)
if counter_current and counter_total and not self._should_display_file(filename):
file_progress = FileProgress(
filename="__phase_progress__",
operation=operation,
percent=percent,
speed=-1.0
)
file_progress._file_counter = (counter_current, counter_total)
file_progress._hidden = True
return file_progress
if not self._should_display_file(filename):
return None
if operation_str.lower() == 'completed':
percent = 100.0
speed = -1.0
if speed_str:
speed = self._parse_speed_from_string(speed_str)
file_progress = FileProgress(
filename=filename,
operation=operation,
percent=percent,
speed=speed
)
size_info = self._extract_data_info(line)
if size_info:
file_progress.current_size, file_progress.total_size = size_info
if counter_current is not None and counter_total is not None:
if operation_str.lower() == 'converting':
file_progress._texture_counter = (counter_current, counter_total)
elif operation_str.lower() == 'building':
file_progress._bsa_counter = (counter_current, counter_total)
else:
file_progress._file_counter = (counter_current, counter_total)
return file_progress
if re.search(r'\[.*?\]\s*(?:Downloading|Installing|Extracting)\s+(?:Mod|Files|Archives)', line, re.IGNORECASE):
return None
match = re.search(r'(?:Installing|Downloading|Extracting|Validating):\s*(.+?)\s*\((\d+(?:\.\d+)?)%\)', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
percent = float(match.group(2))
operation = self._detect_operation_from_line(line)
file_progress = FileProgress(
filename=filename,
operation=operation,
percent=percent
)
size_info = self._extract_data_info(line)
if size_info:
file_progress.current_size, file_progress.total_size = size_info
return file_progress
match = re.search(r'(.+?\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[:-]\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
percent = float(match.group(2))
operation = self._detect_operation_from_line(line)
file_progress = FileProgress(
filename=filename,
operation=operation,
percent=percent
)
size_info = self._extract_data_info(line)
if size_info:
file_progress.current_size, file_progress.total_size = size_info
return file_progress
match = re.search(r'(.+?\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[\[@]\s*([^\]]+)\]?', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
speed_str = match.group(2).strip().rstrip(']')
speed = self._parse_speed(speed_str)
operation = self._detect_operation_from_line(line)
file_progress = FileProgress(
filename=filename,
operation=operation,
speed=speed
)
size_info = self._extract_data_info(line)
if size_info:
file_progress.current_size, file_progress.total_size = size_info
return file_progress
match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s+(?:at|@|:|-)?\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
percent = float(match.group(2))
operation = self._detect_operation_from_line(line)
return FileProgress(
filename=filename,
operation=operation,
percent=percent
)
match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[\(]?\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/?\s*of\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
current_val = float(match.group(2))
current_unit = match.group(3).upper()
total_val = float(match.group(4))
total_unit = match.group(5).upper()
current_bytes = self._convert_to_bytes(current_val, current_unit)
total_bytes = self._convert_to_bytes(total_val, total_unit)
percent = (current_bytes / total_bytes * 100.0) if total_bytes > 0 else 0.0
operation = self._detect_operation_from_line(line)
return FileProgress(
filename=filename,
operation=operation,
percent=percent,
current_size=current_bytes,
total_size=total_bytes
)
match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s+(?:downloading|extracting|validating|installing)\s+at\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
speed_val = float(match.group(2))
speed_unit = match.group(3).upper()
speed = self._convert_to_bytes(speed_val, speed_unit)
operation = self._detect_operation_from_line(line)
return FileProgress(
filename=filename,
operation=operation,
speed=speed
)
return None
def _parse_file_with_percent(self, match: re.Match) -> Optional[FileProgress]:
"""Parse file progress from percentage match."""
filename = match.group(1).strip()
percent = float(match.group(2))
operation = OperationType.UNKNOWN
return FileProgress(
filename=filename,
operation=operation,
percent=percent
)
def _parse_file_with_speed(self, match: re.Match) -> Optional[FileProgress]:
"""Parse file progress from speed match."""
filename = match.group(1).strip()
speed_str = match.group(2).strip()
speed = self._parse_speed(speed_str)
operation = OperationType.UNKNOWN
return FileProgress(
filename=filename,
operation=operation,
speed=speed
)
def _detect_operation_from_line(self, line: str) -> OperationType:
"""Detect operation type from line content."""
line_lower = line.lower()
if 'download' in line_lower:
return OperationType.DOWNLOAD
elif 'extract' in line_lower:
return OperationType.EXTRACT
elif 'validat' in line_lower:
return OperationType.VALIDATE
elif 'install' in line_lower or 'build' in line_lower or 'convert' in line_lower:
return OperationType.INSTALL
else:
return OperationType.UNKNOWN
def _extract_completed_file(self, line: str) -> Optional[str]:
"""Extract filename from completion messages like 'Finished downloading filename.7z'."""
match = re.search(
r'Finished\s+(?:downloading|extracting|validating|installing)\s+(.+?)(?:\.\s|\.$|\s+Hash:)',
line,
re.IGNORECASE
)
if match:
filename = match.group(1).strip()
filename = filename.rstrip('. ')
return filename
return None

View File

@@ -0,0 +1,106 @@
"""Phase extraction methods for ProgressParser (Mixin)."""
import logging
import re
from typing import Optional, Tuple
from jackify.shared.progress_models import InstallationPhase
logger = logging.getLogger(__name__)
class ProgressParserPhaseMixin:
"""Mixin providing phase extraction methods."""
def _extract_phase(self, line: str) -> Optional[Tuple[InstallationPhase, str]]:
"""Extract phase information from line."""
section_match = re.search(r'===?\s*(.+?)\s*===?', line)
if section_match:
section_name = section_match.group(1).strip().lower()
phase = self._map_section_to_phase(section_name)
return (phase, section_match.group(1).strip())
# [FILE_PROGRESS] lines drive file activity only — skip phase extraction for them
if '[FILE_PROGRESS]' in line:
return None
# Make the [timestamp] prefix optional — engine no longer emits it.
action_match = re.search(
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
line,
re.IGNORECASE
)
if action_match:
action = action_match.group(1).lower()
phase = self._map_action_to_phase(action)
return (phase, action_match.group(1))
return None
def _extract_phase_from_section(self, match: re.Match) -> Optional[Tuple[InstallationPhase, str]]:
"""Extract phase from section header match."""
section_name = match.group(1).strip().lower()
phase = self._map_section_to_phase(section_name)
return (phase, match.group(1).strip())
def _extract_phase_from_action(self, match: re.Match) -> Optional[Tuple[InstallationPhase, str]]:
"""Extract phase from action match."""
action = match.group(1).lower()
phase = self._map_action_to_phase(action)
return (phase, match.group(1))
def _map_section_to_phase(self, section_name: str) -> InstallationPhase:
"""Map section name to InstallationPhase enum."""
section_lower = section_name.lower()
if 'download' in section_lower:
return InstallationPhase.DOWNLOAD
elif 'extract' in section_lower:
return InstallationPhase.EXTRACT
elif 'hash' in section_lower or 'validate' in section_lower or 'verif' in section_lower:
return InstallationPhase.VALIDATE
elif 'install' in section_lower:
return InstallationPhase.INSTALL
elif 'bsa' in section_lower or 'building' in section_lower:
return InstallationPhase.INSTALL
elif 'finaliz' in section_lower or 'complet' in section_lower:
return InstallationPhase.FINALIZE
elif ('configur' in section_lower or 'initializ' in section_lower
or 'looking' in section_lower or 'cleaning' in section_lower
or 'unmodified' in section_lower or 'updating' in section_lower
or 'folder' in section_lower or 'delete' in section_lower):
return InstallationPhase.INITIALIZATION
else:
return InstallationPhase.UNKNOWN
def _map_action_to_phase(self, action: str) -> InstallationPhase:
"""Map action word to InstallationPhase enum."""
action_lower = action.lower()
if 'download' in action_lower:
return InstallationPhase.DOWNLOAD
elif 'extract' in action_lower:
return InstallationPhase.EXTRACT
elif 'validat' in action_lower or 'checking' in action_lower:
return InstallationPhase.VALIDATE
elif 'install' in action_lower:
return InstallationPhase.INSTALL
else:
return InstallationPhase.UNKNOWN
def _extract_phase_from_text(self, text: str) -> Optional[Tuple[InstallationPhase, str]]:
"""Extract phase from status text like 'Installing files'."""
text_lower = text.lower()
if 'download' in text_lower:
return (InstallationPhase.DOWNLOAD, text)
elif 'extract' in text_lower:
return (InstallationPhase.EXTRACT, text)
elif 'validat' in text_lower or 'hash' in text_lower:
return (InstallationPhase.VALIDATE, text)
elif 'install' in text_lower:
return (InstallationPhase.INSTALL, text)
elif 'prepar' in text_lower or 'configur' in text_lower:
return (InstallationPhase.INITIALIZATION, text)
elif 'finish' in text_lower or 'complet' in text_lower:
return (InstallationPhase.FINALIZE, text)
else:
return (InstallationPhase.UNKNOWN, text)

Some files were not shown because too many files have changed in this diff Show More