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

@@ -121,17 +121,16 @@ class ModlistOperationsConfigurationCLIMixin:
if debug_mode:
cmd.append('--debug')
self.logger.info("Adding --debug flag to jackify-engine")
if self.context.get('skip_disk_check'):
cmd.append('--skip-disk-check')
self.logger.info("Adding --skip-disk-check flag to jackify-engine")
writeback_path = str(auth_service.get_token_writeback_path())
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
@@ -283,6 +282,7 @@ class ModlistOperationsConfigurationCLIMixin:
proc.wait()
self._current_process = None
auth_service.apply_token_writeback(writeback_path)
if proc.returncode != 0:
print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}")
self.logger.error(f"Engine exited with code {proc.returncode}.")
@@ -595,6 +595,36 @@ class ModlistOperationsConfigurationCLIMixin:
if update_existing_install and app_id:
print(f"{COLOR_SUCCESS}Update mode Steam setup confirmed.{COLOR_RESET}")
print(f"{COLOR_INFO}Reusing Steam AppID: {app_id}{COLOR_RESET}")
# Apply artwork and restart Steam -- skipped in update path since the full
# workflow is bypassed, but artwork and Steam state still need refreshing.
_game_type = self.context.get('detected_game') or self.context.get('special_game_type')
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
ModlistHandler().set_steam_grid_images(str(app_id), install_dir_str, game_type=_game_type)
except Exception as e:
self.logger.warning("Failed to apply Steam artwork in update mode: %s", e)
if _game_type == 'cp2077':
# CP2077 launch options may be absent on lists originally installed
# under v0.5 before CP2077 support was added.
try:
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.handlers.config_handler import ConfigHandler
sh = ShortcutHandler(
config_handler=ConfigHandler(),
steamdeck=bool(self.system_info and self.system_info.is_steamdeck),
)
sh.update_shortcut_launch_options(
shortcut_name,
mo2_exe_path,
'WINEDLLOVERRIDES="version=n,b;winmm=n,b" %command%',
)
except Exception as e:
self.logger.warning("Failed to update CP2077 launch options in update mode: %s", e)
try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
AutomatedPrefixService(self.system_info).restart_steam()
except Exception as e:
self.logger.warning("Failed to restart Steam in update mode: %s", e)
else:
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
if prefix_path:

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_watch_directory": None, # Optional override for manual-download watcher folder
"window_width": None, # Saved window width (None = use dynamic sizing)
"window_height": None # Saved window height (None = use dynamic sizing)
"window_height": None, # Saved window height (None = use dynamic sizing)
}
# Load configuration if exists

View File

@@ -521,11 +521,13 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
# Game-specific Documents directory names (for both Linux home and Wine prefix)
game_docs_dirs = {
"skyrimse": "Skyrim Special Edition",
"skyrimvr": "Skyrim VR",
"fallout4": "Fallout4",
"fallout4vr": "Fallout4VR",
"falloutnv": "FalloutNV",
"oblivion": "Oblivion",
"enderal": "Enderal Special Edition",
"enderalse": "Enderal Special Edition"
"enderalse": "Enderal Special Edition",
}
game_dirs = {
@@ -561,41 +563,193 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
os.makedirs(dir_path, exist_ok=True)
self.logger.debug(f"Created game-specific directory: {dir_path}")
# CRITICAL: Create game-specific Documents directories in Wine prefix
# CP2077 and BG3 use AppData/Local only (no My Games)
appdata_only_dirs = {
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
}
# CRITICAL: Create game-specific directories in Wine prefix
# Required for USVFS to virtualize profile INIs on first launch
if game_name in game_docs_dirs:
docs_dir_name = game_docs_dirs[game_name]
# Find compatdata path for this AppID
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
compatdata_path = path_handler.find_compat_data(appid)
if compatdata_path:
# Create Documents/My Games/{GameName} in Wine prefix
wine_docs_path = os.path.join(
str(compatdata_path),
"pfx",
"drive_c",
"users",
"steamuser",
"Documents",
"My Games",
docs_dir_name
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
compatdata_path = path_handler.find_compat_data(appid)
if compatdata_path:
prefix_user = os.path.join(
str(compatdata_path), "pfx", "drive_c", "users", "steamuser"
)
if game_name in appdata_only_dirs:
appdata_path = os.path.join(
prefix_user, "AppData", "Local", appdata_only_dirs[game_name]
)
try:
os.makedirs(appdata_path, exist_ok=True)
self.logger.info(f"Created Wine prefix AppData/Local directory: {appdata_path}")
except Exception as e:
self.logger.warning(f"Could not create AppData/Local directory {appdata_path}: {e}")
elif game_name in game_docs_dirs:
docs_dir_name = game_docs_dirs[game_name]
wine_docs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name
)
try:
os.makedirs(wine_docs_path, exist_ok=True)
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}")
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
self.logger.info(f"Created Wine prefix Documents directory: {wine_docs_path}")
except Exception as e:
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}")
# Don't fail completely - this is a first-launch optimization
else:
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation")
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time")
if game_name == "skyrimse":
self._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
elif game_name == "fallout4":
self._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
elif game_name == "skyrimvr":
self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
elif game_name == "fallout4vr":
self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
else:
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation")
return True
except Exception as e:
self.logger.error(f"Error creating required directories: {e}")
return False
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch.
Two files must exist before first launch to avoid USVFS and engine issues:
1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file.
USVFS builds its VFS tree at MO2 startup. If this path does not exist,
USVFS logs the directory as missing and skips adding Plugins.txt to the
initial tree. It then tries to reroute the file dynamically, but a mutex
deadlock (thread never releases the write mutex on first launch) blocks
the reroute. The game falls through to the real filesystem, finds no
Plugins.txt, and loads only base-game ESPs - causing a null form crash
for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll).
On second launch the directory exists, USVFS initialises correctly, no crash.
Pre-seeding an empty file gives USVFS its anchor; content is irrelevant
because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway.
2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub.
The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in
SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR)
hooks the Windows INI API, so the game reads the real prefix path directly,
not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses
the prompt. PPR redirects all subsequent reads to the active profile once
it loads, so this stub is never read again after early engine init.
Only created if the file does not already exist.
"""
# Fix 1: empty Plugins.txt anchor for USVFS
appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition")
plugins_txt = os.path.join(appdata_sse, "Plugins.txt")
try:
os.makedirs(appdata_sse, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
# Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup
skyrimprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
)
try:
if not os.path.exists(skyrimprefs_path):
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
f.write("[General]\nbDownloadCC=1\n")
self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}")
else:
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Fallout 4 needs on first launch.
1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4.
INI stub for CC popup suppression is intentionally omitted until the correct
key name in Fallout4Prefs.ini is confirmed via testing.
"""
appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_fo4, "Plugins.txt")
try:
os.makedirs(appdata_fo4, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Skyrim VR needs on first launch.
1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE applies to VR.
2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys:
- bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour
as Skyrim SE; fires before PPR hooks the INI API).
- bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom
tutorial on first launch. Without this, SkyrimVR skips the main menu and
drops the user into the playroom, bypassing the modlist's startup sequence.
"""
appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_vr, "Plugins.txt")
try:
os.makedirs(appdata_vr, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
skyrimprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
)
try:
if not os.path.exists(skyrimprefs_path):
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n")
self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}")
else:
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch.
1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR.
INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini
has not been confirmed via testing.
"""
appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt")
try:
os.makedirs(appdata_fo4vr, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")

View File

@@ -64,7 +64,7 @@ class FilesystemSteamMixin:
default_path = Path.home() / ".steam/steam/steamapps/common"
if default_path.is_dir():
logger.warning(f"Using default Steam library path: {default_path}")
logger.info(f"Using default Steam library path: {default_path}")
return default_path
logger.error("No valid Steam library found via vdf or at default location.")

View File

@@ -18,7 +18,11 @@ class GameDetector:
'fallout3': ['Fallout 3'],
'oblivion': ['Oblivion'],
'starfield': ['Starfield'],
'oblivion_remastered': ['Oblivion Remastered']
'oblivion_remastered': ['Oblivion Remastered'],
'skyrimvr': ['Skyrim VR'],
'fallout4vr': ['Fallout 4 VR'],
'cp2077': ['Cyberpunk 2077'],
'bg3': ["Baldur's Gate 3"],
}
def detect_game_type(self, modlist_name: str) -> Optional[str]:
@@ -26,9 +30,17 @@ class GameDetector:
modlist_lower = modlist_name.lower()
# Check for game-specific keywords in modlist name
# Check for Oblivion Remastered first since "oblivion" is a substring
# Check more specific types before their generic parents
if any(keyword in modlist_lower for keyword in ['oblivion remastered', 'oblivionremastered', 'oblivion_remastered']):
return 'oblivion_remastered'
elif any(keyword in modlist_lower for keyword in ['skyrim vr', 'skyrimvr']):
return 'skyrimvr'
elif any(keyword in modlist_lower for keyword in ['fallout 4 vr', 'fallout4vr', 'fo4vr']):
return 'fallout4vr'
elif any(keyword in modlist_lower for keyword in ['cyberpunk', 'cp2077', 'cyberpunk 2077']):
return 'cp2077'
elif any(keyword in modlist_lower for keyword in ["baldur's gate 3", 'baldursgate3', 'bg3']):
return 'bg3'
elif any(keyword in modlist_lower for keyword in ['skyrim', 'sse', 'skse', 'dragonborn', 'dawnguard']):
return 'skyrim'
elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']):
@@ -134,9 +146,37 @@ class GameDetector:
'min_proton_version': '8.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks']
}
},
'skyrimvr': {
'launcher': 'SKSE',
'min_proton_version': '6.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'],
'notes': 'SteamVR must be installed separately',
},
'fallout4vr': {
'launcher': 'F4SE',
'min_proton_version': '6.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'],
'notes': 'SteamVR must be installed separately',
},
'cp2077': {
'launcher': 'redmod',
'min_proton_version': '8.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'],
'notes': 'Requires WINEDLLOVERRIDES=version=n,b;winmm=n,b for Red4ext/CET. Rootbuilder must use COPY mode.',
},
'bg3': {
'launcher': 'bg3_dx11',
'min_proton_version': '8.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'],
'notes': 'Rootbuilder must use COPY mode.',
},
}
return requirements.get(game_type, {})
def detect_mods(self, modlist_path: Path) -> List[Dict]:

View File

