Release v0.6.0

This commit is contained in:
Omni
2026-04-20 20:57:23 +01:00
parent 69fabb32e6
commit 2ff09a1448
144 changed files with 4841 additions and 1306 deletions

View File

@@ -1,5 +1,35 @@
# Jackify Changelog # Jackify Changelog
## 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 ## v0.5.0.4 - Hotfix
**Release Date:** 29/03/26 **Release Date:** 29/03/26

View File

@@ -35,13 +35,12 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists
## Requirements ## Requirements
- Linux system (most modern distributions will work) - 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 - **Protontricks** — required for modlist configuration
- See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-protontricks) - 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 - **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) - See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-ge-proton)
- **Nexus Mods account** (Premium required for automated downloads) - **Nexus Mods account** (Premium required for fully automated downloads; Non-Premium supported with manual browser steps)
- Non-Premium accounts are supported, but some downloads may require manual browser steps
- See the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide) for full details on the options available - See the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide) for full details on the options available
- **FUSE2 compatibility (libfuse.so.2) is required for AppImage execution** - **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 - **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

View File

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

View File

@@ -121,17 +121,16 @@ class ModlistOperationsConfigurationCLIMixin:
if debug_mode: if debug_mode:
cmd.append('--debug') cmd.append('--debug')
self.logger.info("Adding --debug flag to jackify-engine") self.logger.info("Adding --debug flag to jackify-engine")
if self.context.get('skip_disk_check'): writeback_path = str(auth_service.get_token_writeback_path())
cmd.append('--skip-disk-check')
self.logger.info("Adding --skip-disk-check flag to jackify-engine")
original_env_values = { original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), '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') 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
} }
try: try:
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
if oauth_info: if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService from jackify.backend.services.nexus_oauth_service import NexusOAuthService
@@ -283,6 +282,7 @@ class ModlistOperationsConfigurationCLIMixin:
proc.wait() proc.wait()
self._current_process = None self._current_process = None
auth_service.apply_token_writeback(writeback_path)
if proc.returncode != 0: if proc.returncode != 0:
print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}")
self.logger.error(f"Engine exited with code {proc.returncode}.") self.logger.error(f"Engine exited with code {proc.returncode}.")
@@ -595,6 +595,36 @@ class ModlistOperationsConfigurationCLIMixin:
if update_existing_install and app_id: if update_existing_install and app_id:
print(f"{COLOR_SUCCESS}Update mode Steam setup confirmed.{COLOR_RESET}") print(f"{COLOR_SUCCESS}Update mode Steam setup confirmed.{COLOR_RESET}")
print(f"{COLOR_INFO}Reusing Steam AppID: {app_id}{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: else:
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}") print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
if prefix_path: if prefix_path:

View File

@@ -70,7 +70,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
"manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs "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 "manual_download_watch_directory": None, # Optional override for manual-download watcher folder
"window_width": None, # Saved window width (None = use dynamic sizing) "window_width": None, # Saved window width (None = use dynamic sizing)
"window_height": None # Saved window height (None = use dynamic sizing) "window_height": None, # Saved window height (None = use dynamic sizing)
} }
# Load configuration if exists # Load configuration if exists

View File

@@ -521,11 +521,13 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
# Game-specific Documents directory names (for both Linux home and Wine prefix) # Game-specific Documents directory names (for both Linux home and Wine prefix)
game_docs_dirs = { game_docs_dirs = {
"skyrimse": "Skyrim Special Edition", "skyrimse": "Skyrim Special Edition",
"skyrimvr": "Skyrim VR",
"fallout4": "Fallout4", "fallout4": "Fallout4",
"fallout4vr": "Fallout4VR",
"falloutnv": "FalloutNV", "falloutnv": "FalloutNV",
"oblivion": "Oblivion", "oblivion": "Oblivion",
"enderal": "Enderal Special Edition", "enderal": "Enderal Special Edition",
"enderalse": "Enderal Special Edition" "enderalse": "Enderal Special Edition",
} }
game_dirs = { game_dirs = {
@@ -561,41 +563,193 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
os.makedirs(dir_path, exist_ok=True) os.makedirs(dir_path, exist_ok=True)
self.logger.debug(f"Created game-specific directory: {dir_path}") self.logger.debug(f"Created game-specific directory: {dir_path}")
# CRITICAL: Create game-specific Documents directories in Wine prefix # 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 # Required for USVFS to virtualize profile INIs on first launch
if game_name in game_docs_dirs: from ..handlers.path_handler import PathHandler
docs_dir_name = game_docs_dirs[game_name] path_handler = PathHandler()
compatdata_path = path_handler.find_compat_data(appid)
# Find compatdata path for this AppID if compatdata_path:
from ..handlers.path_handler import PathHandler prefix_user = os.path.join(
path_handler = PathHandler() str(compatdata_path), "pfx", "drive_c", "users", "steamuser"
compatdata_path = path_handler.find_compat_data(appid) )
if compatdata_path: if game_name in appdata_only_dirs:
# Create Documents/My Games/{GameName} in Wine prefix appdata_path = os.path.join(
wine_docs_path = os.path.join( prefix_user, "AppData", "Local", appdata_only_dirs[game_name]
str(compatdata_path),
"pfx",
"drive_c",
"users",
"steamuser",
"Documents",
"My Games",
docs_dir_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: try:
os.makedirs(wine_docs_path, exist_ok=True) os.makedirs(wine_docs_path, exist_ok=True)
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}") self.logger.info(f"Created Wine prefix Documents directory: {wine_docs_path}")
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
except Exception as e: except Exception as e:
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {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: if game_name == "skyrimse":
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation") self._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time") 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 return True
except Exception as e: except Exception as e:
self.logger.error(f"Error creating required directories: {e}") self.logger.error(f"Error creating required directories: {e}")
return False return False
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}")

View File

@@ -64,7 +64,7 @@ class FilesystemSteamMixin:
default_path = Path.home() / ".steam/steam/steamapps/common" default_path = Path.home() / ".steam/steam/steamapps/common"
if default_path.is_dir(): 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 return default_path
logger.error("No valid Steam library found via vdf or at default location.") logger.error("No valid Steam library found via vdf or at default location.")

View File

@@ -18,7 +18,11 @@ class GameDetector:
'fallout3': ['Fallout 3'], 'fallout3': ['Fallout 3'],
'oblivion': ['Oblivion'], 'oblivion': ['Oblivion'],
'starfield': ['Starfield'], '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]: def detect_game_type(self, modlist_name: str) -> Optional[str]:
@@ -26,9 +30,17 @@ class GameDetector:
modlist_lower = modlist_name.lower() modlist_lower = modlist_name.lower()
# Check for game-specific keywords in modlist name # 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']): if any(keyword in modlist_lower for keyword in ['oblivion remastered', 'oblivionremastered', 'oblivion_remastered']):
return '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']): elif any(keyword in modlist_lower for keyword in ['skyrim', 'sse', 'skse', 'dragonborn', 'dawnguard']):
return 'skyrim' return 'skyrim'
elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']): elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']):
@@ -134,7 +146,35 @@ class GameDetector:
'min_proton_version': '8.0', 'min_proton_version': '8.0',
'required_dlc': [], 'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'] '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, {}) return requirements.get(game_type, {})

View File

@@ -2,7 +2,6 @@
from pathlib import Path from pathlib import Path
import os import os
import logging import logging
import requests
import re import re
from typing import Optional from typing import Optional
@@ -147,52 +146,14 @@ class ModlistConfigurationMixin:
print("───────────────────────────────────────────────────────────────────") print("───────────────────────────────────────────────────────────────────")
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") 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.") 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: if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration") status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration")
self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...") self.logger.info("Step 3: Applying modlist registry tweaks...")
try: try:
prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) self._apply_modlist_registry_tweaks()
if not prefix_path_str or not os.path.isdir(prefix_path_str):
raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.")
user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist"
user_reg_dest = Path(prefix_path_str) / "user.reg"
response = requests.get(user_reg_url, verify=True)
response.raise_for_status()
with open(user_reg_dest, "wb") as f:
f.write(response.content)
self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}")
system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist"
system_reg_dest = Path(prefix_path_str) / "system.reg"
response = requests.get(system_reg_url, verify=True)
response.raise_for_status()
with open(system_reg_dest, "wb") as f:
f.write(response.content)
self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}")
except Exception as e: except Exception as e:
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}") self.logger.warning("Modlist registry tweaks failed (non-fatal): %s", 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.")
# The curated registry files overwrite the entire Wine registry, so any
# game-specific entries injected earlier must be re-applied immediately after.
special_game_type = self.detect_special_game_type(self.modlist_dir)
if special_game_type in ["fnv", "fo3", "enderal"]:
self.logger.info(
"Re-injecting %s game registry entries after curated registry overwrite",
special_game_type.upper(),
)
try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
AutomatedPrefixService()._inject_game_registry_entries(prefix_path_str, special_game_type)
except Exception as e:
self.logger.error(
"Failed to restore %s registry entries after curated registry overwrite: %s",
special_game_type.upper(),
e,
)
self.logger.error("Could not restore required game registry entries after applying curated registry files.")
return False
# Step 4: Install Wine Components # Step 4: Install Wine Components
if status_callback: if status_callback:
@@ -258,18 +219,12 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} {failure_msg}") status_callback(f"{self._get_progress_timestamp()} {failure_msg}")
# Continue but user should be aware of potential issues # Continue but user should be aware of potential issues
# Step 4.6: Enable dotfiles visibility for Wine prefix # Step 4.6: Audit final registry state - confirms all writes survived winetricks
if status_callback: self.logger.info("Step 4.6: Auditing registry state...")
status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility")
self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...")
try: try:
if self.protontricks_handler.enable_dotfiles(self.appid): self._audit_registry_state()
self.logger.info("Dotfiles visibility enabled successfully")
else:
self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)")
except Exception as e: except Exception as e:
self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)") self.logger.warning("Registry audit failed (non-fatal): %s", e)
self.logger.info("Step 4.6: Enabling dotfiles visibility... Done")
# Step 4.7: Create Wine prefix Documents directories for USVFS # Step 4.7: Create Wine prefix Documents directories for USVFS
# Critical for USVFS profile INI virtualization on first launch # Critical for USVFS profile INI virtualization on first launch
@@ -277,16 +232,39 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS") 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...") self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...")
try: try:
if self.appid and self.game_var: if self.appid:
# Map game_var to game_name for create_required_dirs # Map detected game type to the key expected by create_required_dirs
game_name_map = { game_name_map = {
"skyrim": "skyrimse",
"skyrimspecialedition": "skyrimse", "skyrimspecialedition": "skyrimse",
"skyrimvr": "skyrimvr",
"fallout": "fallout4",
"fallout4": "fallout4", "fallout4": "fallout4",
"fo4": "fallout4",
"fallout4vr": "fallout4vr",
"fnv": "falloutnv",
"falloutnv": "falloutnv", "falloutnv": "falloutnv",
"oblivion": "oblivion", "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: if game_name:
appid_str = str(self.appid) appid_str = str(self.appid)
@@ -295,13 +273,42 @@ class ModlistConfigurationMixin:
else: else:
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)") self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
else: 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: 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: except Exception as e:
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)") 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") 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 # Step 5: Verify ownership of Modlist directory
if status_callback: if status_callback:
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership") status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
@@ -328,6 +335,10 @@ class ModlistConfigurationMixin:
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}") self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done") 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 # Step 6.5: Handle symlinked downloads directory
if status_callback: if status_callback:
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory") status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
@@ -419,6 +430,21 @@ class ModlistConfigurationMixin:
self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)") self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)")
else: else:
self.logger.warning("Could not set download_directory in ModOrganizer.ini") 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. # 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 # This is intentionally separate from broad binary-path rewriting so it still runs when
@@ -584,6 +610,47 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings") status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings")
self._re_enforce_windows_10_mode() 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 return True # Return True on success
def run_modlist_configuration_phase(self, context: dict = None) -> bool: def run_modlist_configuration_phase(self, context: dict = None) -> bool:
@@ -597,6 +664,48 @@ class ModlistConfigurationMixin:
status_callback = context.get('status_callback') if context else None status_callback = context.get('status_callback') if context else None
return self._execute_configuration_steps(status_callback=status_callback) 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): def _prompt_or_set_resolution(self):
# If on Steam Deck, set 1280x800 automatically # If on Steam Deck, set 1280x800 automatically
if self._is_steam_deck(): if self._is_steam_deck():
@@ -617,3 +726,73 @@ class ModlistConfigurationMixin:
else: else:
self.selected_resolution = None self.selected_resolution = None
self.logger.info("Resolution setup skipped by user.") 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)")

View File

@@ -253,12 +253,13 @@ class ModlistDetectionMixin:
modlist_path = Path(self.modlist_dir) modlist_path = Path(self.modlist_dir)
common_names = [ common_names = [
"Stock Game", "Stock Game",
"Game Root", "StockGame",
"STOCK GAME", "STOCK GAME",
"Stock Game Folder", "Stock Game Folder",
"Stock Folder", "Stock Folder",
"Skyrim Stock", "Skyrim Stock",
Path("root/Skyrim Special Edition") Path("root/Skyrim Special Edition"),
"Game Root",
] ]
found_path = None found_path = None
@@ -326,6 +327,15 @@ class ModlistDetectionMixin:
if mo2_ini.exists(): if mo2_ini.exists():
try: try:
content = mo2_ini.read_text(errors='ignore').lower() 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: 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") self.logger.info("Detected FNV via ModOrganizer.ini markers")
return "fnv" return "fnv"
@@ -335,6 +345,18 @@ class ModlistDetectionMixin:
if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']): 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") self.logger.info("Detected Enderal via ModOrganizer.ini markers")
return "enderal" 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: except Exception as e:
self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}") self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}")
except Exception: except Exception:
@@ -364,6 +386,15 @@ class ModlistDetectionMixin:
if enderal_launcher.exists(): if enderal_launcher.exists():
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'") self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
return "enderal" 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 # Final heuristic using game_var
try: try:
@@ -379,6 +410,18 @@ class ModlistDetectionMixin:
if 'enderal' in gt: if 'enderal' in gt:
self.logger.info("Heuristic detection: game_var indicates Enderal") self.logger.info("Heuristic detection: game_var indicates Enderal")
return "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: except Exception:
pass pass

View File

@@ -61,32 +61,9 @@ class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWi
Handles operations related to modlist detection and configuration Handles operations related to modlist detection and configuration
""" """
# Dictionary mapping modlist name patterns (lowercase, spaces optional) MODLIST_SPECIFIC_COMPONENTS: dict = {}
# to lists of additional Wine components or special actions.
MODLIST_SPECIFIC_COMPONENTS = {
# Pattern: [component1, component2, ... or special_action_string]
"wildlander": ["dotnet48"], # Example from bash script
"licentia": ["dotnet8"], # Example from bash script (needs special handling)
"nolvus": ["dotnet6", "dotnet7"], # Example
# Add other modlists and their specific needs here
# e.g., "fallout4_anotherlife": ["some_component"]
}
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh) MODLIST_WINE_COMPONENTS: dict = {}
# 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
}
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None, def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
mo2_path: Optional[Union[str, Path]] = None, mo2_path: Optional[Union[str, Path]] = None,

View File

@@ -159,14 +159,17 @@ class ModlistInstallCLIConfigurationMixin:
self.logger.info(f"Using machineid: {machineid}") self.logger.info(f"Using machineid: {machineid}")
cmd += ['-o', install_dir_str, '-d', download_dir_str] 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 # Store original environment values to restore later
original_env_values = { original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), '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') 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
} }
try: try:
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
# Temporarily modify current process's environment # Temporarily modify current process's environment
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy) # Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
if oauth_info: if oauth_info:
@@ -341,6 +344,7 @@ class ModlistInstallCLIConfigurationMixin:
print() print()
proc.wait() proc.wait()
auth_service.apply_token_writeback(writeback_path)
finally: finally:
# Stop performance monitoring and get summary # Stop performance monitoring and get summary

View File

@@ -59,32 +59,10 @@ class ModlistWineOpsMixin:
self.logger.error("Could not locate Steam's config.vdf file.") self.logger.error("Could not locate Steam's config.vdf file.")
return False, 'config_vdf_missing' 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: try:
self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}") self.logger.debug(f"Loading config.vdf: {config_vdf_path}")
# CORRECTION: Use the vdf library directly here, not VDFHandler
with open(str(config_vdf_path), 'r') as f: 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 # Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
compat_mapping = steam_config_section.get('CompatToolMapping', {}) compat_mapping = steam_config_section.get('CompatToolMapping', {})
@@ -152,14 +130,24 @@ class ModlistWineOpsMixin:
self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.") self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.")
return True, 'ok' 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 Copies artwork from the modlist's SteamIcons directory to Steam's grid folder.
to the grid directory of all non-zero Steam user directories, named after the new AppID. 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" steam_icons_dir = Path(modlist_dir) / "SteamIcons"
if not steam_icons_dir.is_dir(): 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 return
# Find all non-zero Steam user directories # Find all non-zero Steam user directories
@@ -177,8 +165,8 @@ class ModlistWineOpsMixin:
images = [ images = [
("grid-hero.png", f"{appid}_hero.png"), ("grid-hero.png", f"{appid}_hero.png"),
("grid-logo.png", f"{appid}_logo.png"), ("grid-logo.png", f"{appid}_logo.png"),
("grid-tall.png", f"{appid}.png"),
("grid-tall.png", f"{appid}p.png"), ("grid-tall.png", f"{appid}p.png"),
("grid-wide.png", f"{appid}.png"),
] ]
for src_name, dest_name in images: for src_name, dest_name in images:
@@ -191,7 +179,85 @@ class ModlistWineOpsMixin:
except Exception as e: except Exception as e:
self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}") self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}")
else: 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): 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(" ", "") game = (game_var_full or modlist_name or "").lower().replace(" ", "")
# Add game-specific extras # 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: 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: elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
extras += ["d3dx9_43", "d3dx9"] 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: else:
# Unknown game type install the union of all known component sets # Unknown game type - install the union of all known component sets
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "d3dx9_43", "d3dx9"] extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "d3dx9_43", "d3dx9"]
# Add modlist-specific extras # Add modlist-specific extras
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else "" modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
for key, components in self.MODLIST_WINE_COMPONENTS.items(): for key, components in self.MODLIST_WINE_COMPONENTS.items():
@@ -224,37 +294,49 @@ class ModlistWineOpsMixin:
def _re_enforce_windows_10_mode(self): def _re_enforce_windows_10_mode(self):
""" """
Re-enforce Windows 10 mode after modlist-specific configurations. Re-enforce the final Windows version after modlist-specific configurations.
This matches the legacy script behavior (line 1333) where Windows 10 mode Re-applies win10 after modlist-specific winetricks components, which can
is re-applied after modlist-specific steps to ensure consistency. leave the prefix at a lower version.
""" """
try: try:
if not hasattr(self, 'appid') or not self.appid: 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 return
from ..handlers.winetricks_handler import WinetricksHandler from ..handlers.winetricks_handler import WinetricksHandler
from ..handlers.path_handler import PathHandler from ..handlers.path_handler import PathHandler
# Get prefix path for the AppID # Get prefix path for the AppID - must be compatdata/pfx/, not compatdata/
prefix_path = PathHandler.find_compat_data(str(self.appid)) compatdata_path = PathHandler.find_compat_data(str(self.appid))
if not prefix_path: if not compatdata_path:
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found") self.logger.warning("Cannot re-enforce Windows 11 mode - prefix path not found")
return 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() winetricks_handler = WinetricksHandler()
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path)) wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
if not wine_binary: 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 return
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary) env = os.environ.copy()
env['WINEPREFIX'] = str(prefix_path)
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations") 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: 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: def _handle_symlinked_downloads(self) -> bool:
""" """
@@ -380,21 +462,17 @@ class ModlistWineOpsMixin:
env['WINEPREFIX'] = prefix_path env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Shutdown any running wineserver processes to ensure clean slate self._wait_for_wineserver(prefix_path)
if wineserver_binary:
self.logger.debug("Shutting down wineserver before applying registry fixes...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Wineserver shutdown complete")
except Exception as e:
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override) # Registry fix 1: Set *mscoree=native as a per-exe AppDefaults override for
# Use native .NET runtime instead of Wine's # SkyrimSE.exe only. A global DllOverrides entry breaks .NET 9/10 bootstrap
self.logger.debug("Setting *mscoree=native DLL override...") # (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 = [ cmd1 = [
wine_binary, 'reg', 'add', 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' '/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
] ]
@@ -430,43 +508,12 @@ class ModlistWineOpsMixin:
except Exception as e: except Exception as e:
self.logger.warning(f"Registry flush failed (non-critical): {e}") self.logger.warning(f"Registry flush failed (non-critical): {e}")
# VERIFICATION: Confirm the registry entries persisted ok = result1.returncode == 0 and result2.returncode == 0
self.logger.info("Verifying registry entries were applied and persisted...") if ok:
verification_passed = True self.logger.info("Universal dotnet4.x compatibility fixes applied and flushed")
# Verify *mscoree=native
verify_cmd1 = [
wine_binary, 'reg', 'query',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree'
]
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
self.logger.info("VERIFIED: *mscoree=native is set correctly")
else: else:
self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}") self.logger.error("One or more dotnet4.x registry commands failed - see errors above")
verification_passed = False return ok
# Verify OnlyUseLatestCLR=1
verify_cmd2 = [
wine_binary, 'reg', 'query',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR'
]
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
verification_passed = False
# Both fixes applied and verified
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
return True
else:
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
return False
except Exception as e: except Exception as e:
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {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}") self.logger.error(f"Error finding Wine binary: {e}")
return None 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]: def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
""" """
Recursively search for wine binary within a Proton directory. Recursively search for wine binary within a Proton directory.
@@ -543,4 +788,3 @@ class ModlistWineOpsMixin:
except Exception as e: except Exception as e:
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}") self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None return None

