Release v0.6.0

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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