@@ -2,7 +2,6 @@
from pathlib import Path
import os
import logging
import requests
import re
from typing import Optional
@@ -147,52 +146,14 @@ class ModlistConfigurationMixin:
print("───────────────────────────────────────────────────────────────────")
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
self.logger.info("User confirmed completion of manual steps.")
# Step 3: Download and apply curated user.reg.modlist and system.reg.modlist
# Step 3: Apply targeted registry tweaks (replaces wholesale curated reg file overwrite)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration")
self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...")
status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration")
self.logger.info("Step 3: Applying modlist registry tweaks...")
try:
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
if not prefix_path_str or not os.path.isdir(prefix_path_str):
raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.")
user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist"
user_reg_dest = Path(prefix_path_str) / "user.reg"
response = requests.get(user_reg_url, verify=True)
response.raise_for_status()
with open(user_reg_dest, "wb") as f:
f.write(response.content)
self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}")
system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist"
system_reg_dest = Path(prefix_path_str) / "system.reg"
response = requests.get(system_reg_url, verify=True)
response.raise_for_status()
with open(system_reg_dest, "wb") as f:
f.write(response.content)
self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}")
self._apply_modlist_registry_tweaks()
except Exception as e:
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}")
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}")
return False
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
# 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
self.logger.warning("Modlist registry tweaks failed (non-fatal): %s", e)
# Step 4: Install Wine Components
if status_callback:
@@ -258,18 +219,12 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} {failure_msg}")
# Continue but user should be aware of potential issues
# Step 4.6: Enable dotfiles visibility for Wine prefix
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility")
self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...")
# Step 4.6: Audit final registry state - confirms all writes survived winetricks
self.logger.info("Step 4.6: Auditing registry state...")
try:
if self.protontricks_handler.enable_dotfiles(self.appid):
self.logger.info("Dotfiles visibility enabled successfully")
else:
self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)")
self._audit_registry_state()
except Exception as e:
self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)")
self.logger.info("Step 4.6: Enabling dotfiles visibility... Done")
self.logger.warning("Registry audit failed (non-fatal): %s", e)
# Step 4.7: Create Wine prefix Documents directories for USVFS
# Critical for USVFS profile INI virtualization on first launch
@@ -277,17 +232,40 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...")
try:
if self.appid and self.game_var:
# Map game_var to game_name for create_required_dirs
if self.appid:
# Map detected game type to the key expected by create_required_dirs
game_name_map = {
"skyrim": "skyrimse",
"skyrimspecialedition": "skyrimse",
"skyrimvr": "skyrimvr",
"fallout": "fallout4",
"fallout4": "fallout4",
"fo4": "fallout4",
"fallout4vr": "fallout4vr",
"fnv": "falloutnv",
"falloutnv": "falloutnv",
"oblivion": "oblivion",
"enderalspecialedition": "enderalse"
"enderal": "enderalse",
"enderalspecialedition": "enderalse",
"bg3": "bg3",
"baldursgate3": "bg3",
"cp2077": "cp2077",
"starfield": "starfield",
}
game_name = game_name_map.get(self.game_var.lower(), None)
game_name = game_name_map.get((self.game_var or '').lower(), None)
# Fallback: read gameName= directly from ModOrganizer.ini when loader-based
# detection returned Unknown (e.g. Enderal uses a non-SKSE launcher variant)
if not game_name and self.modlist_dir:
try:
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
_detected = detect_game_type_from_modlist(str(self.modlist_dir))
if _detected:
game_name = game_name_map.get(_detected, _detected)
self.logger.info(f"Step 4.7: game type resolved via gameName= fallback: {_detected} -> {game_name}")
except Exception as _fe:
self.logger.debug(f"Step 4.7 fallback detection failed: {_fe}")
if game_name:
appid_str = str(self.appid)
if self.filesystem_handler.create_required_dirs(game_name, appid_str):
@@ -295,13 +273,42 @@ class ModlistConfigurationMixin:
else:
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
else:
self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping")
self.logger.debug(f"Game {self.game_var!r} not in directory creation map, skipping")
else:
self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation")
self.logger.warning("AppID not available, skipping Wine prefix Documents directory creation")
except Exception as e:
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
# Step 4.8: Configure nxmhandler.ini to suppress MO2 NXM registration popup
self.logger.info("Step 4.8: Configuring nxmhandler.ini...")
try:
self._configure_nxmhandler_ini()
except Exception as e:
self.logger.debug(f"nxmhandler.ini configuration failed (non-critical): {e}")
self.logger.info("Step 4.8: Configuring nxmhandler.ini... Done")
# Step 4.9: Inject game install path registry entries (FNV/FO3/Enderal/CP2077/BG3).
# Required so the game launcher and engine can locate the base game when
# MO2 is running inside the Proton prefix. Idempotent: safe to run on
# reinstall or re-configure.
self.logger.info("Step 4.9: Injecting game registry entries...")
try:
compatdata_path = self.path_handler.find_compat_data(str(self.appid))
if compatdata_path:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.platform_detection_service import PlatformDetectionService
_svc = AutomatedPrefixService(SystemInfo(
is_steamdeck=PlatformDetectionService.get_instance().is_steamdeck
))
_svc._inject_game_registry_entries(str(compatdata_path), self.game_var or '')
else:
self.logger.debug("Compatdata path not found for game registry injection, skipping")
except Exception as e:
self.logger.warning("Game registry injection failed (non-fatal): %s", e)
self.logger.info("Step 4.9: Injecting game registry entries... Done")
# Step 5: Verify ownership of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
@@ -328,6 +335,10 @@ class ModlistConfigurationMixin:
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done")
# Step 6.1: BG3-specific patches to ModOrganizer.ini and MO2 plugins
self._patch_bg3_mod_settings_plugin()
self._set_bg3_rootbuilder_copy_mode()
# Step 6.5: Handle symlinked downloads directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
@@ -419,6 +430,21 @@ class ModlistConfigurationMixin:
self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)")
else:
self.logger.warning("Could not set download_directory in ModOrganizer.ini")
elif modlist_ini_path_obj.is_file():
# Configure Existing / Configure New flows: no explicit download_dir is set, but the
# INI may have duplicate or mangled entries from the original Wabbajack install.
# Read the first valid value, then re-write all occurrences to that value so MO2
# reads the correct path regardless of which occurrence it picks up last.
existing_linux = self.path_handler.get_download_directory_linux_path(modlist_ini_path_obj)
if existing_linux:
if self.path_handler.set_download_directory(
modlist_ini_path_obj, existing_linux, self.modlist_sdcard
):
self.logger.info("Normalised download_directory entries in ModOrganizer.ini")
else:
self.logger.warning("Could not normalise download_directory in ModOrganizer.ini")
else:
self.logger.debug("No existing download_directory value found in ModOrganizer.ini; skipping normalisation")
# Step 8.5: Align /home vs /var/home basis for Z: paths to match modlist install directory.
# This is intentionally separate from broad binary-path rewriting so it still runs when
@@ -584,6 +610,47 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings")
self._re_enforce_windows_10_mode()
# Step 15: Apply tool compatibility settings (xEdit, Pandora, DLL overrides).
# Only runs for standard Skyrim SE/AE modlists. Non-Skyrim games (Enderal, FNV,
# FO3, etc.) are excluded because the mscoree AppDefault targets SkyrimSE.exe,
# which is also Enderal's executable, causing a crash on those modlists.
_special_type = self.detect_special_game_type(self.modlist_dir)
try:
from jackify.backend.handlers.config_handler import ConfigHandler
if ConfigHandler().get('auto_tool_compat', True) and _special_type is None:
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying tool compatibility settings")
self.logger.info("Step 15: Applying tool compatibility settings...")
compatdata_path = str(wineprefix).replace("/pfx", "").rstrip("/")
wine_bin = self._find_wine_binary_for_registry()
if compatdata_path and wine_bin:
from jackify.backend.services.tool_config_service import apply_tool_config
apply_tool_config(
compatdata_path,
wine_bin,
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
install_dotnet9_sdk=True,
install_fxc2_d3dcompiler=True,
)
self.logger.info("Step 15: Tool compatibility settings applied")
else:
self.logger.warning("Step 15: Could not resolve prefix path or wine binary - skipping tool compat")
elif _special_type is not None:
self.logger.info(f"Step 15: Skipping tool compat for {_special_type} modlist")
except Exception as e:
self.logger.warning("Step 15: Tool compatibility settings failed (non-fatal): %s", e)
# Step 16: Nemesis compatibility setup (symlink + workingDirectory fix)
try:
from jackify.backend.services.tool_config_service import setup_nemesis_compatibility
setup_nemesis_compatibility(
modlist_dir=self.modlist_dir,
stock_game_path=self.stock_game_path,
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
)
except Exception as e:
self.logger.warning("Step 16: Nemesis setup failed (non-fatal): %s", e)
return True # Return True on success
def run_modlist_configuration_phase(self, context: dict = None) -> bool:
@@ -597,6 +664,48 @@ class ModlistConfigurationMixin:
status_callback = context.get('status_callback') if context else None
return self._execute_configuration_steps(status_callback=status_callback)
def _configure_nxmhandler_ini(self) -> None:
"""
Set noregister=true in nxmhandler.ini in the MO2 install directory.
MO2 reads this flag on startup and skips the NXM handler registration
popup when it is true. On Linux, MO2's NXM handler cannot be registered
usefully via Wine; Jackify will become its own NXM handler in a later cycle.
Safe to apply on every configuration run - always correct on Linux.
"""
if not self.modlist_dir:
return
nxm_ini_path = os.path.join(self.modlist_dir, "nxmhandler.ini")
try:
if os.path.exists(nxm_ini_path):
with open(nxm_ini_path, 'r', encoding='utf-8') as f:
content = f.read()
if re.search(r'(?im)^\s*noregister\s*=\s*true\s*$', content):
self.logger.debug("nxmhandler.ini noregister already true, skipping")
return
# Replace existing noregister=... line if present, otherwise inject after [General]
if re.search(r'(?im)^\s*noregister\s*=', content):
content = re.sub(r'(?im)^\s*noregister\s*=.*$', 'noregister=true', content)
elif re.search(r'(?im)^\s*\[General\]', content):
content = re.sub(r'(?im)(^\s*\[General\]\s*\n)', r'\1noregister=true\n', content)
else:
content += '\n[General]\nnoregister=true\n'
with open(nxm_ini_path, 'w', encoding='utf-8') as f:
f.write(content)
self.logger.info(f"Set noregister=true in {nxm_ini_path}")
else:
# MO2 creates nxmhandler.ini on first run; pre-create with the flag set
with open(nxm_ini_path, 'w', encoding='utf-8') as f:
f.write("[General]\nnoregister=true\n")
self.logger.info(f"Created nxmhandler.ini with noregister=true: {nxm_ini_path}")
except Exception as e:
self.logger.warning(f"Could not configure nxmhandler.ini: {e}")
def _prompt_or_set_resolution(self):
# If on Steam Deck, set 1280x800 automatically
if self._is_steam_deck():
@@ -617,3 +726,73 @@ class ModlistConfigurationMixin:
else:
self.selected_resolution = None
self.logger.info("Resolution setup skipped by user.")
def _patch_bg3_mod_settings_plugin(self) -> None:
"""
Fix a bug in the BG3 MO2 plugin (Alvadus/BG3-MO2-Unofficial-Plugin) where
mods_order_node is conditionally created but unconditionally referenced.
Bug present in upstream source as of 2026-03; author not yet notified.
Safe to apply: always creating the ModOrder node is valid BG3 XML regardless of mod count.
"""
import os
if not self.modlist_dir:
return
plugin_path = os.path.join(
str(self.modlist_dir),
"plugins", "basic_games", "games", "baldursgate3", "modSettings.py"
)
if not os.path.exists(plugin_path):
self.logger.debug("BG3 modSettings.py plugin not found, skipping patch")
return
try:
with open(plugin_path, 'r', encoding='utf-8') as f:
content = f.read()
buggy = (
" if len(mod_settings) > 1:\n"
" mods_order_node = ET.SubElement(children, \"node\")\n"
" mods_order_node.set(\"id\", \"ModOrder\")"
)
fixed = (
" mods_order_node = ET.SubElement(children, \"node\")\n"
" mods_order_node.set(\"id\", \"ModOrder\")"
)
if buggy in content:
content = content.replace(buggy, fixed)
with open(plugin_path, 'w', encoding='utf-8') as f:
f.write(content)
self.logger.info("Applied modSettings.py patch for BG3 MO2 plugin")
elif fixed in content:
self.logger.debug("BG3 modSettings.py already patched, skipping")
else:
self.logger.warning("BG3 modSettings.py patch target not found - plugin may have changed upstream")
except Exception as e:
self.logger.warning(f"Could not patch BG3 modSettings.py: {e} (non-critical, continuing)")
def _set_bg3_rootbuilder_copy_mode(self) -> None:
"""
Switch Root Builder to copy mode in ModOrganizer.ini for BG3 modlists.
Link mode (the shipped default) fails on Linux - files are not accessible
to the game process across the Wine boundary. Copy mode works reliably.
Applied unconditionally: copy mode is safe regardless of drive layout.
Detected by presence of RootBuilder keys rather than game_var (unreliable for BG3).
"""
import os, re
if not self.modlist_dir:
return
mo2_ini = os.path.join(str(self.modlist_dir), "ModOrganizer.ini")
if not os.path.exists(mo2_ini):
self.logger.debug("ModOrganizer.ini not found, skipping Root Builder copy mode patch")
return
try:
with open(mo2_ini, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
if 'RootBuilder\\' not in content and 'RootBuilder/' not in content:
self.logger.debug("Root Builder not present in ModOrganizer.ini, skipping")
return
content = re.sub(r'^(RootBuilder\\copyfiles\s*=).*$', r'\1**', content, flags=re.MULTILINE)
content = re.sub(r'^(RootBuilder\\linkfiles\s*=).*$', r'\1', content, flags=re.MULTILINE)
with open(mo2_ini, 'w', encoding='utf-8') as f:
f.write(content)
self.logger.info("Set Root Builder to copy mode in ModOrganizer.ini")
except Exception as e:
self.logger.warning(f"Could not set Root Builder copy mode: {e} (non-critical, continuing)")

View File

@@ -253,12 +253,13 @@ class ModlistDetectionMixin:
modlist_path = Path(self.modlist_dir)
common_names = [
"Stock Game",
"Game Root",
"StockGame",
"STOCK GAME",
"Stock Game Folder",
"Stock Folder",
"Skyrim Stock",
Path("root/Skyrim Special Edition")
Path("root/Skyrim Special Edition"),
"Game Root",
]
found_path = None
@@ -326,6 +327,15 @@ class ModlistDetectionMixin:
if mo2_ini.exists():
try:
content = mo2_ini.read_text(errors='ignore').lower()
# Extract gameName= for authoritative game type checks.
# Full-content scans can false-positive on plugin setting keys
# (e.g. enable_skyrimVR=false in a Skyrim SE ini).
game_name_value = ""
for _line in content.splitlines():
stripped_line = _line.strip()
if stripped_line.startswith("gamename="):
game_name_value = stripped_line[len("gamename="):]
break
if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content:
self.logger.info("Detected FNV via ModOrganizer.ini markers")
return "fnv"
@@ -335,6 +345,18 @@ class ModlistDetectionMixin:
if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']):
self.logger.info("Detected Enderal via ModOrganizer.ini markers")
return "enderal"
if 'cyberpunk 2077' in content or 'cyberpunk2077' in content or 'cp2077' in content:
self.logger.info("Detected Cyberpunk 2077 via ModOrganizer.ini markers")
return "cp2077"
if "baldur's gate 3" in content or 'baldursgate3' in content or 'bg3' in content:
self.logger.info("Detected Baldur's Gate 3 via ModOrganizer.ini markers")
return "bg3"
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
self.logger.info("Detected SkyrimVR via ModOrganizer.ini gameName")
return "skyrimvr"
if 'fallout 4 vr' in game_name_value or 'fallout4vr' in game_name_value:
self.logger.info("Detected Fallout 4 VR via ModOrganizer.ini gameName")
return "fallout4vr"
except Exception as e:
self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}")
except Exception:
@@ -364,6 +386,15 @@ class ModlistDetectionMixin:
if enderal_launcher.exists():
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
return "enderal"
cp2077_exe = base / "Cyberpunk2077.exe"
if cp2077_exe.exists():
self.logger.info(f"Detected Cyberpunk 2077 modlist: found Cyberpunk2077.exe in '{base}'")
return "cp2077"
bg3_exe = base / "bg3.exe"
bg3_dx11_exe = base / "bg3_dx11.exe"
if bg3_exe.exists() or bg3_dx11_exe.exists():
self.logger.info(f"Detected BG3 modlist: found BG3 executable in '{base}'")
return "bg3"
# Final heuristic using game_var
try:
@@ -379,6 +410,18 @@ class ModlistDetectionMixin:
if 'enderal' in gt:
self.logger.info("Heuristic detection: game_var indicates Enderal")
return "enderal"
if 'cyberpunk' in gt or 'cp2077' in gt:
self.logger.info("Heuristic detection: game_var indicates Cyberpunk 2077")
return "cp2077"
if "baldur" in gt or 'bg3' in gt:
self.logger.info("Heuristic detection: game_var indicates BG3")
return "bg3"
if 'skyrim vr' in gt or 'skyrimvr' in gt:
self.logger.info("Heuristic detection: game_var indicates SkyrimVR")
return "skyrimvr"
if 'fallout 4 vr' in gt or 'fallout4vr' in gt:
self.logger.info("Heuristic detection: game_var indicates Fallout 4 VR")
return "fallout4vr"
except Exception:
pass

