mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 02:23:45 +02:00
Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
This commit is contained in:
@@ -220,10 +220,21 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
|
||||
def save_config(self):
|
||||
"""Save current configuration to file"""
|
||||
import tempfile
|
||||
try:
|
||||
self._create_config_dir()
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(self.settings, f, indent=2)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.config_file), prefix='.config_tmp_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(self.settings, f, indent=2)
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, self.config_file)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
logger.debug("Saved configuration to file")
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -98,13 +98,7 @@ class ConfigEncryptionMixin:
|
||||
else:
|
||||
self.settings["nexus_api_key"] = None
|
||||
logger.debug("API key cleared")
|
||||
result = self.save_config()
|
||||
if result:
|
||||
try:
|
||||
os.chmod(self.config_file, 0o600)
|
||||
except Exception as e:
|
||||
logger.warning("Could not set restrictive permissions on config: %s", e)
|
||||
return result
|
||||
return self.save_config()
|
||||
except Exception as e:
|
||||
logger.error("Error saving API key: %s", e)
|
||||
return False
|
||||
|
||||
@@ -384,31 +384,7 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
|
||||
logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}")
|
||||
return False # Backup succeeded, but adding entry failed
|
||||
|
||||
def blank_downloads_dir(self, modlist_ini: Path) -> bool:
|
||||
"""
|
||||
Blank or reset the MO2 Downloads Directory
|
||||
Returns True on success, False on failure
|
||||
"""
|
||||
try:
|
||||
self.logger.info("Editing download_directory...")
|
||||
|
||||
# Read the file
|
||||
with open(modlist_ini, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace the download_directory line
|
||||
modified_content = re.sub(r'download_directory[^\n]*', 'download_directory =', content)
|
||||
|
||||
# Write back to the file
|
||||
with open(modlist_ini, 'w') as f:
|
||||
f.write(modified_content)
|
||||
|
||||
self.logger.debug("Download directory cleared successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error blanking downloads directory: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def copy_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool:
|
||||
"""
|
||||
|
||||
@@ -38,6 +38,11 @@ class FilesystemOwnershipMixin:
|
||||
Verify and fix ownership/permissions for modlist directory.
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
if ValidationHandler().is_dangerous_directory(path):
|
||||
logger.error("Refusing to modify permissions on dangerous path: %s", path)
|
||||
return False, f"Refusing to modify permissions on '{path}': system or user root directory."
|
||||
|
||||
if not path.exists():
|
||||
logger.error("Path does not exist: %s", path)
|
||||
return False, f"Path does not exist: {path}"
|
||||
|
||||
@@ -280,8 +280,11 @@ class ModlistMenuHandler:
|
||||
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
|
||||
|
||||
while True:
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
download_dir = resolve_mo2_download_dir(Path(install_dir))
|
||||
result = prefix_service.run_working_workflow(
|
||||
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
|
||||
modlist_name, install_dir, mo2_path, progress_callback,
|
||||
steamdeck=self.steamdeck, download_dir=download_dir,
|
||||
)
|
||||
|
||||
if isinstance(result, tuple) and len(result) == 4:
|
||||
@@ -303,7 +306,6 @@ class ModlistMenuHandler:
|
||||
"name": modlist_name,
|
||||
"appid": str(existing_appid),
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
@@ -335,7 +337,6 @@ class ModlistMenuHandler:
|
||||
"name": modlist_name,
|
||||
"appid": str(appid_int),
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
|
||||
@@ -534,9 +535,7 @@ class ModlistMenuHandler:
|
||||
else:
|
||||
status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}"
|
||||
print(status_line, end="", flush=True)
|
||||
manual_steps_completed = context.get("manual_steps_completed", False)
|
||||
skip_manual_for_existing = context.get("modlist_source") == "existing" # Existing modlists skip manual steps
|
||||
if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed, skip_manual_for_existing=skip_manual_for_existing):
|
||||
if not self.modlist_handler._execute_configuration_steps(status_callback=update_status):
|
||||
if status_line:
|
||||
print()
|
||||
self.logger.error(f"Core configuration steps failed for {context.get('name')}")
|
||||
@@ -555,7 +554,6 @@ class ModlistMenuHandler:
|
||||
if not gui_mode:
|
||||
try:
|
||||
from ..handlers.enb_handler import ENBHandler
|
||||
from pathlib import Path
|
||||
|
||||
enb_handler = ENBHandler()
|
||||
install_dir = Path(context.get('path', ''))
|
||||
@@ -587,8 +585,6 @@ class ModlistMenuHandler:
|
||||
create_vnv_cli_progress_callback,
|
||||
ensure_vnv_cli_manual_downloads,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
modlist_name = context.get('name', '')
|
||||
modlist_path = Path(context.get('path', ''))
|
||||
|
||||
@@ -660,37 +656,57 @@ class ModlistMenuHandler:
|
||||
completion_title = "Modlist Configuration complete!" if is_existing_flow else "Modlist Install and Configuration complete!"
|
||||
completion_log_file = "Configure_Existing_Modlist_workflow.log" if is_existing_flow else "Configure_New_Modlist_workflow.log"
|
||||
|
||||
print("")
|
||||
print("")
|
||||
print("") # Extra blank line before completion
|
||||
print("=" * 35)
|
||||
print("= Configuration phase complete =")
|
||||
print("=" * 35)
|
||||
print("")
|
||||
print(completion_title)
|
||||
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
|
||||
print("• Congratulations and enjoy the game!")
|
||||
print("")
|
||||
if not context.get('suppress_completion_banner'):
|
||||
print("")
|
||||
print("")
|
||||
print("")
|
||||
print("=" * 35)
|
||||
print("= Configuration phase complete =")
|
||||
print("=" * 35)
|
||||
print("")
|
||||
print(completion_title)
|
||||
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
|
||||
print("• Congratulations and enjoy the game!")
|
||||
print("")
|
||||
|
||||
# Show ENB-specific warning if ENB was detected (replaces generic note)
|
||||
if enb_detected:
|
||||
print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}")
|
||||
print("")
|
||||
print("If you plan on using ENB as part of this modlist, you will need to use")
|
||||
print("one of the following Proton versions, otherwise you will have issues:")
|
||||
print("")
|
||||
print(" (in order of recommendation)")
|
||||
print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}")
|
||||
print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}")
|
||||
print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}")
|
||||
print("")
|
||||
print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
|
||||
print("")
|
||||
else:
|
||||
# No ENB detected - no warning needed
|
||||
pass
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}")
|
||||
if not context.get('suppress_completion_banner'):
|
||||
if enb_detected:
|
||||
print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}")
|
||||
print("")
|
||||
print("If you plan on using ENB as part of this modlist, you will need to use")
|
||||
print("one of the following Proton versions, otherwise you will have issues:")
|
||||
print("")
|
||||
print(" (in order of recommendation)")
|
||||
print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}")
|
||||
print(f" {COLOR_INFO}• GE-Proton{COLOR_RESET}")
|
||||
print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}")
|
||||
print("")
|
||||
print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
|
||||
print("")
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}")
|
||||
|
||||
try:
|
||||
install_path = context.get('path')
|
||||
shortcut_name = context.get('name')
|
||||
mo2_exe = context.get('mo2_exe_path') or (
|
||||
os.path.join(install_path, 'ModOrganizer.exe') if install_path else None
|
||||
)
|
||||
if install_path and shortcut_name and mo2_exe:
|
||||
ini_path = Path(install_path) / 'ModOrganizer.ini'
|
||||
dl_str = self.path_handler.get_download_directory_linux_path(ini_path)
|
||||
if dl_str:
|
||||
mounts_result = self.shortcut_handler.ensure_mounts_in_steam_compat(
|
||||
shortcut_name, mo2_exe, dl_str
|
||||
)
|
||||
if mounts_result in ("updated", "steam_running"):
|
||||
context['steam_restart_needed'] = True
|
||||
context['mounts_app_name'] = shortcut_name
|
||||
context['mounts_exe_path'] = mo2_exe
|
||||
context['mounts_dl_path'] = dl_str
|
||||
except Exception as e:
|
||||
self.logger.error("Could not update STEAM_COMPAT_MOUNTS: %s", e, exc_info=True)
|
||||
|
||||
# Only wait for input in CLI mode, not GUI mode
|
||||
if not gui_mode:
|
||||
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")
|
||||
|
||||
@@ -48,13 +48,11 @@ class ModlistConfigurationMixin:
|
||||
else:
|
||||
return True
|
||||
|
||||
def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False, skip_manual_for_existing=False):
|
||||
def _execute_configuration_steps(self, status_callback=None):
|
||||
"""
|
||||
Runs the actual configuration steps for the selected modlist.
|
||||
Args:
|
||||
status_callback (callable, optional): A function to call with status updates during configuration.
|
||||
manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow).
|
||||
skip_manual_for_existing (bool): If True, always skip manual steps (for existing modlists that are already configured).
|
||||
"""
|
||||
try:
|
||||
# Store status_callback for Configuration Summary
|
||||
@@ -88,64 +86,6 @@ class ModlistConfigurationMixin:
|
||||
return False # Abort on failure
|
||||
self.logger.info("Step 1: Setting Protontricks permissions... Done")
|
||||
|
||||
# Step 2: Prompt user for manual steps and wait for compatdata
|
||||
skip_manual_prompt = skip_manual_for_existing # Existing modlists skip manual steps
|
||||
if not manual_steps_completed and not skip_manual_for_existing:
|
||||
# Check if Proton Experimental is already set and compatdata exists
|
||||
proton_ok = False
|
||||
compatdata_ok = False
|
||||
|
||||
# Check Proton version
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}")
|
||||
if self._detect_proton_version():
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}")
|
||||
if self.proton_ver and 'experimental' in self.proton_ver.lower():
|
||||
proton_ok = True
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Proton Experimental detected - proton_ok = True")
|
||||
else:
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version")
|
||||
|
||||
# Check compatdata/prefix
|
||||
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}")
|
||||
|
||||
if prefix_path_str and os.path.isdir(prefix_path_str):
|
||||
compatdata_ok = True
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory exists - compatdata_ok = True")
|
||||
else:
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory does not exist")
|
||||
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] proton_ok: {proton_ok}, compatdata_ok: {compatdata_ok}")
|
||||
|
||||
if proton_ok and compatdata_ok:
|
||||
self.logger.info("Proton Experimental and compatdata already set for this AppID; skipping manual steps prompt.")
|
||||
skip_manual_prompt = True
|
||||
else:
|
||||
self.logger.debug("[MANUAL STEPS DEBUG] Manual steps will be required")
|
||||
|
||||
self.logger.debug(f"[MANUAL STEPS DEBUG] manual_steps_completed: {manual_steps_completed}, skip_manual_prompt: {skip_manual_prompt}")
|
||||
|
||||
if not manual_steps_completed and not skip_manual_prompt:
|
||||
# Check if we're in GUI mode - if so, don't show CLI prompts, just fail and let GUI callbacks handle it
|
||||
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
|
||||
|
||||
if gui_mode:
|
||||
# In GUI mode: don't show CLI prompts, just fail so GUI can show dialog and retry
|
||||
self.logger.info("GUI mode detected: skipping CLI manual steps prompt, will fail configuration to trigger GUI callback")
|
||||
if status_callback:
|
||||
status_callback("Manual Steam/Proton setup required - this will be handled by GUI dialog")
|
||||
# Return False to trigger manual steps callback in GUI
|
||||
return False
|
||||
else:
|
||||
# CLI mode: show the traditional CLI prompt
|
||||
if status_callback:
|
||||
status_callback("Please perform the manual steps in Steam (set Proton, launch shortcut, then close MO2)...")
|
||||
self.logger.info("Prompting user to perform manual Steam/Proton steps and launch shortcut.")
|
||||
print("\n───────────────────────────────────────────────────────────────────")
|
||||
print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} Please follow the on-screen instructions to set Proton Experimental and launch the shortcut from Steam.")
|
||||
print("───────────────────────────────────────────────────────────────────")
|
||||
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
|
||||
self.logger.info("User confirmed completion of manual steps.")
|
||||
# Step 3: Apply targeted registry tweaks (replaces wholesale curated reg file overwrite)
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration")
|
||||
@@ -162,7 +102,31 @@ class ModlistConfigurationMixin:
|
||||
|
||||
# Use canonical logic for all modlists/games
|
||||
components = self.get_modlist_wine_components(self.game_name, self.game_var_full)
|
||||
|
||||
|
||||
# NSF detection: dotnet48 required for Skyrim SE modlists with NetScriptFramework
|
||||
if 'skyrim' in (self.game_var_full or '').lower() and self.modlist_dir:
|
||||
nsf_markers = [
|
||||
Path(self.modlist_dir) / 'mods' / 'NetScriptFramework' / 'DLLPlugins' / 'NetScriptFramework.Runtime.dll',
|
||||
Path(self.modlist_dir) / 'mods' / 'NetScriptFramework' / 'Plugins' / 'CustomSkills.dll',
|
||||
Path(self.modlist_dir) / 'mods' / 'NetScriptFramework',
|
||||
]
|
||||
if any(m.exists() for m in nsf_markers):
|
||||
self._nsf_detected = True
|
||||
if 'dotnet48' not in components:
|
||||
self.logger.info("NetScriptFramework detected, adding dotnet48 to component list")
|
||||
components.insert(0, 'dotnet48')
|
||||
|
||||
# Developer/testing override: JACKIFY_SKIP_WINE_COMPONENTS=comp1,comp2 drops those
|
||||
# components from this run, for iterating without re-installing slow verbs (e.g.
|
||||
# dotnetdesktop6). Unset in normal use, so it has no effect on real installs.
|
||||
_skip_env = os.environ.get('JACKIFY_SKIP_WINE_COMPONENTS', '').strip()
|
||||
if _skip_env:
|
||||
_skip_set = {c.strip() for c in _skip_env.split(',') if c.strip()}
|
||||
_removed = [c for c in components if c in _skip_set]
|
||||
if _removed:
|
||||
components = [c for c in components if c not in _skip_set]
|
||||
self.logger.warning("JACKIFY_SKIP_WINE_COMPONENTS active - skipping %s (testing override)", _removed)
|
||||
|
||||
# All modlists now use their own AppID for wine components
|
||||
target_appid = self.appid
|
||||
|
||||
@@ -414,7 +378,8 @@ class ModlistConfigurationMixin:
|
||||
modlist_ini_path=modlist_ini_path_obj,
|
||||
modlist_dir_path=modlist_dir_path_obj,
|
||||
modlist_sdcard=self.modlist_sdcard,
|
||||
steam_libraries=steam_libraries
|
||||
steam_libraries=steam_libraries,
|
||||
compat_data_path=getattr(self, 'compat_data_path', None)
|
||||
):
|
||||
self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini. Configuration aborted.")
|
||||
self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini.")
|
||||
@@ -447,7 +412,7 @@ class ModlistConfigurationMixin:
|
||||
self.logger.debug("No existing download_directory value found in ModOrganizer.ini; skipping normalisation")
|
||||
|
||||
# Step 8.5: Align /home vs /var/home basis for Z: paths to match modlist install directory.
|
||||
# This is intentionally separate from broad binary-path rewriting so it still runs when
|
||||
# Kept separate from broad binary-path rewriting so it still runs when
|
||||
# engine-installed workflows skip edit_binary_working_paths.
|
||||
if not self.path_handler.align_home_path_basis(
|
||||
modlist_ini_path=modlist_ini_path_obj,
|
||||
@@ -625,12 +590,21 @@ class ModlistConfigurationMixin:
|
||||
wine_bin = self._find_wine_binary_for_registry()
|
||||
if compatdata_path and wine_bin:
|
||||
from jackify.backend.services.tool_config_service import apply_tool_config
|
||||
# NSF/CSF modlists need the global *mscoree=native that winetricks dotnet48
|
||||
# set: the per-exe scoping this function applies starves NSF's CLR hosting
|
||||
# (verified directly). So preserve the global override for them.
|
||||
# dotnet9 SDK install also flips the prefix to win11; NSF/CSF was only
|
||||
# verified working on win10 + global-native, so we keep that state and skip
|
||||
# the dotnet9/win11 step. Whether Synthesis runs under global-native on an NSF
|
||||
# prefix is untested - revisit if a modlist needs live Synthesis.
|
||||
_nsf = getattr(self, '_nsf_detected', False)
|
||||
apply_tool_config(
|
||||
compatdata_path,
|
||||
wine_bin,
|
||||
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
|
||||
install_dotnet9_sdk=True,
|
||||
install_dotnet9_sdk=not _nsf,
|
||||
install_fxc2_d3dcompiler=True,
|
||||
preserve_global_mscoree=_nsf,
|
||||
)
|
||||
self.logger.info("Step 15: Tool compatibility settings applied")
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Modlist fixup handler.
|
||||
|
||||
Applies known binary fixes to installed modlists during the configure phase.
|
||||
Each fix is idempotent: it checks the current state before acting and skips
|
||||
if the fix is already in place.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# JContainers SE - Linux crash fix
|
||||
# Nexus releases up to and including 4.2.9.0 crash on Linux.
|
||||
# Fixed build from https://github.com/rfortier/JContainers-rwf (pending Nexus release).
|
||||
# We replace the DLL in-place in whichever mod directory ships it.
|
||||
_JCONTAINERS_FIXED_SHA256 = "4c00d7194c61097361e8f93521d79752a975fca5f54446e391debb0c56999590"
|
||||
_JCONTAINERS_FIXED_URL = (
|
||||
"https://github.com/rfortier/JContainers-rwf/releases/download/v4.2.13.2/"
|
||||
"JContainers64-v4.2.13.2.for.1.6.1170.patch.luajit.with.gc64.7z"
|
||||
)
|
||||
_JCONTAINERS_DLL_NAME = "JContainers64.dll"
|
||||
_JCONTAINERS_ARCHIVE_NAME = "JContainers64-v4.2.13.2.linux-fix.7z"
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _get_7z() -> Optional[Path]:
|
||||
candidates = []
|
||||
appdir = os.environ.get("APPDIR")
|
||||
if appdir:
|
||||
candidates.append(Path(appdir) / "opt" / "jackify" / "tools" / "7z")
|
||||
candidates.append(Path(__file__).parent.parent.parent / "tools" / "7z")
|
||||
for p in candidates:
|
||||
if p.exists() and os.access(p, os.X_OK):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _find_dll_in_archive(seven_z: Path, archive: Path) -> Optional[str]:
|
||||
"""Return the internal path of JContainers64.dll within the archive, or None."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(seven_z), "l", "-ba", "-slt", str(archive)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("Path = ") and line.lower().endswith(_JCONTAINERS_DLL_NAME.lower()):
|
||||
return line[7:].strip()
|
||||
except Exception as exc:
|
||||
logger.debug("7z list failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
_JCONTAINERS_TARGET_SKSE = "skse64_1_6_1170.dll"
|
||||
|
||||
|
||||
def _detect_skse_dll(mods_dir: Path) -> Optional[str]:
|
||||
"""Return the SKSE loader DLL filename found in mods, or None."""
|
||||
for dll in mods_dir.glob("*/Root/skse64_*.dll"):
|
||||
return dll.name
|
||||
return None
|
||||
|
||||
|
||||
def check_jcontainers_needs_fix(modlist_dir: Path, game_type: Optional[str]) -> list:
|
||||
"""Return list of JContainers64.dll paths that need replacing, or empty list."""
|
||||
if not game_type or "skyrim" not in game_type.lower():
|
||||
return []
|
||||
mods_dir = Path(modlist_dir) / "mods"
|
||||
if not mods_dir.is_dir():
|
||||
return []
|
||||
if _detect_skse_dll(mods_dir) != _JCONTAINERS_TARGET_SKSE:
|
||||
return []
|
||||
targets = list(mods_dir.glob(f"*/SKSE/Plugins/{_JCONTAINERS_DLL_NAME}"))
|
||||
return [p for p in targets if _sha256(p) != _JCONTAINERS_FIXED_SHA256]
|
||||
|
||||
|
||||
def apply_jcontainers_fix(
|
||||
modlist_dir: Path,
|
||||
game_var_full: Optional[str],
|
||||
status_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Replace a broken JContainers64.dll with the Linux-compatible build.
|
||||
|
||||
Scoped to Skyrim-based modlists only. Skips silently if already fixed,
|
||||
if no JContainers mod is found, or if required tools are unavailable.
|
||||
"""
|
||||
if not game_var_full or "skyrim" not in game_var_full.lower():
|
||||
return
|
||||
|
||||
mods_dir = Path(modlist_dir) / "mods"
|
||||
if not mods_dir.is_dir():
|
||||
return
|
||||
|
||||
if _detect_skse_dll(mods_dir) != _JCONTAINERS_TARGET_SKSE:
|
||||
return
|
||||
|
||||
targets = list(mods_dir.glob(f"*/SKSE/Plugins/{_JCONTAINERS_DLL_NAME}"))
|
||||
if not targets:
|
||||
logger.debug("JContainers fix: no %s found under %s", _JCONTAINERS_DLL_NAME, mods_dir)
|
||||
return
|
||||
|
||||
needs_fix = [p for p in targets if _sha256(p) != _JCONTAINERS_FIXED_SHA256]
|
||||
if not needs_fix:
|
||||
logger.debug("JContainers fix: all instances already at known-good version")
|
||||
return
|
||||
|
||||
seven_z = _get_7z()
|
||||
if not seven_z:
|
||||
logger.warning("JContainers fix: 7z not available, cannot apply fix")
|
||||
return
|
||||
|
||||
if status_callback:
|
||||
status_callback("Applying JContainers Linux compatibility fix")
|
||||
logger.info("JContainers fix: %d instance(s) need replacement", len(needs_fix))
|
||||
|
||||
cache_dir = get_jackify_data_dir() / "component_cache" / "fixups"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = cache_dir / _JCONTAINERS_ARCHIVE_NAME
|
||||
|
||||
if not archive_path.exists():
|
||||
logger.info("JContainers fix: downloading fixed archive from %s", _JCONTAINERS_FIXED_URL)
|
||||
try:
|
||||
urllib.request.urlretrieve(_JCONTAINERS_FIXED_URL, archive_path)
|
||||
except Exception as exc:
|
||||
logger.error("JContainers fix: download failed: %s", exc)
|
||||
return
|
||||
|
||||
dll_internal_path = _find_dll_in_archive(seven_z, archive_path)
|
||||
if not dll_internal_path:
|
||||
logger.error("JContainers fix: could not locate %s inside archive", _JCONTAINERS_DLL_NAME)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(seven_z), "e", str(archive_path), f"-o{tmp}", dll_internal_path, "-y"],
|
||||
capture_output=True, timeout=60, check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.error("JContainers fix: extraction failed: %s", exc)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
extracted = Path(tmp) / _JCONTAINERS_DLL_NAME
|
||||
if not extracted.exists():
|
||||
logger.error("JContainers fix: %s not found after extraction", _JCONTAINERS_DLL_NAME)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
actual_hash = _sha256(extracted)
|
||||
if actual_hash != _JCONTAINERS_FIXED_SHA256:
|
||||
logger.error(
|
||||
"JContainers fix: extracted DLL hash %s does not match expected %s, aborting",
|
||||
actual_hash, _JCONTAINERS_FIXED_SHA256,
|
||||
)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
import shutil
|
||||
for target in needs_fix:
|
||||
try:
|
||||
backup = target.with_suffix(".dll.bak")
|
||||
shutil.copy2(str(target), str(backup))
|
||||
logger.info("JContainers fix: backed up %s -> %s", target.name, backup.name)
|
||||
shutil.copy2(str(extracted), str(target))
|
||||
logger.info("JContainers fix: replaced %s", target)
|
||||
except Exception as exc:
|
||||
logger.error("JContainers fix: failed to replace %s: %s", target, exc)
|
||||
@@ -485,8 +485,11 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
print(f"{COLOR_INFO}{message}{COLOR_RESET}")
|
||||
|
||||
try:
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
_download_dir = resolve_mo2_download_dir(Path(install_dir_str))
|
||||
_result = prefix_service.run_working_workflow(
|
||||
shortcut_name, install_dir_str, mo2_exe_path, _cli_progress, steamdeck=self.steamdeck
|
||||
shortcut_name, install_dir_str, mo2_exe_path, _cli_progress,
|
||||
steamdeck=self.steamdeck, download_dir=_download_dir,
|
||||
)
|
||||
except Exception as _wf_err:
|
||||
from jackify.shared.errors import JackifyError
|
||||
@@ -517,7 +520,6 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
'mo2_exe_path': mo2_exe_path,
|
||||
'resolution': self.context.get('resolution'),
|
||||
'skip_confirmation': is_gui_mode,
|
||||
'manual_steps_completed': True
|
||||
}
|
||||
|
||||
from .menu_handler import ModlistMenuHandler
|
||||
|
||||
@@ -111,34 +111,10 @@ class ModlistInstallCLINexusMixin:
|
||||
return []
|
||||
|
||||
def _enhance_nexus_error(self, line: str) -> str:
|
||||
"""
|
||||
Enhance Nexus download error messages by adding the mod URL for easier troubleshooting.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Pattern to match Nexus download errors with ModID and FileID
|
||||
nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):"
|
||||
|
||||
match = re.search(nexus_error_pattern, line)
|
||||
if match:
|
||||
game_name = match.group(1)
|
||||
mod_id = match.group(2)
|
||||
|
||||
# Map game names to Nexus URL segments
|
||||
game_url_map = {
|
||||
'SkyrimSpecialEdition': 'skyrimspecialedition',
|
||||
'Skyrim': 'skyrim',
|
||||
'Fallout4': 'fallout4',
|
||||
'FalloutNewVegas': 'newvegas',
|
||||
'Oblivion': 'oblivion',
|
||||
'Starfield': 'starfield'
|
||||
}
|
||||
|
||||
game_url = game_url_map.get(game_name, game_name.lower())
|
||||
mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}"
|
||||
|
||||
# Add URL on next line for easier debugging
|
||||
return f"{line}\n Nexus URL: {mod_url}"
|
||||
|
||||
"""Enhance Nexus download error messages by adding the mod URL for easier troubleshooting."""
|
||||
from jackify.backend.utils.engine_error_parser import nexus_url_from_error_line
|
||||
url = nexus_url_from_error_line(line)
|
||||
if url:
|
||||
return f"{line}\n Nexus URL: {url}"
|
||||
return line
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class ModlistInstallCLITTWMixin:
|
||||
print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}")
|
||||
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
|
||||
print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).")
|
||||
print(f"\nThis modlist ({modlist_name}) requires Tale of Two Wastelands (TTW).")
|
||||
print(f"TTW combines Fallout 3 and New Vegas into a single game.")
|
||||
print(f"\nWould you like to install TTW now?")
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Wine/Proton operation methods for ModlistHandler (Mixin)."""
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List
|
||||
from typing import Tuple, Optional
|
||||
import os
|
||||
import logging
|
||||
import subprocess
|
||||
import shutil
|
||||
import time
|
||||
import vdf
|
||||
import json
|
||||
import configparser
|
||||
@@ -64,11 +63,11 @@ class ModlistWineOpsMixin:
|
||||
with open(str(config_vdf_path), 'r') as f:
|
||||
config_data = vdf.load(f, mapper=vdf.VDFDict)
|
||||
|
||||
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
|
||||
steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {})
|
||||
compat_mapping = steam_config_section.get('CompatToolMapping', {})
|
||||
app_mapping = compat_mapping.get(appid_to_check, {})
|
||||
proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name'
|
||||
self.proton_ver = proton_tool_name # Store detected version
|
||||
proton_tool_name = app_mapping.get('name')
|
||||
self.proton_ver = proton_tool_name
|
||||
|
||||
if proton_tool_name:
|
||||
self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}")
|
||||
@@ -156,19 +155,19 @@ class ModlistWineOpsMixin:
|
||||
self.logger.error(f"Steam userdata directory not found at {userdata_base}")
|
||||
return
|
||||
|
||||
images = [
|
||||
("grid-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
("grid-wide.png", f"{appid}.png"),
|
||||
]
|
||||
|
||||
for user_dir in userdata_base.iterdir():
|
||||
if not user_dir.is_dir() or user_dir.name == "0":
|
||||
continue
|
||||
grid_dir = user_dir / "config/grid"
|
||||
grid_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
images = [
|
||||
("grid-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
("grid-wide.png", f"{appid}.png"),
|
||||
]
|
||||
|
||||
for src_name, dest_name in images:
|
||||
src_path = steam_icons_dir / src_name
|
||||
dest_path = grid_dir / dest_name
|
||||
@@ -193,19 +192,17 @@ class ModlistWineOpsMixin:
|
||||
self.logger.error(f"Failed to copy tenfoot image: {e}")
|
||||
elif wide_src.exists():
|
||||
try:
|
||||
from PySide6.QtGui import QImage
|
||||
img = QImage(str(wide_src))
|
||||
if not img.isNull():
|
||||
scaled = img.scaled(600, 350)
|
||||
scaled.save(str(tenfoot_dest))
|
||||
self.logger.info(f"Generated tenfoot image from landscape: {tenfoot_dest}")
|
||||
else:
|
||||
self.logger.warning(f"Could not load landscape image for tenfoot generation: {wide_src}")
|
||||
shutil.copyfile(wide_src, tenfoot_dest)
|
||||
self.logger.info(f"Copied landscape image as tenfoot fallback: {tenfoot_dest}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not generate tenfoot image: {e}")
|
||||
self.logger.warning(f"Could not copy tenfoot image: {e}")
|
||||
|
||||
def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None):
|
||||
"""Fetch default artwork from SteamGridDB when no modlist-provided SteamIcons exist."""
|
||||
missing_sources = [src for src, _ in images if not (steam_icons_dir / src).exists()]
|
||||
if missing_sources:
|
||||
self._try_steamgriddb_artwork(appid, game_type, modlist_dir, skip_existing=True)
|
||||
|
||||
def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None, skip_existing: bool = False):
|
||||
"""Fetch artwork from SteamGridDB. When skip_existing is True, slots already present in grid_dir are not overwritten."""
|
||||
if not game_type and modlist_dir:
|
||||
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
||||
game_type = detect_game_type_from_modlist(modlist_dir)
|
||||
@@ -241,19 +238,23 @@ class ModlistWineOpsMixin:
|
||||
for src_name, dest_name in images:
|
||||
src = tmp_dir / src_name
|
||||
if src.exists():
|
||||
dest = grid_dir / dest_name
|
||||
if skip_existing and dest.exists():
|
||||
continue
|
||||
try:
|
||||
shutil.copyfile(src, grid_dir / dest_name)
|
||||
shutil.copyfile(src, dest)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to copy {src_name}: {e}")
|
||||
|
||||
# Generate tenfoot from landscape
|
||||
tenfoot_dest = grid_dir / f"{appid}_tenfoot.png"
|
||||
wide = tmp_dir / "grid-wide.png"
|
||||
if wide.exists():
|
||||
if wide.exists() and not (skip_existing and tenfoot_dest.exists()):
|
||||
try:
|
||||
from PySide6.QtGui import QImage
|
||||
img = QImage(str(wide))
|
||||
if not img.isNull():
|
||||
img.scaled(600, 350).save(str(grid_dir / f"{appid}_tenfoot.png"))
|
||||
img.scaled(600, 350).save(str(tenfoot_dest))
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not generate tenfoot: {e}")
|
||||
|
||||
@@ -271,7 +272,9 @@ class ModlistWineOpsMixin:
|
||||
# Determine game type
|
||||
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
|
||||
# Add game-specific extras
|
||||
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
|
||||
if "fallout4vr" in game or "fo4vr" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "vcrun2012"]
|
||||
elif "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
|
||||
extras += ["d3dx9_43", "d3dx9"]
|
||||
@@ -287,10 +290,13 @@ class ModlistWineOpsMixin:
|
||||
for key, components in self.MODLIST_WINE_COMPONENTS.items():
|
||||
if key in modlist_lower:
|
||||
extras += components
|
||||
# Remove duplicates while preserving order
|
||||
# Remove duplicates while preserving order, then promote Wine EXE installers
|
||||
# (dotnet48, dotnet40) to the front so the long-running installs happen first.
|
||||
seen = set()
|
||||
full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))]
|
||||
return full_list
|
||||
_slow = [c for c in full_list if c in ('dotnet48', 'dotnet40')]
|
||||
_rest = [c for c in full_list if c not in ('dotnet48', 'dotnet40')]
|
||||
return _slow + _rest
|
||||
|
||||
def _re_enforce_windows_10_mode(self):
|
||||
"""
|
||||
@@ -499,6 +505,9 @@ class ModlistWineOpsMixin:
|
||||
else:
|
||||
self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}")
|
||||
|
||||
# NDP v4.8 keys (Release DWORD etc.) are written by the winetricks dotnet48 verb
|
||||
# during component installation; they are not set here.
|
||||
|
||||
# Force wineserver to flush registry changes to disk
|
||||
if wineserver_binary:
|
||||
self.logger.debug("Flushing registry changes to disk via wineserver shutdown...")
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Native Wine component installer.
|
||||
|
||||
Direct-source replacements for winetricks components.
|
||||
Falls back to winetricks -> protontricks for unsupported or failed components.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_D3DCOMPILER_47_X86_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47_32.dll"
|
||||
_D3DCOMPILER_47_X64_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll"
|
||||
_D3DCOMPILER_47_X86_SHA256 = "2ad0d4987fc4624566b190e747c9d95038443956ed816abfd1e2d389b5ec0851"
|
||||
_D3DCOMPILER_47_X64_SHA256 = "4432bbd1a390874f3f0a503d45cc48d346abc3a8c0213c289f4b615bf0ee84f3"
|
||||
|
||||
_DIRECTX_CAB_URL = "https://files.holarse-linuxgaming.de/mirrors/microsoft/directx_Jun2010_redist.exe"
|
||||
_DIRECTX_CAB_SHA256 = "8746ee1a84a083a90e37899d71d50d5c7c015e69688a466aa80447f011780c0d"
|
||||
|
||||
_VCRUN2022_X86_URL = "https://aka.ms/vs/17/release/vc_redist.x86.exe"
|
||||
_VCRUN2022_X64_URL = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
|
||||
|
||||
_VCRUN2022_DLLS_X86 = ["concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll",
|
||||
"msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll",
|
||||
"vcamp140.dll", "vccorlib140.dll", "vcomp140.dll", "vcruntime140.dll"]
|
||||
_VCRUN2022_DLLS_X64 = _VCRUN2022_DLLS_X86 + ["vcruntime140_1.dll"]
|
||||
|
||||
_VCRUN2012_X86_URL = "https://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x86.exe"
|
||||
_VCRUN2012_X64_URL = "https://download.microsoft.com/download/1/6/B/16B06F60-3B20-4FF2-B699-5E9B7962F9AE/VSU_4/vcredist_x64.exe"
|
||||
|
||||
|
||||
# (x86_stage1_patterns, x64_stage1_patterns, x86_dll_filters, x64_dll_filters, regsvr32)
|
||||
_DX_CFG: Dict[str, tuple] = {
|
||||
"d3dcompiler_43": (
|
||||
["*d3dcompiler_43*x86*"], ["*d3dcompiler_43*x64*"],
|
||||
["d3dcompiler_43.dll"], ["d3dcompiler_43.dll"], False),
|
||||
"d3dx9": (
|
||||
["*d3dx9*x86*"], ["*d3dx9*x64*"],
|
||||
["d3dx9_*.dll"], ["d3dx9_*.dll"], False),
|
||||
"d3dx9_43": (
|
||||
["*d3dx9*x86*"], ["*d3dx9*x64*"],
|
||||
["d3dx9_43.dll"], ["d3dx9_43.dll"], False),
|
||||
"d3dx11_43": (
|
||||
["*d3dx11_43*x86*"], ["*d3dx11_43*x64*"],
|
||||
["d3dx11_43.dll"], ["d3dx11_43.dll"], False),
|
||||
"xact": (
|
||||
["*_xact_*x86*", "*_x3daudio_*x86*", "*_xaudio_*x86*"], [],
|
||||
["xactengine*.dll", "xaudio*.dll", "x3daudio*.dll", "xapofx*.dll"], [], True),
|
||||
"xact_x64": (
|
||||
[], ["*_xact_*x64*", "*_x3daudio_*x64*", "*_xaudio_*x64*"],
|
||||
[], ["xactengine*.dll", "xaudio*.dll", "x3daudio*.dll", "xapofx*.dll"], True),
|
||||
}
|
||||
|
||||
_REGISTRY_WRITE_COMPONENTS = {"fontsmooth=rgb"}
|
||||
_DLL_COPY_COMPONENTS = {"d3dcompiler_47"}
|
||||
_DIRECTX_CAB_COMPONENTS = set(_DX_CFG.keys())
|
||||
_WINE_INSTALLER_COMPONENTS = {"vcrun2022", "vcrun2012", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "dotnet9", "dotnetdesktop9", "dotnet10", "dotnetdesktop10"}
|
||||
|
||||
# dotnet48 is handled by the bundled winetricks verb (see winetricks_handler), not natively:
|
||||
# the ndp48 in-place servicing corrupts mscorlib under Wine.
|
||||
SUPPORTED_COMPONENTS = (
|
||||
_REGISTRY_WRITE_COMPONENTS
|
||||
| _DLL_COPY_COMPONENTS
|
||||
| _DIRECTX_CAB_COMPONENTS
|
||||
| _WINE_INSTALLER_COMPONENTS
|
||||
)
|
||||
|
||||
|
||||
class NativeComponentInstaller:
|
||||
"""Direct-source Wine component installer. Handles Groups 1-4 from the native install spec."""
|
||||
|
||||
def __init__(self, wineprefix: str, wine_binary: str, wine_env: dict, log=None):
|
||||
self.wineprefix = wineprefix
|
||||
self.wine_binary = wine_binary
|
||||
self.wine_env = wine_env
|
||||
self.logger = log or logging.getLogger(__name__)
|
||||
|
||||
def _emit_status(self, msg: str) -> None:
|
||||
cb = getattr(self, '_status_callback', None)
|
||||
if cb:
|
||||
cb(msg)
|
||||
|
||||
def _wine_env_base(self, **extra) -> dict:
|
||||
base = {**self.wine_env, 'WINEPREFIX': self.wineprefix}
|
||||
parts = []
|
||||
for src in (base, extra):
|
||||
v = src.pop('WINEDLLOVERRIDES', None)
|
||||
if v:
|
||||
parts.append(v)
|
||||
parts.append('winemenubuilder.exe=d')
|
||||
base.update(extra)
|
||||
base['WINEDLLOVERRIDES'] = ','.join(parts)
|
||||
return base
|
||||
|
||||
def install_components(
|
||||
self,
|
||||
components: List[str],
|
||||
status_callback=None,
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Attempt native install. Returns (succeeded, remaining); remaining goes to winetricks."""
|
||||
self._status_callback = status_callback
|
||||
succeeded = []
|
||||
remaining = []
|
||||
|
||||
native_candidates = [c for c in components if c in SUPPORTED_COMPONENTS]
|
||||
|
||||
for component in components:
|
||||
if component not in SUPPORTED_COMPONENTS:
|
||||
remaining.append(component)
|
||||
continue
|
||||
|
||||
self._current_component = component
|
||||
if status_callback:
|
||||
status_callback(f"[NATIVE_INSTALL] {component}")
|
||||
|
||||
try:
|
||||
ok = self._install_component(component)
|
||||
except Exception as exc:
|
||||
self.logger.error("Native install of %s raised: %s", component, exc, exc_info=True)
|
||||
ok = False
|
||||
|
||||
if ok:
|
||||
self.logger.info("Native install succeeded: %s", component)
|
||||
succeeded.append(component)
|
||||
self._write_winetricks_log(component)
|
||||
else:
|
||||
self.logger.warning("Native install failed for %s, falling through to winetricks", component)
|
||||
remaining.append(component)
|
||||
|
||||
if succeeded:
|
||||
self._apply_dll_overrides()
|
||||
|
||||
return succeeded, remaining
|
||||
|
||||
def _install_component(self, component: str) -> bool:
|
||||
if component in _REGISTRY_WRITE_COMPONENTS:
|
||||
return self._install_registry_write(component)
|
||||
if component in _DLL_COPY_COMPONENTS:
|
||||
return self._install_dll_copy(component)
|
||||
if component in _DIRECTX_CAB_COMPONENTS:
|
||||
return self._install_directx_cab(component)
|
||||
if component == "vcrun2022":
|
||||
return self._install_vcrun2022()
|
||||
if component == "vcrun2012":
|
||||
return self._install_vcrun2012()
|
||||
return self._install_dotnet_modern(component)
|
||||
|
||||
def _write_winetricks_log(self, component: str) -> None:
|
||||
log_path = Path(self.wineprefix) / 'winetricks.log'
|
||||
try:
|
||||
with open(log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(component + '\n')
|
||||
except Exception as exc:
|
||||
self.logger.warning("Could not write winetricks.log: %s", exc)
|
||||
self._write_jackify_component_record(component)
|
||||
|
||||
def _write_jackify_component_record(self, component: str) -> None:
|
||||
record_path = Path(self.wineprefix) / 'jackify_components.json'
|
||||
try:
|
||||
record = json.loads(record_path.read_text(encoding='utf-8')) if record_path.is_file() else {}
|
||||
except Exception:
|
||||
record = {}
|
||||
record[component] = {"method": "native", "timestamp": datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S')}
|
||||
try:
|
||||
record_path.write_text(json.dumps(record, indent=2), encoding='utf-8')
|
||||
except Exception as exc:
|
||||
self.logger.warning("Could not write jackify_components.json: %s", exc)
|
||||
|
||||
def _apply_dll_overrides(self) -> None:
|
||||
reg_file = Path(__file__).parent.parent / 'data' / 'dll_overrides.reg'
|
||||
if not reg_file.is_file():
|
||||
self.logger.warning("dll_overrides.reg not found at %s", reg_file)
|
||||
return
|
||||
overrides: Dict[str, str] = {}
|
||||
in_target = False
|
||||
for line in reg_file.read_text(encoding='utf-8').splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith('['):
|
||||
in_target = 'Wine\\DllOverrides' in s
|
||||
elif in_target and s.startswith('"'):
|
||||
try:
|
||||
q = s.index('"', 1)
|
||||
rest = s[q + 1:]
|
||||
if rest.startswith('='):
|
||||
overrides[s[1:q]] = rest[1:]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if overrides:
|
||||
self._direct_reg_write(r'Software\Wine\DllOverrides', overrides)
|
||||
self.logger.debug("DLL overrides written directly to user.reg (%d entries)", len(overrides))
|
||||
|
||||
def _download_file(self, url: str, dest: Path, sha256: str = "") -> bool:
|
||||
if dest.is_file():
|
||||
if not sha256:
|
||||
return True
|
||||
if self._verify_sha256(dest, sha256):
|
||||
return True
|
||||
self.logger.warning("SHA256 mismatch on cached %s, re-downloading", dest.name)
|
||||
dest.unlink()
|
||||
component = getattr(self, '_current_component', dest.stem)
|
||||
self.logger.info("Downloading %s ...", dest.name)
|
||||
self._emit_status(f"Downloading {dest.name}...")
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Jackify/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
total = int(resp.headers.get('Content-Length', 0) or 0)
|
||||
downloaded = 0
|
||||
start = time.monotonic()
|
||||
last_emit = start
|
||||
chunk_size = 65536
|
||||
with open(dest, 'wb') as f:
|
||||
while True:
|
||||
chunk = resp.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
now = time.monotonic()
|
||||
if total > 0 and now - last_emit >= 0.5:
|
||||
pct = downloaded / total * 100.0
|
||||
elapsed = now - start
|
||||
speed = downloaded / elapsed / 1048576.0 if elapsed > 0.05 else 0.0
|
||||
self._emit_status(f"[NATIVE_DL] {component} {pct:.1f} {speed:.1f}")
|
||||
last_emit = now
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.logger.error("Download failed for %s: %s", url, exc)
|
||||
if dest.is_file():
|
||||
dest.unlink()
|
||||
return False
|
||||
|
||||
def _verify_sha256(self, path: Path, expected: str) -> bool:
|
||||
h = hashlib.sha256()
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(65536), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest().lower() == expected.lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_cabextract(self) -> Optional[str]:
|
||||
if os.environ.get('APPDIR'):
|
||||
candidate = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract')
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
dev_candidate = str(Path(__file__).parent.parent.parent / 'tools' / 'cabextract')
|
||||
if os.path.isfile(dev_candidate) and os.access(dev_candidate, os.X_OK):
|
||||
return dev_candidate
|
||||
return shutil.which('cabextract')
|
||||
|
||||
def _install_registry_write(self, component: str) -> bool:
|
||||
if component != "fontsmooth=rgb":
|
||||
return False
|
||||
return self._direct_reg_write(
|
||||
r'Control Panel\Desktop',
|
||||
{
|
||||
'FontSmoothing': '"2"',
|
||||
'FontSmoothingType': 'dword:00000002',
|
||||
'FontSmoothingGamma': 'dword:00000578',
|
||||
},
|
||||
)
|
||||
|
||||
def _install_dll_copy(self, component: str) -> bool:
|
||||
if component != "d3dcompiler_47":
|
||||
return False
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'd3dcompiler'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
syswow64 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'syswow64'
|
||||
system32 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'system32'
|
||||
syswow64.mkdir(parents=True, exist_ok=True)
|
||||
system32.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for url, sha256, dest, fname in [
|
||||
(_D3DCOMPILER_47_X86_URL, _D3DCOMPILER_47_X86_SHA256, syswow64, 'd3dcompiler_47_32.dll'),
|
||||
(_D3DCOMPILER_47_X64_URL, _D3DCOMPILER_47_X64_SHA256, system32, 'd3dcompiler_47.dll'),
|
||||
]:
|
||||
cached = cache_dir / fname
|
||||
if not self._download_file(url, cached, sha256):
|
||||
return False
|
||||
if not self._verify_sha256(cached, sha256):
|
||||
self.logger.error("SHA256 mismatch on %s after download", fname)
|
||||
cached.unlink()
|
||||
return False
|
||||
shutil.copy2(cached, dest / 'd3dcompiler_47.dll')
|
||||
return True
|
||||
|
||||
def _install_directx_cab(self, component: str) -> bool:
|
||||
cfg = _DX_CFG.get(component)
|
||||
if not cfg:
|
||||
return False
|
||||
cabextract = self._get_cabextract()
|
||||
if not cabextract:
|
||||
self.logger.warning("cabextract not available for %s", component)
|
||||
return False
|
||||
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'directx'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
redist = cache_dir / 'directx_Jun2010_redist.exe'
|
||||
if not self._download_file(_DIRECTX_CAB_URL, redist, _DIRECTX_CAB_SHA256):
|
||||
return False
|
||||
if not self._verify_sha256(redist, _DIRECTX_CAB_SHA256):
|
||||
self.logger.error("SHA256 mismatch on DirectX redistributable")
|
||||
redist.unlink()
|
||||
return False
|
||||
|
||||
x86_patterns, x64_patterns, x86_dll_filters, x64_dll_filters, needs_regsvr32 = cfg
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
for arch_tag, stage1_patterns, dll_filters, dest_dir in [
|
||||
('x86', x86_patterns, x86_dll_filters, syswow64),
|
||||
('x64', x64_patterns, x64_dll_filters, system32),
|
||||
]:
|
||||
if not stage1_patterns:
|
||||
continue
|
||||
arch_tmp = tmpdir_path / arch_tag
|
||||
arch_tmp.mkdir()
|
||||
|
||||
for pattern in stage1_patterns:
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(arch_tmp), '-L', '-F', pattern, str(redist)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
inner_cabs = list(arch_tmp.glob('*.cab'))
|
||||
if not inner_cabs:
|
||||
self.logger.error("No inner cabs found for %s %s", component, arch_tag)
|
||||
return False
|
||||
|
||||
for dll_filter in dll_filters:
|
||||
for inner_cab in inner_cabs:
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(dest_dir), '-L', '-F', dll_filter, str(inner_cab)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if needs_regsvr32:
|
||||
com_dlls = list(syswow64.glob('xactengine*.dll')) + list(syswow64.glob('xaudio*.dll'))
|
||||
com_dlls += list(system32.glob('xactengine*.dll')) + list(system32.glob('xaudio*.dll'))
|
||||
self._register_xact_com(com_dlls)
|
||||
return True
|
||||
|
||||
def _install_vcrun2022(self) -> bool:
|
||||
cabextract = self._get_cabextract()
|
||||
if not cabextract:
|
||||
self.logger.warning("cabextract not available for vcrun2022")
|
||||
return False
|
||||
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'vcrun'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
x86 = cache_dir / 'vc_redist.x86.exe'
|
||||
x64 = cache_dir / 'vc_redist.x64.exe'
|
||||
if not self._download_file(_VCRUN2022_X86_URL, x86):
|
||||
return False
|
||||
if not self._download_file(_VCRUN2022_X64_URL, x64):
|
||||
return False
|
||||
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
# x86 inner cab is 'a10', x64 inner cab is 'a12'
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for exe, cab, dest, dlls in [(x86, 'a10', syswow64, _VCRUN2022_DLLS_X86),
|
||||
(x64, 'a12', system32, _VCRUN2022_DLLS_X64)]:
|
||||
arch_tmp = Path(tmpdir) / cab
|
||||
arch_tmp.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(arch_tmp), '-F', cab, str(exe)], capture_output=True)
|
||||
inner_cab = arch_tmp / cab
|
||||
if not inner_cab.is_file():
|
||||
self.logger.error("vcrun2022: inner cab '%s' not found in %s", cab, exe.name)
|
||||
return False
|
||||
for dll_name in dlls:
|
||||
subprocess.run([cabextract, '-d', str(dest), '-F', dll_name, str(inner_cab)], capture_output=True)
|
||||
if not (dest / 'msvcp140.dll').is_file():
|
||||
self.logger.error("vcrun2022: msvcp140.dll not extracted to %s", dest)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _install_vcrun2012(self) -> bool:
|
||||
cabextract = self._get_cabextract()
|
||||
if not cabextract:
|
||||
self.logger.warning("cabextract not available for vcrun2012")
|
||||
return False
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'vcrun2012'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
x86 = cache_dir / 'vcredist_x86.exe'
|
||||
x64 = cache_dir / 'vcredist_x64.exe'
|
||||
if not self._download_file(_VCRUN2012_X86_URL, x86):
|
||||
return False
|
||||
if not self._download_file(_VCRUN2012_X64_URL, x64):
|
||||
return False
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for exe, dest in [(x86, syswow64), (x64, system32)]:
|
||||
for cab_name in ('a2', 'a3'):
|
||||
td = Path(tmpdir) / (exe.stem + cab_name)
|
||||
td.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(td), '-F', cab_name, str(exe)], capture_output=True)
|
||||
inner = td / cab_name
|
||||
if not inner.is_file():
|
||||
continue
|
||||
dd = td / 'x'
|
||||
dd.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(dd), '-L', '-F', 'F_CENTRAL_*', str(inner)], capture_output=True)
|
||||
for src in dd.iterdir():
|
||||
if src.name.startswith('f_central_'):
|
||||
shutil.copy2(src, dest / (src.name[10:].rsplit('_', 1)[0] + '.dll'))
|
||||
if not (syswow64 / 'msvcr110.dll').is_file():
|
||||
self.logger.error("vcrun2012: msvcr110.dll not extracted to syswow64")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _install_dotnet_modern(self, component: str) -> bool:
|
||||
urls = self._get_dotnet_urls(component)
|
||||
if not urls:
|
||||
return False
|
||||
x86_url, x64_url = urls
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'dotnet'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
x86_zip = cache_dir / Path(x86_url).name
|
||||
x64_zip = cache_dir / Path(x64_url).name
|
||||
if not self._download_file(x86_url, x86_zip):
|
||||
return False
|
||||
if not self._download_file(x64_url, x64_zip):
|
||||
return False
|
||||
pfx = Path(self.wineprefix) / 'drive_c'
|
||||
for zip_path, dest in [(x86_zip, pfx / 'Program Files (x86)' / 'dotnet'),
|
||||
(x64_zip, pfx / 'Program Files' / 'dotnet')]:
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(dest)
|
||||
except Exception as exc:
|
||||
self.logger.error("%s zip extraction failed for %s: %s", component, zip_path.name, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_dotnet_urls(self, component: str) -> Optional[Tuple[str, str]]:
|
||||
manifest = Path(__file__).parent.parent / 'data' / 'native_components_versions.json'
|
||||
try:
|
||||
entry = json.loads(manifest.read_text()).get(component, {})
|
||||
x86 = entry.get('x86_zip_url', '')
|
||||
x64 = entry.get('x64_zip_url', '')
|
||||
if x86 and x64:
|
||||
return x86, x64
|
||||
except Exception as exc:
|
||||
self.logger.error("Could not load dotnet URLs for %s: %s", component, exc)
|
||||
self.logger.error("No zip URLs for %s in versions manifest", component)
|
||||
return None
|
||||
|
||||
def _get_system_dirs(self) -> Tuple[Path, Path]:
|
||||
s64 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'syswow64'
|
||||
s32 = Path(self.wineprefix) / 'drive_c' / 'windows' / 'system32'
|
||||
s64.mkdir(parents=True, exist_ok=True)
|
||||
s32.mkdir(parents=True, exist_ok=True)
|
||||
return s64, s32
|
||||
|
||||
def _register_xact_com(self, dlls: List[Path]) -> None:
|
||||
manifest = Path(__file__).parent.parent / 'data' / 'native_components_versions.json'
|
||||
try:
|
||||
clsid_map = json.loads(manifest.read_text()).get('xact_clsids', {})
|
||||
except Exception:
|
||||
clsid_map = {}
|
||||
for dll_path in dlls:
|
||||
for clsid in clsid_map.get(dll_path.name.lower(), []):
|
||||
dir_name = dll_path.parent.name.lower()
|
||||
win_path = f'"C:\\\\windows\\\\{dir_name}\\\\{dll_path.name}"'
|
||||
self._direct_reg_write(
|
||||
f'Software\\Classes\\CLSID\\{clsid}\\InprocServer32',
|
||||
{'@': win_path, 'ThreadingModel': '"Both"'},
|
||||
)
|
||||
|
||||
def _direct_reg_write(self, key_hkcu: str, values: Dict[str, str]) -> bool:
|
||||
"""Write values to user.reg without spawning Wine. Later sections take precedence, so append is correct."""
|
||||
user_reg = Path(self.wineprefix) / 'user.reg'
|
||||
if not user_reg.is_file():
|
||||
self.logger.warning("user.reg not found at %s", user_reg)
|
||||
return False
|
||||
key_fmted = key_hkcu.replace('\\', '\\\\')
|
||||
try:
|
||||
with open(user_reg, 'a', encoding='utf-8') as f:
|
||||
f.write(f'\n[{key_fmted}] {int(time.time())}\n')
|
||||
for name, val in values.items():
|
||||
f.write(f'@={val}\n' if name == '@' else f'"{name}"={val}\n')
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.logger.error("Direct registry write failed for %s: %s", key_hkcu, exc)
|
||||
return False
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from .path_handler_mo2 import (
|
||||
PathHandlerMO2Mixin,
|
||||
TARGET_EXECUTABLES_LOWER,
|
||||
STOCK_GAME_FOLDERS,
|
||||
SDCARD_PREFIX,
|
||||
)
|
||||
from .path_handler_dxvk import PathHandlerDXVKMixin
|
||||
from .path_handler_steam import PathHandlerSteamMixin
|
||||
@@ -20,7 +19,6 @@ __all__ = [
|
||||
'PathHandler',
|
||||
'TARGET_EXECUTABLES_LOWER',
|
||||
'STOCK_GAME_FOLDERS',
|
||||
'SDCARD_PREFIX',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -131,31 +131,31 @@ class PathHandlerGameMixin:
|
||||
|
||||
@classmethod
|
||||
def find_vanilla_game_paths(cls, game_names=None) -> Dict[str, Path]:
|
||||
"""For each known game, iterate all Steam libraries and look for the canonical game directory in steamapps/common."""
|
||||
GAME_DIR_NAMES = {
|
||||
"Skyrim Special Edition": ["Skyrim Special Edition"],
|
||||
"Fallout 4": ["Fallout 4"],
|
||||
"Fallout New Vegas": ["Fallout New Vegas"],
|
||||
"Oblivion": ["Oblivion"],
|
||||
"Fallout 3": ["Fallout 3", "Fallout 3 goty"]
|
||||
"""Locate vanilla game installations via Steam, Heroic GOG, or Heroic Epic."""
|
||||
GAME_TYPE_MAP = {
|
||||
"Skyrim Special Edition": "skyrim",
|
||||
"Fallout 4": "fallout4",
|
||||
"Fallout New Vegas": "falloutnv",
|
||||
"Oblivion": "oblivion",
|
||||
"Fallout 3": "fallout3",
|
||||
}
|
||||
if game_names is None:
|
||||
game_names = list(GAME_DIR_NAMES.keys())
|
||||
all_steam_libraries = cls.get_all_steam_library_paths()
|
||||
logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}")
|
||||
game_names = list(GAME_TYPE_MAP.keys())
|
||||
|
||||
from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder
|
||||
finder = VanillaGameFinder()
|
||||
found_games = {}
|
||||
for game in game_names:
|
||||
possible_names = GAME_DIR_NAMES.get(game, [game])
|
||||
for lib in all_steam_libraries:
|
||||
for name in possible_names:
|
||||
candidate = lib / "steamapps" / "common" / name
|
||||
logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}")
|
||||
if candidate.is_dir():
|
||||
found_games[game] = candidate
|
||||
logger.info(f"Found vanilla game directory for {game}: {candidate}")
|
||||
break
|
||||
if game in found_games:
|
||||
break
|
||||
game_type = GAME_TYPE_MAP.get(game)
|
||||
if not game_type:
|
||||
continue
|
||||
result = finder.find(game_type)
|
||||
if result:
|
||||
path, store = result
|
||||
found_games[game] = path
|
||||
logger.info("Found vanilla game %s via %s at %s", game, store, path)
|
||||
else:
|
||||
logger.debug("No installation found for %s", game)
|
||||
return found_games
|
||||
|
||||
def _detect_stock_game_path(self) -> bool:
|
||||
|
||||
@@ -21,9 +21,10 @@ TARGET_EXECUTABLES_LOWER = [
|
||||
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe",
|
||||
"sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"
|
||||
]
|
||||
STOCK_GAME_FOLDERS = ["Stock Game", "StockGame", "Game Root", "Stock Folder", "Skyrim Stock"]
|
||||
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
|
||||
|
||||
STOCK_GAME_FOLDERS = [
|
||||
"Stock Game", "StockGame", "STOCK GAME", "Stock Game Folder",
|
||||
"Game Root", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition",
|
||||
]
|
||||
|
||||
class PathHandlerMO2Mixin:
|
||||
"""Mixin providing ModOrganizer.ini path updates and formatting."""
|
||||
@@ -324,7 +325,8 @@ class PathHandlerMO2Mixin:
|
||||
return False
|
||||
|
||||
def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool,
|
||||
steam_libraries: Optional[List[Path]] = None) -> bool:
|
||||
steam_libraries: Optional[List[Path]] = None,
|
||||
compat_data_path: Optional[Path] = None) -> bool:
|
||||
"""Update all binary paths and working directories in ModOrganizer.ini. Critical, regression-prone."""
|
||||
try:
|
||||
logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}")
|
||||
@@ -352,17 +354,32 @@ class PathHandlerMO2Mixin:
|
||||
logger.debug(f"Extracted existing gamePath: {existing_game_path}, drive letter: {gamepath_drive_letter}")
|
||||
break
|
||||
if modlist_sdcard and existing_game_path and existing_game_path.startswith('/run/media') and gamepath_line_index != -1:
|
||||
sdcard_pattern = r'^/run/media/deck/[^/]+(/Games/.*)$'
|
||||
match = re.match(sdcard_pattern, existing_game_path)
|
||||
if match:
|
||||
stripped_path = match.group(1)
|
||||
windows_path = stripped_path.replace('/', '\\\\')
|
||||
stripped_path = None
|
||||
if compat_data_path:
|
||||
dosdevices_d = compat_data_path / "pfx" / "dosdevices" / "d:"
|
||||
if dosdevices_d.exists():
|
||||
try:
|
||||
d_target = os.readlink(str(dosdevices_d))
|
||||
d_mount = Path(d_target).as_posix().rstrip('/')
|
||||
if existing_game_path.startswith(d_mount):
|
||||
stripped_path = existing_game_path[len(d_mount):]
|
||||
logger.debug(f"Resolved SD card D: mount via dosdevices: {d_mount}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read dosdevices/d: symlink: {e}")
|
||||
if stripped_path is None:
|
||||
# Strip the /run/media/deck/<volume> prefix to get the relative path
|
||||
sdcard_pattern = r'^/run/media/[^/]+/[^/]+(.+)$'
|
||||
m = re.match(sdcard_pattern, existing_game_path)
|
||||
if m:
|
||||
stripped_path = m.group(1)
|
||||
else:
|
||||
logger.warning(f"Could not strip SD card prefix from gamePath (no dosdevices symlink available): {existing_game_path}")
|
||||
if stripped_path is not None:
|
||||
windows_path = stripped_path.lstrip('/').replace('/', '\\\\')
|
||||
new_gamepath_value = f"D:\\\\{windows_path}"
|
||||
new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n"
|
||||
logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}")
|
||||
lines[gamepath_line_index] = new_gamepath_line
|
||||
else:
|
||||
logger.warning(f"SD card path doesn't match expected pattern: {existing_game_path}")
|
||||
game_path_updated = False
|
||||
binary_paths_updated = 0
|
||||
working_dirs_updated = 0
|
||||
@@ -601,6 +618,12 @@ class PathHandlerMO2Mixin:
|
||||
if not m:
|
||||
continue
|
||||
raw = m.group(1).strip()
|
||||
ba = re.match(r'@ByteArray\((.+)\)$', raw)
|
||||
if ba:
|
||||
raw = ba.group(1).strip()
|
||||
# Engine's RemapMO2File writes a raw Linux path directly
|
||||
if raw.startswith('/'):
|
||||
return raw
|
||||
# Expect Z:\\path\\... or D:\\path\\... (MO2 doubles backslashes in the file)
|
||||
drive_m = re.match(r'^([ZzDd]):(.+)$', raw)
|
||||
if not drive_m:
|
||||
|
||||
@@ -166,11 +166,11 @@ class PathHandlerSteamMixin:
|
||||
if r not in seen and r != main_resolved:
|
||||
seen.add(r)
|
||||
result.append(r)
|
||||
for extra in (install_dir, download_dir):
|
||||
mp = self.get_mountpoint(extra) if extra else None
|
||||
if mp and mp not in seen:
|
||||
seen.add(mp)
|
||||
result.append(mp)
|
||||
if download_dir:
|
||||
p = str(Path(str(download_dir)).resolve())
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -179,6 +179,8 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
if file_prog:
|
||||
result.file_progress = file_prog
|
||||
result.has_progress = True
|
||||
if '[FILE_PROGRESS]' in line:
|
||||
result.message = ""
|
||||
# Check if file counter was attached (for extraction or install phases)
|
||||
if hasattr(file_prog, '_file_counter'):
|
||||
result.file_counter = file_prog._file_counter
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
CLF3 Progress Parser
|
||||
|
||||
Parses CLF3 --jackify stdout into InstallationProgress state.
|
||||
Each line is a JSON object with a "type" field matching ProgressEvent variants.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, FileProgress, OperationType
|
||||
|
||||
_COUNTER_RE = re.compile(r'\((\d+)/(\d+)\)\s*$')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PHASE_MAP = {
|
||||
"Downloading": InstallationPhase.DOWNLOAD,
|
||||
"Validating": InstallationPhase.VALIDATE,
|
||||
"Installing": InstallationPhase.INSTALL,
|
||||
"Extracting": InstallationPhase.INSTALL,
|
||||
"BSA Build": InstallationPhase.INSTALL,
|
||||
"DDS Transform": InstallationPhase.INSTALL,
|
||||
"Finalizing": InstallationPhase.FINALIZE,
|
||||
"Cleanup": InstallationPhase.FINALIZE,
|
||||
}
|
||||
|
||||
|
||||
class CLF3ProgressStateManager:
|
||||
"""
|
||||
Parses CLF3 --progress-json stdout and maintains InstallationProgress state.
|
||||
Implements the same process_line / get_state interface as ProgressStateManager.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.state = InstallationProgress()
|
||||
self.state.phase = InstallationPhase.INITIALIZATION
|
||||
self.state.phase_name = "Starting"
|
||||
self._total_archives: int = 0
|
||||
self._completed_archives: int = 0
|
||||
self._total_directives: int = 0
|
||||
self._completed_directives: int = 0
|
||||
# True once a real DownloadProgress event fires; distinguishes verify-only runs
|
||||
self._seen_actual_download: bool = False
|
||||
# name -> (downloaded, total, speed)
|
||||
self._active_downloads: dict = {}
|
||||
|
||||
def get_state(self) -> InstallationProgress:
|
||||
return self.state
|
||||
|
||||
def reset(self) -> None:
|
||||
self.__init__()
|
||||
|
||||
def process_line(self, line: str) -> bool:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
if not stripped.startswith('{'):
|
||||
idx = stripped.find('{')
|
||||
if idx < 0:
|
||||
return False
|
||||
stripped = stripped[idx:]
|
||||
try:
|
||||
obj = json.loads(stripped)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
|
||||
event_type = obj.get('type')
|
||||
if not event_type:
|
||||
return False
|
||||
|
||||
handler = _HANDLERS.get(event_type)
|
||||
if handler:
|
||||
return handler(self, obj)
|
||||
return False
|
||||
|
||||
# -- event handlers --
|
||||
|
||||
def _on_download_progress(self, obj: dict) -> bool:
|
||||
name = obj.get('name', '')
|
||||
downloaded = obj.get('downloaded', 0)
|
||||
total = obj.get('total', 0)
|
||||
speed = obj.get('speed', 0.0)
|
||||
self._active_downloads[name] = (downloaded, total, speed)
|
||||
|
||||
self._seen_actual_download = True
|
||||
|
||||
# Preserve "Downloading + Extracting" phase_name if concurrent mode is already active.
|
||||
in_concurrent = (
|
||||
self._total_archives > 0 and
|
||||
self._completed_archives > self._total_archives
|
||||
)
|
||||
if self.state.phase != InstallationPhase.DOWNLOAD:
|
||||
self.state.phase = InstallationPhase.DOWNLOAD
|
||||
self.state.phase_name = "Downloading + Extracting" if in_concurrent else "Downloading"
|
||||
elif self.state.phase_name not in ("Downloading", "Downloading + Extracting"):
|
||||
self.state.phase_name = "Downloading"
|
||||
|
||||
# Rebuild active_files from current download map
|
||||
active_files = []
|
||||
for dl_name, (dl_downloaded, dl_total, dl_speed) in self._active_downloads.items():
|
||||
pct = (dl_downloaded / dl_total * 100.0) if dl_total > 0 else 0.0
|
||||
active_files.append(FileProgress(
|
||||
filename=dl_name,
|
||||
operation=OperationType.DOWNLOAD,
|
||||
percent=pct,
|
||||
current_size=dl_downloaded,
|
||||
total_size=dl_total,
|
||||
speed=dl_speed,
|
||||
))
|
||||
self.state.active_files = active_files
|
||||
|
||||
total_speed = sum(v[2] for v in self._active_downloads.values())
|
||||
speed_mb = total_speed / 1_048_576
|
||||
self.state.message = f"Downloading {len(active_files)} file(s) | {speed_mb:.1f} MB/s"
|
||||
|
||||
if self._total_archives > 0:
|
||||
if in_concurrent:
|
||||
effective_max = self._total_archives * 2
|
||||
self.state.phase_step = self._completed_archives
|
||||
self.state.phase_max_steps = effective_max
|
||||
self.state.overall_percent = min(self._completed_archives / effective_max * 50.0, 50.0)
|
||||
else:
|
||||
self.state.phase_step = self._completed_archives
|
||||
self.state.phase_max_steps = self._total_archives
|
||||
self.state.overall_percent = min(self._completed_archives / self._total_archives * 50.0, 50.0)
|
||||
return True
|
||||
|
||||
def _on_download_complete(self, obj: dict) -> bool:
|
||||
name = obj.get('name', '')
|
||||
self._active_downloads.pop(name, None)
|
||||
self.state.active_files = [f for f in self.state.active_files if f.filename != name]
|
||||
return True
|
||||
|
||||
def _on_archive_complete(self, obj: dict) -> bool:
|
||||
index = obj.get('index', 0)
|
||||
total = obj.get('total', 0)
|
||||
if total:
|
||||
self._total_archives = total
|
||||
actual_total = total or self._total_archives
|
||||
self._completed_archives = index
|
||||
|
||||
# CLF3 emits a single cumulative ArchiveComplete counter spanning both download
|
||||
# and extraction when running concurrently. index > actual_total means extraction
|
||||
# events are being counted on top of the download events.
|
||||
if actual_total > 0 and index > actual_total:
|
||||
extracted = index - actual_total
|
||||
effective_max = actual_total * 2
|
||||
self.state.phase_step = index
|
||||
self.state.phase_max_steps = effective_max
|
||||
self.state.overall_percent = min(index / effective_max * 50.0, 50.0)
|
||||
self.state.phase_name = "Downloading + Extracting"
|
||||
self.state.message = f"Downloaded: {actual_total}/{actual_total} | Extracting: {extracted}/{actual_total}"
|
||||
elif not self._seen_actual_download and self.state.phase in (InstallationPhase.DOWNLOAD, InstallationPhase.VALIDATE):
|
||||
self.state.phase_step = index
|
||||
self.state.phase_max_steps = actual_total
|
||||
if actual_total > 0:
|
||||
self.state.overall_percent = min(index / actual_total * 50.0, 50.0)
|
||||
self.state.phase_name = "Verifying Archives"
|
||||
self.state.message = f"Verifying: {index}/{actual_total}"
|
||||
else:
|
||||
self.state.phase_step = index
|
||||
self.state.phase_max_steps = actual_total
|
||||
phase_name = self.state.phase_name or ""
|
||||
phase_lower = phase_name.lower()
|
||||
if "bsa" in phase_lower:
|
||||
self.state.message = f"Building BSA: {index}/{actual_total}"
|
||||
elif "dds" in phase_lower or "transform" in phase_lower:
|
||||
self.state.message = f"Converting textures: {index}/{actual_total}"
|
||||
elif "extract" in phase_lower:
|
||||
if actual_total > 0:
|
||||
self.state.overall_percent = min(index / actual_total * 50.0, 50.0)
|
||||
self.state.message = f"Extracting: {index}/{actual_total}"
|
||||
else:
|
||||
if actual_total > 0:
|
||||
self.state.overall_percent = min(index / actual_total * 50.0, 50.0)
|
||||
self.state.message = f"Downloaded: {index}/{actual_total}"
|
||||
return True
|
||||
|
||||
def _on_download_skipped(self, obj: dict) -> bool:
|
||||
count = obj.get('count', 0)
|
||||
# ArchiveComplete tracks the authoritative cumulative index; don't double-count here
|
||||
self.state.message = f"Skipped {count} already-downloaded archive(s)"
|
||||
return True
|
||||
|
||||
def _on_phase_change(self, obj: dict) -> bool:
|
||||
phase_label = obj.get('phase', '')
|
||||
phase = InstallationPhase.UNKNOWN
|
||||
for key, val in _PHASE_MAP.items():
|
||||
if key.lower() in phase_label.lower():
|
||||
phase = val
|
||||
break
|
||||
self.state.phase = phase
|
||||
# Default DOWNLOAD phase to "Verifying"; _on_download_progress flips it
|
||||
# to "Downloading" the first time an actual download event arrives.
|
||||
if phase == InstallationPhase.DOWNLOAD:
|
||||
self.state.phase_name = "Verifying"
|
||||
self.state.message = "Verifying"
|
||||
else:
|
||||
self.state.phase_name = phase_label
|
||||
self.state.message = phase_label
|
||||
self.state.phase_step = 0
|
||||
self.state.phase_max_steps = 0
|
||||
self.state.active_files = []
|
||||
self._active_downloads.clear()
|
||||
self._seen_actual_download = False
|
||||
logger.debug("CLF3 phase: %s -> %s", phase_label, phase)
|
||||
return True
|
||||
|
||||
def _on_directive_complete(self, obj: dict) -> bool:
|
||||
index = obj.get('index', 0)
|
||||
total = obj.get('total', 0)
|
||||
if total:
|
||||
self._total_directives = total
|
||||
self._completed_directives = index
|
||||
self.state.phase_step = index
|
||||
self.state.phase_max_steps = total or self._total_directives
|
||||
if total > 0:
|
||||
self.state.overall_percent = 50.0 + min(index / total * 50.0, 50.0)
|
||||
effective_total = total or self._total_directives
|
||||
phase_lower = (self.state.phase_name or "").lower()
|
||||
if "bsa" in phase_lower:
|
||||
self.state.message = f"Building BSA: {index}/{effective_total}"
|
||||
elif "dds" in phase_lower or "transform" in phase_lower:
|
||||
self.state.message = f"Converting textures: {index}/{effective_total}"
|
||||
else:
|
||||
self.state.message = f"Installing: {index}/{effective_total} files"
|
||||
return True
|
||||
|
||||
def _on_directive_phase_started(self, obj: dict) -> bool:
|
||||
directive_type = obj.get('directive_type', '')
|
||||
total = obj.get('total', 0)
|
||||
if total:
|
||||
self._total_directives = total
|
||||
self.state.message = f"Processing {directive_type} ({total} files)"
|
||||
if self.state.phase not in (InstallationPhase.INSTALL, InstallationPhase.FINALIZE):
|
||||
self.state.phase = InstallationPhase.INSTALL
|
||||
self.state.phase_name = "Installing"
|
||||
return True
|
||||
|
||||
def _on_status(self, obj: dict) -> bool:
|
||||
message = obj.get('message', '')
|
||||
if not message:
|
||||
return False
|
||||
self.state.message = message
|
||||
m = _COUNTER_RE.search(message)
|
||||
if m:
|
||||
step = int(m.group(1))
|
||||
total = int(m.group(2))
|
||||
self.state.phase_step = step
|
||||
self.state.phase_max_steps = total
|
||||
if total > 0:
|
||||
# Streaming extraction emits "Extracting <name> (N/total)" Status messages
|
||||
# instead of phase_start+overall_inc. Detect these and drive 50-100% progress.
|
||||
# Only transition to INSTALL when no downloads are active; during concurrent
|
||||
# download+extract we stay in DOWNLOAD phase and track 0-50%.
|
||||
if message.startswith("Extracting ") and not self._active_downloads:
|
||||
self.state.phase = InstallationPhase.INSTALL
|
||||
self.state.phase_name = "Extracting"
|
||||
self.state.overall_percent = 50.0 + min(step / total * 50.0, 50.0)
|
||||
elif self.state.phase == InstallationPhase.INSTALL:
|
||||
self.state.overall_percent = 50.0 + min(step / total * 50.0, 50.0)
|
||||
else:
|
||||
self.state.overall_percent = min(step / total * 50.0, 50.0)
|
||||
return True
|
||||
|
||||
|
||||
_HANDLERS = {
|
||||
'DownloadProgress': CLF3ProgressStateManager._on_download_progress,
|
||||
'DownloadComplete': CLF3ProgressStateManager._on_download_complete,
|
||||
'ArchiveComplete': CLF3ProgressStateManager._on_archive_complete,
|
||||
'DownloadSkipped': CLF3ProgressStateManager._on_download_skipped,
|
||||
'PhaseChange': CLF3ProgressStateManager._on_phase_change,
|
||||
'DirectiveComplete': CLF3ProgressStateManager._on_directive_complete,
|
||||
'DirectivePhaseStarted': CLF3ProgressStateManager._on_directive_phase_started,
|
||||
'Status': CLF3ProgressStateManager._on_status,
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
"""
|
||||
Example usage of ProgressParser
|
||||
|
||||
This file demonstrates how to use the progress parser to extract
|
||||
structured information from jackify-engine output.
|
||||
|
||||
R&D NOTE: This is experimental code for investigation purposes.
|
||||
"""
|
||||
|
||||
from jackify.backend.handlers.progress_parser import ProgressStateManager
|
||||
|
||||
|
||||
def example_usage():
|
||||
"""Example of how to use the progress parser."""
|
||||
|
||||
# Create state manager
|
||||
state_manager = ProgressStateManager()
|
||||
|
||||
# Simulate processing lines from jackify-engine output
|
||||
sample_lines = [
|
||||
"[00:00:00] === Installing files ===",
|
||||
"[00:00:05] [12/14] Installing files (1.1GB/56.3GB)",
|
||||
"[00:00:10] Installing: Enderal Remastered Armory.7z (42%)",
|
||||
"[00:00:15] Extracting: Mandragora Sprouts.7z (96%)",
|
||||
"[00:00:20] Downloading at 45.2MB/s",
|
||||
"[00:00:25] Extracting at 267.3MB/s",
|
||||
"[00:00:30] Progress: 85%",
|
||||
]
|
||||
|
||||
print("Processing sample output lines...\n")
|
||||
|
||||
for line in sample_lines:
|
||||
updated = state_manager.process_line(line)
|
||||
if updated:
|
||||
state = state_manager.get_state()
|
||||
print(f"Line: {line}")
|
||||
print(f" Phase: {state.phase.value} - {state.phase_name}")
|
||||
print(f" Progress: {state.overall_percent:.1f}%")
|
||||
print(f" Step: {state.phase_progress_text}")
|
||||
print(f" Data: {state.data_progress_text}")
|
||||
print(f" Active Files: {len(state.active_files)}")
|
||||
for file_prog in state.active_files:
|
||||
print(f" - {file_prog.filename}: {file_prog.percent:.1f}%")
|
||||
print(f" Speeds: {state.speeds}")
|
||||
print(f" Display: {state.display_text}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
example_usage()
|
||||
|
||||
@@ -11,6 +11,128 @@ logger = logging.getLogger(__name__)
|
||||
class ShortcutLaunchOptionsMixin:
|
||||
"""Mixin providing launch options and icon methods."""
|
||||
|
||||
def get_shortcut_launch_options(self, app_name: str, exe_path: str) -> 'Optional[str]':
|
||||
"""Return current LaunchOptions for a shortcut, or None if the shortcut is not found."""
|
||||
shortcuts_file = self.path_handler._find_shortcuts_vdf()
|
||||
if not shortcuts_file or not os.path.exists(shortcuts_file):
|
||||
return None
|
||||
try:
|
||||
with open(shortcuts_file, 'rb') as f:
|
||||
data = vdf.binary_loads(f.read())
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not read shortcuts.vdf: {e}")
|
||||
return None
|
||||
|
||||
def _norm(p: str) -> str:
|
||||
try:
|
||||
return os.path.normpath(os.path.abspath(p.strip().strip('"'))).lower()
|
||||
except Exception:
|
||||
return p.strip().strip('"').lower()
|
||||
|
||||
exe_norm = _norm(exe_path)
|
||||
for shortcut_data in data.get('shortcuts', {}).values():
|
||||
if (shortcut_data.get('AppName', '').strip() == app_name and
|
||||
_norm(shortcut_data.get('Exe', '')) == exe_norm):
|
||||
return shortcut_data.get('LaunchOptions', '')
|
||||
return None
|
||||
|
||||
def ensure_mounts_in_steam_compat(self, app_name: str, exe_path: str, *paths: str) -> str:
|
||||
"""Add mountpoints of any supplied paths to STEAM_COMPAT_MOUNTS if not already present.
|
||||
|
||||
Reads existing launch options and appends only what is missing — never overwrites
|
||||
unrelated options. Adds the top-level directory of each path so Proton's container
|
||||
can bind-mount the subtree into the prefix.
|
||||
|
||||
When Steam is running, the write is deferred: returns "steam_running" so the caller
|
||||
can stop Steam first, call apply_pending_mounts_update(), then restart Steam.
|
||||
|
||||
Returns:
|
||||
"unchanged" — mounts already correct, no action needed
|
||||
"updated" — Steam was not running; write succeeded
|
||||
"steam_running" — changes needed but deferred; call apply_pending_mounts_update()
|
||||
after stopping Steam
|
||||
"failed" — shortcut not found or write error
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path as _Path
|
||||
|
||||
def _is_covered(path: str, mounts: list) -> bool:
|
||||
"""Return True if path is already reachable via an existing mount entry.
|
||||
|
||||
A path is covered if an existing mount entry is equal to it or is a
|
||||
parent of it. Root '/' is excluded as a catch-all.
|
||||
"""
|
||||
p = _Path(path)
|
||||
for mount in mounts:
|
||||
if mount == '/':
|
||||
continue
|
||||
try:
|
||||
p.relative_to(mount)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
current = self.get_shortcut_launch_options(app_name, exe_path)
|
||||
if current is None:
|
||||
self.logger.warning(f"Shortcut '{app_name}' not found in shortcuts.vdf; cannot update STEAM_COMPAT_MOUNTS")
|
||||
return "failed"
|
||||
|
||||
compat_re = re.compile(r'STEAM_COMPAT_MOUNTS="([^"]*)"')
|
||||
m = compat_re.search(current)
|
||||
existing = [p for p in m.group(1).split(':') if p] if m else []
|
||||
|
||||
mounts_to_add = []
|
||||
for p in paths:
|
||||
if not p:
|
||||
continue
|
||||
if not _is_covered(p, existing) and p not in mounts_to_add:
|
||||
mounts_to_add.append(p)
|
||||
|
||||
if not mounts_to_add:
|
||||
self.logger.debug(f"STEAM_COMPAT_MOUNTS for '{app_name}' already covers required paths")
|
||||
return "unchanged"
|
||||
|
||||
if m:
|
||||
updated_val = ':'.join(existing + mounts_to_add)
|
||||
updated = compat_re.sub(f'STEAM_COMPAT_MOUNTS="{updated_val}"', current)
|
||||
else:
|
||||
val = ':'.join(mounts_to_add)
|
||||
prefix = f'STEAM_COMPAT_MOUNTS="{val}"'
|
||||
updated = f'{prefix} {current}' if current.strip() else f'{prefix} %command%'
|
||||
|
||||
self.logger.info(f"STEAM_COMPAT_MOUNTS update needed for '{app_name}': adding {mounts_to_add}")
|
||||
|
||||
try:
|
||||
from jackify.backend.services.steam_restart_service import get_steam_processes
|
||||
steam_running = bool(get_steam_processes())
|
||||
except Exception:
|
||||
steam_running = False
|
||||
|
||||
if steam_running:
|
||||
# Defer the write — Steam holds shortcuts.vdf in memory and would clobber it.
|
||||
# Store the pending options so the GUI can stop Steam, apply, then restart.
|
||||
self._pending_mounts_app_name = app_name
|
||||
self._pending_mounts_exe_path = exe_path
|
||||
self._pending_mounts_options = updated
|
||||
return "steam_running"
|
||||
|
||||
success = self.update_shortcut_launch_options(app_name, exe_path, updated)
|
||||
return "updated" if success else "failed"
|
||||
|
||||
def apply_pending_mounts_update(self) -> bool:
|
||||
"""Write a deferred STEAM_COMPAT_MOUNTS update. Call only after Steam has stopped."""
|
||||
app_name = getattr(self, '_pending_mounts_app_name', None)
|
||||
exe_path = getattr(self, '_pending_mounts_exe_path', None)
|
||||
options = getattr(self, '_pending_mounts_options', None)
|
||||
if not (app_name and exe_path and options):
|
||||
self.logger.warning("apply_pending_mounts_update called with no pending update")
|
||||
return False
|
||||
self._pending_mounts_app_name = None
|
||||
self._pending_mounts_exe_path = None
|
||||
self._pending_mounts_options = None
|
||||
return self.update_shortcut_launch_options(app_name, exe_path, options)
|
||||
|
||||
def update_shortcut_launch_options(self, app_name, exe_path, new_launch_options):
|
||||
"""
|
||||
Updates the LaunchOptions for a specific existing shortcut in shortcuts.vdf by matching AppName and Exe.
|
||||
|
||||
@@ -180,7 +180,7 @@ class ProcessManager:
|
||||
"""
|
||||
Shared process manager for robust subprocess launching, tracking, and cancellation.
|
||||
"""
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False):
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False, use_pty=False):
|
||||
self.cmd = cmd
|
||||
# Default to cleaned environment if None to prevent AppImage variable inheritance
|
||||
if env is None:
|
||||
@@ -192,6 +192,8 @@ class ProcessManager:
|
||||
self.bufsize = bufsize
|
||||
self.separate_stderr = separate_stderr
|
||||
self.enable_stdin = enable_stdin
|
||||
self.use_pty = use_pty
|
||||
self._pty_master_fd = None
|
||||
self.proc = None
|
||||
self.process_group_pid = None
|
||||
self._stdin_lock = threading.Lock()
|
||||
@@ -200,17 +202,39 @@ class ProcessManager:
|
||||
def _start_process(self):
|
||||
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
|
||||
stdin_arg = subprocess.PIPE if self.enable_stdin else None
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdin=stdin_arg,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=stderr_arg,
|
||||
env=self.env,
|
||||
cwd=self.cwd,
|
||||
text=self.text,
|
||||
bufsize=self.bufsize,
|
||||
start_new_session=True
|
||||
)
|
||||
if self.use_pty:
|
||||
import pty
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
self._pty_master_fd = master_fd
|
||||
# TERM=dumb + NO_COLOR prevent CLF3 from using cursor movement or ANSI
|
||||
# sequences (which would cause in-place overwrites we can't capture).
|
||||
# isatty() still returns True on the slave, so Rust line-buffers stdout.
|
||||
pty_env = dict(self.env) if self.env else {}
|
||||
pty_env['TERM'] = 'dumb'
|
||||
pty_env['NO_COLOR'] = '1'
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdin=stdin_arg,
|
||||
stdout=slave_fd,
|
||||
stderr=stderr_arg,
|
||||
env=pty_env,
|
||||
cwd=self.cwd,
|
||||
start_new_session=True,
|
||||
pass_fds=(slave_fd,),
|
||||
)
|
||||
os.close(slave_fd)
|
||||
else:
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdin=stdin_arg,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=stderr_arg,
|
||||
env=self.env,
|
||||
cwd=self.cwd,
|
||||
text=self.text,
|
||||
bufsize=self.bufsize,
|
||||
start_new_session=True,
|
||||
)
|
||||
self.process_group_pid = os.getpgid(self.proc.pid)
|
||||
|
||||
def cancel(self, timeout_terminate=2, timeout_kill=1, max_cleanup_attempts=3):
|
||||
@@ -267,6 +291,12 @@ class ProcessManager:
|
||||
cleanup_attempts += 1
|
||||
finally:
|
||||
# Always close pipes - unblocks threads blocked on read(1) or iterating stderr
|
||||
if self._pty_master_fd is not None:
|
||||
try:
|
||||
os.close(self._pty_master_fd)
|
||||
except Exception:
|
||||
pass
|
||||
self._pty_master_fd = None
|
||||
if self.proc:
|
||||
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
|
||||
if pipe:
|
||||
@@ -289,6 +319,11 @@ class ProcessManager:
|
||||
return None
|
||||
|
||||
def read_stdout_char(self):
|
||||
if self._pty_master_fd is not None:
|
||||
try:
|
||||
return os.read(self._pty_master_fd, 1)
|
||||
except (OSError, IOError):
|
||||
return None
|
||||
if self.proc and self.proc.stdout:
|
||||
try:
|
||||
return self.proc.stdout.read(1)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
TTW installer backend: install_ttw_backend, start_ttw_installation, cleanup, stream output, integrate.
|
||||
"""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
@@ -144,7 +146,8 @@ class TTWInstallerBackendMixin:
|
||||
exe_dir = str(self.ttw_installer_executable_path.parent)
|
||||
process = subprocess.Popen(
|
||||
cmd, cwd=exe_dir, env=env,
|
||||
stdout=output_fh, stderr=subprocess.STDOUT, bufsize=1
|
||||
stdout=output_fh, stderr=subprocess.STDOUT, bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
self.logger.info("TTW_Linux_Installer process started (PID: %s), output to %s", process.pid, output_file)
|
||||
process._output_fh = output_fh
|
||||
@@ -155,7 +158,7 @@ class TTWInstallerBackendMixin:
|
||||
|
||||
@staticmethod
|
||||
def cleanup_ttw_process(process):
|
||||
"""Clean up after TTW installation process."""
|
||||
"""Terminate the TTW process group, then clean up file handles."""
|
||||
if process:
|
||||
if hasattr(process, '_output_fh'):
|
||||
try:
|
||||
@@ -164,13 +167,18 @@ class TTWInstallerBackendMixin:
|
||||
pass
|
||||
if process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
pgid = os.getpgid(process.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
process.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
pgid = os.getpgid(process.pid)
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
from jackify.shared.paths import cleanup_stale_tmp
|
||||
cleanup_stale_tmp()
|
||||
|
||||
|
||||
@@ -74,10 +74,9 @@ class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
|
||||
def _check_installation(self):
|
||||
"""Check if TTW_Linux_Installer is installed at expected location.
|
||||
|
||||
|
||||
Checks for both old format (ttw_linux_gui) and new format (mpi_installer) executables.
|
||||
"""
|
||||
self._ensure_dirs_exist()
|
||||
|
||||
# Check for both old (ttw_linux_gui) and new (mpi_installer) executable names
|
||||
exe_names = [TTW_INSTALLER_EXECUTABLE_NAME, "mpi_installer"]
|
||||
@@ -104,7 +103,6 @@ class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
(success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
self._ensure_dirs_exist()
|
||||
target_dir = Path(install_dir) if install_dir else self.ttw_installer_dir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
VanillaGameFinder
|
||||
|
||||
Locates vanilla game installations across Steam and Heroic (GOG/Epic)
|
||||
without requiring manual path entry from the user.
|
||||
|
||||
Detection order: Steam appmanifest -> Heroic GOG -> Heroic Epic.
|
||||
No manual path override is offered here; that belongs in user settings.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, NamedTuple, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Store identifiers returned alongside the detected path.
|
||||
STORE_STEAM = "steam"
|
||||
STORE_GOG = "gog"
|
||||
STORE_EPIC = "epic"
|
||||
STORE_UNKNOWN = "unknown"
|
||||
|
||||
GameLocation = Tuple[Path, str] # (path, store)
|
||||
|
||||
|
||||
class SteamEntry(NamedTuple):
|
||||
app_id: str
|
||||
dir_names: List[str]
|
||||
|
||||
|
||||
# Maps Jackify game_type -> ordered list of Steam candidates to try.
|
||||
# Multiple entries handle games with ambiguous type strings (e.g. skyrim = SSE or LE).
|
||||
_STEAM_CATALOG: Dict[str, List[SteamEntry]] = {
|
||||
'skyrim': [
|
||||
SteamEntry('489830', ['Skyrim Special Edition']),
|
||||
SteamEntry('72850', ['Skyrim']),
|
||||
],
|
||||
'skyrimvr': [SteamEntry('611670', ['Skyrim VR'])],
|
||||
'fallout4': [SteamEntry('377160', ['Fallout 4'])],
|
||||
'fallout4vr': [SteamEntry('611660', ['Fallout 4 VR'])],
|
||||
'falloutnv': [SteamEntry('22380', ['Fallout New Vegas', 'FalloutNV'])],
|
||||
'fallout3': [
|
||||
SteamEntry('22300', ['Fallout 3', 'Fallout3']),
|
||||
SteamEntry('22370', ['Fallout 3 goty', 'Fallout 3 GOTY', 'Fallout3']),
|
||||
],
|
||||
'oblivion': [SteamEntry('22330', ['Oblivion'])],
|
||||
'oblivion_remastered': [SteamEntry('2623190', ['Oblivion Remastered'])],
|
||||
'morrowind': [SteamEntry('22320', ['Morrowind'])],
|
||||
'starfield': [SteamEntry('1716740', ['Starfield'])],
|
||||
'enderal': [
|
||||
SteamEntry('976620', ['Enderal Special Edition']),
|
||||
SteamEntry('933480', ['Enderal Forgotten Stories', 'Enderal']),
|
||||
],
|
||||
'bg3': [SteamEntry('1086940', ['Baldurs Gate 3', "Baldur's Gate 3"])],
|
||||
'cp2077': [SteamEntry('1091500', ['Cyberpunk 2077'])],
|
||||
'witcher3': [SteamEntry('292030', ['The Witcher 3 Wild Hunt', 'The Witcher 3: Wild Hunt'])],
|
||||
'darksouls3': [SteamEntry('374320', ['DARK SOULS III'])],
|
||||
'eldenring': [SteamEntry('1245620', ['ELDEN RING'])],
|
||||
'sekiro': [SteamEntry('814380', ['Sekiro'])],
|
||||
'mountandblade2': [SteamEntry('261550', ['Mount & Blade II Bannerlord'])],
|
||||
'stardewvalley': [SteamEntry('413150', ['Stardew Valley'])],
|
||||
'dragonageinquisition': [SteamEntry('1222690', ['Dragon Age Inquisition'])],
|
||||
'hogwartslegacy': [SteamEntry('990080', ['Hogwarts Legacy'])],
|
||||
}
|
||||
|
||||
# Maps Jackify game_type -> list of GOG app IDs (appName in installed.json).
|
||||
# Source: Fluorine-Manager/libs/basic_games/gog_utils.py approach + CLF3 known_games.rs IDs.
|
||||
_HEROIC_GOG_CATALOG: Dict[str, List[str]] = {
|
||||
'falloutnv': ['1454587428'],
|
||||
'fallout3': ['1454315831'],
|
||||
'oblivion': ['1458058109'],
|
||||
'morrowind': ['1440163901'],
|
||||
'bg3': ['1456460669'],
|
||||
'cp2077': ['1423049311'],
|
||||
'witcher3': ['1495134320'],
|
||||
'skyrim': ['1711230643'],
|
||||
'stardewvalley': ['1453375253'],
|
||||
}
|
||||
|
||||
# Maps Jackify game_type -> list of Epic/Legendary app_name slugs (installed.json key).
|
||||
_HEROIC_EPIC_CATALOG: Dict[str, List[str]] = {
|
||||
'fallout3': ['adeae8bbfc94427db57c7dfecce3f1d4'],
|
||||
'falloutnv': ['5daeb974a22a435988892319b3a4f476'],
|
||||
}
|
||||
|
||||
# Epic installs some games into a language-specific subdirectory inside the install root.
|
||||
# Maps game_type -> glob pattern to find the real game directory one level down.
|
||||
# Language suffix varies (English, German, French, ...) so we glob rather than hardcode.
|
||||
_EPIC_SUBDIR_GLOB: Dict[str, str] = {
|
||||
'fallout3': 'Fallout 3 GOTY *',
|
||||
'falloutnv': 'Fallout New Vegas *',
|
||||
}
|
||||
|
||||
# Candidate Heroic config roots: native install then Flatpak.
|
||||
_HEROIC_CONFIG_ROOTS: List[Path] = [
|
||||
Path.home() / '.config' / 'heroic',
|
||||
Path.home() / '.var' / 'app' / 'com.heroicgameslauncher.hgl' / 'config' / 'heroic',
|
||||
]
|
||||
|
||||
|
||||
class VanillaGameFinder:
|
||||
"""
|
||||
Locates vanilla (store-installed) game directories for a given Jackify game_type.
|
||||
Returns a (Path, store) tuple so callers can warn when the game is not on Steam.
|
||||
Searches Steam first, then Heroic-managed stores (GOG, Epic).
|
||||
"""
|
||||
|
||||
def find(self, game_type: str) -> Optional[GameLocation]:
|
||||
"""
|
||||
Return (path, store) for the detected game installation, or None.
|
||||
store is one of: 'steam', 'gog', 'epic', 'unknown'.
|
||||
"""
|
||||
result = self._find_steam(game_type)
|
||||
if result:
|
||||
logger.info("VanillaGameFinder: found %s via Steam at %s", game_type, result)
|
||||
return result, STORE_STEAM
|
||||
|
||||
result = self._find_heroic(game_type)
|
||||
if result:
|
||||
path, store = result
|
||||
logger.info("VanillaGameFinder: found %s via %s at %s", game_type, store, path)
|
||||
return path, store
|
||||
|
||||
logger.debug("VanillaGameFinder: no installation found for %s", game_type)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Steam
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _find_steam(self, game_type: str) -> Optional[Path]:
|
||||
entries = _STEAM_CATALOG.get(game_type)
|
||||
if not entries:
|
||||
return None
|
||||
try:
|
||||
from jackify.backend.handlers.path_handler_steam import PathHandlerSteamMixin
|
||||
library_paths = PathHandlerSteamMixin.get_all_steam_library_paths()
|
||||
except Exception as e:
|
||||
logger.debug("Steam library path detection failed: %s", e)
|
||||
return None
|
||||
|
||||
for library in library_paths:
|
||||
steamapps = library / 'steamapps'
|
||||
if not steamapps.is_dir():
|
||||
continue
|
||||
for entry in entries:
|
||||
path = self._check_steam_entry(steamapps, entry)
|
||||
if path:
|
||||
return path
|
||||
return None
|
||||
|
||||
def _check_steam_entry(self, steamapps: Path, entry: SteamEntry) -> Optional[Path]:
|
||||
manifest = steamapps / f'appmanifest_{entry.app_id}.acf'
|
||||
if not manifest.is_file():
|
||||
return None
|
||||
try:
|
||||
content = manifest.read_text(encoding='utf-8', errors='replace')
|
||||
state_match = re.search(r'"StateFlags"\s+"(\d+)"', content)
|
||||
if state_match and not (int(state_match.group(1)) & 4):
|
||||
logger.debug("Skipping %s: StateFlags=%s (not fully installed)", manifest.name, state_match.group(1))
|
||||
return None
|
||||
match = re.search(r'"installdir"\s+"([^"]+)"', content)
|
||||
if match:
|
||||
path = steamapps / 'common' / match.group(1)
|
||||
if path.is_dir():
|
||||
return path
|
||||
for name in entry.dir_names:
|
||||
fallback = steamapps / 'common' / name
|
||||
if fallback.is_dir():
|
||||
return fallback
|
||||
except OSError as e:
|
||||
logger.debug("Could not read appmanifest %s: %s", manifest, e)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Heroic (GOG via gog_store/installed.json, Epic via legendaryConfig)
|
||||
# Approach adapted from Fluorine-Manager/libs/basic_games/gog_utils.py
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _find_heroic(self, game_type: str) -> Optional[Tuple[Path, str]]:
|
||||
gog_ids = _HEROIC_GOG_CATALOG.get(game_type)
|
||||
if gog_ids:
|
||||
path = self._find_heroic_gog(gog_ids)
|
||||
if path:
|
||||
return path, STORE_GOG
|
||||
|
||||
epic_ids = _HEROIC_EPIC_CATALOG.get(game_type)
|
||||
if epic_ids:
|
||||
path = self._find_heroic_epic(epic_ids, game_type=game_type)
|
||||
if path:
|
||||
return path, STORE_EPIC
|
||||
|
||||
return None
|
||||
|
||||
def _find_heroic_gog(self, app_ids: List[str]) -> Optional[Path]:
|
||||
id_set = set(app_ids)
|
||||
for config_root in _HEROIC_CONFIG_ROOTS:
|
||||
installed_file = config_root / 'gog_store' / 'installed.json'
|
||||
if not installed_file.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(installed_file.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.debug("Could not read Heroic GOG installed.json %s: %s", installed_file, e)
|
||||
continue
|
||||
for entry in data.get('installed', []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get('appName') not in id_set:
|
||||
continue
|
||||
install_path = entry.get('install_path') or entry.get('installPath', '')
|
||||
if install_path:
|
||||
path = Path(install_path)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return None
|
||||
|
||||
def _find_heroic_epic(self, app_ids: List[str], game_type: str = '') -> Optional[Path]:
|
||||
id_set = set(app_ids)
|
||||
subdir_glob = _EPIC_SUBDIR_GLOB.get(game_type, '')
|
||||
for config_root in _HEROIC_CONFIG_ROOTS:
|
||||
installed_file = config_root / 'legendaryConfig' / 'legendary' / 'installed.json'
|
||||
if not installed_file.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(installed_file.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.debug("Could not read Heroic Epic installed.json %s: %s", installed_file, e)
|
||||
continue
|
||||
for app_name, entry in data.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if app_name not in id_set:
|
||||
continue
|
||||
install_path = entry.get('install_path', '')
|
||||
if not install_path:
|
||||
continue
|
||||
path = Path(install_path)
|
||||
if not path.is_dir():
|
||||
continue
|
||||
# Epic installs some titles into a language-specific subdirectory.
|
||||
# Glob for it rather than hardcoding the language suffix.
|
||||
if subdir_glob:
|
||||
matches = sorted(path.glob(subdir_glob))
|
||||
if matches:
|
||||
logger.debug("Epic subdir match for %s: %s", game_type, matches[0])
|
||||
return matches[0]
|
||||
return path
|
||||
return None
|
||||
@@ -382,44 +382,56 @@ class WineUtilsProtonMixin:
|
||||
|
||||
@staticmethod
|
||||
def scan_valve_proton_versions() -> List[Dict[str, Any]]:
|
||||
"""Scan for available Valve Proton versions with fallback priority."""
|
||||
"""Scan for available Valve Proton versions. Discovers all installed X.Y releases dynamically."""
|
||||
logger.info("Scanning for available Valve Proton versions...")
|
||||
found_versions = []
|
||||
seen_names: set = set()
|
||||
steam_libs = WineUtilsProtonMixin.get_steam_library_paths()
|
||||
if not steam_libs:
|
||||
logger.warning("No Steam library paths found")
|
||||
return []
|
||||
preferred_versions = [
|
||||
("Proton - Experimental", 150),
|
||||
("Proton 10.0", 140),
|
||||
("Proton 9.0", 130),
|
||||
("Proton 9.0 (Beta)", 125)
|
||||
]
|
||||
|
||||
for steam_path in steam_libs:
|
||||
logger.debug(f"Scanning Steam library: {steam_path}")
|
||||
for version_name, priority in preferred_versions:
|
||||
proton_path = steam_path / version_name
|
||||
wine_bin = proton_path / "files" / "bin" / "wine"
|
||||
if wine_bin.exists() and wine_bin.is_file():
|
||||
compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(proton_path)
|
||||
found_versions.append({
|
||||
'name': version_name,
|
||||
'path': proton_path,
|
||||
'wine_bin': wine_bin,
|
||||
'priority': priority,
|
||||
'type': 'Valve-Proton',
|
||||
'steam_compat_name': compat_name,
|
||||
})
|
||||
logger.debug(f"Found {version_name} at {proton_path}")
|
||||
found_versions.sort(key=lambda x: x['priority'], reverse=True)
|
||||
unique_versions = []
|
||||
seen_names = set()
|
||||
for version in found_versions:
|
||||
if version['name'] not in seen_names:
|
||||
unique_versions.append(version)
|
||||
seen_names.add(version['name'])
|
||||
logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)")
|
||||
return unique_versions
|
||||
if not steam_path.is_dir():
|
||||
continue
|
||||
for entry in steam_path.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
wine_bin = entry / "files" / "bin" / "wine"
|
||||
if not wine_bin.is_file():
|
||||
continue
|
||||
if name in seen_names:
|
||||
continue
|
||||
|
||||
if name == "Proton - Experimental":
|
||||
major, minor, is_beta = 9999, 9999, False
|
||||
else:
|
||||
m = re.match(r'^Proton (\d+)\.(\d+)(\s+\(Beta\))?$', name)
|
||||
if not m:
|
||||
continue
|
||||
major, minor, is_beta = int(m.group(1)), int(m.group(2)), bool(m.group(3))
|
||||
|
||||
compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(entry)
|
||||
found_versions.append({
|
||||
'name': name,
|
||||
'path': entry,
|
||||
'wine_bin': wine_bin,
|
||||
'priority': major * 10 + (0 if is_beta else 1),
|
||||
'major_version': major,
|
||||
'minor_version': minor,
|
||||
'type': 'Valve-Proton',
|
||||
'steam_compat_name': compat_name,
|
||||
})
|
||||
seen_names.add(name)
|
||||
logger.debug(f"Found Valve Proton: {name}")
|
||||
|
||||
found_versions.sort(
|
||||
key=lambda x: (x['major_version'], x['minor_version'], 0 if x['name'].endswith('(Beta)') else 1),
|
||||
reverse=True,
|
||||
)
|
||||
logger.info(f"Found {len(found_versions)} Valve Proton version(s)")
|
||||
return found_versions
|
||||
|
||||
@staticmethod
|
||||
def scan_all_proton_versions() -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -7,6 +7,7 @@ Discovery, installation strategy, and verification live in mixins.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -54,6 +55,45 @@ class WinetricksHandler(
|
||||
if not components_to_install:
|
||||
return True
|
||||
|
||||
native_wineprefix = env.get('WINEPREFIX', wineprefix)
|
||||
wine_binary = env.get('WINE', '')
|
||||
|
||||
# Native installer tier: direct-source downloads, no winetricks dependency.
|
||||
native = None
|
||||
if wine_binary:
|
||||
try:
|
||||
from .native_component_installer import NativeComponentInstaller
|
||||
native = NativeComponentInstaller(native_wineprefix, wine_binary, env, self.logger)
|
||||
except Exception as native_exc:
|
||||
self.logger.warning("Native installer init failed: %s", native_exc)
|
||||
|
||||
if native:
|
||||
try:
|
||||
_native_ok, components_to_install = native.install_components(
|
||||
components_to_install, status_callback
|
||||
)
|
||||
if not components_to_install:
|
||||
self._set_windows_10_mode_after_install(native_wineprefix, env)
|
||||
return True
|
||||
except Exception as native_exc:
|
||||
self.logger.warning("Native installer skipped due to error: %s", native_exc)
|
||||
|
||||
# dotnet48 (NSF/CSF modlists) installs via the bundled winetricks verb. Fully remove
|
||||
# Wine Mono first (files + mscoree stub + uninstaller entry + its NDP registry keys) so
|
||||
# the prefix is in the clean "no .NET, no mono" state a fresh winetricks dotnet48 expects:
|
||||
# this avoids the "same or higher version already installed" bail (the NDP keys are
|
||||
# mono-registered, winetricks #2367) and lets winetricks complete the .NET first-run
|
||||
# during the verb, so it does not fire at game launch.
|
||||
# The status message persists through the install (the main winetricks call emits none
|
||||
# before it blocks), so the UI shows dotnet48 rather than the prior native component.
|
||||
if 'dotnet48' in components_to_install:
|
||||
if status_callback:
|
||||
status_callback("[NATIVE_INSTALL] dotnet48")
|
||||
status_callback("Installing .NET Framework 4.8 (dotnet48) - the long step, can take several minutes")
|
||||
self.logger.info("Installing dotnet48 via winetricks (NSF/CSF) - long-running step")
|
||||
self._kill_wineserver_for_prefix(env)
|
||||
self._remove_wine_mono(native_wineprefix)
|
||||
|
||||
# Flatpak Steam: use protontricks only; bundled winetricks is unreliable (e.g. from AppImage)
|
||||
flatpak_steam = False
|
||||
try:
|
||||
@@ -455,6 +495,70 @@ class WinetricksHandler(
|
||||
except Exception as e:
|
||||
self.logger.debug("Wineserver -k failed (non-fatal): %s", e)
|
||||
|
||||
def _remove_wine_mono(self, wineprefix: str) -> None:
|
||||
"""Fully remove Wine Mono so a clean winetricks dotnet48 install behaves as on a fresh
|
||||
prefix - the state that installs without the "already installed" bail and without
|
||||
deferring the .NET first-run to game launch.
|
||||
|
||||
Removes: the mono mscoree.dll stubs, the mono runtime directory, the "Wine Mono"
|
||||
uninstaller registry sections, and the mono-registered NET Framework Setup\\NDP keys
|
||||
(those are what trigger the #2367 bail). Wineserver must be dead so the system.reg
|
||||
edit persists.
|
||||
"""
|
||||
import time
|
||||
time.sleep(2)
|
||||
|
||||
for sub in ('system32', 'syswow64'):
|
||||
dll = os.path.join(wineprefix, 'drive_c', 'windows', sub, 'mscoree.dll')
|
||||
try:
|
||||
if os.path.isfile(dll):
|
||||
with open(dll, 'rb') as f:
|
||||
is_mono_stub = b'WINE_MONO_OVERRIDES' in f.read()
|
||||
if is_mono_stub:
|
||||
os.unlink(dll)
|
||||
self.logger.debug("Removed Mono mscoree.dll stub: %s", dll)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mono_dir = os.path.join(wineprefix, 'drive_c', 'windows', 'mono')
|
||||
if os.path.isdir(mono_dir):
|
||||
shutil.rmtree(mono_dir, ignore_errors=True)
|
||||
self.logger.debug("Removed Wine Mono runtime directory")
|
||||
|
||||
system_reg = os.path.join(wineprefix, 'system.reg')
|
||||
if not os.path.isfile(system_reg):
|
||||
return
|
||||
try:
|
||||
with open(system_reg, encoding='utf-8', errors='replace') as f:
|
||||
lines = f.readlines()
|
||||
out, section, drop = [], [], False
|
||||
removed_mono, removed_ndp = 0, 0
|
||||
for line in lines:
|
||||
if line.lstrip().startswith('['):
|
||||
if section and not drop:
|
||||
out.extend(section)
|
||||
end = line.strip().rfind(']')
|
||||
key = line.strip()[1:end].lower() if end > 0 else ''
|
||||
drop = 'net framework setup\\\\ndp' in key
|
||||
if drop:
|
||||
removed_ndp += 1
|
||||
section = [line]
|
||||
elif section:
|
||||
section.append(line)
|
||||
s = line.strip()
|
||||
if s.startswith('"DisplayName"=') and 'Wine Mono' in s and not drop:
|
||||
drop = True
|
||||
removed_mono += 1
|
||||
else:
|
||||
out.append(line)
|
||||
if section and not drop:
|
||||
out.extend(section)
|
||||
with open(system_reg, 'w', encoding='utf-8') as f:
|
||||
f.writelines(out)
|
||||
self.logger.info("Removed Wine Mono: %d uninstaller section(s) + %d NDP section(s)", removed_mono, removed_ndp)
|
||||
except Exception as exc:
|
||||
self.logger.warning("Wine Mono registry removal failed (non-fatal): %s", exc)
|
||||
|
||||
def _cleanup_wine_processes(self):
|
||||
"""Clean up winetricks processes only during component installation."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user