37 Commits
v0.2.2 ... main

Author SHA1 Message Date
Omni
33b3fbaed2 Release v0.6.0.1 - Hotfix 2026-04-24 19:59:36 +01:00
Omni
2ff09a1448 Release v0.6.0 2026-04-20 20:57:23 +01:00
Omni-guides
69fabb32e6 Update README.md 2026-04-15 20:46:41 +01:00
Omni-guides
6453665620 Update README.md 2026-04-15 16:42:11 +01:00
Omni-guides
cacbbf1fb1 Update README.md 2026-04-15 16:41:47 +01:00
Omni
c3551cd269 Sync from development - prepare for v0.5.0.4 2026-03-29 15:46:37 +01:00
Omni
8e4dd06f11 Sync from development - prepare for v0.5.0.3 2026-03-23 13:46:27 +00:00
Omni
e52e1427f6 Sync from development - prepare for v0.5.0.2 2026-03-15 11:03:28 +00:00
Omni
c294431a35 Sync from development - prepare for v0.5.0.1 2026-03-13 23:04:46 +00:00
Omni
7278efd4cd Remove stale non-premium future plan note 2026-03-13 16:40:25 +00:00
Omni
b29568f590 Clarify non-premium support in README 2026-03-13 16:35:29 +00:00
Omni
3556914560 Sync from development - prepare for v0.5.0 2026-03-13 14:43:25 +00:00
Omni
411addeea2 Sync from development - prepare for v0.4.0 2026-02-25 21:16:02 +00:00
Omni
805718222a Sync from development - prepare for v0.4.0 2026-02-25 20:54:28 +00:00
Omni-guides
2eb54b9a36 Revise README for clarity and additional resources
Updated links and clarified instructions for modlist installation options.
2026-02-24 14:17:12 +00:00
Omni-guides
9cc5245db7 Revise README for clarity and updated features
Updated project description and features in README.
2026-02-24 13:41:18 +00:00
Omni-guides
69738e8e9e Add files via upload 2026-02-24 12:17:07 +00:00
Omni-guides
fdee639734 Delete assets/images/wiki/ModlistGuides/ConfigureNew/new.txt 2026-02-24 11:58:25 +00:00
Omni-guides
b123f6f509 Delete assets/images/wiki/ModlistGuides/ConfigureExisting/new.txt 2026-02-24 11:58:14 +00:00
Omni-guides
368e1bf5ef Add files via upload 2026-02-24 11:57:55 +00:00
Omni-guides
ebf61f67db Add files via upload 2026-02-24 11:57:27 +00:00
Omni-guides
15b90d823c Create new.txt 2026-02-24 11:57:04 +00:00
Omni-guides
309303f721 Create new.txt 2026-02-24 11:56:50 +00:00
Omni-guides
59e03eb38e Add files via upload 2026-02-22 21:30:42 +00:00
Omni-guides
f278b9a8b5 Add files via upload 2026-02-22 21:30:06 +00:00
Omni-guides
9a10812796 Delete assets/images/wiki/ModlistGuides/Jackify/new.txt 2026-02-22 21:29:26 +00:00
Omni-guides
61cfda5dac Add files via upload 2026-02-22 21:29:04 +00:00
Omni-guides
9560c1b72a Delete assets/images/wiki/ModlistGuides/AdditionalTools/new.txt 2026-02-22 21:26:29 +00:00
Omni-guides
5be65c25ac Add files via upload 2026-02-22 21:26:02 +00:00
Omni-guides
053aab04a9 Create new.txt 2026-02-22 21:19:40 +00:00
Omni-guides
f8cdd26d64 Create new.txt 2026-02-22 21:19:20 +00:00
Omni-guides
bc6c0f2e1f Create new.txt 2026-02-22 21:19:02 +00:00
Omni
12294d3186 Sync from development - prepare for v0.3.0 2026-02-07 18:26:54 +00:00
Omni
b55e1cf768 Sync from development - prepare for v0.2.2.2 2026-01-28 22:19:40 +00:00
Omni
8e49602714 Sync from development - prepare for v0.2.2.2 2026-01-28 22:16:55 +00:00
Omni
98a9a4c7c6 Sync from development - prepare for v0.2.2.2 2026-01-28 22:13:51 +00:00
Omni
286d51e6a1 Sync from development - prepare for v0.2.2.1 2026-01-24 22:02:29 +00:00
530 changed files with 43580 additions and 35562 deletions

View File