View File

@@ -61,32 +61,9 @@ class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWi
Handles operations related to modlist detection and configuration
"""
# Dictionary mapping modlist name patterns (lowercase, spaces optional)
# to lists of additional Wine components or special actions.
MODLIST_SPECIFIC_COMPONENTS = {
# Pattern: [component1, component2, ... or special_action_string]
"wildlander": ["dotnet48"], # Example from bash script
"licentia": ["dotnet8"], # Example from bash script (needs special handling)
"nolvus": ["dotnet6", "dotnet7"], # Example
# Add other modlists and their specific needs here
# e.g., "fallout4_anotherlife": ["some_component"]
}
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
# dotnet4.x components disabled in v0.1.6.2 -- replaced with universal registry fixes
MODLIST_WINE_COMPONENTS = {
# "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
# "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
# "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
# "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
}
MODLIST_SPECIFIC_COMPONENTS: dict = {}
MODLIST_WINE_COMPONENTS: dict = {}
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
mo2_path: Optional[Union[str, Path]] = None,

View File

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

View File

@@ -59,33 +59,11 @@ class ModlistWineOpsMixin:
self.logger.error("Could not locate Steam's config.vdf file.")
return False, 'config_vdf_missing'
# Add a short delay to allow Steam to potentially finish writing changes
self.logger.debug("Waiting 2 seconds before reading config.vdf...")
time.sleep(2)
try:
self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}")
# CORRECTION: Use the vdf library directly here, not VDFHandler
self.logger.debug(f"Loading config.vdf: {config_vdf_path}")
with open(str(config_vdf_path), 'r') as f:
config_data = vdf.load(f, mapper=vdf.VDFDict)
config_data = vdf.load(f, mapper=vdf.VDFDict)
# --- Write full config.vdf to a debug file ---
debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt")
with open(debug_dump_path, "w") as dump_f:
json.dump(config_data, dump_f, indent=2)
self.logger.info(f"Full config.vdf dumped to {debug_dump_path}")
# --- Log only the relevant section for this AppID ---
steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {})
compat_mapping = steam_config_section.get('CompatToolMapping', {})
app_mapping = compat_mapping.get(appid_to_check, {})
self.logger.debug("───────────────────────────────────────────────────────────────────")
self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):")
self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2))
self.logger.debug("───────────────────────────────────────────────────────────────────")
self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}")
# --- End Debugging ---
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
compat_mapping = steam_config_section.get('CompatToolMapping', {})
app_mapping = compat_mapping.get(appid_to_check, {})
@@ -152,14 +130,24 @@ class ModlistWineOpsMixin:
self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.")
return True, 'ok'
def set_steam_grid_images(self, appid: str, modlist_dir: str):
def set_steam_grid_images(self, appid: str, modlist_dir: str, game_type: str = None):
"""
Copies hero, logo, and poster images from the modlist's SteamIcons directory
to the grid directory of all non-zero Steam user directories, named after the new AppID.
Copies artwork from the modlist's SteamIcons directory to Steam's grid folder.
Falls back to SteamGridDB if no SteamIcons directory is present and an API key
is configured.
"""
if modlist_dir:
try:
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
detected_game_type = detect_game_type_from_modlist(modlist_dir)
if detected_game_type:
game_type = detected_game_type
except Exception as e:
self.logger.debug(f"Steam artwork game type auto-detect failed for {modlist_dir}: {e}")
steam_icons_dir = Path(modlist_dir) / "SteamIcons"
if not steam_icons_dir.is_dir():
self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.")
self._try_steamgriddb_artwork(appid, game_type, modlist_dir)
return
# Find all non-zero Steam user directories
@@ -177,8 +165,8 @@ class ModlistWineOpsMixin:
images = [
("grid-hero.png", f"{appid}_hero.png"),
("grid-logo.png", f"{appid}_logo.png"),
("grid-tall.png", f"{appid}.png"),
("grid-tall.png", f"{appid}p.png"),
("grid-wide.png", f"{appid}.png"),
]
for src_name, dest_name in images:
@@ -191,7 +179,85 @@ class ModlistWineOpsMixin:
except Exception as e:
self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}")
else:
self.logger.warning(f"Image {src_path} not found; skipping.")
self.logger.debug(f"Image {src_path} not found; skipping.")
# Tenfoot: use explicit file if provided, otherwise resize the landscape grid
tenfoot_src = steam_icons_dir / "grid-tenfoot.png"
tenfoot_dest = grid_dir / f"{appid}_tenfoot.png"
wide_src = steam_icons_dir / "grid-wide.png"
if tenfoot_src.exists():
try:
shutil.copyfile(tenfoot_src, tenfoot_dest)
self.logger.info(f"Copied {tenfoot_src} to {tenfoot_dest}")
except Exception as e:
self.logger.error(f"Failed to copy tenfoot image: {e}")
elif wide_src.exists():
try:
from PySide6.QtGui import QImage
img = QImage(str(wide_src))
if not img.isNull():
scaled = img.scaled(600, 350)
scaled.save(str(tenfoot_dest))
self.logger.info(f"Generated tenfoot image from landscape: {tenfoot_dest}")
else:
self.logger.warning(f"Could not load landscape image for tenfoot generation: {wide_src}")
except Exception as e:
self.logger.warning(f"Could not generate tenfoot image: {e}")
def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None):
"""Fetch default artwork from SteamGridDB when no modlist-provided SteamIcons exist."""
if not game_type and modlist_dir:
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
game_type = detect_game_type_from_modlist(modlist_dir)
if not game_type:
self.logger.warning(f"SteamGridDB fallback skipped: could not detect game type for {modlist_dir}")
return
userdata_base = Path.home() / ".steam/steam/userdata"
if not userdata_base.is_dir():
return
import tempfile
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
from jackify.backend.services.steamgriddb_service import fetch_artwork
count = fetch_artwork(game_type, tmp_dir)
if count == 0:
self.logger.debug(f"SteamGridDB returned no artwork for game type: {game_type}")
return
for user_dir in userdata_base.iterdir():
if not user_dir.is_dir() or user_dir.name == "0":
continue
grid_dir = user_dir / "config/grid"
grid_dir.mkdir(parents=True, exist_ok=True)
images = [
("grid-tall.png", f"{appid}p.png"),
("grid-wide.png", f"{appid}.png"),
("grid-hero.png", f"{appid}_hero.png"),
("grid-logo.png", f"{appid}_logo.png"),
]
for src_name, dest_name in images:
src = tmp_dir / src_name
if src.exists():
try:
shutil.copyfile(src, grid_dir / dest_name)
except Exception as e:
self.logger.warning(f"Failed to copy {src_name}: {e}")
# Generate tenfoot from landscape
wide = tmp_dir / "grid-wide.png"
if wide.exists():
try:
from PySide6.QtGui import QImage
img = QImage(str(wide))
if not img.isNull():
img.scaled(600, 350).save(str(grid_dir / f"{appid}_tenfoot.png"))
except Exception as e:
self.logger.debug(f"Could not generate tenfoot: {e}")
self.logger.info(f"Applied SteamGridDB artwork for game type '{game_type}' ({count} images)")
def get_modlist_wine_components(self, modlist_name, game_var_full=None):
"""
@@ -206,12 +272,16 @@ class ModlistWineOpsMixin:
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
# Add game-specific extras
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
extras += ["d3dx9_43", "d3dx9"]
elif "cp2077" in game or "cyberpunk" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
elif "bg3" in game or "baldursgate" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
else:
# Unknown game type install the union of all known component sets
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "d3dx9_43", "d3dx9"]
# Unknown game type - install the union of all known component sets
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "d3dx9_43", "d3dx9"]
# Add modlist-specific extras
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
for key, components in self.MODLIST_WINE_COMPONENTS.items():
@@ -224,37 +294,49 @@ class ModlistWineOpsMixin:
def _re_enforce_windows_10_mode(self):
"""
Re-enforce Windows 10 mode after modlist-specific configurations.
This matches the legacy script behavior (line 1333) where Windows 10 mode
is re-applied after modlist-specific steps to ensure consistency.
Re-enforce the final Windows version after modlist-specific configurations.
Re-applies win10 after modlist-specific winetricks components, which can
leave the prefix at a lower version.
"""
try:
if not hasattr(self, 'appid') or not self.appid:
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
self.logger.warning("Cannot re-enforce Windows 11 mode - no AppID available")
return
from ..handlers.winetricks_handler import WinetricksHandler
from ..handlers.path_handler import PathHandler
# Get prefix path for the AppID
prefix_path = PathHandler.find_compat_data(str(self.appid))
if not prefix_path:
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
# Get prefix path for the AppID - must be compatdata/pfx/, not compatdata/
compatdata_path = PathHandler.find_compat_data(str(self.appid))
if not compatdata_path:
self.logger.warning("Cannot re-enforce Windows 11 mode - prefix path not found")
return
prefix_path = compatdata_path / "pfx"
# Use winetricks handler to set Windows 10 mode
# Use winetricks handler to set Windows 11 mode
winetricks_handler = WinetricksHandler()
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
if not wine_binary:
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
self.logger.warning("Cannot re-enforce Windows 11 mode - wine binary not found")
return
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations")
env = os.environ.copy()
env['WINEPREFIX'] = str(prefix_path)
env['WINE'] = wine_binary
result = subprocess.run(
[winetricks_handler.winetricks_path, '-q', 'win10'],
env=env,
capture_output=True,
text=True,
timeout=300
)
if result.returncode == 0:
self.logger.info("Windows 11 mode re-enforced after modlist-specific configurations")
else:
self.logger.warning("Could not set Windows 11 mode: %s", result.stderr)
except Exception as e:
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
self.logger.warning(f"Error re-enforcing Windows 11 mode: {e}")
def _handle_symlinked_downloads(self) -> bool:
"""
@@ -380,21 +462,17 @@ class ModlistWineOpsMixin:
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Shutdown any running wineserver processes to ensure clean slate
if wineserver_binary:
self.logger.debug("Shutting down wineserver before applying registry fixes...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Wineserver shutdown complete")
except Exception as e:
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
self._wait_for_wineserver(prefix_path)
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
# Use native .NET runtime instead of Wine's
self.logger.debug("Setting *mscoree=native DLL override...")
# Registry fix 1: Set *mscoree=native as a per-exe AppDefaults override for
# SkyrimSE.exe only. A global DllOverrides entry breaks .NET 9/10 bootstrap
# (Synthesis), because the override intercepts mscoree loading for ALL processes
# including the SDK host. Scoping it to SkyrimSE.exe isolates the fix to the
# game process without affecting Synthesis or any other .NET tool.
self.logger.debug("Setting *mscoree=native AppDefaults override for SkyrimSE.exe...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
@@ -430,43 +508,12 @@ class ModlistWineOpsMixin:
except Exception as e:
self.logger.warning(f"Registry flush failed (non-critical): {e}")
# VERIFICATION: Confirm the registry entries persisted
self.logger.info("Verifying registry entries were applied and persisted...")
verification_passed = True
# Verify *mscoree=native
verify_cmd1 = [
wine_binary, 'reg', 'query',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree'
]
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
self.logger.info("VERIFIED: *mscoree=native is set correctly")
ok = result1.returncode == 0 and result2.returncode == 0
if ok:
self.logger.info("Universal dotnet4.x compatibility fixes applied and flushed")
else:
self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}")
verification_passed = False
# Verify OnlyUseLatestCLR=1
verify_cmd2 = [
wine_binary, 'reg', 'query',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR'
]
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
verification_passed = False
# Both fixes applied and verified
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
return True
else:
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
return False
self.logger.error("One or more dotnet4.x registry commands failed - see errors above")
return ok
except Exception as e:
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
@@ -506,6 +553,204 @@ class ModlistWineOpsMixin:
self.logger.error(f"Error finding Wine binary: {e}")
return None
def _wait_for_wineserver(self, prefix_path: str) -> None:
"""Wait for wineserver to stop for the given prefix before direct file edits.
Harmless if wineserver is already stopped - exits immediately.
Prevents in-memory hive flush from overwriting direct .reg file edits.
"""
wine_binary = self._find_wine_binary_for_registry()
if not wine_binary:
self.logger.debug("No wine binary found; skipping wineserver wait")
return
wineserver = os.path.join(os.path.dirname(wine_binary), "wineserver")
if not os.path.exists(wineserver):
self.logger.debug("wineserver binary not found; skipping wait")
return
env = os.environ.copy()
env["WINEPREFIX"] = prefix_path
env["WINEDEBUG"] = "-all"
try:
subprocess.run([wineserver, "-w"], env=env, timeout=30, capture_output=True)
self.logger.debug("wineserver stopped for prefix %s", prefix_path)
except Exception as e:
self.logger.debug("wineserver wait returned non-zero (likely already stopped): %s", e)
def _apply_modlist_registry_tweaks(self) -> bool:
"""Write user.reg values required for modlist operation.
- FontSmoothing/Type/Gamma/Orientation (ClearType subpixel rendering)
- HIGHDPIAWARE (prevents Wine DPI scaling on tools)
- ShowDotFiles=Y (MO2 must see hidden dirs inside the prefix)
"""
try:
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
user_reg = os.path.join(prefix_path, "user.reg")
if not os.path.exists(user_reg):
self.logger.warning("user.reg not found at %s; skipping modlist registry tweaks", user_reg)
return False
self._wait_for_wineserver(prefix_path)
tweaks = [
(
"[Control Panel\\\\Desktop]",
'"FontSmoothing"',
'"2"',
),
(
"[Control Panel\\\\Desktop]",
'"FontSmoothingGamma"',
"dword:00000578",
),
(
"[Control Panel\\\\Desktop]",
'"FontSmoothingOrientation"',
"dword:00000001",
),
(
"[Control Panel\\\\Desktop]",
'"FontSmoothingType"',
"dword:00000002",
),
(
"[Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\AppCompatFlags\\\\Layers]",
'@',
'"~ HIGHDPIAWARE"',
),
(
"[Software\\\\Wine]",
'"ShowDotFiles"',
'"Y"',
),
]
with open(user_reg, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
for section, key, value in tweaks:
in_section = False
updated = False
insert_at = None
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.lower() == section.lower():
in_section = True
continue
if stripped.startswith("[") and in_section:
insert_at = i
break
if in_section and stripped.lower().startswith(key.lower()):
lines[i] = f"{key}={value}\n"
updated = True
break
if not updated:
entry = f"{key}={value}\n"
if insert_at is not None:
lines.insert(insert_at, entry)
elif in_section:
lines.append(entry)
else:
lines.append(f"\n{section}\n")
lines.append(entry)
with open(user_reg, "w", encoding="utf-8") as f:
f.writelines(lines)
self.logger.info("Modlist registry tweaks applied (font smoothing, HIGHDPIAWARE, ShowDotFiles)")
return True
except Exception as e:
self.logger.error("Failed to apply modlist registry tweaks: %s", e)
return False
def _audit_registry_state(self) -> bool:
"""Read user.reg and system.reg and log whether every expected value is present.
Returns True only when all checks pass. Logs a WARNING for each missing or
wrong value so the application log always carries a clear post-configuration
record of registry state.
"""
try:
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
user_reg = os.path.join(prefix_path, "user.reg")
system_reg = os.path.join(prefix_path, "system.reg")
def _read(path):
if not os.path.exists(path):
return ""
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
user_content = _read(user_reg)
system_content = _read(system_reg)
checks = [
# (description, file_content, expected_substring)
(
"ShowDotFiles=Y (user.reg)",
user_content,
'"ShowDotFiles"="Y"',
),
(
"FontSmoothing=2 (user.reg)",
user_content,
'"FontSmoothing"="2"',
),
(
"FontSmoothingType=2 (user.reg)",
user_content,
'"FontSmoothingType"=dword:00000002',
),
(
"FontSmoothingGamma (user.reg)",
user_content,
'"FontSmoothingGamma"=dword:00000578',
),
(
"FontSmoothingOrientation (user.reg)",
user_content,
'"FontSmoothingOrientation"=dword:00000001',
),
(
"HIGHDPIAWARE (user.reg)",
user_content,
'HIGHDPIAWARE',
),
(
"*mscoree=native (user.reg)",
user_content,
'"*mscoree"="native"',
),
(
"OnlyUseLatestCLR=1 (system.reg)",
system_content,
'"OnlyUseLatestCLR"=dword:00000001',
),
]
all_ok = True
for description, content, needle in checks:
if needle in content:
self.logger.info("Registry audit [OK] %s", description)
else:
self.logger.warning("Registry audit [MISSING] %s", description)
all_ok = False
if all_ok:
self.logger.info("Registry audit complete - all values confirmed present")
else:
self.logger.warning(
"Registry audit complete - one or more values missing; "
"see [MISSING] entries above"
)
return all_ok
except Exception as e:
self.logger.error("Registry audit failed with exception: %s", e)
return False
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
"""
Recursively search for wine binary within a Proton directory.
@@ -543,4 +788,3 @@ class ModlistWineOpsMixin:
except Exception as e:
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None

