mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-07 22:47:45 +02:00
Release v0.6.0
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -521,11 +521,13 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
||||
# Game-specific Documents directory names (for both Linux home and Wine prefix)
|
||||
game_docs_dirs = {
|
||||
"skyrimse": "Skyrim Special Edition",
|
||||
"skyrimvr": "Skyrim VR",
|
||||
"fallout4": "Fallout4",
|
||||
"fallout4vr": "Fallout4VR",
|
||||
"falloutnv": "FalloutNV",
|
||||
"oblivion": "Oblivion",
|
||||
"enderal": "Enderal Special Edition",
|
||||
"enderalse": "Enderal Special Edition"
|
||||
"enderalse": "Enderal Special Edition",
|
||||
}
|
||||
|
||||
game_dirs = {
|
||||
@@ -561,41 +563,193 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
self.logger.debug(f"Created game-specific directory: {dir_path}")
|
||||
|
||||
# CRITICAL: Create game-specific Documents directories in Wine prefix
|
||||
# CP2077 and BG3 use AppData/Local only (no My Games)
|
||||
appdata_only_dirs = {
|
||||
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
|
||||
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
|
||||
}
|
||||
|
||||
# CRITICAL: Create game-specific directories in Wine prefix
|
||||
# Required for USVFS to virtualize profile INIs on first launch
|
||||
if game_name in game_docs_dirs:
|
||||
docs_dir_name = game_docs_dirs[game_name]
|
||||
|
||||
# Find compatdata path for this AppID
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
compatdata_path = path_handler.find_compat_data(appid)
|
||||
|
||||
if compatdata_path:
|
||||
# Create Documents/My Games/{GameName} in Wine prefix
|
||||
wine_docs_path = os.path.join(
|
||||
str(compatdata_path),
|
||||
"pfx",
|
||||
"drive_c",
|
||||
"users",
|
||||
"steamuser",
|
||||
"Documents",
|
||||
"My Games",
|
||||
docs_dir_name
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
compatdata_path = path_handler.find_compat_data(appid)
|
||||
|
||||
if compatdata_path:
|
||||
prefix_user = os.path.join(
|
||||
str(compatdata_path), "pfx", "drive_c", "users", "steamuser"
|
||||
)
|
||||
|
||||
if game_name in appdata_only_dirs:
|
||||
appdata_path = os.path.join(
|
||||
prefix_user, "AppData", "Local", appdata_only_dirs[game_name]
|
||||
)
|
||||
try:
|
||||
os.makedirs(appdata_path, exist_ok=True)
|
||||
self.logger.info(f"Created Wine prefix AppData/Local directory: {appdata_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create AppData/Local directory {appdata_path}: {e}")
|
||||
|
||||
elif game_name in game_docs_dirs:
|
||||
docs_dir_name = game_docs_dirs[game_name]
|
||||
wine_docs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(wine_docs_path, exist_ok=True)
|
||||
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}")
|
||||
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
|
||||
self.logger.info(f"Created Wine prefix Documents directory: {wine_docs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}")
|
||||
# Don't fail completely - this is a first-launch optimization
|
||||
else:
|
||||
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation")
|
||||
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time")
|
||||
|
||||
if game_name == "skyrimse":
|
||||
self._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "fallout4":
|
||||
self._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "skyrimvr":
|
||||
self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
|
||||
elif game_name == "fallout4vr":
|
||||
self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
|
||||
else:
|
||||
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating required directories: {e}")
|
||||
return False
|
||||
|
||||
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch.
|
||||
|
||||
Two files must exist before first launch to avoid USVFS and engine issues:
|
||||
|
||||
1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file.
|
||||
USVFS builds its VFS tree at MO2 startup. If this path does not exist,
|
||||
USVFS logs the directory as missing and skips adding Plugins.txt to the
|
||||
initial tree. It then tries to reroute the file dynamically, but a mutex
|
||||
deadlock (thread never releases the write mutex on first launch) blocks
|
||||
the reroute. The game falls through to the real filesystem, finds no
|
||||
Plugins.txt, and loads only base-game ESPs - causing a null form crash
|
||||
for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll).
|
||||
On second launch the directory exists, USVFS initialises correctly, no crash.
|
||||
Pre-seeding an empty file gives USVFS its anchor; content is irrelevant
|
||||
because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway.
|
||||
|
||||
2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub.
|
||||
The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in
|
||||
SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR)
|
||||
hooks the Windows INI API, so the game reads the real prefix path directly,
|
||||
not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses
|
||||
the prompt. PPR redirects all subsequent reads to the active profile once
|
||||
it loads, so this stub is never read again after early engine init.
|
||||
Only created if the file does not already exist.
|
||||
"""
|
||||
# Fix 1: empty Plugins.txt anchor for USVFS
|
||||
appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition")
|
||||
plugins_txt = os.path.join(appdata_sse, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_sse, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
# Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup
|
||||
skyrimprefs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
|
||||
)
|
||||
try:
|
||||
if not os.path.exists(skyrimprefs_path):
|
||||
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[General]\nbDownloadCC=1\n")
|
||||
self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}")
|
||||
else:
|
||||
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
|
||||
|
||||
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Fallout 4 needs on first launch.
|
||||
|
||||
1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4.
|
||||
|
||||
INI stub for CC popup suppression is intentionally omitted until the correct
|
||||
key name in Fallout4Prefs.ini is confirmed via testing.
|
||||
"""
|
||||
appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_fo4, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_fo4, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Skyrim VR needs on first launch.
|
||||
|
||||
1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE applies to VR.
|
||||
|
||||
2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys:
|
||||
- bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour
|
||||
as Skyrim SE; fires before PPR hooks the INI API).
|
||||
- bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom
|
||||
tutorial on first launch. Without this, SkyrimVR skips the main menu and
|
||||
drops the user into the playroom, bypassing the modlist's startup sequence.
|
||||
"""
|
||||
appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_vr, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_vr, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
skyrimprefs_path = os.path.join(
|
||||
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
|
||||
)
|
||||
try:
|
||||
if not os.path.exists(skyrimprefs_path):
|
||||
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n")
|
||||
self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}")
|
||||
else:
|
||||
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
|
||||
|
||||
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""
|
||||
Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch.
|
||||
|
||||
1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS.
|
||||
Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR.
|
||||
|
||||
INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini
|
||||
has not been confirmed via testing.
|
||||
"""
|
||||
appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
|
||||
plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt")
|
||||
try:
|
||||
os.makedirs(appdata_fo4vr, exist_ok=True)
|
||||
if not os.path.exists(plugins_txt):
|
||||
open(plugins_txt, 'w').close()
|
||||
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
|
||||
else:
|
||||
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
|
||||
|
||||
@@ -64,7 +64,7 @@ class FilesystemSteamMixin:
|
||||
|
||||
default_path = Path.home() / ".steam/steam/steamapps/common"
|
||||
if default_path.is_dir():
|
||||
logger.warning(f"Using default Steam library path: {default_path}")
|
||||
logger.info(f"Using default Steam library path: {default_path}")
|
||||
return default_path
|
||||
|
||||
logger.error("No valid Steam library found via vdf or at default location.")
|
||||
|
||||
@@ -18,7 +18,11 @@ class GameDetector:
|
||||
'fallout3': ['Fallout 3'],
|
||||
'oblivion': ['Oblivion'],
|
||||
'starfield': ['Starfield'],
|
||||
'oblivion_remastered': ['Oblivion Remastered']
|
||||
'oblivion_remastered': ['Oblivion Remastered'],
|
||||
'skyrimvr': ['Skyrim VR'],
|
||||
'fallout4vr': ['Fallout 4 VR'],
|
||||
'cp2077': ['Cyberpunk 2077'],
|
||||
'bg3': ["Baldur's Gate 3"],
|
||||
}
|
||||
|
||||
def detect_game_type(self, modlist_name: str) -> Optional[str]:
|
||||
@@ -26,9 +30,17 @@ class GameDetector:
|
||||
modlist_lower = modlist_name.lower()
|
||||
|
||||
# Check for game-specific keywords in modlist name
|
||||
# Check for Oblivion Remastered first since "oblivion" is a substring
|
||||
# Check more specific types before their generic parents
|
||||
if any(keyword in modlist_lower for keyword in ['oblivion remastered', 'oblivionremastered', 'oblivion_remastered']):
|
||||
return 'oblivion_remastered'
|
||||
elif any(keyword in modlist_lower for keyword in ['skyrim vr', 'skyrimvr']):
|
||||
return 'skyrimvr'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout 4 vr', 'fallout4vr', 'fo4vr']):
|
||||
return 'fallout4vr'
|
||||
elif any(keyword in modlist_lower for keyword in ['cyberpunk', 'cp2077', 'cyberpunk 2077']):
|
||||
return 'cp2077'
|
||||
elif any(keyword in modlist_lower for keyword in ["baldur's gate 3", 'baldursgate3', 'bg3']):
|
||||
return 'bg3'
|
||||
elif any(keyword in modlist_lower for keyword in ['skyrim', 'sse', 'skse', 'dragonborn', 'dawnguard']):
|
||||
return 'skyrim'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']):
|
||||
@@ -134,9 +146,37 @@ class GameDetector:
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks']
|
||||
}
|
||||
},
|
||||
'skyrimvr': {
|
||||
'launcher': 'SKSE',
|
||||
'min_proton_version': '6.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'SteamVR must be installed separately',
|
||||
},
|
||||
'fallout4vr': {
|
||||
'launcher': 'F4SE',
|
||||
'min_proton_version': '6.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'SteamVR must be installed separately',
|
||||
},
|
||||
'cp2077': {
|
||||
'launcher': 'redmod',
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'Requires WINEDLLOVERRIDES=version=n,b;winmm=n,b for Red4ext/CET. Rootbuilder must use COPY mode.',
|
||||
},
|
||||
'bg3': {
|
||||
'launcher': 'bg3_dx11',
|
||||
'min_proton_version': '8.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks'],
|
||||
'notes': 'Rootbuilder must use COPY mode.',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return requirements.get(game_type, {})
|
||||
|
||||
def detect_mods(self, modlist_path: Path) -> List[Dict]:
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -253,12 +253,13 @@ class ModlistDetectionMixin:
|
||||
modlist_path = Path(self.modlist_dir)
|
||||
common_names = [
|
||||
"Stock Game",
|
||||
"Game Root",
|
||||
"StockGame",
|
||||
"STOCK GAME",
|
||||
"Stock Game Folder",
|
||||
"Stock Folder",
|
||||
"Skyrim Stock",
|
||||
Path("root/Skyrim Special Edition")
|
||||
Path("root/Skyrim Special Edition"),
|
||||
"Game Root",
|
||||
]
|
||||
|
||||
found_path = None
|
||||
@@ -326,6 +327,15 @@ class ModlistDetectionMixin:
|
||||
if mo2_ini.exists():
|
||||
try:
|
||||
content = mo2_ini.read_text(errors='ignore').lower()
|
||||
# Extract gameName= for authoritative game type checks.
|
||||
# Full-content scans can false-positive on plugin setting keys
|
||||
# (e.g. enable_skyrimVR=false in a Skyrim SE ini).
|
||||
game_name_value = ""
|
||||
for _line in content.splitlines():
|
||||
stripped_line = _line.strip()
|
||||
if stripped_line.startswith("gamename="):
|
||||
game_name_value = stripped_line[len("gamename="):]
|
||||
break
|
||||
if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content:
|
||||
self.logger.info("Detected FNV via ModOrganizer.ini markers")
|
||||
return "fnv"
|
||||
@@ -335,6 +345,18 @@ class ModlistDetectionMixin:
|
||||
if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']):
|
||||
self.logger.info("Detected Enderal via ModOrganizer.ini markers")
|
||||
return "enderal"
|
||||
if 'cyberpunk 2077' in content or 'cyberpunk2077' in content or 'cp2077' in content:
|
||||
self.logger.info("Detected Cyberpunk 2077 via ModOrganizer.ini markers")
|
||||
return "cp2077"
|
||||
if "baldur's gate 3" in content or 'baldursgate3' in content or 'bg3' in content:
|
||||
self.logger.info("Detected Baldur's Gate 3 via ModOrganizer.ini markers")
|
||||
return "bg3"
|
||||
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
|
||||
self.logger.info("Detected SkyrimVR via ModOrganizer.ini gameName")
|
||||
return "skyrimvr"
|
||||
if 'fallout 4 vr' in game_name_value or 'fallout4vr' in game_name_value:
|
||||
self.logger.info("Detected Fallout 4 VR via ModOrganizer.ini gameName")
|
||||
return "fallout4vr"
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}")
|
||||
except Exception:
|
||||
@@ -364,6 +386,15 @@ class ModlistDetectionMixin:
|
||||
if enderal_launcher.exists():
|
||||
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
|
||||
return "enderal"
|
||||
cp2077_exe = base / "Cyberpunk2077.exe"
|
||||
if cp2077_exe.exists():
|
||||
self.logger.info(f"Detected Cyberpunk 2077 modlist: found Cyberpunk2077.exe in '{base}'")
|
||||
return "cp2077"
|
||||
bg3_exe = base / "bg3.exe"
|
||||
bg3_dx11_exe = base / "bg3_dx11.exe"
|
||||
if bg3_exe.exists() or bg3_dx11_exe.exists():
|
||||
self.logger.info(f"Detected BG3 modlist: found BG3 executable in '{base}'")
|
||||
return "bg3"
|
||||
|
||||
# Final heuristic using game_var
|
||||
try:
|
||||
@@ -379,6 +410,18 @@ class ModlistDetectionMixin:
|
||||
if 'enderal' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Enderal")
|
||||
return "enderal"
|
||||
if 'cyberpunk' in gt or 'cp2077' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Cyberpunk 2077")
|
||||
return "cp2077"
|
||||
if "baldur" in gt or 'bg3' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates BG3")
|
||||
return "bg3"
|
||||
if 'skyrim vr' in gt or 'skyrimvr' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates SkyrimVR")
|
||||
return "skyrimvr"
|
||||
if 'fallout 4 vr' in gt or 'fallout4vr' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Fallout 4 VR")
|
||||
return "fallout4vr"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -61,32 +61,9 @@ class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWi
|
||||
Handles operations related to modlist detection and configuration
|
||||
"""
|
||||
|
||||
# Dictionary mapping modlist name patterns (lowercase, spaces optional)
|
||||
# to lists of additional Wine components or special actions.
|
||||
MODLIST_SPECIFIC_COMPONENTS = {
|
||||
# Pattern: [component1, component2, ... or special_action_string]
|
||||
"wildlander": ["dotnet48"], # Example from bash script
|
||||
"licentia": ["dotnet8"], # Example from bash script (needs special handling)
|
||||
"nolvus": ["dotnet6", "dotnet7"], # Example
|
||||
# Add other modlists and their specific needs here
|
||||
# e.g., "fallout4_anotherlife": ["some_component"]
|
||||
}
|
||||
|
||||
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
|
||||
# dotnet4.x components disabled in v0.1.6.2 -- replaced with universal registry fixes
|
||||
MODLIST_WINE_COMPONENTS = {
|
||||
# "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
|
||||
# "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
|
||||
"librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
|
||||
# "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
|
||||
"apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
|
||||
# "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
}
|
||||
MODLIST_SPECIFIC_COMPONENTS: dict = {}
|
||||
|
||||
MODLIST_WINE_COMPONENTS: dict = {}
|
||||
|
||||
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
|
||||
mo2_path: Optional[Union[str, Path]] = None,
|
||||
|
||||
@@ -159,14 +159,17 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
self.logger.info(f"Using machineid: {machineid}")
|
||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||
|
||||
writeback_path = str(auth_service.get_token_writeback_path())
|
||||
# Store original environment values to restore later
|
||||
original_env_values = {
|
||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
|
||||
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
|
||||
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
|
||||
}
|
||||
|
||||
try:
|
||||
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
|
||||
# Temporarily modify current process's environment
|
||||
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
|
||||
if oauth_info:
|
||||
@@ -341,7 +344,8 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
print()
|
||||
|
||||
proc.wait()
|
||||
|
||||
auth_service.apply_token_writeback(writeback_path)
|
||||
|
||||
finally:
|
||||
# Stop performance monitoring and get summary
|
||||
if monitoring_started:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,7 +90,7 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
|
||||
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
|
||||
# Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining"
|
||||
# Timestamp prefix is now optional — engine no longer emits [HH:MM:SS].
|
||||
# Timestamp prefix is now optional - engine no longer emits [HH:MM:SS].
|
||||
self.timestamp_status_pattern = re.compile(
|
||||
r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
|
||||
re.IGNORECASE
|
||||
@@ -157,10 +157,17 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
ParsedLine with extracted information
|
||||
"""
|
||||
result = ParsedLine(message=line.strip())
|
||||
|
||||
|
||||
if not line.strip():
|
||||
return result
|
||||
|
||||
|
||||
# Suppress internal engine lines that are not user-facing
|
||||
_suppress_prefixes = (
|
||||
"Refreshing OAuth Token",
|
||||
)
|
||||
if any(line.strip().startswith(p) for p in _suppress_prefixes):
|
||||
return ParsedLine()
|
||||
|
||||
# Try to extract phase information
|
||||
phase_info = self._extract_phase(line)
|
||||
if phase_info:
|
||||
|
||||
@@ -20,11 +20,11 @@ class ProgressParserPhaseMixin:
|
||||
phase = self._map_section_to_phase(section_name)
|
||||
return (phase, section_match.group(1).strip())
|
||||
|
||||
# [FILE_PROGRESS] lines drive file activity only — skip phase extraction for them
|
||||
# [FILE_PROGRESS] lines drive file activity only - skip phase extraction for them
|
||||
if '[FILE_PROGRESS]' in line:
|
||||
return None
|
||||
|
||||
# Make the [timestamp] prefix optional — engine no longer emits it.
|
||||
# Make the [timestamp] prefix optional - engine no longer emits it.
|
||||
action_match = re.search(
|
||||
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
|
||||
line,
|
||||
|
||||
@@ -87,7 +87,7 @@ class ProtontricksCommandsMixin:
|
||||
env['WINETRICKS'] = str(winetricks_path)
|
||||
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
|
||||
else:
|
||||
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
cabextract_path = self._get_bundled_cabextract_path()
|
||||
if cabextract_path:
|
||||
cabextract_dir = str(cabextract_path.parent)
|
||||
@@ -95,7 +95,7 @@ class ProtontricksCommandsMixin:
|
||||
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
|
||||
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
|
||||
else:
|
||||
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
else:
|
||||
self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class ProtontricksPrefixMixin:
|
||||
self.logger.debug("ShowDotFiles already present in correct format in user.reg")
|
||||
dotfiles_set_success = True
|
||||
else:
|
||||
self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
|
||||
self.logger.info(f"user.reg not found at {user_reg_path}, creating it.")
|
||||
with open(user_reg_path, 'w', encoding='utf-8') as f:
|
||||
f.write('[Software\\\\Wine] 1603891765\n')
|
||||
f.write('"ShowDotFiles"="Y"\n')
|
||||
@@ -157,6 +157,10 @@ class ProtontricksPrefixMixin:
|
||||
self.logger.info("=" * 80)
|
||||
env = self._get_clean_subprocess_env()
|
||||
env["WINEDEBUG"] = "-all"
|
||||
# Preserve the desktop display variables for Step 4. The validated fix
|
||||
# for the blank taskbar popup regression was keeping DISPLAY available.
|
||||
# Do not strip extra desktop activation vars here without a reproduced,
|
||||
# evidence-backed need.
|
||||
|
||||
if self.which_protontricks == 'native':
|
||||
winetricks_path = self._get_bundled_winetricks_path()
|
||||
@@ -164,7 +168,7 @@ class ProtontricksPrefixMixin:
|
||||
env['WINETRICKS'] = str(winetricks_path)
|
||||
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
|
||||
else:
|
||||
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
|
||||
cabextract_path = self._get_bundled_cabextract_path()
|
||||
if cabextract_path:
|
||||
cabextract_dir = str(cabextract_path.parent)
|
||||
@@ -172,7 +176,7 @@ class ProtontricksPrefixMixin:
|
||||
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
|
||||
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
|
||||
else:
|
||||
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
|
||||
else:
|
||||
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class ProtontricksSteamMixin:
|
||||
self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
|
||||
|
||||
if steamdeck:
|
||||
self.logger.warning("Checking for SDCard and setting permissions appropriately...")
|
||||
self.logger.info("Checking for SDCard and setting permissions appropriately...")
|
||||
result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env)
|
||||
for line in result.stdout.splitlines():
|
||||
if "/run/media" in line:
|
||||
|
||||
@@ -104,12 +104,16 @@ class ShortcutCreationMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True)
|
||||
|
||||
dotnet_vars = 'DOTNET_ROOT="" DOTNET_MULTILEVEL_LOOKUP=0'
|
||||
|
||||
final_launch_options = launch_options
|
||||
if compat_mounts_str:
|
||||
if final_launch_options:
|
||||
final_launch_options = f"{compat_mounts_str} {final_launch_options}"
|
||||
else:
|
||||
final_launch_options = compat_mounts_str
|
||||
env_prefix_parts = [p for p in [compat_mounts_str, dotnet_vars] if p]
|
||||
if env_prefix_parts:
|
||||
prefix = " ".join(env_prefix_parts)
|
||||
if final_launch_options:
|
||||
final_launch_options = f"{prefix} {final_launch_options}"
|
||||
else:
|
||||
final_launch_options = prefix
|
||||
|
||||
if not final_launch_options.strip().endswith("%command%"):
|
||||
if final_launch_options:
|
||||
@@ -138,7 +142,6 @@ class ShortcutCreationMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating shortcut: {e}", exc_info=True)
|
||||
print(f"An error occurred while creating the shortcut: {e}")
|
||||
return False, None
|
||||
|
||||
def _is_steam_deck(self):
|
||||
|
||||
@@ -165,7 +165,7 @@ class ShortcutDiscoveryMixin:
|
||||
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
|
||||
return str(int(appid) & 0xFFFFFFFF)
|
||||
|
||||
self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
|
||||
self.logger.debug(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -300,7 +300,6 @@ class ShortcutVDFManagementMixin:
|
||||
try:
|
||||
shutil.copy2(safe_backup, shortcuts_file)
|
||||
self.logger.info(f"Restored shortcuts.vdf from pre-restart backup")
|
||||
print("Restored shortcuts file after Steam restart")
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to restore from pre-restart backup: {e}")
|
||||
@@ -310,9 +309,8 @@ class ShortcutVDFManagementMixin:
|
||||
try:
|
||||
shutil.copy2(backup, shortcuts_file)
|
||||
self.logger.info(f"Restored shortcuts.vdf from regular backup")
|
||||
print("Restored shortcuts file after Steam restart")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to restore from backup: {e}")
|
||||
print("Failed to restore shortcuts file. You may need to recreate your shortcut.")
|
||||
self.logger.warning("shortcuts.vdf restore failed - shortcut may need to be recreated")
|
||||
else:
|
||||
self.logger.info(f"shortcuts.vdf verified intact after restart")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,18 +26,21 @@ class WabbajackParser:
|
||||
'Fallout4': 'fallout4',
|
||||
'FalloutNewVegas': 'falloutnv',
|
||||
'Oblivion': 'oblivion',
|
||||
'Skyrim': 'skyrim', # Legacy Skyrim
|
||||
'Fallout3': 'fallout3', # For completeness
|
||||
'SkyrimVR': 'skyrim', # Treat as Skyrim
|
||||
'Fallout4VR': 'fallout4', # Treat as Fallout 4
|
||||
'Enderal': 'enderal', # Enderal: Forgotten Stories
|
||||
'EnderalSpecialEdition': 'enderal', # Enderal SE
|
||||
'Skyrim': 'skyrim',
|
||||
'Fallout3': 'fallout3',
|
||||
'SkyrimVR': 'skyrimvr',
|
||||
'Fallout4VR': 'fallout4vr',
|
||||
'Enderal': 'enderal',
|
||||
'EnderalSpecialEdition': 'enderal',
|
||||
'Cyberpunk2077': 'cp2077',
|
||||
'BaldursGate3': 'bg3',
|
||||
}
|
||||
|
||||
|
||||
# List of supported games in Jackify
|
||||
self.supported_games = [
|
||||
'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion',
|
||||
'starfield', 'oblivion_remastered', 'enderal'
|
||||
'starfield', 'oblivion_remastered', 'enderal',
|
||||
'skyrimvr', 'fallout4vr', 'bg3',
|
||||
]
|
||||
|
||||
def parse_wabbajack_game_type(self, wabbajack_path: Path) -> Optional[tuple]:
|
||||
@@ -98,6 +101,23 @@ class WabbajackParser:
|
||||
self.logger.error(f"Error parsing .wabbajack file {wabbajack_path}: {e}")
|
||||
return None
|
||||
|
||||
def parse_wabbajack_readme(self, wabbajack_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Extract the readme URL from a .wabbajack file.
|
||||
|
||||
Returns the URL string, or None if not present or unreadable.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(wabbajack_path, 'r') as zip_file:
|
||||
modlist_files = [f for f in zip_file.namelist() if f in ['modlist', 'modlist.json']]
|
||||
if not modlist_files:
|
||||
return None
|
||||
with zip_file.open(modlist_files[0]) as f:
|
||||
data = json.load(f)
|
||||
return data.get('Readme') or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_supported_game(self, game_type: str) -> bool:
|
||||
"""
|
||||
Check if a game type is supported by Jackify's post-install configuration.
|
||||
@@ -128,12 +148,16 @@ class WabbajackParser:
|
||||
"""
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim Special Edition',
|
||||
'fallout4': 'Fallout 4',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
'enderal': 'Enderal',
|
||||
'skyrimvr': 'Skyrim VR',
|
||||
'fallout4vr': 'Fallout 4 VR',
|
||||
'cp2077': 'Cyberpunk 2077',
|
||||
'bg3': "Baldur's Gate 3",
|
||||
}
|
||||
return [display_names.get(game, game) for game in self.supported_games]
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ Extracted from wine_utils for file-size and domain separation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import Optional
|
||||
@@ -56,39 +55,6 @@ class WineUtilsConfigMixin:
|
||||
logger.error(f"Error performing additional tasks: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def modlist_specific_steps(modlist: str, appid: str) -> bool:
|
||||
"""Perform modlist-specific configuration steps. Returns True on success."""
|
||||
try:
|
||||
modlist_configs = {
|
||||
"wildlander": ["dotnet48", "dotnet472", "vcrun2019"],
|
||||
"septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"],
|
||||
"masterstroke": ["dotnet48", "dotnet472"],
|
||||
"diablo": ["dotnet48", "dotnet472"],
|
||||
"living_skyrim": ["dotnet48", "dotnet472", "dotnet462"],
|
||||
"nolvus": ["dotnet8"]
|
||||
}
|
||||
modlist_lower = modlist.lower().replace(" ", "")
|
||||
if "wildlander" in modlist_lower:
|
||||
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
|
||||
return True
|
||||
for pattern, components in modlist_configs.items():
|
||||
if re.search(pattern.replace("|", "|.*"), modlist_lower):
|
||||
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
|
||||
for component in components:
|
||||
if component == "dotnet8":
|
||||
logger.info("Downloading .NET 8 Runtime")
|
||||
pass
|
||||
else:
|
||||
logger.info(f"Installing {component}...")
|
||||
pass
|
||||
return True
|
||||
logger.debug(f"No specific steps needed for {modlist}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error performing modlist-specific steps: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool:
|
||||
"""Set up Fallout New Vegas launch options. Returns True on success."""
|
||||
|
||||
@@ -136,7 +136,7 @@ class WineUtilsProtonMixin:
|
||||
if fallback_path != 'auto':
|
||||
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
|
||||
if fallback_wine_bin.is_file():
|
||||
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
|
||||
logger.info(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
|
||||
return str(fallback_wine_bin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -36,7 +36,6 @@ def _get_clean_winetricks_base_env() -> dict:
|
||||
env["PATH"] = path or "/usr/bin:/bin"
|
||||
return env
|
||||
|
||||
|
||||
class WinetricksEnvMixin:
|
||||
"""Mixin providing env build and dependency check for WinetricksHandler.install_wine_components."""
|
||||
|
||||
@@ -54,10 +53,11 @@ class WinetricksEnvMixin:
|
||||
env['WINEDEBUG'] = '-all'
|
||||
env['WINEPREFIX'] = wineprefix
|
||||
env['WINETRICKS_GUI'] = 'none'
|
||||
if 'DISPLAY' in env:
|
||||
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
|
||||
else:
|
||||
env['DISPLAY'] = env.get('DISPLAY', '')
|
||||
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
|
||||
# Preserve the desktop display variables for Step 4. The validated fix
|
||||
# for the blank taskbar popup regression was keeping DISPLAY available.
|
||||
# Do not strip extra desktop activation vars here without a reproduced,
|
||||
# evidence-backed need.
|
||||
|
||||
try:
|
||||
from ..handlers.config_handler import ConfigHandler
|
||||
@@ -243,7 +243,10 @@ class WinetricksEnvMixin:
|
||||
if not found:
|
||||
missing_deps.append(dep_name)
|
||||
if dep_name in bundled_tools_list:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
|
||||
if dep_name == 'aria2c':
|
||||
self.logger.debug(f" {dep_name}: NOT FOUND (optional - curl/wget used if available)")
|
||||
else:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
|
||||
else:
|
||||
self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)")
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ class ModlistContext:
|
||||
"""Context object for modlist operations."""
|
||||
name: str
|
||||
install_dir: Path
|
||||
download_dir: Path
|
||||
game_type: str
|
||||
nexus_api_key: str
|
||||
download_dir: Optional[Path] = None
|
||||
modlist_value: Optional[str] = None
|
||||
modlist_source: Optional[str] = None # 'identifier' or 'file'
|
||||
resolution: Optional[str] = None
|
||||
@@ -29,8 +29,8 @@ class ModlistContext:
|
||||
"""Convert string paths to Path objects."""
|
||||
if isinstance(self.install_dir, str):
|
||||
self.install_dir = Path(self.install_dir)
|
||||
if isinstance(self.download_dir, str):
|
||||
self.download_dir = Path(self.download_dir)
|
||||
if self.download_dir is not None and isinstance(self.download_dir, str):
|
||||
self.download_dir = Path(self.download_dir) if self.download_dir else None
|
||||
if isinstance(self.mo2_exe_path, str):
|
||||
self.mo2_exe_path = Path(self.mo2_exe_path)
|
||||
|
||||
|
||||
@@ -19,66 +19,68 @@ logger = logging.getLogger(__name__)
|
||||
class GameUtilsMixin:
|
||||
"""Mixin for game-related utility operations"""
|
||||
|
||||
def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Generate launch options for FNV/Enderal games that require vanilla compatdata.
|
||||
|
||||
Args:
|
||||
special_game_type: "fnv" or "enderal"
|
||||
modlist_install_dir: Directory where the modlist is installed
|
||||
|
||||
Returns:
|
||||
Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
|
||||
"""
|
||||
if not special_game_type or special_game_type not in ["fnv", "enderal"]:
|
||||
return None
|
||||
|
||||
logger.info(f"Generating {special_game_type.upper()} launch options")
|
||||
|
||||
# Map game types to AppIDs
|
||||
appid_map = {"fnv": "22380", "enderal": "976620"}
|
||||
appid = appid_map[special_game_type]
|
||||
|
||||
# Find vanilla game compatdata
|
||||
from ..handlers.path_handler import PathHandler
|
||||
compatdata_path = PathHandler.find_compat_data(appid)
|
||||
if not compatdata_path:
|
||||
logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
|
||||
return None
|
||||
|
||||
# Create STEAM_COMPAT_DATA_PATH string
|
||||
compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
|
||||
|
||||
# Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
|
||||
compat_mounts_str = ""
|
||||
try:
|
||||
all_libs = PathHandler.get_all_steam_library_paths()
|
||||
main_steam_lib_path_obj = PathHandler.find_steam_library()
|
||||
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
|
||||
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
|
||||
else:
|
||||
main_steam_lib_path = main_steam_lib_path_obj
|
||||
|
||||
mount_paths = []
|
||||
if main_steam_lib_path:
|
||||
main_resolved = main_steam_lib_path.resolve()
|
||||
for lib_path in all_libs:
|
||||
if lib_path.resolve() != main_resolved:
|
||||
mount_paths.append(str(lib_path.resolve()))
|
||||
|
||||
if mount_paths:
|
||||
mount_paths_str = ':'.join(mount_paths)
|
||||
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
|
||||
logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
|
||||
|
||||
# Combine all launch options
|
||||
launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
|
||||
launch_options = ' '.join(launch_options.split()) # Clean up spacing
|
||||
|
||||
logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
|
||||
return launch_options
|
||||
# TODO post-0.6: remove this method - dead code, never called.
|
||||
# Superseded by registry injection (game paths written directly into the modlist prefix).
|
||||
# def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
|
||||
# """
|
||||
# Generate launch options for FNV/Enderal games that require vanilla compatdata.
|
||||
#
|
||||
# Args:
|
||||
# special_game_type: "fnv" or "enderal"
|
||||
# modlist_install_dir: Directory where the modlist is installed
|
||||
#
|
||||
# Returns:
|
||||
# Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
|
||||
# """
|
||||
# if not special_game_type or special_game_type not in ["fnv", "enderal"]:
|
||||
# return None
|
||||
#
|
||||
# logger.info(f"Generating {special_game_type.upper()} launch options")
|
||||
#
|
||||
# # Map game types to AppIDs
|
||||
# appid_map = {"fnv": "22380", "enderal": "976620"}
|
||||
# appid = appid_map[special_game_type]
|
||||
#
|
||||
# # Find vanilla game compatdata
|
||||
# from ..handlers.path_handler import PathHandler
|
||||
# compatdata_path = PathHandler.find_compat_data(appid)
|
||||
# if not compatdata_path:
|
||||
# logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
|
||||
# return None
|
||||
#
|
||||
# # Create STEAM_COMPAT_DATA_PATH string
|
||||
# compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
|
||||
#
|
||||
# # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
|
||||
# compat_mounts_str = ""
|
||||
# try:
|
||||
# all_libs = PathHandler.get_all_steam_library_paths()
|
||||
# main_steam_lib_path_obj = PathHandler.find_steam_library()
|
||||
# if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
|
||||
# main_steam_lib_path = main_steam_lib_path_obj.parent.parent
|
||||
# else:
|
||||
# main_steam_lib_path = main_steam_lib_path_obj
|
||||
#
|
||||
# mount_paths = []
|
||||
# if main_steam_lib_path:
|
||||
# main_resolved = main_steam_lib_path.resolve()
|
||||
# for lib_path in all_libs:
|
||||
# if lib_path.resolve() != main_resolved:
|
||||
# mount_paths.append(str(lib_path.resolve()))
|
||||
#
|
||||
# if mount_paths:
|
||||
# mount_paths_str = ':'.join(mount_paths)
|
||||
# compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
|
||||
# logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
|
||||
# except Exception as e:
|
||||
# logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
|
||||
#
|
||||
# # Combine all launch options
|
||||
# launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
|
||||
# launch_options = ' '.join(launch_options.split()) # Clean up spacing
|
||||
#
|
||||
# logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
|
||||
# return launch_options
|
||||
|
||||
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
|
||||
"""Find a Steam game installation path by AppID and common names"""
|
||||
@@ -140,36 +142,90 @@ class GameUtilsMixin:
|
||||
|
||||
return None
|
||||
|
||||
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str):
|
||||
def _detect_skyrim_se_modlist(self, modlist_dir: str) -> bool:
|
||||
"""
|
||||
Return True if modlist_dir is a Skyrim SE (non-VR) modlist.
|
||||
|
||||
Used only to trigger first-launch seeding when special_game_type is None.
|
||||
Other games are not yet confirmed to need this treatment.
|
||||
"""
|
||||
if not modlist_dir:
|
||||
return False
|
||||
try:
|
||||
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
mo2_ini = Path(modlist_dir) / "files" / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return False
|
||||
content = mo2_ini.read_text(errors='ignore').lower()
|
||||
# Anchor VR check to gameName= to avoid false positives from plugin
|
||||
# setting keys like enable_skyrimVR=false appearing in SE modlists.
|
||||
for _line in content.splitlines():
|
||||
if _line.strip().startswith("gamename="):
|
||||
game_name_value = _line.strip()[len("gamename="):]
|
||||
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
|
||||
return False
|
||||
break
|
||||
return 'skyrim special edition' in content or 'skse64_loader' in content
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check Skyrim SE detection for {modlist_dir}: {e}")
|
||||
return False
|
||||
|
||||
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str,
|
||||
modlist_dir: Optional[str] = None):
|
||||
"""
|
||||
Pre-create game-specific user directories to prevent first-launch issues.
|
||||
|
||||
Creates both My Documents/My Games and AppData/Local directories for the game.
|
||||
This prevents issues where games fail to create these on first launch under Proton.
|
||||
special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games). For standard
|
||||
games like Skyrim SE that aren't "special" in that sense, modlist_dir is used to
|
||||
detect what directories to seed.
|
||||
"""
|
||||
# Map game types to their directory names
|
||||
# Bethesda-pattern games: same name used for both My Games and AppData/Local
|
||||
game_dir_names = {
|
||||
"skyrim": "Skyrim Special Edition",
|
||||
"skyrimvr": "Skyrim VR",
|
||||
"fnv": "FalloutNV",
|
||||
"fo3": "Fallout3",
|
||||
"fo4": "Fallout4",
|
||||
"fallout4vr": "Fallout4VR",
|
||||
"oblivion": "Oblivion",
|
||||
"oblivion_remastered": "Oblivion Remastered",
|
||||
"enderal": "Enderal Special Edition",
|
||||
"starfield": "Starfield"
|
||||
"starfield": "Starfield",
|
||||
}
|
||||
|
||||
# Get the directory name for this game type
|
||||
game_dir_name = game_dir_names.get(special_game_type)
|
||||
if not game_dir_name:
|
||||
logger.debug(f"No user directory mapping for game type: {special_game_type}")
|
||||
return
|
||||
# Non-Bethesda games: AppData/Local only, with a vendor-namespaced subdirectory
|
||||
game_appdata_only = {
|
||||
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
|
||||
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
|
||||
}
|
||||
|
||||
# special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games).
|
||||
# Skyrim SE returns None from detect_special_game_type but still needs seeding.
|
||||
game_type = special_game_type
|
||||
if special_game_type is None and modlist_dir and self._detect_skyrim_se_modlist(modlist_dir):
|
||||
game_type = "skyrim"
|
||||
|
||||
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
|
||||
|
||||
if game_type in game_appdata_only:
|
||||
appdata_dir = os.path.join(base_path, "AppData", "Local", game_appdata_only[game_type])
|
||||
try:
|
||||
os.makedirs(appdata_dir, exist_ok=True)
|
||||
logger.info(f"Created AppData/Local directory: {appdata_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create AppData/Local directory {appdata_dir}: {e}")
|
||||
return
|
||||
|
||||
game_dir_name = game_dir_names.get(game_type)
|
||||
if not game_dir_name:
|
||||
logger.debug(f"No user directory mapping for game type: {game_type}")
|
||||
return
|
||||
|
||||
directories_to_create = [
|
||||
os.path.join(base_path, "Documents", "My Games", game_dir_name),
|
||||
os.path.join(base_path, "AppData", "Local", game_dir_name)
|
||||
os.path.join(base_path, "AppData", "Local", game_dir_name),
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
@@ -184,90 +240,46 @@ class GameUtilsMixin:
|
||||
if created_count > 0:
|
||||
logger.info(f"Created {created_count} user directories for {game_dir_name}")
|
||||
|
||||
def _get_lorerim_preferred_proton(self):
|
||||
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
|
||||
if game_type == "skyrim":
|
||||
self._seed_skyrim_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "fo4":
|
||||
self._seed_fo4_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "skyrimvr":
|
||||
self._seed_skyrimvr_first_launch_files(base_path, game_dir_name)
|
||||
elif game_type == "fallout4vr":
|
||||
self._seed_fallout4vr_first_launch_files(base_path, game_dir_name)
|
||||
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Skyrim first-launch fix files."""
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Get all available Proton versions
|
||||
available_versions = WineUtils.scan_all_proton_versions()
|
||||
|
||||
if not available_versions:
|
||||
logger.warning("No Proton versions found for Lorerim override")
|
||||
return None
|
||||
|
||||
# Priority order for Lorerim:
|
||||
# 1. GEProton9-27 (specific version)
|
||||
# 2. Other GEProton-9 versions (latest first)
|
||||
# 3. Valve Proton 9 (any version)
|
||||
|
||||
preferred_candidates = []
|
||||
|
||||
for version in available_versions:
|
||||
version_name = version['name']
|
||||
|
||||
# Priority 1: GEProton9-27 specifically
|
||||
if version_name == 'GE-Proton9-27':
|
||||
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
|
||||
return version_name
|
||||
|
||||
# Priority 2: Other GE-Proton 9 versions
|
||||
elif version_name.startswith('GE-Proton9-'):
|
||||
preferred_candidates.append(('ge_proton_9', version_name, version))
|
||||
|
||||
# Priority 3: Valve Proton 9
|
||||
elif 'Proton 9' in version_name:
|
||||
preferred_candidates.append(('valve_proton_9', version_name, version))
|
||||
|
||||
# Return best candidate if any found
|
||||
if preferred_candidates:
|
||||
# Sort by priority (GE-Proton first, then by name for latest)
|
||||
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
best_candidate = preferred_candidates[0]
|
||||
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
|
||||
return best_candidate[1]
|
||||
|
||||
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
|
||||
return None
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error detecting Lorerim Proton preference: {e}")
|
||||
return None
|
||||
logger.warning(f"Could not seed Skyrim first-launch files: {e}")
|
||||
|
||||
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
|
||||
"""Store Proton override information for end-of-install notification"""
|
||||
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Fallout 4 first-launch fix files."""
|
||||
try:
|
||||
# Store override info for later display
|
||||
if not hasattr(self, '_proton_overrides'):
|
||||
self._proton_overrides = []
|
||||
|
||||
self._proton_overrides.append({
|
||||
'modlist': modlist_name,
|
||||
'proton_version': proton_version,
|
||||
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
|
||||
})
|
||||
|
||||
logger.debug(f"Stored Proton override notification: {modlist_name} → {proton_version}")
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store Proton override notification: {e}")
|
||||
logger.warning(f"Could not seed FO4 first-launch files: {e}")
|
||||
|
||||
def _show_proton_override_notification(self, progress_callback=None):
|
||||
"""Display any Proton override notifications to the user"""
|
||||
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Skyrim VR first-launch fix files."""
|
||||
try:
|
||||
if hasattr(self, '_proton_overrides') and self._proton_overrides:
|
||||
for override in self._proton_overrides:
|
||||
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("")
|
||||
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
|
||||
|
||||
logger.info(notification_msg)
|
||||
|
||||
# Clear notifications after display
|
||||
self._proton_overrides = []
|
||||
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to show Proton override notification: {e}")
|
||||
logger.warning(f"Could not seed SkyrimVR first-launch files: {e}")
|
||||
|
||||
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
|
||||
"""Delegate to FileSystemHandler to seed Fallout 4 VR first-launch fix files."""
|
||||
try:
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
fsh = FileSystemHandler()
|
||||
fsh._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not seed FO4VR first-launch files: {e}")
|
||||
|
||||
@@ -20,23 +20,6 @@ class ProtonOperationsMixin:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Check for Lorerim-specific Proton override first
|
||||
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
||||
if modlist_normalized == 'lorerim':
|
||||
lorerim_proton = self._get_lorerim_preferred_proton()
|
||||
if lorerim_proton:
|
||||
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
|
||||
self._store_proton_override_notification("Lorerim", lorerim_proton)
|
||||
return lorerim_proton
|
||||
|
||||
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
|
||||
if modlist_normalized == 'lostlegacy':
|
||||
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
|
||||
if lostlegacy_proton:
|
||||
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
|
||||
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
|
||||
return lostlegacy_proton
|
||||
|
||||
config_handler = ConfigHandler()
|
||||
user_proton_path = config_handler.get_game_proton_path()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Registry operations mixin for AutomatedPrefixService."""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -74,7 +75,7 @@ class RegistryOperationsMixin:
|
||||
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
|
||||
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
|
||||
|
||||
Direct file editing is preferred over `wine reg add` — faster, no Wine
|
||||
Direct file editing is preferred over `wine reg add` - faster, no Wine
|
||||
process overhead, and works even when Proton isn't on PATH. Falls back
|
||||
to subprocess wine reg add when the reg files haven't been created yet.
|
||||
"""
|
||||
@@ -91,10 +92,12 @@ class RegistryOperationsMixin:
|
||||
|
||||
fix1 = fix2 = False
|
||||
|
||||
# Targeted per-exe override for SkyrimSE.exe only - see modlist_wine_ops.py
|
||||
# for rationale. Global DllOverrides entry breaks .NET 9/10 bootstrap.
|
||||
if os.path.exists(user_reg):
|
||||
fix1 = self._reg_set_value(
|
||||
user_reg,
|
||||
"[Software\\\\Wine\\\\DllOverrides]",
|
||||
"[Software\\\\Wine\\\\AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides]",
|
||||
'"*mscoree"',
|
||||
'"native"',
|
||||
)
|
||||
@@ -123,7 +126,7 @@ class RegistryOperationsMixin:
|
||||
|
||||
r1 = subprocess.run(
|
||||
[wine_binary, 'reg', 'add',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
|
||||
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'],
|
||||
env=env, capture_output=True, text=True, errors='replace',
|
||||
)
|
||||
@@ -145,6 +148,53 @@ class RegistryOperationsMixin:
|
||||
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
|
||||
return False
|
||||
|
||||
def _apply_cp2077_dll_overrides(self, modlist_compatdata_path: str) -> bool:
|
||||
"""Write CP2077 DLL overrides directly into the prefix user.reg.
|
||||
|
||||
MO2 on Linux launches each executable through a separate Proton invocation,
|
||||
so WINEDLLOVERRIDES set in Steam launch options is not inherited by the game
|
||||
process. Writing the overrides into user.reg ensures they are always applied
|
||||
regardless of how the process is started.
|
||||
|
||||
version and winmm are the entry-point DLLs for CET and Red4ext respectively.
|
||||
Without native,builtin for both, neither mod framework can inject into the
|
||||
game process and CP2077 exits immediately.
|
||||
"""
|
||||
try:
|
||||
user_reg = os.path.join(modlist_compatdata_path, "pfx", "user.reg")
|
||||
if not os.path.exists(user_reg):
|
||||
logger.warning("user.reg not found, cannot apply CP2077 DLL overrides")
|
||||
return False
|
||||
|
||||
section = "[Software\\\\Wine\\\\DllOverrides]"
|
||||
overrides = [
|
||||
('"version"', '"native,builtin"'),
|
||||
('"winmm"', '"native,builtin"'),
|
||||
]
|
||||
for key, val in overrides:
|
||||
self._reg_set_value(user_reg, section, key, val)
|
||||
|
||||
logger.info("Applied CP2077 DLL overrides (version, winmm) to prefix registry")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply CP2077 DLL overrides: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _wow64_counterpart(section: str) -> str:
|
||||
"""Return the Wow6432Node counterpart for a registry section, or vice versa.
|
||||
|
||||
NaK writes both paths for every game so both 32-bit and 64-bit lookups
|
||||
resolve correctly regardless of the calling process's bitness.
|
||||
"""
|
||||
low = section.lower()
|
||||
if "wow6432node" in low:
|
||||
# Strip Wow6432Node to get the 64-bit path
|
||||
return re.sub(r'(?i)wow6432node\\\\', '', section)
|
||||
else:
|
||||
# Insert Wow6432Node after the opening [Software\\
|
||||
return re.sub(r'(?i)(\[Software\\\\)', r'\1Wow6432Node\\\\', section)
|
||||
|
||||
def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool:
|
||||
"""Set or add a key=value pair in a Wine .reg text file."""
|
||||
try:
|
||||
@@ -319,19 +369,19 @@ class RegistryOperationsMixin:
|
||||
"name": "Fallout New Vegas",
|
||||
"common_names": ["Fallout New Vegas", "FalloutNV"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"22300": { # Fallout 3 AppID
|
||||
"name": "Fallout 3",
|
||||
"common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"22370": { # Fallout 3 GOTY AppID alias
|
||||
"name": "Fallout 3",
|
||||
"common_names": ["Fallout 3 GOTY", "Fallout 3"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
|
||||
"path_key": "Installed Path",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"976620": { # Enderal Special Edition AppID
|
||||
"name": "Enderal",
|
||||
@@ -339,6 +389,72 @@ class RegistryOperationsMixin:
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"1091500": { # Cyberpunk 2077 AppID
|
||||
"name": "Cyberpunk 2077",
|
||||
"common_names": ["Cyberpunk 2077"],
|
||||
"registry_section": "[Software\\\\CD Projekt Red\\\\Cyberpunk 2077]",
|
||||
"path_key": "InstallFolder",
|
||||
},
|
||||
"1086940": { # Baldur's Gate 3 AppID
|
||||
"name": "Baldur's Gate 3",
|
||||
"common_names": ["Baldur's Gate 3", "BaldursGate3"],
|
||||
"registry_section": "[Software\\\\Larian Studios\\\\Baldur's Gate 3]",
|
||||
"path_key": "InstallDir",
|
||||
},
|
||||
"611670": { # Skyrim VR AppID (64-bit, no Wow6432Node)
|
||||
"name": "Skyrim VR",
|
||||
"common_names": ["Skyrim VR", "SkyrimVR"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim VR]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"611660": { # Fallout 4 VR AppID (64-bit, no Wow6432Node)
|
||||
"name": "Fallout 4 VR",
|
||||
"common_names": ["Fallout 4 VR", "Fallout4VR"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout 4 VR]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"22330": { # Oblivion AppID
|
||||
"name": "Oblivion",
|
||||
"common_names": ["Oblivion", "Elder Scrolls IV Oblivion"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\oblivion]",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
"1716740": { # Starfield AppID (64-bit, no Wow6432Node)
|
||||
"name": "Starfield",
|
||||
"common_names": ["Starfield"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Starfield]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"489830": { # Skyrim Special Edition AppID (64-bit, no Wow6432Node)
|
||||
"name": "Skyrim Special Edition",
|
||||
"common_names": ["Skyrim Special Edition", "SkyrimSE", "Skyrim Anniversary Edition"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim Special Edition]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"377160": { # Fallout 4 AppID (64-bit, no Wow6432Node)
|
||||
"name": "Fallout 4",
|
||||
"common_names": ["Fallout 4", "Fallout4"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout4]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"22320": { # Morrowind AppID (32-bit, Wow6432Node)
|
||||
"name": "Morrowind",
|
||||
"common_names": ["Morrowind", "Elder Scrolls III Morrowind"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\morrowind]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"292030": { # The Witcher 3 AppID (64-bit, no Wow6432Node)
|
||||
"name": "The Witcher 3",
|
||||
"common_names": ["The Witcher 3", "Witcher 3", "The Witcher 3 Wild Hunt"],
|
||||
"registry_section": "[Software\\\\CD Projekt Red\\\\The Witcher 3]",
|
||||
"path_key": "InstallFolder",
|
||||
},
|
||||
"2623190": { # Oblivion Remastered AppID (64-bit UE5, no Wow6432Node)
|
||||
"name": "Oblivion Remastered",
|
||||
"common_names": ["Oblivion Remastered", "OblivionRemastered"],
|
||||
"registry_section": "[Software\\\\Bethesda Softworks\\\\Oblivion Remastered]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
}
|
||||
|
||||
pfx_path = Path(modlist_compatdata_path) / "pfx"
|
||||
@@ -359,24 +475,22 @@ class RegistryOperationsMixin:
|
||||
game_dir_name = Path(game_path).name
|
||||
canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}"
|
||||
wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\"
|
||||
success = self._reg_set_value(
|
||||
system_reg_path,
|
||||
config["registry_section"],
|
||||
f'"{config["path_key"]}"',
|
||||
f'"{wine_val}"',
|
||||
)
|
||||
key = f'"{config["path_key"]}"'
|
||||
val = f'"{wine_val}"'
|
||||
success = self._reg_set_value(system_reg_path, config["registry_section"], key, val)
|
||||
self._reg_set_value(system_reg_path, self._wow64_counterpart(config["registry_section"]), key, val)
|
||||
if success:
|
||||
logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}")
|
||||
else:
|
||||
logger.warning(f"Failed to set canonical registry path for {config['name']}")
|
||||
else:
|
||||
# Symlink failed — fall back to writing the real Z:/D: path
|
||||
# Symlink failed - fall back to writing the real Z:/D: path
|
||||
logger.warning(f"Symlink failed for {config['name']}, writing real path to registry")
|
||||
success = self._update_registry_path(
|
||||
system_reg_path,
|
||||
config["registry_section"],
|
||||
config["path_key"],
|
||||
game_path
|
||||
system_reg_path, config["registry_section"], config["path_key"], game_path
|
||||
)
|
||||
self._update_registry_path(
|
||||
system_reg_path, self._wow64_counterpart(config["registry_section"]), config["path_key"], game_path
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Updated registry entry for {config['name']} (real path fallback)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
181
jackify/backend/services/steamgriddb_service.py
Normal file
181
jackify/backend/services/steamgriddb_service.py
Normal 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
|
||||
600
jackify/backend/services/tool_config_service.py
Normal file
600
jackify/backend/services/tool_config_service.py
Normal 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)
|
||||
503
jackify/backend/services/tool_registry.py
Normal file
503
jackify/backend/services/tool_registry.py
Normal 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}"
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user