View File

@@ -180,5 +180,6 @@ class PathHandlerGameMixin:
self.stock_game_path = found_path self.stock_game_path = found_path
return True return True
self.stock_game_path = None 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 return True

View File

@@ -534,7 +534,8 @@ class PathHandlerMO2Mixin:
def set_download_directory(self, modlist_ini_path: Path, download_dir_linux_path, modlist_sdcard: bool) -> bool: 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). 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: if not modlist_ini_path.is_file() or not download_dir_linux_path:
return False return False
@@ -553,35 +554,62 @@ class PathHandlerMO2Mixin:
formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path) formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path)
with open(modlist_ini_path, 'r', encoding='utf-8') as f: with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines() 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" new_line = f"download_directory = {formatted}\n"
if download_line_idx >= 0: replaced = [i for i, l in enumerate(lines) if re.match(r'^\s*download_directory\s*=', l, re.IGNORECASE)]
lines[download_line_idx] = new_line if replaced:
for i in replaced:
lines[i] = new_line
else: else:
if in_general: # 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) insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
if insert_idx >= 0: if insert_idx >= 0:
insert_idx += 1
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
insert_idx += 1 insert_idx += 1
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]): lines.insert(insert_idx, new_line)
insert_idx += 1
lines.insert(insert_idx, new_line)
else: else:
lines.append("[General]\n") lines.append("[General]\n")
lines.append(new_line) lines.append(new_line)
with open(modlist_ini_path, 'w', encoding='utf-8') as f: with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines) 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 return True
except Exception as e: except Exception as e:
logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}") logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}")
return False 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

View File

@@ -90,7 +90,7 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]" # Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s" # 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" # 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( 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)?', r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
re.IGNORECASE re.IGNORECASE
@@ -161,6 +161,13 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
if not line.strip(): if not line.strip():
return result 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 # Try to extract phase information
phase_info = self._extract_phase(line) phase_info = self._extract_phase(line)
if phase_info: if phase_info:

View File

@@ -20,11 +20,11 @@ class ProgressParserPhaseMixin:
phase = self._map_section_to_phase(section_name) phase = self._map_section_to_phase(section_name)
return (phase, section_match.group(1).strip()) 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: if '[FILE_PROGRESS]' in line:
return None 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( action_match = re.search(
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)', r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
line, line,

View File

@@ -87,7 +87,7 @@ class ProtontricksCommandsMixin:
env['WINETRICKS'] = str(winetricks_path) env['WINETRICKS'] = str(winetricks_path)
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
else: 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() cabextract_path = self._get_bundled_cabextract_path()
if cabextract_path: if cabextract_path:
cabextract_dir = str(cabextract_path.parent) 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 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}") self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
else: 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: else:
self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")

View File

@@ -74,7 +74,7 @@ class ProtontricksPrefixMixin:
self.logger.debug("ShowDotFiles already present in correct format in user.reg") self.logger.debug("ShowDotFiles already present in correct format in user.reg")
dotfiles_set_success = True dotfiles_set_success = True
else: 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: with open(user_reg_path, 'w', encoding='utf-8') as f:
f.write('[Software\\\\Wine] 1603891765\n') f.write('[Software\\\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n') f.write('"ShowDotFiles"="Y"\n')
@@ -157,6 +157,10 @@ class ProtontricksPrefixMixin:
self.logger.info("=" * 80) self.logger.info("=" * 80)
env = self._get_clean_subprocess_env() env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all" 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': if self.which_protontricks == 'native':
winetricks_path = self._get_bundled_winetricks_path() winetricks_path = self._get_bundled_winetricks_path()
@@ -164,7 +168,7 @@ class ProtontricksPrefixMixin:
env['WINETRICKS'] = str(winetricks_path) env['WINETRICKS'] = str(winetricks_path)
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
else: 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() cabextract_path = self._get_bundled_cabextract_path()
if cabextract_path: if cabextract_path:
cabextract_dir = str(cabextract_path.parent) 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 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}") self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
else: 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: else:
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")

View File

@@ -81,7 +81,7 @@ class ProtontricksSteamMixin:
self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}") self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
if steamdeck: 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) result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env)
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
if "/run/media" in line: if "/run/media" in line:

View File

@@ -104,12 +104,16 @@ class ShortcutCreationMixin:
except Exception as e: except Exception as e:
self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True) 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 final_launch_options = launch_options
if compat_mounts_str: env_prefix_parts = [p for p in [compat_mounts_str, dotnet_vars] if p]
if final_launch_options: if env_prefix_parts:
final_launch_options = f"{compat_mounts_str} {final_launch_options}" prefix = " ".join(env_prefix_parts)
else: if final_launch_options:
final_launch_options = compat_mounts_str final_launch_options = f"{prefix} {final_launch_options}"
else:
final_launch_options = prefix
if not final_launch_options.strip().endswith("%command%"): if not final_launch_options.strip().endswith("%command%"):
if final_launch_options: if final_launch_options:
@@ -138,7 +142,6 @@ class ShortcutCreationMixin:
except Exception as e: except Exception as e:
self.logger.error(f"Error creating shortcut: {e}", exc_info=True) self.logger.error(f"Error creating shortcut: {e}", exc_info=True)
print(f"An error occurred while creating the shortcut: {e}")
return False, None return False, None
def _is_steam_deck(self): def _is_steam_deck(self):

View File

@@ -165,7 +165,7 @@ class ShortcutDiscoveryMixin:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)") self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
return str(int(appid) & 0xFFFFFFFF) 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 return None
except Exception as e: except Exception as e:

View File

@@ -300,7 +300,6 @@ class ShortcutVDFManagementMixin:
try: try:
shutil.copy2(safe_backup, shortcuts_file) shutil.copy2(safe_backup, shortcuts_file)
self.logger.info(f"Restored shortcuts.vdf from pre-restart backup") self.logger.info(f"Restored shortcuts.vdf from pre-restart backup")
print("Restored shortcuts file after Steam restart")
return return
except Exception as e: except Exception as e:
self.logger.error(f"Failed to restore from pre-restart backup: {e}") self.logger.error(f"Failed to restore from pre-restart backup: {e}")
@@ -310,9 +309,8 @@ class ShortcutVDFManagementMixin:
try: try:
shutil.copy2(backup, shortcuts_file) shutil.copy2(backup, shortcuts_file)
self.logger.info(f"Restored shortcuts.vdf from regular backup") self.logger.info(f"Restored shortcuts.vdf from regular backup")
print("Restored shortcuts file after Steam restart")
except Exception as e: except Exception as e:
self.logger.error(f"Failed to restore from backup: {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: else:
self.logger.info(f"shortcuts.vdf verified intact after restart") self.logger.info(f"shortcuts.vdf verified intact after restart")

View File

@@ -266,7 +266,7 @@ class ProcessManager:
pass pass
cleanup_attempts += 1 cleanup_attempts += 1
finally: 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: if self.proc:
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr): for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
if pipe: if pipe:

View File

@@ -279,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" 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 target_mod_dir = mods_dir / mod_folder_name
if skip_copy: 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) logger.info("TTW already at target location, skipping copy: %s", target_mod_dir)
else: else:
logger.info("Copying TTW output to %s", target_mod_dir) logger.info("Copying TTW output to %s", target_mod_dir)

View File

@@ -26,18 +26,21 @@ class WabbajackParser:
'Fallout4': 'fallout4', 'Fallout4': 'fallout4',
'FalloutNewVegas': 'falloutnv', 'FalloutNewVegas': 'falloutnv',
'Oblivion': 'oblivion', 'Oblivion': 'oblivion',
'Skyrim': 'skyrim', # Legacy Skyrim 'Skyrim': 'skyrim',
'Fallout3': 'fallout3', # For completeness 'Fallout3': 'fallout3',
'SkyrimVR': 'skyrim', # Treat as Skyrim 'SkyrimVR': 'skyrimvr',
'Fallout4VR': 'fallout4', # Treat as Fallout 4 'Fallout4VR': 'fallout4vr',
'Enderal': 'enderal', # Enderal: Forgotten Stories 'Enderal': 'enderal',
'EnderalSpecialEdition': 'enderal', # Enderal SE 'EnderalSpecialEdition': 'enderal',
'Cyberpunk2077': 'cp2077',
'BaldursGate3': 'bg3',
} }
# List of supported games in Jackify # List of supported games in Jackify
self.supported_games = [ self.supported_games = [
'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion', '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]: 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}") self.logger.error(f"Error parsing .wabbajack file {wabbajack_path}: {e}")
return None 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: def is_supported_game(self, game_type: str) -> bool:
""" """
Check if a game type is supported by Jackify's post-install configuration. Check if a game type is supported by Jackify's post-install configuration.
@@ -133,7 +153,11 @@ class WabbajackParser:
'oblivion': 'Oblivion', 'oblivion': 'Oblivion',
'starfield': 'Starfield', 'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered', '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] return [display_names.get(game, game) for game in self.supported_games]

View File

@@ -6,7 +6,6 @@ Extracted from wine_utils for file-size and domain separation.
""" """
import os import os
import re
import subprocess import subprocess
import logging import logging
from typing import Optional from typing import Optional
@@ -56,39 +55,6 @@ class WineUtilsConfigMixin:
logger.error(f"Error performing additional tasks: {e}") logger.error(f"Error performing additional tasks: {e}")
return False 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 @staticmethod
def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool: 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.""" """Set up Fallout New Vegas launch options. Returns True on success."""

View File

@@ -136,7 +136,7 @@ class WineUtilsProtonMixin:
if fallback_path != 'auto': if fallback_path != 'auto':
fallback_wine_bin = Path(fallback_path) / "files/bin/wine" fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
if fallback_wine_bin.is_file(): 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) return str(fallback_wine_bin)
except Exception: except Exception:
pass pass

View File

@@ -36,7 +36,6 @@ def _get_clean_winetricks_base_env() -> dict:
env["PATH"] = path or "/usr/bin:/bin" env["PATH"] = path or "/usr/bin:/bin"
return env return env
class WinetricksEnvMixin: class WinetricksEnvMixin:
"""Mixin providing env build and dependency check for WinetricksHandler.install_wine_components.""" """Mixin providing env build and dependency check for WinetricksHandler.install_wine_components."""
@@ -54,10 +53,11 @@ class WinetricksEnvMixin:
env['WINEDEBUG'] = '-all' env['WINEDEBUG'] = '-all'
env['WINEPREFIX'] = wineprefix env['WINEPREFIX'] = wineprefix
env['WINETRICKS_GUI'] = 'none' env['WINETRICKS_GUI'] = 'none'
if 'DISPLAY' in env: env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d' # Preserve the desktop display variables for Step 4. The validated fix
else: # for the blank taskbar popup regression was keeping DISPLAY available.
env['DISPLAY'] = env.get('DISPLAY', '') # Do not strip extra desktop activation vars here without a reproduced,
# evidence-backed need.
try: try:
from ..handlers.config_handler import ConfigHandler from ..handlers.config_handler import ConfigHandler
@@ -243,7 +243,10 @@ class WinetricksEnvMixin:
if not found: if not found:
missing_deps.append(dep_name) missing_deps.append(dep_name)
if dep_name in bundled_tools_list: 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: else:
self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)") self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)")

View File

@@ -14,9 +14,9 @@ class ModlistContext:
"""Context object for modlist operations.""" """Context object for modlist operations."""
name: str name: str
install_dir: Path install_dir: Path
download_dir: Path
game_type: str game_type: str
nexus_api_key: str nexus_api_key: str
download_dir: Optional[Path] = None
modlist_value: Optional[str] = None modlist_value: Optional[str] = None
modlist_source: Optional[str] = None # 'identifier' or 'file' modlist_source: Optional[str] = None # 'identifier' or 'file'
resolution: Optional[str] = None resolution: Optional[str] = None
@@ -29,8 +29,8 @@ class ModlistContext:
"""Convert string paths to Path objects.""" """Convert string paths to Path objects."""
if isinstance(self.install_dir, str): if isinstance(self.install_dir, str):
self.install_dir = Path(self.install_dir) self.install_dir = Path(self.install_dir)
if isinstance(self.download_dir, str): if self.download_dir is not None and isinstance(self.download_dir, str):
self.download_dir = Path(self.download_dir) self.download_dir = Path(self.download_dir) if self.download_dir else None
if isinstance(self.mo2_exe_path, str): if isinstance(self.mo2_exe_path, str):
self.mo2_exe_path = Path(self.mo2_exe_path) self.mo2_exe_path = Path(self.mo2_exe_path)

View File

@@ -19,66 +19,68 @@ logger = logging.getLogger(__name__)
class GameUtilsMixin: class GameUtilsMixin:
"""Mixin for game-related utility operations""" """Mixin for game-related utility operations"""
def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]: # TODO post-0.6: remove this method - dead code, never called.
""" # Superseded by registry injection (game paths written directly into the modlist prefix).
Generate launch options for FNV/Enderal games that require vanilla compatdata. # def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
# """
Args: # Generate launch options for FNV/Enderal games that require vanilla compatdata.
special_game_type: "fnv" or "enderal" #
modlist_install_dir: Directory where the modlist is installed # Args:
# special_game_type: "fnv" or "enderal"
Returns: # modlist_install_dir: Directory where the modlist is installed
Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed #
""" # Returns:
if not special_game_type or special_game_type not in ["fnv", "enderal"]: # Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
return None # """
# if not special_game_type or special_game_type not in ["fnv", "enderal"]:
logger.info(f"Generating {special_game_type.upper()} launch options") # return None
#
# Map game types to AppIDs # logger.info(f"Generating {special_game_type.upper()} launch options")
appid_map = {"fnv": "22380", "enderal": "976620"} #
appid = appid_map[special_game_type] # # Map game types to AppIDs
# appid_map = {"fnv": "22380", "enderal": "976620"}
# Find vanilla game compatdata # appid = appid_map[special_game_type]
from ..handlers.path_handler import PathHandler #
compatdata_path = PathHandler.find_compat_data(appid) # # Find vanilla game compatdata
if not compatdata_path: # from ..handlers.path_handler import PathHandler
logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})") # compatdata_path = PathHandler.find_compat_data(appid)
return None # if not compatdata_path:
# logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
# Create STEAM_COMPAT_DATA_PATH string # return None
compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"' #
# # Create STEAM_COMPAT_DATA_PATH string
# Generate STEAM_COMPAT_MOUNTS if multiple libraries exist # compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
compat_mounts_str = "" #
try: # # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
all_libs = PathHandler.get_all_steam_library_paths() # compat_mounts_str = ""
main_steam_lib_path_obj = PathHandler.find_steam_library() # try:
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": # all_libs = PathHandler.get_all_steam_library_paths()
main_steam_lib_path = main_steam_lib_path_obj.parent.parent # main_steam_lib_path_obj = PathHandler.find_steam_library()
else: # if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
main_steam_lib_path = main_steam_lib_path_obj # main_steam_lib_path = main_steam_lib_path_obj.parent.parent
# else:
mount_paths = [] # main_steam_lib_path = main_steam_lib_path_obj
if main_steam_lib_path: #
main_resolved = main_steam_lib_path.resolve() # mount_paths = []
for lib_path in all_libs: # if main_steam_lib_path:
if lib_path.resolve() != main_resolved: # main_resolved = main_steam_lib_path.resolve()
mount_paths.append(str(lib_path.resolve())) # for lib_path in all_libs:
# if lib_path.resolve() != main_resolved:
if mount_paths: # mount_paths.append(str(lib_path.resolve()))
mount_paths_str = ':'.join(mount_paths) #
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"' # if mount_paths:
logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}") # mount_paths_str = ':'.join(mount_paths)
except Exception as e: # compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}") # logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
# except Exception as e:
# Combine all launch options # logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip() #
launch_options = ' '.join(launch_options.split()) # Clean up spacing # # Combine all launch options
# launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}") # launch_options = ' '.join(launch_options.split()) # Clean up spacing
return launch_options #
# 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]: def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
"""Find a Steam game installation path by AppID and common names""" """Find a Steam game installation path by AppID and common names"""
@@ -140,36 +142,90 @@ class GameUtilsMixin:
return None 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. Pre-create game-specific user directories to prevent first-launch issues.
Creates both My Documents/My Games and AppData/Local directories for the game. 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 = { game_dir_names = {
"skyrim": "Skyrim Special Edition", "skyrim": "Skyrim Special Edition",
"skyrimvr": "Skyrim VR",
"fnv": "FalloutNV", "fnv": "FalloutNV",
"fo3": "Fallout3", "fo3": "Fallout3",
"fo4": "Fallout4", "fo4": "Fallout4",
"fallout4vr": "Fallout4VR",
"oblivion": "Oblivion", "oblivion": "Oblivion",
"oblivion_remastered": "Oblivion Remastered", "oblivion_remastered": "Oblivion Remastered",
"enderal": "Enderal Special Edition", "enderal": "Enderal Special Edition",
"starfield": "Starfield" "starfield": "Starfield",
} }
# Get the directory name for this game type # Non-Bethesda games: AppData/Local only, with a vendor-namespaced subdirectory
game_dir_name = game_dir_names.get(special_game_type) game_appdata_only = {
if not game_dir_name: "cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
logger.debug(f"No user directory mapping for game type: {special_game_type}") "bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
return }
# 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") 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 = [ directories_to_create = [
os.path.join(base_path, "Documents", "My Games", game_dir_name), 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 created_count = 0
@@ -184,90 +240,46 @@ class GameUtilsMixin:
if created_count > 0: if created_count > 0:
logger.info(f"Created {created_count} user directories for {game_dir_name}") logger.info(f"Created {created_count} user directories for {game_dir_name}")
def _get_lorerim_preferred_proton(self): if game_type == "skyrim":
"""Get Lorerim's preferred Proton 9 version with specific priority order""" 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: try:
from jackify.backend.handlers.wine_utils import WineUtils from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
# Get all available Proton versions fsh._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
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
except Exception as e: except Exception as e:
logger.error(f"Error detecting Lorerim Proton preference: {e}") logger.warning(f"Could not seed Skyrim first-launch files: {e}")
return None
def _store_proton_override_notification(self, modlist_name: str, proton_version: str): def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Store Proton override information for end-of-install notification""" """Delegate to FileSystemHandler to seed Fallout 4 first-launch fix files."""
try: try:
# Store override info for later display from jackify.backend.handlers.filesystem_handler import FileSystemHandler
if not hasattr(self, '_proton_overrides'): fsh = FileSystemHandler()
self._proton_overrides = [] fsh._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
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}")
except Exception as e: 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): def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Display any Proton override notifications to the user""" """Delegate to FileSystemHandler to seed Skyrim VR first-launch fix files."""
try: try:
if hasattr(self, '_proton_overrides') and self._proton_overrides: from jackify.backend.handlers.filesystem_handler import FileSystemHandler
for override in self._proton_overrides: fsh = FileSystemHandler()
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility" fsh._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
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 = []
except Exception as e: 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}")