View File

@@ -180,5 +180,6 @@ class PathHandlerGameMixin:
self.stock_game_path = found_path
return True
self.stock_game_path = None
self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.")
searched = [str(modlist_path / n) for n in preferred_order]
self.logger.info(f"No common Stock Game/Game Root directory found (searched: {searched}). Will assume vanilla game path is needed for some operations.")
return True

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:
"""
Set download_directory in ModOrganizer.ini to the correct Wine path (Z: or D: for SD card).
Use only when download dir is known (e.g. Install a Modlist flow). Configure New/Existing leave as-is.
Replaces ALL occurrences of the key throughout the file - MO2 reads the last one, and
duplicate [General] sections from Wabbajack installs are common.
"""
if not modlist_ini_path.is_file() or not download_dir_linux_path:
return False
@@ -553,35 +554,62 @@ class PathHandlerMO2Mixin:
formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path)
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
in_general = False
download_line_idx = -1
for i, line in enumerate(lines):
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
in_general = True
continue
if in_general and re.match(r'^\s*\[', line):
break
if in_general and re.match(r'^\s*download_directory\s*=', line, re.IGNORECASE):
download_line_idx = i
break
new_line = f"download_directory = {formatted}\n"
if download_line_idx >= 0:
lines[download_line_idx] = new_line
replaced = [i for i, l in enumerate(lines) if re.match(r'^\s*download_directory\s*=', l, re.IGNORECASE)]
if replaced:
for i in replaced:
lines[i] = new_line
else:
if in_general:
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
if insert_idx >= 0:
# No existing entry - insert after [General]
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
if insert_idx >= 0:
insert_idx += 1
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
insert_idx += 1
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
insert_idx += 1
lines.insert(insert_idx, new_line)
lines.insert(insert_idx, new_line)
else:
lines.append("[General]\n")
lines.append(new_line)
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted}")
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted} ({len(replaced)} occurrence(s))")
return True
except Exception as e:
logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}")
return False
def get_download_directory_linux_path(self, modlist_ini_path: Path) -> Optional[str]:
"""
Read the first valid download_directory value from ModOrganizer.ini and convert to a Linux path.
Returns None if no valid Z: or D: path is found.
"""
if not modlist_ini_path.is_file():
return None
try:
with open(modlist_ini_path, 'r', encoding='utf-8-sig') as f:
lines = f.readlines()
except UnicodeDecodeError:
try:
with open(modlist_ini_path, 'r', encoding='latin-1') as f:
lines = f.readlines()
except Exception:
return None
except Exception:
return None
for line in lines:
m = re.match(r'^\s*download_directory\s*=\s*(.+)$', line, re.IGNORECASE)
if not m:
continue
raw = m.group(1).strip()
# Expect Z:\\path\\... or D:\\path\\... (MO2 doubles backslashes in the file)
drive_m = re.match(r'^([ZzDd]):(.+)$', raw)
if not drive_m:
continue
drive, rest = drive_m.group(1).upper(), drive_m.group(2)
# Collapse doubled backslashes back to single separators
rest = re.sub(r'\\\\', '/', rest).replace('\\', '/')
if drive == 'Z':
return '/' + rest.lstrip('/')
# D: (SD card) - return as-is with leading slash; caller handles sdcard prefix
return '/' + rest.lstrip('/')
return None

