mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 01:47:45 +02:00
Release v0.6.0
This commit is contained in:
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user