View File

@@ -20,23 +20,6 @@ class ProtonOperationsMixin:
from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils 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() config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path() user_proton_path = config_handler.get_game_proton_path()

View File

@@ -1,5 +1,6 @@
"""Registry operations mixin for AutomatedPrefixService.""" """Registry operations mixin for AutomatedPrefixService."""
import os import os
import re
import subprocess import subprocess
import logging import logging
from pathlib import Path from pathlib import Path
@@ -74,7 +75,7 @@ class RegistryOperationsMixin:
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str): def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists. """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 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. to subprocess wine reg add when the reg files haven't been created yet.
""" """
@@ -91,10 +92,12 @@ class RegistryOperationsMixin:
fix1 = fix2 = False 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): if os.path.exists(user_reg):
fix1 = self._reg_set_value( fix1 = self._reg_set_value(
user_reg, user_reg,
"[Software\\\\Wine\\\\DllOverrides]", "[Software\\\\Wine\\\\AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides]",
'"*mscoree"', '"*mscoree"',
'"native"', '"native"',
) )
@@ -123,7 +126,7 @@ class RegistryOperationsMixin:
r1 = subprocess.run( r1 = subprocess.run(
[wine_binary, 'reg', 'add', [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'], '/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'],
env=env, capture_output=True, text=True, errors='replace', 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}") logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False 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: 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.""" """Set or add a key=value pair in a Wine .reg text file."""
try: try:
@@ -319,19 +369,19 @@ class RegistryOperationsMixin:
"name": "Fallout New Vegas", "name": "Fallout New Vegas",
"common_names": ["Fallout New Vegas", "FalloutNV"], "common_names": ["Fallout New Vegas", "FalloutNV"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]", "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
"path_key": "Installed Path", "path_key": "installed path",
}, },
"22300": { # Fallout 3 AppID "22300": { # Fallout 3 AppID
"name": "Fallout 3", "name": "Fallout 3",
"common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"], "common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]", "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
"path_key": "Installed Path", "path_key": "installed path",
}, },
"22370": { # Fallout 3 GOTY AppID alias "22370": { # Fallout 3 GOTY AppID alias
"name": "Fallout 3", "name": "Fallout 3",
"common_names": ["Fallout 3 GOTY", "Fallout 3"], "common_names": ["Fallout 3 GOTY", "Fallout 3"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]", "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
"path_key": "Installed Path", "path_key": "installed path",
}, },
"976620": { # Enderal Special Edition AppID "976620": { # Enderal Special Edition AppID
"name": "Enderal", "name": "Enderal",
@@ -339,6 +389,72 @@ class RegistryOperationsMixin:
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]", "registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
"path_key": "installed path", "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" pfx_path = Path(modlist_compatdata_path) / "pfx"
@@ -359,24 +475,22 @@ class RegistryOperationsMixin:
game_dir_name = Path(game_path).name game_dir_name = Path(game_path).name
canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}" canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}"
wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\" wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\"
success = self._reg_set_value( key = f'"{config["path_key"]}"'
system_reg_path, val = f'"{wine_val}"'
config["registry_section"], success = self._reg_set_value(system_reg_path, config["registry_section"], key, val)
f'"{config["path_key"]}"', self._reg_set_value(system_reg_path, self._wow64_counterpart(config["registry_section"]), key, val)
f'"{wine_val}"',
)
if success: if success:
logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}") logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}")
else: else:
logger.warning(f"Failed to set canonical registry path for {config['name']}") logger.warning(f"Failed to set canonical registry path for {config['name']}")
else: 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") logger.warning(f"Symlink failed for {config['name']}, writing real path to registry")
success = self._update_registry_path( success = self._update_registry_path(
system_reg_path, system_reg_path, config["registry_section"], config["path_key"], game_path
config["registry_section"], )
config["path_key"], self._update_registry_path(
game_path system_reg_path, self._wow64_counterpart(config["registry_section"]), config["path_key"], game_path
) )
if success: if success:
logger.info(f"Updated registry entry for {config['name']} (real path fallback)") logger.info(f"Updated registry entry for {config['name']} (real path fallback)")

View File

@@ -38,25 +38,29 @@ class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin):
# Initialize native Steam service # Initialize native Steam service
steam_service = NativeSteamService() steam_service = NativeSteamService()
# Use custom launch options if provided, otherwise generate default # Always compute STEAM_COMPAT_MOUNTS; custom_launch_options replaces %command% but
# still needs mounts so game assets on other drives are reachable inside the prefix.
mounts_prefix = ""
try:
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
mount_paths = path_handler.get_steam_compat_mount_paths(
install_dir=modlist_install_dir, download_dir=download_dir
)
if mount_paths:
mounts_prefix = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}"'
logger.info(f"Generated STEAM_COMPAT_MOUNTS: {mounts_prefix}")
except Exception as e:
logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS: {e}")
if custom_launch_options: if custom_launch_options:
launch_options = custom_launch_options launch_options = f"{mounts_prefix} {custom_launch_options}".strip() if mounts_prefix else custom_launch_options
logger.info(f"Using pre-generated launch options: {launch_options}") logger.info(f"Launch options (custom + mounts): {launch_options}")
elif mounts_prefix:
launch_options = f'{mounts_prefix} %command%'
logger.info(f"Launch options (mounts only): {launch_options}")
else: else:
# Generate STEAM_COMPAT_MOUNTS including install and download mountpoints
launch_options = "%command%" launch_options = "%command%"
try:
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
mount_paths = path_handler.get_steam_compat_mount_paths(
install_dir=modlist_install_dir, download_dir=download_dir
)
if mount_paths:
launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%'
logger.info(f"Generated launch options with mounts: {launch_options}")
except Exception as e:
logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}")
launch_options = "%command%"
# Get user's preferred Proton version (with Lorerim-specific override) # Get user's preferred Proton version (with Lorerim-specific override)
proton_version = self._get_user_proton_version(shortcut_name) proton_version = self._get_user_proton_version(shortcut_name)

View File

@@ -178,10 +178,20 @@ class WorkflowMixin:
modlist_handler = ModlistHandler() modlist_handler = ModlistHandler()
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir) special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
# No launch options needed - FNV, FO3 and Enderal use registry injection
custom_launch_options = None custom_launch_options = None
if special_game_type in ["fnv", "fo3", "enderal"]: if special_game_type in ["fnv", "fo3", "enderal"]:
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist") logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
elif special_game_type == "cp2077":
logger.info("Cyberpunk 2077 modlist detected - setting WINEDLLOVERRIDES for Red4ext/CET")
# version=n,b overrides d3d version detection for REDmod; winmm=n,b required for CET
custom_launch_options = 'WINEDLLOVERRIDES="version=n,b;winmm=n,b" %command%'
elif special_game_type == "bg3":
logger.info("Baldur's Gate 3 modlist detected")
logger.warning("BG3 modlists require Rootbuilder in COPY mode - verify this in MO2 plugin settings")
elif special_game_type in ["skyrimvr", "fallout4vr"]:
game_label = "Skyrim VR" if special_game_type == "skyrimvr" else "Fallout 4 VR"
logger.warning("%s modlist detected - SteamVR must be installed and running for this modlist to work", game_label)
logger.warning("%s modlists use Rootbuilder for game root files - ensure Rootbuilder is set to COPY mode in MO2 plugin settings", game_label)
else: else:
logger.debug("Standard modlist - no special game handling needed") logger.debug("Standard modlist - no special game handling needed")
@@ -202,6 +212,31 @@ class WorkflowMixin:
if progress_callback: if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam shut down") progress_callback(f"{self._get_progress_timestamp()} Steam shut down")
# Pre-fetch SteamGridDB artwork before shortcut creation so the icon field in
# shortcuts.vdf is populated at write time. Steam caches the icon on first read
# after restart; setting it after the fact has no effect.
steamicons_dir = Path(modlist_install_dir) / "SteamIcons"
if not steamicons_dir.is_dir():
from ..services.steamgriddb_service import detect_game_type_from_modlist
_prefetch_game_type = detect_game_type_from_modlist(modlist_install_dir)
if _prefetch_game_type:
try:
from ..services.steamgriddb_service import fetch_artwork
steamicons_dir.mkdir(parents=True, exist_ok=True)
count = fetch_artwork(_prefetch_game_type, steamicons_dir)
if count == 0:
steamicons_dir.rmdir()
logger.debug("SteamGridDB pre-fetch returned no images")
else:
logger.info(f"Pre-fetched {count} SteamGridDB images to {steamicons_dir}")
except Exception as e:
logger.debug(f"SteamGridDB pre-fetch failed: {e}")
try:
if steamicons_dir.is_dir() and not any(steamicons_dir.iterdir()):
steamicons_dir.rmdir()
except Exception:
pass
# Step 1: Create shortcut with native Steam service (Steam is now shut down) # Step 1: Create shortcut with native Steam service (Steam is now shut down)
logger.info("Step 1: Creating shortcut with native Steam service") logger.info("Step 1: Creating shortcut with native Steam service")
# Create shortcut using native Steam service with special game launch options # Create shortcut using native Steam service with special game launch options
@@ -222,9 +257,9 @@ class WorkflowMixin:
from ..handlers.modlist_handler import ModlistHandler from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler() modlist_handler = ModlistHandler()
modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir) modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir)
logger.info(f"Applied Steam artwork for shortcut '{shortcut_name}' (AppID: {appid})") logger.info(f"Steam artwork applied for shortcut '{shortcut_name}' (AppID: {appid})")
except Exception as e: except Exception as e:
logger.warning(f"Failed to apply Steam artwork: {e}") logger.warning(f"Steam artwork application failed: {e}")
# Step 2: Start Steam (if auto_restart enabled) # Step 2: Start Steam (if auto_restart enabled)
logger.info("Step 2: auto_restart=%s", auto_restart) logger.info("Step 2: auto_restart=%s", auto_restart)
@@ -243,6 +278,7 @@ class WorkflowMixin:
logger.info("Step 2 completed: Steam started") logger.info("Step 2 completed: Steam started")
if progress_callback: if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam started successfully") progress_callback(f"{self._get_progress_timestamp()} Steam started successfully")
progress_callback("[Jackify] Steam restart complete")
else: else:
logger.info("Step 2 skipped: Auto-restart disabled by user") logger.info("Step 2 skipped: Auto-restart disabled by user")
if progress_callback: if progress_callback:
@@ -287,6 +323,15 @@ class WorkflowMixin:
self._inject_game_registry_entries(str(prefix_path), special_game_type) self._inject_game_registry_entries(str(prefix_path), special_game_type)
else: else:
logger.warning("Could not find prefix path for registry injection") logger.warning("Could not find prefix path for registry injection")
elif special_game_type == "cp2077":
logger.info("Step 5: Applying CP2077 DLL overrides to prefix registry")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Configuring CP2077 mod framework DLL overrides...")
if prefix_path:
self._apply_cp2077_dll_overrides(str(prefix_path))
else:
logger.warning("Could not find prefix path for CP2077 DLL override injection")
else: else:
logger.info("Step 5: Skipping registry injection for standard modlist") logger.info("Step 5: Skipping registry injection for standard modlist")
@@ -296,18 +341,18 @@ class WorkflowMixin:
progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...") progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...")
if prefix_path: if prefix_path:
self._create_game_user_directories(str(prefix_path), special_game_type) self._create_game_user_directories(str(prefix_path), special_game_type, modlist_install_dir)
else: else:
logger.warning("Could not find prefix path for directory creation") logger.warning("Could not find prefix path for directory creation")
last_timestamp = self._get_progress_timestamp() last_timestamp = self._get_progress_timestamp()
logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}") logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}")
if progress_callback: if progress_callback:
progress_callback(f"{last_timestamp} Steam integration complete") progress_callback(f"{last_timestamp} Steam integration complete")
progress_callback("") # Blank line after Steam integration complete progress_callback("") # Blank line after Steam integration complete
# Show Proton override notification if applicable
self._show_proton_override_notification(progress_callback)
if progress_callback: if progress_callback:
progress_callback("") # Extra blank line to span across Configuration Summary progress_callback("") # Extra blank line to span across Configuration Summary

View File

@@ -221,7 +221,7 @@ class FileValidatorService:
def _validate(self, file_path: Path, expected_hash: str) -> ValidationResult: def _validate(self, file_path: Path, expected_hash: str) -> ValidationResult:
try: try:
# No expected hash accept by filename match alone, just move the file. # No expected hash - accept by filename match alone, just move the file.
if not (expected_hash or "").strip(): if not (expected_hash or "").strip():
return ValidationResult(matches=True, computed_hash=None, file_path=file_path) return ValidationResult(matches=True, computed_hash=None, file_path=file_path)
h = xxhash.xxh64() if xxhash else _XXH64Fallback() h = xxhash.xxh64() if xxhash else _XXH64Fallback()

View File

@@ -22,7 +22,9 @@ logger = logging.getLogger(__name__)
STATUS = Literal["pending", "browser_opened", "validating", "complete", "deferred", "skipped", "error"] STATUS = Literal["pending", "browser_opened", "validating", "complete", "deferred", "skipped", "error"]
_STATE_FILE = Path.home() / '.local' / 'share' / 'jackify' / 'manual_download_state.json' def _get_state_file() -> Path:
from jackify.shared.paths import get_jackify_data_dir
return get_jackify_data_dir() / 'manual_download_state.json'
@dataclass @dataclass

View File

@@ -224,7 +224,7 @@ class ManualDownloadManagerRuntimeMixin:
item_to_notify = item item_to_notify = item
completed_now = True completed_now = True
else: else:
# Hash mismatch or validation error revert to pending so the # Hash mismatch or validation error - revert to pending so the
# sliding window can re-open a browser tab and the watcher can # sliding window can re-open a browser tab and the watcher can
# re-validate if the user downloads the correct file. # re-validate if the user downloads the correct file.
item.status = 'pending' item.status = 'pending'

View File

@@ -91,9 +91,7 @@ class ModlistGalleryService:
return metadata return metadata
except Exception as e: except Exception as e:
print(f"Error fetching modlist metadata: {e}") logger.warning("Error fetching modlist metadata: %s - falling back to cache", e)
print("Falling back to cached metadata (may be outdated)")
# Fall back to cache if network/engine fails
return self._load_from_cache() return self._load_from_cache()
def _fetch_from_engine( def _fetch_from_engine(
@@ -164,7 +162,7 @@ class ModlistGalleryService:
data = json.load(f) data = json.load(f)
return parse_modlist_metadata_response(data) return parse_modlist_metadata_response(data)
except Exception as e: except Exception as e:
print(f"Error loading cache: {e}") logger.warning("Error loading metadata cache: %s", e)
return None return None
def _save_to_cache(self, metadata: ModlistMetadataResponse): def _save_to_cache(self, metadata: ModlistMetadataResponse):
@@ -182,7 +180,7 @@ class ModlistGalleryService:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
except Exception as e: except Exception as e:
print(f"Error saving cache: {e}") logger.warning("Error saving metadata cache: %s", e)
def _metadata_to_dict(self, metadata: ModlistMetadata) -> dict: def _metadata_to_dict(self, metadata: ModlistMetadata) -> dict:
"""Convert ModlistMetadata to dict for JSON serialization""" """Convert ModlistMetadata to dict for JSON serialization"""
@@ -306,7 +304,7 @@ class ModlistGalleryService:
) )
return result.returncode == 0 return result.returncode == 0
except Exception as e: except Exception as e:
print(f"Error downloading images: {e}") logger.warning("Error downloading gallery images: %s", e)
return False return False
def get_cached_image_path(self, metadata: ModlistMetadata, size: str = "large") -> Optional[Path]: def get_cached_image_path(self, metadata: ModlistMetadata, size: str = "large") -> Optional[Path]:

View File

@@ -104,9 +104,21 @@ class ModlistService(ModlistServiceInstallationMixin):
elif game_type_lower == 'enderal': elif game_type_lower == 'enderal':
raw_modlists = [m for m in raw_modlists if 'enderal' in m.get('game', '').lower()] raw_modlists = [m for m in raw_modlists if 'enderal' in m.get('game', '').lower()]
elif game_type_lower == 'skyrimvr':
raw_modlists = [m for m in raw_modlists if 'skyrim vr' in m.get('game', '').lower()]
elif game_type_lower == 'fallout4vr':
raw_modlists = [m for m in raw_modlists if 'fallout 4 vr' in m.get('game', '').lower()]
elif game_type_lower == 'cp2077':
raw_modlists = [m for m in raw_modlists if 'cyberpunk' in m.get('game', '').lower()]
elif game_type_lower == 'bg3':
raw_modlists = [m for m in raw_modlists if "baldur" in m.get('game', '').lower()]
elif game_type_lower == 'other': elif game_type_lower == 'other':
# Exclude all main category games to show only "Other" games # Exclude all main category games to show only "Other" games
main_category_keywords = ['skyrim', 'fallout 4', 'fallout new vegas', 'oblivion', 'starfield', 'enderal'] main_category_keywords = ['skyrim', 'fallout 4', 'fallout new vegas', 'oblivion', 'starfield', 'enderal', 'cyberpunk', "baldur's gate", 'skyrim vr', 'fallout 4 vr']
def is_main_category(game_name): def is_main_category(game_name):
game_lower = game_name.lower() game_lower = game_name.lower()
return any(keyword in game_lower for keyword in main_category_keywords) return any(keyword in game_lower for keyword in main_category_keywords)