View File

@@ -90,7 +90,7 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
# Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining"
# Timestamp prefix is now optional engine no longer emits [HH:MM:SS].
# Timestamp prefix is now optional - engine no longer emits [HH:MM:SS].
self.timestamp_status_pattern = re.compile(
r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
re.IGNORECASE
@@ -157,10 +157,17 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
ParsedLine with extracted information
"""
result = ParsedLine(message=line.strip())
if not line.strip():
return result
# Suppress internal engine lines that are not user-facing
_suppress_prefixes = (
"Refreshing OAuth Token",
)
if any(line.strip().startswith(p) for p in _suppress_prefixes):
return ParsedLine()
# Try to extract phase information
phase_info = self._extract_phase(line)
if phase_info:

View File

@@ -20,11 +20,11 @@ class ProgressParserPhaseMixin:
phase = self._map_section_to_phase(section_name)
return (phase, section_match.group(1).strip())
# [FILE_PROGRESS] lines drive file activity only skip phase extraction for them
# [FILE_PROGRESS] lines drive file activity only - skip phase extraction for them
if '[FILE_PROGRESS]' in line:
return None
# Make the [timestamp] prefix optional engine no longer emits it.
# Make the [timestamp] prefix optional - engine no longer emits it.
action_match = re.search(
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
line,

View File

@@ -87,7 +87,7 @@ class ProtontricksCommandsMixin:
env['WINETRICKS'] = str(winetricks_path)
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
else:
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
cabextract_path = self._get_bundled_cabextract_path()
if cabextract_path:
cabextract_dir = str(cabextract_path.parent)
@@ -95,7 +95,7 @@ class ProtontricksCommandsMixin:
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
else:
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
else:
self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")

View File

@@ -74,7 +74,7 @@ class ProtontricksPrefixMixin:
self.logger.debug("ShowDotFiles already present in correct format in user.reg")
dotfiles_set_success = True
else:
self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
self.logger.info(f"user.reg not found at {user_reg_path}, creating it.")
with open(user_reg_path, 'w', encoding='utf-8') as f:
f.write('[Software\\\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n')
@@ -157,6 +157,10 @@ class ProtontricksPrefixMixin:
self.logger.info("=" * 80)
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"
# Preserve the desktop display variables for Step 4. The validated fix
# for the blank taskbar popup regression was keeping DISPLAY available.
# Do not strip extra desktop activation vars here without a reproduced,
# evidence-backed need.
if self.which_protontricks == 'native':
winetricks_path = self._get_bundled_winetricks_path()
@@ -164,7 +168,7 @@ class ProtontricksPrefixMixin:
env['WINETRICKS'] = str(winetricks_path)
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
else:
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
cabextract_path = self._get_bundled_cabextract_path()
if cabextract_path:
cabextract_dir = str(cabextract_path.parent)
@@ -172,7 +176,7 @@ class ProtontricksPrefixMixin:
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
else:
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
else:
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")

View File

@@ -81,7 +81,7 @@ class ProtontricksSteamMixin:
self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
if steamdeck:
self.logger.warning("Checking for SDCard and setting permissions appropriately...")
self.logger.info("Checking for SDCard and setting permissions appropriately...")
result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env)
for line in result.stdout.splitlines():
if "/run/media" in line:

View File

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

View File

@@ -165,7 +165,7 @@ class ShortcutDiscoveryMixin:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
return str(int(appid) & 0xFFFFFFFF)
self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
self.logger.debug(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
return None
except Exception as e:

View File

@@ -300,7 +300,6 @@ class ShortcutVDFManagementMixin:
try:
shutil.copy2(safe_backup, shortcuts_file)
self.logger.info(f"Restored shortcuts.vdf from pre-restart backup")
print("Restored shortcuts file after Steam restart")
return
except Exception as e:
self.logger.error(f"Failed to restore from pre-restart backup: {e}")
@@ -310,9 +309,8 @@ class ShortcutVDFManagementMixin:
try:
shutil.copy2(backup, shortcuts_file)
self.logger.info(f"Restored shortcuts.vdf from regular backup")
print("Restored shortcuts file after Steam restart")
except Exception as e:
self.logger.error(f"Failed to restore from backup: {e}")
print("Failed to restore shortcuts file. You may need to recreate your shortcut.")
self.logger.warning("shortcuts.vdf restore failed - shortcut may need to be recreated")
else:
self.logger.info(f"shortcuts.vdf verified intact after restart")

View File

@@ -266,7 +266,7 @@ class ProcessManager:
pass
cleanup_attempts += 1
finally:
# Always close pipes unblocks threads blocked on read(1) or iterating stderr
# Always close pipes - unblocks threads blocked on read(1) or iterating stderr
if self.proc:
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
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"
target_mod_dir = mods_dir / mod_folder_name
if skip_copy:
# TTW was installed directly to target_mod_dir no copy needed
# TTW was installed directly to target_mod_dir - no copy needed
logger.info("TTW already at target location, skipping copy: %s", target_mod_dir)
else:
logger.info("Copying TTW output to %s", target_mod_dir)

View File

@@ -26,18 +26,21 @@ class WabbajackParser:
'Fallout4': 'fallout4',
'FalloutNewVegas': 'falloutnv',
'Oblivion': 'oblivion',
'Skyrim': 'skyrim', # Legacy Skyrim
'Fallout3': 'fallout3', # For completeness
'SkyrimVR': 'skyrim', # Treat as Skyrim
'Fallout4VR': 'fallout4', # Treat as Fallout 4
'Enderal': 'enderal', # Enderal: Forgotten Stories
'EnderalSpecialEdition': 'enderal', # Enderal SE
'Skyrim': 'skyrim',
'Fallout3': 'fallout3',
'SkyrimVR': 'skyrimvr',
'Fallout4VR': 'fallout4vr',
'Enderal': 'enderal',
'EnderalSpecialEdition': 'enderal',
'Cyberpunk2077': 'cp2077',
'BaldursGate3': 'bg3',
}
# List of supported games in Jackify
self.supported_games = [
'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion',
'starfield', 'oblivion_remastered', 'enderal'
'starfield', 'oblivion_remastered', 'enderal',
'skyrimvr', 'fallout4vr', 'bg3',
]
def parse_wabbajack_game_type(self, wabbajack_path: Path) -> Optional[tuple]:
@@ -98,6 +101,23 @@ class WabbajackParser:
self.logger.error(f"Error parsing .wabbajack file {wabbajack_path}: {e}")
return None
def parse_wabbajack_readme(self, wabbajack_path: Path) -> Optional[str]:
"""
Extract the readme URL from a .wabbajack file.
Returns the URL string, or None if not present or unreadable.
"""
try:
with zipfile.ZipFile(wabbajack_path, 'r') as zip_file:
modlist_files = [f for f in zip_file.namelist() if f in ['modlist', 'modlist.json']]
if not modlist_files:
return None
with zip_file.open(modlist_files[0]) as f:
data = json.load(f)
return data.get('Readme') or None
except Exception:
return None
def is_supported_game(self, game_type: str) -> bool:
"""
Check if a game type is supported by Jackify's post-install configuration.
@@ -128,12 +148,16 @@ class WabbajackParser:
"""
display_names = {
'skyrim': 'Skyrim Special Edition',
'fallout4': 'Fallout 4',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
'enderal': 'Enderal',
'skyrimvr': 'Skyrim VR',
'fallout4vr': 'Fallout 4 VR',
'cp2077': 'Cyberpunk 2077',
'bg3': "Baldur's Gate 3",
}
return [display_names.get(game, game) for game in self.supported_games]

View File

@@ -6,7 +6,6 @@ Extracted from wine_utils for file-size and domain separation.
"""
import os
import re
import subprocess
import logging
from typing import Optional
@@ -56,39 +55,6 @@ class WineUtilsConfigMixin:
logger.error(f"Error performing additional tasks: {e}")
return False
@staticmethod
def modlist_specific_steps(modlist: str, appid: str) -> bool:
"""Perform modlist-specific configuration steps. Returns True on success."""
try:
modlist_configs = {
"wildlander": ["dotnet48", "dotnet472", "vcrun2019"],
"septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"],
"masterstroke": ["dotnet48", "dotnet472"],
"diablo": ["dotnet48", "dotnet472"],
"living_skyrim": ["dotnet48", "dotnet472", "dotnet462"],
"nolvus": ["dotnet8"]
}
modlist_lower = modlist.lower().replace(" ", "")
if "wildlander" in modlist_lower:
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
return True
for pattern, components in modlist_configs.items():
if re.search(pattern.replace("|", "|.*"), modlist_lower):
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
for component in components:
if component == "dotnet8":
logger.info("Downloading .NET 8 Runtime")
pass
else:
logger.info(f"Installing {component}...")
pass
return True
logger.debug(f"No specific steps needed for {modlist}")
return True
except Exception as e:
logger.error(f"Error performing modlist-specific steps: {e}")
return False
@staticmethod
def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool:
"""Set up Fallout New Vegas launch options. Returns True on success."""