@@ -1,5 +1,213 @@
# Jackify Changelog
## v0.6.0.1 - Hotfix
**Release Date:** 24/04/26
- Resolved some issues with the integration of jackify-engine 0.5.4.
---
## v0.6 - Game Support Expansion, Modding Tool Support, Post-Install Quality
**Release Date:** 20/04/26
### New Game Support
- Additional Game Support - Post-Install automation for BG3, Skyrim VR, and Fallout 4 VR.
- Skyrim VR / Fallout 4 VR: if your modlist needs additional steps you know of, that Jackify does not yet handle, please open an issue on GitHub with your modlist name and the additional steps required. I cannot testing FO4VR directly as I dont own the game.
### Modding Tool Support
- Initial compatibility settings for xEdit, Synthesis, and Pandora are applied automatically during install and configure. Re-apply any time via "Configure Tool Compatibility" in Additional Tasks.
### Steam Shortcut Graphics
- Steam grid artwork now automatically applied to each shortcut, populating all five slots correctly (portrait, landscape, hero, logo, tenfoot).
### First-Launch Reliability
- First Launch Fixes - Skyrim SE modlists should now launch cleanly first time. No more first-launch crash, incorrect AE/CC popup display, initial NXM prompt in MO2, character creation issues, and wrong initial save location.
### Fixes
- Configuration no longer wipes game install paths. Registry writes are now targeted rather than full-prefix replacements.
- Fixed crashes on shutdown caused by force-killing background threads.
### Logging
- Console output reduced to errors only. All informational output goes to the log file and Show Details panel.
### Engine (0.5.4)
- Fixed Nexus sessions silently expiring after installs longer than ~1 hour. The engine now persists refreshed OAuth tokens so you stay logged in across long installs.
- Fixed large downloads hanging indefinitely if a Nexus CDN connection stalled mid-transfer. Downloads now recover automatically and resume from where they left off.
- Removed the disk space pre-flight check, which was incorrectly blocking installs for users with sufficient space. Out-of-disk conditions are still caught and reported if they actually occur.
---
## v0.5.0.4 - Hotfix
**Release Date:** 29/03/26
- Fixed self-update failing silently due to the downloaded archive overwriting the extraction target before the update helper could apply it.
- Engine updated to 0.5.3. NAME_MAX pre-flight check removed — was incorrectly blocking installs on standard filesystems. eCryptFS/fscrypt users still receive an error at the point of failure.
- Fixed Google Drive downloads failing. The Wabbajack CDN proxy was returning a cached broken response for some files; the engine now detects the hash mismatch, retries direct, and constructs a `drive.usercontent.google.com` URL with `confirm=t` to bypass the virus-scan warning page.
- Fixed focus stealing from other windows during the Wine component install phase.
- Fixed a crash on window close from a leaked focus-reclaim timer.
- Baloo file indexer suspended during install and config phases on KDE. No-op elsewhere.
- Fixed Flatpak protontricks install failing on fresh Steam Decks due to Flathub not being registered at user scope.
## v0.5.0.3 - Hotfix
**Release Date:** 23/03/26
- Engine updated to 0.5.2.
- Fixed manual downloads getting stuck on "Browser Opened" when the expected filename has a leading numeric prefix (e.g. `1_filename.zip`) that is absent from the browser-saved file. Both the live download watcher and the startup precheck scan now handle this correctly.
- Fixed "Continue Anyway" on the disk space warning having no effect. The flag was missing from the CLI argument parser, and a separate engine-level registration bug caused it to be rejected regardless. Both are now resolved. The dialog also correctly displays separate download and install space requirements and notes when both paths share the same drive.
- Fixed FNV, FO3, and Enderal modlists losing their game registry paths after configuration. The curated registry files applied during the configuration phase overwrite the Wine prefix registry entirely, wiping the game install paths injected earlier. Jackify now re-injects the correct paths immediately after the curated files are applied.
- Improved detection and guidance for modlists that require the Skyrim Special Edition Creation Kit. If the engine reports missing Creation Kit files, Jackify now surfaces step-by-step instructions for installing and first-launching the Creation Kit via Steam so the required files are in place before retrying.
- Filesystem filename length limit (NAME_MAX) no longer hard-blocks installation on standard filesystems. The check previously triggered incorrectly on ext4/btrfs/XFS. For users on encrypted home directories where the limit is genuinely reduced, Jackify now shows a warning dialog listing the affected files with a "Continue Anyway" option.
- Archive index errors now produce an actionable failure message identifying the specific archive to delete and re-download, rather than a bare engine exception.
- TTW installer temporary working files are now cleaned up after each TTW installation run. These files were previously never removed and could accumulate several GB per install attempt.
- Each GitHub release now includes a `SHA256SUMS` file for verifying your download. See the README for instructions.
## v0.5.0.2 - Hotfix
**Release Date:** 15/03/26
- Disk space warning at install start is no longer a hard block. If the pre-flight check fires before any download or install progress has started, Jackify now shows a warning dialog with the required and available space, a note that modlist updates typically need far less space than a fresh install, and a "Continue Anyway" option. Cancelling still aborts normally.
- Engine: fixed a false-positive in the pre-flight filename length check that could incorrectly trigger on modlist paths using backslash separators.
- Engine: temp folder cleanup at the end of install no longer crashes an otherwise successful installation if a BSA or temp directory is still locked.
## v0.5.0.1 - Hotfix
**Release Date:** 13/03/26
- Fixed Proton prefix creation failing for users who previously had Flatpak Steam installed but have since switched to native Steam.
- Fixed Configure Existing Modlist mangling binary and working directory paths for modlists using a `StockGame` folder (no space variant).
## v0.5.0 - Non-Premium Support, Modlist Update Handling and Overall Reliability Improvements
**Release Date:** 13/03/26
### New in v0.5.0
- Full non-premium install support in both GUI and CLI. Feedback is welcome on this new feature, both positive and negative
- New Jackify Download Manager for Non-Premium accounts, or files Jackify cannot auto-download.
- Improved modlist update handling so existing installs are detected more reliably and Jackify can reuse the existing setup instead of creating duplicate Steam shortcuts.
- Improved Viva New Vegas automation across GUI and CLI paths.
- Improved Wabbajack and Mod Organizer 2 standalone installation workflows.
- Better guidance when Skyrim AE/CC content is missing.
- Further improvements on user-facing logging and error handling
### Manual Download Improvements
- Handles manual downloads more smoothly from start to finish:
- opens required links for you in your system Browser
- watches your download folder
- verifies files and moves them to the correct location automatically,continues with the rest of the modlist install when ready
- Better controls in both GUI and CLI:
- pause/resume download flows, or defer individual archives (useful if one is temporarily unavailable)
- retry deferred items
- reopen file links
- change concurrent browser tab count
- change watch folder
- Deferred items (e.g temporarily unavailable) are retried correctly on later retry/recheck passes.
### Update and Install Reliability
- Worked to improve feature parity between the GUI and CLI frontends, tidying up a few edge cases where CLI behavior did not yet match GUI workflows closely enough.
- Improved update messaging (clearer wording on success/failure).
- Better cancellation handling so stopping a workflow is less likely to leave background processes running.
- Better focus recovery after Steam restart in key workflows.
- Better handling when both Flatpak and native Steam are installed: Jackify now prefers the Steam install that actually contains your installed games, with safe fallback rules if both look valid.
- Install Proton selection now self-heals on startup if the configured Proton was removed, automatically falling back to the best available installed Proton.
- For `/var/home`-based installs (for example Bazzite layouts), ModOrganizer.ini path basis is now aligned so executable/working/game paths resolve correctly.
### Nexus Authentication
- OAuth protocol handler desktop file is now updated if the registered AppImage path no longer matches the current location, preventing silent callback failures after the AppImage is moved or renamed.
- OAuth waiting dialog now includes a "Paste callback URL" button for manual fallback if the browser does not dispatch the jackify:// callback automatically.
### Logging and Error Quality
- Better targeted guidance when required prerequisites or content are missing.
- Improved logging around updater source selection (Nexus/GitHub fallback behavior).
- Better error context while keeping sensitive tokens/keys redacted.
- Install failure fallback now surfaces recent actionable engine output (and resource-limit warnings) instead of only a generic exit-code message.
### Updated jackify-engine to 0.5.0:
- Improved non-premium/manual-download support through a structured manual-download protocol that lets Jackify pause, guide the user, recheck files, and continue installation cleanly once required archives are present.
- Better pre-flight validation before large downloads begin, including earlier checks for game availability, disk space, and filesystem path-length limits.
- More accurate structured error handling for installation failures, with better classification of storage, permission, network, authentication, and validation issues.
---
## v0.4.0 - Error Handling Rewrite
**Release Date:** 2026-02-25
### New Features
- Structured error handling across GUI and CLI with typed `JackifyError` dialogs (clear message, suggested action, numbered recovery steps, optional technical detail).
- Structured engine error receiver: stderr JSON errors are parsed and mapped to user-facing error types, with exit-code fallback.
- Nexus account tier indicator in Settings OAuth (`[Premium]` / `[Free]`) with cached status checks.
- Modlist metadata support via `.jackify_meta.json`, written after install and used by configure workflows.
- TTW eligibility workflow expanded:
- Configure New / Configure Existing can trigger TTW workflow when eligible.
- CLI `configure-modlist` now prompts TTW when eligible.
- FO3 support in configure workflows, including prefix/registry handling.
- Standalone MO2 setup in Additional Tasks (GUI and CLI).
### Bug Fixes
- Proton auto-detection reliability improved, including GE-Proton ranking and fallback behavior.
- Added detection support for system-packaged Proton layouts (Issue #162).
- Download stall false positives reduced by checking byte advancement instead of speed readout alone.
- Flatpak Steam access handling improved with install-directory override support.
- TTW installer output directory is pre-populated to the modlist location.
- Unknown game fallback behavior improved so Wine component installation can continue where appropriate.
### Improvements
- GUI debug log naming standardised to `jackify-debug.log`.
- Error reporting/logging flow cleaned up to improve user facing info and hopefully ease support.
- "Lazy" GUI screen initialization (main menu first, other screens on demand).
- Proton handling improved with Valve Proton fallback when GE-Proton is unavailable.
- FNV/FO3/Enderal registry injection now attempts canonical `C:\Program Files (x86)\Steam\steamapps\common\<Game>` paths via in-prefix symlink, with fallback to real `Z:/D:` paths if symlink creation fails. Looking forward to feedback on this one if anyone still has their FNV launcher only show "Install" instead of "Play".
### Engine Updates
- jackify-engine updated to `0.4.8`.
- Archive download progress improvements (remaining size + ETA).
- Download speed reporting reliability improvements on Linux.
- ZIP extraction fixes for Cyrillic filenames.
---
## v0.3.0 - Codebase Refactoring
**Release Date:** 2026-02-06
### Technical Improvements
- **Code Architecture**: Refactored 13 large files (1000-5000 lines each) into 50+ focused modules using mixin pattern. All main files now under 600 lines.
### Bug Fixes
- **Configure New Modlist GUI**: Fixed window not shrinking when Show Details unchecked
- **CLI Wabbajack Installer**: Added missing installation command to CLI menu
- **Wabbajack Installer**: Fixed installation to non-primary disk
### Improvements
- **Wabbajack Install - Honour Install Proton**: Wabbajack installer now uses the user's selected Install Proton from Settings (same as modlist install/configure). Previously hardcoded to Proton Experimental. Fallback to Proton Experimental when no selection or path invalid.
- **STEAM_COMPAT_MOUNTS (Issue #155)**: Launch options now include mountpoints for both the modlist install path and the download path when known, so MO2 can access game and downloads on different drives. Uses new mountpoint helper and passes install_dir/download_dir through the Install a Modlist workflow.
- **MO2 download_directory (Issue #154)**: When configuring after Install a Modlist, Jackify now sets `download_directory` in ModOrganizer.ini to the correct Wine path (Z: or D: on SD card) so MO2 finds the download folder. Configure New and Configure Existing continue to leave or blank the key as before.
- **Winetricks / Protontricks**: For Flatpak Steam, use protontricks only. Winetricks alone struggles with the flatpak sandbox.
- **Wine Component Animation**: Added pulser animation for individual wine component installation progress in Configure Existing and Install Modlist workflows
- **Wabbajack Installer Log Rotation**: Added log rotation for Wabbajack installer workflow logs
---
## v0.2.2.2 - ModOrganizer.ini Path Fixes for SD Card Installations
**Release Date:** 2026-01-28
### Bug Fixes
- **ModOrganizer.ini Path Mangling**: Fixed incorrect drive letter assignment when modlist is on SD card but vanilla game is on internal storage. Now uses gamePath drive letter as source of truth for vanilla game paths.
- **Proton Config Name Mismatch (Issues #150, #151)**: Fixed incorrect Proton names written to Steam config.vdf CompatToolMapping. Naive string conversion produced wrong names (e.g., `proton_9.0_(beta)` instead of `proton_9`). Now resolves correct internal names from `compatibilitytool.vdf` (third-party) or App ID mapping (Valve Proton). CachyOS and other community Proton builds in `compatibilitytools.d/` are now detected and selectable.
- **Removed Lorerim/Lost Legacy Proton Override**: No longer forces Proton 9 for specific modlists. ENB compatibility warnings are handled by the success dialog instead.
### Engine Updates
- **jackify-engine 0.4.7**: Fixed incorrect quoting/escaping of MO2 `customExecutables` by writing clean, unquoted Proton `Z:\...` paths in `ModOrganizer.ini`. This eliminates engine-side quote corruption that previously triggered SD card path mangling issues.
### Improvements
- **Improved Wine Component install debug log output**: Will now print the full command being used for winetricks and protontricks when debug mode is enabled, making it easier to reproduce issues manually.
---
## v0.2.2.1 - TTW Installer Pinning and Configure New Modlist CLI Fix
**Release Date:** 2026-01-24
### Bug Fixes
- **Configure New Modlist CLI**: Fixed manual Proton setup prompts appearing in CLI. Now uses automated prefix workflow like the install command.
- **TTW_Linux_Installer Version Pinning**: Pinned to v0.0.7. Will re-introduce latest version following more testing.
---
## v0.2.2 - VNV Automation and First-Launch Improvements
**Release Date:** 2026-01-21
@@ -1154,4 +1362,4 @@ This release completes the logging refactor that was blocking development workfl
- Modular handler architecture for extensibility.
## v0.0.09 and Earlier
See commit history for previous versions.
See commit history for previous versions.

172
README.md
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,760 @@
"""CLI configuration phase methods for ModlistInstallCLI (Mixin)."""
import json
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")
writeback_path = str(auth_service.get_token_writeback_path())
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
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,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=False,
env=clean_env,
cwd=engine_dir,
)
proc = self._current_process
def _write_stdin(payload: str) -> bool:
if not proc.stdin or proc.poll() is not None:
return False
try:
proc.stdin.write((payload + '\n').encode('utf-8'))
proc.stdin.flush()
return True
except Exception:
self.logger.debug("Failed writing to engine stdin", exc_info=True)
return False
buffer = b''
inline_progress_active = False
pending_manual = []
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk in (b'\n', b'\r'):
line = buffer.decode('utf-8', errors='replace')
decoded = line.rstrip('\r\n')
if decoded.startswith('{'):
try:
event = json.loads(decoded)
except (json.JSONDecodeError, ValueError):
event = None
if event:
event_name = event.get('event')
if event_name == 'manual_download_required':
pending_manual.append(event)
buffer = b''
continue
if event_name == 'manual_download_list_complete':
loop_iter = event.get('loop_iteration', 1)
for item in pending_manual:
item['loop_iteration'] = loop_iter
from jackify.backend.handlers.config_handler import ConfigHandler
raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2)
try:
manual_limit = int(raw_limit)
except (TypeError, ValueError):
manual_limit = 2
from jackify.frontends.cli.commands.manual_download_flow import run_cli_manual_download_phase
completed = run_cli_manual_download_phase(
events=list(pending_manual),
loop_iteration=loop_iter,
download_dir=actual_download_path,
stdin_write=_write_stdin,
concurrent_limit=max(1, min(5, manual_limit)),
)
if not completed:
if proc.poll() is None:
proc.terminate()
buffer = b''
break
pending_manual.clear()
buffer = b''
continue
if event_name == 'manual_download_phase_complete':
print("All manual downloads confirmed. Resuming installation...")
buffer = b''
continue
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''
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
auth_service.apply_token_writeback(writeback_path)
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'
existing_shortcut_appid = self.context.get('existing_shortcut_appid')
update_existing_install = bool(self.context.get('update_existing_install'))
if update_existing_install and existing_shortcut_appid:
app_id = str(existing_shortcut_appid)
success = True
prefix_path = None
result = True
print(f"\n{COLOR_INFO}Update mode selected. Reusing existing Steam shortcut AppID {app_id}.{COLOR_RESET}")
use_automated_prefix = False
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:
if update_existing_install and app_id:
print(f"{COLOR_SUCCESS}Update mode Steam setup confirmed.{COLOR_RESET}")
print(f"{COLOR_INFO}Reusing Steam AppID: {app_id}{COLOR_RESET}")
# Apply artwork and restart Steam -- skipped in update path since the full
# workflow is bypassed, but artwork and Steam state still need refreshing.
_game_type = self.context.get('detected_game') or self.context.get('special_game_type')
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
ModlistHandler().set_steam_grid_images(str(app_id), install_dir_str, game_type=_game_type)
except Exception as e:
self.logger.warning("Failed to apply Steam artwork in update mode: %s", e)
if _game_type == 'cp2077':
# CP2077 launch options may be absent on lists originally installed
# under v0.5 before CP2077 support was added.
try:
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.handlers.config_handler import ConfigHandler
sh = ShortcutHandler(
config_handler=ConfigHandler(),
steamdeck=bool(self.system_info and self.system_info.is_steamdeck),
)
sh.update_shortcut_launch_options(
shortcut_name,
mo2_exe_path,
'WINEDLLOVERRIDES="version=n,b;winmm=n,b" %command%',
)
except Exception as e:
self.logger.warning("Failed to update CP2077 launch options in update mode: %s", e)
try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
AutomatedPrefixService(self.system_info).restart_steam()
except Exception as e:
self.logger.warning("Failed to restart Steam in update mode: %s", e)
else:
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:
self.logger.info("Post-installation configuration completed successfully")
print(f"{COLOR_INFO}Core configuration complete. Checking post-install automation...{COLOR_RESET}")
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,
should_offer_vnv_automation,
)
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.handlers.path_handler import PathHandler
from jackify.frontends.cli.commands.vnv_manual_downloads import (
build_vnv_cli_manual_file_callback,
create_vnv_cli_progress_callback,
ensure_vnv_cli_manual_downloads,
)
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")
install_path = Path(install_dir_str)
if should_offer_vnv_automation(modlist_name_for_automation, install_path):
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
vnv_service = VNVPostInstallService(
modlist_install_location=install_path,
game_root=resolved_game_root or install_path,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = vnv_service.check_already_completed()
all_vnv_steps_done = (
completed['root_mods']
and completed['4gb_patch']
and completed['bsa_decompressed']
)
if all_vnv_steps_done:
print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}")
elif _confirm_vnv(vnv_service.get_automation_description()):
if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print):
print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}")
else:
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
try:
automation_ran, vnv_error = run_vnv_automation_if_applicable(
modlist_name=modlist_name_for_automation,
modlist_install_location=install_path,
game_root=None, # Auto-detect from modlist structure.
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=progress_callback,
manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print),
confirmation_callback=lambda _description: True,
)
finally:
close_progress()
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}")
else:
print(f"{COLOR_INFO}VNV automation skipped by user.{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}")
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
self.logger.warning("Post-installation configuration had issues")
else:
print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}")
if detected_game:
print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Could not detect game type from ModOrganizer.ini for automated configuration.{COLOR_RESET}")
print(f"{COLOR_INFO}You may need to manually configure the modlist for Steam/Proton.{COLOR_RESET}")

View File

@@ -0,0 +1,170 @@
"""GUI configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
logger = logging.getLogger(__name__)
class ModlistOperationsConfigurationGUIMixin:
"""Mixin providing GUI configuration phase methods."""
def configuration_phase_gui_mode(self, context,
progress_callback=None,
manual_steps_callback=None,
completion_callback=None):
"""
GUI-friendly configuration phase that uses callbacks instead of prompts.
This method provides the same functionality as configuration_phase() but
integrates with GUI frontends using Qt callbacks instead of CLI prompts.
Args:
context: Configuration context dict with modlist details
progress_callback: Called with progress messages (str)
manual_steps_callback: Called when manual steps needed (modlist_name, retry_count)
completion_callback: Called when configuration completes (success, message, modlist_name)
"""
try:
from .modlist_operations import _get_user_proton_version
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
try:
config_context = {
'name': context.get('modlist_name', ''),
'path': context.get('install_dir', ''),
'mo2_exe_path': context.get('mo2_exe_path', ''),
'modlist_value': context.get('modlist_value'),
'modlist_source': context.get('modlist_source'),
'resolution': context.get('resolution'),
'skip_confirmation': True,
'manual_steps_completed': False
}
existing_app_id = context.get('app_id')
if existing_app_id:
config_context['appid'] = existing_app_id
if progress_callback:
progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...")
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
retry_count = 0
max_retries = 3
while retry_count < max_retries:
if progress_callback:
progress_callback("Running modlist configuration...")
result = modlist_menu.run_modlist_configuration_phase(config_context)
if progress_callback:
progress_callback(f"Configuration attempt {retry_count}: {'Success' if result else 'Failed'}")
if result:
if completion_callback:
completion_callback(True, "Core configuration complete", 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, "Core configuration complete", config_context['name'])
return True
else:
if progress_callback:
progress_callback("Configuration failed, manual Steam/Proton setup required")
if manual_steps_callback:
if progress_callback:
progress_callback(f"About to call manual_steps_callback for {config_context['name']}, retry 1")
manual_steps_callback(config_context['name'], 1)
if progress_callback:
progress_callback("manual_steps_callback completed")
return True
if completion_callback:
completion_callback(False, "Configuration failed", config_context['name'])
return False
finally:
if original_gui_mode is not None:
os.environ['JACKIFY_GUI_MODE'] = original_gui_mode
else:
os.environ.pop('JACKIFY_GUI_MODE', None)
except Exception as e:
error_msg = f"Configuration failed: {str(e)}"
if completion_callback:
completion_callback(False, error_msg, context.get('modlist_name', 'Unknown'))
return False

View File

@@ -0,0 +1,408 @@
"""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']}")
install_dir_value = self.context.get('install_dir')
install_dir_real = os.path.realpath(str(install_dir_value[0] if isinstance(install_dir_value, tuple) else install_dir_value))
existing_appid = self._find_existing_shortcut_appid(self.context['modlist_name'], install_dir_real)
eligible_update, update_meta = self._evaluate_update_candidate(
self.context['modlist_name'],
install_dir_real,
existing_appid,
)
if eligible_update:
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Existing modlist installation detected in this directory.{COLOR_RESET}")
relation = update_meta.get("version_relation")
if relation == "different":
print(
f"{COLOR_INFO}Detected version change: installed v{update_meta.get('installed_version')} -> "
f"selected v{update_meta.get('requested_version')}.{COLOR_RESET}"
)
elif relation == "same" and update_meta.get("installed_version"):
print(
f"{COLOR_INFO}Detected same version (v{update_meta.get('installed_version')}). "
"Use update mode for repair/reconfigure behavior." + f"{COLOR_RESET}"
)
print("Choose how to proceed:")
print(" 1. Update existing install (recommended)")
print(" 2. New install with a different Steam shortcut name")
print(" 0. Cancel")
update_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if update_choice == "1":
self.context['update_existing_install'] = True
self.context['existing_shortcut_appid'] = existing_appid
self.logger.info("CLI update mode selected; reusing AppID %s", existing_appid)
elif update_choice == "2":
print(
f"{COLOR_WARNING}For a new install, choose a different Modlist Name before proceeding.{COLOR_RESET}"
)
return None
else:
self.logger.info("User cancelled at CLI update detection prompt.")
return None
if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
authenticated, method, username = auth_service.get_auth_status()
if authenticated:
if method == 'oauth':
print("\n" + "-" * 28)
print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print("\n" + "-" * 28)
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info
else:
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
authenticated = False
if not authenticated:
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}")
print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}")
print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}")
authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower()
if authorize in ('', 'y', 'yes'):
print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}")
print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}")
print(f"{COLOR_INFO}Note: You may see a security warning about a self-signed certificate.{COLOR_RESET}")
print(f"{COLOR_INFO}This is normal - click 'Advanced' and 'Proceed' to continue.{COLOR_RESET}")
def show_message(msg):
print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}")
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
if success:
print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}")
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}")
self.logger.info("User declined Nexus authorization.")
return None
self.logger.debug("Nexus authentication configured for engine.")
self._display_summary()
game_type = None
game_name = None
if self.context.get('modlist_source_type') == 'online_list':
game_name = self.context.get('modlist_game', '')
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion remastered': 'oblivion_remastered'
}
game_type = game_mapping.get(game_name.lower())
if not game_type:
game_type = 'unknown'
elif self.context.get('modlist_source_type') == 'local_file':
wabbajack_path = self.context.get('modlist_value')
if wabbajack_path:
result = self.wabbajack_parser.parse_wabbajack_game_type(Path(wabbajack_path))
if result:
if isinstance(result, tuple):
game_type, raw_game_type = result
game_name = raw_game_type if game_type == 'unknown' else game_type
else:
game_type = result
game_name = game_type
if game_type and not self.wabbajack_parser.is_supported_game(game_type):
print("\n" + "" * 46)
print(" Game Support Notice\n")
print(f"You are about to install a modlist for: {game_name or 'Unknown'}\n")
print("Jackify does not provide post-install configuration for this game.")
print("You can still install and use the modlist, but you will need to manually set up Steam shortcuts and other steps after installation.\n")
print("Press [Enter] to continue, or [Ctrl+C] to cancel.")
print("" * 46 + "\n")
try:
input()
except KeyboardInterrupt:
print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}")
return None
if self.context.get('skip_confirmation'):
confirm = 'y'
else:
confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower()
if confirm != 'y':
self.logger.info("User cancelled at final confirmation.")
print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}")
return None
self.logger.info("Discovery phase complete.")
context_for_logging = self.context.copy()
if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None:
context_for_logging['nexus_api_key'] = "[REDACTED]"
self.logger.info(f"Context: {context_for_logging}")
return self.context

View File

@@ -0,0 +1,67 @@
"""Game detection methods for ModlistInstallCLI (Mixin)."""
import logging
from pathlib import Path
from typing import Optional, Dict
logger = logging.getLogger(__name__)
class ModlistOperationsGameDetectionMixin:
"""Mixin providing game type detection methods."""
def detect_game_type(self, modlist_info: Optional[Dict] = None, wabbajack_file_path: Optional[Path] = None) -> Optional[str]:
"""
Detect the game type for a modlist installation.
Args:
modlist_info: Dictionary containing modlist information (for online modlists)
wabbajack_file_path: Path to .wabbajack file (for local files)
Returns:
Jackify game type string or None if detection fails
"""
if wabbajack_file_path:
self.logger.info(f"Detecting game type from .wabbajack file: {wabbajack_file_path}")
game_type = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_file_path)
if game_type:
self.logger.info(f"Detected game type from .wabbajack file: {game_type}")
return game_type
else:
self.logger.warning(f"Could not detect game type from .wabbajack file: {wabbajack_file_path}")
return None
elif modlist_info and 'game' in modlist_info:
game_name = modlist_info['game'].lower()
self.logger.info(f"Detecting game type from modlist info: {game_name}")
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion remastered': 'oblivion_remastered'
}
game_type = game_mapping.get(game_name)
if game_type:
self.logger.info(f"Mapped game name '{game_name}' to game type: {game_type}")
return game_type
else:
self.logger.warning(f"Unknown game name in modlist info: {game_name}")
return None
else:
self.logger.warning("No modlist info or .wabbajack file path provided for game detection")
return None
def check_game_support(self, game_type: str) -> bool:
"""
Check if a game type is supported by Jackify's post-install configuration.
Args:
game_type: Jackify game type string
Returns:
True if the game is supported, False otherwise
"""
return self.wabbajack_parser.is_supported_game(game_type)

View File

@@ -0,0 +1,99 @@
"""Nexus and engine methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
from ..handlers.ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
logger = logging.getLogger(__name__)
class ModlistOperationsNexusMixin:
"""Mixin providing Nexus API and engine methods."""
def _get_nexus_api_key(self) -> Optional[str]:
return self.context.get('nexus_api_key')
def get_all_modlists_from_engine(self, game_type=None):
"""
Call the Jackify engine with 'list-modlists' and return a list of modlist dicts.
Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags.
Args:
game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas")
"""
from .modlist_operations import get_jackify_engine_path
engine_executable = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_executable)
if not os.path.exists(engine_executable):
print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}")
print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}")
return []
env = os.environ.copy()
env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"
command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url']
if game_type:
command.extend(['--game', game_type])
try:
result = subprocess.run(
command,
capture_output=True, text=True, check=True,
env=env, cwd=engine_dir
)
lines = result.stdout.splitlines()
modlists = []
for line in lines:
line = line.strip()
if not line or line.startswith('Loading') or line.startswith('Loaded'):
continue
status_down = '[DOWN]' in line
status_nsfw = '[NSFW]' in line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
parts = clean_line.rsplit(' - ', 3)
if len(parts) != 4:
continue
modlist_name = parts[0].strip()
game_name = parts[1].strip()
sizes_str = parts[2].strip()
machine_url = parts[3].strip()
size_parts = sizes_str.split('|')
if len(size_parts) != 3:
continue
download_size = size_parts[0].strip()
install_size = size_parts[1].strip()
total_size = size_parts[2].strip()
if not modlist_name or not game_name or not machine_url:
continue
modlists.append({
'id': modlist_name,
'name': modlist_name,
'game': game_name,
'download_size': download_size,
'install_size': install_size,
'total_size': total_size,
'machine_url': machine_url,
'status_down': status_down,
'status_nsfw': status_nsfw
})
return modlists
except subprocess.CalledProcessError as e:
self.logger.error(f"list-modlists failed. Code: {e.returncode}")
if e.stdout:
self.logger.error(f"Engine stdout:\n{e.stdout}")
if e.stderr:
self.logger.error(f"Engine stderr:\n{e.stderr}")
print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}")
return []
except Exception as e:
self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True)
print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}")
return []

View File

@@ -5,17 +5,10 @@ Reusable tab completion functions for Jackify CLI, including bash-like path comp
import os
import readline
import logging # Added for debugging
import logging
# Get a logger for this module
completer_logger = logging.getLogger(__name__) # Logger will be named src.modules.completers
# Set level to DEBUG for this logger to ensure all debug messages are generated.
# These messages will be handled by handlers configured in the main application (e.g., via LoggingHandler).
completer_logger = logging.getLogger(__name__)
completer_logger.setLevel(logging.INFO)
# Ensure messages DO NOT propagate to the root logger's console handler by default.
# A dedicated file handler will be added in jackify-cli.py.
completer_logger.propagate = False
# IMPORTANT: Do NOT include '/' in the completer delimiters!
@@ -68,7 +61,6 @@ def path_completer(text, state):
final_match_strings_for_readline = []
text_dir_part = os.path.dirname(text)
# If text is a directory with trailing slash, use it as the base for completions
if os.path.isdir(text) and text.endswith(os.sep):
base_path = text
elif os.path.isdir(text):

View File

@@ -11,16 +11,21 @@ import json
import logging
import shutil
import re
import base64
import hashlib
from pathlib import Path
from typing import Optional
# Initialize logger
from .config_handler_encryption import ConfigEncryptionMixin
from .config_handler_directories import ConfigDirectoriesMixin
from .config_handler_proton import ConfigProtonMixin
from jackify.shared.steam_utils import (
STEAM_PREFERENCE_AUTO,
resolve_preferred_steam_installation,
)
logger = logging.getLogger(__name__)
class ConfigHandler:
class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonMixin):
"""
Handles application configuration and settings
Singleton pattern ensures all code shares the same instance
@@ -49,6 +54,7 @@ class ConfigHandler:
"resolution": None,
"protontricks_path": None,
"steam_path": None,
"steam_install_preference": STEAM_PREFERENCE_AUTO, # auto|flatpak|native
"nexus_api_key": None, # Base64 encoded API key
"default_install_parent_dir": None, # Parent directory for modlist installations
"default_download_parent_dir": None, # Parent directory for downloads
@@ -60,9 +66,11 @@ class ConfigHandler:
"game_proton_path": None, # Proton version for game shortcuts (can be any Proton 9+), separate from install proton
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
"proton_version": None, # Install Proton version name - None means auto-detect
"steam_restart_strategy": "jackify", # "jackify" (default) or "nak_simple"
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
"manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs
"manual_download_watch_directory": None, # Optional override for manual-download watcher folder
"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)
}
# Load configuration if exists
@@ -71,14 +79,13 @@ class ConfigHandler:
# Perform version migrations
self._migrate_config()
# Normalize/repair Proton selections on every startup so stale deleted versions
# cannot break workflows.
self.normalize_proton_paths_on_boot()
# If steam_path is not set, detect it
if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path()
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
# Do NOT overwrite user's saved settings!
if not os.path.exists(self.config_file) and not self.settings.get("proton_path"):
self._auto_detect_proton()
# If jackify_data_dir is not set, initialize it to default
if not self.settings.get("jackify_data_dir"):
@@ -94,35 +101,16 @@ class ConfigHandler:
str: Path to the Steam installation or None if not found
"""
logger.info("Detecting Steam installation path...")
# Common Steam installation paths
steam_paths = [
os.path.expanduser("~/.steam/steam"),
os.path.expanduser("~/.local/share/Steam"),
os.path.expanduser("~/.steam/root")
]
# Check each path
for path in steam_paths:
if os.path.exists(path):
logger.info(f"Found Steam installation at: {path}")
return path
# If not found in common locations, try to find using libraryfolders.vdf
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.steam/root/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf") # Flatpak
]
for vdf_path in libraryfolders_vdf_paths:
if os.path.exists(vdf_path):
# Extract the Steam path from the libraryfolders.vdf path
steam_path = os.path.dirname(os.path.dirname(vdf_path))
logger.info(f"Found Steam installation at: {steam_path}")
return steam_path
preference = self.settings.get("steam_install_preference", STEAM_PREFERENCE_AUTO)
install_type, install_root = resolve_preferred_steam_installation(preference=preference)
if install_root:
logger.info(
"Selected Steam installation: %s (%s)",
install_type,
install_root,
)
return str(install_root)
logger.error("Steam installation not found")
return None
@@ -214,8 +202,8 @@ class ConfigHandler:
config.update(saved_config)
return config
except Exception as e:
# Don't use logger here - can cause recursion if logger tries to access config
print(f"Warning: Error reading configuration from disk: {e}", file=sys.stderr)
# Use logger.warning instead of print to stderr - logger is initialized before config access
logger.warning(f"Error reading configuration from disk: {e}")
return self.settings.copy()
def reload_config(self):
@@ -305,224 +293,8 @@ class ConfigHandler:
def get_protontricks_path(self):
"""Get the path to protontricks executable"""
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
return self.settings.get("protontricks_path")
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):
"""
Save resolution setting to configuration
@@ -589,262 +361,6 @@ class ConfigHandler:
logger.error(f"Error clearing resolution: {e}")
return False
def set_default_install_parent_dir(self, path):
"""
Save the parent directory for modlist installations
Args:
path (str): Parent directory path to save
Returns:
bool: True if saved successfully, False otherwise
"""
try:
if path and os.path.exists(path):
self.settings["default_install_parent_dir"] = path
logger.debug(f"Default install parent directory saved: {path}")
return self.save_config()
else:
logger.warning(f"Invalid or non-existent path for install parent directory: {path}")
return False
except Exception as e:
logger.error(f"Error saving install parent directory: {e}")
return False
def get_default_install_parent_dir(self):
"""
Retrieve the saved parent directory for modlist installations
Returns:
str: Saved parent directory path or None if not saved
"""
try:
path = self.settings.get("default_install_parent_dir")
if path and os.path.exists(path):
logger.debug(f"Retrieved default install parent directory: {path}")
return path
else:
logger.debug("No valid default install parent directory found")
return None
except Exception as e:
logger.error(f"Error retrieving install parent directory: {e}")
return None
def set_default_download_parent_dir(self, path):
"""
Save the parent directory for downloads
Args:
path (str): Parent directory path to save
Returns:
bool: True if saved successfully, False otherwise
"""
try:
if path and os.path.exists(path):
self.settings["default_download_parent_dir"] = path
logger.debug(f"Default download parent directory saved: {path}")
return self.save_config()
else:
logger.warning(f"Invalid or non-existent path for download parent directory: {path}")
return False
except Exception as e:
logger.error(f"Error saving download parent directory: {e}")
return False
def get_default_download_parent_dir(self):
"""
Retrieve the saved parent directory for downloads
Returns:
str: Saved parent directory path or None if not saved
"""
try:
path = self.settings.get("default_download_parent_dir")
if path and os.path.exists(path):
logger.debug(f"Retrieved default download parent directory: {path}")
return path
else:
logger.debug("No valid default download parent directory found")
return None
except Exception as e:
logger.error(f"Error retrieving download parent directory: {e}")
return None
def has_saved_install_parent_dir(self):
"""
Check if a default install parent directory is saved in configuration
Returns:
bool: True if directory exists and is valid, False otherwise
"""
path = self.settings.get("default_install_parent_dir")
return path is not None and os.path.exists(path)
def has_saved_download_parent_dir(self):
"""
Check if a default download parent directory is saved in configuration
Returns:
bool: True if directory exists and is valid, False otherwise
"""
path = self.settings.get("default_download_parent_dir")
return path is not None and os.path.exists(path)
def get_modlist_install_base_dir(self):
"""
Get the configurable base directory for modlist installations
Returns:
str: Base directory path for modlist installations
"""
return self.settings.get("modlist_install_base_dir", os.path.expanduser("~/Games"))
def set_modlist_install_base_dir(self, path):
"""
Set the configurable base directory for modlist installations
Args:
path (str): Base directory path to save
Returns:
bool: True if saved successfully, False otherwise
"""
try:
if path:
self.settings["modlist_install_base_dir"] = path
logger.debug(f"Modlist install base directory saved: {path}")
return self.save_config()
else:
logger.warning("Invalid path for modlist install base directory")
return False
except Exception as e:
logger.error(f"Error saving modlist install base directory: {e}")
return False
def get_modlist_downloads_base_dir(self):
"""
Get the configurable base directory for modlist downloads
Returns:
str: Base directory path for modlist downloads
"""
return self.settings.get("modlist_downloads_base_dir", os.path.expanduser("~/Games/Modlist_Downloads"))
def set_modlist_downloads_base_dir(self, path):
"""
Set the configurable base directory for modlist downloads
Args:
path (str): Base directory path to save
Returns:
bool: True if saved successfully, False otherwise
"""
try:
if path:
self.settings["modlist_downloads_base_dir"] = path
logger.debug(f"Modlist downloads base directory saved: {path}")
return self.save_config()
else:
logger.warning("Invalid path for modlist downloads base directory")
return False
except Exception as e:
logger.error(f"Error saving modlist downloads base directory: {e}")
return False
def get_proton_path(self):
"""
Retrieve the saved Install Proton path from configuration (for jackify-engine).
Always reads fresh from disk.
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()

View File

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

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