View File

@@ -150,16 +150,17 @@ class ModlistServiceInstallationMixin:
elif context.get('machineid'): elif context.get('machineid'):
cmd += ['-m', context['machineid']] cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str] cmd += ['-o', install_dir_str, '-d', download_dir_str]
if context.get('skip_disk_check'):
cmd.append('--skip-disk-check')
writeback_path = str(auth_service.get_token_writeback_path())
original_env_values = { original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), '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') 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
} }
try: try:
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
if oauth_info: if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService from jackify.backend.services.nexus_oauth_service import NexusOAuthService
@@ -285,6 +286,7 @@ class ModlistServiceInstallationMixin:
_ck_missing = True _ck_missing = True
proc.wait() proc.wait()
auth_service.apply_token_writeback(writeback_path)
if proc.returncode != 0: if proc.returncode != 0:
if output_callback: if output_callback:
output_callback(f"Jackify Install Engine exited with code {proc.returncode}.") output_callback(f"Jackify Install Engine exited with code {proc.returncode}.")

View File

@@ -6,6 +6,7 @@ Unified service for Nexus authentication using OAuth or API key fallback
""" """
import logging import logging
import os
from typing import Optional, Tuple from typing import Optional, Tuple
from .nexus_oauth_service import NexusOAuthService from .nexus_oauth_service import NexusOAuthService
from ..handlers.oauth_token_handler import OAuthTokenHandler from ..handlers.oauth_token_handler import OAuthTokenHandler
@@ -288,6 +289,41 @@ class NexusAuthService:
logger.warning("No authentication available for engine") logger.warning("No authentication available for engine")
return (None, None) return (None, None)
def get_token_writeback_path(self) -> 'Path':
"""Return a PID-unique path where the engine should write back refreshed tokens."""
from pathlib import Path
from jackify.shared.paths import get_jackify_data_dir
return get_jackify_data_dir() / f"oauth_writeback_{os.getpid()}.json"
def apply_token_writeback(self, writeback_path) -> bool:
"""
Read engine-written token writeback file and update local token store.
Called after engine process exits. No-op if file does not exist (engine not yet
supporting writeback, or API key auth was used).
"""
import json
from pathlib import Path
path = Path(writeback_path)
if not path.exists():
return False
try:
data = json.loads(path.read_text())
oauth = data.get('oauth', {})
if oauth.get('access_token') and oauth.get('refresh_token'):
self.token_handler.save_token({'oauth': oauth})
logger.info("Applied OAuth token writeback from engine - refresh token rotation preserved")
return True
logger.debug("Token writeback file present but contains no usable OAuth data")
return False
except Exception as e:
logger.warning("Failed to apply token writeback: %s", e)
return False
finally:
try:
path.unlink(missing_ok=True)
except Exception:
pass
def clear_all_auth(self) -> bool: def clear_all_auth(self) -> bool:
""" """
Clear all authentication (both OAuth and API key) Clear all authentication (both OAuth and API key)

View File

@@ -26,7 +26,7 @@ class NexusPremiumService:
is_oauth: True when auth_token is an OAuth Bearer token. is_oauth: True when auth_token is an OAuth Bearer token.
Returns: Returns:
(is_premium, username) both None/False on failure. (is_premium, username) - both None/False on failure.
""" """
cached = self._read_cache(auth_token, is_oauth=is_oauth) cached = self._read_cache(auth_token, is_oauth=is_oauth)
if cached is not None: if cached is not None:

View File

@@ -20,8 +20,6 @@ def _get_restart_strategy() -> str:
from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.handlers.config_handler import ConfigHandler
strategy = ConfigHandler().get("steam_restart_strategy", STRATEGY_JACKIFY) strategy = ConfigHandler().get("steam_restart_strategy", STRATEGY_JACKIFY)
if strategy == "nak_simple":
strategy = STRATEGY_SIMPLE
if strategy not in (STRATEGY_JACKIFY, STRATEGY_SIMPLE): if strategy not in (STRATEGY_JACKIFY, STRATEGY_SIMPLE):
return STRATEGY_JACKIFY return STRATEGY_JACKIFY
return strategy return strategy
@@ -203,7 +201,7 @@ def is_flatpak_steam() -> bool:
def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool: def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool:
"""Grant Flatpak Steam filesystem access to the parent of the given path. """Grant Flatpak Steam filesystem access to the parent of the given path.
Safe to call on non-Flatpak systems returns True immediately. Safe to call on non-Flatpak systems - returns True immediately.
Skips if the path is already covered by an existing override. Skips if the path is already covered by an existing override.
Returns True if access was already present or successfully granted, False on error. Returns True if access was already present or successfully granted, False on error.
""" """
@@ -212,7 +210,7 @@ def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool:
return True return True
flatpak_cmd = _get_flatpak_command() flatpak_cmd = _get_flatpak_command()
if not flatpak_cmd: if not flatpak_cmd:
logger.warning("Flatpak Steam detected but flatpak command not found cannot grant filesystem access") logger.warning("Flatpak Steam detected but flatpak command not found - cannot grant filesystem access")
return False return False
grant_path = str(_Path(path).parent) grant_path = str(_Path(path).parent)
env = _get_clean_subprocess_env() env = _get_clean_subprocess_env()

View File

@@ -0,0 +1,181 @@
"""
SteamGridDB artwork fetching service.
Fetches top-voted artwork for a game from steamgriddb.com using the
official API. Used as a fallback when a modlist has no SteamIcons/ directory.
PRIVATE: This file contains an obfuscated API key. Do NOT sync to public-src.
"""
import base64
import logging
import urllib.request
import urllib.error
import json
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_BASE_URL = "https://www.steamgriddb.com/api/v2"
# Obfuscated Jackify service key - XOR with mask, base64-encoded.
# Keep this file out of public-src.
_OBF = b"LgRUXwtXTwUEAw02cnR7EHgEVFldXklTUlFQNiQmJBM="
_MSK = b"Jackify2024SGDB!Jackify2024SGDB!"
def _get_api_key() -> str:
raw = base64.b64decode(_OBF)
return bytes(a ^ b for a, b in zip(raw, _MSK)).decode()
# Steam App IDs for each Jackify game type key
GAME_STEAM_APP_IDS = {
"skyrim": "489830",
"skyrimvr": "611670",
"fo4": "377160",
"fallout4vr": "611660",
"fnv": "22380",
"fo3": "22300",
"oblivion": "22330",
"oblivion_remastered": "2623190",
"enderal": "976620",
"starfield": "1716740",
"cp2077": "1091500",
"bg3": "1086940",
}
# Artwork slots: (endpoint_path, query_string, dest_filename)
_ARTWORK_SLOTS = [
("grids", "dimensions=600x900&types=static&nsfw=false", "grid-tall.png"),
("grids", "dimensions=920x430&types=static&nsfw=false", "grid-wide.png"),
("heroes", "dimensions=1920x620&types=static&nsfw=false", "grid-hero.png"),
("logos", "types=static&nsfw=false", "grid-logo.png"),
]
def _api_get(endpoint: str, api_key: str) -> Optional[dict]:
url = f"{_BASE_URL}/{endpoint}"
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": "Jackify/0.6",
})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
logger.warning(f"SteamGridDB API error {e.code} for {url}")
except Exception as e:
logger.warning(f"SteamGridDB request failed for {url}: {e}")
return None
def _download(url: str, dest: Path) -> bool:
try:
req = urllib.request.Request(url, headers={"User-Agent": "Jackify/0.6"})
with urllib.request.urlopen(req, timeout=15) as resp:
dest.write_bytes(resp.read())
return True
except Exception as e:
logger.warning(f"Failed to download {url}: {e}")
return False
def detect_game_type_from_modlist(modlist_dir: str) -> Optional[str]:
"""Read gameName= from ModOrganizer.ini and return the Jackify game type key.
Covers all supported game types. Returns None if the ini cannot be read or
the game is not in GAME_STEAM_APP_IDS.
"""
if not modlist_dir:
return None
try:
from pathlib import Path as _Path
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 None
content = mo2_ini.read_text(errors='ignore').lower()
game_name_value = ""
for _line in content.splitlines():
stripped = _line.strip()
if "=" not in stripped:
continue
key, value = stripped.split("=", 1)
if key.strip().lower() == "gamename":
game_name_value = value.strip()
break
gn = game_name_value.strip()
if gn:
if 'skyrim vr' in gn or 'skyrimvr' in gn:
return "skyrimvr"
if 'fallout 4 vr' in gn or 'fallout4vr' in gn:
return "fallout4vr"
if 'skyrim special edition' in gn:
return "skyrim"
if 'fallout new vegas' in gn or 'falloutnv' in gn or 'new vegas' in gn or gn == 'ttw':
return "fnv"
if 'fallout3' in gn or ('fallout 3' in gn and 'fallout 4' not in gn):
return "fo3"
if 'fallout 4' in gn:
return "fo4"
if 'starfield' in gn:
return "starfield"
if 'oblivion remastered' in gn:
return "oblivion_remastered"
if 'oblivion' in gn:
return "oblivion"
if 'enderal' in gn:
return "enderal"
if 'cyberpunk' in gn or 'cp2077' in gn:
return "cp2077"
if "baldur" in gn or 'bg3' in gn:
return "bg3"
else:
# gameName= absent - fall back to content scan for common markers
if 'skyrim special edition' in content or 'skse64_loader' in content:
return "skyrim"
if 'nvse_loader' in content or 'falloutnv' in content:
return "fnv"
if 'fose_loader' in content:
return "fo3"
if 'f4se_loader' in content:
return "fo4"
if 'baldur' in content or 'bg3' in content:
return "bg3"
if 'cyberpunk' in content or 'cp2077' in content:
return "cp2077"
if 'starfield' in content:
return "starfield"
except Exception as e:
logger.debug(f"detect_game_type_from_modlist failed for {modlist_dir}: {e}")
return None
def fetch_artwork(game_type: str, dest_dir: Path) -> int:
"""
Fetch top-voted artwork for game_type from SteamGridDB into dest_dir.
Returns the number of images successfully downloaded.
dest_dir must already exist.
"""
steam_appid = GAME_STEAM_APP_IDS.get(game_type)
if not steam_appid:
logger.debug(f"No Steam App ID mapping for game type: {game_type}")
return 0
api_key = _get_api_key()
downloaded = 0
for endpoint, query, filename in _ARTWORK_SLOTS:
data = _api_get(f"{endpoint}/steam/{steam_appid}?{query}", api_key)
if not data or not data.get("success") or not data.get("data"):
logger.debug(f"No {endpoint} results for {game_type} ({steam_appid})")
continue
image_url = data["data"][0]["url"]
dest_path = dest_dir / filename
if _download(image_url, dest_path):
logger.info(f"Downloaded {filename} for {game_type} from SteamGridDB")
downloaded += 1
return downloaded

View File