View File

@@ -136,7 +136,7 @@ class WineUtilsProtonMixin:
if fallback_path != 'auto':
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
if fallback_wine_bin.is_file():
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
logger.info(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
return str(fallback_wine_bin)
except Exception:
pass

View File

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

View File

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

View File

@@ -19,66 +19,68 @@ logger = logging.getLogger(__name__)
class GameUtilsMixin:
"""Mixin for game-related utility operations"""
def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
"""
Generate launch options for FNV/Enderal games that require vanilla compatdata.
Args:
special_game_type: "fnv" or "enderal"
modlist_install_dir: Directory where the modlist is installed
Returns:
Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
"""
if not special_game_type or special_game_type not in ["fnv", "enderal"]:
return None
logger.info(f"Generating {special_game_type.upper()} launch options")
# Map game types to AppIDs
appid_map = {"fnv": "22380", "enderal": "976620"}
appid = appid_map[special_game_type]
# Find vanilla game compatdata
from ..handlers.path_handler import PathHandler
compatdata_path = PathHandler.find_compat_data(appid)
if not compatdata_path:
logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
return None
# Create STEAM_COMPAT_DATA_PATH string
compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
# Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
compat_mounts_str = ""
try:
all_libs = PathHandler.get_all_steam_library_paths()
main_steam_lib_path_obj = PathHandler.find_steam_library()
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
else:
main_steam_lib_path = main_steam_lib_path_obj
mount_paths = []
if main_steam_lib_path:
main_resolved = main_steam_lib_path.resolve()
for lib_path in all_libs:
if lib_path.resolve() != main_resolved:
mount_paths.append(str(lib_path.resolve()))
if mount_paths:
mount_paths_str = ':'.join(mount_paths)
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
except Exception as e:
logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
# Combine all launch options
launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
launch_options = ' '.join(launch_options.split()) # Clean up spacing
logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
return launch_options
# TODO post-0.6: remove this method - dead code, never called.
# Superseded by registry injection (game paths written directly into the modlist prefix).
# def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
# """
# Generate launch options for FNV/Enderal games that require vanilla compatdata.
#
# Args:
# special_game_type: "fnv" or "enderal"
# modlist_install_dir: Directory where the modlist is installed
#
# Returns:
# Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
# """
# if not special_game_type or special_game_type not in ["fnv", "enderal"]:
# return None
#
# logger.info(f"Generating {special_game_type.upper()} launch options")
#
# # Map game types to AppIDs
# appid_map = {"fnv": "22380", "enderal": "976620"}
# appid = appid_map[special_game_type]
#
# # Find vanilla game compatdata
# from ..handlers.path_handler import PathHandler
# compatdata_path = PathHandler.find_compat_data(appid)
# if not compatdata_path:
# logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
# return None
#
# # Create STEAM_COMPAT_DATA_PATH string
# compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
#
# # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
# compat_mounts_str = ""
# try:
# all_libs = PathHandler.get_all_steam_library_paths()
# main_steam_lib_path_obj = PathHandler.find_steam_library()
# if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
# main_steam_lib_path = main_steam_lib_path_obj.parent.parent
# else:
# main_steam_lib_path = main_steam_lib_path_obj
#
# mount_paths = []
# if main_steam_lib_path:
# main_resolved = main_steam_lib_path.resolve()
# for lib_path in all_libs:
# if lib_path.resolve() != main_resolved:
# mount_paths.append(str(lib_path.resolve()))
#
# if mount_paths:
# mount_paths_str = ':'.join(mount_paths)
# compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
# logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
# except Exception as e:
# logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
#
# # Combine all launch options
# launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
# launch_options = ' '.join(launch_options.split()) # Clean up spacing
#
# logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
# return launch_options
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
"""Find a Steam game installation path by AppID and common names"""
@@ -140,36 +142,90 @@ class GameUtilsMixin:
return None
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str):
def _detect_skyrim_se_modlist(self, modlist_dir: str) -> bool:
"""
Return True if modlist_dir is a Skyrim SE (non-VR) modlist.
Used only to trigger first-launch seeding when special_game_type is None.
Other games are not yet confirmed to need this treatment.
"""
if not modlist_dir:
return False
try:
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
mo2_ini = Path(modlist_dir) / "files" / "ModOrganizer.ini"
if not mo2_ini.exists():
return False
content = mo2_ini.read_text(errors='ignore').lower()
# Anchor VR check to gameName= to avoid false positives from plugin
# setting keys like enable_skyrimVR=false appearing in SE modlists.
for _line in content.splitlines():
if _line.strip().startswith("gamename="):
game_name_value = _line.strip()[len("gamename="):]
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
return False
break
return 'skyrim special edition' in content or 'skse64_loader' in content
except Exception as e:
logger.debug(f"Could not check Skyrim SE detection for {modlist_dir}: {e}")
return False
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str,
modlist_dir: Optional[str] = None):
"""
Pre-create game-specific user directories to prevent first-launch issues.
Creates both My Documents/My Games and AppData/Local directories for the game.
This prevents issues where games fail to create these on first launch under Proton.
special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games). For standard
games like Skyrim SE that aren't "special" in that sense, modlist_dir is used to
detect what directories to seed.
"""
# Map game types to their directory names
# Bethesda-pattern games: same name used for both My Games and AppData/Local
game_dir_names = {
"skyrim": "Skyrim Special Edition",
"skyrimvr": "Skyrim VR",
"fnv": "FalloutNV",
"fo3": "Fallout3",
"fo4": "Fallout4",
"fallout4vr": "Fallout4VR",
"oblivion": "Oblivion",
"oblivion_remastered": "Oblivion Remastered",
"enderal": "Enderal Special Edition",
"starfield": "Starfield"
"starfield": "Starfield",
}
# Get the directory name for this game type
game_dir_name = game_dir_names.get(special_game_type)
if not game_dir_name:
logger.debug(f"No user directory mapping for game type: {special_game_type}")
return
# Non-Bethesda games: AppData/Local only, with a vendor-namespaced subdirectory
game_appdata_only = {
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
}
# special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games).
# Skyrim SE returns None from detect_special_game_type but still needs seeding.
game_type = special_game_type
if special_game_type is None and modlist_dir and self._detect_skyrim_se_modlist(modlist_dir):
game_type = "skyrim"
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
if game_type in game_appdata_only:
appdata_dir = os.path.join(base_path, "AppData", "Local", game_appdata_only[game_type])
try:
os.makedirs(appdata_dir, exist_ok=True)
logger.info(f"Created AppData/Local directory: {appdata_dir}")
except Exception as e:
logger.warning(f"Failed to create AppData/Local directory {appdata_dir}: {e}")
return
game_dir_name = game_dir_names.get(game_type)
if not game_dir_name:
logger.debug(f"No user directory mapping for game type: {game_type}")
return
directories_to_create = [
os.path.join(base_path, "Documents", "My Games", game_dir_name),
os.path.join(base_path, "AppData", "Local", game_dir_name)
os.path.join(base_path, "AppData", "Local", game_dir_name),
]
created_count = 0
@@ -184,90 +240,46 @@ class GameUtilsMixin:
if created_count > 0:
logger.info(f"Created {created_count} user directories for {game_dir_name}")
def _get_lorerim_preferred_proton(self):
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
if game_type == "skyrim":
self._seed_skyrim_first_launch_files(base_path, game_dir_name)
elif game_type == "fo4":
self._seed_fo4_first_launch_files(base_path, game_dir_name)
elif game_type == "skyrimvr":
self._seed_skyrimvr_first_launch_files(base_path, game_dir_name)
elif game_type == "fallout4vr":
self._seed_fallout4vr_first_launch_files(base_path, game_dir_name)
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Skyrim first-launch fix files."""
try:
from jackify.backend.handlers.wine_utils import WineUtils
# Get all available Proton versions
available_versions = WineUtils.scan_all_proton_versions()
if not available_versions:
logger.warning("No Proton versions found for Lorerim override")
return None
# Priority order for Lorerim:
# 1. GEProton9-27 (specific version)
# 2. Other GEProton-9 versions (latest first)
# 3. Valve Proton 9 (any version)
preferred_candidates = []
for version in available_versions:
version_name = version['name']
# Priority 1: GEProton9-27 specifically
if version_name == 'GE-Proton9-27':
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
return version_name
# Priority 2: Other GE-Proton 9 versions
elif version_name.startswith('GE-Proton9-'):
preferred_candidates.append(('ge_proton_9', version_name, version))
# Priority 3: Valve Proton 9
elif 'Proton 9' in version_name:
preferred_candidates.append(('valve_proton_9', version_name, version))
# Return best candidate if any found
if preferred_candidates:
# Sort by priority (GE-Proton first, then by name for latest)
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
best_candidate = preferred_candidates[0]
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
return best_candidate[1]
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
return None
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.error(f"Error detecting Lorerim Proton preference: {e}")
return None
logger.warning(f"Could not seed Skyrim first-launch files: {e}")
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
"""Store Proton override information for end-of-install notification"""
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Fallout 4 first-launch fix files."""
try:
# Store override info for later display
if not hasattr(self, '_proton_overrides'):
self._proton_overrides = []
self._proton_overrides.append({
'modlist': modlist_name,
'proton_version': proton_version,
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
})
logger.debug(f"Stored Proton override notification: {modlist_name}{proton_version}")
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.error(f"Failed to store Proton override notification: {e}")
logger.warning(f"Could not seed FO4 first-launch files: {e}")
def _show_proton_override_notification(self, progress_callback=None):
"""Display any Proton override notifications to the user"""
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Skyrim VR first-launch fix files."""
try:
if hasattr(self, '_proton_overrides') and self._proton_overrides:
for override in self._proton_overrides:
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
if progress_callback:
progress_callback("")
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
logger.info(notification_msg)
# Clear notifications after display
self._proton_overrides = []
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.error(f"Failed to show Proton override notification: {e}")
logger.warning(f"Could not seed SkyrimVR first-launch files: {e}")
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Fallout 4 VR first-launch fix files."""
try:
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.warning(f"Could not seed FO4VR first-launch files: {e}")

View File

@@ -20,23 +20,6 @@ class ProtonOperationsMixin:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
# Check for Lorerim-specific Proton override first
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
if modlist_normalized == 'lorerim':
lorerim_proton = self._get_lorerim_preferred_proton()
if lorerim_proton:
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
self._store_proton_override_notification("Lorerim", lorerim_proton)
return lorerim_proton
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
if modlist_normalized == 'lostlegacy':
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
if lostlegacy_proton:
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
return lostlegacy_proton
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()

View File

@@ -1,5 +1,6 @@
"""Registry operations mixin for AutomatedPrefixService."""
import os
import re
import subprocess
import logging
from pathlib import Path
@@ -74,7 +75,7 @@ class RegistryOperationsMixin:
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
Direct file editing is preferred over `wine reg add` faster, no Wine
Direct file editing is preferred over `wine reg add` - faster, no Wine
process overhead, and works even when Proton isn't on PATH. Falls back
to subprocess wine reg add when the reg files haven't been created yet.
"""
@@ -91,10 +92,12 @@ class RegistryOperationsMixin:
fix1 = fix2 = False
# Targeted per-exe override for SkyrimSE.exe only - see modlist_wine_ops.py
# for rationale. Global DllOverrides entry breaks .NET 9/10 bootstrap.
if os.path.exists(user_reg):
fix1 = self._reg_set_value(
user_reg,
"[Software\\\\Wine\\\\DllOverrides]",
"[Software\\\\Wine\\\\AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides]",
'"*mscoree"',
'"native"',
)
@@ -123,7 +126,7 @@ class RegistryOperationsMixin:
r1 = subprocess.run(
[wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'],
env=env, capture_output=True, text=True, errors='replace',
)
@@ -145,6 +148,53 @@ class RegistryOperationsMixin:
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False
def _apply_cp2077_dll_overrides(self, modlist_compatdata_path: str) -> bool:
"""Write CP2077 DLL overrides directly into the prefix user.reg.
MO2 on Linux launches each executable through a separate Proton invocation,
so WINEDLLOVERRIDES set in Steam launch options is not inherited by the game
process. Writing the overrides into user.reg ensures they are always applied
regardless of how the process is started.
version and winmm are the entry-point DLLs for CET and Red4ext respectively.
Without native,builtin for both, neither mod framework can inject into the
game process and CP2077 exits immediately.
"""
try:
user_reg = os.path.join(modlist_compatdata_path, "pfx", "user.reg")
if not os.path.exists(user_reg):
logger.warning("user.reg not found, cannot apply CP2077 DLL overrides")
return False
section = "[Software\\\\Wine\\\\DllOverrides]"
overrides = [
('"version"', '"native,builtin"'),
('"winmm"', '"native,builtin"'),
]
for key, val in overrides:
self._reg_set_value(user_reg, section, key, val)
logger.info("Applied CP2077 DLL overrides (version, winmm) to prefix registry")
return True
except Exception as e:
logger.error(f"Failed to apply CP2077 DLL overrides: {e}")
return False
@staticmethod
def _wow64_counterpart(section: str) -> str:
"""Return the Wow6432Node counterpart for a registry section, or vice versa.
NaK writes both paths for every game so both 32-bit and 64-bit lookups
resolve correctly regardless of the calling process's bitness.
"""
low = section.lower()
if "wow6432node" in low:
# Strip Wow6432Node to get the 64-bit path
return re.sub(r'(?i)wow6432node\\\\', '', section)
else:
# Insert Wow6432Node after the opening [Software\\
return re.sub(r'(?i)(\[Software\\\\)', r'\1Wow6432Node\\\\', section)
def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool:
"""Set or add a key=value pair in a Wine .reg text file."""
try:
@@ -319,19 +369,19 @@ class RegistryOperationsMixin:
"name": "Fallout New Vegas",
"common_names": ["Fallout New Vegas", "FalloutNV"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
"path_key": "Installed Path",
"path_key": "installed path",
},
"22300": { # Fallout 3 AppID
"name": "Fallout 3",
"common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
"path_key": "Installed Path",
"path_key": "installed path",
},
"22370": { # Fallout 3 GOTY AppID alias
"name": "Fallout 3",
"common_names": ["Fallout 3 GOTY", "Fallout 3"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
"path_key": "Installed Path",
"path_key": "installed path",
},
"976620": { # Enderal Special Edition AppID
"name": "Enderal",
@@ -339,6 +389,72 @@ class RegistryOperationsMixin:
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
"path_key": "installed path",
},
"1091500": { # Cyberpunk 2077 AppID
"name": "Cyberpunk 2077",
"common_names": ["Cyberpunk 2077"],
"registry_section": "[Software\\\\CD Projekt Red\\\\Cyberpunk 2077]",
"path_key": "InstallFolder",
},
"1086940": { # Baldur's Gate 3 AppID
"name": "Baldur's Gate 3",
"common_names": ["Baldur's Gate 3", "BaldursGate3"],
"registry_section": "[Software\\\\Larian Studios\\\\Baldur's Gate 3]",
"path_key": "InstallDir",
},
"611670": { # Skyrim VR AppID (64-bit, no Wow6432Node)
"name": "Skyrim VR",
"common_names": ["Skyrim VR", "SkyrimVR"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim VR]",
"path_key": "Installed Path",
},
"611660": { # Fallout 4 VR AppID (64-bit, no Wow6432Node)
"name": "Fallout 4 VR",
"common_names": ["Fallout 4 VR", "Fallout4VR"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout 4 VR]",
"path_key": "Installed Path",
},
"22330": { # Oblivion AppID
"name": "Oblivion",
"common_names": ["Oblivion", "Elder Scrolls IV Oblivion"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\oblivion]",
"path_key": "installed path",
},
"1716740": { # Starfield AppID (64-bit, no Wow6432Node)
"name": "Starfield",
"common_names": ["Starfield"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Starfield]",
"path_key": "Installed Path",
},
"489830": { # Skyrim Special Edition AppID (64-bit, no Wow6432Node)
"name": "Skyrim Special Edition",
"common_names": ["Skyrim Special Edition", "SkyrimSE", "Skyrim Anniversary Edition"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim Special Edition]",
"path_key": "Installed Path",
},
"377160": { # Fallout 4 AppID (64-bit, no Wow6432Node)
"name": "Fallout 4",
"common_names": ["Fallout 4", "Fallout4"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout4]",
"path_key": "Installed Path",
},
"22320": { # Morrowind AppID (32-bit, Wow6432Node)
"name": "Morrowind",
"common_names": ["Morrowind", "Elder Scrolls III Morrowind"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\morrowind]",
"path_key": "Installed Path",
},
"292030": { # The Witcher 3 AppID (64-bit, no Wow6432Node)
"name": "The Witcher 3",
"common_names": ["The Witcher 3", "Witcher 3", "The Witcher 3 Wild Hunt"],
"registry_section": "[Software\\\\CD Projekt Red\\\\The Witcher 3]",
"path_key": "InstallFolder",
},
"2623190": { # Oblivion Remastered AppID (64-bit UE5, no Wow6432Node)
"name": "Oblivion Remastered",
"common_names": ["Oblivion Remastered", "OblivionRemastered"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Oblivion Remastered]",
"path_key": "Installed Path",
},
}
pfx_path = Path(modlist_compatdata_path) / "pfx"
@@ -359,24 +475,22 @@ class RegistryOperationsMixin:
game_dir_name = Path(game_path).name
canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}"
wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\"
success = self._reg_set_value(
system_reg_path,
config["registry_section"],
f'"{config["path_key"]}"',
f'"{wine_val}"',
)
key = f'"{config["path_key"]}"'
val = f'"{wine_val}"'
success = self._reg_set_value(system_reg_path, config["registry_section"], key, val)
self._reg_set_value(system_reg_path, self._wow64_counterpart(config["registry_section"]), key, val)
if success:
logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}")
else:
logger.warning(f"Failed to set canonical registry path for {config['name']}")
else:
# Symlink failed fall back to writing the real Z:/D: path
# Symlink failed - fall back to writing the real Z:/D: path
logger.warning(f"Symlink failed for {config['name']}, writing real path to registry")
success = self._update_registry_path(
system_reg_path,
config["registry_section"],
config["path_key"],
game_path
system_reg_path, config["registry_section"], config["path_key"], game_path
)
self._update_registry_path(
system_reg_path, self._wow64_counterpart(config["registry_section"]), config["path_key"], game_path
)
if success:
logger.info(f"Updated registry entry for {config['name']} (real path fallback)")

View File

@@ -38,25 +38,29 @@ class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin):
# Initialize native Steam service
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:
launch_options = custom_launch_options
logger.info(f"Using pre-generated launch options: {launch_options}")
launch_options = f"{mounts_prefix} {custom_launch_options}".strip() if mounts_prefix else custom_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:
# Generate STEAM_COMPAT_MOUNTS including install and download mountpoints
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)
proton_version = self._get_user_proton_version(shortcut_name)

View File

@@ -178,10 +178,20 @@ class WorkflowMixin:
modlist_handler = ModlistHandler()
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
if special_game_type in ["fnv", "fo3", "enderal"]:
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:
logger.debug("Standard modlist - no special game handling needed")
@@ -202,6 +212,31 @@ class WorkflowMixin:
if progress_callback:
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)
logger.info("Step 1: Creating shortcut with native Steam service")
# Create shortcut using native Steam service with special game launch options
@@ -222,9 +257,9 @@ class WorkflowMixin:
from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler()
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:
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)
logger.info("Step 2: auto_restart=%s", auto_restart)
@@ -243,6 +278,7 @@ class WorkflowMixin:
logger.info("Step 2 completed: Steam started")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam started successfully")
progress_callback("[Jackify] Steam restart complete")
else:
logger.info("Step 2 skipped: Auto-restart disabled by user")
if progress_callback:
@@ -287,6 +323,15 @@ class WorkflowMixin:
self._inject_game_registry_entries(str(prefix_path), special_game_type)
else:
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:
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...")
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:
logger.warning("Could not find prefix path for directory creation")
last_timestamp = self._get_progress_timestamp()
logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}")
if progress_callback:
progress_callback(f"{last_timestamp} 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:
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:
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():
return ValidationResult(matches=True, computed_hash=None, file_path=file_path)
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"]
_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

View File

@@ -224,7 +224,7 @@ class ManualDownloadManagerRuntimeMixin:
item_to_notify = item
completed_now = True
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
# re-validate if the user downloads the correct file.
item.status = 'pending'

View File

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

View File

@@ -103,10 +103,22 @@ class ModlistService(ModlistServiceInstallationMixin):
elif game_type_lower == 'enderal':
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':
# 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):
game_lower = game_name.lower()
return any(keyword in game_lower for keyword in main_category_keywords)

View File

@@ -150,16 +150,17 @@ class ModlistServiceInstallationMixin:
elif context.get('machineid'):
cmd += ['-m', context['machineid']]
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 = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
@@ -285,6 +286,7 @@ class ModlistServiceInstallationMixin:
_ck_missing = True
proc.wait()
auth_service.apply_token_writeback(writeback_path)
if proc.returncode != 0:
if output_callback:
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 os
from typing import Optional, Tuple
from .nexus_oauth_service import NexusOAuthService
from ..handlers.oauth_token_handler import OAuthTokenHandler
@@ -288,6 +289,41 @@ class NexusAuthService:
logger.warning("No authentication available for engine")
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:
"""
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.
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)
if cached is not None:

