Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411addeea2 | ||
|
|
805718222a | ||
|
|
2eb54b9a36 | ||
|
|
9cc5245db7 | ||
|
|
69738e8e9e | ||
|
|
fdee639734 | ||
|
|
b123f6f509 | ||
|
|
368e1bf5ef | ||
|
|
ebf61f67db | ||
|
|
15b90d823c | ||
|
|
309303f721 | ||
|
|
59e03eb38e | ||
|
|
f278b9a8b5 | ||
|
|
9a10812796 | ||
|
|
61cfda5dac | ||
|
|
9560c1b72a | ||
|
|
5be65c25ac | ||
|
|
053aab04a9 | ||
|
|
f8cdd26d64 | ||
|
|
bc6c0f2e1f | ||
|
|
12294d3186 |
60
CHANGELOG.md
@@ -1,5 +1,63 @@
|
|||||||
# Jackify Changelog
|
# 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
|
## v0.2.2.2 - ModOrganizer.ini Path Fixes for SD Card Installations
|
||||||
**Release Date:** 2026-01-28
|
**Release Date:** 2026-01-28
|
||||||
|
|
||||||
@@ -1179,4 +1237,4 @@ This release completes the logging refactor that was blocking development workfl
|
|||||||
- Modular handler architecture for extensibility.
|
- Modular handler architecture for extensibility.
|
||||||
|
|
||||||
## v0.0.09 and Earlier
|
## v0.0.09 and Earlier
|
||||||
See commit history for previous versions.
|
See commit history for previous versions.
|
||||||
|
|||||||
171
README.md
@@ -2,162 +2,119 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[Wiki](https://github.com/Omni-guides/Jackify/wiki) | [Nexus](https://www.nexusmods.com/site/mods/1427) | [Download](https://www.nexusmods.com/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>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
# Jackify
|
# 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.
|
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.
|
||||||
|
|
||||||
### **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.
|
|
||||||
|
|
||||||
[](https://ko-fi.com/D1D8H8WBD)
|
|
||||||
|
|
||||||
## Features
|
## 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
|
- **Complete Modlist Workflow**: Install from scratch with Nexus Premium, configure a pre-downloaded modlist, or reconfigure an existing modlist already in Steam
|
||||||
- Comprehensive Modlist Support: Support for Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, Enderal and more
|
- **Game Support**: Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, Enderal, and more
|
||||||
- Automated Steam Integration: Automatic Steam shortcut creation with complete Proton configuration
|
- **Automated Steam Integration**: Steam shortcut creation with full Proton configuration
|
||||||
- Professional Interface: Both GUI and CLI interfaces with identical features
|
- **GUI and CLI**: Both interfaces provide identical functionality
|
||||||
|
|
||||||
## Disclaimer
|
## 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
|
- **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
|
- **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 will work)
|
||||||
|
- Steam installed and configured
|
||||||
- Linux system (Most modern distributions supported)
|
- **Protontricks** — required for modlist configuration
|
||||||
- Python 3.8+ installed
|
- See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-protontricks)
|
||||||
- Steam installed and configured, Proton Experimental available
|
- **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)
|
- **Nexus Mods Premium subscription** (required for automated downloads)
|
||||||
- Non-premium support planned for future releases
|
- Non-Premium users can still install modlists via Wabbajack under Proton
|
||||||
- **FUSE** (required for AppImage execution)
|
- Native non-premium support planned for a future release
|
||||||
- Pre-installed on most Linux distributions
|
- See the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide) for full details on the options available
|
||||||
- If AppImage fails to run, install FUSE using your distribution's package manager
|
- **FUSE** (required for AppImage execution, pre-installed on most distributions)
|
||||||
- **Ubuntu/Debian only**: Qt platform plugin library
|
- **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`
|
- `sudo apt install libxcb-cursor-dev`
|
||||||
- Required for Qt GUI to initialize properly
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Download latest release from Nexus Mods
|
|
||||||
# Extract the Jackify.AppImage from the 7z archive
|
|
||||||
chmod +x Jackify.AppImage
|
chmod +x Jackify.AppImage
|
||||||
./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).
|
For a full 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
|
|
||||||
|
|
||||||
## Supported Games
|
## Supported Games
|
||||||
|
|
||||||
- Skyrim Special Edition
|
- Skyrim Special Edition
|
||||||
- Fallout 4
|
- Fallout 4
|
||||||
- Fallout New Vegas
|
- Fallout New Vegas
|
||||||
- Oblivion
|
- Oblivion
|
||||||
- Starfield
|
- Starfield
|
||||||
- Enderal
|
- 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
|
## Architecture
|
||||||
|
|
||||||
Jackify follows a clean separation between frontend and backend:
|
Jackify follows a clean separation between frontend and backend:
|
||||||
|
|
||||||
- Backend Services: Pure business logic with no UI dependencies
|
- **Backend Services**: Pure business logic with no UI dependencies
|
||||||
- Frontend Interfaces: CLI and GUI implementations using shared backend
|
- **Frontend Interfaces**: CLI and GUI implementations sharing the same backend
|
||||||
- Native Engine: Powered by jackify-engine (custom fork of wabbajack-cli.exe) for optimal performance and compatibility
|
- **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 creating and modifying Steam shortcuts
|
- **Steam Integration**: Direct Steam shortcuts.vdf manipulation for shortcut creation and management
|
||||||
|
|
||||||
## Configuration
|
All Jackify relted files and configuration data is are stored in `~/Jackify/` and `~/.config/jackify/`.
|
||||||
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.
|
|
||||||
|
|
||||||
## Contributing
|
## 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
|
## Future Plans (not guaranteed)
|
||||||
- Add full TTW+Modlist automation for TTW based modlists
|
|
||||||
- Replace the API Key requirement with a more secure OAuth based approach
|
- Continue to expand supported games for fully automated configuration
|
||||||
- Add support for modding and modlist creation tools via a sister application or module
|
- Non-Premium / manual download support
|
||||||
- Revise the GUI to be more refined
|
- GUI refinements
|
||||||
- Dark/Light theme support for the GUI
|
- Dark/Light theme support
|
||||||
- Advanced logging and diagnostics - more detailed troubleshooting information
|
|
||||||
- Automatic dependency resolution - ensure all required tools and libraries are installed
|
## 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
|
## Support
|
||||||
- Issues: Report bugs and request features via GitHub Issues
|
|
||||||
- Documentation: See the Wiki for detailed guides
|
- **Bugs and feature requests**: [GitHub Issues](https://github.com/Omni-guides/Jackify/issues)
|
||||||
- Community: Join the community in the #unofficial-linux-help channel of the Official Wabbajack discord server - https://discord.gg/wabbajack
|
- **Documentation**: [Wiki](https://github.com/Omni-guides/Jackify/wiki)
|
||||||
|
- **Community**: [#unofficial-linux-help](https://discord.gg/wabbajack) on the Wabbajack Discord
|
||||||
|
|
||||||
## Acknowledgments
|
## 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
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[](https://ko-fi.com/D1D8H8WBD)
|
[](https://ko-fi.com/D1D8H8WBD)
|
||||||
|
|
||||||
**Jackify** - Simplifying Wabbajack modlist installation and configuration on Linux
|
|
||||||
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 186 KiB |
BIN
assets/images/wiki/ModlistGuides/Jackify/jackify-main-window.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 957 KiB |
BIN
assets/images/wiki/ModlistGuides/Wabbajack/wj-first-launch.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 678 KiB |
|
After Width: | Height: | Size: 685 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 344 KiB |
BIN
assets/images/wiki/ModlistGuides/Wabbajack/wj-settings-login.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/images/wiki/ModlistGuides/shared/mo2-run-button.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
|||||||
Wabbajack modlists natively on Linux systems.
|
Wabbajack modlists natively on Linux systems.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.2.2.2"
|
__version__ = "0.4.0"
|
||||||
|
|||||||
644
jackify/backend/core/modlist_operations_configuration_cli.py
Normal 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}")
|
||||||
170
jackify/backend/core/modlist_operations_configuration_gui.py
Normal 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
|
||||||
368
jackify/backend/core/modlist_operations_discovery.py
Normal 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
|
||||||
67
jackify/backend/core/modlist_operations_game_detection.py
Normal 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)
|
||||||
99
jackify/backend/core/modlist_operations_nexus.py
Normal 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 []
|
||||||
@@ -5,17 +5,10 @@ Reusable tab completion functions for Jackify CLI, including bash-like path comp
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import readline
|
import readline
|
||||||
import logging # Added for debugging
|
import logging
|
||||||
|
|
||||||
# Get a logger for this module
|
completer_logger = logging.getLogger(__name__)
|
||||||
completer_logger = logging.getLogger(__name__) # Logger will be named src.modules.completers
|
|
||||||
|
|
||||||
# Set level to DEBUG for this logger to ensure all debug messages are generated.
|
|
||||||
# These messages will be handled by handlers configured in the main application (e.g., via LoggingHandler).
|
|
||||||
completer_logger.setLevel(logging.INFO)
|
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
|
completer_logger.propagate = False
|
||||||
|
|
||||||
# IMPORTANT: Do NOT include '/' in the completer delimiters!
|
# IMPORTANT: Do NOT include '/' in the completer delimiters!
|
||||||
@@ -68,7 +61,6 @@ def path_completer(text, state):
|
|||||||
|
|
||||||
final_match_strings_for_readline = []
|
final_match_strings_for_readline = []
|
||||||
text_dir_part = os.path.dirname(text)
|
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):
|
if os.path.isdir(text) and text.endswith(os.sep):
|
||||||
base_path = text
|
base_path = text
|
||||||
elif os.path.isdir(text):
|
elif os.path.isdir(text):
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import re
|
import re
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
# Initialize logger
|
from .config_handler_encryption import ConfigEncryptionMixin
|
||||||
|
from .config_handler_directories import ConfigDirectoriesMixin
|
||||||
|
from .config_handler_proton import ConfigProtonMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConfigHandler:
|
class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonMixin):
|
||||||
"""
|
"""
|
||||||
Handles application configuration and settings
|
Handles application configuration and settings
|
||||||
Singleton pattern ensures all code shares the same instance
|
Singleton pattern ensures all code shares the same instance
|
||||||
@@ -60,7 +61,7 @@ class ConfigHandler:
|
|||||||
"game_proton_path": None, # Proton version for game shortcuts (can be any Proton 9+), separate from install proton
|
"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_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
|
||||||
"proton_version": None, # Install Proton version name - None means auto-detect
|
"proton_version": None, # Install Proton version name - None means auto-detect
|
||||||
"steam_restart_strategy": "jackify", # "jackify" (default) or "nak_simple"
|
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
|
||||||
"window_width": None, # Saved window width (None = use dynamic sizing)
|
"window_width": None, # Saved window width (None = use dynamic sizing)
|
||||||
"window_height": None # Saved window height (None = use dynamic sizing)
|
"window_height": None # Saved window height (None = use dynamic sizing)
|
||||||
}
|
}
|
||||||
@@ -214,8 +215,8 @@ class ConfigHandler:
|
|||||||
config.update(saved_config)
|
config.update(saved_config)
|
||||||
return config
|
return config
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't use logger here - can cause recursion if logger tries to access config
|
# Use logger.warning instead of print to stderr - logger is initialized before config access
|
||||||
print(f"Warning: Error reading configuration from disk: {e}", file=sys.stderr)
|
logger.warning(f"Error reading configuration from disk: {e}")
|
||||||
return self.settings.copy()
|
return self.settings.copy()
|
||||||
|
|
||||||
def reload_config(self):
|
def reload_config(self):
|
||||||
@@ -305,224 +306,8 @@ class ConfigHandler:
|
|||||||
|
|
||||||
def get_protontricks_path(self):
|
def get_protontricks_path(self):
|
||||||
"""Get the path to protontricks executable"""
|
"""Get the path to protontricks executable"""
|
||||||
return self.settings.get("protontricks_path")
|
return self.settings.get("protontricks_path")
|
||||||
|
|
||||||
def _get_encryption_key(self) -> bytes:
|
|
||||||
"""
|
|
||||||
Generate encryption key for API key storage using same method as OAuth tokens
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Fernet-compatible encryption key
|
|
||||||
"""
|
|
||||||
import socket
|
|
||||||
import getpass
|
|
||||||
|
|
||||||
try:
|
|
||||||
hostname = socket.gethostname()
|
|
||||||
username = getpass.getuser()
|
|
||||||
|
|
||||||
# Try to get machine ID
|
|
||||||
machine_id = None
|
|
||||||
try:
|
|
||||||
with open('/etc/machine-id', 'r') as f:
|
|
||||||
machine_id = f.read().strip()
|
|
||||||
except:
|
|
||||||
try:
|
|
||||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
|
||||||
machine_id = f.read().strip()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
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 Fernet-compatible 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
|
|
||||||
|
|
||||||
Args:
|
|
||||||
api_key: Plain text API key
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Encrypted API key string
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from Crypto.Cipher import AES
|
|
||||||
from Crypto.Random import get_random_bytes
|
|
||||||
|
|
||||||
# Derive 32-byte AES key
|
|
||||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
|
||||||
|
|
||||||
# Generate random nonce
|
|
||||||
nonce = get_random_bytes(12)
|
|
||||||
|
|
||||||
# Encrypt with AES-GCM
|
|
||||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
|
||||||
ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8'))
|
|
||||||
|
|
||||||
# Combine and encode
|
|
||||||
combined = nonce + ciphertext + tag
|
|
||||||
return base64.b64encode(combined).decode('utf-8')
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
# Fallback to base64 if pycryptodome not available
|
|
||||||
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(f"Error encrypting API key: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Decrypt API key using AES-GCM
|
|
||||||
|
|
||||||
Args:
|
|
||||||
encrypted_key: Encrypted API key string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Decrypted API key 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'):
|
|
||||||
# Fallback to base64 decode if old pycrypto is installed
|
|
||||||
try:
|
|
||||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Derive 32-byte AES key
|
|
||||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
|
||||||
|
|
||||||
# Decode and split
|
|
||||||
combined = base64.b64decode(encrypted_key.encode('utf-8'))
|
|
||||||
nonce = combined[:12]
|
|
||||||
tag = combined[-16:]
|
|
||||||
ciphertext = combined[12:-16]
|
|
||||||
|
|
||||||
# Decrypt with AES-GCM
|
|
||||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
|
||||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
|
||||||
|
|
||||||
return plaintext.decode('utf-8')
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
# Fallback to base64 decode
|
|
||||||
try:
|
|
||||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
except AttributeError:
|
|
||||||
# Old pycrypto doesn't have MODE_GCM, fallback to base64
|
|
||||||
try:
|
|
||||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
# Might be old base64-only format, try decoding
|
|
||||||
try:
|
|
||||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
|
||||||
except:
|
|
||||||
logger.error(f"Error decrypting API key: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def save_api_key(self, api_key):
|
|
||||||
"""
|
|
||||||
Save Nexus API key with Fernet encryption
|
|
||||||
|
|
||||||
Args:
|
|
||||||
api_key (str): Plain text API key
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if saved successfully, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if api_key:
|
|
||||||
# Encrypt the API key using Fernet
|
|
||||||
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:
|
|
||||||
# Clear the API key if empty
|
|
||||||
self.settings["nexus_api_key"] = None
|
|
||||||
logger.debug("API key cleared")
|
|
||||||
|
|
||||||
result = self.save_config()
|
|
||||||
|
|
||||||
# Set restrictive permissions on config file
|
|
||||||
if result:
|
|
||||||
try:
|
|
||||||
os.chmod(self.config_file, 0o600)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not set restrictive permissions on config: {e}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving API key: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_api_key(self):
|
|
||||||
"""
|
|
||||||
Retrieve and decrypt the saved Nexus API key.
|
|
||||||
Always reads fresh from disk.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Decrypted API key or None if not saved
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config = self._read_config_from_disk()
|
|
||||||
encrypted_key = config.get("nexus_api_key")
|
|
||||||
if encrypted_key:
|
|
||||||
# Decrypt the API key
|
|
||||||
decrypted_key = self._decrypt_api_key(encrypted_key)
|
|
||||||
return decrypted_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.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if API key exists, False otherwise
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if cleared successfully, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.settings["nexus_api_key"] = None
|
|
||||||
logger.debug("API key cleared from configuration")
|
|
||||||
return self.save_config()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error clearing API key: {e}")
|
|
||||||
return False
|
|
||||||
def save_resolution(self, resolution):
|
def save_resolution(self, resolution):
|
||||||
"""
|
"""
|
||||||
Save resolution setting to configuration
|
Save resolution setting to configuration
|
||||||
@@ -589,262 +374,6 @@ class ConfigHandler:
|
|||||||
logger.error(f"Error clearing resolution: {e}")
|
logger.error(f"Error clearing resolution: {e}")
|
||||||
return False
|
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.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Saved Install Proton path, or None if not set (indicates auto-detect mode)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config = self._read_config_from_disk()
|
|
||||||
proton_path = config.get("proton_path")
|
|
||||||
# Return None if missing/None/empty string - don't default to "auto"
|
|
||||||
if not proton_path:
|
|
||||||
logger.debug("proton_path not set in config - will use auto-detection")
|
|
||||||
return None
|
|
||||||
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 None
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Saved Game Proton path, Install Proton path, or None if not saved (indicates auto-detect mode)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config = self._read_config_from_disk()
|
|
||||||
game_proton_path = config.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 = config.get("proton_path") # Returns None if not set
|
|
||||||
|
|
||||||
# Return None if missing/None/empty string
|
|
||||||
if not game_proton_path:
|
|
||||||
logger.debug("game_proton_path not set in config - will use auto-detection")
|
|
||||||
return None
|
|
||||||
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.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Saved Proton version or 'auto' if not saved
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config = self._read_config_from_disk()
|
|
||||||
proton_version = config.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:
|
|
||||||
# Set proton_path to None (will appear as null in JSON) so jackify-engine doesn't get invalid path
|
|
||||||
# Code will auto-detect on each run when proton_path is None
|
|
||||||
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(f"Failed to auto-detect Proton: {e}")
|
|
||||||
# Set proton_path to None (will appear as null in JSON)
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
108
jackify/backend/handlers/config_handler_directories.py
Normal 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
|
||||||
137
jackify/backend/handlers/config_handler_encryption.py
Normal 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
|
||||||
76
jackify/backend/handlers/config_handler_proton.py
Normal 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()
|
||||||
@@ -73,7 +73,7 @@ def diagnose_stalled_engine(pid: int, duration: int = 60) -> Dict[str, Any]:
|
|||||||
samples.append(sample)
|
samples.append(sample)
|
||||||
|
|
||||||
# Real-time status
|
# 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 | "
|
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']}")
|
f"Threads: {sample['thread_count']:2d} | Status: {sample['status']}")
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class EnginePerformanceMonitor:
|
|||||||
# Also monitor the parent Python process for comparison
|
# Also monitor the parent Python process for comparison
|
||||||
try:
|
try:
|
||||||
self._parent_process = psutil.Process(os.getpid())
|
self._parent_process = psutil.Process(os.getpid())
|
||||||
except:
|
except Exception:
|
||||||
self._parent_process = None
|
self._parent_process = None
|
||||||
|
|
||||||
self._monitoring = True
|
self._monitoring = True
|
||||||
@@ -179,7 +179,7 @@ class EnginePerformanceMonitor:
|
|||||||
if metrics.parent_cpu_percent is not None:
|
if metrics.parent_cpu_percent is not None:
|
||||||
parent_info = f", Python wrapper: {metrics.parent_cpu_percent:.1f}% CPU"
|
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"for {self.stall_duration}s+ (Memory: {metrics.memory_mb:.1f}MB, "
|
||||||
f"Threads: {metrics.thread_count}, FDs: {metrics.fd_count}{parent_info})")
|
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_cpu_percent = self._parent_process.cpu_percent()
|
||||||
parent_memory_info = self._parent_process.memory_info()
|
parent_memory_info = self._parent_process.memory_info()
|
||||||
parent_memory_mb = parent_memory_info.rss / (1024 * 1024)
|
parent_memory_mb = parent_memory_info.rss / (1024 * 1024)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get I/O info
|
# Get I/O info
|
||||||
|
|||||||
@@ -11,19 +11,20 @@ from typing import Optional, List, Dict, Tuple
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import subprocess # Needed for running sudo commands
|
import subprocess
|
||||||
import pwd # To get user name
|
import pwd
|
||||||
import grp # To get group name
|
import grp
|
||||||
import requests # Import requests
|
|
||||||
import vdf # Import VDF library at the top level
|
|
||||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class FileSystemHandler:
|
|
||||||
|
class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Keep instance logger if needed, but static methods use module logger
|
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -36,7 +37,7 @@ class FileSystemHandler:
|
|||||||
return Path(path)
|
return Path(path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to normalize path {path}: {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
|
@staticmethod
|
||||||
def validate_path(path: Path) -> bool:
|
def validate_path(path: Path) -> bool:
|
||||||
@@ -50,7 +51,6 @@ class FileSystemHandler:
|
|||||||
logger.warning(f"Validation failed: No read access - {path}")
|
logger.warning(f"Validation failed: No read access - {path}")
|
||||||
return False
|
return False
|
||||||
# Check write access (important for many operations)
|
# 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 path.is_dir():
|
||||||
if not os.access(path, os.W_OK):
|
if not os.access(path, os.W_OK):
|
||||||
logger.warning(f"Validation failed: No write access to directory - {path}")
|
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):
|
if not os.access(path.parent, os.W_OK):
|
||||||
logger.warning(f"Validation failed: No write access to parent dir of file - {path.parent}")
|
logger.warning(f"Validation failed: No write access to parent dir of file - {path.parent}")
|
||||||
return False
|
return False
|
||||||
return True # Passed existence and access checks
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to validate path {path}: {e}")
|
logger.error(f"Failed to validate path {path}: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -192,16 +192,16 @@ class FileSystemHandler:
|
|||||||
if recursive and path.is_dir():
|
if recursive and path.is_dir():
|
||||||
for root, dirs, files in os.walk(path):
|
for root, dirs, files in os.walk(path):
|
||||||
try:
|
try:
|
||||||
os.chmod(root, 0o755) # Dirs typically 755
|
os.chmod(root, 0o755)
|
||||||
except Exception as dir_e:
|
except Exception as dir_e:
|
||||||
logger.warning(f"Failed to chmod dir {root}: {dir_e}")
|
logger.warning(f"Failed to chmod dir {root}: {dir_e}")
|
||||||
for file in files:
|
for file in files:
|
||||||
try:
|
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:
|
except Exception as file_e:
|
||||||
logger.warning(f"Failed to chmod file {os.path.join(root, file)}: {file_e}")
|
logger.warning(f"Failed to chmod file {os.path.join(root, file)}: {file_e}")
|
||||||
elif path.is_file():
|
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():
|
elif path.is_dir():
|
||||||
os.chmod(path, permissions) # Set specific perm for top-level dir if not recursive
|
os.chmod(path, permissions) # Set specific perm for top-level dir if not recursive
|
||||||
logger.debug(f"Set permissions for {path} (recursive={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}")
|
logger.debug(f"Path {path} matches SD card pattern: {pattern}")
|
||||||
return True
|
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.")
|
logger.debug(f"Path {path} does not appear to be on a standard SD card mount.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -306,7 +300,7 @@ class FileSystemHandler:
|
|||||||
|
|
||||||
FileSystemHandler.ensure_directory(destination.parent)
|
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}")
|
logger.info(f"Moved directory {source} to {destination}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -321,8 +315,6 @@ class FileSystemHandler:
|
|||||||
logger.error(f"Copy failed: Source is not a directory - {source}")
|
logger.error(f"Copy failed: Source is not a directory - {source}")
|
||||||
return False
|
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)
|
FileSystemHandler.ensure_directory(destination.parent)
|
||||||
|
|
||||||
shutil.copytree(source, destination, dirs_exist_ok=dirs_exist_ok)
|
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}")
|
logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}")
|
||||||
return False # Backup succeeded, but adding entry failed
|
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:
|
def blank_downloads_dir(self, modlist_ini: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Blank or reset the MO2 Downloads Directory
|
Blank or reset the MO2 Downloads Directory
|
||||||
@@ -664,7 +562,7 @@ class FileSystemHandler:
|
|||||||
self.logger.debug(f"Created game-specific directory: {dir_path}")
|
self.logger.debug(f"Created game-specific directory: {dir_path}")
|
||||||
|
|
||||||
# CRITICAL: Create game-specific Documents directories in Wine prefix
|
# CRITICAL: Create game-specific Documents directories in Wine prefix
|
||||||
# This is required for USVFS to virtualize profile INI files on first launch
|
# Required for USVFS to virtualize profile INIs on first launch
|
||||||
if game_name in game_docs_dirs:
|
if game_name in game_docs_dirs:
|
||||||
docs_dir_name = game_docs_dirs[game_name]
|
docs_dir_name = game_docs_dirs[game_name]
|
||||||
|
|
||||||
@@ -701,267 +599,3 @@ class FileSystemHandler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error creating required directories: {e}")
|
self.logger.error(f"Error creating required directories: {e}")
|
||||||
return False
|
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 verify_ownership_and_permissions(path: Path) -> tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Verify and fix ownership/permissions for modlist directory.
|
|
||||||
Returns (success, error_message).
|
|
||||||
|
|
||||||
Logic:
|
|
||||||
- If files NOT owned by user: Can't fix without sudo, return error with instructions
|
|
||||||
- If files owned by user: Try to fix permissions ourselves with chmod
|
|
||||||
"""
|
|
||||||
if not path.exists():
|
|
||||||
logger.error(f"Path does not exist: {path}")
|
|
||||||
return False, f"Path does not exist: {path}"
|
|
||||||
|
|
||||||
# Check if all files/dirs are owned by the user
|
|
||||||
if not FileSystemHandler.all_owned_by_user(path):
|
|
||||||
# Files not owned by us - need sudo to fix
|
|
||||||
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(f"Ownership issue detected: Some files in {path} are not owned by {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
|
|
||||||
|
|
||||||
# Files are owned by us - try to fix permissions ourselves
|
|
||||||
logger.info(f"Files in {path} are owned by current user, verifying permissions...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['chmod', '-R', '755', str(path)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
logger.info(f"Permissions set successfully for {path}")
|
|
||||||
return True, ""
|
|
||||||
else:
|
|
||||||
logger.warning(f"chmod returned non-zero but we'll continue: {result.stderr}")
|
|
||||||
# Non-critical if chmod fails on our own files, might be read-only filesystem or similar
|
|
||||||
return True, ""
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error running chmod: {e}, continuing anyway")
|
|
||||||
# Non-critical error, we own the files so proceed
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
|
||||||
"""
|
|
||||||
DEPRECATED: Use verify_ownership_and_permissions() instead.
|
|
||||||
This method is kept for backwards compatibility but no longer executes sudo.
|
|
||||||
"""
|
|
||||||
logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
|
|
||||||
success, error_msg = FileSystemHandler.verify_ownership_and_permissions(path)
|
|
||||||
if not success:
|
|
||||||
logger.error(error_msg)
|
|
||||||
print(error_msg)
|
|
||||||
return success
|
|
||||||
|
|
||||||
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",
|
|
||||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf" # Flatpak
|
|
||||||
]
|
|
||||||
|
|
||||||
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) ...
|
|
||||||
55
jackify/backend/handlers/filesystem_handler_download.py
Normal 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
|
||||||
89
jackify/backend/handlers/filesystem_handler_ownership.py
Normal 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
|
||||||
124
jackify/backend/handlers/filesystem_handler_steam.py
Normal 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
|
||||||
@@ -15,6 +15,7 @@ class GameDetector:
|
|||||||
'skyrim': ['Skyrim Special Edition', 'Skyrim'],
|
'skyrim': ['Skyrim Special Edition', 'Skyrim'],
|
||||||
'fallout4': ['Fallout 4'],
|
'fallout4': ['Fallout 4'],
|
||||||
'falloutnv': ['Fallout New Vegas'],
|
'falloutnv': ['Fallout New Vegas'],
|
||||||
|
'fallout3': ['Fallout 3'],
|
||||||
'oblivion': ['Oblivion'],
|
'oblivion': ['Oblivion'],
|
||||||
'starfield': ['Starfield'],
|
'starfield': ['Starfield'],
|
||||||
'oblivion_remastered': ['Oblivion Remastered']
|
'oblivion_remastered': ['Oblivion Remastered']
|
||||||
@@ -34,6 +35,8 @@ class GameDetector:
|
|||||||
return 'fallout4'
|
return 'fallout4'
|
||||||
elif any(keyword in modlist_lower for keyword in ['fallout new vegas', 'fonv', 'fnv', 'new vegas', 'nvse']):
|
elif any(keyword in modlist_lower for keyword in ['fallout new vegas', 'fonv', 'fnv', 'new vegas', 'nvse']):
|
||||||
return 'falloutnv'
|
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']):
|
elif any(keyword in modlist_lower for keyword in ['oblivion', 'obse', 'shivering isles']):
|
||||||
return 'oblivion'
|
return 'oblivion'
|
||||||
elif any(keyword in modlist_lower for keyword in ['starfield', 'sf', 'starfieldse']):
|
elif any(keyword in modlist_lower for keyword in ['starfield', 'sf', 'starfieldse']):
|
||||||
@@ -108,6 +111,12 @@ class GameDetector:
|
|||||||
'required_dlc': [],
|
'required_dlc': [],
|
||||||
'compatibility_tools': ['protontricks', 'winetricks']
|
'compatibility_tools': ['protontricks', 'winetricks']
|
||||||
},
|
},
|
||||||
|
'fallout3': {
|
||||||
|
'launcher': 'FOSE',
|
||||||
|
'min_proton_version': '5.0',
|
||||||
|
'required_dlc': [],
|
||||||
|
'compatibility_tools': ['protontricks', 'winetricks']
|
||||||
|
},
|
||||||
'oblivion': {
|
'oblivion': {
|
||||||
'launcher': 'OBSE',
|
'launcher': 'OBSE',
|
||||||
'min_proton_version': '5.0',
|
'min_proton_version': '5.0',
|
||||||
@@ -173,6 +182,7 @@ class GameDetector:
|
|||||||
'skyrim': 'SKSE',
|
'skyrim': 'SKSE',
|
||||||
'fallout4': 'F4SE',
|
'fallout4': 'F4SE',
|
||||||
'falloutnv': 'NVSE',
|
'falloutnv': 'NVSE',
|
||||||
|
'fallout3': 'FOSE',
|
||||||
'oblivion': 'OBSE',
|
'oblivion': 'OBSE',
|
||||||
'starfield': 'SFSE',
|
'starfield': 'SFSE',
|
||||||
'oblivion_remastered': 'OBSE'
|
'oblivion_remastered': 'OBSE'
|
||||||
@@ -205,6 +215,7 @@ class GameDetector:
|
|||||||
'skyrim': ['vcrun2019', 'dotnet48', 'dxvk'],
|
'skyrim': ['vcrun2019', 'dotnet48', 'dxvk'],
|
||||||
'fallout4': ['vcrun2019', 'dotnet48', 'dxvk'],
|
'fallout4': ['vcrun2019', 'dotnet48', 'dxvk'],
|
||||||
'falloutnv': ['vcrun2019', 'dotnet48'],
|
'falloutnv': ['vcrun2019', 'dotnet48'],
|
||||||
|
'fallout3': ['vcrun2019', 'dotnet48'],
|
||||||
'oblivion': ['vcrun2019', 'dotnet48'],
|
'oblivion': ['vcrun2019', 'dotnet48'],
|
||||||
'starfield': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'],
|
'starfield': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'],
|
||||||
'oblivion_remastered': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk']
|
'oblivion_remastered': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk']
|
||||||
@@ -222,6 +233,7 @@ class GameDetector:
|
|||||||
'skyrim': ['SkyrimSE.exe', 'Skyrim.exe'],
|
'skyrim': ['SkyrimSE.exe', 'Skyrim.exe'],
|
||||||
'fallout4': ['Fallout4.exe'],
|
'fallout4': ['Fallout4.exe'],
|
||||||
'falloutnv': ['FalloutNV.exe'],
|
'falloutnv': ['FalloutNV.exe'],
|
||||||
|
'fallout3': ['Fallout3.exe'],
|
||||||
'oblivion': ['Oblivion.exe']
|
'oblivion': ['Oblivion.exe']
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +262,11 @@ class GameDetector:
|
|||||||
'config_dirs': ['Data', 'Saves'],
|
'config_dirs': ['Data', 'Saves'],
|
||||||
'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\FalloutNV']
|
'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': {
|
'oblivion': {
|
||||||
'ini_files': ['Oblivion.ini'],
|
'ini_files': ['Oblivion.ini'],
|
||||||
'config_dirs': ['Data', 'Saves'],
|
'config_dirs': ['Data', 'Saves'],
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import sys
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import subprocess # Add subprocess import
|
import subprocess # Add subprocess import
|
||||||
# from datetime import datetime # Not used currently
|
|
||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
@@ -33,10 +32,14 @@ from .resolution_handler import ResolutionHandler
|
|||||||
from .protontricks_handler import ProtontricksHandler
|
from .protontricks_handler import ProtontricksHandler
|
||||||
from .path_handler import PathHandler
|
from .path_handler import PathHandler
|
||||||
from .vdf_handler import VDFHandler
|
from .vdf_handler import VDFHandler
|
||||||
from .mo2_handler import MO2Handler
|
|
||||||
from jackify.shared.ui_utils import print_section_header
|
from jackify.shared.ui_utils import print_section_header
|
||||||
from .completers import path_completer
|
from .completers import path_completer
|
||||||
|
|
||||||
|
try:
|
||||||
|
import readline
|
||||||
|
except ImportError:
|
||||||
|
readline = None
|
||||||
|
|
||||||
# Define exports for this module
|
# Define exports for this module
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'MenuHandler',
|
'MenuHandler',
|
||||||
@@ -47,700 +50,11 @@ __all__ = [
|
|||||||
# Initialize logger
|
# Initialize logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# --- Input Handling with Readline Tab Completion ---
|
from .menu_handler_input import (
|
||||||
# Simple function for basic input
|
basic_input_prompt, input_prompt, simple_path_completer,
|
||||||
def basic_input_prompt(message, **kwargs):
|
READLINE_AVAILABLE, READLINE_HAS_PROMPT, READLINE_HAS_DISPLAY_HOOK,
|
||||||
return input(message)
|
)
|
||||||
|
from .menu_handler_modlist import ModlistMenuHandler
|
||||||
# --- 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)}")
|
|
||||||
# --- 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}")
|
|
||||||
|
|
||||||
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:
|
|
||||||
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}")
|
|
||||||
|
|
||||||
# Check GUI mode early to avoid input() calls in GUI context
|
|
||||||
import os
|
|
||||||
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 = ""
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Configure ENB for Linux compatibility (non-blocking, same as GUI)
|
|
||||||
enb_detected = False
|
|
||||||
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)
|
|
||||||
# Non-blocking: continue workflow even if ENB config fails
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"ENB configuration skipped due to error: {e}")
|
|
||||||
# Continue workflow - ENB config is optional
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
print("")
|
|
||||||
print("Running VNV post-install automation...")
|
|
||||||
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=None, # CLI doesn't support manual file callback yet
|
|
||||||
confirmation_callback=None # Will use default confirmation in CLI
|
|
||||||
)
|
|
||||||
if error:
|
|
||||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
|
|
||||||
print("You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"VNV automation check skipped: {e}")
|
|
||||||
# Not an error - just means VNV automation wasn't applicable
|
|
||||||
|
|
||||||
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("")
|
|
||||||
|
|
||||||
# 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()}/Configure_New_Modlist_workflow.log")
|
|
||||||
# Only wait for input in CLI mode, not GUI mode
|
|
||||||
if not gui_mode:
|
|
||||||
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
class MenuHandler:
|
class MenuHandler:
|
||||||
"""
|
"""
|
||||||
@@ -757,7 +71,6 @@ class MenuHandler:
|
|||||||
steamdeck=self.config_handler.settings.get('steamdeck', False),
|
steamdeck=self.config_handler.settings.get('steamdeck', False),
|
||||||
verbose=False
|
verbose=False
|
||||||
)
|
)
|
||||||
self.mo2_handler = MO2Handler(self)
|
|
||||||
|
|
||||||
def display_banner(self):
|
def display_banner(self):
|
||||||
"""Display the application banner - DEPRECATED: Banner display should be handled by frontend"""
|
"""Display the application banner - DEPRECATED: Banner display should be handled by frontend"""
|
||||||
@@ -1005,8 +318,7 @@ class MenuHandler:
|
|||||||
self.logger.info(f"Selected directory (exists): {chosen_path}")
|
self.logger.info(f"Selected directory (exists): {chosen_path}")
|
||||||
return chosen_path
|
return chosen_path
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Path exists but is not a directory: {chosen_path}")
|
print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}")
|
||||||
print(f"{COLOR_ERROR}Error: Path exists but is not a directory: {chosen_path}{COLOR_RESET}")
|
|
||||||
if not self._ask_try_again(): return None
|
if not self._ask_try_again(): return None
|
||||||
continue
|
continue
|
||||||
elif create_if_missing:
|
elif create_if_missing:
|
||||||
@@ -1072,8 +384,8 @@ class MenuHandler:
|
|||||||
print("")
|
print("")
|
||||||
return file_path
|
return file_path
|
||||||
else:
|
else:
|
||||||
print(f"{COLOR_ERROR}Error: Path is not a valid '{extension_filter}' file or a directory: {file_path}{COLOR_RESET}")
|
print(f"{COLOR_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_INFO}Please check the path and try again, or press Ctrl+C or 'q' to cancel.{COLOR_RESET}")
|
||||||
if not self._ask_try_again():
|
if not self._ask_try_again():
|
||||||
print("")
|
print("")
|
||||||
return None
|
return None
|
||||||
@@ -1082,65 +394,5 @@ class MenuHandler:
|
|||||||
print("")
|
print("")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
if READLINE_AVAILABLE:
|
if READLINE_AVAILABLE and readline:
|
||||||
readline.set_completer(None)
|
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
|
|
||||||
98
jackify/backend/handlers/menu_handler_input.py
Normal 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
|
||||||
665
jackify/backend/handlers/menu_handler_modlist.py
Normal 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
|
||||||
@@ -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
|
|
||||||
584
jackify/backend/handlers/modlist_configuration.py
Normal 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.")
|
||||||
|
|
||||||
386
jackify/backend/handlers/modlist_detection.py
Normal 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
|
||||||
540
jackify/backend/handlers/modlist_install_cli_configuration.py
Normal 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}")
|
||||||
451
jackify/backend/handlers/modlist_install_cli_discovery.py
Normal 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
|
||||||
|
|
||||||
144
jackify/backend/handlers/modlist_install_cli_nexus.py
Normal 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
|
||||||
|
|
||||||
361
jackify/backend/handlers/modlist_install_cli_ttw.py
Normal 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}")
|
||||||
546
jackify/backend/handlers/modlist_wine_ops.py
Normal 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
|
||||||
|
|
||||||
@@ -66,12 +66,12 @@ class OAuthTokenHandler:
|
|||||||
# Linux machine-id
|
# Linux machine-id
|
||||||
with open('/etc/machine-id', 'r') as f:
|
with open('/etc/machine-id', 'r') as f:
|
||||||
machine_id = f.read().strip()
|
machine_id = f.read().strip()
|
||||||
except:
|
except (OSError, IOError):
|
||||||
try:
|
try:
|
||||||
# Alternative locations
|
# Alternative locations
|
||||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||||
machine_id = f.read().strip()
|
machine_id = f.read().strip()
|
||||||
except:
|
except (OSError, IOError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Combine multiple sources of machine-specific data
|
# Combine multiple sources of machine-specific data
|
||||||
@@ -221,7 +221,7 @@ class OAuthTokenHandler:
|
|||||||
# Clean up temp file on error
|
# Clean up temp file on error
|
||||||
try:
|
try:
|
||||||
os.unlink(temp_path)
|
os.unlink(temp_path)
|
||||||
except:
|
except (OSError, IOError):
|
||||||
pass
|
pass
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|||||||
149
jackify/backend/handlers/path_handler_dxvk.py
Normal 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
|
||||||
184
jackify/backend/handlers/path_handler_game.py
Normal 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
|
||||||
492
jackify/backend/handlers/path_handler_mo2.py
Normal 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
|
||||||
226
jackify/backend/handlers/path_handler_steam.py
Normal 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
|
||||||
149
jackify/backend/handlers/progress_parser_extraction.py
Normal 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))
|
||||||
235
jackify/backend/handlers/progress_parser_files.py
Normal 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
|
||||||
106
jackify/backend/handlers/progress_parser_phase.py
Normal 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)
|
||||||
167
jackify/backend/handlers/progress_state_metrics.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Metrics and synthetic entry methods for ProgressStateManager (Mixin)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from jackify.shared.progress_models import FileProgress, OperationType, InstallationPhase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from jackify.backend.handlers.progress_parser import ParsedLine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressStateMetricsMixin:
|
||||||
|
"""Mixin providing metrics augmentation methods."""
|
||||||
|
|
||||||
|
def _augment_file_metrics(self, file_progress: FileProgress) -> None:
|
||||||
|
"""Populate size/speed info to improve UI accuracy."""
|
||||||
|
now = time.time()
|
||||||
|
history = self._file_history.get(file_progress.filename)
|
||||||
|
|
||||||
|
total_size = file_progress.total_size or (history.get('total') if history else None)
|
||||||
|
if total_size and file_progress.percent and not file_progress.current_size:
|
||||||
|
file_progress.current_size = int((file_progress.percent / 100.0) * total_size)
|
||||||
|
elif file_progress.current_size and not total_size and file_progress.total_size:
|
||||||
|
total_size = file_progress.total_size
|
||||||
|
|
||||||
|
if total_size and not file_progress.total_size:
|
||||||
|
file_progress.total_size = total_size
|
||||||
|
|
||||||
|
current_size = file_progress.current_size or 0
|
||||||
|
|
||||||
|
computed_speed = 0.0
|
||||||
|
if file_progress.speed < 0:
|
||||||
|
computed_speed = 0.0
|
||||||
|
if history and current_size:
|
||||||
|
prev_bytes = history.get('bytes', 0)
|
||||||
|
prev_time = history.get('time', now)
|
||||||
|
delta_bytes = current_size - prev_bytes
|
||||||
|
delta_time = now - prev_time
|
||||||
|
|
||||||
|
if delta_bytes >= 0 and delta_time >= 1.0:
|
||||||
|
computed_speed = delta_bytes / delta_time
|
||||||
|
elif history.get('computed_speed'):
|
||||||
|
computed_speed = history.get('computed_speed', 0.0)
|
||||||
|
|
||||||
|
file_progress.speed = computed_speed
|
||||||
|
else:
|
||||||
|
computed_speed = file_progress.speed
|
||||||
|
|
||||||
|
if current_size or total_size:
|
||||||
|
self._file_history[file_progress.filename] = {
|
||||||
|
'bytes': current_size,
|
||||||
|
'time': now,
|
||||||
|
'total': total_size or (history.get('total') if history else None),
|
||||||
|
'computed_speed': computed_speed,
|
||||||
|
}
|
||||||
|
elif history:
|
||||||
|
self._file_history[file_progress.filename] = history
|
||||||
|
|
||||||
|
def _maybe_add_wabbajack_progress(self, parsed: "ParsedLine") -> bool:
|
||||||
|
"""Create a synthetic file entry for .wabbajack archive download."""
|
||||||
|
if not parsed.data_info:
|
||||||
|
return False
|
||||||
|
if not parsed.data_info:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_bytes, total_bytes = parsed.data_info
|
||||||
|
if total_bytes <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for fp in self.state.active_files:
|
||||||
|
if fp.filename.lower().endswith('.wabbajack'):
|
||||||
|
synthetic_entry = fp
|
||||||
|
if getattr(fp, self._synthetic_flag, False):
|
||||||
|
percent = (current_bytes / total_bytes) * 100.0
|
||||||
|
synthetic_entry.percent = percent
|
||||||
|
synthetic_entry.current_size = current_bytes
|
||||||
|
synthetic_entry.total_size = total_bytes
|
||||||
|
synthetic_entry.last_update = time.time()
|
||||||
|
self._augment_file_metrics(synthetic_entry)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
synthetic_entry = None
|
||||||
|
for fp in self.state.active_files:
|
||||||
|
if getattr(fp, self._synthetic_flag, False):
|
||||||
|
synthetic_entry = fp
|
||||||
|
break
|
||||||
|
|
||||||
|
message = (parsed.message or "")
|
||||||
|
phase_name = (parsed.phase_name or "").lower()
|
||||||
|
should_force = 'wabbajack' in message.lower() or 'wabbajack' in phase_name
|
||||||
|
|
||||||
|
if not synthetic_entry:
|
||||||
|
if self._has_real_download_activity() and not should_force:
|
||||||
|
return False
|
||||||
|
if self.state.phase not in (InstallationPhase.INITIALIZATION, InstallationPhase.DOWNLOAD) and not should_force:
|
||||||
|
return False
|
||||||
|
|
||||||
|
percent = (current_bytes / total_bytes) * 100.0
|
||||||
|
if not self._wabbajack_entry_name:
|
||||||
|
filename_match = re.search(r'([A-Za-z0-9_\-\.]+\.wabbajack)', message, re.IGNORECASE)
|
||||||
|
if filename_match:
|
||||||
|
self._wabbajack_entry_name = filename_match.group(1)
|
||||||
|
if not self._wabbajack_entry_name:
|
||||||
|
self._wabbajack_entry_name = "Downloading .wabbajack file"
|
||||||
|
entry_name = self._wabbajack_entry_name
|
||||||
|
|
||||||
|
if synthetic_entry:
|
||||||
|
synthetic_entry.percent = percent
|
||||||
|
synthetic_entry.current_size = current_bytes
|
||||||
|
synthetic_entry.total_size = total_bytes
|
||||||
|
synthetic_entry.last_update = time.time()
|
||||||
|
self._augment_file_metrics(synthetic_entry)
|
||||||
|
else:
|
||||||
|
special_file = FileProgress(
|
||||||
|
filename=entry_name,
|
||||||
|
operation=OperationType.DOWNLOAD,
|
||||||
|
percent=percent,
|
||||||
|
current_size=current_bytes,
|
||||||
|
total_size=total_bytes
|
||||||
|
)
|
||||||
|
special_file.last_update = time.time()
|
||||||
|
setattr(special_file, self._synthetic_flag, True)
|
||||||
|
self._augment_file_metrics(special_file)
|
||||||
|
self.state.add_file(special_file)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _has_real_download_activity(self) -> bool:
|
||||||
|
"""Check if there are real download entries already visible."""
|
||||||
|
for fp in self.state.active_files:
|
||||||
|
if getattr(fp, self._synthetic_flag, False):
|
||||||
|
continue
|
||||||
|
if fp.operation == OperationType.DOWNLOAD:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _remove_synthetic_wabbajack(self) -> None:
|
||||||
|
"""Remove any synthetic .wabbajack entries once real files appear."""
|
||||||
|
remaining = []
|
||||||
|
removed = False
|
||||||
|
for fp in self.state.active_files:
|
||||||
|
if getattr(fp, self._synthetic_flag, False):
|
||||||
|
removed = True
|
||||||
|
self._file_history.pop(fp.filename, None)
|
||||||
|
continue
|
||||||
|
remaining.append(fp)
|
||||||
|
if removed:
|
||||||
|
self.state.active_files = remaining
|
||||||
|
|
||||||
|
def _remove_all_wabbajack_entries(self) -> None:
|
||||||
|
"""Remove ALL .wabbajack entries when archive download phase starts."""
|
||||||
|
remaining = []
|
||||||
|
removed = False
|
||||||
|
for fp in self.state.active_files:
|
||||||
|
if fp.filename.lower().endswith('.wabbajack') or 'wabbajack' in fp.filename.lower():
|
||||||
|
removed = True
|
||||||
|
self._file_history.pop(fp.filename, None)
|
||||||
|
continue
|
||||||
|
remaining.append(fp)
|
||||||
|
if removed:
|
||||||
|
self.state.active_files = remaining
|
||||||
|
self._wabbajack_entry_name = None
|
||||||
239
jackify/backend/handlers/progress_state_processing.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""Line processing methods for ProgressStateManager (Mixin)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from jackify.shared.progress_models import (
|
||||||
|
InstallationPhase,
|
||||||
|
InstallationProgress,
|
||||||
|
FileProgress,
|
||||||
|
OperationType,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from jackify.backend.handlers.progress_parser import ParsedLine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressStateProcessingMixin:
|
||||||
|
"""Mixin providing line processing methods."""
|
||||||
|
|
||||||
|
def process_line(self, line: str) -> bool:
|
||||||
|
"""
|
||||||
|
Process a line of output and update state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if state was updated, False otherwise
|
||||||
|
"""
|
||||||
|
parsed = self.parser.parse_line(line)
|
||||||
|
|
||||||
|
if not parsed.has_progress:
|
||||||
|
return False
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
phase_changed = False
|
||||||
|
if parsed.phase and parsed.phase != self.state.phase:
|
||||||
|
previous_phase = self.state.phase
|
||||||
|
|
||||||
|
if previous_phase == InstallationPhase.DOWNLOAD:
|
||||||
|
self._download_files_seen = {}
|
||||||
|
self._download_total_bytes = 0
|
||||||
|
self._download_processed_bytes = 0
|
||||||
|
|
||||||
|
if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info:
|
||||||
|
if self.state.data_total > 0:
|
||||||
|
self.state.data_processed = 0
|
||||||
|
self.state.data_total = 0
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if previous_phase == InstallationPhase.VALIDATE:
|
||||||
|
if self.state.phase_name and 'validat' in self.state.phase_name.lower():
|
||||||
|
self.state.phase_name = ""
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
phase_changed = True
|
||||||
|
self._previous_phase = self.state.phase
|
||||||
|
self.state.phase = parsed.phase
|
||||||
|
updated = True
|
||||||
|
elif parsed.phase:
|
||||||
|
self.state.phase = parsed.phase
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.phase_name:
|
||||||
|
self.state.phase_name = parsed.phase_name
|
||||||
|
updated = True
|
||||||
|
elif phase_changed:
|
||||||
|
if self.state.phase_name and self.state.phase != InstallationPhase.VALIDATE:
|
||||||
|
self.state.phase_name = ""
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if self.state.phase == InstallationPhase.DOWNLOAD:
|
||||||
|
if self.state.phase_name and 'validat' in self.state.phase_name.lower():
|
||||||
|
self.state.phase_name = ""
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.overall_percent is not None:
|
||||||
|
self.state.overall_percent = parsed.overall_percent
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.step_info:
|
||||||
|
self.state.phase_step, self.state.phase_max_steps = parsed.step_info
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.data_info:
|
||||||
|
self.state.data_processed, self.state.data_total = parsed.data_info
|
||||||
|
if self.state.data_total > 0 and self.state.overall_percent == 0.0:
|
||||||
|
self.state.overall_percent = (self.state.data_processed / self.state.data_total) * 100.0
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.file_counter:
|
||||||
|
self.state.phase_step, self.state.phase_max_steps = parsed.file_counter
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.file_progress:
|
||||||
|
if hasattr(parsed.file_progress, '_texture_counter'):
|
||||||
|
tex_current, tex_total = parsed.file_progress._texture_counter
|
||||||
|
self.state.texture_conversion_current = tex_current
|
||||||
|
self.state.texture_conversion_total = tex_total
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if hasattr(parsed.file_progress, '_bsa_counter'):
|
||||||
|
bsa_current, bsa_total = parsed.file_progress._bsa_counter
|
||||||
|
self.state.bsa_building_current = bsa_current
|
||||||
|
self.state.bsa_building_total = bsa_total
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if hasattr(parsed.file_progress, '_hidden') and parsed.file_progress._hidden:
|
||||||
|
return updated
|
||||||
|
|
||||||
|
if parsed.file_progress.filename.lower().endswith('.wabbajack'):
|
||||||
|
self._wabbajack_entry_name = parsed.file_progress.filename
|
||||||
|
self._remove_synthetic_wabbajack()
|
||||||
|
self._has_real_wabbajack = True
|
||||||
|
else:
|
||||||
|
if parsed.file_progress.operation == OperationType.DOWNLOAD:
|
||||||
|
self._remove_all_wabbajack_entries()
|
||||||
|
self._has_real_wabbajack = True
|
||||||
|
|
||||||
|
if self.state.phase == InstallationPhase.DOWNLOAD and parsed.file_progress.operation == OperationType.DOWNLOAD:
|
||||||
|
filename = parsed.file_progress.filename
|
||||||
|
total_size = parsed.file_progress.total_size or 0
|
||||||
|
current_size = parsed.file_progress.current_size or 0
|
||||||
|
|
||||||
|
if filename not in self._download_files_seen:
|
||||||
|
if total_size > 0:
|
||||||
|
self._download_total_bytes += total_size
|
||||||
|
self._download_files_seen[filename] = (total_size, current_size)
|
||||||
|
self._download_processed_bytes += current_size
|
||||||
|
else:
|
||||||
|
old_total, old_current = self._download_files_seen[filename]
|
||||||
|
if total_size > old_total:
|
||||||
|
self._download_total_bytes += (total_size - old_total)
|
||||||
|
if current_size > old_current:
|
||||||
|
self._download_processed_bytes += (current_size - old_current)
|
||||||
|
self._download_files_seen[filename] = (max(old_total, total_size), current_size)
|
||||||
|
|
||||||
|
if self.state.data_total == 0 and self._download_total_bytes > 0:
|
||||||
|
self.state.data_total = self._download_total_bytes
|
||||||
|
self.state.data_processed = self._download_processed_bytes
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
self._augment_file_metrics(parsed.file_progress)
|
||||||
|
existing_file = None
|
||||||
|
for f in self.state.active_files:
|
||||||
|
if f.filename == parsed.file_progress.filename:
|
||||||
|
existing_file = f
|
||||||
|
break
|
||||||
|
|
||||||
|
if parsed.file_progress.percent >= 100.0 and not existing_file:
|
||||||
|
updated = True
|
||||||
|
elif parsed.file_progress.percent >= 100.0:
|
||||||
|
parsed.file_progress.percent = 100.0
|
||||||
|
parsed.file_progress.last_update = time.time()
|
||||||
|
self.state.add_file(parsed.file_progress)
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
self.state.add_file(parsed.file_progress)
|
||||||
|
updated = True
|
||||||
|
elif parsed.data_info:
|
||||||
|
phase_name_lower = (parsed.phase_name or "").lower()
|
||||||
|
message_lower = (parsed.message or "").lower()
|
||||||
|
is_archive_phase = (
|
||||||
|
'mod archives' in phase_name_lower or
|
||||||
|
'downloading mod archives' in message_lower or
|
||||||
|
(parsed.phase == InstallationPhase.DOWNLOAD and self._has_real_download_activity())
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_archive_phase:
|
||||||
|
self._remove_all_wabbajack_entries()
|
||||||
|
self._has_real_wabbajack = True
|
||||||
|
|
||||||
|
if not getattr(self, '_has_real_wabbajack', False):
|
||||||
|
if self._maybe_add_wabbajack_progress(parsed):
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.completed_filename:
|
||||||
|
if not self.parser.should_display_file(parsed.completed_filename):
|
||||||
|
parsed.completed_filename = None
|
||||||
|
|
||||||
|
if parsed.completed_filename:
|
||||||
|
if self.state.phase == InstallationPhase.DOWNLOAD:
|
||||||
|
filename = parsed.completed_filename
|
||||||
|
if filename in self._download_files_seen:
|
||||||
|
old_total, old_current = self._download_files_seen[filename]
|
||||||
|
if old_current < old_total:
|
||||||
|
self._download_processed_bytes += (old_total - old_current)
|
||||||
|
self._download_files_seen[filename] = (old_total, old_total)
|
||||||
|
if self.state.data_total == 0 and self._download_total_bytes > 0:
|
||||||
|
self.state.data_total = self._download_total_bytes
|
||||||
|
self.state.data_processed = self._download_processed_bytes
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
found_existing = False
|
||||||
|
for file_prog in self.state.active_files:
|
||||||
|
filename_match = (
|
||||||
|
file_prog.filename == parsed.completed_filename or
|
||||||
|
file_prog.filename.endswith(parsed.completed_filename) or
|
||||||
|
parsed.completed_filename in file_prog.filename
|
||||||
|
)
|
||||||
|
if filename_match:
|
||||||
|
file_prog.percent = 100.0
|
||||||
|
file_prog.last_update = time.time()
|
||||||
|
updated = True
|
||||||
|
found_existing = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found_existing:
|
||||||
|
operation = OperationType.DOWNLOAD
|
||||||
|
if parsed.file_progress:
|
||||||
|
operation = parsed.file_progress.operation
|
||||||
|
|
||||||
|
completed_file = FileProgress(
|
||||||
|
filename=parsed.completed_filename,
|
||||||
|
operation=operation,
|
||||||
|
percent=100.0,
|
||||||
|
current_size=0,
|
||||||
|
total_size=0
|
||||||
|
)
|
||||||
|
completed_file.last_update = time.time()
|
||||||
|
self.state.add_file(completed_file)
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.speed_info:
|
||||||
|
operation, speed = parsed.speed_info
|
||||||
|
self.state.update_speed(operation, speed)
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if parsed.message:
|
||||||
|
self.state.message = parsed.message
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self.state.timestamp = time.time()
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self.state.remove_completed_files()
|
||||||
|
|
||||||
|
return updated
|
||||||
147
jackify/backend/handlers/protontricks_commands.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Protontricks run/launch commands mixin.
|
||||||
|
Extracted from protontricks_handler for file-size and domain separation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProtontricksCommandsMixin:
|
||||||
|
"""Mixin providing run_protontricks and run_protontricks_launch."""
|
||||||
|
|
||||||
|
def run_protontricks(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Run protontricks with the given arguments and keyword arguments.
|
||||||
|
kwargs are passed to subprocess.run (e.g., stderr=subprocess.DEVNULL).
|
||||||
|
Returns subprocess.CompletedProcess or None.
|
||||||
|
"""
|
||||||
|
if self.which_protontricks is None:
|
||||||
|
if not self.detect_protontricks():
|
||||||
|
self.logger.error("Could not detect protontricks installation")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.which_protontricks == 'bundled':
|
||||||
|
from .subprocess_utils import get_safe_python_executable
|
||||||
|
python_exe = get_safe_python_executable()
|
||||||
|
wrapper_script = self._get_bundled_protontricks_wrapper_path()
|
||||||
|
if wrapper_script and Path(wrapper_script).exists():
|
||||||
|
cmd = [python_exe, str(wrapper_script)]
|
||||||
|
cmd.extend([str(a) for a in args])
|
||||||
|
else:
|
||||||
|
cmd = [python_exe, "-m", "protontricks.cli.main"]
|
||||||
|
cmd.extend([str(a) for a in args])
|
||||||
|
elif self.which_protontricks == 'flatpak':
|
||||||
|
cmd = list(self._get_flatpak_run_args())
|
||||||
|
if kwargs.get('env') and kwargs['env'].get('WINETRICKS_CACHE'):
|
||||||
|
try:
|
||||||
|
cache_val = str(Path(kwargs['env']['WINETRICKS_CACHE']).resolve())
|
||||||
|
cmd.append(f'--env=WINETRICKS_CACHE={cache_val}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cmd.append("com.github.Matoking.protontricks")
|
||||||
|
cmd.extend(args)
|
||||||
|
else:
|
||||||
|
cmd = ["protontricks"]
|
||||||
|
cmd.extend(args)
|
||||||
|
|
||||||
|
run_kwargs = {
|
||||||
|
'stdout': subprocess.PIPE,
|
||||||
|
'stderr': subprocess.PIPE,
|
||||||
|
'text': True,
|
||||||
|
**kwargs
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_str = ' '.join(map(str, cmd))
|
||||||
|
self.logger.debug("=" * 80)
|
||||||
|
self.logger.debug("PROTONTRICKS COMMAND (for manual reproduction):")
|
||||||
|
self.logger.debug(f" {cmd_str}")
|
||||||
|
self.logger.debug("=" * 80)
|
||||||
|
|
||||||
|
if 'env' in kwargs and kwargs['env']:
|
||||||
|
env = self._get_clean_subprocess_env()
|
||||||
|
env.update(kwargs['env'])
|
||||||
|
else:
|
||||||
|
env = self._get_clean_subprocess_env()
|
||||||
|
|
||||||
|
env['WINEDEBUG'] = '-all'
|
||||||
|
steam_dir = self._get_steam_dir_from_libraryfolders()
|
||||||
|
if steam_dir:
|
||||||
|
env['STEAM_DIR'] = str(steam_dir)
|
||||||
|
self.logger.debug(f"Set STEAM_DIR for protontricks: {steam_dir}")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf - protontricks may prompt user")
|
||||||
|
|
||||||
|
if self.which_protontricks == 'native':
|
||||||
|
winetricks_path = self._get_bundled_winetricks_path()
|
||||||
|
if winetricks_path:
|
||||||
|
env['WINETRICKS'] = str(winetricks_path)
|
||||||
|
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||||
|
cabextract_path = self._get_bundled_cabextract_path()
|
||||||
|
if cabextract_path:
|
||||||
|
cabextract_dir = str(cabextract_path.parent)
|
||||||
|
current_path = env.get('PATH', '')
|
||||||
|
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
|
||||||
|
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||||
|
else:
|
||||||
|
self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
|
||||||
|
|
||||||
|
from ..handlers.config_handler import ConfigHandler
|
||||||
|
config_handler = ConfigHandler()
|
||||||
|
debug_mode = config_handler.get('debug_mode', False)
|
||||||
|
if not debug_mode:
|
||||||
|
env['WINETRICKS_SUPER_QUIET'] = '1'
|
||||||
|
self.logger.debug("Set WINETRICKS_SUPER_QUIET=1 to suppress winetricks verbose output")
|
||||||
|
else:
|
||||||
|
self.logger.debug("Debug mode enabled - winetricks verbose output will be shown")
|
||||||
|
|
||||||
|
run_kwargs['env'] = env
|
||||||
|
try:
|
||||||
|
return subprocess.run(cmd, **run_kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error running protontricks: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run_protontricks_launch(self, appid, installer_path, *extra_args):
|
||||||
|
"""
|
||||||
|
Run protontricks-launch (for WebView or similar installers).
|
||||||
|
Returns subprocess.CompletedProcess or None.
|
||||||
|
"""
|
||||||
|
if self.which_protontricks is None:
|
||||||
|
if not self.detect_protontricks():
|
||||||
|
self.logger.error("Could not detect protontricks installation")
|
||||||
|
return None
|
||||||
|
if self.which_protontricks == 'bundled':
|
||||||
|
from .subprocess_utils import get_safe_python_executable
|
||||||
|
python_exe = get_safe_python_executable()
|
||||||
|
cmd = [python_exe, "-m", "protontricks.cli.launch", "--appid", appid, str(installer_path)]
|
||||||
|
elif self.which_protontricks == 'flatpak':
|
||||||
|
cmd = self._get_flatpak_run_args() + ["--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)]
|
||||||
|
else:
|
||||||
|
launch_path = shutil.which("protontricks-launch")
|
||||||
|
if not launch_path:
|
||||||
|
self.logger.error("protontricks-launch command not found in PATH.")
|
||||||
|
return None
|
||||||
|
cmd = [launch_path, "--appid", appid, str(installer_path)]
|
||||||
|
if extra_args:
|
||||||
|
cmd.extend(extra_args)
|
||||||
|
self.logger.debug(f"Running protontricks-launch: {' '.join(map(str, cmd))}")
|
||||||
|
try:
|
||||||
|
env = self._get_clean_subprocess_env()
|
||||||
|
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error running protontricks-launch: {e}")
|
||||||
|
return None
|
||||||
195
jackify/backend/handlers/protontricks_detection.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Protontricks detection and version mixin.
|
||||||
|
Extracted from protontricks_handler for file-size and domain separation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .subprocess_utils import get_clean_subprocess_env
|
||||||
|
|
||||||
|
|
||||||
|
class ProtontricksDetectionMixin:
|
||||||
|
"""Mixin providing protontricks detection, Steam dir, bundled paths, and version checks."""
|
||||||
|
|
||||||
|
def _get_steam_dir_from_libraryfolders(self) -> Optional[Path]:
|
||||||
|
"""Determine Steam installation directory from libraryfolders.vdf."""
|
||||||
|
from ..handlers.path_handler import PathHandler
|
||||||
|
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",
|
||||||
|
Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/config/libraryfolders.vdf",
|
||||||
|
]
|
||||||
|
for vdf_path in vdf_paths:
|
||||||
|
if vdf_path.is_file():
|
||||||
|
steam_dir = vdf_path.parent.parent
|
||||||
|
if (steam_dir / "steamapps").exists():
|
||||||
|
self.logger.debug(f"Determined STEAM_DIR from libraryfolders.vdf: {steam_dir}")
|
||||||
|
return steam_dir
|
||||||
|
library_paths = PathHandler.get_all_steam_library_paths()
|
||||||
|
if library_paths:
|
||||||
|
first_lib = library_paths[0]
|
||||||
|
if '.var/app/com.valvesoftware.Steam' in str(first_lib):
|
||||||
|
data_steam = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam"
|
||||||
|
if (data_steam / "steamapps").exists():
|
||||||
|
self.logger.debug(f"Determined STEAM_DIR from Flatpak data path: {data_steam}")
|
||||||
|
return data_steam
|
||||||
|
if (first_lib / "steamapps").exists():
|
||||||
|
self.logger.debug(f"Determined STEAM_DIR from Flatpak library path: {first_lib}")
|
||||||
|
return first_lib
|
||||||
|
elif (first_lib / "steamapps").exists():
|
||||||
|
self.logger.debug(f"Determined STEAM_DIR from native library path: {first_lib}")
|
||||||
|
return first_lib
|
||||||
|
self.logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_bundled_winetricks_path(self) -> Optional[Path]:
|
||||||
|
"""Get path to bundled winetricks (AppImage and dev)."""
|
||||||
|
possible_paths = []
|
||||||
|
if os.environ.get('APPDIR'):
|
||||||
|
possible_paths.append(Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'winetricks')
|
||||||
|
module_dir = Path(__file__).parent.parent.parent
|
||||||
|
possible_paths.append(module_dir / 'tools' / 'winetricks')
|
||||||
|
for path in possible_paths:
|
||||||
|
if path.exists() and os.access(path, os.X_OK):
|
||||||
|
self.logger.debug(f"Found bundled winetricks at: {path}")
|
||||||
|
return path
|
||||||
|
self.logger.warning(f"Bundled winetricks not found. Tried paths: {possible_paths}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_bundled_cabextract_path(self) -> Optional[Path]:
|
||||||
|
"""Get path to bundled cabextract (AppImage and dev)."""
|
||||||
|
possible_paths = []
|
||||||
|
if os.environ.get('APPDIR'):
|
||||||
|
possible_paths.append(Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'cabextract')
|
||||||
|
module_dir = Path(__file__).parent.parent.parent
|
||||||
|
possible_paths.append(module_dir / 'tools' / 'cabextract')
|
||||||
|
for path in possible_paths:
|
||||||
|
if path.exists() and os.access(path, os.X_OK):
|
||||||
|
self.logger.debug(f"Found bundled cabextract at: {path}")
|
||||||
|
return path
|
||||||
|
self.logger.warning(f"Bundled cabextract not found. Tried paths: {possible_paths}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_bundled_protontricks_wrapper_path(self) -> Optional[str]:
|
||||||
|
"""Return path to bundled protontricks wrapper script if any. Returns None to use python -m fallback."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_clean_subprocess_env(self):
|
||||||
|
"""Create clean environment for subprocess (remove AppImage/bundle vars)."""
|
||||||
|
env = get_clean_subprocess_env()
|
||||||
|
if 'LD_LIBRARY_PATH_ORIG' in env:
|
||||||
|
env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG']
|
||||||
|
else:
|
||||||
|
env.pop('LD_LIBRARY_PATH', None)
|
||||||
|
if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'):
|
||||||
|
dyld_entries = env['DYLD_LIBRARY_PATH'].split(os.pathsep)
|
||||||
|
cleaned_dyld = [p for p in dyld_entries if not p.startswith(sys._MEIPASS)]
|
||||||
|
if cleaned_dyld:
|
||||||
|
env['DYLD_LIBRARY_PATH'] = os.pathsep.join(cleaned_dyld)
|
||||||
|
else:
|
||||||
|
env.pop('DYLD_LIBRARY_PATH', None)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def _get_native_steam_service(self):
|
||||||
|
"""Get native Steam operations service instance."""
|
||||||
|
if self._native_steam_service is None:
|
||||||
|
from ..services.native_steam_operations_service import NativeSteamOperationsService
|
||||||
|
self._native_steam_service = NativeSteamOperationsService(steamdeck=self.steamdeck)
|
||||||
|
return self._native_steam_service
|
||||||
|
|
||||||
|
def detect_protontricks(self):
|
||||||
|
"""Detect if protontricks is installed (native or flatpak). Returns True if found."""
|
||||||
|
self.logger.debug("Detecting if protontricks is installed...")
|
||||||
|
protontricks_path_which = shutil.which("protontricks")
|
||||||
|
self.flatpak_path = shutil.which("flatpak")
|
||||||
|
if protontricks_path_which:
|
||||||
|
try:
|
||||||
|
with open(protontricks_path_which, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
if "flatpak run" in content:
|
||||||
|
self.logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}")
|
||||||
|
self.which_protontricks = 'flatpak'
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Native Protontricks found at {protontricks_path_which}")
|
||||||
|
self.which_protontricks = 'native'
|
||||||
|
self.protontricks_path = protontricks_path_which
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error reading protontricks executable: {e}")
|
||||||
|
try:
|
||||||
|
env = self._get_clean_subprocess_env()
|
||||||
|
result_user = subprocess.run(
|
||||||
|
["flatpak", "list", "--user"],
|
||||||
|
capture_output=True, text=True, env=env
|
||||||
|
)
|
||||||
|
if result_user.returncode == 0 and "com.github.Matoking.protontricks" in result_user.stdout:
|
||||||
|
self.logger.info("Flatpak Protontricks is installed (user-level)")
|
||||||
|
self.which_protontricks = 'flatpak'
|
||||||
|
self.flatpak_install_type = 'user'
|
||||||
|
return True
|
||||||
|
result_system = subprocess.run(
|
||||||
|
["flatpak", "list", "--system"],
|
||||||
|
capture_output=True, text=True, env=env
|
||||||
|
)
|
||||||
|
if result_system.returncode == 0 and "com.github.Matoking.protontricks" in result_system.stdout:
|
||||||
|
self.logger.info("Flatpak Protontricks is installed (system-level)")
|
||||||
|
self.which_protontricks = 'flatpak'
|
||||||
|
self.flatpak_install_type = 'system'
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Unexpected error checking flatpak: {e}")
|
||||||
|
self.logger.warning("Protontricks not found (native or flatpak).")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_flatpak_run_args(self) -> List[str]:
|
||||||
|
"""Get flatpak run arguments (--user or --system)."""
|
||||||
|
base_args = ["flatpak", "run"]
|
||||||
|
if self.flatpak_install_type == 'user':
|
||||||
|
base_args.append("--user")
|
||||||
|
elif self.flatpak_install_type == 'system':
|
||||||
|
base_args.append("--system")
|
||||||
|
return base_args
|
||||||
|
|
||||||
|
def _get_flatpak_alias_string(self, command=None) -> str:
|
||||||
|
"""Get flatpak alias string for bashrc."""
|
||||||
|
flag = f"--{self.flatpak_install_type}" if self.flatpak_install_type else ""
|
||||||
|
if command:
|
||||||
|
return f"flatpak run {flag} --command={command} com.github.Matoking.protontricks" if flag else f"flatpak run --command={command} com.github.Matoking.protontricks"
|
||||||
|
return f"flatpak run {flag} com.github.Matoking.protontricks" if flag else "flatpak run com.github.Matoking.protontricks"
|
||||||
|
|
||||||
|
def check_protontricks_version(self):
|
||||||
|
"""Check if protontricks version is sufficient (>= 1.12). Returns True if OK."""
|
||||||
|
try:
|
||||||
|
if self.which_protontricks == 'flatpak':
|
||||||
|
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-V"]
|
||||||
|
else:
|
||||||
|
cmd = ["protontricks", "-V"]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
version_str = result.stdout.split(' ')[1].strip('()')
|
||||||
|
cleaned_version = re.sub(r'[^0-9.]', '', version_str)
|
||||||
|
self.protontricks_version = cleaned_version
|
||||||
|
version_parts = cleaned_version.split('.')
|
||||||
|
if len(version_parts) >= 2:
|
||||||
|
major, minor = int(version_parts[0]), int(version_parts[1])
|
||||||
|
if major < 1 or (major == 1 and minor < 12):
|
||||||
|
self.logger.error(f"Protontricks version {cleaned_version} is too old. Version 1.12.0 or newer is required.")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
self.logger.error(f"Could not parse protontricks version: {cleaned_version}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error checking protontricks version: {e}")
|
||||||
|
return False
|
||||||
271
jackify/backend/handlers/protontricks_prefix.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Protontricks prefix/Wine component mixin.
|
||||||
|
Extracted from protontricks_handler for file-size and domain separation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProtontricksPrefixMixin:
|
||||||
|
"""Mixin for Wine prefix operations: dotfiles, win10, prefix path, component install/verify."""
|
||||||
|
|
||||||
|
def enable_dotfiles(self, appid):
|
||||||
|
"""Enable visibility of (.)dot files in the Wine prefix. Returns True on success."""
|
||||||
|
self.logger.debug(f"APPID={appid}")
|
||||||
|
self.logger.info("Enabling visibility of (.)dot files...")
|
||||||
|
try:
|
||||||
|
result = self.run_protontricks(
|
||||||
|
"-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles",
|
||||||
|
appid,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
if result and result.returncode == 0 and "ShowDotFiles" in result.stdout and "Y" in result.stdout:
|
||||||
|
self.logger.info("DotFiles already enabled via registry... skipping")
|
||||||
|
return True
|
||||||
|
elif result and result.returncode != 0:
|
||||||
|
self.logger.info(f"Initial query for ShowDotFiles likely failed (Exit Code: {result.returncode}). Proceeding to set it. Stderr: {result.stderr}")
|
||||||
|
elif not result:
|
||||||
|
self.logger.error("Failed to execute initial dotfile query command.")
|
||||||
|
|
||||||
|
dotfiles_set_success = False
|
||||||
|
self.logger.debug("Attempting to set ShowDotFiles registry key...")
|
||||||
|
result_add = self.run_protontricks(
|
||||||
|
"-c", "WINEDEBUG=-all wine reg add \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles /d Y /f",
|
||||||
|
appid,
|
||||||
|
)
|
||||||
|
if result_add and result_add.returncode == 0:
|
||||||
|
self.logger.info("'wine reg add' command executed successfully.")
|
||||||
|
dotfiles_set_success = True
|
||||||
|
elif result_add:
|
||||||
|
self.logger.warning(f"'wine reg add' command failed (Exit Code: {result_add.returncode}). Stderr: {result_add.stderr}")
|
||||||
|
else:
|
||||||
|
self.logger.error("Failed to execute 'wine reg add' command.")
|
||||||
|
|
||||||
|
self.logger.debug("Ensuring user.reg has correct entry...")
|
||||||
|
prefix_path = self.get_wine_prefix_path(appid)
|
||||||
|
if prefix_path:
|
||||||
|
user_reg_path = Path(prefix_path) / "user.reg"
|
||||||
|
try:
|
||||||
|
if user_reg_path.exists():
|
||||||
|
content = user_reg_path.read_text(encoding='utf-8', errors='ignore')
|
||||||
|
has_correct_format = '[Software\\\\Wine]' in content and '"ShowDotFiles"="Y"' in content
|
||||||
|
has_broken_format = '[SoftwareWine]' in content and '"ShowDotFiles"="Y"' in content
|
||||||
|
if has_broken_format and not has_correct_format:
|
||||||
|
self.logger.debug(f"Found broken ShowDotFiles format in {user_reg_path}, fixing...")
|
||||||
|
content = content.replace('[SoftwareWine]', '[Software\\\\Wine]')
|
||||||
|
user_reg_path.write_text(content, encoding='utf-8')
|
||||||
|
dotfiles_set_success = True
|
||||||
|
elif not has_correct_format:
|
||||||
|
self.logger.debug(f"Adding ShowDotFiles entry to {user_reg_path}")
|
||||||
|
with open(user_reg_path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write('\n[Software\\\\Wine] 1603891765\n')
|
||||||
|
f.write('"ShowDotFiles"="Y"\n')
|
||||||
|
dotfiles_set_success = True
|
||||||
|
else:
|
||||||
|
self.logger.debug("ShowDotFiles already present in correct format in user.reg")
|
||||||
|
dotfiles_set_success = True
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
|
||||||
|
with open(user_reg_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('[Software\\\\Wine] 1603891765\n')
|
||||||
|
f.write('"ShowDotFiles"="Y"\n')
|
||||||
|
dotfiles_set_success = True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Error reading/writing user.reg: {e}")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Could not get WINEPREFIX path, skipping user.reg modification.")
|
||||||
|
|
||||||
|
self.logger.debug("Verifying dotfile setting after attempts...")
|
||||||
|
verify_result = self.run_protontricks(
|
||||||
|
"-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles",
|
||||||
|
appid,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
query_verified = False
|
||||||
|
if verify_result and verify_result.returncode == 0 and "ShowDotFiles" in verify_result.stdout and "Y" in verify_result.stdout:
|
||||||
|
self.logger.debug("Verification query successful and key is set.")
|
||||||
|
query_verified = True
|
||||||
|
elif verify_result:
|
||||||
|
self.logger.info(f"Verification query failed or key not found (Exit Code: {verify_result.returncode}). Stderr: {verify_result.stderr}")
|
||||||
|
else:
|
||||||
|
self.logger.error("Failed to execute verification query command.")
|
||||||
|
|
||||||
|
if dotfiles_set_success:
|
||||||
|
if query_verified:
|
||||||
|
self.logger.info("Dotfiles enabled and verified successfully!")
|
||||||
|
else:
|
||||||
|
self.logger.info("Dotfiles potentially enabled (reg add/user.reg succeeded), but verification query failed.")
|
||||||
|
return True
|
||||||
|
self.logger.error("Failed to enable dotfiles using registry and user.reg methods.")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Unexpected error enabling dotfiles: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_win10_prefix(self, appid):
|
||||||
|
"""Set Windows 10 version in the proton prefix. Returns True on success."""
|
||||||
|
try:
|
||||||
|
env = self._get_clean_subprocess_env()
|
||||||
|
env["WINEDEBUG"] = "-all"
|
||||||
|
if self.which_protontricks == 'flatpak':
|
||||||
|
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "--no-bwrap", appid, "win10"]
|
||||||
|
else:
|
||||||
|
cmd = ["protontricks", "--no-bwrap", appid, "win10"]
|
||||||
|
subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error setting Windows 10 prefix: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_wine_prefix_path(self, appid) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the WINEPREFIX path for a given AppID.
|
||||||
|
Uses native path discovery when enabled, else protontricks -c echo $WINEPREFIX.
|
||||||
|
"""
|
||||||
|
if self.use_native_operations:
|
||||||
|
self.logger.debug(f"Getting WINEPREFIX for AppID {appid} via native path discovery")
|
||||||
|
try:
|
||||||
|
return self._get_native_steam_service().get_wine_prefix_path(appid)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Native WINEPREFIX detection failed, falling back to protontricks: {e}")
|
||||||
|
|
||||||
|
self.logger.debug(f"Getting WINEPREFIX for AppID {appid}")
|
||||||
|
result = self.run_protontricks("-c", "echo $WINEPREFIX", appid)
|
||||||
|
if result and result.returncode == 0 and result.stdout.strip():
|
||||||
|
prefix_path = result.stdout.strip()
|
||||||
|
self.logger.debug(f"Detected WINEPREFIX: {prefix_path}")
|
||||||
|
return prefix_path
|
||||||
|
self.logger.error(f"Failed to get WINEPREFIX for AppID {appid}. Stderr: {result.stderr if result else 'N/A'}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None):
|
||||||
|
"""
|
||||||
|
Install Wine components into the prefix using protontricks.
|
||||||
|
If specific_components is None, use default set (fontsmooth=rgb, xact, xact_x64, vcrun2022).
|
||||||
|
"""
|
||||||
|
self.logger.info("=" * 80)
|
||||||
|
self.logger.info("USING PROTONTRICKS")
|
||||||
|
self.logger.info("=" * 80)
|
||||||
|
env = self._get_clean_subprocess_env()
|
||||||
|
env["WINEDEBUG"] = "-all"
|
||||||
|
|
||||||
|
if self.which_protontricks == 'native':
|
||||||
|
winetricks_path = self._get_bundled_winetricks_path()
|
||||||
|
if winetricks_path:
|
||||||
|
env['WINETRICKS'] = str(winetricks_path)
|
||||||
|
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||||
|
cabextract_path = self._get_bundled_cabextract_path()
|
||||||
|
if cabextract_path:
|
||||||
|
cabextract_dir = str(cabextract_path.parent)
|
||||||
|
current_path = env.get('PATH', '')
|
||||||
|
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
|
||||||
|
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
|
||||||
|
|
||||||
|
from ..handlers.config_handler import ConfigHandler
|
||||||
|
config_handler = ConfigHandler()
|
||||||
|
debug_mode = config_handler.get('debug_mode', False)
|
||||||
|
if not debug_mode:
|
||||||
|
env['WINETRICKS_SUPER_QUIET'] = '1'
|
||||||
|
self.logger.debug("Set WINETRICKS_SUPER_QUIET=1 in install_wine_components to suppress winetricks verbose output")
|
||||||
|
|
||||||
|
from jackify.shared.paths import get_jackify_data_dir
|
||||||
|
jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache'
|
||||||
|
jackify_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._ensure_flatpak_cache_access(jackify_cache_dir)
|
||||||
|
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
|
||||||
|
self.logger.info(f"Using winetricks cache: {jackify_cache_dir}")
|
||||||
|
|
||||||
|
if specific_components is not None:
|
||||||
|
components_to_install = specific_components
|
||||||
|
self.logger.info(f"Installing specific components: {components_to_install}")
|
||||||
|
else:
|
||||||
|
components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
|
||||||
|
self.logger.info(f"Installing default components: {components_to_install}")
|
||||||
|
if not components_to_install:
|
||||||
|
self.logger.info("No Wine components to install.")
|
||||||
|
return True
|
||||||
|
self.logger.info(f"AppID: {appid}, Game: {game_var}, Components: {components_to_install}")
|
||||||
|
max_attempts = 3
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
if attempt > 1:
|
||||||
|
self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...")
|
||||||
|
self._cleanup_wine_processes()
|
||||||
|
try:
|
||||||
|
result = self.run_protontricks("--no-bwrap", appid, "-q", *components_to_install, env=env, timeout=600)
|
||||||
|
self.logger.debug(f"Protontricks output: {result.stdout if result else ''}")
|
||||||
|
if result and result.returncode == 0:
|
||||||
|
self.logger.info("Wine Component installation command completed.")
|
||||||
|
if self._verify_components_installed(appid, components_to_install):
|
||||||
|
self.logger.info("Component verification successful - all components installed correctly.")
|
||||||
|
return True
|
||||||
|
self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Protontricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode if result else 'N/A'}")
|
||||||
|
config_handler = ConfigHandler()
|
||||||
|
debug_mode = config_handler.get('debug_mode', False)
|
||||||
|
if debug_mode:
|
||||||
|
self.logger.error(f"Stdout: {result.stdout.strip() if result else ''}")
|
||||||
|
self.logger.error(f"Stderr: {result.stderr.strip() if result else ''}")
|
||||||
|
elif result and result.stderr:
|
||||||
|
stderr_lower = result.stderr.lower()
|
||||||
|
if any(k in stderr_lower for k in ['error', 'failed', 'cannot', 'warning: cannot find']):
|
||||||
|
error_lines = [line for line in result.stderr.strip().split('\n')
|
||||||
|
if any(k in line.lower() for k in ['error', 'failed', 'cannot', 'warning: cannot find'])
|
||||||
|
and 'executing' not in line.lower()]
|
||||||
|
if error_lines:
|
||||||
|
self.logger.error(f"Stderr (errors only): {' '.join(error_lines)}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error during protontricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True)
|
||||||
|
self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _verify_components_installed(self, appid: str, components: List[str]) -> bool:
|
||||||
|
"""Verify every requested component is present in protontricks list-installed."""
|
||||||
|
try:
|
||||||
|
self.logger.info("Verifying installed components...")
|
||||||
|
result = self.run_protontricks("--no-bwrap", appid, "list-installed", timeout=30)
|
||||||
|
if not result or result.returncode != 0:
|
||||||
|
self.logger.error("Failed to query installed components")
|
||||||
|
self.logger.debug(f"list-installed stderr: {result.stderr if result else 'N/A'}")
|
||||||
|
return False
|
||||||
|
installed_output = result.stdout.lower()
|
||||||
|
self.logger.debug(f"Installed components output: {installed_output}")
|
||||||
|
missing = []
|
||||||
|
for component in components:
|
||||||
|
base_component = component.split('=')[0].lower()
|
||||||
|
if base_component in installed_output or component.lower() in installed_output:
|
||||||
|
continue
|
||||||
|
missing.append(component)
|
||||||
|
if missing:
|
||||||
|
self.logger.error(f"Components not in list-installed: {missing}")
|
||||||
|
return False
|
||||||
|
self.logger.info("Verification passed - all components in list-installed")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error verifying components: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _cleanup_wine_processes(self):
|
||||||
|
"""Clean up wine-related processes during component installation."""
|
||||||
|
try:
|
||||||
|
subprocess.run("pgrep -f 'win7|win10|ShowDotFiles|protontricks' | xargs -r kill -9",
|
||||||
|
shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
subprocess.run("pkill -9 winetricks",
|
||||||
|
shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error cleaning up wine processes: {e}")
|
||||||