@@ -0,0 +1,600 @@
"""
Tool compatibility configuration service.
Applies Wine registry settings required for modding tools to work correctly
on Linux. Applied automatically during prefix setup and available as a
standalone operation for existing prefixes.
Based on research into NaK's registry configuration (external reference only).
"""
import logging
import os
import subprocess
import tempfile
import urllib.request
from pathlib import Path
from typing import Callable, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Registry content
# ---------------------------------------------------------------------------
# xEdit family executables that require WinXP compatibility mode.
# Wine's default Windows version causes xEdit to fail on certain operations.
_XEDIT_EXECUTABLES = [
"SSEEdit.exe", "SSEEdit64.exe",
"FO4Edit.exe", "FO4Edit64.exe",
"TES4Edit.exe", "TES4Edit64.exe",
"xEdit64.exe",
"SF1Edit64.exe",
"FNVEdit.exe", "FNVEdit64.exe",
"xFOEdit.exe", "xFOEdit64.exe",
"xSFEEdit.exe", "xSFEEdit64.exe",
"xTESEdit.exe", "xTESEdit64.exe",
"FO3Edit.exe", "FO3Edit64.exe",
]
# DLL overrides applied to the prefix globally.
# All set to native,builtin so game/tool-provided DLLs take priority.
_DLL_OVERRIDES = [
"dwrite",
"winmm",
"version",
"dxgi",
"dbghelp",
"d3d12",
"wininet",
"winhttp",
"dinput",
"dinput8",
]
def _build_reg_content() -> str:
lines = ["Windows Registry Editor Version 5.00", ""]
# xEdit WinXP compatibility
for exe in _XEDIT_EXECUTABLES:
lines.append(f"[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\{exe}]")
lines.append('"Version"="winxp"')
lines.append("")
# Pandora Behaviour Engine - decorated window causes UI glitches on Linux
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\Pandora Behaviour Engine+.exe\\X11 Driver]")
lines.append('"Decorated"="N"')
lines.append("")
# Skyrim SE / SKSE game process needs native mscoree to load dotnet4 correctly.
# Scoped to SkyrimSE.exe only so it does not interfere with .NET 9/10 tools
# (Synthesis, SDK host) that run in the same prefix.
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides]")
lines.append('"*mscoree"="native"')
lines.append("")
# Prevent Wine windows from stealing keyboard focus via WM_TAKE_FOCUS.
# Without this, each Wine subprocess launched during winetricks installs
# briefly grabs X11 focus (via XWayland), interrupting whatever the user
# is typing in other applications.
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\X11 Driver]")
lines.append('"UseTakeFocus"="N"')
lines.append("")
# Global DLL overrides
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides]")
for dll in _DLL_OVERRIDES:
lines.append(f'"{dll}"="native,builtin"')
lines.append("")
return "\r\n".join(lines)
# .NET 9 SDK - direct installer, not available via winetricks.
# Synthesis runs on .NET 9; the SDK (not just runtime) is required for patcher compilation.
# Versions match Fluorine's confirmed-working prefix configuration.
_DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.exe"
_DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.exe"
# .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2.
# Covers Synthesis patchers targeting .NET 10 runtime.
_DOTNET10_DESKTOP_URL = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe"
_DOTNET10_DESKTOP_FILENAME = "windowsdesktop-runtime-10.0.2-win-x64.exe"
# DigiCert Universal Root CA - required for NuGet package signature validation.
# Without this, dotnet fails to verify NuGet package signatures when Synthesis
# compiles patchers. Imported into the Wine prefix Windows cert store so no
# system-level changes are needed.
_DIGICERT_CERT_URL = "https://cacerts.digicert.com/DigiCertTrustedRootG4.crt.pem"
_DIGICERT_CERT_FILENAME = "DigiCertTrustedRootG4.crt.pem"
# fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation.
# The winetricks-provided d3dcompiler_47 lacks support for certain shader models
# used by Community Shaders, causing "failed shaders" during compilation.
_FXC2_D3DCOMPILER_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll"
_FXC2_D3DCOMPILER_FILENAME = "fxc2_d3dcompiler_47.dll"
def _install_dotnet9_sdk(
prefix_path: Path,
wine_bin: str,
log: Callable[[str], None],
) -> bool:
"""
Download and install the .NET 9 SDK into the Wine prefix.
Cached to avoid re-downloading on subsequent runs.
"""
try:
from jackify.shared.paths import get_jackify_data_dir
cache_dir = get_jackify_data_dir() / "cache"
cache_dir.mkdir(parents=True, exist_ok=True)
installer = cache_dir / _DOTNET9_SDK_FILENAME
if not installer.exists():
log(f"Downloading .NET 9 SDK ({_DOTNET9_SDK_FILENAME})...")
urllib.request.urlretrieve(_DOTNET9_SDK_URL, installer)
log(".NET 9 SDK downloaded")
else:
log(".NET 9 SDK installer already cached, skipping download")
log("Installing .NET 9 SDK (this may take a few minutes)...")
env = os.environ.copy()
env["WINEPREFIX"] = str(prefix_path)
env["WINEDEBUG"] = "-all"
env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d"
env["DISPLAY"] = env.get("DISPLAY", ":0")
result = subprocess.run(
[wine_bin, str(installer), "/install", "/quiet", "/norestart"],
env=env,
capture_output=True,
text=True,
timeout=600,
)
if result.returncode not in (0, 3010): # 3010 = success, reboot required
log(f".NET 9 SDK installer exited with code {result.returncode}")
return False
log(".NET 9 SDK installed successfully")
return True
except Exception as e:
log(f"Failed to install .NET 9 SDK: {e}")
return False
def _install_dotnet10_desktop_runtime(
prefix_path: Path,
wine_bin: str,
log: Callable[[str], None],
) -> bool:
"""
Download and install the .NET Desktop Runtime 10 into the Wine prefix.
Provides NETCore.App and WindowsDesktop.App 10.x for patchers targeting .NET 10.
"""
try:
from jackify.shared.paths import get_jackify_data_dir
cache_dir = get_jackify_data_dir() / "cache"
cache_dir.mkdir(parents=True, exist_ok=True)
installer = cache_dir / _DOTNET10_DESKTOP_FILENAME
if not installer.exists():
log(f"Downloading .NET Desktop Runtime 10 ({_DOTNET10_DESKTOP_FILENAME})...")
urllib.request.urlretrieve(_DOTNET10_DESKTOP_URL, installer)
log(".NET Desktop Runtime 10 downloaded")
else:
log(".NET Desktop Runtime 10 already cached, skipping download")
log("Installing .NET Desktop Runtime 10...")
env = os.environ.copy()
env["WINEPREFIX"] = str(prefix_path)
env["WINEDEBUG"] = "-all"
env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d"
env["DISPLAY"] = env.get("DISPLAY", ":0")
result = subprocess.run(
[wine_bin, str(installer), "/install", "/quiet", "/norestart"],
env=env,
capture_output=True,
text=True,
timeout=300,
)
if result.returncode not in (0, 3010):
log(f".NET Desktop Runtime 10 installer exited with code {result.returncode}")
return False
log(".NET Desktop Runtime 10 installed successfully")
return True
except Exception as e:
log(f"Failed to install .NET Desktop Runtime 10: {e}")
return False
def _install_nuget_cert(
prefix_path: Path,
wine_bin: str,
log: Callable[[str], None],
) -> bool:
"""
Import the DigiCert Trusted Root G4 CA into the Wine prefix Windows cert
store. Required for NuGet package signature validation when Synthesis
compiles patchers. Uses wine certutil so no system-level changes are needed.
"""
try:
from jackify.shared.paths import get_jackify_data_dir
cache_dir = get_jackify_data_dir() / "cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cert_file = cache_dir / _DIGICERT_CERT_FILENAME
if not cert_file.exists():
log(f"Downloading DigiCert Trusted Root G4 certificate...")
urllib.request.urlretrieve(_DIGICERT_CERT_URL, cert_file)
log("Certificate downloaded")
else:
log("DigiCert certificate already cached, skipping download")
log("Importing certificate into Wine prefix cert store...")
env = os.environ.copy()
env["WINEPREFIX"] = str(prefix_path)
env["WINEDEBUG"] = "-all"
env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d"
env["DISPLAY"] = env.get("DISPLAY", ":0")
result = subprocess.run(
[wine_bin, "certutil", "-addstore", "Root", str(cert_file)],
env=env,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
log(f"certutil exited with code {result.returncode} (may already be installed)")
else:
log("DigiCert certificate imported into Wine cert store")
return True
except Exception as e:
log(f"Failed to install NuGet certificate: {e}")
return False
def _install_fxc2_d3dcompiler(
prefix_path: Path,
log: Callable[[str], None],
) -> bool:
"""
Replace the winetricks-installed d3dcompiler_47.dll with the Mozilla fxc2
build, which supports shader models required by Community Shaders.
Applies to both system32 (64-bit) and syswow64 (32-bit) locations.
"""
try:
from jackify.shared.paths import get_jackify_data_dir
cache_dir = get_jackify_data_dir() / "cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cached_dll = cache_dir / _FXC2_D3DCOMPILER_FILENAME
if not cached_dll.exists():
log("Downloading fxc2 d3dcompiler_47.dll...")
urllib.request.urlretrieve(_FXC2_D3DCOMPILER_URL, cached_dll)
log("fxc2 d3dcompiler_47.dll downloaded")
else:
log("fxc2 d3dcompiler_47.dll already cached, skipping download")
import shutil
targets = [
prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll",
prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll",
]
for target in targets:
if target.parent.exists():
shutil.copy2(cached_dll, target)
log(f"Installed fxc2 d3dcompiler_47.dll -> {target.parent.name}")
return True
except Exception as e:
log(f"Failed to install fxc2 d3dcompiler_47.dll (non-fatal): {e}")
return False
def _set_windows_version_win11(
prefix_path: Path,
wine_bin: str,
log: Callable[[str], None],
) -> None:
"""
Set the Wine prefix Windows version to Windows 11.
Matches Fluorine's prefix configuration; required for .NET 9/10 to run
correctly. winetricks components may leave the prefix at a lower version.
"""
try:
from pathlib import Path as _Path
module_dir = _Path(__file__).parent.parent.parent
winetricks_bin = str(module_dir / "tools" / "winetricks")
if not os.path.exists(winetricks_bin):
appdir = os.environ.get("APPDIR", "")
if appdir:
winetricks_bin = os.path.join(appdir, "opt", "jackify", "tools", "winetricks")
if not os.path.exists(winetricks_bin):
log("Bundled winetricks not found - skipping Windows version update")
return
log("Setting Windows version to Windows 11...")
env = os.environ.copy()
env["WINEPREFIX"] = str(prefix_path)
env["WINE"] = wine_bin
env["WINEDEBUG"] = "-all"
env["DISPLAY"] = env.get("DISPLAY", ":0")
result = subprocess.run(
[winetricks_bin, "-q", "win11"],
env=env,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
log(f"winetricks win11 exited with code {result.returncode} (non-fatal)")
else:
log("Windows version set to Windows 11")
except subprocess.TimeoutExpired:
log("winetricks win10 timed out (non-fatal)")
except Exception as e:
log(f"Failed to set Windows version: {e} (non-fatal)")
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
def apply_tool_config(
compatdata_path: str,
wine_bin: str,
log: Optional[Callable[[str], None]] = None,
install_dotnet9_sdk: bool = False,
install_fxc2_d3dcompiler: bool = False,
) -> bool:
"""
Apply tool compatibility settings to the Wine prefix.
install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK, which is
required for Synthesis. Intentionally opt-in - the download is ~220MB and
only appropriate when the user explicitly runs Configure Tool Compatibility
from Additional Tasks.
install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla
fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders.
Returns True if registry settings applied successfully (dotnet SDK install
failures are non-fatal since the registry settings still have value).
"""
def _log(msg: str):
logger.info(msg)
if log:
log(msg)
prefix_path = Path(compatdata_path) / "pfx"
if not prefix_path.exists():
_log(f"Wine prefix not found at {prefix_path}")
return False
if install_fxc2_d3dcompiler:
_install_fxc2_d3dcompiler(prefix_path, _log)
if install_dotnet9_sdk:
_install_dotnet9_sdk(prefix_path, wine_bin, _log)
_install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log)
_install_nuget_cert(prefix_path, wine_bin, _log)
_set_windows_version_win11(prefix_path, wine_bin, _log)
# Remove legacy global *mscoree=native from DllOverrides if present.
# Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis).
# The targeted AppDefaults\SkyrimSE.exe entry written below replaces it.
try:
env_clean = os.environ.copy()
env_clean["WINEPREFIX"] = str(prefix_path)
env_clean["WINEDEBUG"] = "-all"
env_clean["DISPLAY"] = env_clean.get("DISPLAY", ":0")
subprocess.run(
[wine_bin, "reg", "delete",
"HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides",
"/v", "*mscoree", "/f"],
env=env_clean, capture_output=True, text=True, timeout=15,
)
_log("Removed legacy global *mscoree override (if present)")
except Exception as e:
_log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}")
reg_content = _build_reg_content()
try:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".reg", delete=False, encoding="utf-8"
) as tf:
tf.write(reg_content)
reg_file = tf.name
_log("Applying tool compatibility registry settings...")
env = os.environ.copy()
env["WINEPREFIX"] = str(prefix_path)
env["WINEDEBUG"] = "-all"
env["DISPLAY"] = env.get("DISPLAY", ":0")
result = subprocess.run(
[wine_bin, "regedit", reg_file],
env=env,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
_log(f"wine regedit exited with code {result.returncode}: {result.stderr[:200]}")
return False
_log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)")
return True
except subprocess.TimeoutExpired:
_log("wine regedit timed out after 30 seconds")
return False
except Exception as e:
_log(f"Failed to apply tool config: {e}")
return False
finally:
try:
os.unlink(reg_file)
except Exception:
pass
def setup_nemesis_compatibility(
modlist_dir: str,
stock_game_path: Optional[str],
log: Optional[Callable[[str], None]] = None,
) -> None:
"""
Prepare Nemesis Unlimited Behavior Engine to run correctly on Linux.
Two issues affect Nemesis under Wine/MO2 on Linux:
1. Nemesis resolves a relative `mods` path against the filesystem root,
causing a "cannot access /mods" error. Symlinking Nemesis_Engine from
the mod directory into the real Data directory fixes this.
2. A non-blank "Start In" (workingDirectory) in ModOrganizer.ini causes
Nemesis to hang. Blank it out for the Nemesis executable entry.
Non-fatal - logs failures but does not raise.
"""
def _log(msg: str):
logger.info(msg)
if log:
log(msg)
modlist_path = Path(modlist_dir)
mods_dir = modlist_path / "mods"
if not mods_dir.is_dir():
_log("Nemesis setup: mods directory not found, skipping")
return
# Find the Nemesis_Engine directory inside the mods tree
nemesis_engine_src: Optional[Path] = None
try:
for mod_dir in mods_dir.iterdir():
candidate = mod_dir / "Nemesis_Engine"
if candidate.is_dir():
nemesis_engine_src = candidate
break
except Exception as e:
_log(f"Nemesis setup: error scanning mods directory: {e}")
return
if nemesis_engine_src is None:
_log("Nemesis setup: Nemesis_Engine not found in mods - modlist may not include Nemesis")
return
# Create symlink in Data/ so Nemesis can find its engine at a predictable path
if stock_game_path:
data_dir = Path(stock_game_path) / "Data"
try:
data_dir.mkdir(parents=True, exist_ok=True)
symlink_path = data_dir / "Nemesis_Engine"
if symlink_path.is_symlink():
existing_target = symlink_path.resolve()
if existing_target == nemesis_engine_src.resolve():
_log("Nemesis setup: symlink already correct, skipping")
else:
symlink_path.unlink()
symlink_path.symlink_to(nemesis_engine_src)
_log(f"Nemesis setup: updated symlink at {symlink_path}")
elif symlink_path.exists():
_log(f"Nemesis setup: {symlink_path} exists and is not a symlink - leaving it alone")
else:
symlink_path.symlink_to(nemesis_engine_src)
_log(f"Nemesis setup: created symlink {symlink_path} -> {nemesis_engine_src}")
except Exception as e:
_log(f"Nemesis setup: failed to create symlink: {e}")
else:
_log("Nemesis setup: no stock game path available - skipping symlink")
# Blank workingDirectory for the Nemesis executable in ModOrganizer.ini
mo2_ini = modlist_path / "ModOrganizer.ini"
if not mo2_ini.is_file():
_log("Nemesis setup: ModOrganizer.ini not found, skipping workingDirectory fix")
return
try:
content = mo2_ini.read_text(encoding="utf-8")
except Exception as e:
_log(f"Nemesis setup: could not read ModOrganizer.ini: {e}")
return
import re
# Find all executable indices whose binary points to Nemesis
nemesis_indices = re.findall(
r'^(\d+)\\binary=.*Nemesis Unlimited Behavior Engine\.exe',
content,
re.MULTILINE | re.IGNORECASE,
)
if not nemesis_indices:
_log("Nemesis setup: no Nemesis executable entry found in ModOrganizer.ini")
return
modified = content
changed = 0
for idx in nemesis_indices:
# Replace non-blank workingDirectory for this index
pattern = rf'^({re.escape(idx)}\\workingDirectory=).+$'
replacement = rf'\g<1>'
new_content, n = re.subn(pattern, replacement, modified, flags=re.MULTILINE)
if n:
modified = new_content
changed += n
if changed:
try:
mo2_ini.write_text(modified, encoding="utf-8")
_log(f"Nemesis setup: blanked workingDirectory for {len(nemesis_indices)} Nemesis executable entry(s) in ModOrganizer.ini")
except Exception as e:
_log(f"Nemesis setup: failed to write ModOrganizer.ini: {e}")
else:
_log("Nemesis setup: workingDirectory already blank for all Nemesis entries")
def apply_tool_config_for_appid(
appid: str,
log: Optional[Callable[[str], None]] = None,
install_dotnet9_sdk: bool = True,
) -> bool:
"""
Resolve compatdata path and wine binary from an AppID, then apply tool config.
Convenience wrapper for the standalone Additional Tasks flow.
"""
def _log(msg: str):
logger.info(msg)
if log:
log(msg)
try:
from jackify.backend.handlers.wine_utils_proton import WineUtilsProtonMixin
compatdata_path, _, wine_bin = WineUtilsProtonMixin.get_proton_paths(appid)
except Exception as e:
_log(f"Could not resolve Proton paths for AppID {appid}: {e}")
return False
if not compatdata_path or not wine_bin:
_log(f"Could not resolve Wine prefix for AppID {appid}. Is this modlist configured in Steam?")
return False
return apply_tool_config(compatdata_path, wine_bin, log, install_dotnet9_sdk=install_dotnet9_sdk, install_fxc2_d3dcompiler=True)

View File

@@ -0,0 +1,503 @@
"""
Third-party tool registry.
Manages install, update, downgrade, and uninstall of independently-versioned
tools that Jackify either invokes directly (Tier 1) or makes available for users
to run from MO2 (Tier 2).
Each tool stores a manifest at:
$jackify_data_dir/tools/<tool_id>/manifest.json
TTW_Linux_Installer is a special case: it has a pre-existing handler with its
own config keys. The registry reads those keys for status display and delegates
install/update to the existing handler rather than managing storage itself.
"""
import json
import logging
import os
import re
import tarfile
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import requests
from jackify.shared.paths import get_jackify_data_dir
logger = logging.getLogger(__name__)
TOOLS_BASE_DIR = get_jackify_data_dir() / "tools"
GITHUB_API = "https://api.github.com/repos/{repo}/releases/{ref}"
@dataclass
class ToolDefinition:
tool_id: str
display_name: str
description: str
github_repo: str # e.g. "SulfurNitride/CLF3"
asset_patterns: List[str] # ordered list of regex patterns to match release asset filename
tier: int # 1 = Jackify invokes it, 2 = user runs it themselves
executable_names: List[str] = field(default_factory=list)
pinned_version: Optional[str] = None # None = always use latest
can_uninstall: bool = True # False for tools Jackify hard-depends on
@dataclass
class ToolStatus:
definition: ToolDefinition
installed: bool
installed_version: Optional[str]
previous_version: Optional[str]
binary_path: Optional[Path]
latest_version: Optional[str] = None
update_available: bool = False
@property
def can_downgrade(self) -> bool:
prev_dir = TOOLS_BASE_DIR / self.definition.tool_id / "_previous"
return self.previous_version is not None and prev_dir.exists()
# ---------------------------------------------------------------------------
# Tool catalogue
# ---------------------------------------------------------------------------
TOOL_DEFINITIONS: List[ToolDefinition] = [
ToolDefinition(
tool_id="ttw_installer",
display_name="TTW Linux Installer",
description="Automates Tale of Two Wastelands installation on Linux. Required for the TTW workflow.",
github_repo="SulfurNitride/TTW_Linux_Installer",
asset_patterns=[r"universal-mpi-installer.*\.(zip|tar\.gz)"],
executable_names=["mpi_installer", "ttw_linux_gui"],
tier=1,
can_uninstall=False,
),
ToolDefinition(
tool_id="clf3",
display_name="CLF3",
description="Rust-based Wabbajack file handler. Planned as an experimental engine alternative.",
github_repo="SulfurNitride/CLF3",
asset_patterns=[r"clf3.*linux.*x86_64", r"clf3.*\.tar\.gz", r"clf3.*\.zip"],
executable_names=["clf3"],
tier=1,
can_uninstall=True,
),
ToolDefinition(
tool_id="fluorine",
display_name="Fluorine Manager",
description="Linux-native MO2 port with FUSE-based VFS and built-in Rootbuilder support.",
github_repo="SulfurNitride/Fluorine-Manager",
asset_patterns=[r"fluorine.*\.appimage", r"fluorine.*\.tar\.gz", r"fluorine.*\.zip"],
executable_names=["Fluorine", "fluorine"],
tier=2,
),
ToolDefinition(
tool_id="bodyslide",
display_name="BodySlide (Linux Port)",
description="BodySlide and Outfit Studio ported to Linux. For body/outfit mesh conversion.",
github_repo="SulfurNitride/BodySlide-and-Outfit-Studio-Linux-Port",
asset_patterns=[r"bodyslide.*linux.*\.(appimage|tar\.gz|zip)", r".*bodyslide.*\.(tar\.gz|zip)"],
executable_names=["BodySlide", "BodySlide_x64"],
tier=2,
),
ToolDefinition(
tool_id="radium",
display_name="Radium Textures",
description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation.",
github_repo="SulfurNitride/Radium-Textures",
asset_patterns=[r"radium.*linux.*x86_64", r"radium.*\.tar\.gz", r"radium.*\.zip"],
executable_names=["radium", "radium-textures"],
tier=2,
),
]
_TOOL_MAP: Dict[str, ToolDefinition] = {t.tool_id: t for t in TOOL_DEFINITIONS}
# ---------------------------------------------------------------------------
# Manifest helpers
# ---------------------------------------------------------------------------
def _manifest_path(tool_id: str) -> Path:
return TOOLS_BASE_DIR / tool_id / "manifest.json"
def _read_manifest(tool_id: str) -> dict:
mp = _manifest_path(tool_id)
if mp.exists():
try:
return json.loads(mp.read_text())
except Exception:
pass
return {}
def _write_manifest(tool_id: str, data: dict) -> None:
mp = _manifest_path(tool_id)
mp.parent.mkdir(parents=True, exist_ok=True)
mp.write_text(json.dumps(data, indent=2))
# ---------------------------------------------------------------------------
# TTW bridge - reads existing config keys written by TTWInstallerHandler
# ---------------------------------------------------------------------------
def _ttw_status_from_config() -> Tuple[bool, Optional[str], Optional[Path]]:
"""Return (installed, version, binary_path) by reading TTWInstallerHandler config."""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
cfg = ConfigHandler()
version = cfg.get("ttw_installer_version")
install_path_str = cfg.get("ttw_installer_install_path")
if not install_path_str:
return False, None, None
install_dir = Path(install_path_str)
for exe_name in ["mpi_installer", "ttw_linux_gui"]:
exe = install_dir / exe_name
if exe.is_file():
return True, str(version) if version else None, exe
return False, None, None
except Exception as e:
logger.debug("TTW config read failed: %s", e)
return False, None, None
# ---------------------------------------------------------------------------
# GitHub release fetching
# ---------------------------------------------------------------------------
def fetch_latest_release_info(github_repo: str, pinned_version: Optional[str] = None) -> Optional[dict]:
"""Fetch release metadata from GitHub API. Returns parsed JSON or None on failure."""
if pinned_version:
tags = [pinned_version, f"v{pinned_version}"] if not pinned_version.startswith("v") else [pinned_version]
for tag in tags:
url = GITHUB_API.format(repo=github_repo, ref=f"tags/{tag}")
try:
resp = requests.get(url, timeout=10, verify=True)
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.debug("GitHub fetch error for %s@%s: %s", github_repo, tag, e)
return None
url = GITHUB_API.format(repo=github_repo, ref="latest")
try:
resp = requests.get(url, timeout=10, verify=True)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.debug("GitHub fetch error for %s: %s", github_repo, e)
return None
def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]:
assets = release_data.get("assets", [])
for pattern in asset_patterns:
for asset in assets:
if re.search(pattern, asset.get("name", ""), re.IGNORECASE):
return asset
return None
# ---------------------------------------------------------------------------
# Core install logic (shared across all non-TTW tools)
# ---------------------------------------------------------------------------
def _download_and_extract(tool_id: str, asset: dict, target_dir: Path) -> Tuple[bool, str]:
"""Download a release asset and extract it into target_dir."""
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fs = FileSystemHandler()
asset_name = asset.get("name", "")
download_url = asset.get("browser_download_url", "")
if not download_url:
return False, "Asset has no download URL"
temp_path = target_dir / asset_name
logger.info("Downloading %s", asset_name)
if not fs.download_file(download_url, temp_path, overwrite=True, quiet=True):
return False, f"Download failed: {asset_name}"
try:
name_lower = asset_name.lower()
is_archive = False
if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
is_archive = True
with tarfile.open(temp_path, "r:gz") as tf:
tf.extractall(path=target_dir)
elif name_lower.endswith(".zip"):
is_archive = True
with zipfile.ZipFile(temp_path, "r") as zf:
zf.extractall(path=target_dir)
elif name_lower.endswith(".appimage"):
temp_path.chmod(0o755)
else:
return False, f"Unsupported archive format: {asset_name}"
finally:
if is_archive:
try:
temp_path.unlink(missing_ok=True)
except Exception:
pass
return True, ""
def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]:
for exe_name in tool_def.executable_names:
direct = search_dir / exe_name
if direct.is_file():
return direct
for found in search_dir.rglob(exe_name):
if found.is_file():
return found
# AppImage pattern
for found in search_dir.rglob(f"{exe_name}*.AppImage"):
if found.is_file():
return found
return None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
class ToolRegistry:
"""Read/write interface to the managed tool store."""
def get_status(self, tool_id: str) -> Optional[ToolStatus]:
defn = _TOOL_MAP.get(tool_id)
if defn is None:
return None
return self._build_status(defn)
def get_all_statuses(self) -> List[ToolStatus]:
return [self._build_status(d) for d in TOOL_DEFINITIONS]
def check_latest_version(self, tool_id: str) -> Optional[str]:
"""Fetch latest tag from GitHub. Returns tag string or None."""
defn = _TOOL_MAP.get(tool_id)
if defn is None:
return None
data = fetch_latest_release_info(defn.github_repo, defn.pinned_version)
if data:
return data.get("tag_name") or data.get("name")
return None
def install(self, tool_id: str) -> Tuple[bool, str]:
defn = _TOOL_MAP.get(tool_id)
if defn is None:
return False, f"Unknown tool: {tool_id}"
if tool_id == "ttw_installer":
return self._install_ttw()
install_dir = TOOLS_BASE_DIR / tool_id
install_dir.mkdir(parents=True, exist_ok=True)
data = fetch_latest_release_info(defn.github_repo, defn.pinned_version)
if not data:
return False, f"Could not fetch release info for {defn.display_name}"
asset = _find_asset(data, defn.asset_patterns)
if not asset:
all_names = [a.get("name", "") for a in data.get("assets", [])]
return False, f"No matching asset found. Available: {', '.join(all_names)}"
tag = data.get("tag_name") or data.get("name", "unknown")
ok, err = _download_and_extract(tool_id, asset, install_dir)
if not ok:
return False, err
exe_path = _find_executable(defn, install_dir)
if exe_path:
try:
os.chmod(exe_path, 0o755)
except Exception:
pass
manifest = _read_manifest(tool_id)
_write_manifest(tool_id, {
"installed_version": tag,
"previous_version": manifest.get("installed_version"),
"binary_path": str(exe_path) if exe_path else None,
"install_dir": str(install_dir),
})
logger.info("Installed %s %s", defn.display_name, tag)
return True, f"{defn.display_name} {tag} installed"
def update(self, tool_id: str) -> Tuple[bool, str]:
"""Update to latest release. Saves current as previous for downgrade."""
defn = _TOOL_MAP.get(tool_id)
if defn is None:
return False, f"Unknown tool: {tool_id}"
if tool_id == "ttw_installer":
return self._install_ttw()
manifest = _read_manifest(tool_id)
current_dir = TOOLS_BASE_DIR / tool_id
prev_dir = TOOLS_BASE_DIR / tool_id / "_previous"
# Back up current install before overwriting
if current_dir.exists() and manifest.get("installed_version"):
import shutil
try:
if prev_dir.exists():
shutil.rmtree(prev_dir)
# Copy current files (excluding _previous subdir) to _previous
prev_dir.mkdir(parents=True, exist_ok=True)
for item in current_dir.iterdir():
if item.name == "_previous":
continue
dest = prev_dir / item.name
if item.is_file():
shutil.copy2(item, dest)
elif item.is_dir():
shutil.copytree(item, dest)
except Exception as e:
logger.warning("Could not back up previous version of %s: %s", tool_id, e)
ok, msg = self.install(tool_id)
if ok and manifest.get("installed_version"):
# Preserve previous_version in manifest (install() sets it from current manifest)
updated_manifest = _read_manifest(tool_id)
updated_manifest["previous_version"] = manifest.get("installed_version")
_write_manifest(tool_id, updated_manifest)
return ok, msg
def downgrade(self, tool_id: str) -> Tuple[bool, str]:
"""Swap current install with the backed-up previous version."""
defn = _TOOL_MAP.get(tool_id)
if defn is None:
return False, f"Unknown tool: {tool_id}"
if tool_id == "ttw_installer":
return False, "Downgrade not supported for TTW Linux Installer via this interface"
import shutil
current_dir = TOOLS_BASE_DIR / tool_id
prev_dir = TOOLS_BASE_DIR / tool_id / "_previous"
if not prev_dir.exists():
return False, f"No previous version stored for {defn.display_name}"
manifest = _read_manifest(tool_id)
current_version = manifest.get("installed_version")
previous_version = manifest.get("previous_version")
# Swap: move current out, move previous in
swap_dir = TOOLS_BASE_DIR / tool_id / "_swap"
try:
if swap_dir.exists():
shutil.rmtree(swap_dir)
swap_dir.mkdir(parents=True)
for item in current_dir.iterdir():
if item.name in ("_previous", "_swap"):
continue
shutil.move(str(item), str(swap_dir / item.name))
for item in prev_dir.iterdir():
shutil.move(str(item), str(current_dir / item.name))
# Put what was current into _previous
if prev_dir.exists():
shutil.rmtree(prev_dir)
prev_dir.mkdir()
for item in swap_dir.iterdir():
shutil.move(str(item), str(prev_dir / item.name))
shutil.rmtree(swap_dir, ignore_errors=True)
except Exception as e:
return False, f"Downgrade failed: {e}"
exe_path = _find_executable(defn, current_dir)
if exe_path:
try:
os.chmod(exe_path, 0o755)
except Exception:
pass
_write_manifest(tool_id, {
"installed_version": previous_version,
"previous_version": current_version,
"binary_path": str(exe_path) if exe_path else None,
"install_dir": str(current_dir),
})
logger.info("Downgraded %s from %s to %s", defn.display_name, current_version, previous_version)
return True, f"{defn.display_name} downgraded to {previous_version}"
def uninstall(self, tool_id: str) -> Tuple[bool, str]:
defn = _TOOL_MAP.get(tool_id)
if defn is None:
return False, f"Unknown tool: {tool_id}"
if not defn.can_uninstall:
return False, f"{defn.display_name} cannot be uninstalled - Jackify depends on it"
import shutil
tool_dir = TOOLS_BASE_DIR / tool_id
if tool_dir.exists():
try:
shutil.rmtree(tool_dir)
except Exception as e:
return False, f"Uninstall failed: {e}"
logger.info("Uninstalled %s", defn.display_name)
return True, f"{defn.display_name} uninstalled"
def get_binary_path(self, tool_id: str) -> Optional[Path]:
"""Return the installed binary path for a Tier 1 tool, or None."""
if tool_id == "ttw_installer":
_, _, binary = _ttw_status_from_config()
return binary
manifest = _read_manifest(tool_id)
bp = manifest.get("binary_path")
if bp:
p = Path(bp)
if p.is_file():
return p
return None
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _build_status(self, defn: ToolDefinition) -> ToolStatus:
if defn.tool_id == "ttw_installer":
installed, version, binary = _ttw_status_from_config()
return ToolStatus(
definition=defn,
installed=installed,
installed_version=version,
previous_version=None,
binary_path=binary,
)
manifest = _read_manifest(defn.tool_id)
installed_version = manifest.get("installed_version")
binary_path_str = manifest.get("binary_path")
binary_path = Path(binary_path_str) if binary_path_str else None
installed = installed_version is not None and (binary_path is None or binary_path.is_file())
return ToolStatus(
definition=defn,
installed=installed,
installed_version=installed_version,
previous_version=manifest.get("previous_version"),
binary_path=binary_path,
)
def _install_ttw(self) -> Tuple[bool, str]:
"""Delegate TTW install to the existing handler."""
try:
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.config_handler import ConfigHandler
fs = FileSystemHandler()
cfg = ConfigHandler()
handler = TTWInstallerHandler(
steamdeck=False, verbose=False,
filesystem_handler=fs, config_handler=cfg,
)
return handler.install_ttw_installer()
except Exception as e:
return False, f"TTW install failed: {e}"

