Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support

This commit is contained in:
Omni
2026-06-21 21:47:48 +01:00
parent 33b3fbaed2
commit 7fff107389
483 changed files with 11150 additions and 6050 deletions
+13 -2
View File
@@ -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
+1 -25
View File
@@ -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?")
+38 -29
View File
@@ -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
-2
View File
@@ -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',
]
+21 -21
View File
@@ -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:
+34 -11
View File
@@ -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.
+47 -12
View File
@@ -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
+43 -31
View File
@@ -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: