Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b3fbaed2 | ||
|
|
2ff09a1448 | ||
|
|
69fabb32e6 | ||
|
|
6453665620 | ||
|
|
cacbbf1fb1 | ||
|
|
c3551cd269 | ||
|
|
8e4dd06f11 | ||
|
|
e52e1427f6 | ||
|
|
c294431a35 | ||
|
|
7278efd4cd | ||
|
|
b29568f590 | ||
|
|
3556914560 |
125
CHANGELOG.md
@@ -1,5 +1,130 @@
|
||||
# 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
|
||||
|
||||
|
||||
21
README.md
@@ -14,7 +14,7 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists
|
||||
|
||||
## Features
|
||||
|
||||
- **Complete Modlist Workflow**: Install from scratch with Nexus Premium, configure a pre-downloaded modlist, or reconfigure an existing modlist already in Steam
|
||||
- **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
|
||||
@@ -35,17 +35,15 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists
|
||||
## Requirements
|
||||
|
||||
- Linux system (most modern distributions will work)
|
||||
- Steam installed and configured
|
||||
- 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 Premium subscription** (required for automated downloads)
|
||||
- Non-Premium users can still install modlists via Wabbajack under Proton
|
||||
- Native non-premium support planned for a future release
|
||||
- **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
|
||||
- **FUSE** (required for AppImage execution, pre-installed on most distributions)
|
||||
- **Ubuntu/Debian-based distros only** (Ubuntu, Kubuntu, Linux Mint, Pop!_OS, Zorin OS, elementary OS, and others): Qt platform plugin library
|
||||
- **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`
|
||||
|
||||
## Installation Quick Start
|
||||
@@ -61,6 +59,14 @@ chmod +x Jackify.AppImage
|
||||
|
||||
For CLI mode: `./Jackify.AppImage --cli`
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS
|
||||
```
|
||||
|
||||
You should see `Jackify.AppImage: OK`. If you see a failure, do not run the file.
|
||||
|
||||
For a full step-by-step guide with screenshots, see the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide).
|
||||
|
||||
## Supported Games
|
||||
@@ -91,7 +97,6 @@ At this early stage of development, I'd prefer GitHub Issues for bug reports and
|
||||
## Future Plans (not guaranteed)
|
||||
|
||||
- Continue to expand supported games for fully automated configuration
|
||||
- Non-Premium / manual download support
|
||||
- GUI refinements
|
||||
- Dark/Light theme support
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.6.0.1"
|
||||
|
||||
@@ -107,7 +107,7 @@ def get_jackify_engine_path():
|
||||
logger.warning(f"AppImage engine not found at expected path: {engine_path}")
|
||||
|
||||
# Priority 3: Check if THIS process is actually running from Jackify AppImage
|
||||
# (not just inheriting APPDIR from another AppImage like Cursor)
|
||||
# (not just inheriting APPDIR from another AppImage context)
|
||||
appdir = os.environ.get('APPDIR')
|
||||
if appdir and sys.argv[0] and 'jackify' in sys.argv[0].lower() and '/tmp/.mount_' in sys.argv[0]:
|
||||
# Only use AppImage path if we're actually running a Jackify AppImage
|
||||
@@ -179,6 +179,92 @@ class ModlistInstallCLI(
|
||||
# Initialize process tracking for cleanup
|
||||
self._current_process = None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_version_token(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
token = str(value).strip()
|
||||
if not token:
|
||||
return None
|
||||
return token.lstrip("vV").lower()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_modlist_name(value: str | None) -> str:
|
||||
return " ".join((value or "").strip().lower().split())
|
||||
|
||||
def _get_requested_modlist_version(self) -> str | None:
|
||||
info = self.context.get("selected_modlist_info") or {}
|
||||
return self._normalize_version_token(info.get("version"))
|
||||
|
||||
def _evaluate_update_candidate(
|
||||
self,
|
||||
modlist_name: str,
|
||||
install_dir: str,
|
||||
existing_appid: str | None,
|
||||
) -> tuple[bool, dict]:
|
||||
from jackify.backend.utils.modlist_meta import read_modlist_meta
|
||||
|
||||
result = {
|
||||
"eligible": False,
|
||||
"reason": "unknown",
|
||||
"requested_version": None,
|
||||
"installed_version": None,
|
||||
"version_relation": "unknown",
|
||||
"installed_name": None,
|
||||
}
|
||||
if not existing_appid:
|
||||
result["reason"] = "missing_shortcut_appid"
|
||||
return False, result
|
||||
|
||||
meta = read_modlist_meta(install_dir)
|
||||
if not meta:
|
||||
result["reason"] = "missing_meta"
|
||||
return False, result
|
||||
|
||||
installed_name = (meta.get("modlist_name") or "").strip()
|
||||
result["installed_name"] = installed_name
|
||||
if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
|
||||
result["reason"] = "modlist_name_mismatch"
|
||||
return False, result
|
||||
|
||||
requested_version = self._get_requested_modlist_version()
|
||||
installed_version = self._normalize_version_token(meta.get("modlist_version"))
|
||||
result["requested_version"] = requested_version
|
||||
result["installed_version"] = installed_version
|
||||
if requested_version and installed_version:
|
||||
result["version_relation"] = "same" if requested_version == installed_version else "different"
|
||||
|
||||
result["eligible"] = True
|
||||
result["reason"] = "eligible"
|
||||
return True, result
|
||||
|
||||
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
|
||||
try:
|
||||
install_real = os.path.realpath(install_dir)
|
||||
candidate_exes = [
|
||||
os.path.join(install_real, "ModOrganizer.exe"),
|
||||
os.path.join(install_real, "files", "ModOrganizer.exe"),
|
||||
]
|
||||
|
||||
for exe_path in candidate_exes:
|
||||
if not os.path.exists(exe_path):
|
||||
continue
|
||||
appid = self.shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
|
||||
if appid:
|
||||
return appid
|
||||
|
||||
for shortcut in self.shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
|
||||
if (
|
||||
shortcut.get("AppName", "").strip() == modlist_name.strip()
|
||||
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
|
||||
):
|
||||
raw_appid = shortcut.get("appid")
|
||||
if raw_appid is not None:
|
||||
return str(int(raw_appid) & 0xFFFFFFFF)
|
||||
except Exception as e:
|
||||
self.logger.warning("CLI update detection: failed shortcut lookup: %s", e)
|
||||
return None
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running jackify-engine process"""
|
||||
if self._current_process and self._current_process.poll() is None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""CLI configuration phase methods for ModlistInstallCLI (Mixin)."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
@@ -120,14 +121,16 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
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
|
||||
@@ -166,19 +169,81 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
|
||||
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||
clean_env = get_clean_subprocess_env()
|
||||
self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
|
||||
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 == b'\n':
|
||||
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():
|
||||
@@ -197,26 +262,6 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
inline_progress_active = False
|
||||
print(line, end='')
|
||||
buffer = b''
|
||||
elif chunk == b'\r':
|
||||
line = buffer.decode('utf-8', errors='replace')
|
||||
if '[FILE_PROGRESS]' in line:
|
||||
parts = line.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
line = parts[0].rstrip()
|
||||
else:
|
||||
buffer = b''
|
||||
continue
|
||||
clean_line = line.rstrip('\r\n')
|
||||
if clean_line.startswith("Installing files "):
|
||||
print(f"\r{clean_line}", end='')
|
||||
inline_progress_active = True
|
||||
else:
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(line, end='')
|
||||
sys.stdout.flush()
|
||||
buffer = b''
|
||||
|
||||
if buffer:
|
||||
line = buffer.decode('utf-8', errors='replace')
|
||||
@@ -237,6 +282,7 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
|
||||
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}.")
|
||||
@@ -400,6 +446,16 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
|
||||
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}")
|
||||
@@ -535,17 +591,50 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
success, prefix_path, app_id = True, None, None
|
||||
else:
|
||||
success, prefix_path, app_id = False, None, None
|
||||
|
||||
if success:
|
||||
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
|
||||
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
|
||||
@@ -572,18 +661,28 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
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")
|
||||
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
|
||||
self.logger.info("Running post-installation configuration phase using ModlistService")
|
||||
|
||||
configuration_success = modlist_service.configure_modlist_post_steam(modlist_context)
|
||||
|
||||
if configuration_success:
|
||||
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
|
||||
self.logger.info("Post-installation configuration completed successfully")
|
||||
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
|
||||
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:
|
||||
@@ -593,31 +692,47 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
return user_input in ("", "y", "yes")
|
||||
def _manual_vnv_file(title: str, instructions: str):
|
||||
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
|
||||
print(instructions)
|
||||
try:
|
||||
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
if not file_input:
|
||||
return None
|
||||
selected = Path(file_input).expanduser().resolve()
|
||||
return selected if selected.exists() else None
|
||||
automation_ran, vnv_error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name_for_automation,
|
||||
modlist_install_location=Path(install_dir_str),
|
||||
game_root=None, # Auto-detect from modlist structure.
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=lambda msg: print(msg),
|
||||
manual_file_callback=_manual_vnv_file,
|
||||
confirmation_callback=_confirm_vnv,
|
||||
)
|
||||
if automation_ran and not vnv_error:
|
||||
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
|
||||
if vnv_error:
|
||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
|
||||
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}")
|
||||
@@ -632,6 +747,7 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
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")
|
||||
|
||||
@@ -68,7 +68,7 @@ class ModlistOperationsConfigurationGUIMixin:
|
||||
|
||||
if result:
|
||||
if completion_callback:
|
||||
completion_callback(True, "Configuration completed successfully!", config_context['name'])
|
||||
completion_callback(True, "Core configuration complete", config_context['name'])
|
||||
return True
|
||||
else:
|
||||
retry_count += 1
|
||||
@@ -139,7 +139,7 @@ class ModlistOperationsConfigurationGUIMixin:
|
||||
|
||||
if result:
|
||||
if completion_callback:
|
||||
completion_callback(True, "Configuration completed successfully!", config_context['name'])
|
||||
completion_callback(True, "Core configuration complete", config_context['name'])
|
||||
return True
|
||||
else:
|
||||
if progress_callback:
|
||||
|
||||
@@ -243,6 +243,46 @@ class ModlistOperationsDiscoveryMixin:
|
||||
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()
|
||||
|
||||
@@ -17,6 +17,10 @@ from typing import Optional
|
||||
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__)
|
||||
|
||||
@@ -50,6 +54,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
"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
|
||||
@@ -62,8 +67,10 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
|
||||
"proton_version": None, # Install Proton version name - None means auto-detect
|
||||
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
|
||||
"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
|
||||
@@ -72,14 +79,13 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
# 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"):
|
||||
@@ -95,35 +101,16 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
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
|
||||
|
||||
@@ -376,4 +363,4 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ Config handler Proton path and version getters and auto-detect.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,6 +12,105 @@ logger = logging.getLogger(__name__)
|
||||
class ConfigProtonMixin:
|
||||
"""Mixin providing Proton path/version and auto-detect for ConfigHandler."""
|
||||
|
||||
@staticmethod
|
||||
def _is_usable_proton_path(proton_path: Optional[str]) -> bool:
|
||||
"""Return True when path looks like a valid Proton install directory."""
|
||||
if not proton_path:
|
||||
return False
|
||||
try:
|
||||
p = Path(str(proton_path)).expanduser()
|
||||
if not p.is_dir():
|
||||
return False
|
||||
# Valve Proton structure
|
||||
if (p / "dist" / "bin" / "wine").exists():
|
||||
return True
|
||||
# GE-Proton structure
|
||||
if (p / "files" / "bin" / "wine").exists():
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _best_proton_entry() -> Optional[Dict[str, Any]]:
|
||||
"""Get best detected Proton entry or None."""
|
||||
try:
|
||||
from .wine_utils import WineUtils
|
||||
return WineUtils.select_best_proton()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def normalize_proton_paths_on_boot(self) -> bool:
|
||||
"""
|
||||
Ensure stored Proton paths are valid at startup, repairing stale selections.
|
||||
|
||||
Rules:
|
||||
- If install proton path is missing/invalid, auto-detect next best and persist it.
|
||||
- If no compatible Proton exists, persist install path/version as null.
|
||||
- If game proton path is set and invalid, reset it to install proton (or null).
|
||||
|
||||
Returns:
|
||||
True if config values were changed and saved, False otherwise.
|
||||
"""
|
||||
changed = False
|
||||
|
||||
install_path = self.settings.get("proton_path")
|
||||
if install_path == "auto":
|
||||
install_path = None
|
||||
|
||||
install_valid = self._is_usable_proton_path(install_path)
|
||||
if not install_valid:
|
||||
best = self._best_proton_entry()
|
||||
if best:
|
||||
best_path = str(best["path"])
|
||||
best_name = str(best.get("name") or Path(best_path).name)
|
||||
if self.settings.get("proton_path") != best_path:
|
||||
self.settings["proton_path"] = best_path
|
||||
changed = True
|
||||
if self.settings.get("proton_version") != best_name:
|
||||
self.settings["proton_version"] = best_name
|
||||
changed = True
|
||||
logger.warning(
|
||||
"Install Proton path was missing/invalid; auto-selected %s (%s)",
|
||||
best_name,
|
||||
best_path,
|
||||
)
|
||||
else:
|
||||
if self.settings.get("proton_path") is not None:
|
||||
self.settings["proton_path"] = None
|
||||
changed = True
|
||||
if self.settings.get("proton_version") is not None:
|
||||
self.settings["proton_version"] = None
|
||||
changed = True
|
||||
logger.warning(
|
||||
"Install Proton path was missing/invalid and no compatible Proton was found"
|
||||
)
|
||||
else:
|
||||
# Keep proton_version in sync with existing valid path when missing/legacy.
|
||||
if not self.settings.get("proton_version"):
|
||||
self.settings["proton_version"] = Path(str(install_path)).name
|
||||
changed = True
|
||||
|
||||
effective_install = self.settings.get("proton_path")
|
||||
game_path = self.settings.get("game_proton_path")
|
||||
|
||||
# Legacy/placeholder values should not persist for runtime resolution.
|
||||
if game_path in ("same_as_install", "auto"):
|
||||
target = effective_install
|
||||
if self.settings.get("game_proton_path") != target:
|
||||
self.settings["game_proton_path"] = target
|
||||
changed = True
|
||||
elif game_path and not self._is_usable_proton_path(game_path):
|
||||
self.settings["game_proton_path"] = effective_install
|
||||
changed = True
|
||||
logger.warning(
|
||||
"Game Proton path was missing/invalid; reset to install Proton path"
|
||||
)
|
||||
|
||||
if changed:
|
||||
self.save_config()
|
||||
return changed
|
||||
|
||||
def get_proton_path(self):
|
||||
"""Retrieve the saved Install Proton path. Always reads fresh from disk."""
|
||||
try:
|
||||
|
||||
@@ -521,11 +521,13 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
||||
# Game-specific Documents directory names (for both Linux home and Wine prefix)
|
||||
game_docs_dirs = {
|
||||
"skyrimse": "Skyrim Special Edition",
|
||||
"skyrimvr": "Skyrim VR",
|
||||
"fallout4": "Fallout4",
|
||||
"fallout4vr": "Fallout4VR",
|
||||
"falloutnv": "FalloutNV",
|
||||
"oblivion": "Oblivion",
|
||||
"enderal": "Enderal Special Edition",
|
||||
"enderalse": "Enderal Special Edition"
|
||||
"enderalse": "Enderal Special Edition",
|
||||
}
|
||||
|
||||
game_dirs = {
|
||||
@@ -561,41 +563,193 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
self.logger.debug(f"Created game-specific directory: {dir_path}")
|
||||
|
||||
# CRITICAL: Create game-specific Documents directories in Wine prefix
|
||||
# CP2077 and BG3 use AppData/Local only (no My Games)
|
||||
appdata_only_dirs = {
|
||||
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
|
||||
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
|
||||
}
|
||||
|
||||
# CRITICAL: Create game-specific directories in Wine prefix
|
||||
# Required for USVFS to virtualize profile INIs on first launch
|
||||
if game_name in game_docs_dirs:
|
||||
docs_dir_name = game_docs_dirs[game_name]
|
||||
|
||||
# Find compatdata path for this AppID
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
compatdata_path = path_handler.find_compat_data(appid)
|
||||
|
||||
if compatdata_path:
|
||||
# Create Documents/My Games/{GameName} in Wine prefix
|
||||
wine_docs_path = os.path.join(
|
||||
str(compatdata_path),
|
||||
"pfx",
|
||||
"drive_c",
|
||||
"users",
|
||||
"steamuser",
|
||||
"Documents",
|
||||
"My Games",
|
||||
docs_dir_name
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
compatdata_path = path_handler.find_compat_data(appid)
|
||||
|
||||
if compatdata_path:
|
||||
prefix_user = os.path.join(
|
||||
str(compatdata_path), "pfx", "drive_c", "users", "steamuser"
|
||||
)
|
||||
|
||||
if game_name in appdata_only_dirs:
|
||||
appdata_path = os.path.join(
|
||||
prefix_user, "AppData", "Local", appdata_only_dirs[game_name]
|
||||
)
|
||||
try:
|
||||
os.makedirs(appdata_path, exist_ok=True)
|
||||
self.logger.info(f"Created Wine prefix AppData/Local directory: {appdata_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create AppData/Local directory {appdata_path}: {e}")
|
||||
|
||||
elif game_name in game_docs_dirs:
|
||||
docs_dir_name = game_docs_dirs[game_name]
|
||||
wine_docs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(wine_docs_path, exist_ok=True)
|
||||
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}")
|
||||
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
|
||||
self.logger.info(f"Created Wine prefix Documents directory: {wine_docs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}")
|
||||
# Don't fail completely - this is a first-launch optimization
|
||||
else:
|
||||
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation")
|
||||
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time")
|
||||
|
||||
if game_name == "skyrimse":
|
||||
self._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "fallout4":
|
||||
self._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "skyrimvr":
|
||||
self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "fallout4vr":
|
||||
self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
|
||||
else:
|
||||
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating required directories: {e}")
|
||||
return False
|
||||
|
||||
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch.
|
||||
|
||||
Two files must exist before first launch to avoid USVFS and engine issues:
|
||||
|
||||
1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file.
|
||||
USVFS builds its VFS tree at MO2 startup. If this path does not exist,
|
||||
USVFS logs the directory as missing and skips adding Plugins.txt to the
|
||||
initial tree. It then tries to reroute the file dynamically, but a mutex
|
||||
deadlock (thread never releases the write mutex on first launch) blocks
|
||||
the reroute. The game falls through to the real filesystem, finds no
|
||||
Plugins.txt, and loads only base-game ESPs - causing a null form crash
|
||||
for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll).
|
||||
On second launch the directory exists, USVFS initialises correctly, no crash.
|
||||
Pre-seeding an empty file gives USVFS its anchor; content is irrelevant
|
||||
because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway.
|
||||
|
||||
2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub.
|
||||
The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in
|
||||
SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR)
|
||||
hooks the Windows INI API, so the game reads the real prefix path directly,
|
||||
not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses
|
||||
the prompt. PPR redirects all subsequent reads to the active profile once
|
||||
it loads, so this stub is never read again after early engine init.
|
||||
Only created if the file does not already exist.
|
||||
"""
|
||||
# Fix 1: empty Plugins.txt anchor for USVFS
|
||||
appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition")
|
||||
plugins_txt = os.path.join(appdata_sse, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_sse, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
# Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup
|
||||
skyrimprefs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
|
||||
)
|
||||
try:
|
||||
if not os.path.exists(skyrimprefs_path):
|
||||
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[General]\nbDownloadCC=1\n")
|
||||
self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}")
|
||||
else:
|
||||
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
|
||||
|
||||
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Fallout 4 needs on first launch.
|
||||
|
||||
1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4.
|
||||
|
||||
INI stub for CC popup suppression is intentionally omitted until the correct
|
||||
key name in Fallout4Prefs.ini is confirmed via testing.
|
||||
"""
|
||||
appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_fo4, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_fo4, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Skyrim VR needs on first launch.
|
||||
|
||||
1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE applies to VR.
|
||||
|
||||
2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys:
|
||||
- bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour
|
||||
as Skyrim SE; fires before PPR hooks the INI API).
|
||||
- bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom
|
||||
tutorial on first launch. Without this, SkyrimVR skips the main menu and
|
||||
drops the user into the playroom, bypassing the modlist's startup sequence.
|
||||
"""
|
||||
appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_vr, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_vr, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
skyrimprefs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
|
||||
)
|
||||
try:
|
||||
if not os.path.exists(skyrimprefs_path):
|
||||
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n")
|
||||
self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}")
|
||||
else:
|
||||
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
|
||||
|
||||
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch.
|
||||
|
||||
1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR.
|
||||
|
||||
INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini
|
||||
has not been confirmed via testing.
|
||||
"""
|
||||
appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_fo4vr, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
@@ -64,7 +64,7 @@ class FilesystemSteamMixin:
|
||||
|
||||
default_path = Path.home() / ".steam/steam/steamapps/common"
|
||||
if default_path.is_dir():
|
||||
logger.warning(f"Using default Steam library path: {default_path}")
|
||||
logger.info(f"Using default Steam library path: {default_path}")
|
||||
return default_path
|
||||
|
||||
logger.error("No valid Steam library found via vdf or at default location.")
|
||||
|
||||
@@ -18,7 +18,11 @@ class GameDetector:
|
||||
'fallout3': ['Fallout 3'],
|
||||
'oblivion': ['Oblivion'],
|
||||
'starfield': ['Starfield'],
|
||||
'oblivion_remastered': ['Oblivion Remastered']
|
||||
'oblivion_remastered': ['Oblivion Remastered'],
|
||||
'skyrimvr': ['Skyrim VR'],
|
||||
'fallout4vr': ['Fallout 4 VR'],
|
||||
'cp2077': ['Cyberpunk 2077'],
|
||||
'bg3': ["Baldur's Gate 3"],
|
||||
}
|
||||
|
||||
def detect_game_type(self, modlist_name: str) -> Optional[str]:
|
||||
@@ -26,9 +30,17 @@ class GameDetector:
|
||||
modlist_lower = modlist_name.lower()
|
||||
|
||||
# Check for game-specific keywords in modlist name
|
||||
# Check for Oblivion Remastered first since "oblivion" is a substring
|
||||
# Check more specific types before their generic parents
|
||||
if any(keyword in modlist_lower for keyword in ['oblivion remastered', 'oblivionremastered', 'oblivion_remastered']):
|
||||
return 'oblivion_remastered'
|
||||
elif any(keyword in modlist_lower for keyword in ['skyrim vr', 'skyrimvr']):
|
||||
return 'skyrimvr'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout 4 vr', 'fallout4vr', 'fo4vr']):
|
||||
return 'fallout4vr'
|
||||
elif any(keyword in modlist_lower for keyword in ['cyberpunk', 'cp2077', 'cyberpunk 2077']):
|
||||
return 'cp2077'
|
||||
elif any(keyword in modlist_lower for keyword in ["baldur's gate 3", 'baldursgate3', 'bg3']):
|
||||
return 'bg3'
|
||||
elif any(keyword in modlist_lower for keyword in ['skyrim', 'sse', 'skse', 'dragonborn', 'dawnguard']):
|
||||
return 'skyrim'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']):
|
||||
@@ -134,9 +146,37 @@ class GameDetector:
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks']
|
||||
}
|
||||
},
|
||||
'skyrimvr': {
|
||||
'launcher': 'SKSE',
|
||||
'min_proton_version': '6.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'SteamVR must be installed separately',
|
||||
},
|
||||
'fallout4vr': {
|
||||
'launcher': 'F4SE',
|
||||
'min_proton_version': '6.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'SteamVR must be installed separately',
|
||||
},
|
||||
'cp2077': {
|
||||
'launcher': 'redmod',
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'Requires WINEDLLOVERRIDES=version=n,b;winmm=n,b for Red4ext/CET. Rootbuilder must use COPY mode.',
|
||||
},
|
||||
'bg3': {
|
||||
'launcher': 'bg3_dx11',
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'Rootbuilder must use COPY mode.',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return requirements.get(game_type, {})
|
||||
|
||||
def detect_mods(self, modlist_path: Path) -> List[Dict]:
|
||||
|
||||
@@ -279,46 +279,56 @@ class ModlistMenuHandler:
|
||||
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
|
||||
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
|
||||
|
||||
# Run the automated workflow
|
||||
result = prefix_service.run_working_workflow(
|
||||
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
|
||||
)
|
||||
|
||||
# Handle the result
|
||||
if isinstance(result, tuple) and len(result) == 4:
|
||||
if result[0] == "CONFLICT":
|
||||
# Handle conflict - ask user what to do
|
||||
conflicts = result[1]
|
||||
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
|
||||
for i, conflict in enumerate(conflicts, 1):
|
||||
print(f" {i}. Name: {conflict['name']}")
|
||||
print(f" Executable: {conflict['exe']}")
|
||||
print(f" Start Directory: {conflict['startdir']}")
|
||||
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
|
||||
print(" 1. Use existing shortcut (recommended)")
|
||||
print(" 2. Create new shortcut anyway")
|
||||
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
|
||||
if choice == "1":
|
||||
# Use existing shortcut
|
||||
existing_appid = conflicts[0].get('appid')
|
||||
if existing_appid:
|
||||
context = {
|
||||
"name": modlist_name,
|
||||
"appid": str(existing_appid),
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
elif choice == "2":
|
||||
# Create new shortcut - would need to handle this, but for now just fail
|
||||
print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}")
|
||||
return True
|
||||
else:
|
||||
while True:
|
||||
result = prefix_service.run_working_workflow(
|
||||
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.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(" 1. Use existing shortcut (recommended)")
|
||||
print(" 2. Choose a different shortcut name")
|
||||
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
|
||||
if choice == "1":
|
||||
existing_appid = conflicts[0].get('appid')
|
||||
if existing_appid:
|
||||
context = {
|
||||
"name": modlist_name,
|
||||
"appid": str(existing_appid),
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
print(f"{COLOR_ERROR}Could not determine existing shortcut AppID.{COLOR_RESET}")
|
||||
return True
|
||||
if choice == "2":
|
||||
print("")
|
||||
print(f"{COLOR_PROMPT}Enter a different shortcut name for this modlist.{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}(Current conflicting name: {modlist_name}){COLOR_RESET}")
|
||||
new_name = input(f"{COLOR_PROMPT}New shortcut name (or 'q' to cancel): {COLOR_RESET}").strip()
|
||||
if new_name.lower() == 'q':
|
||||
print(f"{COLOR_INFO}Configuration cancelled by user.{COLOR_RESET}")
|
||||
return True
|
||||
if not new_name:
|
||||
print(f"{COLOR_ERROR}Name cannot be empty.{COLOR_RESET}")
|
||||
continue
|
||||
if new_name == modlist_name:
|
||||
print(f"{COLOR_ERROR}Please enter a different name to resolve the conflict.{COLOR_RESET}")
|
||||
continue
|
||||
modlist_name = new_name
|
||||
print(f"{COLOR_INFO}Retrying Steam setup with shortcut name: {modlist_name}{COLOR_RESET}")
|
||||
continue
|
||||
print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}")
|
||||
return True
|
||||
else:
|
||||
# Success - get the results
|
||||
|
||||
success, prefix_path, appid_int, last_timestamp = result
|
||||
if success and appid_int:
|
||||
context = {
|
||||
@@ -330,10 +340,9 @@ class ModlistMenuHandler:
|
||||
}
|
||||
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
else:
|
||||
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
|
||||
return True
|
||||
else:
|
||||
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
|
||||
return True
|
||||
|
||||
# Unexpected result format
|
||||
print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}")
|
||||
self.logger.error(f"Unexpected result format from automated workflow: {result}")
|
||||
@@ -566,8 +575,18 @@ class ModlistMenuHandler:
|
||||
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
|
||||
# Only in CLI mode - GUI handles this in install_modlist.py
|
||||
if not gui_mode:
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
|
||||
from jackify.backend.services.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,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
modlist_name = context.get('name', '')
|
||||
@@ -581,33 +600,46 @@ class ModlistMenuHandler:
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
return user_input in ("", "y", "yes")
|
||||
|
||||
def _manual_vnv_file(title: str, instructions: str):
|
||||
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
|
||||
print(instructions)
|
||||
try:
|
||||
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
if not file_input:
|
||||
return None
|
||||
selected = Path(file_input).expanduser().resolve()
|
||||
return selected if selected.exists() else None
|
||||
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=modlist_path,
|
||||
game_root=None, # Will be auto-detected
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=lambda msg: print(msg),
|
||||
manual_file_callback=_manual_vnv_file,
|
||||
confirmation_callback=_confirm_vnv
|
||||
)
|
||||
if automation_ran and not error:
|
||||
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
|
||||
if error:
|
||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
|
||||
if should_offer_vnv_automation(modlist_name, modlist_path):
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
resolved_game_root = game_paths.get('Fallout New Vegas')
|
||||
vnv_service = VNVPostInstallService(
|
||||
modlist_install_location=modlist_path,
|
||||
game_root=resolved_game_root or modlist_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, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=modlist_path,
|
||||
game_root=None, # Will be auto-detected
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=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 error:
|
||||
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
|
||||
if error:
|
||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
|
||||
else:
|
||||
print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"VNV automation check skipped: {e}")
|
||||
# Not an error - just means VNV automation wasn't applicable
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import logging
|
||||
import requests
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
@@ -147,33 +146,14 @@ class ModlistConfigurationMixin:
|
||||
print("───────────────────────────────────────────────────────────────────")
|
||||
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
|
||||
self.logger.info("User confirmed completion of manual steps.")
|
||||
# Step 3: Download and apply curated user.reg.modlist and system.reg.modlist
|
||||
# Step 3: Apply targeted registry tweaks (replaces wholesale curated reg file overwrite)
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration")
|
||||
self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...")
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration")
|
||||
self.logger.info("Step 3: Applying modlist registry tweaks...")
|
||||
try:
|
||||
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
||||
if not prefix_path_str or not os.path.isdir(prefix_path_str):
|
||||
raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.")
|
||||
user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist"
|
||||
user_reg_dest = Path(prefix_path_str) / "user.reg"
|
||||
response = requests.get(user_reg_url, verify=True)
|
||||
response.raise_for_status()
|
||||
with open(user_reg_dest, "wb") as f:
|
||||
f.write(response.content)
|
||||
self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}")
|
||||
system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist"
|
||||
system_reg_dest = Path(prefix_path_str) / "system.reg"
|
||||
response = requests.get(system_reg_url, verify=True)
|
||||
response.raise_for_status()
|
||||
with open(system_reg_dest, "wb") as f:
|
||||
f.write(response.content)
|
||||
self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}")
|
||||
self._apply_modlist_registry_tweaks()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}")
|
||||
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}")
|
||||
return False
|
||||
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
|
||||
self.logger.warning("Modlist registry tweaks failed (non-fatal): %s", e)
|
||||
|
||||
# Step 4: Install Wine Components
|
||||
if status_callback:
|
||||
@@ -239,18 +219,12 @@ class ModlistConfigurationMixin:
|
||||
status_callback(f"{self._get_progress_timestamp()} {failure_msg}")
|
||||
# Continue but user should be aware of potential issues
|
||||
|
||||
# Step 4.6: Enable dotfiles visibility for Wine prefix
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility")
|
||||
self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...")
|
||||
# Step 4.6: Audit final registry state - confirms all writes survived winetricks
|
||||
self.logger.info("Step 4.6: Auditing registry state...")
|
||||
try:
|
||||
if self.protontricks_handler.enable_dotfiles(self.appid):
|
||||
self.logger.info("Dotfiles visibility enabled successfully")
|
||||
else:
|
||||
self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)")
|
||||
self._audit_registry_state()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)")
|
||||
self.logger.info("Step 4.6: Enabling dotfiles visibility... Done")
|
||||
self.logger.warning("Registry audit failed (non-fatal): %s", e)
|
||||
|
||||
# Step 4.7: Create Wine prefix Documents directories for USVFS
|
||||
# Critical for USVFS profile INI virtualization on first launch
|
||||
@@ -258,17 +232,40 @@ class ModlistConfigurationMixin:
|
||||
status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS")
|
||||
self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...")
|
||||
try:
|
||||
if self.appid and self.game_var:
|
||||
# Map game_var to game_name for create_required_dirs
|
||||
if self.appid:
|
||||
# Map detected game type to the key expected by create_required_dirs
|
||||
game_name_map = {
|
||||
"skyrim": "skyrimse",
|
||||
"skyrimspecialedition": "skyrimse",
|
||||
"skyrimvr": "skyrimvr",
|
||||
"fallout": "fallout4",
|
||||
"fallout4": "fallout4",
|
||||
"fo4": "fallout4",
|
||||
"fallout4vr": "fallout4vr",
|
||||
"fnv": "falloutnv",
|
||||
"falloutnv": "falloutnv",
|
||||
"oblivion": "oblivion",
|
||||
"enderalspecialedition": "enderalse"
|
||||
"enderal": "enderalse",
|
||||
"enderalspecialedition": "enderalse",
|
||||
"bg3": "bg3",
|
||||
"baldursgate3": "bg3",
|
||||
"cp2077": "cp2077",
|
||||
"starfield": "starfield",
|
||||
}
|
||||
game_name = game_name_map.get(self.game_var.lower(), None)
|
||||
|
||||
game_name = game_name_map.get((self.game_var or '').lower(), None)
|
||||
|
||||
# Fallback: read gameName= directly from ModOrganizer.ini when loader-based
|
||||
# detection returned Unknown (e.g. Enderal uses a non-SKSE launcher variant)
|
||||
if not game_name and self.modlist_dir:
|
||||
try:
|
||||
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
||||
_detected = detect_game_type_from_modlist(str(self.modlist_dir))
|
||||
if _detected:
|
||||
game_name = game_name_map.get(_detected, _detected)
|
||||
self.logger.info(f"Step 4.7: game type resolved via gameName= fallback: {_detected} -> {game_name}")
|
||||
except Exception as _fe:
|
||||
self.logger.debug(f"Step 4.7 fallback detection failed: {_fe}")
|
||||
|
||||
if game_name:
|
||||
appid_str = str(self.appid)
|
||||
if self.filesystem_handler.create_required_dirs(game_name, appid_str):
|
||||
@@ -276,13 +273,42 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
|
||||
else:
|
||||
self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping")
|
||||
self.logger.debug(f"Game {self.game_var!r} not in directory creation map, skipping")
|
||||
else:
|
||||
self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation")
|
||||
self.logger.warning("AppID not available, skipping Wine prefix Documents directory creation")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
|
||||
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
|
||||
|
||||
# Step 4.8: Configure nxmhandler.ini to suppress MO2 NXM registration popup
|
||||
self.logger.info("Step 4.8: Configuring nxmhandler.ini...")
|
||||
try:
|
||||
self._configure_nxmhandler_ini()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"nxmhandler.ini configuration failed (non-critical): {e}")
|
||||
self.logger.info("Step 4.8: Configuring nxmhandler.ini... Done")
|
||||
|
||||
# Step 4.9: Inject game install path registry entries (FNV/FO3/Enderal/CP2077/BG3).
|
||||
# Required so the game launcher and engine can locate the base game when
|
||||
# MO2 is running inside the Proton prefix. Idempotent: safe to run on
|
||||
# reinstall or re-configure.
|
||||
self.logger.info("Step 4.9: Injecting game registry entries...")
|
||||
try:
|
||||
compatdata_path = self.path_handler.find_compat_data(str(self.appid))
|
||||
if compatdata_path:
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
_svc = AutomatedPrefixService(SystemInfo(
|
||||
is_steamdeck=PlatformDetectionService.get_instance().is_steamdeck
|
||||
))
|
||||
_svc._inject_game_registry_entries(str(compatdata_path), self.game_var or '')
|
||||
else:
|
||||
self.logger.debug("Compatdata path not found for game registry injection, skipping")
|
||||
except Exception as e:
|
||||
self.logger.warning("Game registry injection failed (non-fatal): %s", e)
|
||||
self.logger.info("Step 4.9: Injecting game registry entries... Done")
|
||||
|
||||
# Step 5: Verify ownership of Modlist directory
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
|
||||
@@ -309,6 +335,10 @@ class ModlistConfigurationMixin:
|
||||
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
|
||||
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done")
|
||||
|
||||
# Step 6.1: BG3-specific patches to ModOrganizer.ini and MO2 plugins
|
||||
self._patch_bg3_mod_settings_plugin()
|
||||
self._set_bg3_rootbuilder_copy_mode()
|
||||
|
||||
# Step 6.5: Handle symlinked downloads directory
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
|
||||
@@ -400,6 +430,33 @@ class ModlistConfigurationMixin:
|
||||
self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)")
|
||||
else:
|
||||
self.logger.warning("Could not set download_directory in ModOrganizer.ini")
|
||||
elif modlist_ini_path_obj.is_file():
|
||||
# Configure Existing / Configure New flows: no explicit download_dir is set, but the
|
||||
# INI may have duplicate or mangled entries from the original Wabbajack install.
|
||||
# Read the first valid value, then re-write all occurrences to that value so MO2
|
||||
# reads the correct path regardless of which occurrence it picks up last.
|
||||
existing_linux = self.path_handler.get_download_directory_linux_path(modlist_ini_path_obj)
|
||||
if existing_linux:
|
||||
if self.path_handler.set_download_directory(
|
||||
modlist_ini_path_obj, existing_linux, self.modlist_sdcard
|
||||
):
|
||||
self.logger.info("Normalised download_directory entries in ModOrganizer.ini")
|
||||
else:
|
||||
self.logger.warning("Could not normalise download_directory in ModOrganizer.ini")
|
||||
else:
|
||||
self.logger.debug("No existing download_directory value found in ModOrganizer.ini; skipping normalisation")
|
||||
|
||||
# Step 8.5: Align /home vs /var/home basis for Z: paths to match modlist install directory.
|
||||
# This is intentionally separate from broad binary-path rewriting so it still runs when
|
||||
# engine-installed workflows skip edit_binary_working_paths.
|
||||
if not self.path_handler.align_home_path_basis(
|
||||
modlist_ini_path=modlist_ini_path_obj,
|
||||
modlist_dir_path=modlist_dir_path_obj,
|
||||
modlist_sdcard=self.modlist_sdcard,
|
||||
):
|
||||
self.logger.error("Failed to align home-path basis in ModOrganizer.ini. Configuration aborted.")
|
||||
self.logger.error("Failed to align /home path basis in ModOrganizer.ini.")
|
||||
return False
|
||||
|
||||
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
|
||||
|
||||
@@ -539,6 +596,9 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
self.logger.debug("Step 13: No special launch options needed for this modlist type")
|
||||
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Finalizing post-install configuration")
|
||||
|
||||
# Do not call status_callback here, the final message is handled in menu_handler
|
||||
# if status_callback:
|
||||
# status_callback("Configuration completed successfully!")
|
||||
@@ -546,8 +606,51 @@ class ModlistConfigurationMixin:
|
||||
self.logger.info("Configuration steps completed successfully.")
|
||||
|
||||
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings")
|
||||
self._re_enforce_windows_10_mode()
|
||||
|
||||
# Step 15: Apply tool compatibility settings (xEdit, Pandora, DLL overrides).
|
||||
# Only runs for standard Skyrim SE/AE modlists. Non-Skyrim games (Enderal, FNV,
|
||||
# FO3, etc.) are excluded because the mscoree AppDefault targets SkyrimSE.exe,
|
||||
# which is also Enderal's executable, causing a crash on those modlists.
|
||||
_special_type = self.detect_special_game_type(self.modlist_dir)
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
if ConfigHandler().get('auto_tool_compat', True) and _special_type is None:
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying tool compatibility settings")
|
||||
self.logger.info("Step 15: Applying tool compatibility settings...")
|
||||
compatdata_path = str(wineprefix).replace("/pfx", "").rstrip("/")
|
||||
wine_bin = self._find_wine_binary_for_registry()
|
||||
if compatdata_path and wine_bin:
|
||||
from jackify.backend.services.tool_config_service import apply_tool_config
|
||||
apply_tool_config(
|
||||
compatdata_path,
|
||||
wine_bin,
|
||||
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
|
||||
install_dotnet9_sdk=True,
|
||||
install_fxc2_d3dcompiler=True,
|
||||
)
|
||||
self.logger.info("Step 15: Tool compatibility settings applied")
|
||||
else:
|
||||
self.logger.warning("Step 15: Could not resolve prefix path or wine binary - skipping tool compat")
|
||||
elif _special_type is not None:
|
||||
self.logger.info(f"Step 15: Skipping tool compat for {_special_type} modlist")
|
||||
except Exception as e:
|
||||
self.logger.warning("Step 15: Tool compatibility settings failed (non-fatal): %s", e)
|
||||
|
||||
# Step 16: Nemesis compatibility setup (symlink + workingDirectory fix)
|
||||
try:
|
||||
from jackify.backend.services.tool_config_service import setup_nemesis_compatibility
|
||||
setup_nemesis_compatibility(
|
||||
modlist_dir=self.modlist_dir,
|
||||
stock_game_path=self.stock_game_path,
|
||||
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning("Step 16: Nemesis setup failed (non-fatal): %s", e)
|
||||
|
||||
return True # Return True on success
|
||||
|
||||
def run_modlist_configuration_phase(self, context: dict = None) -> bool:
|
||||
@@ -561,6 +664,48 @@ class ModlistConfigurationMixin:
|
||||
status_callback = context.get('status_callback') if context else None
|
||||
return self._execute_configuration_steps(status_callback=status_callback)
|
||||
|
||||
def _configure_nxmhandler_ini(self) -> None:
|
||||
"""
|
||||
Set noregister=true in nxmhandler.ini in the MO2 install directory.
|
||||
|
||||
MO2 reads this flag on startup and skips the NXM handler registration
|
||||
popup when it is true. On Linux, MO2's NXM handler cannot be registered
|
||||
usefully via Wine; Jackify will become its own NXM handler in a later cycle.
|
||||
Safe to apply on every configuration run - always correct on Linux.
|
||||
"""
|
||||
if not self.modlist_dir:
|
||||
return
|
||||
|
||||
nxm_ini_path = os.path.join(self.modlist_dir, "nxmhandler.ini")
|
||||
|
||||
try:
|
||||
if os.path.exists(nxm_ini_path):
|
||||
with open(nxm_ini_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if re.search(r'(?im)^\s*noregister\s*=\s*true\s*$', content):
|
||||
self.logger.debug("nxmhandler.ini noregister already true, skipping")
|
||||
return
|
||||
|
||||
# Replace existing noregister=... line if present, otherwise inject after [General]
|
||||
if re.search(r'(?im)^\s*noregister\s*=', content):
|
||||
content = re.sub(r'(?im)^\s*noregister\s*=.*$', 'noregister=true', content)
|
||||
elif re.search(r'(?im)^\s*\[General\]', content):
|
||||
content = re.sub(r'(?im)(^\s*\[General\]\s*\n)', r'\1noregister=true\n', content)
|
||||
else:
|
||||
content += '\n[General]\nnoregister=true\n'
|
||||
|
||||
with open(nxm_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
self.logger.info(f"Set noregister=true in {nxm_ini_path}")
|
||||
else:
|
||||
# MO2 creates nxmhandler.ini on first run; pre-create with the flag set
|
||||
with open(nxm_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[General]\nnoregister=true\n")
|
||||
self.logger.info(f"Created nxmhandler.ini with noregister=true: {nxm_ini_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not configure nxmhandler.ini: {e}")
|
||||
|
||||
def _prompt_or_set_resolution(self):
|
||||
# If on Steam Deck, set 1280x800 automatically
|
||||
if self._is_steam_deck():
|
||||
@@ -582,3 +727,72 @@ class ModlistConfigurationMixin:
|
||||
self.selected_resolution = None
|
||||
self.logger.info("Resolution setup skipped by user.")
|
||||
|
||||
def _patch_bg3_mod_settings_plugin(self) -> None:
|
||||
"""
|
||||
Fix a bug in the BG3 MO2 plugin (Alvadus/BG3-MO2-Unofficial-Plugin) where
|
||||
mods_order_node is conditionally created but unconditionally referenced.
|
||||
Bug present in upstream source as of 2026-03; author not yet notified.
|
||||
Safe to apply: always creating the ModOrder node is valid BG3 XML regardless of mod count.
|
||||
"""
|
||||
import os
|
||||
if not self.modlist_dir:
|
||||
return
|
||||
plugin_path = os.path.join(
|
||||
str(self.modlist_dir),
|
||||
"plugins", "basic_games", "games", "baldursgate3", "modSettings.py"
|
||||
)
|
||||
if not os.path.exists(plugin_path):
|
||||
self.logger.debug("BG3 modSettings.py plugin not found, skipping patch")
|
||||
return
|
||||
try:
|
||||
with open(plugin_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
buggy = (
|
||||
" if len(mod_settings) > 1:\n"
|
||||
" mods_order_node = ET.SubElement(children, \"node\")\n"
|
||||
" mods_order_node.set(\"id\", \"ModOrder\")"
|
||||
)
|
||||
fixed = (
|
||||
" mods_order_node = ET.SubElement(children, \"node\")\n"
|
||||
" mods_order_node.set(\"id\", \"ModOrder\")"
|
||||
)
|
||||
if buggy in content:
|
||||
content = content.replace(buggy, fixed)
|
||||
with open(plugin_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
self.logger.info("Applied modSettings.py patch for BG3 MO2 plugin")
|
||||
elif fixed in content:
|
||||
self.logger.debug("BG3 modSettings.py already patched, skipping")
|
||||
else:
|
||||
self.logger.warning("BG3 modSettings.py patch target not found - plugin may have changed upstream")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not patch BG3 modSettings.py: {e} (non-critical, continuing)")
|
||||
|
||||
def _set_bg3_rootbuilder_copy_mode(self) -> None:
|
||||
"""
|
||||
Switch Root Builder to copy mode in ModOrganizer.ini for BG3 modlists.
|
||||
Link mode (the shipped default) fails on Linux - files are not accessible
|
||||
to the game process across the Wine boundary. Copy mode works reliably.
|
||||
Applied unconditionally: copy mode is safe regardless of drive layout.
|
||||
Detected by presence of RootBuilder keys rather than game_var (unreliable for BG3).
|
||||
"""
|
||||
import os, re
|
||||
if not self.modlist_dir:
|
||||
return
|
||||
mo2_ini = os.path.join(str(self.modlist_dir), "ModOrganizer.ini")
|
||||
if not os.path.exists(mo2_ini):
|
||||
self.logger.debug("ModOrganizer.ini not found, skipping Root Builder copy mode patch")
|
||||
return
|
||||
try:
|
||||
with open(mo2_ini, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
if 'RootBuilder\\' not in content and 'RootBuilder/' not in content:
|
||||
self.logger.debug("Root Builder not present in ModOrganizer.ini, skipping")
|
||||
return
|
||||
content = re.sub(r'^(RootBuilder\\copyfiles\s*=).*$', r'\1**', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^(RootBuilder\\linkfiles\s*=).*$', r'\1', content, flags=re.MULTILINE)
|
||||
with open(mo2_ini, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
self.logger.info("Set Root Builder to copy mode in ModOrganizer.ini")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not set Root Builder copy mode: {e} (non-critical, continuing)")
|
||||
|
||||
@@ -253,12 +253,13 @@ class ModlistDetectionMixin:
|
||||
modlist_path = Path(self.modlist_dir)
|
||||
common_names = [
|
||||
"Stock Game",
|
||||
"Game Root",
|
||||
"StockGame",
|
||||
"STOCK GAME",
|
||||
"Stock Game Folder",
|
||||
"Stock Folder",
|
||||
"Skyrim Stock",
|
||||
Path("root/Skyrim Special Edition")
|
||||
Path("root/Skyrim Special Edition"),
|
||||
"Game Root",
|
||||
]
|
||||
|
||||
found_path = None
|
||||
@@ -326,6 +327,15 @@ class ModlistDetectionMixin:
|
||||
if mo2_ini.exists():
|
||||
try:
|
||||
content = mo2_ini.read_text(errors='ignore').lower()
|
||||
# Extract gameName= for authoritative game type checks.
|
||||
# Full-content scans can false-positive on plugin setting keys
|
||||
# (e.g. enable_skyrimVR=false in a Skyrim SE ini).
|
||||
game_name_value = ""
|
||||
for _line in content.splitlines():
|
||||
stripped_line = _line.strip()
|
||||
if stripped_line.startswith("gamename="):
|
||||
game_name_value = stripped_line[len("gamename="):]
|
||||
break
|
||||
if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content:
|
||||
self.logger.info("Detected FNV via ModOrganizer.ini markers")
|
||||
return "fnv"
|
||||
@@ -335,6 +345,18 @@ class ModlistDetectionMixin:
|
||||
if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']):
|
||||
self.logger.info("Detected Enderal via ModOrganizer.ini markers")
|
||||
return "enderal"
|
||||
if 'cyberpunk 2077' in content or 'cyberpunk2077' in content or 'cp2077' in content:
|
||||
self.logger.info("Detected Cyberpunk 2077 via ModOrganizer.ini markers")
|
||||
return "cp2077"
|
||||
if "baldur's gate 3" in content or 'baldursgate3' in content or 'bg3' in content:
|
||||
self.logger.info("Detected Baldur's Gate 3 via ModOrganizer.ini markers")
|
||||
return "bg3"
|
||||
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
|
||||
self.logger.info("Detected SkyrimVR via ModOrganizer.ini gameName")
|
||||
return "skyrimvr"
|
||||
if 'fallout 4 vr' in game_name_value or 'fallout4vr' in game_name_value:
|
||||
self.logger.info("Detected Fallout 4 VR via ModOrganizer.ini gameName")
|
||||
return "fallout4vr"
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}")
|
||||
except Exception:
|
||||
@@ -364,6 +386,15 @@ class ModlistDetectionMixin:
|
||||
if enderal_launcher.exists():
|
||||
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
|
||||
return "enderal"
|
||||
cp2077_exe = base / "Cyberpunk2077.exe"
|
||||
if cp2077_exe.exists():
|
||||
self.logger.info(f"Detected Cyberpunk 2077 modlist: found Cyberpunk2077.exe in '{base}'")
|
||||
return "cp2077"
|
||||
bg3_exe = base / "bg3.exe"
|
||||
bg3_dx11_exe = base / "bg3_dx11.exe"
|
||||
if bg3_exe.exists() or bg3_dx11_exe.exists():
|
||||
self.logger.info(f"Detected BG3 modlist: found BG3 executable in '{base}'")
|
||||
return "bg3"
|
||||
|
||||
# Final heuristic using game_var
|
||||
try:
|
||||
@@ -379,6 +410,18 @@ class ModlistDetectionMixin:
|
||||
if 'enderal' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Enderal")
|
||||
return "enderal"
|
||||
if 'cyberpunk' in gt or 'cp2077' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Cyberpunk 2077")
|
||||
return "cp2077"
|
||||
if "baldur" in gt or 'bg3' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates BG3")
|
||||
return "bg3"
|
||||
if 'skyrim vr' in gt or 'skyrimvr' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates SkyrimVR")
|
||||
return "skyrimvr"
|
||||
if 'fallout 4 vr' in gt or 'fallout4vr' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Fallout 4 VR")
|
||||
return "fallout4vr"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -61,32 +61,9 @@ class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWi
|
||||
Handles operations related to modlist detection and configuration
|
||||
"""
|
||||
|
||||
# Dictionary mapping modlist name patterns (lowercase, spaces optional)
|
||||
# to lists of additional Wine components or special actions.
|
||||
MODLIST_SPECIFIC_COMPONENTS = {
|
||||
# Pattern: [component1, component2, ... or special_action_string]
|
||||
"wildlander": ["dotnet48"], # Example from bash script
|
||||
"licentia": ["dotnet8"], # Example from bash script (needs special handling)
|
||||
"nolvus": ["dotnet6", "dotnet7"], # Example
|
||||
# Add other modlists and their specific needs here
|
||||
# e.g., "fallout4_anotherlife": ["some_component"]
|
||||
}
|
||||
|
||||
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
|
||||
# dotnet4.x components disabled in v0.1.6.2 -- replaced with universal registry fixes
|
||||
MODLIST_WINE_COMPONENTS = {
|
||||
# "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
|
||||
# "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
|
||||
"librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
|
||||
# "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
|
||||
"apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
|
||||
# "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
}
|
||||
MODLIST_SPECIFIC_COMPONENTS: dict = {}
|
||||
|
||||
MODLIST_WINE_COMPONENTS: dict = {}
|
||||
|
||||
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
|
||||
mo2_path: Optional[Union[str, Path]] = None,
|
||||
|
||||
@@ -159,14 +159,17 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
self.logger.info(f"Using machineid: {machineid}")
|
||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||
|
||||
writeback_path = str(auth_service.get_token_writeback_path())
|
||||
# Store original environment values to restore later
|
||||
original_env_values = {
|
||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
|
||||
'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
|
||||
# Temporarily modify current process's environment
|
||||
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
|
||||
if oauth_info:
|
||||
@@ -341,7 +344,8 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
print()
|
||||
|
||||
proc.wait()
|
||||
|
||||
auth_service.apply_token_writeback(writeback_path)
|
||||
|
||||
finally:
|
||||
# Stop performance monitoring and get summary
|
||||
if monitoring_started:
|
||||
|
||||
@@ -7,6 +7,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -352,10 +353,16 @@ class ModlistInstallCLITTWMixin:
|
||||
print(f"\nTTW has been installed to: {ttw_output_path}")
|
||||
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
|
||||
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
else:
|
||||
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
|
||||
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
|
||||
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
|
||||
@@ -59,33 +59,11 @@ class ModlistWineOpsMixin:
|
||||
self.logger.error("Could not locate Steam's config.vdf file.")
|
||||
return False, 'config_vdf_missing'
|
||||
|
||||
# Add a short delay to allow Steam to potentially finish writing changes
|
||||
self.logger.debug("Waiting 2 seconds before reading config.vdf...")
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}")
|
||||
# CORRECTION: Use the vdf library directly here, not VDFHandler
|
||||
self.logger.debug(f"Loading config.vdf: {config_vdf_path}")
|
||||
with open(str(config_vdf_path), 'r') as f:
|
||||
config_data = vdf.load(f, mapper=vdf.VDFDict)
|
||||
config_data = vdf.load(f, mapper=vdf.VDFDict)
|
||||
|
||||
# --- Write full config.vdf to a debug file ---
|
||||
debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt")
|
||||
with open(debug_dump_path, "w") as dump_f:
|
||||
json.dump(config_data, dump_f, indent=2)
|
||||
self.logger.info(f"Full config.vdf dumped to {debug_dump_path}")
|
||||
|
||||
# --- Log only the relevant section for this AppID ---
|
||||
steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {})
|
||||
compat_mapping = steam_config_section.get('CompatToolMapping', {})
|
||||
app_mapping = compat_mapping.get(appid_to_check, {})
|
||||
self.logger.debug("───────────────────────────────────────────────────────────────────")
|
||||
self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):")
|
||||
self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2))
|
||||
self.logger.debug("───────────────────────────────────────────────────────────────────")
|
||||
self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}")
|
||||
# --- End Debugging ---
|
||||
|
||||
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
|
||||
compat_mapping = steam_config_section.get('CompatToolMapping', {})
|
||||
app_mapping = compat_mapping.get(appid_to_check, {})
|
||||
@@ -152,14 +130,24 @@ class ModlistWineOpsMixin:
|
||||
self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.")
|
||||
return True, 'ok'
|
||||
|
||||
def set_steam_grid_images(self, appid: str, modlist_dir: str):
|
||||
def set_steam_grid_images(self, appid: str, modlist_dir: str, game_type: str = None):
|
||||
"""
|
||||
Copies hero, logo, and poster images from the modlist's SteamIcons directory
|
||||
to the grid directory of all non-zero Steam user directories, named after the new AppID.
|
||||
Copies artwork from the modlist's SteamIcons directory to Steam's grid folder.
|
||||
Falls back to SteamGridDB if no SteamIcons directory is present and an API key
|
||||
is configured.
|
||||
"""
|
||||
if modlist_dir:
|
||||
try:
|
||||
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
||||
detected_game_type = detect_game_type_from_modlist(modlist_dir)
|
||||
if detected_game_type:
|
||||
game_type = detected_game_type
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Steam artwork game type auto-detect failed for {modlist_dir}: {e}")
|
||||
|
||||
steam_icons_dir = Path(modlist_dir) / "SteamIcons"
|
||||
if not steam_icons_dir.is_dir():
|
||||
self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.")
|
||||
self._try_steamgriddb_artwork(appid, game_type, modlist_dir)
|
||||
return
|
||||
|
||||
# Find all non-zero Steam user directories
|
||||
@@ -177,8 +165,8 @@ class ModlistWineOpsMixin:
|
||||
images = [
|
||||
("grid-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
("grid-tall.png", f"{appid}.png"),
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
("grid-wide.png", f"{appid}.png"),
|
||||
]
|
||||
|
||||
for src_name, dest_name in images:
|
||||
@@ -191,7 +179,85 @@ class ModlistWineOpsMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}")
|
||||
else:
|
||||
self.logger.warning(f"Image {src_path} not found; skipping.")
|
||||
self.logger.debug(f"Image {src_path} not found; skipping.")
|
||||
|
||||
# Tenfoot: use explicit file if provided, otherwise resize the landscape grid
|
||||
tenfoot_src = steam_icons_dir / "grid-tenfoot.png"
|
||||
tenfoot_dest = grid_dir / f"{appid}_tenfoot.png"
|
||||
wide_src = steam_icons_dir / "grid-wide.png"
|
||||
if tenfoot_src.exists():
|
||||
try:
|
||||
shutil.copyfile(tenfoot_src, tenfoot_dest)
|
||||
self.logger.info(f"Copied {tenfoot_src} to {tenfoot_dest}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to copy tenfoot image: {e}")
|
||||
elif wide_src.exists():
|
||||
try:
|
||||
from PySide6.QtGui import QImage
|
||||
img = QImage(str(wide_src))
|
||||
if not img.isNull():
|
||||
scaled = img.scaled(600, 350)
|
||||
scaled.save(str(tenfoot_dest))
|
||||
self.logger.info(f"Generated tenfoot image from landscape: {tenfoot_dest}")
|
||||
else:
|
||||
self.logger.warning(f"Could not load landscape image for tenfoot generation: {wide_src}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not generate tenfoot image: {e}")
|
||||
|
||||
def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None):
|
||||
"""Fetch default artwork from SteamGridDB when no modlist-provided SteamIcons exist."""
|
||||
if not game_type and modlist_dir:
|
||||
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
||||
game_type = detect_game_type_from_modlist(modlist_dir)
|
||||
if not game_type:
|
||||
self.logger.warning(f"SteamGridDB fallback skipped: could not detect game type for {modlist_dir}")
|
||||
return
|
||||
|
||||
userdata_base = Path.home() / ".steam/steam/userdata"
|
||||
if not userdata_base.is_dir():
|
||||
return
|
||||
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
from jackify.backend.services.steamgriddb_service import fetch_artwork
|
||||
count = fetch_artwork(game_type, tmp_dir)
|
||||
if count == 0:
|
||||
self.logger.debug(f"SteamGridDB returned no artwork for game type: {game_type}")
|
||||
return
|
||||
|
||||
for user_dir in userdata_base.iterdir():
|
||||
if not user_dir.is_dir() or user_dir.name == "0":
|
||||
continue
|
||||
grid_dir = user_dir / "config/grid"
|
||||
grid_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
images = [
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
("grid-wide.png", f"{appid}.png"),
|
||||
("grid-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
]
|
||||
for src_name, dest_name in images:
|
||||
src = tmp_dir / src_name
|
||||
if src.exists():
|
||||
try:
|
||||
shutil.copyfile(src, grid_dir / dest_name)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to copy {src_name}: {e}")
|
||||
|
||||
# Generate tenfoot from landscape
|
||||
wide = tmp_dir / "grid-wide.png"
|
||||
if wide.exists():
|
||||
try:
|
||||
from PySide6.QtGui import QImage
|
||||
img = QImage(str(wide))
|
||||
if not img.isNull():
|
||||
img.scaled(600, 350).save(str(grid_dir / f"{appid}_tenfoot.png"))
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not generate tenfoot: {e}")
|
||||
|
||||
self.logger.info(f"Applied SteamGridDB artwork for game type '{game_type}' ({count} images)")
|
||||
|
||||
def get_modlist_wine_components(self, modlist_name, game_var_full=None):
|
||||
"""
|
||||
@@ -206,12 +272,16 @@ class ModlistWineOpsMixin:
|
||||
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
|
||||
# Add game-specific extras
|
||||
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
|
||||
extras += ["d3dx9_43", "d3dx9"]
|
||||
elif "cp2077" in game or "cyberpunk" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
elif "bg3" in game or "baldursgate" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
else:
|
||||
# Unknown game type — install the union of all known component sets
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "d3dx9_43", "d3dx9"]
|
||||
# Unknown game type - install the union of all known component sets
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "d3dx9_43", "d3dx9"]
|
||||
# Add modlist-specific extras
|
||||
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
||||
for key, components in self.MODLIST_WINE_COMPONENTS.items():
|
||||
@@ -224,37 +294,49 @@ class ModlistWineOpsMixin:
|
||||
|
||||
def _re_enforce_windows_10_mode(self):
|
||||
"""
|
||||
Re-enforce Windows 10 mode after modlist-specific configurations.
|
||||
This matches the legacy script behavior (line 1333) where Windows 10 mode
|
||||
is re-applied after modlist-specific steps to ensure consistency.
|
||||
Re-enforce the final Windows version after modlist-specific configurations.
|
||||
Re-applies win10 after modlist-specific winetricks components, which can
|
||||
leave the prefix at a lower version.
|
||||
"""
|
||||
try:
|
||||
if not hasattr(self, 'appid') or not self.appid:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
|
||||
self.logger.warning("Cannot re-enforce Windows 11 mode - no AppID available")
|
||||
return
|
||||
|
||||
from ..handlers.winetricks_handler import WinetricksHandler
|
||||
from ..handlers.path_handler import PathHandler
|
||||
|
||||
# Get prefix path for the AppID
|
||||
prefix_path = PathHandler.find_compat_data(str(self.appid))
|
||||
if not prefix_path:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
|
||||
# Get prefix path for the AppID - must be compatdata/pfx/, not compatdata/
|
||||
compatdata_path = PathHandler.find_compat_data(str(self.appid))
|
||||
if not compatdata_path:
|
||||
self.logger.warning("Cannot re-enforce Windows 11 mode - prefix path not found")
|
||||
return
|
||||
prefix_path = compatdata_path / "pfx"
|
||||
|
||||
# Use winetricks handler to set Windows 10 mode
|
||||
# Use winetricks handler to set Windows 11 mode
|
||||
winetricks_handler = WinetricksHandler()
|
||||
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
|
||||
if not wine_binary:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
|
||||
self.logger.warning("Cannot re-enforce Windows 11 mode - wine binary not found")
|
||||
return
|
||||
|
||||
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
|
||||
|
||||
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations")
|
||||
env = os.environ.copy()
|
||||
env['WINEPREFIX'] = str(prefix_path)
|
||||
env['WINE'] = wine_binary
|
||||
result = subprocess.run(
|
||||
[winetricks_handler.winetricks_path, '-q', 'win10'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.logger.info("Windows 11 mode re-enforced after modlist-specific configurations")
|
||||
else:
|
||||
self.logger.warning("Could not set Windows 11 mode: %s", result.stderr)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
|
||||
self.logger.warning(f"Error re-enforcing Windows 11 mode: {e}")
|
||||
|
||||
def _handle_symlinked_downloads(self) -> bool:
|
||||
"""
|
||||
@@ -380,21 +462,17 @@ class ModlistWineOpsMixin:
|
||||
env['WINEPREFIX'] = prefix_path
|
||||
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
|
||||
|
||||
# Shutdown any running wineserver processes to ensure clean slate
|
||||
if wineserver_binary:
|
||||
self.logger.debug("Shutting down wineserver before applying registry fixes...")
|
||||
try:
|
||||
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
|
||||
self.logger.debug("Wineserver shutdown complete")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
|
||||
self._wait_for_wineserver(prefix_path)
|
||||
|
||||
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
|
||||
# Use native .NET runtime instead of Wine's
|
||||
self.logger.debug("Setting *mscoree=native DLL override...")
|
||||
# Registry fix 1: Set *mscoree=native as a per-exe AppDefaults override for
|
||||
# SkyrimSE.exe only. A global DllOverrides entry breaks .NET 9/10 bootstrap
|
||||
# (Synthesis), because the override intercepts mscoree loading for ALL processes
|
||||
# including the SDK host. Scoping it to SkyrimSE.exe isolates the fix to the
|
||||
# game process without affecting Synthesis or any other .NET tool.
|
||||
self.logger.debug("Setting *mscoree=native AppDefaults override for SkyrimSE.exe...")
|
||||
cmd1 = [
|
||||
wine_binary, 'reg', 'add',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
|
||||
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
|
||||
]
|
||||
|
||||
@@ -430,43 +508,12 @@ class ModlistWineOpsMixin:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Registry flush failed (non-critical): {e}")
|
||||
|
||||
# VERIFICATION: Confirm the registry entries persisted
|
||||
self.logger.info("Verifying registry entries were applied and persisted...")
|
||||
verification_passed = True
|
||||
|
||||
# Verify *mscoree=native
|
||||
verify_cmd1 = [
|
||||
wine_binary, 'reg', 'query',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'/v', '*mscoree'
|
||||
]
|
||||
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
|
||||
self.logger.info("VERIFIED: *mscoree=native is set correctly")
|
||||
ok = result1.returncode == 0 and result2.returncode == 0
|
||||
if ok:
|
||||
self.logger.info("Universal dotnet4.x compatibility fixes applied and flushed")
|
||||
else:
|
||||
self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}")
|
||||
verification_passed = False
|
||||
|
||||
# Verify OnlyUseLatestCLR=1
|
||||
verify_cmd2 = [
|
||||
wine_binary, 'reg', 'query',
|
||||
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
|
||||
'/v', 'OnlyUseLatestCLR'
|
||||
]
|
||||
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
||||
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
|
||||
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
|
||||
else:
|
||||
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
|
||||
verification_passed = False
|
||||
|
||||
# Both fixes applied and verified
|
||||
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
|
||||
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
|
||||
return False
|
||||
self.logger.error("One or more dotnet4.x registry commands failed - see errors above")
|
||||
return ok
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
|
||||
@@ -506,6 +553,204 @@ class ModlistWineOpsMixin:
|
||||
self.logger.error(f"Error finding Wine binary: {e}")
|
||||
return None
|
||||
|
||||
def _wait_for_wineserver(self, prefix_path: str) -> None:
|
||||
"""Wait for wineserver to stop for the given prefix before direct file edits.
|
||||
|
||||
Harmless if wineserver is already stopped - exits immediately.
|
||||
Prevents in-memory hive flush from overwriting direct .reg file edits.
|
||||
"""
|
||||
wine_binary = self._find_wine_binary_for_registry()
|
||||
if not wine_binary:
|
||||
self.logger.debug("No wine binary found; skipping wineserver wait")
|
||||
return
|
||||
wineserver = os.path.join(os.path.dirname(wine_binary), "wineserver")
|
||||
if not os.path.exists(wineserver):
|
||||
self.logger.debug("wineserver binary not found; skipping wait")
|
||||
return
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = prefix_path
|
||||
env["WINEDEBUG"] = "-all"
|
||||
try:
|
||||
subprocess.run([wineserver, "-w"], env=env, timeout=30, capture_output=True)
|
||||
self.logger.debug("wineserver stopped for prefix %s", prefix_path)
|
||||
except Exception as e:
|
||||
self.logger.debug("wineserver wait returned non-zero (likely already stopped): %s", e)
|
||||
|
||||
def _apply_modlist_registry_tweaks(self) -> bool:
|
||||
"""Write user.reg values required for modlist operation.
|
||||
|
||||
- FontSmoothing/Type/Gamma/Orientation (ClearType subpixel rendering)
|
||||
- HIGHDPIAWARE (prevents Wine DPI scaling on tools)
|
||||
- ShowDotFiles=Y (MO2 must see hidden dirs inside the prefix)
|
||||
"""
|
||||
try:
|
||||
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
||||
user_reg = os.path.join(prefix_path, "user.reg")
|
||||
if not os.path.exists(user_reg):
|
||||
self.logger.warning("user.reg not found at %s; skipping modlist registry tweaks", user_reg)
|
||||
return False
|
||||
|
||||
self._wait_for_wineserver(prefix_path)
|
||||
|
||||
tweaks = [
|
||||
(
|
||||
"[Control Panel\\\\Desktop]",
|
||||
'"FontSmoothing"',
|
||||
'"2"',
|
||||
),
|
||||
(
|
||||
"[Control Panel\\\\Desktop]",
|
||||
'"FontSmoothingGamma"',
|
||||
"dword:00000578",
|
||||
),
|
||||
(
|
||||
"[Control Panel\\\\Desktop]",
|
||||
'"FontSmoothingOrientation"',
|
||||
"dword:00000001",
|
||||
),
|
||||
(
|
||||
"[Control Panel\\\\Desktop]",
|
||||
'"FontSmoothingType"',
|
||||
"dword:00000002",
|
||||
),
|
||||
(
|
||||
"[Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\AppCompatFlags\\\\Layers]",
|
||||
'@',
|
||||
'"~ HIGHDPIAWARE"',
|
||||
),
|
||||
(
|
||||
"[Software\\\\Wine]",
|
||||
'"ShowDotFiles"',
|
||||
'"Y"',
|
||||
),
|
||||
]
|
||||
|
||||
with open(user_reg, "r", encoding="utf-8", errors="ignore") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for section, key, value in tweaks:
|
||||
in_section = False
|
||||
updated = False
|
||||
insert_at = None
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.lower() == section.lower():
|
||||
in_section = True
|
||||
continue
|
||||
if stripped.startswith("[") and in_section:
|
||||
insert_at = i
|
||||
break
|
||||
if in_section and stripped.lower().startswith(key.lower()):
|
||||
lines[i] = f"{key}={value}\n"
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
entry = f"{key}={value}\n"
|
||||
if insert_at is not None:
|
||||
lines.insert(insert_at, entry)
|
||||
elif in_section:
|
||||
lines.append(entry)
|
||||
else:
|
||||
lines.append(f"\n{section}\n")
|
||||
lines.append(entry)
|
||||
|
||||
with open(user_reg, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
self.logger.info("Modlist registry tweaks applied (font smoothing, HIGHDPIAWARE, ShowDotFiles)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to apply modlist registry tweaks: %s", e)
|
||||
return False
|
||||
|
||||
def _audit_registry_state(self) -> bool:
|
||||
"""Read user.reg and system.reg and log whether every expected value is present.
|
||||
|
||||
Returns True only when all checks pass. Logs a WARNING for each missing or
|
||||
wrong value so the application log always carries a clear post-configuration
|
||||
record of registry state.
|
||||
"""
|
||||
try:
|
||||
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
||||
user_reg = os.path.join(prefix_path, "user.reg")
|
||||
system_reg = os.path.join(prefix_path, "system.reg")
|
||||
|
||||
def _read(path):
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return f.read()
|
||||
|
||||
user_content = _read(user_reg)
|
||||
system_content = _read(system_reg)
|
||||
|
||||
checks = [
|
||||
# (description, file_content, expected_substring)
|
||||
(
|
||||
"ShowDotFiles=Y (user.reg)",
|
||||
user_content,
|
||||
'"ShowDotFiles"="Y"',
|
||||
),
|
||||
(
|
||||
"FontSmoothing=2 (user.reg)",
|
||||
user_content,
|
||||
'"FontSmoothing"="2"',
|
||||
),
|
||||
(
|
||||
"FontSmoothingType=2 (user.reg)",
|
||||
user_content,
|
||||
'"FontSmoothingType"=dword:00000002',
|
||||
),
|
||||
(
|
||||
"FontSmoothingGamma (user.reg)",
|
||||
user_content,
|
||||
'"FontSmoothingGamma"=dword:00000578',
|
||||
),
|
||||
(
|
||||
"FontSmoothingOrientation (user.reg)",
|
||||
user_content,
|
||||
'"FontSmoothingOrientation"=dword:00000001',
|
||||
),
|
||||
(
|
||||
"HIGHDPIAWARE (user.reg)",
|
||||
user_content,
|
||||
'HIGHDPIAWARE',
|
||||
),
|
||||
(
|
||||
"*mscoree=native (user.reg)",
|
||||
user_content,
|
||||
'"*mscoree"="native"',
|
||||
),
|
||||
(
|
||||
"OnlyUseLatestCLR=1 (system.reg)",
|
||||
system_content,
|
||||
'"OnlyUseLatestCLR"=dword:00000001',
|
||||
),
|
||||
]
|
||||
|
||||
all_ok = True
|
||||
for description, content, needle in checks:
|
||||
if needle in content:
|
||||
self.logger.info("Registry audit [OK] %s", description)
|
||||
else:
|
||||
self.logger.warning("Registry audit [MISSING] %s", description)
|
||||
all_ok = False
|
||||
|
||||
if all_ok:
|
||||
self.logger.info("Registry audit complete - all values confirmed present")
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Registry audit complete - one or more values missing; "
|
||||
"see [MISSING] entries above"
|
||||
)
|
||||
return all_ok
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Registry audit failed with exception: %s", e)
|
||||
return False
|
||||
|
||||
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Recursively search for wine binary within a Proton directory.
|
||||
@@ -543,4 +788,3 @@ class ModlistWineOpsMixin:
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ class PathHandlerGameMixin:
|
||||
return False
|
||||
modlist_path = Path(self.modlist_dir)
|
||||
preferred_order = [
|
||||
"Stock Game", "STOCK GAME", "Skyrim Stock", "Stock Game Folder",
|
||||
"Stock Game", "StockGame", "STOCK GAME", "Skyrim Stock", "Stock Game Folder",
|
||||
"Stock Folder", Path("root/Skyrim Special Edition"), "Game Root"
|
||||
]
|
||||
found_path = None
|
||||
@@ -180,5 +180,6 @@ class PathHandlerGameMixin:
|
||||
self.stock_game_path = found_path
|
||||
return True
|
||||
self.stock_game_path = None
|
||||
self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.")
|
||||
searched = [str(modlist_path / n) for n in preferred_order]
|
||||
self.logger.info(f"No common Stock Game/Game Root directory found (searched: {searched}). Will assume vanilla game path is needed for some operations.")
|
||||
return True
|
||||
|
||||
@@ -21,13 +21,102 @@ TARGET_EXECUTABLES_LOWER = [
|
||||
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe",
|
||||
"sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"
|
||||
]
|
||||
STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"]
|
||||
STOCK_GAME_FOLDERS = ["Stock Game", "StockGame", "Game Root", "Stock Folder", "Skyrim Stock"]
|
||||
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
|
||||
|
||||
|
||||
class PathHandlerMO2Mixin:
|
||||
"""Mixin providing ModOrganizer.ini path updates and formatting."""
|
||||
|
||||
@staticmethod
|
||||
def _desired_home_basis_from_modlist_dir(modlist_dir_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Determine desired Linux home-path basis from modlist install directory.
|
||||
|
||||
Returns:
|
||||
"/var/home" when modlist dir is under /var/home,
|
||||
"/home" when modlist dir is under /home,
|
||||
None otherwise.
|
||||
"""
|
||||
try:
|
||||
posix = modlist_dir_path.as_posix()
|
||||
except Exception:
|
||||
posix = str(modlist_dir_path).replace("\\", "/")
|
||||
if posix.startswith("/var/home/"):
|
||||
return "/var/home"
|
||||
if posix.startswith("/home/"):
|
||||
return "/home"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_z_home_basis_in_line(line: str, desired_home_basis: str) -> str:
|
||||
"""
|
||||
Rewrite only Z:-drive /home -> /var/home path basis in a single INI line.
|
||||
|
||||
Preserves slash style (forward or backslash), and leaves D: paths untouched.
|
||||
"""
|
||||
if desired_home_basis == "/var/home":
|
||||
# Z:/home/... -> Z:/var/home/...
|
||||
# Z:\\home\\... -> Z:\\var\\home\\...
|
||||
return re.sub(r'([Zz]:[/\\]+)home([/\\]+)', r'\1var\2home\2', line)
|
||||
return line
|
||||
|
||||
def align_home_path_basis(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool) -> bool:
|
||||
"""
|
||||
Align gamePath/binary/workingDirectory home-path basis to modlist_dir_path.
|
||||
|
||||
This is a targeted post-processing step for Z: paths only:
|
||||
- If install path is /var/home/... then rewrite Z:/home/... to Z:/var/home/...
|
||||
- Otherwise do nothing.
|
||||
"""
|
||||
if modlist_sdcard:
|
||||
return True
|
||||
desired_home_basis = self._desired_home_basis_from_modlist_dir(modlist_dir_path)
|
||||
# This alignment pass is intentionally one-way:
|
||||
# only promote Z:/home -> Z:/var/home when install dir uses /var/home.
|
||||
if desired_home_basis != "/var/home":
|
||||
return True
|
||||
if not modlist_ini_path.is_file():
|
||||
logger.error(f"INI file {modlist_ini_path} does not exist for home-basis alignment")
|
||||
return False
|
||||
try:
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
changed = 0
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not (
|
||||
re.match(r'^\s*gamepath\s*=.*$', stripped, re.IGNORECASE)
|
||||
or re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE)
|
||||
or re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
|
||||
):
|
||||
continue
|
||||
rewritten = self._rewrite_z_home_basis_in_line(line, desired_home_basis)
|
||||
if rewritten != line:
|
||||
lines[i] = rewritten
|
||||
changed += 1
|
||||
|
||||
if changed > 0:
|
||||
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
logger.info(
|
||||
"Aligned ModOrganizer.ini home-path basis to %s for %d line(s): %s",
|
||||
desired_home_basis,
|
||||
changed,
|
||||
modlist_ini_path,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"No home-path basis alignment needed for %s (target %s)",
|
||||
modlist_ini_path,
|
||||
desired_home_basis,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error aligning home path basis in {modlist_ini_path}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
|
||||
"""Removes SD card mount prefix. Returns path as POSIX-style string."""
|
||||
@@ -344,10 +433,16 @@ class PathHandlerMO2Mixin:
|
||||
if "/mods/" in cleaned_value:
|
||||
idx = cleaned_value.index("/mods/")
|
||||
rel_path = cleaned_value[idx:].lstrip('/')
|
||||
elif existing_game_path:
|
||||
rel_path = None
|
||||
game_path_base = existing_game_path
|
||||
else:
|
||||
rel_path = exe_name
|
||||
processed_modlist_path = self._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
|
||||
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
|
||||
if rel_path is not None:
|
||||
processed_modlist_path = self._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
|
||||
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
|
||||
else:
|
||||
new_binary_path = f"{drive_prefix}/{game_path_base}/{exe_name}".replace('\\', '/').replace('//', '/')
|
||||
formatted_binary_path = PathHandlerMO2Mixin._format_binary_for_mo2(new_binary_path)
|
||||
if '"' in formatted_binary_path:
|
||||
formatted_binary_path = formatted_binary_path.replace('"', '')
|
||||
@@ -439,7 +534,8 @@ class PathHandlerMO2Mixin:
|
||||
def set_download_directory(self, modlist_ini_path: Path, download_dir_linux_path, modlist_sdcard: bool) -> bool:
|
||||
"""
|
||||
Set download_directory in ModOrganizer.ini to the correct Wine path (Z: or D: for SD card).
|
||||
Use only when download dir is known (e.g. Install a Modlist flow). Configure New/Existing leave as-is.
|
||||
Replaces ALL occurrences of the key throughout the file - MO2 reads the last one, and
|
||||
duplicate [General] sections from Wabbajack installs are common.
|
||||
"""
|
||||
if not modlist_ini_path.is_file() or not download_dir_linux_path:
|
||||
return False
|
||||
@@ -458,35 +554,62 @@ class PathHandlerMO2Mixin:
|
||||
formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path)
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
in_general = False
|
||||
download_line_idx = -1
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
|
||||
in_general = True
|
||||
continue
|
||||
if in_general and re.match(r'^\s*\[', line):
|
||||
break
|
||||
if in_general and re.match(r'^\s*download_directory\s*=', line, re.IGNORECASE):
|
||||
download_line_idx = i
|
||||
break
|
||||
new_line = f"download_directory = {formatted}\n"
|
||||
if download_line_idx >= 0:
|
||||
lines[download_line_idx] = new_line
|
||||
replaced = [i for i, l in enumerate(lines) if re.match(r'^\s*download_directory\s*=', l, re.IGNORECASE)]
|
||||
if replaced:
|
||||
for i in replaced:
|
||||
lines[i] = new_line
|
||||
else:
|
||||
if in_general:
|
||||
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
|
||||
if insert_idx >= 0:
|
||||
# No existing entry - insert after [General]
|
||||
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
|
||||
if insert_idx >= 0:
|
||||
insert_idx += 1
|
||||
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
|
||||
insert_idx += 1
|
||||
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
|
||||
insert_idx += 1
|
||||
lines.insert(insert_idx, new_line)
|
||||
lines.insert(insert_idx, new_line)
|
||||
else:
|
||||
lines.append("[General]\n")
|
||||
lines.append(new_line)
|
||||
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted}")
|
||||
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted} ({len(replaced)} occurrence(s))")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_download_directory_linux_path(self, modlist_ini_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Read the first valid download_directory value from ModOrganizer.ini and convert to a Linux path.
|
||||
Returns None if no valid Z: or D: path is found.
|
||||
"""
|
||||
if not modlist_ini_path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8-sig') as f:
|
||||
lines = f.readlines()
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
with open(modlist_ini_path, 'r', encoding='latin-1') as f:
|
||||
lines = f.readlines()
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
for line in lines:
|
||||
m = re.match(r'^\s*download_directory\s*=\s*(.+)$', line, re.IGNORECASE)
|
||||
if not m:
|
||||
continue
|
||||
raw = m.group(1).strip()
|
||||
# Expect Z:\\path\\... or D:\\path\\... (MO2 doubles backslashes in the file)
|
||||
drive_m = re.match(r'^([ZzDd]):(.+)$', raw)
|
||||
if not drive_m:
|
||||
continue
|
||||
drive, rest = drive_m.group(1).upper(), drive_m.group(2)
|
||||
# Collapse doubled backslashes back to single separators
|
||||
rest = re.sub(r'\\\\', '/', rest).replace('\\', '/')
|
||||
if drive == 'Z':
|
||||
return '/' + rest.lstrip('/')
|
||||
# D: (SD card) - return as-is with leading slash; caller handles sdcard prefix
|
||||
return '/' + rest.lstrip('/')
|
||||
return None
|
||||
|
||||
@@ -12,6 +12,10 @@ from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import vdf
|
||||
from jackify.shared.steam_utils import (
|
||||
get_ordered_steam_roots,
|
||||
STEAM_PREFERENCE_AUTO,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,11 +27,7 @@ class PathHandlerSteamMixin:
|
||||
def find_steam_config_vdf() -> Optional[Path]:
|
||||
"""Finds the active Steam config.vdf file."""
|
||||
logger.debug("Searching for Steam config.vdf...")
|
||||
possible_steam_paths = [
|
||||
Path.home() / ".steam/steam",
|
||||
Path.home() / ".local/share/Steam",
|
||||
Path.home() / ".steam/root"
|
||||
]
|
||||
possible_steam_paths = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
|
||||
for steam_path in possible_steam_paths:
|
||||
potential_path = steam_path / "config/config.vdf"
|
||||
if potential_path.is_file():
|
||||
@@ -40,10 +40,9 @@ class PathHandlerSteamMixin:
|
||||
def find_steam_library() -> Optional[Path]:
|
||||
"""Find the primary Steam library common directory containing games."""
|
||||
logger.debug("Attempting to find Steam library...")
|
||||
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
|
||||
libraryfolders_vdf_paths = [
|
||||
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
|
||||
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
|
||||
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"),
|
||||
str(root / "config" / "libraryfolders.vdf") for root in ordered_roots
|
||||
]
|
||||
for path in libraryfolders_vdf_paths:
|
||||
if os.path.exists(path):
|
||||
@@ -92,14 +91,11 @@ class PathHandlerSteamMixin:
|
||||
logger.info(f"Using Steam library common path: {library_paths[0]}")
|
||||
return library_paths[0]
|
||||
logger.debug("No valid common paths found in VDF, checking default location...")
|
||||
default_common_path = Path.home() / ".steam/steam/steamapps/common"
|
||||
if default_common_path.is_dir():
|
||||
logger.info(f"Using default Steam library common path: {default_common_path}")
|
||||
return default_common_path
|
||||
default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common"
|
||||
if default_common_path_local.is_dir():
|
||||
logger.info(f"Using default local Steam library common path: {default_common_path_local}")
|
||||
return default_common_path_local
|
||||
for root in ordered_roots:
|
||||
default_common_path = root / "steamapps" / "common"
|
||||
if default_common_path.is_dir():
|
||||
logger.info(f"Using default Steam library common path: {default_common_path}")
|
||||
return default_common_path
|
||||
logger.error("No valid Steam library common path found in VDF or default locations.")
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -181,12 +177,8 @@ class PathHandlerSteamMixin:
|
||||
def get_all_steam_library_paths() -> List[Path]:
|
||||
"""Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak)."""
|
||||
logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...")
|
||||
vdf_paths = [
|
||||
Path.home() / ".steam/steam/config/libraryfolders.vdf",
|
||||
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
|
||||
Path.home() / ".steam/root/config/libraryfolders.vdf",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf",
|
||||
]
|
||||
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
|
||||
vdf_paths = [root / "config" / "libraryfolders.vdf" for root in ordered_roots]
|
||||
library_paths = set()
|
||||
for vdf_path in vdf_paths:
|
||||
if vdf_path.is_file():
|
||||
|
||||
@@ -90,7 +90,7 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
|
||||
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
|
||||
# Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining"
|
||||
# Timestamp prefix is now optional — engine no longer emits [HH:MM:SS].
|
||||
# Timestamp prefix is now optional - engine no longer emits [HH:MM:SS].
|
||||
self.timestamp_status_pattern = re.compile(
|
||||
r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
|
||||
re.IGNORECASE
|
||||
@@ -157,10 +157,17 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
ParsedLine with extracted information
|
||||
"""
|
||||
result = ParsedLine(message=line.strip())
|
||||
|
||||
|
||||
if not line.strip():
|
||||
return result
|
||||
|
||||
|
||||
# Suppress internal engine lines that are not user-facing
|
||||
_suppress_prefixes = (
|
||||
"Refreshing OAuth Token",
|
||||
)
|
||||
if any(line.strip().startswith(p) for p in _suppress_prefixes):
|
||||
return ParsedLine()
|
||||
|
||||
# Try to extract phase information
|
||||
phase_info = self._extract_phase(line)
|
||||
if phase_info:
|
||||
|
||||
@@ -20,11 +20,11 @@ class ProgressParserPhaseMixin:
|
||||
phase = self._map_section_to_phase(section_name)
|
||||
return (phase, section_match.group(1).strip())
|
||||
|
||||
# [FILE_PROGRESS] lines drive file activity only — skip phase extraction for them
|
||||
# [FILE_PROGRESS] lines drive file activity only - skip phase extraction for them
|
||||
if '[FILE_PROGRESS]' in line:
|
||||
return None
|
||||
|
||||
# Make the [timestamp] prefix optional — engine no longer emits it.
|
||||
# Make the [timestamp] prefix optional - engine no longer emits it.
|
||||
action_match = re.search(
|
||||
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
|
||||
line,
|
||||
|
||||
@@ -87,7 +87,7 @@ class ProtontricksCommandsMixin:
|
||||
env['WINETRICKS'] = str(winetricks_path)
|
||||
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
|
||||
else:
|
||||
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
cabextract_path = self._get_bundled_cabextract_path()
|
||||
if cabextract_path:
|
||||
cabextract_dir = str(cabextract_path.parent)
|
||||
@@ -95,7 +95,7 @@ class ProtontricksCommandsMixin:
|
||||
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
|
||||
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
|
||||
else:
|
||||
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
else:
|
||||
self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class ProtontricksPrefixMixin:
|
||||
self.logger.debug("ShowDotFiles already present in correct format in user.reg")
|
||||
dotfiles_set_success = True
|
||||
else:
|
||||
self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
|
||||
self.logger.info(f"user.reg not found at {user_reg_path}, creating it.")
|
||||
with open(user_reg_path, 'w', encoding='utf-8') as f:
|
||||
f.write('[Software\\\\Wine] 1603891765\n')
|
||||
f.write('"ShowDotFiles"="Y"\n')
|
||||
@@ -157,6 +157,10 @@ class ProtontricksPrefixMixin:
|
||||
self.logger.info("=" * 80)
|
||||
env = self._get_clean_subprocess_env()
|
||||
env["WINEDEBUG"] = "-all"
|
||||
# Preserve the desktop display variables for Step 4. The validated fix
|
||||
# for the blank taskbar popup regression was keeping DISPLAY available.
|
||||
# Do not strip extra desktop activation vars here without a reproduced,
|
||||
# evidence-backed need.
|
||||
|
||||
if self.which_protontricks == 'native':
|
||||
winetricks_path = self._get_bundled_winetricks_path()
|
||||
@@ -164,7 +168,7 @@ class ProtontricksPrefixMixin:
|
||||
env['WINETRICKS'] = str(winetricks_path)
|
||||
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
|
||||
else:
|
||||
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
cabextract_path = self._get_bundled_cabextract_path()
|
||||
if cabextract_path:
|
||||
cabextract_dir = str(cabextract_path.parent)
|
||||
@@ -172,7 +176,7 @@ class ProtontricksPrefixMixin:
|
||||
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
|
||||
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
|
||||
else:
|
||||
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
else:
|
||||
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class ProtontricksSteamMixin:
|
||||
self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
|
||||
|
||||
if steamdeck:
|
||||
self.logger.warning("Checking for SDCard and setting permissions appropriately...")
|
||||
self.logger.info("Checking for SDCard and setting permissions appropriately...")
|
||||
result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env)
|
||||
for line in result.stdout.splitlines():
|
||||
if "/run/media" in line:
|
||||
|
||||
@@ -104,12 +104,16 @@ class ShortcutCreationMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True)
|
||||
|
||||
dotnet_vars = 'DOTNET_ROOT="" DOTNET_MULTILEVEL_LOOKUP=0'
|
||||
|
||||
final_launch_options = launch_options
|
||||
if compat_mounts_str:
|
||||
if final_launch_options:
|
||||
final_launch_options = f"{compat_mounts_str} {final_launch_options}"
|
||||
else:
|
||||
final_launch_options = compat_mounts_str
|
||||
env_prefix_parts = [p for p in [compat_mounts_str, dotnet_vars] if p]
|
||||
if env_prefix_parts:
|
||||
prefix = " ".join(env_prefix_parts)
|
||||
if final_launch_options:
|
||||
final_launch_options = f"{prefix} {final_launch_options}"
|
||||
else:
|
||||
final_launch_options = prefix
|
||||
|
||||
if not final_launch_options.strip().endswith("%command%"):
|
||||
if final_launch_options:
|
||||
@@ -138,7 +142,6 @@ class ShortcutCreationMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating shortcut: {e}", exc_info=True)
|
||||
print(f"An error occurred while creating the shortcut: {e}")
|
||||
return False, None
|
||||
|
||||
def _is_steam_deck(self):
|
||||
|
||||
@@ -165,7 +165,7 @@ class ShortcutDiscoveryMixin:
|
||||
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
|
||||
return str(int(appid) & 0xFFFFFFFF)
|
||||
|
||||
self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
|
||||
self.logger.debug(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -300,7 +300,6 @@ class ShortcutVDFManagementMixin:
|
||||
try:
|
||||
shutil.copy2(safe_backup, shortcuts_file)
|
||||
self.logger.info(f"Restored shortcuts.vdf from pre-restart backup")
|
||||
print("Restored shortcuts file after Steam restart")
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to restore from pre-restart backup: {e}")
|
||||
@@ -310,9 +309,8 @@ class ShortcutVDFManagementMixin:
|
||||
try:
|
||||
shutil.copy2(backup, shortcuts_file)
|
||||
self.logger.info(f"Restored shortcuts.vdf from regular backup")
|
||||
print("Restored shortcuts file after Steam restart")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to restore from backup: {e}")
|
||||
print("Failed to restore shortcuts file. You may need to recreate your shortcut.")
|
||||
self.logger.warning("shortcuts.vdf restore failed - shortcut may need to be recreated")
|
||||
else:
|
||||
self.logger.info(f"shortcuts.vdf verified intact after restart")
|
||||
|
||||
@@ -6,6 +6,32 @@ import resource
|
||||
import sys
|
||||
import shutil
|
||||
import logging
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def suspend_baloo() -> bool:
|
||||
"""Suspend KDE Baloo file indexer. Safe to call on non-KDE or headless systems."""
|
||||
if not shutil.which("balooctl"):
|
||||
return False
|
||||
try:
|
||||
subprocess.run(["balooctl", "suspend"], capture_output=True, timeout=5)
|
||||
logger.debug("Baloo file indexer suspended")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def resume_baloo() -> None:
|
||||
"""Resume KDE Baloo file indexer. No-op if balooctl is not present."""
|
||||
if not shutil.which("balooctl"):
|
||||
return
|
||||
try:
|
||||
subprocess.run(["balooctl", "resume"], capture_output=True, timeout=5)
|
||||
logger.debug("Baloo file indexer resumed")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_safe_python_executable():
|
||||
"""
|
||||
@@ -154,7 +180,7 @@ class ProcessManager:
|
||||
"""
|
||||
Shared process manager for robust subprocess launching, tracking, and cancellation.
|
||||
"""
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False):
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False):
|
||||
self.cmd = cmd
|
||||
# Default to cleaned environment if None to prevent AppImage variable inheritance
|
||||
if env is None:
|
||||
@@ -165,14 +191,18 @@ class ProcessManager:
|
||||
self.text = text
|
||||
self.bufsize = bufsize
|
||||
self.separate_stderr = separate_stderr
|
||||
self.enable_stdin = enable_stdin
|
||||
self.proc = None
|
||||
self.process_group_pid = None
|
||||
self._stdin_lock = threading.Lock()
|
||||
self._start_process()
|
||||
|
||||
def _start_process(self):
|
||||
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
|
||||
stdin_arg = subprocess.PIPE if self.enable_stdin else None
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdin=stdin_arg,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=stderr_arg,
|
||||
env=self.env,
|
||||
@@ -190,31 +220,45 @@ class ProcessManager:
|
||||
cleanup_attempts = 0
|
||||
try:
|
||||
if self.proc:
|
||||
# Terminate process group first so child tools don't survive parent exit.
|
||||
if self.process_group_pid:
|
||||
try:
|
||||
os.killpg(self.process_group_pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_terminate)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.proc.kill()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_kill)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
self.proc.wait(timeout=timeout_terminate)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Kill entire process group (catches 7zz and other child processes)
|
||||
|
||||
# Escalate to SIGKILL for stubborn children/process group.
|
||||
if self.process_group_pid:
|
||||
try:
|
||||
os.killpg(self.process_group_pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
# Last resort: pkill by command name
|
||||
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_kill)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Last resort: pkill by command name (kept bounded).
|
||||
while cleanup_attempts < max_cleanup_attempts:
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
|
||||
@@ -222,9 +266,9 @@ class ProcessManager:
|
||||
pass
|
||||
cleanup_attempts += 1
|
||||
finally:
|
||||
# Always close pipes — unblocks threads blocked on read(1) or iterating stderr
|
||||
# Always close pipes - unblocks threads blocked on read(1) or iterating stderr
|
||||
if self.proc:
|
||||
for pipe in (self.proc.stdout, self.proc.stderr):
|
||||
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
|
||||
if pipe:
|
||||
try:
|
||||
pipe.close()
|
||||
@@ -250,4 +294,20 @@ class ProcessManager:
|
||||
return self.proc.stdout.read(1)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return None
|
||||
return None
|
||||
|
||||
def write_stdin(self, line: str) -> bool:
|
||||
"""
|
||||
Write a line to the process stdin. Thread-safe.
|
||||
Returns True on success, False if stdin is not available or process is gone.
|
||||
"""
|
||||
if not self.enable_stdin or not self.proc or not self.proc.stdin:
|
||||
return False
|
||||
with self._stdin_lock:
|
||||
try:
|
||||
payload = line if line.endswith('\n') else line + '\n'
|
||||
self.proc.stdin.write(payload.encode())
|
||||
self.proc.stdin.flush()
|
||||
return True
|
||||
except (OSError, BrokenPipeError):
|
||||
return False
|
||||
|
||||
@@ -64,21 +64,36 @@ class TTWInstallerBackendMixin:
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, universal_newlines=True
|
||||
)
|
||||
error_context: list = []
|
||||
capturing_explanation = False
|
||||
if process.stdout:
|
||||
for line in process.stdout:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
self.logger.info("TTW_Linux_Installer: %s", line)
|
||||
lower = line.lower()
|
||||
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
|
||||
error_context.append(line.strip())
|
||||
capturing_explanation = True
|
||||
elif capturing_explanation and line.startswith(' '):
|
||||
error_context.append(line.strip())
|
||||
else:
|
||||
capturing_explanation = False
|
||||
process.wait()
|
||||
ret = process.returncode
|
||||
if ret == 0:
|
||||
self.logger.info("TTW installation completed successfully.")
|
||||
return True, "TTW installation completed successfully!"
|
||||
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
|
||||
if error_context:
|
||||
return False, "TTW installation failed:\n" + "\n".join(error_context)
|
||||
return False, f"TTW installation failed with exit code {ret}"
|
||||
except Exception as e:
|
||||
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
|
||||
return False, f"Error executing TTW_Linux_Installer: {e}"
|
||||
finally:
|
||||
from jackify.shared.paths import cleanup_stale_tmp
|
||||
cleanup_stale_tmp()
|
||||
|
||||
def start_ttw_installation(self, ttw_mpi_path: Path, ttw_output_path: Path, output_file: Path):
|
||||
"""Start TTW installation process (non-blocking). Returns (process, error_message)."""
|
||||
@@ -156,6 +171,8 @@ class TTWInstallerBackendMixin:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
from jackify.shared.paths import cleanup_stale_tmp
|
||||
cleanup_stale_tmp()
|
||||
|
||||
def install_ttw_backend_with_output_stream(self, ttw_mpi_path: Path, ttw_output_path: Path, output_callback=None):
|
||||
"""Install TTW with streaming output (DEPRECATED - use start_ttw_installation instead)."""
|
||||
@@ -210,6 +227,8 @@ class TTWInstallerBackendMixin:
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, universal_newlines=True
|
||||
)
|
||||
error_context: list = []
|
||||
capturing_explanation = False
|
||||
if process.stdout:
|
||||
for line in process.stdout:
|
||||
line = line.rstrip()
|
||||
@@ -217,16 +236,29 @@ class TTWInstallerBackendMixin:
|
||||
self.logger.info("TTW_Linux_Installer: %s", line)
|
||||
if output_callback:
|
||||
output_callback(line)
|
||||
lower = line.lower()
|
||||
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
|
||||
error_context.append(line.strip())
|
||||
capturing_explanation = True
|
||||
elif capturing_explanation and line.startswith(' '):
|
||||
error_context.append(line.strip())
|
||||
else:
|
||||
capturing_explanation = False
|
||||
process.wait()
|
||||
ret = process.returncode
|
||||
if ret == 0:
|
||||
self.logger.info("TTW installation completed successfully.")
|
||||
return True, "TTW installation completed successfully!"
|
||||
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
|
||||
if error_context:
|
||||
return False, "TTW installation failed:\n" + "\n".join(error_context)
|
||||
return False, f"TTW installation failed with exit code {ret}"
|
||||
except Exception as e:
|
||||
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
|
||||
return False, f"Error executing TTW_Linux_Installer: {e}"
|
||||
finally:
|
||||
from jackify.shared.paths import cleanup_stale_tmp
|
||||
cleanup_stale_tmp()
|
||||
|
||||
@staticmethod
|
||||
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str, skip_copy: bool = False) -> bool:
|
||||
@@ -247,7 +279,7 @@ class TTWInstallerBackendMixin:
|
||||
mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands"
|
||||
target_mod_dir = mods_dir / mod_folder_name
|
||||
if skip_copy:
|
||||
# TTW was installed directly to target_mod_dir — no copy needed
|
||||
# TTW was installed directly to target_mod_dir - no copy needed
|
||||
logger.info("TTW already at target location, skipping copy: %s", target_mod_dir)
|
||||
else:
|
||||
logger.info("Copying TTW output to %s", target_mod_dir)
|
||||
|
||||
@@ -26,18 +26,21 @@ class WabbajackParser:
|
||||
'Fallout4': 'fallout4',
|
||||
'FalloutNewVegas': 'falloutnv',
|
||||
'Oblivion': 'oblivion',
|
||||
'Skyrim': 'skyrim', # Legacy Skyrim
|
||||
'Fallout3': 'fallout3', # For completeness
|
||||
'SkyrimVR': 'skyrim', # Treat as Skyrim
|
||||
'Fallout4VR': 'fallout4', # Treat as Fallout 4
|
||||
'Enderal': 'enderal', # Enderal: Forgotten Stories
|
||||
'EnderalSpecialEdition': 'enderal', # Enderal SE
|
||||
'Skyrim': 'skyrim',
|
||||
'Fallout3': 'fallout3',
|
||||
'SkyrimVR': 'skyrimvr',
|
||||
'Fallout4VR': 'fallout4vr',
|
||||
'Enderal': 'enderal',
|
||||
'EnderalSpecialEdition': 'enderal',
|
||||
'Cyberpunk2077': 'cp2077',
|
||||
'BaldursGate3': 'bg3',
|
||||
}
|
||||
|
||||
|
||||
# List of supported games in Jackify
|
||||
self.supported_games = [
|
||||
'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion',
|
||||
'starfield', 'oblivion_remastered', 'enderal'
|
||||
'starfield', 'oblivion_remastered', 'enderal',
|
||||
'skyrimvr', 'fallout4vr', 'bg3',
|
||||
]
|
||||
|
||||
def parse_wabbajack_game_type(self, wabbajack_path: Path) -> Optional[tuple]:
|
||||
@@ -98,6 +101,23 @@ class WabbajackParser:
|
||||
self.logger.error(f"Error parsing .wabbajack file {wabbajack_path}: {e}")
|
||||
return None
|
||||
|
||||
def parse_wabbajack_readme(self, wabbajack_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Extract the readme URL from a .wabbajack file.
|
||||
|
||||
Returns the URL string, or None if not present or unreadable.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(wabbajack_path, 'r') as zip_file:
|
||||
modlist_files = [f for f in zip_file.namelist() if f in ['modlist', 'modlist.json']]
|
||||
if not modlist_files:
|
||||
return None
|
||||
with zip_file.open(modlist_files[0]) as f:
|
||||
data = json.load(f)
|
||||
return data.get('Readme') or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_supported_game(self, game_type: str) -> bool:
|
||||
"""
|
||||
Check if a game type is supported by Jackify's post-install configuration.
|
||||
@@ -128,12 +148,16 @@ class WabbajackParser:
|
||||
"""
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim Special Edition',
|
||||
'fallout4': 'Fallout 4',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
'enderal': 'Enderal',
|
||||
'skyrimvr': 'Skyrim VR',
|
||||
'fallout4vr': 'Fallout 4 VR',
|
||||
'cp2077': 'Cyberpunk 2077',
|
||||
'bg3': "Baldur's Gate 3",
|
||||
}
|
||||
return [display_names.get(game, game) for game in self.supported_games]
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ Extracted from wine_utils for file-size and domain separation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import Optional
|
||||
@@ -56,39 +55,6 @@ class WineUtilsConfigMixin:
|
||||
logger.error(f"Error performing additional tasks: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def modlist_specific_steps(modlist: str, appid: str) -> bool:
|
||||
"""Perform modlist-specific configuration steps. Returns True on success."""
|
||||
try:
|
||||
modlist_configs = {
|
||||
"wildlander": ["dotnet48", "dotnet472", "vcrun2019"],
|
||||
"septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"],
|
||||
"masterstroke": ["dotnet48", "dotnet472"],
|
||||
"diablo": ["dotnet48", "dotnet472"],
|
||||
"living_skyrim": ["dotnet48", "dotnet472", "dotnet462"],
|
||||
"nolvus": ["dotnet8"]
|
||||
}
|
||||
modlist_lower = modlist.lower().replace(" ", "")
|
||||
if "wildlander" in modlist_lower:
|
||||
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
|
||||
return True
|
||||
for pattern, components in modlist_configs.items():
|
||||
if re.search(pattern.replace("|", "|.*"), modlist_lower):
|
||||
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
|
||||
for component in components:
|
||||
if component == "dotnet8":
|
||||
logger.info("Downloading .NET 8 Runtime")
|
||||
pass
|
||||
else:
|
||||
logger.info(f"Installing {component}...")
|
||||
pass
|
||||
return True
|
||||
logger.debug(f"No specific steps needed for {modlist}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error performing modlist-specific steps: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool:
|
||||
"""Set up Fallout New Vegas launch options. Returns True on success."""
|
||||
|
||||
@@ -136,7 +136,7 @@ class WineUtilsProtonMixin:
|
||||
if fallback_path != 'auto':
|
||||
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
|
||||
if fallback_wine_bin.is_file():
|
||||
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
|
||||
logger.info(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
|
||||
return str(fallback_wine_bin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -36,7 +36,6 @@ def _get_clean_winetricks_base_env() -> dict:
|
||||
env["PATH"] = path or "/usr/bin:/bin"
|
||||
return env
|
||||
|
||||
|
||||
class WinetricksEnvMixin:
|
||||
"""Mixin providing env build and dependency check for WinetricksHandler.install_wine_components."""
|
||||
|
||||
@@ -54,10 +53,11 @@ class WinetricksEnvMixin:
|
||||
env['WINEDEBUG'] = '-all'
|
||||
env['WINEPREFIX'] = wineprefix
|
||||
env['WINETRICKS_GUI'] = 'none'
|
||||
if 'DISPLAY' in env:
|
||||
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
|
||||
else:
|
||||
env['DISPLAY'] = env.get('DISPLAY', '')
|
||||
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
|
||||
# Preserve the desktop display variables for Step 4. The validated fix
|
||||
# for the blank taskbar popup regression was keeping DISPLAY available.
|
||||
# Do not strip extra desktop activation vars here without a reproduced,
|
||||
# evidence-backed need.
|
||||
|
||||
try:
|
||||
from ..handlers.config_handler import ConfigHandler
|
||||
@@ -243,7 +243,10 @@ class WinetricksEnvMixin:
|
||||
if not found:
|
||||
missing_deps.append(dep_name)
|
||||
if dep_name in bundled_tools_list:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
|
||||
if dep_name == 'aria2c':
|
||||
self.logger.debug(f" {dep_name}: NOT FOUND (optional - curl/wget used if available)")
|
||||
else:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
|
||||
else:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)")
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ class ModlistContext:
|
||||
"""Context object for modlist operations."""
|
||||
name: str
|
||||
install_dir: Path
|
||||
download_dir: Path
|
||||
game_type: str
|
||||
nexus_api_key: str
|
||||
download_dir: Optional[Path] = None
|
||||
modlist_value: Optional[str] = None
|
||||
modlist_source: Optional[str] = None # 'identifier' or 'file'
|
||||
resolution: Optional[str] = None
|
||||
@@ -29,8 +29,8 @@ class ModlistContext:
|
||||
"""Convert string paths to Path objects."""
|
||||
if isinstance(self.install_dir, str):
|
||||
self.install_dir = Path(self.install_dir)
|
||||
if isinstance(self.download_dir, str):
|
||||
self.download_dir = Path(self.download_dir)
|
||||
if self.download_dir is not None and isinstance(self.download_dir, str):
|
||||
self.download_dir = Path(self.download_dir) if self.download_dir else None
|
||||
if isinstance(self.mo2_exe_path, str):
|
||||
self.mo2_exe_path = Path(self.mo2_exe_path)
|
||||
|
||||
|
||||
@@ -5,12 +5,56 @@ import logging
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PrefixCreationMixin:
|
||||
"""Mixin providing prefix creation methods for AutomatedPrefixService."""
|
||||
|
||||
def _get_preferred_steam_root_and_type(self) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve the active Steam root/type using the shared v0.5 selector."""
|
||||
from jackify.shared.steam_utils import (
|
||||
STEAM_PREFERENCE_AUTO,
|
||||
resolve_preferred_steam_installation,
|
||||
)
|
||||
from ..handlers.config_handler import ConfigHandler
|
||||
|
||||
preference = STEAM_PREFERENCE_AUTO
|
||||
try:
|
||||
preference = ConfigHandler().get("steam_install_preference", STEAM_PREFERENCE_AUTO)
|
||||
except Exception:
|
||||
logger.debug("Could not read steam_install_preference; falling back to auto", exc_info=True)
|
||||
|
||||
preferred_type, preferred_root = resolve_preferred_steam_installation(preference)
|
||||
return preferred_root, preferred_type
|
||||
|
||||
def _get_library_roots_for_steam_root(self, steam_root: Path) -> list[Path]:
|
||||
"""
|
||||
Read library roots for one chosen Steam install only.
|
||||
|
||||
This avoids mixing native and Flatpak libraries in dual-install environments.
|
||||
"""
|
||||
roots: list[Path] = [steam_root]
|
||||
vdf_path = steam_root / "config" / "libraryfolders.vdf"
|
||||
if not vdf_path.is_file():
|
||||
return roots
|
||||
|
||||
try:
|
||||
text = vdf_path.read_text(encoding="utf-8", errors="ignore")
|
||||
for match in re.finditer(r'"path"\s*"([^"]+)"', text):
|
||||
raw_path = match.group(1).replace("\\\\", "\\")
|
||||
lib_root = Path(raw_path).expanduser()
|
||||
try:
|
||||
resolved = lib_root.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
resolved = lib_root
|
||||
if resolved not in roots:
|
||||
roots.append(resolved)
|
||||
except Exception:
|
||||
logger.debug("Failed reading libraryfolders.vdf for %s", steam_root, exc_info=True)
|
||||
return roots
|
||||
|
||||
def _get_compatdata_path_for_appid(self, appid: int) -> Optional[Path]:
|
||||
"""
|
||||
Get the compatdata path for a given AppID.
|
||||
@@ -31,13 +75,11 @@ class PrefixCreationMixin:
|
||||
if compatdata_path:
|
||||
return compatdata_path
|
||||
|
||||
# Prefix doesn't exist yet - determine where to create it from libraryfolders.vdf
|
||||
library_paths = PathHandler.get_all_steam_library_paths()
|
||||
if library_paths:
|
||||
# Use the first library (typically the default library)
|
||||
# Construct compatdata path: library_path/steamapps/compatdata/appid
|
||||
first_library = library_paths[0]
|
||||
compatdata_base = first_library / "steamapps" / "compatdata"
|
||||
# Prefix doesn't exist yet - derive it from the selected active Steam root,
|
||||
# not from a mixed native/Flatpak library list.
|
||||
preferred_root, _preferred_type = self._get_preferred_steam_root_and_type()
|
||||
if preferred_root:
|
||||
compatdata_base = preferred_root / "steamapps" / "compatdata"
|
||||
return compatdata_base / str(appid)
|
||||
|
||||
# Only fallback if VDF parsing completely fails
|
||||
@@ -156,36 +198,24 @@ class PrefixCreationMixin:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Determine Steam locations based on installation type
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
all_libraries = path_handler.get_all_steam_library_paths()
|
||||
|
||||
# Check if we have Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths
|
||||
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
|
||||
|
||||
if is_flatpak_steam and all_libraries:
|
||||
# Flatpak Steam: Use the actual library root from libraryfolders.vdf
|
||||
# Compatdata should be in the library root, not the client root
|
||||
flatpak_library_root = all_libraries[0] # Use first library (typically the default)
|
||||
flatpak_client_root = flatpak_library_root.parent.parent / ".steam/steam"
|
||||
|
||||
if not flatpak_library_root.is_dir():
|
||||
logger.error(
|
||||
f"Flatpak Steam library root does not exist: {flatpak_library_root}"
|
||||
)
|
||||
return False
|
||||
|
||||
steam_root = flatpak_client_root if flatpak_client_root.is_dir() else flatpak_library_root
|
||||
# CRITICAL: compatdata must be in the library root, not client root
|
||||
compatdata_dir = flatpak_library_root / "steamapps/compatdata"
|
||||
proton_common_dir = flatpak_library_root / "steamapps/common"
|
||||
else:
|
||||
# Native Steam (or unknown): fall back to legacy ~/.steam/steam layout
|
||||
steam_root = Path.home() / ".steam/steam"
|
||||
compatdata_dir = steam_root / "steamapps/compatdata"
|
||||
proton_common_dir = steam_root / "steamapps/common"
|
||||
|
||||
# Determine Steam locations from the selected active Steam install only.
|
||||
steam_root, steam_type = self._get_preferred_steam_root_and_type()
|
||||
if not steam_root:
|
||||
logger.error("Could not determine active Steam root for prefix creation")
|
||||
return False
|
||||
|
||||
if not steam_root.is_dir():
|
||||
logger.error("Preferred Steam root does not exist: %s", steam_root)
|
||||
return False
|
||||
|
||||
compatdata_dir = steam_root / "steamapps" / "compatdata"
|
||||
proton_common_dir = steam_root / "steamapps" / "common"
|
||||
logger.info(
|
||||
"Prefix creation using preferred Steam install: type=%s root=%s",
|
||||
steam_type or "unknown",
|
||||
steam_root,
|
||||
)
|
||||
|
||||
# Ensure compatdata root exists and is a directory we actually want to use
|
||||
if not compatdata_dir.is_dir():
|
||||
logger.error(f"Compatdata root does not exist: {compatdata_dir}. Aborting prefix creation.")
|
||||
@@ -256,4 +286,3 @@ class PrefixCreationMixin:
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating prefix: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -19,66 +19,68 @@ logger = logging.getLogger(__name__)
|
||||
class GameUtilsMixin:
|
||||
"""Mixin for game-related utility operations"""
|
||||
|
||||
def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Generate launch options for FNV/Enderal games that require vanilla compatdata.
|
||||
|
||||
Args:
|
||||
special_game_type: "fnv" or "enderal"
|
||||
modlist_install_dir: Directory where the modlist is installed
|
||||
|
||||
Returns:
|
||||
Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
|
||||
"""
|
||||
if not special_game_type or special_game_type not in ["fnv", "enderal"]:
|
||||
return None
|
||||
|
||||
logger.info(f"Generating {special_game_type.upper()} launch options")
|
||||
|
||||
# Map game types to AppIDs
|
||||
appid_map = {"fnv": "22380", "enderal": "976620"}
|
||||
appid = appid_map[special_game_type]
|
||||
|
||||
# Find vanilla game compatdata
|
||||
from ..handlers.path_handler import PathHandler
|
||||
compatdata_path = PathHandler.find_compat_data(appid)
|
||||
if not compatdata_path:
|
||||
logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
|
||||
return None
|
||||
|
||||
# Create STEAM_COMPAT_DATA_PATH string
|
||||
compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
|
||||
|
||||
# Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
|
||||
compat_mounts_str = ""
|
||||
try:
|
||||
all_libs = PathHandler.get_all_steam_library_paths()
|
||||
main_steam_lib_path_obj = PathHandler.find_steam_library()
|
||||
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
|
||||
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
|
||||
else:
|
||||
main_steam_lib_path = main_steam_lib_path_obj
|
||||
|
||||
mount_paths = []
|
||||
if main_steam_lib_path:
|
||||
main_resolved = main_steam_lib_path.resolve()
|
||||
for lib_path in all_libs:
|
||||
if lib_path.resolve() != main_resolved:
|
||||
mount_paths.append(str(lib_path.resolve()))
|
||||
|
||||
if mount_paths:
|
||||
mount_paths_str = ':'.join(mount_paths)
|
||||
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
|
||||
logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
|
||||
|
||||
# Combine all launch options
|
||||
launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
|
||||
launch_options = ' '.join(launch_options.split()) # Clean up spacing
|
||||
|
||||
logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
|
||||
return launch_options
|
||||
# TODO post-0.6: remove this method - dead code, never called.
|
||||
# Superseded by registry injection (game paths written directly into the modlist prefix).
|
||||
# def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
|
||||
# """
|
||||
# Generate launch options for FNV/Enderal games that require vanilla compatdata.
|
||||
#
|
||||
# Args:
|
||||
# special_game_type: "fnv" or "enderal"
|
||||
# modlist_install_dir: Directory where the modlist is installed
|
||||
#
|
||||
# Returns:
|
||||
# Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
|
||||
# """
|
||||
# if not special_game_type or special_game_type not in ["fnv", "enderal"]:
|
||||
# return None
|
||||
#
|
||||
# logger.info(f"Generating {special_game_type.upper()} launch options")
|
||||
#
|
||||
# # Map game types to AppIDs
|
||||
# appid_map = {"fnv": "22380", "enderal": "976620"}
|
||||
# appid = appid_map[special_game_type]
|
||||
#
|
||||
# # Find vanilla game compatdata
|
||||
# from ..handlers.path_handler import PathHandler
|
||||
# compatdata_path = PathHandler.find_compat_data(appid)
|
||||
# if not compatdata_path:
|
||||
# logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
|
||||
# return None
|
||||
#
|
||||
# # Create STEAM_COMPAT_DATA_PATH string
|
||||
# compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
|
||||
#
|
||||
# # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
|
||||
# compat_mounts_str = ""
|
||||
# try:
|
||||
# all_libs = PathHandler.get_all_steam_library_paths()
|
||||
# main_steam_lib_path_obj = PathHandler.find_steam_library()
|
||||
# if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
|
||||
# main_steam_lib_path = main_steam_lib_path_obj.parent.parent
|
||||
# else:
|
||||
# main_steam_lib_path = main_steam_lib_path_obj
|
||||
#
|
||||
# mount_paths = []
|
||||
# if main_steam_lib_path:
|
||||
# main_resolved = main_steam_lib_path.resolve()
|
||||
# for lib_path in all_libs:
|
||||
# if lib_path.resolve() != main_resolved:
|
||||
# mount_paths.append(str(lib_path.resolve()))
|
||||
#
|
||||
# if mount_paths:
|
||||
# mount_paths_str = ':'.join(mount_paths)
|
||||
# compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
|
||||
# logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
|
||||
# except Exception as e:
|
||||
# logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
|
||||
#
|
||||
# # Combine all launch options
|
||||
# launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
|
||||
# launch_options = ' '.join(launch_options.split()) # Clean up spacing
|
||||
#
|
||||
# logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
|
||||
# return launch_options
|
||||
|
||||
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
|
||||
"""Find a Steam game installation path by AppID and common names"""
|
||||
@@ -140,36 +142,90 @@ class GameUtilsMixin:
|
||||
|
||||
return None
|
||||
|
||||
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str):
|
||||
def _detect_skyrim_se_modlist(self, modlist_dir: str) -> bool:
|
||||
"""
|
||||
Return True if modlist_dir is a Skyrim SE (non-VR) modlist.
|
||||
|
||||
Used only to trigger first-launch seeding when special_game_type is None.
|
||||
Other games are not yet confirmed to need this treatment.
|
||||
"""
|
||||
if not modlist_dir:
|
||||
return False
|
||||
try:
|
||||
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
mo2_ini = Path(modlist_dir) / "files" / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return False
|
||||
content = mo2_ini.read_text(errors='ignore').lower()
|
||||
# Anchor VR check to gameName= to avoid false positives from plugin
|
||||
# setting keys like enable_skyrimVR=false appearing in SE modlists.
|
||||
for _line in content.splitlines():
|
||||
if _line.strip().startswith("gamename="):
|
||||
game_name_value = _line.strip()[len("gamename="):]
|
||||
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
|
||||
return False
|
||||
break
|
||||
return 'skyrim special edition' in content or 'skse64_loader' in content
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check Skyrim SE detection for {modlist_dir}: {e}")
|
||||
return False
|
||||
|
||||
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str,
|
||||
modlist_dir: Optional[str] = None):
|
||||
"""
|
||||
Pre-create game-specific user directories to prevent first-launch issues.
|
||||
|
||||
Creates both My Documents/My Games and AppData/Local directories for the game.
|
||||
This prevents issues where games fail to create these on first launch under Proton.
|
||||
special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games). For standard
|
||||
games like Skyrim SE that aren't "special" in that sense, modlist_dir is used to
|
||||
detect what directories to seed.
|
||||
"""
|
||||
# Map game types to their directory names
|
||||
# Bethesda-pattern games: same name used for both My Games and AppData/Local
|
||||
game_dir_names = {
|
||||
"skyrim": "Skyrim Special Edition",
|
||||
"skyrimvr": "Skyrim VR",
|
||||
"fnv": "FalloutNV",
|
||||
"fo3": "Fallout3",
|
||||
"fo4": "Fallout4",
|
||||
"fallout4vr": "Fallout4VR",
|
||||
"oblivion": "Oblivion",
|
||||
"oblivion_remastered": "Oblivion Remastered",
|
||||
"enderal": "Enderal Special Edition",
|
||||
"starfield": "Starfield"
|
||||
"starfield": "Starfield",
|
||||
}
|
||||
|
||||
# Get the directory name for this game type
|
||||
game_dir_name = game_dir_names.get(special_game_type)
|
||||
if not game_dir_name:
|
||||
logger.debug(f"No user directory mapping for game type: {special_game_type}")
|
||||
return
|
||||
# Non-Bethesda games: AppData/Local only, with a vendor-namespaced subdirectory
|
||||
game_appdata_only = {
|
||||
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
|
||||
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
|
||||
}
|
||||
|
||||
# special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games).
|
||||
# Skyrim SE returns None from detect_special_game_type but still needs seeding.
|
||||
game_type = special_game_type
|
||||
if special_game_type is None and modlist_dir and self._detect_skyrim_se_modlist(modlist_dir):
|
||||
game_type = "skyrim"
|
||||
|
||||
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
|
||||
|
||||
if game_type in game_appdata_only:
|
||||
appdata_dir = os.path.join(base_path, "AppData", "Local", game_appdata_only[game_type])
|
||||
try:
|
||||
os.makedirs(appdata_dir, exist_ok=True)
|
||||
logger.info(f"Created AppData/Local directory: {appdata_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create AppData/Local directory {appdata_dir}: {e}")
|
||||
return
|
||||
|
||||
game_dir_name = game_dir_names.get(game_type)
|
||||
if not game_dir_name:
|
||||
logger.debug(f"No user directory mapping for game type: {game_type}")
|
||||
return
|
||||
|
||||
directories_to_create = [
|
||||
os.path.join(base_path, "Documents", "My Games", game_dir_name),
|
||||
os.path.join(base_path, "AppData", "Local", game_dir_name)
|
||||
os.path.join(base_path, "AppData", "Local", game_dir_name),
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
@@ -184,90 +240,46 @@ class GameUtilsMixin:
|
||||
if created_count > 0:
|
||||
logger.info(f"Created {created_count} user directories for {game_dir_name}")
|
||||
|
||||
def _get_lorerim_preferred_proton(self):
|
||||
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
|
||||
if game_type == "skyrim":
|
||||
self._seed_skyrim_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "fo4":
|
||||
self._seed_fo4_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "skyrimvr":
|
||||
self._seed_skyrimvr_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "fallout4vr":
|
||||
self._seed_fallout4vr_first_launch_files(base_path, game_dir_name)
|
||||
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Skyrim first-launch fix files."""
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Get all available Proton versions
|
||||
available_versions = WineUtils.scan_all_proton_versions()
|
||||
|
||||
if not available_versions:
|
||||
logger.warning("No Proton versions found for Lorerim override")
|
||||
return None
|
||||
|
||||
# Priority order for Lorerim:
|
||||
# 1. GEProton9-27 (specific version)
|
||||
# 2. Other GEProton-9 versions (latest first)
|
||||
# 3. Valve Proton 9 (any version)
|
||||
|
||||
preferred_candidates = []
|
||||
|
||||
for version in available_versions:
|
||||
version_name = version['name']
|
||||
|
||||
# Priority 1: GEProton9-27 specifically
|
||||
if version_name == 'GE-Proton9-27':
|
||||
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
|
||||
return version_name
|
||||
|
||||
# Priority 2: Other GE-Proton 9 versions
|
||||
elif version_name.startswith('GE-Proton9-'):
|
||||
preferred_candidates.append(('ge_proton_9', version_name, version))
|
||||
|
||||
# Priority 3: Valve Proton 9
|
||||
elif 'Proton 9' in version_name:
|
||||
preferred_candidates.append(('valve_proton_9', version_name, version))
|
||||
|
||||
# Return best candidate if any found
|
||||
if preferred_candidates:
|
||||
# Sort by priority (GE-Proton first, then by name for latest)
|
||||
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
best_candidate = preferred_candidates[0]
|
||||
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
|
||||
return best_candidate[1]
|
||||
|
||||
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
|
||||
return None
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error detecting Lorerim Proton preference: {e}")
|
||||
return None
|
||||
logger.warning(f"Could not seed Skyrim first-launch files: {e}")
|
||||
|
||||
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
|
||||
"""Store Proton override information for end-of-install notification"""
|
||||
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Fallout 4 first-launch fix files."""
|
||||
try:
|
||||
# Store override info for later display
|
||||
if not hasattr(self, '_proton_overrides'):
|
||||
self._proton_overrides = []
|
||||
|
||||
self._proton_overrides.append({
|
||||
'modlist': modlist_name,
|
||||
'proton_version': proton_version,
|
||||
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
|
||||
})
|
||||
|
||||
logger.debug(f"Stored Proton override notification: {modlist_name} → {proton_version}")
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store Proton override notification: {e}")
|
||||
logger.warning(f"Could not seed FO4 first-launch files: {e}")
|
||||
|
||||
def _show_proton_override_notification(self, progress_callback=None):
|
||||
"""Display any Proton override notifications to the user"""
|
||||
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Skyrim VR first-launch fix files."""
|
||||
try:
|
||||
if hasattr(self, '_proton_overrides') and self._proton_overrides:
|
||||
for override in self._proton_overrides:
|
||||
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("")
|
||||
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
|
||||
|
||||
logger.info(notification_msg)
|
||||
|
||||
# Clear notifications after display
|
||||
self._proton_overrides = []
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to show Proton override notification: {e}")
|
||||
logger.warning(f"Could not seed SkyrimVR first-launch files: {e}")
|
||||
|
||||
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Fallout 4 VR first-launch fix files."""
|
||||
try:
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not seed FO4VR first-launch files: {e}")
|
||||
|
||||
@@ -20,23 +20,6 @@ class ProtonOperationsMixin:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Check for Lorerim-specific Proton override first
|
||||
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
||||
if modlist_normalized == 'lorerim':
|
||||
lorerim_proton = self._get_lorerim_preferred_proton()
|
||||
if lorerim_proton:
|
||||
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
|
||||
self._store_proton_override_notification("Lorerim", lorerim_proton)
|
||||
return lorerim_proton
|
||||
|
||||
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
|
||||
if modlist_normalized == 'lostlegacy':
|
||||
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
|
||||
if lostlegacy_proton:
|
||||
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
|
||||
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
|
||||
return lostlegacy_proton
|
||||
|
||||
config_handler = ConfigHandler()
|
||||
user_proton_path = config_handler.get_game_proton_path()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Registry operations mixin for AutomatedPrefixService."""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -74,7 +75,7 @@ class RegistryOperationsMixin:
|
||||
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
|
||||
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
|
||||
|
||||
Direct file editing is preferred over `wine reg add` — faster, no Wine
|
||||
Direct file editing is preferred over `wine reg add` - faster, no Wine
|
||||
process overhead, and works even when Proton isn't on PATH. Falls back
|
||||
to subprocess wine reg add when the reg files haven't been created yet.
|
||||
"""
|
||||
@@ -91,10 +92,12 @@ class RegistryOperationsMixin:
|
||||
|
||||
fix1 = fix2 = False
|
||||
|
||||
# Targeted per-exe override for SkyrimSE.exe only - see modlist_wine_ops.py
|
||||
# for rationale. Global DllOverrides entry breaks .NET 9/10 bootstrap.
|
||||
if os.path.exists(user_reg):
|
||||
fix1 = self._reg_set_value(
|
||||
user_reg,
|
||||
"[Software\\\\Wine\\\\DllOverrides]",
|
||||
"[Software\\\\Wine\\\\AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides]",
|
||||
'"*mscoree"',
|
||||
'"native"',
|
||||
)
|
||||
@@ -123,7 +126,7 @@ class RegistryOperationsMixin:
|
||||
|
||||
r1 = subprocess.run(
|
||||
[wine_binary, 'reg', 'add',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
|
||||
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'],
|
||||
env=env, capture_output=True, text=True, errors='replace',
|
||||
)
|
||||
@@ -145,6 +148,53 @@ class RegistryOperationsMixin:
|
||||
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
|
||||
return False
|
||||
|
||||
def _apply_cp2077_dll_overrides(self, modlist_compatdata_path: str) -> bool:
|
||||
"""Write CP2077 DLL overrides directly into the prefix user.reg.
|
||||
|
||||
MO2 on Linux launches each executable through a separate Proton invocation,
|
||||
so WINEDLLOVERRIDES set in Steam launch options is not inherited by the game
|
||||
process. Writing the overrides into user.reg ensures they are always applied
|
||||
regardless of how the process is started.
|
||||
|
||||
version and winmm are the entry-point DLLs for CET and Red4ext respectively.
|
||||
Without native,builtin for both, neither mod framework can inject into the
|
||||
game process and CP2077 exits immediately.
|
||||
"""
|
||||
try:
|
||||
user_reg = os.path.join(modlist_compatdata_path, "pfx", "user.reg")
|
||||
if not os.path.exists(user_reg):
|
||||
logger.warning("user.reg not found, cannot apply CP2077 DLL overrides")
|
||||
return False
|
||||
|
||||
section = "[Software\\\\Wine\\\\DllOverrides]"
|
||||
overrides = [
|
||||
('"version"', '"native,builtin"'),
|
||||
('"winmm"', '"native,builtin"'),
|
||||
]
|
||||
for key, val in overrides:
|
||||
self._reg_set_value(user_reg, section, key, val)
|
||||
|
||||
logger.info("Applied CP2077 DLL overrides (version, winmm) to prefix registry")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply CP2077 DLL overrides: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _wow64_counterpart(section: str) -> str:
|
||||
"""Return the Wow6432Node counterpart for a registry section, or vice versa.
|
||||
|
||||
NaK writes both paths for every game so both 32-bit and 64-bit lookups
|
||||
resolve correctly regardless of the calling process's bitness.
|
||||
"""
|
||||
low = section.lower()
|
||||
if "wow6432node" in low:
|
||||
# Strip Wow6432Node to get the 64-bit path
|
||||
return re.sub(r'(?i)wow6432node\\\\', '', section)
|
||||
else:
|
||||
# Insert Wow6432Node after the opening [Software\\
|
||||
return re.sub(r'(?i)(\[Software\\\\)', r'\1Wow6432Node\\\\', section)
|
||||
|
||||
def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool:
|
||||
"""Set or add a key=value pair in a Wine .reg text file."""
|
||||
try:
|
||||
@@ -319,19 +369,19 @@ class RegistryOperationsMixin:
|
||||
"name": "Fallout New Vegas",
|
||||
"common_names": ["Fallout New Vegas", "FalloutNV"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"22300": { # Fallout 3 AppID
|
||||
"name": "Fallout 3",
|
||||
"common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"22370": { # Fallout 3 GOTY AppID alias
|
||||
"name": "Fallout 3",
|
||||
"common_names": ["Fallout 3 GOTY", "Fallout 3"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"976620": { # Enderal Special Edition AppID
|
||||
"name": "Enderal",
|
||||
@@ -339,6 +389,72 @@ class RegistryOperationsMixin:
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"1091500": { # Cyberpunk 2077 AppID
|
||||
"name": "Cyberpunk 2077",
|
||||
"common_names": ["Cyberpunk 2077"],
|
||||
"registry_section": "[Software\\\\CD Projekt Red\\\\Cyberpunk 2077]",
|
||||
"path_key": "InstallFolder",
|
||||
},
|
||||
"1086940": { # Baldur's Gate 3 AppID
|
||||
"name": "Baldur's Gate 3",
|
||||
"common_names": ["Baldur's Gate 3", "BaldursGate3"],
|
||||
"registry_section": "[Software\\\\Larian Studios\\\\Baldur's Gate 3]",
|
||||
"path_key": "InstallDir",
|
||||
},
|
||||
"611670": { # Skyrim VR AppID (64-bit, no Wow6432Node)
|
||||
"name": "Skyrim VR",
|
||||
"common_names": ["Skyrim VR", "SkyrimVR"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim VR]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"611660": { # Fallout 4 VR AppID (64-bit, no Wow6432Node)
|
||||
"name": "Fallout 4 VR",
|
||||
"common_names": ["Fallout 4 VR", "Fallout4VR"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout 4 VR]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"22330": { # Oblivion AppID
|
||||
"name": "Oblivion",
|
||||
"common_names": ["Oblivion", "Elder Scrolls IV Oblivion"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\oblivion]",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"1716740": { # Starfield AppID (64-bit, no Wow6432Node)
|
||||
"name": "Starfield",
|
||||
"common_names": ["Starfield"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Starfield]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"489830": { # Skyrim Special Edition AppID (64-bit, no Wow6432Node)
|
||||
"name": "Skyrim Special Edition",
|
||||
"common_names": ["Skyrim Special Edition", "SkyrimSE", "Skyrim Anniversary Edition"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim Special Edition]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"377160": { # Fallout 4 AppID (64-bit, no Wow6432Node)
|
||||
"name": "Fallout 4",
|
||||
"common_names": ["Fallout 4", "Fallout4"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout4]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"22320": { # Morrowind AppID (32-bit, Wow6432Node)
|
||||
"name": "Morrowind",
|
||||
"common_names": ["Morrowind", "Elder Scrolls III Morrowind"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\morrowind]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"292030": { # The Witcher 3 AppID (64-bit, no Wow6432Node)
|
||||
"name": "The Witcher 3",
|
||||
"common_names": ["The Witcher 3", "Witcher 3", "The Witcher 3 Wild Hunt"],
|
||||
"registry_section": "[Software\\\\CD Projekt Red\\\\The Witcher 3]",
|
||||
"path_key": "InstallFolder",
|
||||
},
|
||||
"2623190": { # Oblivion Remastered AppID (64-bit UE5, no Wow6432Node)
|
||||
"name": "Oblivion Remastered",
|
||||
"common_names": ["Oblivion Remastered", "OblivionRemastered"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Oblivion Remastered]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
}
|
||||
|
||||
pfx_path = Path(modlist_compatdata_path) / "pfx"
|
||||
@@ -359,24 +475,22 @@ class RegistryOperationsMixin:
|
||||
game_dir_name = Path(game_path).name
|
||||
canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}"
|
||||
wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\"
|
||||
success = self._reg_set_value(
|
||||
system_reg_path,
|
||||
config["registry_section"],
|
||||
f'"{config["path_key"]}"',
|
||||
f'"{wine_val}"',
|
||||
)
|
||||
key = f'"{config["path_key"]}"'
|
||||
val = f'"{wine_val}"'
|
||||
success = self._reg_set_value(system_reg_path, config["registry_section"], key, val)
|
||||
self._reg_set_value(system_reg_path, self._wow64_counterpart(config["registry_section"]), key, val)
|
||||
if success:
|
||||
logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}")
|
||||
else:
|
||||
logger.warning(f"Failed to set canonical registry path for {config['name']}")
|
||||
else:
|
||||
# Symlink failed — fall back to writing the real Z:/D: path
|
||||
# Symlink failed - fall back to writing the real Z:/D: path
|
||||
logger.warning(f"Symlink failed for {config['name']}, writing real path to registry")
|
||||
success = self._update_registry_path(
|
||||
system_reg_path,
|
||||
config["registry_section"],
|
||||
config["path_key"],
|
||||
game_path
|
||||
system_reg_path, config["registry_section"], config["path_key"], game_path
|
||||
)
|
||||
self._update_registry_path(
|
||||
system_reg_path, self._wow64_counterpart(config["registry_section"]), config["path_key"], game_path
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Updated registry entry for {config['name']} (real path fallback)")
|
||||
|
||||
@@ -269,10 +269,8 @@ exit"""
|
||||
def get_ttw_installer_path() -> Optional[Path]:
|
||||
"""Get path to TTW_Linux_Installer if available"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
ttw_path = get_jackify_data_dir() / "TTW_Linux_Installer" / "ttw_linux_gui"
|
||||
if ttw_path.exists():
|
||||
return ttw_path
|
||||
from .ttw_installer_service import get_ttw_installer_path
|
||||
return get_ttw_installer_path()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -375,7 +373,7 @@ exit"""
|
||||
def get_prefix_path(self, appid: int) -> Optional[Path]:
|
||||
"""
|
||||
Get the path to the Proton prefix for the given AppID.
|
||||
Uses same Flatpak detection as create_prefix_with_proton_wrapper.
|
||||
Uses the same preferred Steam install selection as create_prefix_with_proton_wrapper.
|
||||
|
||||
Args:
|
||||
appid: The AppID (unsigned, positive number)
|
||||
@@ -383,20 +381,11 @@ exit"""
|
||||
Returns:
|
||||
Path to the prefix directory, or None if not found
|
||||
"""
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
all_libraries = path_handler.get_all_steam_library_paths()
|
||||
steam_root, _steam_type = self._get_preferred_steam_root_and_type()
|
||||
if not steam_root:
|
||||
return None
|
||||
|
||||
# Check if Flatpak Steam
|
||||
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
|
||||
|
||||
if is_flatpak_steam and all_libraries:
|
||||
# Flatpak Steam: use first library root
|
||||
library_root = all_libraries[0]
|
||||
compatdata_dir = library_root / "steamapps/compatdata"
|
||||
else:
|
||||
# Native Steam
|
||||
compatdata_dir = Path.home() / ".steam/steam/steamapps/compatdata"
|
||||
compatdata_dir = steam_root / "steamapps" / "compatdata"
|
||||
|
||||
# Ensure we use the absolute value (unsigned AppID)
|
||||
prefix_dir = compatdata_dir / str(abs(appid))
|
||||
@@ -405,4 +394,3 @@ exit"""
|
||||
return prefix_dir
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||