View File

@@ -7,7 +7,9 @@ and coordinating the update process.
import logging import logging
import os import os
import shutil
import subprocess import subprocess
import tempfile
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -32,6 +34,7 @@ class UpdateInfo:
file_size: Optional[int] = None file_size: Optional[int] = None
is_critical: bool = False is_critical: bool = False
is_delta_update: bool = False is_delta_update: bool = False
github_download_url: Optional[str] = None
class UpdateService: class UpdateService:
@@ -98,7 +101,7 @@ class UpdateService:
break break
if download_url: if download_url:
# Prefer Nexus CDN for Premium users if this version is available there github_url = download_url
nexus_url = self._try_nexus_download_url(latest_version) nexus_url = self._try_nexus_download_url(latest_version)
update_source = "github" update_source = "github"
if nexus_url: if nexus_url:
@@ -108,16 +111,13 @@ class UpdateService:
else: else:
logger.info("Update source: GitHub Releases (version %s)", latest_version) logger.info("Update source: GitHub Releases (version %s)", latest_version)
# Determine if this is a delta update
is_delta = '.delta' in download_url or 'delta' in download_url.lower() is_delta = '.delta' in download_url or 'delta' in download_url.lower()
# Safety checks to prevent segfault
try: try:
# Sanitize string fields
safe_version = str(latest_version) if latest_version else "" safe_version = str(latest_version) if latest_version else ""
safe_tag = str(release_data.get('tag_name', '')) safe_tag = str(release_data.get('tag_name', ''))
safe_date = str(release_data.get('published_at', '')) safe_date = str(release_data.get('published_at', ''))
safe_changelog = str(release_data.get('body', ''))[:1000] # Limit size safe_changelog = str(release_data.get('body', ''))[:1000]
safe_url = str(download_url) safe_url = str(download_url)
logger.debug(f"Creating UpdateInfo for version {safe_version}") logger.debug(f"Creating UpdateInfo for version {safe_version}")
@@ -131,6 +131,7 @@ class UpdateService:
file_size=file_size, file_size=file_size,
is_delta_update=is_delta, is_delta_update=is_delta,
source=update_source, source=update_source,
github_download_url=str(github_url),
) )
logger.debug(f"UpdateInfo created successfully") logger.debug(f"UpdateInfo created successfully")
@@ -159,6 +160,13 @@ class UpdateService:
and return a CDN download URL for the file matching target_version. and return a CDN download URL for the file matching target_version.
Returns None on any failure or if the version is not yet on Nexus. Returns None on any failure or if the version is not yet on Nexus.
""" """
try:
from jackify.backend.handlers.config_handler import ConfigHandler
if ConfigHandler().get('force_github_updates', False):
logger.info("Nexus update source bypassed: force_github_updates is enabled")
return None
except Exception:
pass
try: try:
from jackify.backend.services.nexus_auth_service import NexusAuthService from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService() auth_service = NexusAuthService()
@@ -304,30 +312,35 @@ class UpdateService:
def download_update(self, update_info: UpdateInfo, def download_update(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]: progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
""" """
Download update using full AppImage replacement. Download update AppImage. Falls back to GitHub if the primary source fails.
Since we can't rely on external tools being available, we use a reliable
full replacement approach that works on all systems without dependencies.
Args:
update_info: Information about the update to download
progress_callback: Optional callback for download progress (bytes_downloaded, total_bytes)
Returns:
Path to downloaded file, or None if download failed
""" """
try: logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source)
logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source) result = self._download_update_manual(update_info, progress_callback)
result = self._download_update_manual(update_info, progress_callback) if result:
if result: logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result)
logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result)
else:
logger.error("Update download failed: %s from %s", update_info.version, update_info.source)
return result return result
except Exception as e: # Primary source failed - fall back to GitHub if we came from Nexus
logger.error(f"Failed to download update: {e}") if update_info.source == "nexus" and update_info.github_download_url:
return None logger.warning("Nexus download failed, falling back to GitHub")
fallback = UpdateInfo(
version=update_info.version,
tag_name=update_info.tag_name,
release_date=update_info.release_date,
changelog=update_info.changelog,
download_url=update_info.github_download_url,
source="github",
file_size=update_info.file_size,
is_delta_update=False,
github_download_url=update_info.github_download_url,
)
result = self._download_update_manual(fallback, progress_callback)
if result:
logger.info("Update download complete via GitHub fallback: %s -> %s", update_info.version, result)
return result
logger.error("Update download failed: %s", update_info.version)
return None
def _download_update_manual(self, update_info: UpdateInfo, def _download_update_manual(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]: progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
@@ -414,27 +427,41 @@ class UpdateService:
return None return None
def _extract_appimage_from_7z(self, archive: Path, dest_dir: Path, version: str) -> Optional[Path]: def _extract_appimage_from_7z(self, archive: Path, dest_dir: Path, version: str) -> Optional[Path]:
"""Extract Jackify.AppImage from a 7z archive into dest_dir.""" """Extract AppImage from a 7z archive into dest_dir."""
seven_z = self._get_bundled_7z_path() seven_z = self._get_bundled_7z_path()
if not seven_z: if not seven_z:
logger.error("Bundled 7z not found, cannot extract update archive") logger.error("Bundled 7z not found, cannot extract update archive")
return None return None
out_path = dest_dir / f"Jackify-{version}.AppImage" out_path = dest_dir / f"Jackify-{version}.AppImage"
if out_path.exists():
out_path.unlink()
tmp_dir = Path(tempfile.mkdtemp(dir=dest_dir))
try: try:
result = subprocess.run( result = subprocess.run(
[str(seven_z), 'e', str(archive), 'Jackify.AppImage', f'-o{dest_dir}', '-y'], [str(seven_z), 'e', str(archive), f'-o{tmp_dir}', '-y'],
capture_output=True, text=True, timeout=120 capture_output=True, text=True, timeout=120
) )
extracted = dest_dir / 'Jackify.AppImage' if result.returncode != 0:
if result.returncode != 0 or not extracted.exists():
logger.error("7z extraction failed (rc=%d): %s", result.returncode, result.stderr.strip()) logger.error("7z extraction failed (rc=%d): %s", result.returncode, result.stderr.strip())
return None return None
extracted.rename(out_path) candidates = list(tmp_dir.glob('*.AppImage'))
logger.info("Extracted AppImage from archive: %s", out_path) if not candidates:
logger.error("No .AppImage found in archive contents: %s",
[p.name for p in tmp_dir.iterdir()])
return None
extracted = candidates[0]
logger.debug("Found %s in archive (%d bytes)", extracted.name, extracted.stat().st_size)
shutil.move(str(extracted), str(out_path))
if not out_path.exists():
logger.error("AppImage missing after move to %s", out_path)
return None
logger.info("Extracted AppImage to %s (%d bytes)", out_path, out_path.stat().st_size)
return out_path return out_path
except Exception as e: except Exception as e:
logger.error("Exception during 7z extraction: %s", e) logger.error("Exception during 7z extraction: %s", e)
return None return None
finally:
shutil.rmtree(str(tmp_dir), ignore_errors=True)
def apply_update(self, new_appimage_path: Path) -> bool: def apply_update(self, new_appimage_path: Path) -> bool:
""" """

View File

@@ -6,7 +6,7 @@ import re
from typing import Optional from typing import Optional
# Matches CC content file names: ccXXXsse001-name.bsa/esm/esl/esp, ccXXXfo4001-name.ba2, etc. # Matches CC content file names: ccXXXsse001-name.bsa/esm/esl/esp, ccXXXfo4001-name.ba2, etc.
# No leading \b filenames often appear with a Data_ prefix (Data_ccbgssse019-...) # No leading \b - filenames often appear with a Data_ prefix (Data_ccbgssse019-...)
# where _ is a word char and would prevent \b from matching. # where _ is a word char and would prevent \b from matching.
_CC_FILE_RE = re.compile( _CC_FILE_RE = re.compile(
r'cc[a-z]{2,8}\d{3,4}[-\w]*\.(?:bsa|esm|esl|esp|ba2)', r'cc[a-z]{2,8}\d{3,4}[-\w]*\.(?:bsa|esm|esl|esp|ba2)',

View File

@@ -53,7 +53,7 @@ _TYPE_MAP = {
suggestion="Check your internet connection and retry.", suggestion="Check your internet connection and retry.",
solutions=[ solutions=[
"Verify your internet connection.", "Verify your internet connection.",
"Re-run the install Wabbajack resumes from where it stopped.", "Re-run the install - Wabbajack resumes from where it stopped.",
"Check if Nexus Mods is reachable at nexusmods.com.", "Check if Nexus Mods is reachable at nexusmods.com.",
"Disable VPN or proxy if active.", "Disable VPN or proxy if active.",
], ],
@@ -84,7 +84,7 @@ _TYPE_MAP = {
"archive_corrupt": lambda msg, ctx: InstallError( "archive_corrupt": lambda msg, ctx: InstallError(
"Corrupted Archive", "Corrupted Archive",
msg, msg,
suggestion="Re-run the install Wabbajack will re-download and re-verify the file.", suggestion="Re-run the install - Wabbajack will re-download and re-verify the file.",
solutions=[ solutions=[
"Re-run the install.", "Re-run the install.",
"Check available disk space (partial downloads appear corrupt).", "Check available disk space (partial downloads appear corrupt).",
@@ -99,7 +99,7 @@ _TYPE_MAP = {
solutions=[ solutions=[
"Verify the modlist name is correct.", "Verify the modlist name is correct.",
"Ensure the target game is installed.", "Ensure the target game is installed.",
"Re-run the modlist index may have been temporarily unavailable.", "Re-run - the modlist index may have been temporarily unavailable.",
], ],
technical=_ctx_detail(ctx), technical=_ctx_detail(ctx),
), ),
@@ -108,7 +108,7 @@ _TYPE_MAP = {
msg, msg,
suggestion="Re-run the install to re-download any failed files.", suggestion="Re-run the install to re-download any failed files.",
solutions=[ solutions=[
"Re-run the install Wabbajack resumes and re-validates.", "Re-run the install - Wabbajack resumes and re-validates.",
"Check available disk space.", "Check available disk space.",
"Check Modlist_Install_workflow.log for specific failures.", "Check Modlist_Install_workflow.log for specific failures.",
], ],

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@
"targets": { "targets": {
".NETCoreApp,Version=v8.0": {}, ".NETCoreApp,Version=v8.0": {},
".NETCoreApp,Version=v8.0/linux-x64": { ".NETCoreApp,Version=v8.0/linux-x64": {
"jackify-engine/0.5.3": { "jackify-engine/0.5.4": {
"dependencies": { "dependencies": {
"Markdig": "0.40.0", "Markdig": "0.40.0",
"Microsoft.Extensions.Configuration.Json": "9.0.1", "Microsoft.Extensions.Configuration.Json": "9.0.1",
@@ -22,16 +22,16 @@
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"System.CommandLine": "2.0.0-beta4.22272.1", "System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.CLI.Builder": "0.5.3", "Wabbajack.CLI.Builder": "0.5.4",
"Wabbajack.Downloaders.Bethesda": "0.5.3", "Wabbajack.Downloaders.Bethesda": "0.5.4",
"Wabbajack.Downloaders.Dispatcher": "0.5.3", "Wabbajack.Downloaders.Dispatcher": "0.5.4",
"Wabbajack.Hashing.xxHash64": "0.5.3", "Wabbajack.Hashing.xxHash64": "0.5.4",
"Wabbajack.Networking.Discord": "0.5.3", "Wabbajack.Networking.Discord": "0.5.4",
"Wabbajack.Networking.GitHub": "0.5.3", "Wabbajack.Networking.GitHub": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3", "Wabbajack.Paths.IO": "0.5.4",
"Wabbajack.Server.Lib": "0.5.3", "Wabbajack.Server.Lib": "0.5.4",
"Wabbajack.Services.OSIntegrated": "0.5.3", "Wabbajack.Services.OSIntegrated": "0.5.4",
"Wabbajack.VFS": "0.5.3", "Wabbajack.VFS": "0.5.4",
"MegaApiClient": "1.0.0.0", "MegaApiClient": "1.0.0.0",
"runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.24" "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.24"
}, },
@@ -1781,7 +1781,7 @@
} }
} }
}, },
"Wabbajack.CLI.Builder/0.5.3": { "Wabbajack.CLI.Builder/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.Configuration.Json": "9.0.1", "Microsoft.Extensions.Configuration.Json": "9.0.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -1791,109 +1791,109 @@
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.CommandLine": "2.0.0-beta4.22272.1", "System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.Paths": "0.5.3" "Wabbajack.Paths": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.CLI.Builder.dll": {} "Wabbajack.CLI.Builder.dll": {}
} }
}, },
"Wabbajack.Common/0.5.3": { "Wabbajack.Common/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.Reactive": "6.0.1", "System.Reactive": "6.0.1",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Networking.Http": "0.5.3", "Wabbajack.Networking.Http": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3" "Wabbajack.Paths.IO": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Common.dll": {} "Wabbajack.Common.dll": {}
} }
}, },
"Wabbajack.Compiler/0.5.3": { "Wabbajack.Compiler/0.5.4": {
"dependencies": { "dependencies": {
"F23.StringSimilarity": "6.0.0", "F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Dispatcher": "0.5.3", "Wabbajack.Downloaders.Dispatcher": "0.5.4",
"Wabbajack.Installer": "0.5.3", "Wabbajack.Installer": "0.5.4",
"Wabbajack.VFS": "0.5.3", "Wabbajack.VFS": "0.5.4",
"ini-parser-netstandard": "2.5.2" "ini-parser-netstandard": "2.5.2"
}, },
"runtime": { "runtime": {
"Wabbajack.Compiler.dll": {} "Wabbajack.Compiler.dll": {}
} }
}, },
"Wabbajack.Compression.BSA/0.5.3": { "Wabbajack.Compression.BSA/0.5.4": {
"dependencies": { "dependencies": {
"K4os.Compression.LZ4.Streams": "1.3.8", "K4os.Compression.LZ4.Streams": "1.3.8",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2", "SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.DTOs": "0.5.3" "Wabbajack.DTOs": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Compression.BSA.dll": {} "Wabbajack.Compression.BSA.dll": {}
} }
}, },
"Wabbajack.Compression.Zip/0.5.3": { "Wabbajack.Compression.Zip/0.5.4": {
"dependencies": { "dependencies": {
"Wabbajack.IO.Async": "0.5.3" "Wabbajack.IO.Async": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Compression.Zip.dll": {} "Wabbajack.Compression.Zip.dll": {}
} }
}, },
"Wabbajack.Configuration/0.5.3": { "Wabbajack.Configuration/0.5.4": {
"runtime": { "runtime": {
"Wabbajack.Configuration.dll": {} "Wabbajack.Configuration.dll": {}
} }
}, },
"Wabbajack.Downloaders.Bethesda/0.5.3": { "Wabbajack.Downloaders.Bethesda/0.5.4": {
"dependencies": { "dependencies": {
"LibAES-CTR": "1.1.0", "LibAES-CTR": "1.1.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2", "SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Networking.BethesdaNet": "0.5.3" "Wabbajack.Networking.BethesdaNet": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Bethesda.dll": {} "Wabbajack.Downloaders.Bethesda.dll": {}
} }
}, },
"Wabbajack.Downloaders.Dispatcher/0.5.3": { "Wabbajack.Downloaders.Dispatcher/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Bethesda": "0.5.3", "Wabbajack.Downloaders.Bethesda": "0.5.4",
"Wabbajack.Downloaders.GameFile": "0.5.3", "Wabbajack.Downloaders.GameFile": "0.5.4",
"Wabbajack.Downloaders.GoogleDrive": "0.5.3", "Wabbajack.Downloaders.GoogleDrive": "0.5.4",
"Wabbajack.Downloaders.Http": "0.5.3", "Wabbajack.Downloaders.Http": "0.5.4",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.5.3", "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Downloaders.Manual": "0.5.3", "Wabbajack.Downloaders.Manual": "0.5.4",
"Wabbajack.Downloaders.MediaFire": "0.5.3", "Wabbajack.Downloaders.MediaFire": "0.5.4",
"Wabbajack.Downloaders.Mega": "0.5.3", "Wabbajack.Downloaders.Mega": "0.5.4",
"Wabbajack.Downloaders.ModDB": "0.5.3", "Wabbajack.Downloaders.ModDB": "0.5.4",
"Wabbajack.Downloaders.Nexus": "0.5.3", "Wabbajack.Downloaders.Nexus": "0.5.4",
"Wabbajack.Downloaders.VerificationCache": "0.5.3", "Wabbajack.Downloaders.VerificationCache": "0.5.4",
"Wabbajack.Downloaders.WabbajackCDN": "0.5.3", "Wabbajack.Downloaders.WabbajackCDN": "0.5.4",
"Wabbajack.Networking.WabbajackClientApi": "0.5.3" "Wabbajack.Networking.WabbajackClientApi": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Dispatcher.dll": {} "Wabbajack.Downloaders.Dispatcher.dll": {}
} }
}, },
"Wabbajack.Downloaders.GameFile/0.5.3": { "Wabbajack.Downloaders.GameFile/0.5.4": {
"dependencies": { "dependencies": {
"GameFinder.StoreHandlers.EADesktop": "4.5.0", "GameFinder.StoreHandlers.EADesktop": "4.5.0",
"GameFinder.StoreHandlers.EGS": "4.5.0", "GameFinder.StoreHandlers.EGS": "4.5.0",
@@ -1903,361 +1903,361 @@
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.VFS": "0.5.3" "Wabbajack.VFS": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.GameFile.dll": {} "Wabbajack.Downloaders.GameFile.dll": {}
} }
}, },
"Wabbajack.Downloaders.GoogleDrive/0.5.3": { "Wabbajack.Downloaders.GoogleDrive/0.5.4": {
"dependencies": { "dependencies": {
"HtmlAgilityPack": "1.11.72", "HtmlAgilityPack": "1.11.72",
"Microsoft.AspNetCore.Http.Extensions": "2.3.0", "Microsoft.AspNetCore.Http.Extensions": "2.3.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Networking.Http": "0.5.3", "Wabbajack.Networking.Http": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3" "Wabbajack.Networking.Http.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.GoogleDrive.dll": {} "Wabbajack.Downloaders.GoogleDrive.dll": {}
} }
}, },
"Wabbajack.Downloaders.Http/0.5.3": { "Wabbajack.Downloaders.Http/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Networking.BethesdaNet": "0.5.3", "Wabbajack.Networking.BethesdaNet": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3", "Wabbajack.Networking.Http.Interfaces": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3" "Wabbajack.Paths.IO": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Http.dll": {} "Wabbajack.Downloaders.Http.dll": {}
} }
}, },
"Wabbajack.Downloaders.Interfaces/0.5.3": { "Wabbajack.Downloaders.Interfaces/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Compression.Zip": "0.5.3", "Wabbajack.Compression.Zip": "0.5.4",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3" "Wabbajack.Paths.IO": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Interfaces.dll": {} "Wabbajack.Downloaders.Interfaces.dll": {}
} }
}, },
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.3": { "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.4": {
"dependencies": { "dependencies": {
"F23.StringSimilarity": "6.0.0", "F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Networking.Http": "0.5.3", "Wabbajack.Networking.Http": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3" "Wabbajack.Networking.Http.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {} "Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {}
} }
}, },
"Wabbajack.Downloaders.Manual/0.5.3": { "Wabbajack.Downloaders.Manual/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3" "Wabbajack.Downloaders.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Manual.dll": {} "Wabbajack.Downloaders.Manual.dll": {}
} }
}, },
"Wabbajack.Downloaders.MediaFire/0.5.3": { "Wabbajack.Downloaders.MediaFire/0.5.4": {
"dependencies": { "dependencies": {
"HtmlAgilityPack": "1.11.72", "HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3" "Wabbajack.Networking.Http.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.MediaFire.dll": {} "Wabbajack.Downloaders.MediaFire.dll": {}
} }
}, },
"Wabbajack.Downloaders.Mega/0.5.3": { "Wabbajack.Downloaders.Mega/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3" "Wabbajack.Paths.IO": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Mega.dll": {} "Wabbajack.Downloaders.Mega.dll": {}
} }
}, },
"Wabbajack.Downloaders.ModDB/0.5.3": { "Wabbajack.Downloaders.ModDB/0.5.4": {
"dependencies": { "dependencies": {
"HtmlAgilityPack": "1.11.72", "HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Networking.Http": "0.5.3", "Wabbajack.Networking.Http": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3" "Wabbajack.Networking.Http.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.ModDB.dll": {} "Wabbajack.Downloaders.ModDB.dll": {}
} }
}, },
"Wabbajack.Downloaders.Nexus/0.5.3": { "Wabbajack.Downloaders.Nexus/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Hashing.xxHash64": "0.5.3", "Wabbajack.Hashing.xxHash64": "0.5.4",
"Wabbajack.Networking.Http": "0.5.3", "Wabbajack.Networking.Http": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3", "Wabbajack.Networking.Http.Interfaces": "0.5.4",
"Wabbajack.Networking.NexusApi": "0.5.3", "Wabbajack.Networking.NexusApi": "0.5.4",
"Wabbajack.Paths": "0.5.3" "Wabbajack.Paths": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Nexus.dll": {} "Wabbajack.Downloaders.Nexus.dll": {}
} }
}, },
"Wabbajack.Downloaders.VerificationCache/0.5.3": { "Wabbajack.Downloaders.VerificationCache/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Stub.System.Data.SQLite.Core.NetStandard": "1.0.119", "Stub.System.Data.SQLite.Core.NetStandard": "1.0.119",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3" "Wabbajack.Paths.IO": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.VerificationCache.dll": {} "Wabbajack.Downloaders.VerificationCache.dll": {}
} }
}, },
"Wabbajack.Downloaders.WabbajackCDN/0.5.3": { "Wabbajack.Downloaders.WabbajackCDN/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Toolkit.HighPerformance": "7.1.2", "Microsoft.Toolkit.HighPerformance": "7.1.2",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Networking.Http": "0.5.3", "Wabbajack.Networking.Http": "0.5.4",
"Wabbajack.RateLimiter": "0.5.3" "Wabbajack.RateLimiter": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.WabbajackCDN.dll": {} "Wabbajack.Downloaders.WabbajackCDN.dll": {}
} }
}, },
"Wabbajack.DTOs/0.5.3": { "Wabbajack.DTOs/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Hashing.xxHash64": "0.5.3", "Wabbajack.Hashing.xxHash64": "0.5.4",
"Wabbajack.Paths": "0.5.3" "Wabbajack.Paths": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.DTOs.dll": {} "Wabbajack.DTOs.dll": {}
} }
}, },
"Wabbajack.FileExtractor/0.5.3": { "Wabbajack.FileExtractor/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"OMODFramework": "3.0.1", "OMODFramework": "3.0.1",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Compression.BSA": "0.5.3", "Wabbajack.Compression.BSA": "0.5.4",
"Wabbajack.Hashing.PHash": "0.5.3", "Wabbajack.Hashing.PHash": "0.5.4",
"Wabbajack.Paths": "0.5.3" "Wabbajack.Paths": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.FileExtractor.dll": {} "Wabbajack.FileExtractor.dll": {}
} }
}, },
"Wabbajack.Hashing.PHash/0.5.3": { "Wabbajack.Hashing.PHash/0.5.4": {
"dependencies": { "dependencies": {
"BCnEncoder.Net.ImageSharp": "1.1.1", "BCnEncoder.Net.ImageSharp": "1.1.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Shipwreck.Phash": "0.5.0", "Shipwreck.Phash": "0.5.0",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Paths": "0.5.3", "Wabbajack.Paths": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3" "Wabbajack.Paths.IO": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Hashing.PHash.dll": {} "Wabbajack.Hashing.PHash.dll": {}
} }
}, },
"Wabbajack.Hashing.xxHash64/0.5.3": { "Wabbajack.Hashing.xxHash64/0.5.4": {
"dependencies": { "dependencies": {
"Wabbajack.Paths": "0.5.3", "Wabbajack.Paths": "0.5.4",
"Wabbajack.RateLimiter": "0.5.3" "Wabbajack.RateLimiter": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Hashing.xxHash64.dll": {} "Wabbajack.Hashing.xxHash64.dll": {}
} }
}, },
"Wabbajack.Installer/0.5.3": { "Wabbajack.Installer/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"Octopus.Octodiff": "2.0.548", "Octopus.Octodiff": "2.0.548",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Downloaders.Dispatcher": "0.5.3", "Wabbajack.Downloaders.Dispatcher": "0.5.4",
"Wabbajack.Downloaders.GameFile": "0.5.3", "Wabbajack.Downloaders.GameFile": "0.5.4",
"Wabbajack.FileExtractor": "0.5.3", "Wabbajack.FileExtractor": "0.5.4",
"Wabbajack.Networking.NexusApi": "0.5.3", "Wabbajack.Networking.NexusApi": "0.5.4",
"Wabbajack.Networking.WabbajackClientApi": "0.5.3", "Wabbajack.Networking.WabbajackClientApi": "0.5.4",
"Wabbajack.Paths": "0.5.3", "Wabbajack.Paths": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3", "Wabbajack.Paths.IO": "0.5.4",
"Wabbajack.VFS": "0.5.3", "Wabbajack.VFS": "0.5.4",
"ini-parser-netstandard": "2.5.2" "ini-parser-netstandard": "2.5.2"
}, },
"runtime": { "runtime": {
"Wabbajack.Installer.dll": {} "Wabbajack.Installer.dll": {}
} }
}, },
"Wabbajack.IO.Async/0.5.3": { "Wabbajack.IO.Async/0.5.4": {
"runtime": { "runtime": {
"Wabbajack.IO.Async.dll": {} "Wabbajack.IO.Async.dll": {}
} }
}, },
"Wabbajack.Networking.BethesdaNet/0.5.3": { "Wabbajack.Networking.BethesdaNet/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Networking.Http": "0.5.3", "Wabbajack.Networking.Http": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3" "Wabbajack.Networking.Http.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.BethesdaNet.dll": {} "Wabbajack.Networking.BethesdaNet.dll": {}
} }
}, },
"Wabbajack.Networking.Discord/0.5.3": { "Wabbajack.Networking.Discord/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Networking.Http.Interfaces": "0.5.3" "Wabbajack.Networking.Http.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.Discord.dll": {} "Wabbajack.Networking.Discord.dll": {}
} }
}, },
"Wabbajack.Networking.GitHub/0.5.3": { "Wabbajack.Networking.GitHub/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0", "Octokit": "14.0.0",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3" "Wabbajack.Networking.Http.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.GitHub.dll": {} "Wabbajack.Networking.GitHub.dll": {}
} }
}, },
"Wabbajack.Networking.Http/0.5.3": { "Wabbajack.Networking.Http/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Http": "9.0.1", "Microsoft.Extensions.Http": "9.0.1",
"Microsoft.Extensions.Logging": "9.0.1", "Microsoft.Extensions.Logging": "9.0.1",
"Wabbajack.Configuration": "0.5.3", "Wabbajack.Configuration": "0.5.4",
"Wabbajack.Downloaders.Interfaces": "0.5.3", "Wabbajack.Downloaders.Interfaces": "0.5.4",
"Wabbajack.Hashing.xxHash64": "0.5.3", "Wabbajack.Hashing.xxHash64": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3", "Wabbajack.Networking.Http.Interfaces": "0.5.4",
"Wabbajack.Paths": "0.5.3", "Wabbajack.Paths": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3" "Wabbajack.Paths.IO": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.Http.dll": {} "Wabbajack.Networking.Http.dll": {}
} }
}, },
"Wabbajack.Networking.Http.Interfaces/0.5.3": { "Wabbajack.Networking.Http.Interfaces/0.5.4": {
"dependencies": { "dependencies": {
"Wabbajack.Hashing.xxHash64": "0.5.3" "Wabbajack.Hashing.xxHash64": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.Http.Interfaces.dll": {} "Wabbajack.Networking.Http.Interfaces.dll": {}
} }
}, },
"Wabbajack.Networking.NexusApi/0.5.3": { "Wabbajack.Networking.NexusApi/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Networking.Http": "0.5.3", "Wabbajack.Networking.Http": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3", "Wabbajack.Networking.Http.Interfaces": "0.5.4",
"Wabbajack.Networking.WabbajackClientApi": "0.5.3" "Wabbajack.Networking.WabbajackClientApi": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.NexusApi.dll": {} "Wabbajack.Networking.NexusApi.dll": {}
} }
}, },
"Wabbajack.Networking.WabbajackClientApi/0.5.3": { "Wabbajack.Networking.WabbajackClientApi/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0", "Octokit": "14.0.0",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3", "Wabbajack.Paths.IO": "0.5.4",
"Wabbajack.VFS.Interfaces": "0.5.3", "Wabbajack.VFS.Interfaces": "0.5.4",
"YamlDotNet": "16.3.0" "YamlDotNet": "16.3.0"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.WabbajackClientApi.dll": {} "Wabbajack.Networking.WabbajackClientApi.dll": {}
} }
}, },
"Wabbajack.Paths/0.5.3": { "Wabbajack.Paths/0.5.4": {
"runtime": { "runtime": {
"Wabbajack.Paths.dll": {} "Wabbajack.Paths.dll": {}
} }
}, },
"Wabbajack.Paths.IO/0.5.3": { "Wabbajack.Paths.IO/0.5.4": {
"dependencies": { "dependencies": {
"Wabbajack.Paths": "0.5.3", "Wabbajack.Paths": "0.5.4",
"shortid": "4.0.0" "shortid": "4.0.0"
}, },
"runtime": { "runtime": {
"Wabbajack.Paths.IO.dll": {} "Wabbajack.Paths.IO.dll": {}
} }
}, },
"Wabbajack.RateLimiter/0.5.3": { "Wabbajack.RateLimiter/0.5.4": {
"runtime": { "runtime": {
"Wabbajack.RateLimiter.dll": {} "Wabbajack.RateLimiter.dll": {}
} }
}, },
"Wabbajack.Server.Lib/0.5.3": { "Wabbajack.Server.Lib/0.5.4": {
"dependencies": { "dependencies": {
"FluentFTP": "52.0.0", "FluentFTP": "52.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -2265,58 +2265,58 @@
"Nettle": "3.0.0", "Nettle": "3.0.0",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.Networking.Http.Interfaces": "0.5.3", "Wabbajack.Networking.Http.Interfaces": "0.5.4",
"Wabbajack.Services.OSIntegrated": "0.5.3" "Wabbajack.Services.OSIntegrated": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Server.Lib.dll": {} "Wabbajack.Server.Lib.dll": {}
} }
}, },
"Wabbajack.Services.OSIntegrated/0.5.3": { "Wabbajack.Services.OSIntegrated/0.5.4": {
"dependencies": { "dependencies": {
"DeviceId": "6.8.0", "DeviceId": "6.8.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Compiler": "0.5.3", "Wabbajack.Compiler": "0.5.4",
"Wabbajack.Downloaders.Dispatcher": "0.5.3", "Wabbajack.Downloaders.Dispatcher": "0.5.4",
"Wabbajack.Installer": "0.5.3", "Wabbajack.Installer": "0.5.4",
"Wabbajack.Networking.BethesdaNet": "0.5.3", "Wabbajack.Networking.BethesdaNet": "0.5.4",
"Wabbajack.Networking.Discord": "0.5.3", "Wabbajack.Networking.Discord": "0.5.4",
"Wabbajack.VFS": "0.5.3" "Wabbajack.VFS": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.Services.OSIntegrated.dll": {} "Wabbajack.Services.OSIntegrated.dll": {}
} }
}, },
"Wabbajack.VFS/0.5.3": { "Wabbajack.VFS/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"System.Data.SQLite.Core": "1.0.119", "System.Data.SQLite.Core": "1.0.119",
"Wabbajack.Common": "0.5.3", "Wabbajack.Common": "0.5.4",
"Wabbajack.FileExtractor": "0.5.3", "Wabbajack.FileExtractor": "0.5.4",
"Wabbajack.Hashing.PHash": "0.5.3", "Wabbajack.Hashing.PHash": "0.5.4",
"Wabbajack.Hashing.xxHash64": "0.5.3", "Wabbajack.Hashing.xxHash64": "0.5.4",
"Wabbajack.Paths": "0.5.3", "Wabbajack.Paths": "0.5.4",
"Wabbajack.Paths.IO": "0.5.3", "Wabbajack.Paths.IO": "0.5.4",
"Wabbajack.VFS.Interfaces": "0.5.3" "Wabbajack.VFS.Interfaces": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.VFS.dll": {} "Wabbajack.VFS.dll": {}
} }
}, },
"Wabbajack.VFS.Interfaces/0.5.3": { "Wabbajack.VFS.Interfaces/0.5.4": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.5.3", "Wabbajack.DTOs": "0.5.4",
"Wabbajack.Hashing.xxHash64": "0.5.3", "Wabbajack.Hashing.xxHash64": "0.5.4",
"Wabbajack.Paths": "0.5.3" "Wabbajack.Paths": "0.5.4"
}, },
"runtime": { "runtime": {
"Wabbajack.VFS.Interfaces.dll": {} "Wabbajack.VFS.Interfaces.dll": {}
@@ -2333,7 +2333,7 @@
} }
}, },
"libraries": { "libraries": {
"jackify-engine/0.5.3": { "jackify-engine/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
@@ -3022,202 +3022,202 @@
"path": "yamldotnet/16.3.0", "path": "yamldotnet/16.3.0",
"hashPath": "yamldotnet.16.3.0.nupkg.sha512" "hashPath": "yamldotnet.16.3.0.nupkg.sha512"
}, },
"Wabbajack.CLI.Builder/0.5.3": { "Wabbajack.CLI.Builder/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Common/0.5.3": { "Wabbajack.Common/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Compiler/0.5.3": { "Wabbajack.Compiler/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Compression.BSA/0.5.3": { "Wabbajack.Compression.BSA/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Compression.Zip/0.5.3": { "Wabbajack.Compression.Zip/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Configuration/0.5.3": { "Wabbajack.Configuration/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Bethesda/0.5.3": { "Wabbajack.Downloaders.Bethesda/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Dispatcher/0.5.3": { "Wabbajack.Downloaders.Dispatcher/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.GameFile/0.5.3": { "Wabbajack.Downloaders.GameFile/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.GoogleDrive/0.5.3": { "Wabbajack.Downloaders.GoogleDrive/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Http/0.5.3": { "Wabbajack.Downloaders.Http/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Interfaces/0.5.3": { "Wabbajack.Downloaders.Interfaces/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.3": { "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Manual/0.5.3": { "Wabbajack.Downloaders.Manual/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.MediaFire/0.5.3": { "Wabbajack.Downloaders.MediaFire/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Mega/0.5.3": { "Wabbajack.Downloaders.Mega/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.ModDB/0.5.3": { "Wabbajack.Downloaders.ModDB/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Nexus/0.5.3": { "Wabbajack.Downloaders.Nexus/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.VerificationCache/0.5.3": { "Wabbajack.Downloaders.VerificationCache/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.WabbajackCDN/0.5.3": { "Wabbajack.Downloaders.WabbajackCDN/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.DTOs/0.5.3": { "Wabbajack.DTOs/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.FileExtractor/0.5.3": { "Wabbajack.FileExtractor/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Hashing.PHash/0.5.3": { "Wabbajack.Hashing.PHash/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Hashing.xxHash64/0.5.3": { "Wabbajack.Hashing.xxHash64/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Installer/0.5.3": { "Wabbajack.Installer/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.IO.Async/0.5.3": { "Wabbajack.IO.Async/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.BethesdaNet/0.5.3": { "Wabbajack.Networking.BethesdaNet/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.Discord/0.5.3": { "Wabbajack.Networking.Discord/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.GitHub/0.5.3": { "Wabbajack.Networking.GitHub/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.Http/0.5.3": { "Wabbajack.Networking.Http/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.Http.Interfaces/0.5.3": { "Wabbajack.Networking.Http.Interfaces/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.NexusApi/0.5.3": { "Wabbajack.Networking.NexusApi/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.WabbajackClientApi/0.5.3": { "Wabbajack.Networking.WabbajackClientApi/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Paths/0.5.3": { "Wabbajack.Paths/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Paths.IO/0.5.3": { "Wabbajack.Paths.IO/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.RateLimiter/0.5.3": { "Wabbajack.RateLimiter/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Server.Lib/0.5.3": { "Wabbajack.Server.Lib/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Services.OSIntegrated/0.5.3": { "Wabbajack.Services.OSIntegrated/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.VFS/0.5.3": { "Wabbajack.VFS/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.VFS.Interfaces/0.5.3": { "Wabbajack.VFS.Interfaces/0.5.4": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""

Binary file not shown.

View File

@@ -11,12 +11,21 @@ import signal
import logging import logging
from .main import JackifyCLI from .main import JackifyCLI
from jackify.shared.logging import LoggingHandler
from jackify import __version__ as jackify_version
# Set up logging def _setup_cli_logging() -> logging.Logger:
logging.basicConfig( debug_mode = '--debug' in sys.argv or '-d' in sys.argv
level=logging.INFO, if not debug_mode:
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' try:
) from jackify.backend.handlers.config_handler import ConfigHandler
debug_mode = ConfigHandler().get('debug_mode', False)
except Exception:
pass
return LoggingHandler().setup_application_logging(debug_mode)
root_logger = _setup_cli_logging()
root_logger.info("Jackify %s starting (CLI)", jackify_version)
def terminate_children(signum, frame): def terminate_children(signum, frame):
"""Signal handler to terminate child processes on exit""" """Signal handler to terminate child processes on exit"""

View File

@@ -209,7 +209,6 @@ class InstallModlistCommand:
'modlist_value': getattr(args, 'modlist_value', None), 'modlist_value': getattr(args, 'modlist_value', None),
'skip_confirmation': True, 'skip_confirmation': True,
'resolution': getattr(args, 'resolution', None), 'resolution': getattr(args, 'resolution', None),
'skip_disk_check': getattr(args, 'skip_disk_check', False),
} }
def _validate_install_context(self, context: dict) -> bool: def _validate_install_context(self, context: dict) -> bool:
@@ -317,16 +316,21 @@ class InstallModlistCommand:
# Check if game is supported # Check if game is supported
if game_type and not modlist_cli.check_game_support(game_type): if game_type and not modlist_cli.check_game_support(game_type):
# Show unsupported game warning
supported_games = modlist_cli.wabbajack_parser.get_supported_games_display_names() supported_games = modlist_cli.wabbajack_parser.get_supported_games_display_names()
supported_games_str = ", ".join(supported_games) supported_games_str = ", ".join(supported_games)
print(f"\n{COLOR_WARNING}Game Support Notice{COLOR_RESET}") print(f"\n{COLOR_WARNING}Game Support Notice{COLOR_RESET}")
print(f"{COLOR_WARNING}While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to: {supported_games_str}.{COLOR_RESET}") print(f"{COLOR_WARNING}While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to: {supported_games_str}.{COLOR_RESET}")
print(f"{COLOR_WARNING}We are working to add more automated support in future releases!{COLOR_RESET}") print(f"{COLOR_WARNING}We are working to add more automated support in future releases!{COLOR_RESET}")
response = input(f"{COLOR_PROMPT}Press Enter to continue, or type 'cancel' to abort: {COLOR_RESET}").strip().lower()
# Ask for confirmation to continue if response == 'cancel':
response = input(f"{COLOR_PROMPT}Click Enter to continue with the modlist installation, or type 'cancel' to abort: {COLOR_RESET}").strip().lower() print("[INFO] Modlist installation cancelled by user.")
return 1
elif game_type in ('skyrimvr', 'fallout4vr'):
game_label = "Skyrim VR" if game_type == 'skyrimvr' else "Fallout 4 VR"
print(f"\n{COLOR_WARNING}VR Platform Notice{COLOR_RESET}")
print(f"{COLOR_WARNING}{game_label} modlist detected. Jackify will handle the install and prefix setup, but running VR modlists on Linux requires a working VR platform (SteamVR, ALVR, WiVRn, etc.) configured independently.{COLOR_RESET}")
print(f"{COLOR_WARNING}VR support is best effort. Full functionality depends on your VR setup.{COLOR_RESET}")
response = input(f"{COLOR_PROMPT}Press Enter to continue, or type 'cancel' to abort: {COLOR_RESET}").strip().lower()
if response == 'cancel': if response == 'cancel':
print("[INFO] Modlist installation cancelled by user.") print("[INFO] Modlist installation cancelled by user.")
return 1 return 1

View File

@@ -411,8 +411,6 @@ class JackifyCLI:
parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)') parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)')
parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)') parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)')
parser.add_argument('--update', action='store_true', help='Check for and install updates') parser.add_argument('--update', action='store_true', help='Check for and install updates')
parser.add_argument('--skip-disk-check', action='store_true', help='Skip the pre-flight disk space check (use when retrying after a disk-full warning)')
# Add command-specific arguments # Add command-specific arguments
self.commands['install_modlist'].add_top_level_args(parser) self.commands['install_modlist'].add_top_level_args(parser)

View File

@@ -39,8 +39,10 @@ class AdditionalMenuHandler:
print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via Proton){COLOR_RESET}") print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via Proton){COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Setup Mod Organizer 2") print(f"{COLOR_SELECTION}4.{COLOR_RESET} Setup Mod Organizer 2")
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}") print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Configure Tool Compatibility")
print(f" {COLOR_ACTION}→ Apply Wine registry settings for xEdit, Synthesis, Pandora, Nemesis{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip() selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu if selection.lower() == 'q': # Allow 'q' to re-display menu
continue continue
@@ -52,6 +54,8 @@ class AdditionalMenuHandler:
self._execute_install_wabbajack(cli_instance) self._execute_install_wabbajack(cli_instance)
elif selection == "4": elif selection == "4":
self._execute_setup_mo2(cli_instance) self._execute_setup_mo2(cli_instance)
elif selection == "5":
self._execute_configure_tool_compat(cli_instance)
elif selection == "0": elif selection == "0":
break break
else: else:
@@ -150,7 +154,7 @@ class AdditionalMenuHandler:
else: else:
output_path = Path(output_path).expanduser() output_path = Path(output_path).expanduser()
# Check if output directory already has content mirror GUI behaviour # Check if output directory already has content - mirror GUI behaviour
if output_path.exists() and output_path.is_dir(): if output_path.exists() and output_path.is_dir():
try: try:
has_files = any(output_path.iterdir()) has_files = any(output_path.iterdir())
@@ -405,3 +409,68 @@ class AdditionalMenuHandler:
if self.logger: if self.logger:
self.logger.debug("AdditionalMenuHandler: Executing Setup MO2 command") self.logger.debug("AdditionalMenuHandler: Executing Setup MO2 command")
command.run() command.run()
def _execute_configure_tool_compat(self, cli_instance):
"""Apply tool compatibility settings to an existing configured modlist prefix."""
from jackify.backend.handlers.modlist_handler import ModlistHandler
from jackify.backend.services.tool_config_service import apply_tool_config_for_appid
from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS
self._clear_screen()
print_jackify_banner()
print_section_header("Configure Tool Compatibility")
print(f"{COLOR_INFO}Discovering configured modlists...{COLOR_RESET}")
try:
handler = ModlistHandler()
discovered = handler.discover_executable_shortcuts("ModOrganizer.exe")
shortcuts = [
{"name": m.get("name", "Unknown"), "appid": str(m.get("appid", ""))}
for m in discovered
if m.get("appid")
]
except Exception as e:
print(f"{COLOR_ERROR}Failed to discover modlists: {e}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
if not shortcuts:
print(f"{COLOR_WARNING}No configured modlists found.{COLOR_RESET}")
print(f"{COLOR_INFO}Install and configure a modlist first.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print()
for i, s in enumerate(shortcuts, 1):
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {s['name']}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel")
selection = input(f"\n{COLOR_PROMPT}Select modlist (0-{len(shortcuts)}): {COLOR_RESET}").strip()
if selection == "0" or not selection:
return
try:
idx = int(selection) - 1
if idx < 0 or idx >= len(shortcuts):
raise ValueError()
except ValueError:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
chosen = shortcuts[idx]
print(f"\n{COLOR_INFO}Applying tool compatibility settings for: {chosen['name']}{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a few minutes...{COLOR_RESET}\n")
def _log(msg: str):
print(f"{COLOR_INFO}{msg}{COLOR_RESET}")
ok = apply_tool_config_for_appid(chosen["appid"], log=_log)
if ok:
print(f"\n{COLOR_SUCCESS}Tool compatibility configured successfully.{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}Tool compatibility configuration failed. Check logs for details.{COLOR_RESET}")
input("\nPress Enter to return to menu...")

View File

@@ -38,7 +38,7 @@ def handle_protocol_url(url: str):
if error: if error:
error_description = params.get('error_description', ['No description'])[0] error_description = params.get('error_description', ['No description'])[0]
_log_error(f"OAuth error: {error} {error_description}") _log_error(f"OAuth error: {error} - {error_description}")
return return
if not code or not state: if not code or not state:

View File

@@ -22,6 +22,7 @@ from PySide6.QtGui import QFont, QClipboard
from ....backend.services.update_service import UpdateService from ....backend.services.update_service import UpdateService
from ....backend.models.configuration import SystemInfo from ....backend.models.configuration import SystemInfo
from .... import __version__ from .... import __version__
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,7 +46,7 @@ class UpdateCheckThread(QThread):
self.update_check_finished.emit(None) self.update_check_finished.emit(None)
class AboutDialog(QDialog): class AboutDialog(ThreadLifecycleMixin, QDialog):
"""About dialog showing system info and app details.""" """About dialog showing system info and app details."""
def __init__(self, system_info: SystemInfo, parent=None): def __init__(self, system_info: SystemInfo, parent=None):
@@ -420,8 +421,7 @@ Python: {platform.python_version()}"""
def closeEvent(self, event): def closeEvent(self, event):
"""Handle dialog close event.""" """Handle dialog close event."""
if self.update_check_thread and self.update_check_thread.isRunning(): self.update_check_thread = self._park_thread(
self.update_check_thread.terminate() self.update_check_thread, ["update_available", "no_update", "check_failed"]
self.update_check_thread.wait() )
event.accept() event.accept()

View File

@@ -12,7 +12,7 @@ from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QWidget, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QWidget,
QSpacerItem, QSizePolicy, QFrame, QApplication QSpacerItem, QSizePolicy, QFrame, QApplication
) )
from PySide6.QtCore import Qt from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QIcon, QFont from PySide6.QtGui import QIcon, QFont
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -145,7 +145,8 @@ class ENBProtonDialog(QDialog):
# OK button # OK button
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch() btn_row.addStretch()
self.ok_btn = QPushButton("I Understand") self.ok_btn = QPushButton("I Understand (3s)")
self.ok_btn.setEnabled(False)
self.ok_btn.setStyleSheet( self.ok_btn.setStyleSheet(
"QPushButton { " "QPushButton { "
" background: #3fb7d6; " " background: #3fb7d6; "
@@ -162,9 +163,19 @@ class ENBProtonDialog(QDialog):
"QPushButton:pressed { " "QPushButton:pressed { "
" background: #2d8fa8; " " background: #2d8fa8; "
"}" "}"
"QPushButton:disabled { "
" background: #555; "
" color: #aaa; "
"}"
) )
self.ok_btn.clicked.connect(self.accept) self.ok_btn.clicked.connect(self.accept)
btn_row.addWidget(self.ok_btn) btn_row.addWidget(self.ok_btn)
self._protect_countdown = 3
self._protect_timer = QTimer(self)
self._protect_timer.setInterval(1000)
self._protect_timer.timeout.connect(self._on_protect_tick)
self._protect_timer.start()
btn_row.addStretch() btn_row.addStretch()
layout.addLayout(btn_row) layout.addLayout(btn_row)
@@ -173,6 +184,15 @@ class ENBProtonDialog(QDialog):
logger.info(f"ENBProtonDialog created for modlist: {modlist_name}") logger.info(f"ENBProtonDialog created for modlist: {modlist_name}")
def _on_protect_tick(self):
self._protect_countdown -= 1
if self._protect_countdown > 0:
self.ok_btn.setText(f"I Understand ({self._protect_countdown}s)")
else:
self._protect_timer.stop()
self.ok_btn.setText("I Understand")
self.ok_btn.setEnabled(True)
def _set_dialog_icon(self): def _set_dialog_icon(self):
"""Set the dialog icon to Wabbajack icon if available""" """Set the dialog icon to Wabbajack icon if available"""
try: try:

View File

@@ -361,7 +361,8 @@ class ManualDownloadDialog(QDialog):
logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True) logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True)
def _on_pick_folder(self) -> None: def _on_pick_folder(self) -> None:
chosen = QFileDialog.getExistingDirectory(self, "Select watch folder", str(self._watch_dir)) from jackify.frontends.gui.utils import browse_directory
chosen = browse_directory(self, "Select watch folder", str(self._watch_dir))
if chosen: if chosen:
from jackify.backend.services.download_watcher_service import WatcherConfig from jackify.backend.services.download_watcher_service import WatcherConfig
self._watch_dir = Path(chosen) self._watch_dir = Path(chosen)
@@ -441,7 +442,7 @@ class ManualDownloadDialog(QDialog):
def _on_all_done_slot(self, completed: int, skipped: int) -> None: def _on_all_done_slot(self, completed: int, skipped: int) -> None:
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
self._progress_label.setText( self._progress_label.setText(
f"All downloads complete ({completed} accepted, {skipped} deferred) closing..." f"All downloads complete ({completed} accepted, {skipped} deferred) - closing..."
) )
# Raise now while the dialog is still visible so the user sees the completion state # Raise now while the dialog is still visible so the user sees the completion state
self._raise_main_window() self._raise_main_window()

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