View File

@@ -20,8 +20,6 @@ def _get_restart_strategy() -> str:
from jackify.backend.handlers.config_handler import ConfigHandler
strategy = ConfigHandler().get("steam_restart_strategy", STRATEGY_JACKIFY)
if strategy == "nak_simple":
strategy = STRATEGY_SIMPLE
if strategy not in (STRATEGY_JACKIFY, STRATEGY_SIMPLE):
return STRATEGY_JACKIFY
return strategy
@@ -203,7 +201,7 @@ def is_flatpak_steam() -> bool:
def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool:
"""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.
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
flatpak_cmd = _get_flatpak_command()
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
grant_path = str(_Path(path).parent)
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 os
import shutil
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from pathlib import Path
@@ -32,6 +34,7 @@ class UpdateInfo:
file_size: Optional[int] = None
is_critical: bool = False
is_delta_update: bool = False
github_download_url: Optional[str] = None
class UpdateService:
@@ -98,7 +101,7 @@ class UpdateService:
break
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)
update_source = "github"
if nexus_url:
@@ -108,16 +111,13 @@ class UpdateService:
else:
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()
# Safety checks to prevent segfault
try:
# Sanitize string fields
safe_version = str(latest_version) if latest_version else ""
safe_tag = str(release_data.get('tag_name', ''))
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)
logger.debug(f"Creating UpdateInfo for version {safe_version}")
@@ -131,6 +131,7 @@ class UpdateService:
file_size=file_size,
is_delta_update=is_delta,
source=update_source,
github_download_url=str(github_url),
)
logger.debug(f"UpdateInfo created successfully")
@@ -159,6 +160,13 @@ class UpdateService:
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.
"""
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:
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
@@ -301,33 +309,38 @@ class UpdateService:
logger.debug(f"Self-updating enabled for AppImage: {appimage_path}")
return True
def download_update(self, update_info: UpdateInfo,
def download_update(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
"""
Download update using full AppImage replacement.
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
Download update AppImage. Falls back to GitHub if the primary source fails.
"""
try:
logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source)
result = self._download_update_manual(update_info, progress_callback)
if 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)
logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source)
result = self._download_update_manual(update_info, progress_callback)
if result:
logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result)
return result
except Exception as e:
logger.error(f"Failed to download update: {e}")
return None
# Primary source failed - fall back to GitHub if we came from Nexus
if update_info.source == "nexus" and update_info.github_download_url:
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,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
@@ -414,27 +427,41 @@ class UpdateService:
return None
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()
if not seven_z:
logger.error("Bundled 7z not found, cannot extract update archive")
return None
out_path = dest_dir / f"Jackify-{version}.AppImage"
if out_path.exists():
out_path.unlink()
tmp_dir = Path(tempfile.mkdtemp(dir=dest_dir))
try:
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
)
extracted = dest_dir / 'Jackify.AppImage'
if result.returncode != 0 or not extracted.exists():
if result.returncode != 0:
logger.error("7z extraction failed (rc=%d): %s", result.returncode, result.stderr.strip())
return None
extracted.rename(out_path)
logger.info("Extracted AppImage from archive: %s", out_path)
candidates = list(tmp_dir.glob('*.AppImage'))
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
except Exception as e:
logger.error("Exception during 7z extraction: %s", e)
return None
finally:
shutil.rmtree(str(tmp_dir), ignore_errors=True)
def apply_update(self, new_appimage_path: Path) -> bool:
"""

View File

@@ -6,7 +6,7 @@ import re
from typing import Optional
# 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.
_CC_FILE_RE = re.compile(
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.",
solutions=[
"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.",
"Disable VPN or proxy if active.",
],
@@ -84,7 +84,7 @@ _TYPE_MAP = {
"archive_corrupt": lambda msg, ctx: InstallError(
"Corrupted Archive",
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=[
"Re-run the install.",
"Check available disk space (partial downloads appear corrupt).",
@@ -99,7 +99,7 @@ _TYPE_MAP = {
solutions=[
"Verify the modlist name is correct.",
"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),
),
@@ -108,7 +108,7 @@ _TYPE_MAP = {
msg,
suggestion="Re-run the install to re-download any failed files.",
solutions=[
"Re-run the install Wabbajack resumes and re-validates.",
"Re-run the install - Wabbajack resumes and re-validates.",
"Check available disk space.",
"Check Modlist_Install_workflow.log for specific failures.",
],