mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 00:23:42 +02:00
Release v0.7.2 - TTW Linux Installer 0.2.0
This commit is contained in:
+1
-1
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.7.1.1"
|
||||
__version__ = "0.7.2"
|
||||
|
||||
@@ -64,7 +64,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
"modlist_downloads_base_dir": os.path.expanduser("~/Games/Modlist_Downloads"), # Configurable base directory for downloads
|
||||
"jackify_data_dir": None, # Configurable Jackify data directory (default: ~/Jackify)
|
||||
"use_winetricks_for_components": True, # DEPRECATED: Migrated to component_installation_method. Kept for backward compatibility.
|
||||
"component_installation_method": "winetricks", # "winetricks" (default) or "system_protontricks"
|
||||
"component_installation_method": "native", # "native" (default), "winetricks", or "system_protontricks"
|
||||
"game_proton_path": None, # Proton version for game shortcuts (can be any Proton 9+), separate from install proton
|
||||
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
|
||||
"proton_version": None, # Install Proton version name - None means auto-detect
|
||||
@@ -140,7 +140,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
Handles breaking changes and data format updates
|
||||
"""
|
||||
current_version = self.settings.get("version", "0.0.0")
|
||||
target_version = "0.2.0"
|
||||
target_version = "0.7.2"
|
||||
|
||||
if current_version == target_version:
|
||||
return
|
||||
@@ -191,6 +191,20 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
self.save_config()
|
||||
logger.info("Config migration completed")
|
||||
|
||||
# Migration: pre-0.7.2 -> v0.7.2
|
||||
# component_installation_method semantics changed: the old lone "winetricks" value
|
||||
# actually meant "native install, winetricks fallback for unsupported components" -
|
||||
# that behavior is now the "native" option. Explicit full winetricks/protontricks
|
||||
# bypass modes are new in 0.7.2, so old configs must be remapped once rather than
|
||||
# silently reinterpreted as the new bypass meaning.
|
||||
if version.parse(current_version) < version.parse("0.7.2"):
|
||||
if self.settings.get("component_installation_method") == "winetricks":
|
||||
self.settings["component_installation_method"] = "native"
|
||||
logger.info("Migrated component_installation_method 'winetricks' -> 'native'")
|
||||
self.settings["version"] = target_version
|
||||
self.save_config()
|
||||
logger.info("Config migration completed")
|
||||
|
||||
def _read_config_from_disk(self):
|
||||
"""
|
||||
Read configuration directly from disk without caching.
|
||||
@@ -305,6 +319,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
"""Set preferred resolution"""
|
||||
resolution = f"{width}x{height}"
|
||||
self.settings["resolution"] = resolution
|
||||
self._dirty_keys.add("resolution")
|
||||
logger.debug(f"Set resolution to: {resolution}")
|
||||
return True
|
||||
|
||||
@@ -315,6 +330,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
def set_last_modlist(self, modlist_name):
|
||||
"""Save the last selected modlist"""
|
||||
self.settings["last_selected_modlist"] = modlist_name
|
||||
self._dirty_keys.add("last_selected_modlist")
|
||||
logger.debug(f"Set last selected modlist to: {modlist_name}")
|
||||
return True
|
||||
|
||||
@@ -325,6 +341,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
def set_protontricks_path(self, path):
|
||||
"""Set the path to protontricks executable"""
|
||||
self.settings["protontricks_path"] = path
|
||||
self._dirty_keys.add("protontricks_path")
|
||||
logger.debug(f"Set protontricks path to: {path}")
|
||||
return True
|
||||
|
||||
@@ -350,7 +367,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
# Clear resolution if 'Leave unchanged' or empty
|
||||
self.settings["resolution"] = None
|
||||
logger.debug("Resolution cleared")
|
||||
|
||||
self._dirty_keys.add("resolution")
|
||||
return self.save_config()
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving resolution: {e}")
|
||||
@@ -392,6 +409,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
"""
|
||||
try:
|
||||
self.settings["resolution"] = None
|
||||
self._dirty_keys.add("resolution")
|
||||
logger.debug("Resolution cleared from configuration")
|
||||
return self.save_config()
|
||||
except Exception as e:
|
||||
|
||||
@@ -183,35 +183,6 @@ class FileSystemHandler(
|
||||
logger.warning(f"No suitable backup found for {original_file_path} in {backup_dir} or adjacent.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def set_permissions(path: Path, permissions: int = 0o755, recursive: bool = True) -> bool:
|
||||
"""Set file or directory permissions (non-sudo)."""
|
||||
try:
|
||||
if not path.exists():
|
||||
logger.error(f"Cannot set permissions: Path does not exist - {path}")
|
||||
return False
|
||||
|
||||
if recursive and path.is_dir():
|
||||
for root, dirs, files in os.walk(path):
|
||||
try:
|
||||
os.chmod(root, 0o755)
|
||||
except Exception as dir_e:
|
||||
logger.warning(f"Failed to chmod dir {root}: {dir_e}")
|
||||
for file in files:
|
||||
try:
|
||||
os.chmod(os.path.join(root, file), 0o644)
|
||||
except Exception as file_e:
|
||||
logger.warning(f"Failed to chmod file {os.path.join(root, file)}: {file_e}")
|
||||
elif path.is_file():
|
||||
os.chmod(path, 0o644 if permissions == 0o755 else permissions)
|
||||
elif path.is_dir():
|
||||
os.chmod(path, permissions) # Set specific perm for top-level dir if not recursive
|
||||
logger.debug(f"Set permissions for {path} (recursive={recursive})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set permissions for {path}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_permissions(path: Path) -> Optional[int]:
|
||||
"""Get file or directory permissions (last 3 octal digits)."""
|
||||
|
||||
@@ -24,6 +24,8 @@ class FilesystemOwnershipMixin:
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in dirs + files:
|
||||
full_path = os.path.join(root, name)
|
||||
if os.path.islink(full_path):
|
||||
continue
|
||||
try:
|
||||
stat = os.stat(full_path)
|
||||
if stat.st_uid != uid or stat.st_gid not in gids:
|
||||
@@ -93,6 +95,8 @@ class FilesystemOwnershipMixin:
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in dirs + files:
|
||||
full = os.path.join(root, name)
|
||||
if os.path.islink(full):
|
||||
continue
|
||||
try:
|
||||
if os.stat(full).st_mode & 0o777 != 0o755:
|
||||
return False
|
||||
|
||||
@@ -579,13 +579,18 @@ class ModlistConfigurationMixin:
|
||||
self._re_enforce_windows_10_mode()
|
||||
|
||||
# Step 15: Apply tool compatibility settings (xEdit, Pandora, DLL overrides).
|
||||
# Only runs for standard Skyrim SE/AE modlists. Non-Skyrim games (Enderal, FNV,
|
||||
# FO3, etc.) are excluded because the mscoree AppDefault targets SkyrimSE.exe,
|
||||
# which is also Enderal's executable, causing a crash on those modlists.
|
||||
# Runs for standard Skyrim SE/AE modlists and Enderal SE. Other special types
|
||||
# (FNV, FO3, etc.) are excluded entirely - different engine, none of these
|
||||
# fixes apply. Enderal SE shares the Skyrim SE engine and plugin format, so
|
||||
# xEdit/Pandora/DLL overrides/Synthesis dotnet+NuGet setup all still apply -
|
||||
# only the mscoree AppDefault is skipped for it (apply_engine_mscoree below),
|
||||
# since that entry is scoped to SkyrimSE.exe, which is also Enderal's own
|
||||
# game process and would crash it.
|
||||
_special_type = self.detect_special_game_type(self.modlist_dir)
|
||||
_enderal = _special_type == 'enderal'
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
if ConfigHandler().get('auto_tool_compat', True) and _special_type is None:
|
||||
if ConfigHandler().get('auto_tool_compat', True) and (_special_type is None or _enderal):
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Applying tool compatibility settings")
|
||||
self.logger.info("Step 15: Applying tool compatibility settings...")
|
||||
@@ -596,10 +601,10 @@ class ModlistConfigurationMixin:
|
||||
# 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
|
||||
# dotnet 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 has not been specifically tested - revisit if a modlist needs live
|
||||
# the dotnet SDK/win11 step. Whether Synthesis runs under global-native on an
|
||||
# NSF prefix has not been specifically tested - revisit if a modlist needs live
|
||||
# Synthesis. The NuGet cert Synthesis needs is applied regardless (see
|
||||
# apply_tool_config), since it doesn't touch mscoree or Windows version.
|
||||
_nsf = getattr(self, '_nsf_detected', False)
|
||||
@@ -607,13 +612,21 @@ class ModlistConfigurationMixin:
|
||||
compatdata_path,
|
||||
wine_bin,
|
||||
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
|
||||
install_dotnet9_sdk=not _nsf,
|
||||
install_dotnet_sdk=not _nsf,
|
||||
install_fxc2_d3dcompiler=True,
|
||||
preserve_global_mscoree=_nsf,
|
||||
apply_engine_mscoree=not _enderal,
|
||||
)
|
||||
self.logger.info("Step 15: Tool compatibility settings applied")
|
||||
else:
|
||||
self.logger.warning("Step 15: Could not resolve prefix path or wine binary - skipping tool compat")
|
||||
|
||||
if self.modlist_dir:
|
||||
from jackify.backend.services.synthesis_updater import update_synthesis
|
||||
update_synthesis(
|
||||
str(self.modlist_dir),
|
||||
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
|
||||
)
|
||||
elif _special_type is not None:
|
||||
self.logger.info(f"Step 15: Skipping tool compat for {_special_type} modlist")
|
||||
except Exception as e:
|
||||
|
||||
@@ -264,39 +264,28 @@ class ModlistInstallCLITTWMixin:
|
||||
lower = clean.lower()
|
||||
rendered = ""
|
||||
|
||||
# Match GUI behavior: explicit Loading manifest counter line
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
phase_state["current"] = "Loading manifest"
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
|
||||
# Match GUI behavior: explicit Progress: NN% counter line
|
||||
progress_match = re.search(r'progress:\s*(\d+)%', lower)
|
||||
if progress_match:
|
||||
percent = int(progress_match.group(1))
|
||||
rendered = f"[TTW] {phase_state['current']}: {percent}%"
|
||||
else:
|
||||
# Match GUI behavior: generic [X/Y] counters with current phase name.
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', clean)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
|
||||
else:
|
||||
# Update phase state from milestone-like lines, then echo milestones.
|
||||
if 'manifest' in lower:
|
||||
phase_state["current"] = "Loading manifest"
|
||||
elif any(token in lower for token in ('extract', 'decompress', 'installing', 'copying', 'merge')):
|
||||
phase_state["current"] = clean
|
||||
# Update phase state from milestone-like lines, then echo milestones.
|
||||
if 'manifest' in lower:
|
||||
phase_state["current"] = "Loading manifest"
|
||||
elif any(token in lower for token in ('extract', 'decompress', 'installing', 'copying', 'merge')):
|
||||
phase_state["current"] = clean
|
||||
|
||||
is_milestone = any(token in lower for token in ('===', 'complete', 'finished', 'starting', 'valid'))
|
||||
is_error = 'error:' in lower
|
||||
is_warning = 'warning:' in lower
|
||||
if is_milestone or is_error or is_warning:
|
||||
rendered = f"[TTW] {clean}"
|
||||
is_milestone = any(token in lower for token in ('===', 'complete', 'finished', 'starting', 'valid'))
|
||||
is_error = 'error:' in lower
|
||||
is_warning = 'warning:' in lower
|
||||
if is_milestone or is_error or is_warning:
|
||||
rendered = f"[TTW] {clean}"
|
||||
|
||||
if not rendered or rendered == phase_state["last_rendered"]:
|
||||
return
|
||||
phase_state["last_rendered"] = rendered
|
||||
if rendered.startswith("[TTW] Loading manifest:") or re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered):
|
||||
if re.search(r'^\[TTW\] .+?: \d+%$', rendered):
|
||||
# In-place progress updates for counters/phases.
|
||||
print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True)
|
||||
progress_line_active["value"] = True
|
||||
|
||||
@@ -273,18 +273,18 @@ class ModlistWineOpsMixin:
|
||||
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
|
||||
# Add game-specific extras
|
||||
if "fallout4vr" in game or "fo4vr" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "vcrun2012"]
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnet9", "dotnetdesktop6", "dotnetdesktop9", "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"]
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnet9", "dotnetdesktop6", "dotnetdesktop9"]
|
||||
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
|
||||
extras += ["d3dx9_43", "d3dx9"]
|
||||
elif "cp2077" in game or "cyberpunk" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnet9", "dotnetdesktop6", "dotnetdesktop9"]
|
||||
elif "bg3" in game or "baldursgate" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnet9", "dotnetdesktop6", "dotnetdesktop9"]
|
||||
else:
|
||||
# Unknown game type - install the union of all known component sets
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "d3dx9_43", "d3dx9"]
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnet9", "dotnetdesktop6", "dotnetdesktop9", "d3dx9_43", "d3dx9"]
|
||||
# Add modlist-specific extras
|
||||
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
||||
for key, components in self.MODLIST_WINE_COMPONENTS.items():
|
||||
|
||||
@@ -93,6 +93,24 @@ class NativeComponentInstaller:
|
||||
if cb:
|
||||
cb(msg)
|
||||
|
||||
def _kill_wineserver_for_prefix(self) -> None:
|
||||
"""Kill wineserver for this prefix so the vcrun2022/vcrun2012 installers start against a
|
||||
fresh wineserver instead of whatever state a prior step left behind (matches the same
|
||||
precaution winetricks_handler takes before its own wine invocations)."""
|
||||
wineserver = self.wine_env.get('WINESERVER')
|
||||
if not wineserver or not os.path.exists(wineserver):
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
[wineserver, '-k'],
|
||||
env=self._wine_env_base(),
|
||||
timeout=10,
|
||||
capture_output=True,
|
||||
)
|
||||
self.logger.debug("Killed wineserver for prefix before native Wine-based installers")
|
||||
except Exception as exc:
|
||||
self.logger.debug("Wineserver -k failed (non-fatal): %s", exc)
|
||||
|
||||
def _wine_env_base(self, **extra) -> dict:
|
||||
base = {**self.wine_env, 'WINEPREFIX': self.wineprefix}
|
||||
parts = []
|
||||
@@ -117,6 +135,9 @@ class NativeComponentInstaller:
|
||||
|
||||
native_candidates = [c for c in components if c in SUPPORTED_COMPONENTS]
|
||||
|
||||
if any(c in ("vcrun2022", "vcrun2012") for c in native_candidates):
|
||||
self._kill_wineserver_for_prefix()
|
||||
|
||||
for component in components:
|
||||
if component not in SUPPORTED_COMPONENTS:
|
||||
remaining.append(component)
|
||||
@@ -408,7 +429,7 @@ class NativeComponentInstaller:
|
||||
# 3010 = reboot required - normal for VC redist, treat as success
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2022: x86 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2022 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
self.logger.error("vcrun2022 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
# x64: same msvcp140.dll pre-extraction workaround
|
||||
@@ -436,7 +457,7 @@ class NativeComponentInstaller:
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2022: x64 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2022 x64 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
self.logger.error("vcrun2022 x64 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
critical = [
|
||||
@@ -477,7 +498,7 @@ class NativeComponentInstaller:
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2012: x86 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2012 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
self.logger.error("vcrun2012 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
self.logger.info("vcrun2012: running x64 installer")
|
||||
@@ -489,7 +510,7 @@ class NativeComponentInstaller:
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2012: x64 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2012 x64 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
self.logger.error("vcrun2012 x64 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
if not (syswow64 / 'msvcr110.dll').is_file():
|
||||
|
||||
@@ -52,12 +52,12 @@ class ProgressParserPhaseMixin:
|
||||
def _map_section_to_phase(self, section_name: str) -> InstallationPhase:
|
||||
"""Map section name to InstallationPhase enum."""
|
||||
section_lower = section_name.lower()
|
||||
if 'download' in section_lower:
|
||||
if 'hash' in section_lower or 'validate' in section_lower or 'verif' in section_lower:
|
||||
return InstallationPhase.VALIDATE
|
||||
elif 'download' in section_lower:
|
||||
return InstallationPhase.DOWNLOAD
|
||||
elif 'extract' in section_lower:
|
||||
return InstallationPhase.EXTRACT
|
||||
elif 'hash' in section_lower or 'validate' in section_lower or 'verif' in section_lower:
|
||||
return InstallationPhase.VALIDATE
|
||||
elif 'install' in section_lower:
|
||||
return InstallationPhase.INSTALL
|
||||
elif 'bsa' in section_lower or 'building' in section_lower:
|
||||
|
||||
@@ -51,11 +51,11 @@ class TTWInstallerBackendMixin:
|
||||
return False, "Could not detect Fallout 3 or Fallout New Vegas installation paths"
|
||||
cmd = [
|
||||
str(self.ttw_installer_executable_path),
|
||||
"install",
|
||||
"--fo3", str(fallout3_path),
|
||||
"--fnv", str(falloutnv_path),
|
||||
"--mpi", str(ttw_mpi_path),
|
||||
"--output", str(ttw_output_path),
|
||||
"--start"
|
||||
"--dest", str(ttw_output_path),
|
||||
]
|
||||
self.logger.info("Executing TTW_Linux_Installer: %s", ' '.join(cmd))
|
||||
try:
|
||||
@@ -133,11 +133,11 @@ class TTWInstallerBackendMixin:
|
||||
return None, "Could not detect Fallout 3 or Fallout New Vegas installation paths"
|
||||
cmd = [
|
||||
str(self.ttw_installer_executable_path),
|
||||
"install",
|
||||
"--fo3", str(fallout3_path),
|
||||
"--fnv", str(falloutnv_path),
|
||||
"--mpi", str(ttw_mpi_path),
|
||||
"--output", str(ttw_output_path),
|
||||
"--start"
|
||||
"--dest", str(ttw_output_path),
|
||||
]
|
||||
self.logger.info("Executing TTW_Linux_Installer: %s", ' '.join(cmd))
|
||||
try:
|
||||
@@ -220,11 +220,11 @@ class TTWInstallerBackendMixin:
|
||||
return False, "Could not detect Fallout 3 or Fallout New Vegas installation paths"
|
||||
cmd = [
|
||||
str(self.ttw_installer_executable_path),
|
||||
"install",
|
||||
"--fo3", str(fallout3_path),
|
||||
"--fnv", str(falloutnv_path),
|
||||
"--mpi", str(ttw_mpi_path),
|
||||
"--output", str(ttw_output_path),
|
||||
"--start"
|
||||
"--dest", str(ttw_output_path),
|
||||
]
|
||||
self.logger.info("Executing TTW_Linux_Installer: %s", ' '.join(cmd))
|
||||
try:
|
||||
|
||||
@@ -26,14 +26,13 @@ logger = logging.getLogger(__name__)
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
JACKIFY_BASE_DIR = get_jackify_data_dir()
|
||||
DEFAULT_TTW_INSTALLER_DIR = JACKIFY_BASE_DIR / "TTW_Linux_Installer"
|
||||
TTW_INSTALLER_EXECUTABLE_NAME = "ttw_linux_gui" # Same executable, runs in CLI mode with args
|
||||
TTW_INSTALLER_EXECUTABLE_NAME = "mpi_installer"
|
||||
|
||||
# GitHub release info
|
||||
TTW_INSTALLER_REPO = "SulfurNitride/TTW_Linux_Installer"
|
||||
TTW_INSTALLER_RELEASE_URL = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/releases/latest"
|
||||
# Pin to 0.0.7 - last version with old format (ttw_linux_gui, universal-mpi-installer)
|
||||
# Set to None to use latest release
|
||||
TTW_INSTALLER_PINNED_VERSION = "0.0.7"
|
||||
TTW_INSTALLER_PINNED_VERSION = "0.2.0"
|
||||
|
||||
|
||||
class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
@@ -73,25 +72,18 @@ class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
self.ttw_installer_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _check_installation(self):
|
||||
"""Check if TTW_Linux_Installer is installed at expected location.
|
||||
"""Check if TTW_Linux_Installer is installed at expected location."""
|
||||
potential_exe_path = self.ttw_installer_dir / TTW_INSTALLER_EXECUTABLE_NAME
|
||||
if potential_exe_path.is_file() and os.access(potential_exe_path, os.X_OK):
|
||||
self.ttw_installer_executable_path = potential_exe_path
|
||||
self.ttw_installer_installed = True
|
||||
self.logger.info(f"Found TTW_Linux_Installer at: {self.ttw_installer_executable_path}")
|
||||
return
|
||||
|
||||
Checks for both old format (ttw_linux_gui) and new format (mpi_installer) executables.
|
||||
"""
|
||||
|
||||
# Check for both old (ttw_linux_gui) and new (mpi_installer) executable names
|
||||
exe_names = [TTW_INSTALLER_EXECUTABLE_NAME, "mpi_installer"]
|
||||
for exe_name in exe_names:
|
||||
potential_exe_path = self.ttw_installer_dir / exe_name
|
||||
if potential_exe_path.is_file() and os.access(potential_exe_path, os.X_OK):
|
||||
self.ttw_installer_executable_path = potential_exe_path
|
||||
self.ttw_installer_installed = True
|
||||
self.logger.info(f"Found TTW_Linux_Installer at: {self.ttw_installer_executable_path}")
|
||||
return
|
||||
|
||||
# Not found
|
||||
self.ttw_installer_installed = False
|
||||
self.ttw_installer_executable_path = None
|
||||
self.logger.info(f"TTW_Linux_Installer not found (searched for: {', '.join(exe_names)})")
|
||||
self.logger.info(f"TTW_Linux_Installer not found (searched for: {TTW_INSTALLER_EXECUTABLE_NAME})")
|
||||
|
||||
def install_ttw_installer(self, install_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
||||
"""Download and install TTW_Linux_Installer from GitHub releases.
|
||||
@@ -138,15 +130,14 @@ class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
data = resp.json()
|
||||
release_tag = data.get("tag_name") or data.get("name")
|
||||
|
||||
# Find Linux asset - universal-mpi-installer pattern (can be .zip or .tar.gz)
|
||||
# Find Linux asset - mpi-installer-linux-* pattern (can be .zip or .tar.gz)
|
||||
linux_asset = None
|
||||
asset_names = [asset.get("name", "") for asset in data.get("assets", [])]
|
||||
self.logger.info(f"Available release assets: {asset_names}")
|
||||
|
||||
|
||||
for asset in data.get("assets", []):
|
||||
name = asset.get("name", "").lower()
|
||||
# Look for universal-mpi-installer pattern
|
||||
if "universal-mpi-installer" in name and name.endswith((".zip", ".tar.gz")):
|
||||
if "linux" in name and "mpi-installer" in name and name.endswith((".zip", ".tar.gz")):
|
||||
linux_asset = asset
|
||||
self.logger.info(f"Found Linux asset: {asset.get('name')}")
|
||||
break
|
||||
@@ -185,39 +176,19 @@ class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find executable - support both old (ttw_linux_gui) and new (mpi_installer) names
|
||||
# Try old name first (since we're pinning to 0.0.7)
|
||||
exe_names = [TTW_INSTALLER_EXECUTABLE_NAME, "mpi_installer"]
|
||||
exe_path = None
|
||||
|
||||
for exe_name in exe_names:
|
||||
potential_path = target_dir / exe_name
|
||||
if potential_path.is_file():
|
||||
exe_path = potential_path
|
||||
self.logger.info(f"Found executable: {exe_name}")
|
||||
break
|
||||
# Search recursively
|
||||
for p in target_dir.rglob(exe_name):
|
||||
potential_path = target_dir / TTW_INSTALLER_EXECUTABLE_NAME
|
||||
if potential_path.is_file():
|
||||
exe_path = potential_path
|
||||
else:
|
||||
for p in target_dir.rglob(TTW_INSTALLER_EXECUTABLE_NAME):
|
||||
if p.is_file():
|
||||
exe_path = p
|
||||
self.logger.info(f"Found executable: {exe_name} at {p}")
|
||||
break
|
||||
if exe_path:
|
||||
break
|
||||
|
||||
if not exe_path or not exe_path.is_file():
|
||||
return False, f"TTW_Linux_Installer executable not found after extraction (searched for: {', '.join(exe_names)})"
|
||||
|
||||
# Remove any other executable versions to avoid confusion
|
||||
for exe_name in exe_names:
|
||||
if exe_name != exe_path.name:
|
||||
other_exe = target_dir / exe_name
|
||||
if other_exe.is_file():
|
||||
self.logger.info(f"Removing other version executable: {other_exe}")
|
||||
try:
|
||||
other_exe.unlink()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to remove {other_exe}: {e}")
|
||||
return False, f"TTW_Linux_Installer executable not found after extraction (searched for: {TTW_INSTALLER_EXECUTABLE_NAME})"
|
||||
self.logger.info(f"Found executable: {exe_path}")
|
||||
|
||||
# Set executable permissions
|
||||
try:
|
||||
@@ -259,16 +230,10 @@ class TTWInstallerHandler(TTWInstallerBackendMixin):
|
||||
# If we have a pinned version, compare against that instead of latest
|
||||
if TTW_INSTALLER_PINNED_VERSION:
|
||||
if not installed:
|
||||
# No version recorded - check if executable exists to infer version
|
||||
if self.ttw_installer_installed and self.ttw_installer_executable_path:
|
||||
exe_name = self.ttw_installer_executable_path.name
|
||||
# If pinned to 0.0.7 but found mpi_installer, it's wrong version
|
||||
if TTW_INSTALLER_PINNED_VERSION == "0.0.7" and exe_name == "mpi_installer":
|
||||
return (True, None, TTW_INSTALLER_PINNED_VERSION)
|
||||
# If pinned to 0.0.7 and found ttw_linux_gui, assume correct
|
||||
elif TTW_INSTALLER_PINNED_VERSION == "0.0.7" and exe_name == "ttw_linux_gui":
|
||||
return (False, None, TTW_INSTALLER_PINNED_VERSION)
|
||||
# Not installed - don't show as update available
|
||||
# Installed but no version on record (e.g. pre-upgrade install) - force a
|
||||
# re-download so the recorded version matches the pinned executable.
|
||||
if self.ttw_installer_installed:
|
||||
return (True, None, TTW_INSTALLER_PINNED_VERSION)
|
||||
return (False, None, TTW_INSTALLER_PINNED_VERSION)
|
||||
|
||||
# Compare against pinned version
|
||||
|
||||
@@ -58,9 +58,24 @@ class WinetricksHandler(
|
||||
native_wineprefix = env.get('WINEPREFIX', wineprefix)
|
||||
wine_binary = env.get('WINE', '')
|
||||
|
||||
# Check user preference for component installation method
|
||||
from ..handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
|
||||
# Get component installation method with migration
|
||||
method = config_handler.get('component_installation_method', 'native')
|
||||
|
||||
# Migrate bundled_protontricks to system_protontricks (no longer supported)
|
||||
if method == 'bundled_protontricks':
|
||||
self.logger.warning("Bundled protontricks no longer supported, migrating to system_protontricks")
|
||||
method = 'system_protontricks'
|
||||
config_handler.set('component_installation_method', 'system_protontricks')
|
||||
|
||||
# Native installer tier: direct-source downloads, no winetricks dependency.
|
||||
# Only runs in "native" mode - "winetricks" and "system_protontricks" are explicit
|
||||
# full-bypass modes, e.g. as an escape hatch if a native install regression ships.
|
||||
native = None
|
||||
if wine_binary:
|
||||
if method == 'native' and wine_binary:
|
||||
try:
|
||||
from .native_component_installer import NativeComponentInstaller
|
||||
native = NativeComponentInstaller(native_wineprefix, wine_binary, env, self.logger)
|
||||
@@ -110,19 +125,6 @@ class WinetricksHandler(
|
||||
components_to_install, wineprefix, game_var, status_callback, appid=appid
|
||||
)
|
||||
|
||||
# Check user preference for component installation method
|
||||
from ..handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
|
||||
# Get component installation method with migration
|
||||
method = config_handler.get('component_installation_method', 'winetricks')
|
||||
|
||||
# Migrate bundled_protontricks to system_protontricks (no longer supported)
|
||||
if method == 'bundled_protontricks':
|
||||
self.logger.warning("Bundled protontricks no longer supported, migrating to system_protontricks")
|
||||
method = 'system_protontricks'
|
||||
config_handler.set('component_installation_method', 'system_protontricks')
|
||||
|
||||
# Choose installation method based on user preference
|
||||
if method == 'system_protontricks':
|
||||
self.logger.info("=" * 80)
|
||||
|
||||
@@ -281,7 +281,19 @@ class PrefixCreationMixin:
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Proton timed out; prefix may still be initializing")
|
||||
logger.warning(
|
||||
f"Proton wineboot did not finish within {timeout}s; a cold Proton build can "
|
||||
"still be doing one-time setup in the background. Polling for the prefix "
|
||||
"to appear before giving up."
|
||||
)
|
||||
pfx = compat_dir / 'pfx'
|
||||
poll_deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < poll_deadline:
|
||||
if pfx.exists():
|
||||
logger.info(f"Proton prefix appeared during extended wait at: {pfx}")
|
||||
return True
|
||||
time.sleep(5)
|
||||
logger.warning(f"Proton prefix still not found at: {pfx} after extended wait")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating prefix: {e}")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
.NET 10 SDK and Desktop Runtime installation for Synthesis patcher compilation.
|
||||
|
||||
Split out of tool_config_service.py to keep that file under the project's
|
||||
line-count guardrail.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# .NET 10 SDK - ZIP distribution, extracted directly to avoid running an EXE under Wine.
|
||||
# Synthesis requires the SDK (not just runtime) for patcher compilation. A newer SDK can
|
||||
# still build projects targeting older TFMs (net6/7/8/9) as long as the matching runtime is
|
||||
# present - which the native/winetricks component pipeline already installs separately - but
|
||||
# an older SDK cannot target a newer TFM it doesn't recognize. Some Synthesis patchers target
|
||||
# net10.0, so the SDK itself must be 10, matching Fluorine's confirmed-working configuration.
|
||||
# ZIP distribution: Microsoft's officially supported xcopy-deployable install method, no
|
||||
# installer-side effects the CLI depends on. Confirmed sufficient for net10.0 patcher builds
|
||||
# by Styyx (2026-07-14).
|
||||
_DOTNET_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.301/dotnet-sdk-10.0.301-win-x64.zip"
|
||||
_DOTNET_SDK_FILENAME = "dotnet-sdk-10.0.301-win-x64.zip"
|
||||
|
||||
# .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2.
|
||||
# Covers Synthesis patchers targeting .NET 10 runtime. ZIP distribution, same rationale
|
||||
# as the SDK above - avoids running an EXE installer under Wine.
|
||||
_DOTNET10_DESKTOP_URL = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.zip"
|
||||
_DOTNET10_DESKTOP_FILENAME = "windowsdesktop-runtime-10.0.2-win-x64.zip"
|
||||
|
||||
|
||||
def install_dotnet_sdk(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Download and extract the .NET 10 SDK ZIP into the Wine prefix.
|
||||
Uses the standalone ZIP distribution to avoid running an EXE under Wine.
|
||||
Synthesis requires the full SDK (Roslyn compiler) for patcher compilation.
|
||||
"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
sdk_zip = cache_dir / _DOTNET_SDK_FILENAME
|
||||
|
||||
if not sdk_zip.exists():
|
||||
log(f"Downloading .NET 10 SDK ({_DOTNET_SDK_FILENAME})...")
|
||||
urllib.request.urlretrieve(_DOTNET_SDK_URL, sdk_zip)
|
||||
log(".NET 10 SDK downloaded")
|
||||
else:
|
||||
log(".NET 10 SDK already cached, skipping download")
|
||||
|
||||
dest = prefix_path / "drive_c" / "Program Files" / "dotnet"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
log("Extracting .NET 10 SDK...")
|
||||
with zipfile.ZipFile(sdk_zip) as zf:
|
||||
zf.extractall(dest)
|
||||
log(".NET 10 SDK extracted successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install .NET 10 SDK: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def find_winetricks_bin() -> Optional[str]:
|
||||
"""Locate the bundled winetricks binary, checking APPDIR for the AppImage case."""
|
||||
module_dir = Path(__file__).parent.parent.parent
|
||||
winetricks_bin = str(module_dir / "tools" / "winetricks")
|
||||
if not os.path.exists(winetricks_bin):
|
||||
appdir = os.environ.get("APPDIR", "")
|
||||
if appdir:
|
||||
winetricks_bin = os.path.join(appdir, "opt", "jackify", "tools", "winetricks")
|
||||
return winetricks_bin if os.path.exists(winetricks_bin) else None
|
||||
|
||||
|
||||
def install_dotnet10_desktop_runtime(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Download and extract the .NET Desktop Runtime 10 ZIP into the Wine prefix.
|
||||
Provides NETCore.App and WindowsDesktop.App 10.x for patchers targeting .NET 10.
|
||||
Falls back to the bundled winetricks dotnetdesktop10 verb if the ZIP install fails.
|
||||
"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
runtime_zip = cache_dir / _DOTNET10_DESKTOP_FILENAME
|
||||
|
||||
if not runtime_zip.exists():
|
||||
log(f"Downloading .NET Desktop Runtime 10 ({_DOTNET10_DESKTOP_FILENAME})...")
|
||||
urllib.request.urlretrieve(_DOTNET10_DESKTOP_URL, runtime_zip)
|
||||
log(".NET Desktop Runtime 10 downloaded")
|
||||
else:
|
||||
log(".NET Desktop Runtime 10 already cached, skipping download")
|
||||
|
||||
dest = prefix_path / "drive_c" / "Program Files" / "dotnet"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
log("Extracting .NET Desktop Runtime 10...")
|
||||
with zipfile.ZipFile(runtime_zip) as zf:
|
||||
zf.extractall(dest)
|
||||
log(".NET Desktop Runtime 10 extracted successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install .NET Desktop Runtime 10 via ZIP: {e}")
|
||||
return _install_dotnet10_desktop_runtime_winetricks(prefix_path, wine_bin, log)
|
||||
|
||||
|
||||
def _install_dotnet10_desktop_runtime_winetricks(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""Fallback: install the .NET Desktop Runtime 10 via the bundled winetricks verb."""
|
||||
winetricks_bin = find_winetricks_bin()
|
||||
if not winetricks_bin:
|
||||
log("Bundled winetricks not found - cannot fall back for .NET Desktop Runtime 10")
|
||||
return False
|
||||
|
||||
try:
|
||||
log("Falling back to winetricks dotnetdesktop10...")
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINE"] = wine_bin
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
|
||||
result = subprocess.run(
|
||||
[winetricks_bin, "-q", "dotnetdesktop10"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log(f"winetricks dotnetdesktop10 exited with code {result.returncode}")
|
||||
return False
|
||||
|
||||
log(".NET Desktop Runtime 10 installed via winetricks fallback")
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
log("winetricks dotnetdesktop10 timed out")
|
||||
return False
|
||||
except Exception as e:
|
||||
log(f"winetricks dotnetdesktop10 fallback failed: {e}")
|
||||
return False
|
||||
@@ -22,20 +22,21 @@ def _load_verifier():
|
||||
|
||||
|
||||
def resolve_pfx_for_appid(appid: str) -> Optional[Path]:
|
||||
"""Resolve the Proton prefix path for a Steam AppID."""
|
||||
"""Resolve the Proton prefix path for a Steam AppID.
|
||||
|
||||
Delegates to PathHandler.find_compat_data(), which scans all configured
|
||||
Steam library folders, not just the default Steam root - users with
|
||||
custom/secondary libraries (e.g. on other mounts) can have compatdata
|
||||
outside the default install location.
|
||||
"""
|
||||
if not appid:
|
||||
return None
|
||||
steam_roots = [
|
||||
Path.home() / ".steam" / "steam",
|
||||
Path.home() / ".local" / "share" / "Steam",
|
||||
Path.home() / ".steam" / "root",
|
||||
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam",
|
||||
]
|
||||
for root in steam_roots:
|
||||
pfx = root / "steamapps" / "compatdata" / str(appid) / "pfx"
|
||||
if pfx.is_dir():
|
||||
return pfx
|
||||
return None
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
compatdata = PathHandler.find_compat_data(str(appid))
|
||||
if compatdata is None:
|
||||
return None
|
||||
pfx = compatdata / "pfx"
|
||||
return pfx if pfx.is_dir() else None
|
||||
|
||||
|
||||
def run_install_verification(pfx: Path, modlist_dir: Path, game_type: str, appid: str = "", modlist_name: str = ""):
|
||||
|
||||
@@ -113,10 +113,10 @@ def _hex_reg_value(name: str, data: bytes) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_certs_reg_content(bundles: Tuple[Path, ...]) -> Tuple[str, int]:
|
||||
def _build_certs_reg_content(bundles: Tuple[Path, ...]) -> Tuple[str, frozenset]:
|
||||
"""
|
||||
Build a .reg file adding every cert in the given PEM bundles to the Wine
|
||||
prefix's Root store. Returns (reg_content, unique_cert_count). Re-importing
|
||||
prefix's Root store. Returns (reg_content, unique_thumbprints). Re-importing
|
||||
is idempotent - regedit overwrites identical keys in place.
|
||||
"""
|
||||
seen = set()
|
||||
@@ -131,13 +131,36 @@ def _build_certs_reg_content(bundles: Tuple[Path, ...]) -> Tuple[str, int]:
|
||||
parts.append(f"[{_ROOT_STORE_KEY}\\{thumbprint}]")
|
||||
parts.append(_hex_reg_value("Blob", _cert_registry_blob(der)))
|
||||
parts.append("")
|
||||
return "\n".join(parts), len(seen)
|
||||
return "\n".join(parts), frozenset(seen)
|
||||
|
||||
|
||||
def _missing_thumbprints(prefix_path: Path, thumbprints: frozenset) -> frozenset:
|
||||
"""
|
||||
Check which of the given thumbprints are actually present in the prefix's
|
||||
on-disk system.reg. regedit's exit code alone is not reliable here - large
|
||||
batch imports (~400+ keys) have been observed to silently drop individual
|
||||
entries while exiting 0, with no indication in stdout/stderr.
|
||||
|
||||
system.reg stores hive-relative paths (no HKEY_LOCAL_MACHINE prefix) with
|
||||
each backslash doubled, unlike the .reg file syntax used to write them.
|
||||
"""
|
||||
system_reg = prefix_path / "system.reg"
|
||||
if not system_reg.exists():
|
||||
return thumbprints
|
||||
|
||||
text = system_reg.read_text(encoding="utf-8", errors="replace")
|
||||
key_path = _ROOT_STORE_KEY.split("\\", 1)[1]
|
||||
file_literal = key_path.replace("\\", "\\\\")
|
||||
pattern = re.compile(rf"\[{re.escape(file_literal)}\\\\([0-9A-Fa-f]+)\]")
|
||||
present = set(pattern.findall(text))
|
||||
return thumbprints - present
|
||||
|
||||
|
||||
def install_nuget_cert(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
max_attempts: int = 3,
|
||||
) -> bool:
|
||||
"""
|
||||
Import the .NET SDK's bundled code-signing and timestamp trusted-root
|
||||
@@ -152,6 +175,14 @@ def install_nuget_cert(
|
||||
exits (confirmed on CachyOS + GE-Proton10-14: store reported the add,
|
||||
cross-process reg query and the on-disk .reg files never saw it).
|
||||
|
||||
regedit's own exit code is also not trustworthy on a batch this size
|
||||
(~400 keys): it has been observed to exit 0 while silently dropping a
|
||||
single entry, with no trace in stdout/stderr. Confirmed root cause of a
|
||||
long-standing, hard-to-reproduce Synthesis NuGet failure - the one dropped
|
||||
cert was the VeriSign Universal Root, the exact timestamp-chain root
|
||||
NU3028 checks for. So every import is verified against the on-disk
|
||||
registry afterward and retried on a partial result.
|
||||
|
||||
Requires the .NET SDK already installed in the prefix - the PEM bundles
|
||||
ship inside the SDK and are the exact trust anchors NuGet itself uses for
|
||||
package and timestamp signature validation on Linux.
|
||||
@@ -161,23 +192,30 @@ def install_nuget_cert(
|
||||
log(".NET SDK trusted root bundles not found - skipping NuGet certificate import")
|
||||
return False
|
||||
|
||||
try:
|
||||
reg_content, cert_count = _build_certs_reg_content(trusted_roots)
|
||||
reg_content, thumbprints = _build_certs_reg_content(trusted_roots)
|
||||
cert_count = len(thumbprints)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
wineserver_bin = os.path.join(os.path.dirname(wine_bin), "wineserver")
|
||||
|
||||
reg_file = None
|
||||
missing = thumbprints
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".reg", delete=False, encoding="utf-8"
|
||||
) as tf:
|
||||
tf.write(reg_content)
|
||||
reg_file = tf.name
|
||||
|
||||
log(f"Importing {cert_count} code-signing and timestamp roots into Wine cert store...")
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
|
||||
try:
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
log(
|
||||
f"Importing {cert_count} code-signing and timestamp roots into "
|
||||
f"Wine cert store (attempt {attempt}/{max_attempts})..."
|
||||
)
|
||||
result = subprocess.run(
|
||||
[wine_bin, "regedit", reg_file],
|
||||
env=env,
|
||||
@@ -185,38 +223,47 @@ def install_nuget_cert(
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(reg_file)
|
||||
except Exception:
|
||||
pass
|
||||
if result.returncode != 0:
|
||||
log(f"Certificate registry import exited with code {result.returncode}")
|
||||
log(f"stderr: {result.stderr[:500]}")
|
||||
continue
|
||||
|
||||
if result.returncode != 0:
|
||||
log(f"Certificate registry import exited with code {result.returncode}")
|
||||
log(f"stderr: {result.stderr[:500]}")
|
||||
return False
|
||||
# wineserver batches registry writes in memory and flushes to the
|
||||
# on-disk .reg files lazily. "wineserver -w" blocks until wineserver
|
||||
# exits, forcing the flush - same pattern used elsewhere for registry
|
||||
# writes (see modlist_wine_ops.py).
|
||||
if os.path.exists(wineserver_bin):
|
||||
try:
|
||||
subprocess.run(
|
||||
[wineserver_bin, "-w"], env=env, timeout=60, capture_output=True,
|
||||
)
|
||||
except Exception as e:
|
||||
log(f"wineserver flush failed (non-fatal): {e}")
|
||||
else:
|
||||
log(f"wineserver not found at {wineserver_bin}; registry flush may not persist")
|
||||
|
||||
# wineserver batches registry writes in memory and flushes to the
|
||||
# on-disk .reg files lazily. "wineserver -w" blocks until wineserver
|
||||
# exits, forcing the flush - same pattern used elsewhere for registry
|
||||
# writes (see modlist_wine_ops.py).
|
||||
wineserver_bin = os.path.join(os.path.dirname(wine_bin), "wineserver")
|
||||
if os.path.exists(wineserver_bin):
|
||||
try:
|
||||
subprocess.run(
|
||||
[wineserver_bin, "-w"], env=env, timeout=60, capture_output=True,
|
||||
)
|
||||
except Exception as e:
|
||||
log(f"wineserver flush failed (non-fatal): {e}")
|
||||
else:
|
||||
log(f"wineserver not found at {wineserver_bin}; registry flush may not persist")
|
||||
missing = _missing_thumbprints(prefix_path, thumbprints)
|
||||
if not missing:
|
||||
log(f"Imported {cert_count} certificates into Wine cert store")
|
||||
return True
|
||||
|
||||
log(f"Imported {cert_count} certificates into Wine cert store")
|
||||
return True
|
||||
log(f"{len(missing)} of {cert_count} certificates did not persist - retrying import")
|
||||
|
||||
log(
|
||||
f"Certificate import incomplete after {max_attempts} attempts - "
|
||||
f"{len(missing)} of {cert_count} certificates still missing"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install NuGet certificates: {e}")
|
||||
return False
|
||||
finally:
|
||||
if reg_file:
|
||||
try:
|
||||
os.unlink(reg_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def configure_nuget_signature_policy(
|
||||
@@ -231,17 +278,26 @@ def configure_nuget_signature_policy(
|
||||
|
||||
Steam/Proton prefixes always use "steamuser" as the Windows user.
|
||||
|
||||
Must run before the first dotnet invocation in the prefix: the SDK
|
||||
auto-generates a bare-bones NuGet.Config (no trust policy) as a side
|
||||
effect of any restore if none exists yet, and this function only writes
|
||||
when the file is absent.
|
||||
Ideally runs before the first dotnet invocation in the prefix, since the
|
||||
SDK auto-generates a bare-bones NuGet.Config (no trust policy) as a side
|
||||
effect of any restore if none exists yet. That stub can also predate this
|
||||
fix (an older Jackify version, or a manual restore run in the prefix
|
||||
before configuration), so an existing file is only left alone if it
|
||||
already carries our trust policy - otherwise it's replaced.
|
||||
"""
|
||||
config_dir = prefix_path / "drive_c" / "users" / "steamuser" / "AppData" / "Roaming" / "NuGet"
|
||||
config_path = config_dir / "NuGet.Config"
|
||||
|
||||
if config_path.exists():
|
||||
log("NuGet.Config already exists - leaving it unchanged")
|
||||
return True
|
||||
try:
|
||||
existing = config_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
log(f"Failed to read existing NuGet.Config: {e}")
|
||||
existing = ""
|
||||
if "trustedSigners" in existing:
|
||||
log("NuGet.Config already has a trust policy - leaving it unchanged")
|
||||
return True
|
||||
log("NuGet.Config exists without a trust policy (SDK-generated stub) - replacing it")
|
||||
|
||||
try:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Synthesis patcher version bump for modlists that bundle it.
|
||||
|
||||
Bundled Synthesis builds as old as 0.29.2 and as recent as 0.35.3 crash listing
|
||||
patchers whenever the live Mutagen-Modding/Synthesis.Registry listing contains a
|
||||
GameRelease enum member the bundled Mutagen.Bethesda assembly doesn't recognize
|
||||
(confirmed 2026-07-17: the registry added "EnderalSEGog" on 2026-07-14, breaking
|
||||
every Synthesis build up to and including 0.35.3, on Linux and Windows alike).
|
||||
0.36.5 ships the mutagen bump that fixes the parser. Since Synthesis isn't
|
||||
installed by Jackify at all - it comes entirely from whatever the modlist's own
|
||||
MO2 download bundled - the fix is to overwrite it in place with a known-good
|
||||
release, only for modlists that actually include it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TARGET_VERSION = "0.36.5"
|
||||
_SYNTHESIS_ZIP_URL = (
|
||||
f"https://github.com/Mutagen-Modding/Synthesis/releases/download/{_TARGET_VERSION}/Synthesis.zip"
|
||||
)
|
||||
_VERSION_MARKER_FILENAME = ".jackify_synthesis_version"
|
||||
_GUI_SETTINGS_FILENAME = "GuiSettings.json"
|
||||
_PIPELINE_SETTINGS_FILENAME = "PipelineSettings.json"
|
||||
|
||||
|
||||
def _resolve_mo2_binary_path(raw_value: str) -> Optional[Path]:
|
||||
"""Convert a ModOrganizer.ini `\\binary` value (e.g. `Z:/home/deck/.../Synthesis.exe`
|
||||
or `D:\\\\home\\\\deck\\\\...\\\\Synthesis.exe`) into a real Linux Path."""
|
||||
m = re.match(r"(?i)^[a-z]:[\\/](.+)$", raw_value.strip())
|
||||
if not m:
|
||||
return None
|
||||
linux_path = m.group(1).replace("\\\\", "/").replace("\\", "/")
|
||||
return Path("/" + linux_path)
|
||||
|
||||
|
||||
def find_synthesis_dir(modlist_dir: str) -> Optional[Path]:
|
||||
"""
|
||||
Find Synthesis's install directory for a modlist by reading its
|
||||
`[customExecutables]` entry in ModOrganizer.ini, the same way
|
||||
setup_nemesis_compatibility locates Nemesis. Returns None if the
|
||||
modlist doesn't include Synthesis.
|
||||
"""
|
||||
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = mo2_ini.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Synthesis detection: could not read ModOrganizer.ini: %s", e)
|
||||
return None
|
||||
|
||||
# jackify-engine's fresh Wabbajack extraction pads "key = value"; MO2's own
|
||||
# resave (after the user opens it once) tightens it to "key=value" - both
|
||||
# forms occur in the wild, so whitespace around '=' must be tolerated.
|
||||
match = re.search(
|
||||
r'^\d+\\binary\s*=\s*(.*Synthesis\.exe)\s*$',
|
||||
content,
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
binary_path = _resolve_mo2_binary_path(match.group(1))
|
||||
if binary_path is None or not binary_path.is_file():
|
||||
return None
|
||||
|
||||
return binary_path.parent
|
||||
|
||||
|
||||
def _resolve_game_data_path(modlist_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Read MO2's own `gamePath` out of ModOrganizer.ini and return it as a
|
||||
Windows-style `<gamePath>\\Data` string, for use as Synthesis's
|
||||
DataPathOverride. gamePath is whatever Jackify/CLF3 already resolved as
|
||||
this modlist's managed game directory (StockGame, Game Root, a vanilla
|
||||
Steam/GOG/Epic install, etc.) - no separate detection needed here.
|
||||
"""
|
||||
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = mo2_ini.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Synthesis DataPathOverride: could not read ModOrganizer.ini: %s", e)
|
||||
return None
|
||||
|
||||
match = re.search(
|
||||
r'^gamePath\s*=\s*@ByteArray\((.*)\)\s*$',
|
||||
content,
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# MO2 doubles every backslash (Qt escaping); some writers double that
|
||||
# again. Collapse any run of backslashes down to one, then append Data.
|
||||
game_path = re.sub(r"\\+", r"\\", match.group(1).strip())
|
||||
if not game_path:
|
||||
return None
|
||||
return f"{game_path}\\Data"
|
||||
|
||||
|
||||
def _set_data_path_override(synthesis_dir: Path, modlist_dir: str, log: Callable[[str], None]) -> None:
|
||||
"""
|
||||
Merge-patch each profile's DataPathOverride to point at this modlist's
|
||||
actual game data folder, if it isn't already correct. Bundled Synthesis
|
||||
installs often ship with this null, which leaves the field blank in
|
||||
Synthesis's own UI until the user fills it in manually.
|
||||
"""
|
||||
expected = _resolve_game_data_path(modlist_dir)
|
||||
if not expected:
|
||||
return
|
||||
|
||||
pipeline_settings_path = synthesis_dir / _PIPELINE_SETTINGS_FILENAME
|
||||
try:
|
||||
pipeline_settings = json.loads(pipeline_settings_path.read_text(encoding="utf-8")) if pipeline_settings_path.is_file() else {"Version": 2, "Profiles": []}
|
||||
changed = False
|
||||
for profile in pipeline_settings.get("Profiles", []):
|
||||
if profile.get("DataPathOverride") != expected:
|
||||
profile["DataPathOverride"] = expected
|
||||
changed = True
|
||||
if changed:
|
||||
pipeline_settings_path.write_text(json.dumps(pipeline_settings, indent=4), encoding="utf-8")
|
||||
log(f"Synthesis update: set DataPathOverride to {expected}")
|
||||
except Exception as e:
|
||||
log(f"Synthesis update: could not set DataPathOverride (non-fatal): {e}")
|
||||
|
||||
|
||||
def get_installed_version(synthesis_dir: Path) -> Optional[str]:
|
||||
"""Read the version Jackify last wrote to this Synthesis install, if any."""
|
||||
marker = synthesis_dir / _VERSION_MARKER_FILENAME
|
||||
if not marker.is_file():
|
||||
return None
|
||||
try:
|
||||
return marker.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _disable_mo2_mode(synthesis_dir: Path, log: Callable[[str], None]) -> None:
|
||||
"""
|
||||
Merge-patch Synthesis's own settings files to default MO2 Mode off and
|
||||
suppress its first-run prompt, without disturbing anything else - these
|
||||
files ship pre-populated inside the Wabbajack archive with the modlist
|
||||
author's own curated patcher list, which must survive untouched.
|
||||
|
||||
MO2 Mode blocks patcher building while running inside MO2's VFS (which
|
||||
can't reliably handle multithreaded dotnet build I/O), requiring a
|
||||
build-then-run two-phase workflow with no equivalent to Windows'
|
||||
"run outside MO2 once" on Linux/Proton. Confirmed via a side-by-side
|
||||
diff of two real installs (2026-07-17): the only fields that differ
|
||||
between an MO2-Mode-enabled and MO2-Mode-disabled install are
|
||||
BlockBuildingWithinMo2 (PipelineSettings.json) and HasSeenMo2Prompt
|
||||
(GuiSettings.json, which only suppresses the dialog and is set to true
|
||||
either way once a user has seen it once).
|
||||
"""
|
||||
gui_settings_path = synthesis_dir / _GUI_SETTINGS_FILENAME
|
||||
try:
|
||||
gui_settings = json.loads(gui_settings_path.read_text(encoding="utf-8")) if gui_settings_path.is_file() else {"Version": 2}
|
||||
gui_settings["HasSeenMo2Prompt"] = True
|
||||
gui_settings_path.write_text(json.dumps(gui_settings, indent=4), encoding="utf-8")
|
||||
except Exception as e:
|
||||
log(f"Synthesis update: could not set HasSeenMo2Prompt (non-fatal): {e}")
|
||||
|
||||
pipeline_settings_path = synthesis_dir / _PIPELINE_SETTINGS_FILENAME
|
||||
try:
|
||||
pipeline_settings = json.loads(pipeline_settings_path.read_text(encoding="utf-8")) if pipeline_settings_path.is_file() else {"Version": 2, "Profiles": []}
|
||||
pipeline_settings["BlockBuildingWithinMo2"] = False
|
||||
pipeline_settings_path.write_text(json.dumps(pipeline_settings, indent=4), encoding="utf-8")
|
||||
except Exception as e:
|
||||
log(f"Synthesis update: could not set BlockBuildingWithinMo2 (non-fatal): {e}")
|
||||
|
||||
log("Synthesis update: MO2 Mode defaulted off (no Linux equivalent to launching outside MO2)")
|
||||
|
||||
|
||||
def update_synthesis(
|
||||
modlist_dir: str,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Overwrite a modlist's bundled Synthesis with the target version if it's
|
||||
older, or if Jackify hasn't touched it before. Non-fatal - logs failures
|
||||
but does not raise.
|
||||
"""
|
||||
def _log(msg: str):
|
||||
logger.info(msg)
|
||||
if log:
|
||||
log(msg)
|
||||
|
||||
synthesis_dir = find_synthesis_dir(modlist_dir)
|
||||
if synthesis_dir is None:
|
||||
_log("Synthesis update: no Synthesis executable entry found, skipping")
|
||||
return False
|
||||
|
||||
_set_data_path_override(synthesis_dir, modlist_dir, _log)
|
||||
|
||||
if get_installed_version(synthesis_dir) == _TARGET_VERSION:
|
||||
_log(f"Synthesis update: already at {_TARGET_VERSION}, skipping")
|
||||
return True
|
||||
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = cache_dir / f"Synthesis-{_TARGET_VERSION}.zip"
|
||||
|
||||
if not zip_path.exists():
|
||||
_log(f"Downloading Synthesis {_TARGET_VERSION}...")
|
||||
urllib.request.urlretrieve(_SYNTHESIS_ZIP_URL, zip_path)
|
||||
_log("Synthesis downloaded")
|
||||
else:
|
||||
_log("Synthesis already cached, skipping download")
|
||||
|
||||
_log(f"Updating Synthesis to {_TARGET_VERSION} in {synthesis_dir}...")
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(synthesis_dir)
|
||||
|
||||
_disable_mo2_mode(synthesis_dir, _log)
|
||||
|
||||
(synthesis_dir / _VERSION_MARKER_FILENAME).write_text(_TARGET_VERSION, encoding="utf-8")
|
||||
_log(f"Synthesis updated to {_TARGET_VERSION}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_log(f"Synthesis update failed (non-fatal): {e}")
|
||||
return False
|
||||
@@ -14,10 +14,14 @@ import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from jackify.backend.services.dotnet10_installer import (
|
||||
find_winetricks_bin,
|
||||
install_dotnet10_desktop_runtime as _install_dotnet10_desktop_runtime,
|
||||
install_dotnet_sdk as _install_dotnet_sdk,
|
||||
)
|
||||
from jackify.backend.services.nuget_signature_service import (
|
||||
configure_nuget_signature_policy,
|
||||
install_nuget_cert,
|
||||
@@ -60,7 +64,7 @@ _DLL_OVERRIDES = [
|
||||
]
|
||||
|
||||
|
||||
def _build_reg_content() -> str:
|
||||
def _build_reg_content(apply_engine_mscoree: bool = True, install_dotnet_sdk: bool = False) -> str:
|
||||
lines = ["Windows Registry Editor Version 5.00", ""]
|
||||
|
||||
# xEdit WinXP compatibility
|
||||
@@ -76,10 +80,13 @@ def _build_reg_content() -> str:
|
||||
|
||||
# Skyrim SE / SKSE game process needs native mscoree to load dotnet4 correctly.
|
||||
# Scoped to SkyrimSE.exe only so it does not interfere with .NET 9/10 tools
|
||||
# (Synthesis, SDK host) that run in the same prefix.
|
||||
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides]")
|
||||
lines.append('"*mscoree"="native"')
|
||||
lines.append("")
|
||||
# (Synthesis, SDK host) that run in the same prefix. Enderal SE also runs as
|
||||
# SkyrimSE.exe under the hood, so this entry must be skipped there - it would
|
||||
# apply to Enderal's own game process and crash it (apply_engine_mscoree=False).
|
||||
if apply_engine_mscoree:
|
||||
lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides]")
|
||||
lines.append('"*mscoree"="native"')
|
||||
lines.append("")
|
||||
|
||||
# Prevent Wine windows from stealing keyboard focus via WM_TAKE_FOCUS.
|
||||
# Without this, each Wine subprocess launched during winetricks installs
|
||||
@@ -103,6 +110,13 @@ def _build_reg_content() -> str:
|
||||
# the registry equivalent of setx, so every process in the prefix (including
|
||||
# Synthesis's internal dotnet build calls) inherits these automatically.
|
||||
#
|
||||
# MSBUILDDISABLENODEREUSE alone only stops node reuse - it does not cap how
|
||||
# many patcher builds Synthesis fires off at once, which on large lists
|
||||
# (Tuxborn-sized) spawned ~150 concurrent dotnet.exe processes and OOM'd the
|
||||
# host. DOTNET_PROCESSOR_COUNT makes the CLR report 8 logical processors,
|
||||
# which caps Parallel.ForEach/Task-based concurrency that defaults to
|
||||
# Environment.ProcessorCount (including Synthesis's own patcher scheduler).
|
||||
#
|
||||
# The two NUGET_* entries are needed for NuGet package signature validation
|
||||
# (Synthesis fails to compile patchers without it): offline revocation mode
|
||||
# avoids online CRL/OCSP checks that routinely fail or time out under Wine's
|
||||
@@ -115,24 +129,28 @@ def _build_reg_content() -> str:
|
||||
lines.append("[HKEY_CURRENT_USER\\Environment]")
|
||||
lines.append('"UseSharedCompilation"="false"')
|
||||
lines.append('"MSBUILDDISABLENODEREUSE"="1"')
|
||||
lines.append('"DOTNET_PROCESSOR_COUNT"="8"')
|
||||
lines.append('"NUGET_CERT_REVOCATION_MODE"="offline"')
|
||||
lines.append('"NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY"="10,1000"')
|
||||
|
||||
# The dotnet SDK/runtime are ZIP-extracted, not installed via the real EXE
|
||||
# installer, which normally adds Program Files\dotnet to the system Path.
|
||||
# Without it, anything that shells out to a bare "dotnet" command (Synthesis
|
||||
# calls `dotnet --info` on startup via Process.Start) fails with
|
||||
# Win32Exception "File not found" even though the SDK is present on disk.
|
||||
# Standard Windows system directories are kept alongside it so nothing else
|
||||
# that relies on PATH lookup regresses.
|
||||
if install_dotnet_sdk:
|
||||
lines.append(
|
||||
'"Path"="C:\\\\Program Files\\\\dotnet;C:\\\\windows\\\\system32;C:\\\\windows;'
|
||||
'C:\\\\windows\\\\System32\\\\Wbem;C:\\\\windows\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\"'
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
# .NET 9 SDK - ZIP distribution, extracted directly to avoid running an EXE under Wine.
|
||||
# Synthesis requires the SDK (not just runtime) for patcher compilation.
|
||||
# Versions match Fluorine's confirmed-working prefix configuration.
|
||||
_DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.zip"
|
||||
_DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.zip"
|
||||
|
||||
# .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2.
|
||||
# Covers Synthesis patchers targeting .NET 10 runtime.
|
||||
_DOTNET10_DESKTOP_URL = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe"
|
||||
_DOTNET10_DESKTOP_FILENAME = "windowsdesktop-runtime-10.0.2-win-x64.exe"
|
||||
|
||||
# fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation.
|
||||
# The winetricks-provided d3dcompiler_47 lacks support for certain shader models
|
||||
# used by Community Shaders, causing "failed shaders" during compilation.
|
||||
@@ -140,92 +158,6 @@ _FXC2_D3DCOMPILER_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompi
|
||||
_FXC2_D3DCOMPILER_FILENAME = "fxc2_d3dcompiler_47.dll"
|
||||
|
||||
|
||||
def _install_dotnet9_sdk(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Download and extract the .NET 9 SDK ZIP into the Wine prefix.
|
||||
Uses the standalone ZIP distribution to avoid running an EXE under Wine.
|
||||
Synthesis requires the full SDK (Roslyn compiler) for patcher compilation.
|
||||
"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
sdk_zip = cache_dir / _DOTNET9_SDK_FILENAME
|
||||
|
||||
if not sdk_zip.exists():
|
||||
log(f"Downloading .NET 9 SDK ({_DOTNET9_SDK_FILENAME})...")
|
||||
urllib.request.urlretrieve(_DOTNET9_SDK_URL, sdk_zip)
|
||||
log(".NET 9 SDK downloaded")
|
||||
else:
|
||||
log(".NET 9 SDK already cached, skipping download")
|
||||
|
||||
dest = prefix_path / "drive_c" / "Program Files" / "dotnet"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
log("Extracting .NET 9 SDK...")
|
||||
with zipfile.ZipFile(sdk_zip) as zf:
|
||||
zf.extractall(dest)
|
||||
log(".NET 9 SDK extracted successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install .NET 9 SDK: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def _install_dotnet10_desktop_runtime(
|
||||
prefix_path: Path,
|
||||
wine_bin: str,
|
||||
log: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""
|
||||
Download and install the .NET Desktop Runtime 10 into the Wine prefix.
|
||||
Provides NETCore.App and WindowsDesktop.App 10.x for patchers targeting .NET 10.
|
||||
"""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
cache_dir = get_jackify_data_dir() / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
installer = cache_dir / _DOTNET10_DESKTOP_FILENAME
|
||||
|
||||
if not installer.exists():
|
||||
log(f"Downloading .NET Desktop Runtime 10 ({_DOTNET10_DESKTOP_FILENAME})...")
|
||||
urllib.request.urlretrieve(_DOTNET10_DESKTOP_URL, installer)
|
||||
log(".NET Desktop Runtime 10 downloaded")
|
||||
else:
|
||||
log(".NET Desktop Runtime 10 already cached, skipping download")
|
||||
|
||||
log("Installing .NET Desktop Runtime 10...")
|
||||
env = os.environ.copy()
|
||||
env["WINEPREFIX"] = str(prefix_path)
|
||||
env["WINEDEBUG"] = "-all"
|
||||
env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d"
|
||||
env["DISPLAY"] = env.get("DISPLAY", ":0")
|
||||
|
||||
result = subprocess.run(
|
||||
[wine_bin, str(installer), "/install", "/quiet", "/norestart"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode not in (0, 3010):
|
||||
log(f".NET Desktop Runtime 10 installer exited with code {result.returncode}")
|
||||
return False
|
||||
|
||||
log(".NET Desktop Runtime 10 installed successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log(f"Failed to install .NET Desktop Runtime 10: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _install_fxc2_d3dcompiler(
|
||||
prefix_path: Path,
|
||||
log: Callable[[str], None],
|
||||
@@ -276,14 +208,8 @@ def _set_windows_version_win11(
|
||||
correctly. winetricks components may leave the prefix at a lower version.
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
module_dir = _Path(__file__).parent.parent.parent
|
||||
winetricks_bin = str(module_dir / "tools" / "winetricks")
|
||||
if not os.path.exists(winetricks_bin):
|
||||
appdir = os.environ.get("APPDIR", "")
|
||||
if appdir:
|
||||
winetricks_bin = os.path.join(appdir, "opt", "jackify", "tools", "winetricks")
|
||||
if not os.path.exists(winetricks_bin):
|
||||
winetricks_bin = find_winetricks_bin()
|
||||
if not winetricks_bin:
|
||||
log("Bundled winetricks not found - skipping Windows version update")
|
||||
return
|
||||
|
||||
@@ -321,14 +247,15 @@ def apply_tool_config(
|
||||
compatdata_path: str,
|
||||
wine_bin: str,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
install_dotnet9_sdk: bool = False,
|
||||
install_dotnet_sdk: bool = False,
|
||||
install_fxc2_d3dcompiler: bool = False,
|
||||
preserve_global_mscoree: bool = False,
|
||||
apply_engine_mscoree: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Apply tool compatibility settings to the Wine prefix.
|
||||
|
||||
install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK and flips the
|
||||
install_dotnet_sdk=True downloads and installs the .NET 10 SDK and flips the
|
||||
prefix to Windows 11, which are required for Synthesis. Intentionally opt-in -
|
||||
the download is ~220MB and the win11 flip has not been verified against NSF/CSF
|
||||
prefixes (see preserve_global_mscoree).
|
||||
@@ -339,6 +266,11 @@ def apply_tool_config(
|
||||
install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla
|
||||
fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders.
|
||||
|
||||
apply_engine_mscoree=False skips the SkyrimSE.exe-scoped native mscoree
|
||||
AppDefaults entry while still applying everything else (xEdit, Pandora, DLL
|
||||
overrides, dotnet SDK/NuGet). Set False for Enderal SE, whose own game process
|
||||
is also SkyrimSE.exe - the entry would apply to Enderal itself and crash it.
|
||||
|
||||
Returns True if registry settings applied successfully (dotnet SDK install
|
||||
failures are non-fatal since the registry settings still have value).
|
||||
"""
|
||||
@@ -355,8 +287,8 @@ def apply_tool_config(
|
||||
if install_fxc2_d3dcompiler:
|
||||
_install_fxc2_d3dcompiler(prefix_path, _log)
|
||||
|
||||
if install_dotnet9_sdk:
|
||||
_install_dotnet9_sdk(prefix_path, wine_bin, _log)
|
||||
if install_dotnet_sdk:
|
||||
_install_dotnet_sdk(prefix_path, wine_bin, _log)
|
||||
_install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log)
|
||||
_set_windows_version_win11(prefix_path, wine_bin, _log)
|
||||
|
||||
@@ -390,7 +322,10 @@ def apply_tool_config(
|
||||
# "dotnet run" can hang on VBCSCompiler's named pipe and leave an orphaned
|
||||
# dotnet.exe/winedevice.exe spinning at high CPU indefinitely (confirmed
|
||||
# reproducible during the original cert-import ordering bug).
|
||||
reg_content = _build_reg_content()
|
||||
reg_content = _build_reg_content(
|
||||
apply_engine_mscoree=apply_engine_mscoree,
|
||||
install_dotnet_sdk=install_dotnet_sdk,
|
||||
)
|
||||
regedit_ok = False
|
||||
|
||||
try:
|
||||
@@ -430,16 +365,14 @@ def apply_tool_config(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Must run before the first dotnet invocation in this prefix: the SDK
|
||||
# auto-generates a bare-bones NuGet.Config (nuget.org source only, no trust
|
||||
# policy) as a side effect of any restore if none exists yet, and
|
||||
# configure_nuget_signature_policy skips writing when the file already
|
||||
# exists - so a stub landing first means our trust policy never applies.
|
||||
# configure_nuget_signature_policy replaces any existing NuGet.Config that
|
||||
# lacks our trust policy (e.g. an SDK-generated stub from a restore that
|
||||
# ran before this step, possibly under an older Jackify version).
|
||||
configure_nuget_signature_policy(prefix_path, _log)
|
||||
|
||||
# NuGet cert import requires the .NET SDK already present (the trusted-root
|
||||
# PEM bundles ship inside the SDK). On NSF/CSF prefixes
|
||||
# (install_dotnet9_sdk=False) the SDK is not installed, so this is skipped
|
||||
# (install_dotnet_sdk=False) the SDK is not installed, so this is skipped
|
||||
# there - those modlists don't run Synthesis in the same prefix.
|
||||
install_nuget_cert(prefix_path, wine_bin, _log)
|
||||
|
||||
@@ -489,6 +422,14 @@ def setup_nemesis_compatibility(
|
||||
|
||||
if nemesis_engine_src is None:
|
||||
_log("Nemesis setup: Nemesis_Engine not found in mods - modlist may not include Nemesis")
|
||||
if stock_game_path:
|
||||
stale_symlink = Path(stock_game_path) / "Data" / "Nemesis_Engine"
|
||||
if stale_symlink.is_symlink():
|
||||
try:
|
||||
stale_symlink.unlink()
|
||||
_log(f"Nemesis setup: removed stale symlink at {stale_symlink} (source mod no longer present)")
|
||||
except Exception as e:
|
||||
_log(f"Nemesis setup: failed to remove stale symlink at {stale_symlink}: {e}")
|
||||
return
|
||||
|
||||
# Create symlink in Data/ so Nemesis can find its engine at a predictable path
|
||||
@@ -564,7 +505,7 @@ def setup_nemesis_compatibility(
|
||||
def apply_tool_config_for_appid(
|
||||
appid: str,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
install_dotnet9_sdk: bool = True,
|
||||
install_dotnet_sdk: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Resolve compatdata path and wine binary from an AppID, then apply tool config.
|
||||
@@ -575,6 +516,20 @@ def apply_tool_config_for_appid(
|
||||
if log:
|
||||
log(msg)
|
||||
|
||||
apply_engine_mscoree = True
|
||||
modlist_dir: Optional[str] = None
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
||||
handler = ModlistHandler()
|
||||
for shortcut in handler.discover_executable_shortcuts("ModOrganizer.exe"):
|
||||
if str(shortcut.get("appid", "")) == str(appid):
|
||||
modlist_dir = shortcut.get("path") or None
|
||||
if handler.detect_special_game_type(shortcut.get("path", "")) == "enderal":
|
||||
apply_engine_mscoree = False
|
||||
break
|
||||
except Exception as e:
|
||||
_log(f"Could not determine game type for AppID {appid}, applying default tool config: {e}")
|
||||
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils_proton import WineUtilsProtonMixin
|
||||
compatdata_path, _, wine_bin = WineUtilsProtonMixin.get_proton_paths(appid)
|
||||
@@ -586,4 +541,15 @@ def apply_tool_config_for_appid(
|
||||
_log(f"Could not resolve Wine prefix for AppID {appid}. Is this modlist configured in Steam?")
|
||||
return False
|
||||
|
||||
return apply_tool_config(compatdata_path, wine_bin, log, install_dotnet9_sdk=install_dotnet9_sdk, install_fxc2_d3dcompiler=True)
|
||||
result = apply_tool_config(
|
||||
compatdata_path, wine_bin, log,
|
||||
install_dotnet_sdk=install_dotnet_sdk,
|
||||
install_fxc2_d3dcompiler=True,
|
||||
apply_engine_mscoree=apply_engine_mscoree,
|
||||
)
|
||||
|
||||
if modlist_dir:
|
||||
from jackify.backend.services.synthesis_updater import update_synthesis
|
||||
update_synthesis(modlist_dir, log=log)
|
||||
|
||||
return result
|
||||
|
||||
@@ -102,12 +102,12 @@ TOOL_DEFINITIONS: List[ToolDefinition] = [
|
||||
display_name="TTW Linux Installer",
|
||||
description="Automates Tale of Two Wastelands installation on Linux. Required for the TTW workflow.",
|
||||
github_repo="SulfurNitride/TTW_Linux_Installer",
|
||||
asset_patterns=[r"universal-mpi-installer.*\.(zip|tar\.gz)"],
|
||||
executable_names=["mpi_installer", "ttw_linux_gui"],
|
||||
asset_patterns=[r"mpi-installer-linux.*\.(zip|tar\.gz)"],
|
||||
executable_names=["mpi_installer"],
|
||||
tier=1,
|
||||
can_uninstall=True,
|
||||
can_launch=True,
|
||||
pinned_version="0.0.7", # must match TTW_INSTALLER_PINNED_VERSION in ttw_installer_handler.py
|
||||
pinned_version="0.2.0", # must match TTW_INSTALLER_PINNED_VERSION in ttw_installer_handler.py
|
||||
nexus_mod_id=1657,
|
||||
nexus_file_filter="mpi",
|
||||
),
|
||||
@@ -288,12 +288,11 @@ def _ttw_status_from_config() -> Tuple[bool, Optional[str], Optional[Path]]:
|
||||
get_jackify_data_dir() / "TTW_Linux_Installer", # legacy location
|
||||
]
|
||||
for tool_dir in search_dirs:
|
||||
for exe_name in ["ttw_linux_gui", "mpi_installer"]:
|
||||
exe = tool_dir / exe_name
|
||||
if exe.is_file():
|
||||
manifest = _read_manifest("ttw_installer")
|
||||
version = manifest.get("installed_version")
|
||||
return True, version, exe
|
||||
exe = tool_dir / "mpi_installer"
|
||||
if exe.is_file():
|
||||
manifest = _read_manifest("ttw_installer")
|
||||
version = manifest.get("installed_version")
|
||||
return True, version, exe
|
||||
return False, None, None
|
||||
except Exception as e:
|
||||
logger.debug("TTW status check failed: %s", e)
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
"display_name": "TTW Linux Installer",
|
||||
"description": "Automates Tale of Two Wastelands installation on Linux. Required for the TTW workflow.",
|
||||
"github_repo": "SulfurNitride/TTW_Linux_Installer",
|
||||
"asset_patterns": ["universal-mpi-installer.*\\.(zip|tar\\.gz)"],
|
||||
"executable_names": ["mpi_installer", "ttw_linux_gui"],
|
||||
"asset_patterns": ["mpi-installer-linux.*\\.(zip|tar\\.gz)"],
|
||||
"executable_names": ["mpi_installer"],
|
||||
"tier": 1,
|
||||
"can_uninstall": true,
|
||||
"can_launch": true,
|
||||
"pinned_version": "0.0.7",
|
||||
"pinned_version": "0.2.0",
|
||||
"nexus_mod_id": 1657,
|
||||
"nexus_game_domain": "site",
|
||||
"nexus_file_filter": "mpi"
|
||||
|
||||
@@ -12,7 +12,6 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
import requests
|
||||
@@ -52,43 +51,12 @@ class UpdateService:
|
||||
self.github_repo = "Omni-guides/Jackify"
|
||||
self.github_api_base = "https://api.github.com"
|
||||
self.update_check_timeout = 10 # seconds
|
||||
|
||||
_UPDATE_CHECK_CACHE_HOURS = 24
|
||||
|
||||
def _get_last_check_timestamp(self) -> Optional[datetime]:
|
||||
"""Read last successful update-check timestamp from config."""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
raw = ConfigHandler().get('last_update_check')
|
||||
if raw:
|
||||
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _save_last_check_timestamp(self) -> None:
|
||||
"""Store the current UTC time as the last successful update-check timestamp."""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
config = ConfigHandler()
|
||||
config.set('last_update_check', now)
|
||||
config.save_config()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def check_for_updates(self) -> Optional[UpdateInfo]:
|
||||
"""Check for available updates via GitHub releases API.
|
||||
|
||||
Returns UpdateInfo if an update is available, None otherwise.
|
||||
Skips the network call if a successful check was made within the last 24 hours.
|
||||
"""
|
||||
last_check = self._get_last_check_timestamp()
|
||||
if last_check is not None:
|
||||
age_hours = (datetime.now(timezone.utc) - last_check).total_seconds() / 3600
|
||||
if age_hours < self._UPDATE_CHECK_CACHE_HOURS:
|
||||
logger.debug("Skipping update check - last check was %.1f hours ago", age_hours)
|
||||
return None
|
||||
try:
|
||||
url = f"{self.github_api_base}/repos/{self.github_repo}/releases/latest"
|
||||
headers = {
|
||||
@@ -107,7 +75,6 @@ class UpdateService:
|
||||
response.raise_for_status()
|
||||
|
||||
release_data = response.json()
|
||||
self._save_last_check_timestamp()
|
||||
latest_version = release_data['tag_name'].lstrip('v')
|
||||
|
||||
if self._is_newer_version(latest_version):
|
||||
|
||||
@@ -201,35 +201,25 @@ class AdditionalMenuHandler:
|
||||
return
|
||||
lower = clean.lower()
|
||||
rendered = ""
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
phase_state["current"] = "Loading manifest"
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
|
||||
progress_match = re.search(r'progress:\s*(\d+)%', lower)
|
||||
if progress_match:
|
||||
percent = int(progress_match.group(1))
|
||||
rendered = f"[TTW] {phase_state['current']}: {percent}%"
|
||||
else:
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', clean)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
|
||||
else:
|
||||
if 'manifest' in lower:
|
||||
phase_state["current"] = "Loading manifest"
|
||||
elif any(t in lower for t in ('extract', 'decompress', 'installing', 'copying', 'merge')):
|
||||
phase_state["current"] = clean
|
||||
is_milestone = any(t in lower for t in ('===', 'complete', 'finished', 'starting', 'valid'))
|
||||
is_error = 'error:' in lower
|
||||
is_warning = 'warning:' in lower
|
||||
if is_milestone or is_error or is_warning:
|
||||
rendered = f"[TTW] {clean}"
|
||||
if 'manifest' in lower:
|
||||
phase_state["current"] = "Loading manifest"
|
||||
elif any(t in lower for t in ('extract', 'decompress', 'installing', 'copying', 'merge')):
|
||||
phase_state["current"] = clean
|
||||
is_milestone = any(t in lower for t in ('===', 'complete', 'finished', 'starting', 'valid'))
|
||||
is_error = 'error:' in lower
|
||||
is_warning = 'warning:' in lower
|
||||
if is_milestone or is_error or is_warning:
|
||||
rendered = f"[TTW] {clean}"
|
||||
|
||||
if not rendered or rendered == phase_state["last_rendered"]:
|
||||
return
|
||||
phase_state["last_rendered"] = rendered
|
||||
if re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered):
|
||||
if re.search(r'^\[TTW\] .+?: \d+%$', rendered):
|
||||
print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True)
|
||||
progress_line_active["value"] = True
|
||||
else:
|
||||
|
||||
@@ -16,10 +16,9 @@ from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QGroupBox, QTextEdit, QApplication
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont, QClipboard
|
||||
|
||||
from ....backend.services.update_service import UpdateService
|
||||
from ....backend.models.configuration import SystemInfo
|
||||
from .... import __version__
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
@@ -27,34 +26,13 @@ from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleM
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateCheckThread(QThread):
|
||||
"""Background thread for checking updates."""
|
||||
|
||||
update_check_finished = Signal(object) # UpdateInfo or None
|
||||
|
||||
def __init__(self, update_service: UpdateService):
|
||||
super().__init__()
|
||||
self.update_service = update_service
|
||||
|
||||
def run(self):
|
||||
"""Check for updates in background."""
|
||||
try:
|
||||
update_info = self.update_service.check_for_updates()
|
||||
self.update_check_finished.emit(update_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for updates: {e}")
|
||||
self.update_check_finished.emit(None)
|
||||
|
||||
|
||||
class AboutDialog(ThreadLifecycleMixin, QDialog):
|
||||
"""About dialog showing system info and app details."""
|
||||
|
||||
|
||||
def __init__(self, system_info: SystemInfo, parent=None):
|
||||
super().__init__(parent)
|
||||
self.system_info = system_info
|
||||
self.update_service = UpdateService(__version__)
|
||||
self.update_check_thread = None
|
||||
|
||||
|
||||
self.setup_ui()
|
||||
self.setup_connections()
|
||||
|
||||
@@ -117,45 +95,9 @@ class AboutDialog(ThreadLifecycleMixin, QDialog):
|
||||
|
||||
layout.addWidget(jackify_group)
|
||||
|
||||
# Update status
|
||||
self.update_status_label = QLabel("")
|
||||
self.update_status_label.setStyleSheet("color: #666; font-size: 10pt; margin: 5px;")
|
||||
self.update_status_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.update_status_label)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
# Update check button
|
||||
self.update_button = QPushButton("Check for Updates")
|
||||
self.update_button.clicked.connect(self.check_for_updates)
|
||||
self.update_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #23272e;
|
||||
color: #3fd0ea;
|
||||
font-weight: bold;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #3fd0ea;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #3fd0ea;
|
||||
color: #23272e;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #2bb8d6;
|
||||
color: #23272e;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #444;
|
||||
color: #666;
|
||||
border-color: #666;
|
||||
}
|
||||
""")
|
||||
button_layout.addWidget(self.update_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
|
||||
# Copy Info button
|
||||
copy_button = QPushButton("Copy Info")
|
||||
copy_button.clicked.connect(self.copy_system_info)
|
||||
@@ -324,36 +266,6 @@ class AboutDialog(ThreadLifecycleMixin, QDialog):
|
||||
logger.error(f"Error getting engine version: {e}")
|
||||
return "Unknown"
|
||||
|
||||
def check_for_updates(self):
|
||||
"""Check for updates in background."""
|
||||
if self.update_check_thread and self.update_check_thread.isRunning():
|
||||
return
|
||||
|
||||
self.update_button.setEnabled(False)
|
||||
self.update_button.setText("Checking...")
|
||||
self.update_status_label.setText("Checking for updates...")
|
||||
|
||||
self.update_check_thread = UpdateCheckThread(self.update_service)
|
||||
self.update_check_thread.update_check_finished.connect(self.update_check_finished)
|
||||
self.update_check_thread.start()
|
||||
|
||||
def update_check_finished(self, update_info):
|
||||
"""Handle update check completion."""
|
||||
self.update_button.setEnabled(True)
|
||||
self.update_button.setText("Check for Updates")
|
||||
|
||||
if update_info:
|
||||
self.update_status_label.setText(f"Update available: v{update_info.version}")
|
||||
self.update_status_label.setStyleSheet("color: #3fd0ea; font-size: 10pt; margin: 5px;")
|
||||
|
||||
# Show update dialog
|
||||
from .update_dialog import UpdateDialog
|
||||
update_dialog = UpdateDialog(update_info, self.update_service, self)
|
||||
update_dialog.exec()
|
||||
else:
|
||||
self.update_status_label.setText("You're running the latest version")
|
||||
self.update_status_label.setStyleSheet("color: #666; font-size: 10pt; margin: 5px;")
|
||||
|
||||
def copy_system_info(self):
|
||||
"""Copy system information to clipboard."""
|
||||
try:
|
||||
@@ -421,7 +333,4 @@ Python: {platform.python_version()}"""
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle dialog close event."""
|
||||
self.update_check_thread = self._park_thread(
|
||||
self.update_check_thread, ["update_available", "no_update", "check_failed"]
|
||||
)
|
||||
event.accept()
|
||||
@@ -319,14 +319,16 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
# Save component installation method preference
|
||||
if self.winetricks_radio.isChecked():
|
||||
method = 'winetricks'
|
||||
else: # protontricks_radio (alternative)
|
||||
elif self.protontricks_radio.isChecked():
|
||||
method = 'system_protontricks'
|
||||
else: # native_radio (default)
|
||||
method = 'native'
|
||||
|
||||
old_method = self.config_handler.get('component_installation_method', 'winetricks')
|
||||
old_method = self.config_handler.get('component_installation_method', 'native')
|
||||
method_changed = (old_method != method)
|
||||
|
||||
self.config_handler.set("component_installation_method", method)
|
||||
self.config_handler.set("use_winetricks_for_components", method == 'winetricks')
|
||||
self.config_handler.set("use_winetricks_for_components", method != 'system_protontricks')
|
||||
|
||||
# Force immediate save and verify
|
||||
save_result = self.config_handler.save_config()
|
||||
|
||||
@@ -262,18 +262,29 @@ class SettingsDialogTabsMixin:
|
||||
component_layout.addWidget(QLabel("Wine Components Installation:"))
|
||||
self.component_method_group = QButtonGroup()
|
||||
component_method_layout = QVBoxLayout()
|
||||
current_method = self.config_handler.get('component_installation_method', 'winetricks')
|
||||
current_method = self.config_handler.get('component_installation_method', 'native')
|
||||
if current_method == 'bundled_protontricks':
|
||||
current_method = 'system_protontricks'
|
||||
self.winetricks_radio = QRadioButton("Winetricks (Default)")
|
||||
self.native_radio = QRadioButton("Native (Default)")
|
||||
self.native_radio.setChecked(current_method == 'native')
|
||||
self.native_radio.setToolTip(
|
||||
"Install components directly, without winetricks or protontricks, falling back to "
|
||||
"bundled winetricks only for the handful of components not yet supported natively."
|
||||
)
|
||||
self.component_method_group.addButton(self.native_radio, 0)
|
||||
component_method_layout.addWidget(self.native_radio)
|
||||
self.winetricks_radio = QRadioButton("Winetricks")
|
||||
self.winetricks_radio.setChecked(current_method == 'winetricks')
|
||||
self.winetricks_radio.setToolTip("Use bundled winetricks for component installation. Faster and more reliable.")
|
||||
self.component_method_group.addButton(self.winetricks_radio, 0)
|
||||
self.winetricks_radio.setToolTip("Use bundled winetricks for every component, bypassing the native installer entirely.")
|
||||
self.component_method_group.addButton(self.winetricks_radio, 1)
|
||||
component_method_layout.addWidget(self.winetricks_radio)
|
||||
self.protontricks_radio = QRadioButton("Protontricks (Alternative)")
|
||||
self.protontricks_radio = QRadioButton("Protontricks")
|
||||
self.protontricks_radio.setChecked(current_method == 'system_protontricks')
|
||||
self.protontricks_radio.setToolTip("Use system-installed protontricks (flatpak or native). Fallback option if winetricks fails.")
|
||||
self.component_method_group.addButton(self.protontricks_radio, 1)
|
||||
self.protontricks_radio.setToolTip(
|
||||
"Use system-installed protontricks (flatpak or native) for every component, "
|
||||
"bypassing the native installer entirely."
|
||||
)
|
||||
self.component_method_group.addButton(self.protontricks_radio, 2)
|
||||
component_method_layout.addWidget(self.protontricks_radio)
|
||||
component_layout.addLayout(component_method_layout)
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ class SuccessDialog(QDialog):
|
||||
"QLabel { color: #3fd0ea; font-size: 11px; margin-top: 4px; padding: 4px; background-color: transparent; }"
|
||||
)
|
||||
readme_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
readme_label.setOpenExternalLinks(False)
|
||||
readme_label.linkActivated.connect(open_url)
|
||||
card_layout.addWidget(readme_label)
|
||||
|
||||
@@ -218,6 +219,7 @@ class SuccessDialog(QDialog):
|
||||
"}"
|
||||
)
|
||||
kofi_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
kofi_label.setOpenExternalLinks(False)
|
||||
kofi_label.linkActivated.connect(open_url)
|
||||
card_layout.addWidget(kofi_label)
|
||||
|
||||
|
||||
@@ -397,7 +397,6 @@ def main(initial_nxm_url: str = ""):
|
||||
window._prefetch_manifests_on_startup()
|
||||
|
||||
if initial_nxm_url:
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(500, lambda: window._on_nxm_url_received(initial_nxm_url))
|
||||
|
||||
# Ensure cleanup on exit
|
||||
|
||||
@@ -47,7 +47,7 @@ class MainWindowStartupMixin:
|
||||
|
||||
def _check_protontricks_on_startup(self):
|
||||
try:
|
||||
method = self.config_handler.get('component_installation_method', 'winetricks')
|
||||
method = self.config_handler.get('component_installation_method', 'native')
|
||||
if method != 'system_protontricks':
|
||||
logger.debug(f"Skipping protontricks check (current method: {method}).")
|
||||
return
|
||||
|
||||
@@ -17,14 +17,28 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
||||
"""Detect game type by checking ModOrganizer.ini for loader executables."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Enderal and FNV run on the Skyrim/FO3 engine and share its loader
|
||||
# executables (skse64_loader.exe, etc.), so they must be identified before
|
||||
# the generic engine keyword scan below or they always get misdetected as
|
||||
# skyrim/fallout3.
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
||||
special = ModlistHandler().detect_special_game_type(install_dir)
|
||||
if special == 'fnv':
|
||||
return 'falloutnv'
|
||||
if special:
|
||||
return special
|
||||
except Exception as e:
|
||||
logger.warning(f"Special game type detection failed: {e}")
|
||||
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return 'skyrim' # Fallback to most common
|
||||
|
||||
|
||||
try:
|
||||
content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower()
|
||||
|
||||
|
||||
if 'skse64_loader.exe' in content or 'skyrim special edition' in content:
|
||||
return 'skyrim'
|
||||
elif 'f4se_loader.exe' in content or 'fallout 4' in content:
|
||||
|
||||
@@ -15,6 +15,7 @@ import logging
|
||||
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code, nexus_url_from_error_line
|
||||
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error
|
||||
from jackify.shared.errors import JackifyError, cc_content_missing, creation_kit_missing
|
||||
from jackify.shared.progress_models import InstallationPhase
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -202,6 +203,23 @@ class InstallerThread(QThread):
|
||||
_stderr_fh.close()
|
||||
logger.info("CLF3 stderr log written to /tmp/clf3_stderr.log")
|
||||
|
||||
def _is_download_phase(self) -> bool:
|
||||
"""True while the engine is downloading, per the structured progress parser.
|
||||
|
||||
[FILE_PROGRESS] per-file console text (filename/percent/speed) is only
|
||||
useful during downloads, where per-file speed is the whole point. During
|
||||
hashing/extract/install it fires once per file with no throttling on the
|
||||
engine side, flooding Show Details with a filename flash for every one of
|
||||
tens of thousands of files. The Activity panel's counters are unaffected
|
||||
either way - they come from progress_state, parsed unconditionally above.
|
||||
"""
|
||||
if not self.progress_state_manager:
|
||||
return False
|
||||
try:
|
||||
return self.progress_state_manager.get_state().phase == InstallationPhase.DOWNLOAD
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _remember_stdout_line(self, line: str) -> None:
|
||||
"""Keep a bounded tail of meaningful stdout lines for failure diagnostics."""
|
||||
cleaned = (line or "").strip()
|
||||
@@ -604,6 +622,10 @@ class InstallerThread(QThread):
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.progress_received.emit(parts[0].rstrip())
|
||||
if self._is_download_phase():
|
||||
tail = parts[1].strip() if len(parts) > 1 else ''
|
||||
if tail:
|
||||
self.progress_received.emit(tail + '\r')
|
||||
else:
|
||||
self.progress_received.emit(decoded + '\r')
|
||||
elif b'\n' in buffer:
|
||||
@@ -671,6 +693,10 @@ class InstallerThread(QThread):
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
if self._is_download_phase():
|
||||
tail = parts[1].strip() if len(parts) > 1 else ''
|
||||
if tail:
|
||||
self.output_received.emit(tail + '\r')
|
||||
last_was_blank = False
|
||||
continue
|
||||
if decoded.strip() == '':
|
||||
@@ -687,6 +713,10 @@ class InstallerThread(QThread):
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
if self._is_download_phase():
|
||||
tail = parts[1].strip() if len(parts) > 1 else ''
|
||||
if tail:
|
||||
self.output_received.emit(tail + '\r')
|
||||
else:
|
||||
self._remember_stdout_line(decoded)
|
||||
self.output_received.emit(decoded)
|
||||
|
||||
@@ -404,12 +404,24 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
"""Reset the screen to default state when navigating back from main menu"""
|
||||
if not getattr(self, '_integration_mode', False):
|
||||
# Reset form fields only when not pre-populated by a caller
|
||||
self.file_edit.setText("")
|
||||
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
# Clear integration mode first - a caller (e.g. the Begin Again automated TTW
|
||||
# trigger) sets it again immediately after this runs, via set_modlist_integration_mode.
|
||||
# Without this reset it stays True forever once set, so a later standalone TTW
|
||||
# install would silently try to integrate into a stale modlist from a prior run.
|
||||
self._integration_mode = False
|
||||
self.file_edit.setText("")
|
||||
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
self.status_banner.setText("Ready to install")
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
background-color: #2a2a2a;
|
||||
color: {JACKIFY_COLOR_BLUE};
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
""")
|
||||
|
||||
# Re-enable controls (in case they were disabled from previous errors)
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from PySide6.QtCore import QThread, Signal, Qt
|
||||
from PySide6.QtWidgets import QProgressDialog, QApplication
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
import os
|
||||
@@ -34,6 +35,16 @@ class TTWIntegrationMixin:
|
||||
ttw_target = Path(install_dir) / "mods" / "[NoDelete] Tale of Two Wastelands"
|
||||
self.install_dir_edit.setText(str(ttw_target))
|
||||
|
||||
self.status_banner.setText("Please fill in the details above and click Start Install")
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
background-color: #2a2a2a;
|
||||
color: {JACKIFY_COLOR_BLUE};
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
""")
|
||||
|
||||
# Reset saved geometry so showEvent can properly collapse from current window size
|
||||
self._saved_geometry = None
|
||||
self._saved_min_size = None
|
||||
|
||||
@@ -13,14 +13,15 @@ class TTWOutputMixin:
|
||||
if not hasattr(self, '_ttw_seen_lines'):
|
||||
self._ttw_seen_lines = set()
|
||||
self._ttw_current_phase = None
|
||||
self._ttw_last_progress = 0
|
||||
self._ttw_last_activity_update = 0
|
||||
self._ttw_bsa_total = 0
|
||||
self._ttw_bsa_done = 0
|
||||
self.ttw_start_time = time.time()
|
||||
|
||||
lines_to_display = []
|
||||
html_fragments = []
|
||||
show_details_due_to_error = False
|
||||
latest_progress = None
|
||||
bsa_progress_changed = False
|
||||
|
||||
for cleaned in messages:
|
||||
if not cleaned:
|
||||
@@ -29,26 +30,30 @@ class TTWOutputMixin:
|
||||
lower_cleaned = cleaned.lower()
|
||||
|
||||
try:
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
latest_progress = (current, total, percent)
|
||||
if not self._ttw_bsa_total:
|
||||
bsa_total_match = re.search(r'(\d+)\s+output BSAs', cleaned)
|
||||
if bsa_total_match:
|
||||
self._ttw_bsa_total = int(bsa_total_match.group(1))
|
||||
bsa_progress_changed = True
|
||||
|
||||
if 'loading manifest:' in lower_cleaned:
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
|
||||
is_warning = 'warning:' in lower_cleaned
|
||||
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
|
||||
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
||||
is_bsa_progress = cleaned.startswith('[BSA]')
|
||||
is_bsa_done = is_bsa_progress and ('... ok' in lower_cleaned or '... failed' in lower_cleaned)
|
||||
if is_bsa_done:
|
||||
self._ttw_bsa_done += 1
|
||||
bsa_progress_changed = True
|
||||
is_milestone = is_bsa_progress or any(
|
||||
kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid']
|
||||
)
|
||||
is_file_op = not is_bsa_progress and any(
|
||||
ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx']
|
||||
)
|
||||
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
||||
|
||||
if is_error and 'cannot get directory path for location type' in lower_cleaned:
|
||||
@@ -66,12 +71,11 @@ class TTWOutputMixin:
|
||||
else:
|
||||
lines_to_display.append(cleaned)
|
||||
|
||||
if latest_progress:
|
||||
current, total, percent = latest_progress
|
||||
if bsa_progress_changed and self._ttw_bsa_total:
|
||||
current_time = time.time()
|
||||
if abs(percent - self._ttw_last_progress) >= 1 or (current_time - self._ttw_last_activity_update) >= 0.5:
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
self._ttw_last_progress = percent
|
||||
if (current_time - self._ttw_last_activity_update) >= 0.3:
|
||||
percent = int(self._ttw_bsa_done / self._ttw_bsa_total * 100)
|
||||
self._update_ttw_phase("Building archives", self._ttw_bsa_done, self._ttw_bsa_total, percent)
|
||||
self._ttw_last_activity_update = current_time
|
||||
|
||||
if html_fragments or lines_to_display:
|
||||
@@ -129,21 +133,13 @@ class TTWOutputMixin:
|
||||
pass
|
||||
|
||||
try:
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
progress_match = re.search(r'Progress:\s*(\d+)%', cleaned)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
percent = int(progress_match.group(1))
|
||||
self._update_ttw_activity(percent, 100, percent)
|
||||
|
||||
if 'loading manifest:' in lower_cleaned:
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -38,26 +38,16 @@ def on_installation_output_simple(self, message):
|
||||
self._safe_append_text(cleaned)
|
||||
|
||||
# Extract progress for Activity window ONLY - minimal regex with error handling
|
||||
# Pattern: [X/Y] or "Loading manifest: X/Y"
|
||||
# Pattern: "Progress: NN%" or "Loading manifest: <path>"
|
||||
try:
|
||||
# Try to extract [X/Y] pattern
|
||||
import re
|
||||
match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
match = re.search(r'Progress:\s*(\d+)%', cleaned)
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
total = int(match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
phase = self._ttw_current_phase or "Processing"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
|
||||
# Try "Loading manifest: X/Y"
|
||||
match = re.search(r'loading manifest:\s*(\d+)/(\d+)', cleaned.lower())
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
total = int(match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
percent = int(match.group(1))
|
||||
self._update_ttw_activity(percent, 100, percent)
|
||||
|
||||
if 'loading manifest:' in cleaned.lower():
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
except (RecursionError, re.error, Exception):
|
||||
# If regex fails, just skip progress extraction - show output anyway
|
||||
pass
|
||||
|
||||
@@ -112,6 +112,7 @@ class TTWUISetupMixin:
|
||||
instruction_text.setWordWrap(True)
|
||||
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
||||
instruction_text.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
instruction_text.setOpenExternalLinks(False)
|
||||
instruction_text.linkActivated.connect(open_url)
|
||||
user_config_vbox.addWidget(instruction_text)
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ class TTWWorkflowMixin:
|
||||
self._safe_append_text("Starting TTW installation...")
|
||||
|
||||
self.file_progress_list.clear()
|
||||
self._update_ttw_phase("Initialising TTW installation", 0, 0, 0)
|
||||
self._update_ttw_phase("Initialising TTW installation")
|
||||
QApplication.processEvents()
|
||||
|
||||
self.status_banner.setVisible(True)
|
||||
|
||||
@@ -58,6 +58,8 @@ class ModlistGalleryLoadingMixin:
|
||||
|
||||
# Position overlay in center of content area
|
||||
def position_overlay():
|
||||
if getattr(self, '_loading_overlay', None) is None:
|
||||
return
|
||||
if hasattr(self, 'content_area') and self.content_area.isVisible():
|
||||
content_width = self.content_area.width()
|
||||
content_height = self.content_area.height()
|
||||
|
||||
@@ -11,17 +11,19 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QLabel,
|
||||
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from jackify import __version__ as _JACKIFY_VERSION
|
||||
from jackify.backend.services.tool_registry import (
|
||||
ToolDefinition, ToolRegistry, ToolStatus,
|
||||
apply_remote_manifest, fetch_remote_manifest, fetch_release_list,
|
||||
get_active_engine_id, get_effective_definitions,
|
||||
)
|
||||
from jackify.backend.services.update_service import UpdateInfo, UpdateService
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
from jackify.frontends.gui.screens.tools_hub_card import ToolCard, btn_style, section_header
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
@@ -113,6 +115,22 @@ class _ReleaseFetchThread(QThread):
|
||||
self.releases_ready.emit(self._tool_id, releases)
|
||||
|
||||
|
||||
class _JackifyUpdateCheckThread(QThread):
|
||||
update_ready = Signal(object) # UpdateInfo or None
|
||||
|
||||
def __init__(self, update_service: UpdateService):
|
||||
super().__init__()
|
||||
self._update_service = update_service
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
update_info = self._update_service.check_for_updates()
|
||||
except Exception as e:
|
||||
logger.debug("Jackify update check failed: %s", e)
|
||||
update_info = None
|
||||
self.update_ready.emit(update_info)
|
||||
|
||||
|
||||
# -- main screen -------------------------------------------------------------
|
||||
class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""Tools Hub: engine selection and third-party tool management."""
|
||||
@@ -128,6 +146,10 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
self._version_thread: Optional[_VersionCheckThread] = None
|
||||
self._manifest_thread: Optional[_ManifestFetchThread] = None
|
||||
self._release_thread: Optional[_ReleaseFetchThread] = None
|
||||
self._jackify_update_thread: Optional[_JackifyUpdateCheckThread] = None
|
||||
self._jackify_update_info: Optional[UpdateInfo] = None
|
||||
self._jackify_manual_check_pending = False
|
||||
self._update_service = UpdateService(_JACKIFY_VERSION)
|
||||
self._active_engine_id = get_active_engine_id()
|
||||
|
||||
self._setup_ui()
|
||||
@@ -139,15 +161,25 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
self.setLayout(root)
|
||||
|
||||
header_row = QHBoxLayout()
|
||||
|
||||
jackify_version_label = QLabel(f"Jackify v{_JACKIFY_VERSION}")
|
||||
jackify_version_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self._btn_update_jackify = QPushButton("Check for Updates")
|
||||
self._btn_update_jackify.setFixedSize(150, 30)
|
||||
self._btn_update_jackify.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self._btn_update_jackify.setStyleSheet(btn_style(_C_UPDATE, width=134))
|
||||
self._btn_update_jackify.clicked.connect(self._on_update_jackify)
|
||||
|
||||
self._btn_update_all = QPushButton("Update All")
|
||||
self._btn_update_all.setFixedSize(100, 30)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
|
||||
self._btn_update_all.setFixedSize(150, 30)
|
||||
self._btn_update_all.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True, width=134))
|
||||
self._btn_update_all.setEnabled(False)
|
||||
self._btn_update_all.clicked.connect(self._on_update_all)
|
||||
# Left spacer matches button width so title stays centred
|
||||
left_spacer = QWidget()
|
||||
left_spacer.setFixedWidth(100)
|
||||
header_row.addWidget(left_spacer)
|
||||
|
||||
header_row.addWidget(jackify_version_label)
|
||||
header_row.addSpacing(8)
|
||||
header_row.addWidget(self._btn_update_jackify)
|
||||
header_row.addStretch()
|
||||
title = QLabel("<b>Tools Hub</b>")
|
||||
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
|
||||
@@ -265,6 +297,47 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
self._rebuild_card_list()
|
||||
self._start_manifest_fetch()
|
||||
self._start_version_check()
|
||||
self._start_jackify_update_check()
|
||||
|
||||
def _start_jackify_update_check(self):
|
||||
if self._jackify_update_thread and self._jackify_update_thread.isRunning():
|
||||
return
|
||||
self._jackify_update_thread = _JackifyUpdateCheckThread(self._update_service)
|
||||
self._jackify_update_thread.update_ready.connect(self._on_jackify_update_ready)
|
||||
self._jackify_update_thread.start()
|
||||
|
||||
def _on_jackify_update_ready(self, update_info: Optional[UpdateInfo]):
|
||||
self._jackify_update_info = update_info
|
||||
manual = self._jackify_manual_check_pending
|
||||
self._jackify_manual_check_pending = False
|
||||
self._btn_update_jackify.setEnabled(True)
|
||||
|
||||
if update_info:
|
||||
self._btn_update_jackify.setText("Update Jackify")
|
||||
if manual:
|
||||
self._open_jackify_update_dialog()
|
||||
else:
|
||||
self._btn_update_jackify.setText("Up to date" if manual else "Check for Updates")
|
||||
if manual:
|
||||
QTimer.singleShot(
|
||||
2500, lambda: self._btn_update_jackify.setText("Check for Updates")
|
||||
)
|
||||
|
||||
def _on_update_jackify(self):
|
||||
if self._jackify_update_info:
|
||||
self._open_jackify_update_dialog()
|
||||
return
|
||||
if self._jackify_update_thread and self._jackify_update_thread.isRunning():
|
||||
return
|
||||
self._jackify_manual_check_pending = True
|
||||
self._btn_update_jackify.setEnabled(False)
|
||||
self._btn_update_jackify.setText("Checking...")
|
||||
self._start_jackify_update_check()
|
||||
|
||||
def _open_jackify_update_dialog(self):
|
||||
from jackify.frontends.gui.dialogs.update_dialog import UpdateDialog
|
||||
dialog = UpdateDialog(self._jackify_update_info, self._update_service, self)
|
||||
dialog.exec()
|
||||
|
||||
def _start_manifest_fetch(self):
|
||||
if self._manifest_thread and self._manifest_thread.isRunning():
|
||||
@@ -296,7 +369,7 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
has_update = card.set_latest_version(tag)
|
||||
if has_update:
|
||||
self._btn_update_all.setEnabled(True)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE))
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, width=134))
|
||||
any_updates = any(c._status.update_available for c in self._cards.values())
|
||||
main_menu = self._get_main_menu()
|
||||
if main_menu:
|
||||
@@ -423,7 +496,7 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
any_remaining = any(c._status.installed and c._status.update_available for c in self._cards.values())
|
||||
self._btn_update_all.setEnabled(any_remaining)
|
||||
if not any_remaining:
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True, width=134))
|
||||
|
||||
def _start_downgrade_flow(self, tool_id: str):
|
||||
card = self._cards.get(tool_id)
|
||||
|
||||
@@ -94,6 +94,7 @@ class ToolCard(QFrame):
|
||||
self._name_label = QLabel(name_html)
|
||||
self._name_label.setTextFormat(Qt.RichText)
|
||||
self._name_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
self._name_label.setOpenExternalLinks(False)
|
||||
self._name_label.linkActivated.connect(self._open_url)
|
||||
self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
|
||||
info_col.addWidget(self._name_label)
|
||||
|
||||
@@ -14,7 +14,9 @@ No external dependencies required. vdf is bundled in tools/vdf/.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import configparser
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
@@ -1079,8 +1081,12 @@ def check_ttw_installation(modlist_dir: Path, game_type: str, r: Results, modlis
|
||||
|
||||
def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
|
||||
"""Check whether Tool Compatibility Config has been applied to this prefix."""
|
||||
# Tool compat is not applied for these game types - nothing to check
|
||||
_no_tool_compat = ("falloutnv", "fallout3", "enderal", "cp2077", "bg3", "skyrimvr", "fallout4vr")
|
||||
# Tool compat is not applied for these game types - nothing to check.
|
||||
# Enderal SE shares the Skyrim SE engine/plugin format and gets the same
|
||||
# xEdit/Pandora/DLL-overrides/Synthesis fixes (see modlist_configuration.py
|
||||
# Step 15) - only the SkyrimSE.exe-scoped mscoree entry is skipped for it,
|
||||
# handled below.
|
||||
_no_tool_compat = ("falloutnv", "fallout3", "cp2077", "bg3", "skyrimvr", "fallout4vr")
|
||||
if game_type in _no_tool_compat:
|
||||
return
|
||||
|
||||
@@ -1108,12 +1114,16 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
|
||||
else:
|
||||
r.warn("Global DLL overrides not found")
|
||||
|
||||
# Synthesis: mscoree AppDefaults entry is Skyrim-specific
|
||||
if game_type == "skyrim":
|
||||
if "AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides" in content and "mscoree" in content:
|
||||
r.ok("Synthesis mscoree fix applied (SkyrimSE.exe AppDefaults)")
|
||||
else:
|
||||
r.warn("Synthesis mscoree AppDefaults entry not found")
|
||||
# Synthesis/dotnet checks apply to both Skyrim and Enderal SE (same engine and
|
||||
# plugin format). The mscoree AppDefaults entry itself is Skyrim-only: it is
|
||||
# scoped to SkyrimSE.exe, which is also Enderal's own game process, so
|
||||
# Step 15 deliberately skips writing it there - don't flag it missing.
|
||||
if game_type in ("skyrim", "enderal"):
|
||||
if game_type == "skyrim":
|
||||
if "AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides" in content and "mscoree" in content:
|
||||
r.ok("Synthesis mscoree fix applied (SkyrimSE.exe AppDefaults)")
|
||||
else:
|
||||
r.warn("Synthesis mscoree AppDefaults entry not found")
|
||||
|
||||
# OnlyUseLatestCLR is written to HKLM during the same Synthesis compat pass
|
||||
sys_content = system_reg.read_text(errors="replace") if system_reg.exists() else ""
|
||||
@@ -1122,9 +1132,9 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
|
||||
else:
|
||||
r.warn("OnlyUseLatestCLR not found in system.reg")
|
||||
|
||||
# .NET 9 SDK is installed natively (not via winetricks) for Synthesis.
|
||||
# NSF/CSF prefixes use global *mscoree=native and deliberately skip dotnet9 (win11 flip
|
||||
# breaks NSF), so suppress the check when that override is present. Must check the
|
||||
# .NET 10 SDK is installed natively (not via winetricks) for Synthesis.
|
||||
# NSF/CSF prefixes use global *mscoree=native and deliberately skip the SDK install (win11
|
||||
# flip breaks NSF), so suppress the check when that override is present. Must check the
|
||||
# global [Software\Wine\DllOverrides] section specifically, not just any occurrence of
|
||||
# the string - every Skyrim modlist (NSF or not) also gets a scoped
|
||||
# AppDefaults\SkyrimSE.exe\DllOverrides entry with the same value, which a plain
|
||||
@@ -1133,22 +1143,54 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
|
||||
_nsf_prefix = bool(_global_overrides_match and '"*mscoree"="native"' in _global_overrides_match.group(0))
|
||||
sdk_base = pfx / "drive_c" / "Program Files" / "dotnet" / "sdk"
|
||||
if _nsf_prefix:
|
||||
r.ok(".NET 9 SDK check skipped (NSF/CSF prefix - global mscoree=native detected)")
|
||||
r.ok(".NET 10 SDK check skipped (NSF/CSF prefix - global mscoree=native detected)")
|
||||
elif sdk_base.is_dir():
|
||||
net9_dirs = [d for d in sdk_base.iterdir() if d.is_dir() and d.name.startswith("9.")]
|
||||
if net9_dirs:
|
||||
r.ok(f".NET 9 SDK present: {net9_dirs[0].name}")
|
||||
net10_dirs = [d for d in sdk_base.iterdir() if d.is_dir() and d.name.startswith("10.")]
|
||||
if net10_dirs:
|
||||
r.ok(f".NET 10 SDK present: {net10_dirs[0].name}")
|
||||
else:
|
||||
installed = [d.name for d in sdk_base.iterdir() if d.is_dir()]
|
||||
r.warn(f".NET 9 SDK not found under drive_c/Program Files/dotnet/sdk/ (found: {installed or 'none'})")
|
||||
r.warn(f".NET 10 SDK not found under drive_c/Program Files/dotnet/sdk/ (found: {installed or 'none'})")
|
||||
else:
|
||||
r.warn(".NET 9 SDK not found (drive_c/Program Files/dotnet/sdk/ missing; run Configure Tool Compatibility)")
|
||||
r.warn(".NET 10 SDK not found (drive_c/Program Files/dotnet/sdk/ missing; run Configure Tool Compatibility)")
|
||||
|
||||
if not _nsf_prefix:
|
||||
sys_content = system_reg.read_text(errors="replace") if system_reg.exists() else ""
|
||||
_check_nuget_signature_config(pfx, content, sys_content, r)
|
||||
|
||||
|
||||
_PEM_CERT_RE = re.compile(
|
||||
r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _sdk_trusted_root_thumbprints(pfx: Path) -> Optional[set]:
|
||||
"""
|
||||
Compute the expected set of trusted-root cert thumbprints from the newest
|
||||
installed .NET SDK's bundled PEM files - the same source
|
||||
nuget_signature_service.install_nuget_cert reads from and imports.
|
||||
"""
|
||||
sdk_root = pfx / "drive_c" / "Program Files" / "dotnet" / "sdk"
|
||||
if not sdk_root.is_dir():
|
||||
return None
|
||||
|
||||
for version_dir in sorted(sdk_root.iterdir(), reverse=True):
|
||||
trustedroots = version_dir / "trustedroots"
|
||||
codesign = trustedroots / "codesignctl.pem"
|
||||
timestamp = trustedroots / "timestampctl.pem"
|
||||
if not (codesign.exists() and timestamp.exists()):
|
||||
continue
|
||||
thumbprints = set()
|
||||
for bundle in (codesign, timestamp):
|
||||
text = bundle.read_text(encoding="utf-8", errors="replace")
|
||||
for match in _PEM_CERT_RE.finditer(text):
|
||||
der = base64.b64decode("".join(match.group(1).split()))
|
||||
thumbprints.add(hashlib.sha1(der).hexdigest().upper())
|
||||
return thumbprints
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_nuget_signature_config(pfx: Path, user_reg_content: str, system_reg_content: str, r: Results):
|
||||
"""
|
||||
Check NuGet package signature validation is configured for Synthesis.
|
||||
@@ -1175,12 +1217,31 @@ def _check_nuget_signature_config(pfx: Path, user_reg_content: str, system_reg_c
|
||||
# via a real CachyOS repro (wine-11.0) where the import genuinely succeeded
|
||||
# but landed in system.reg while an older Wine build on another machine put
|
||||
# it in user.reg. Check both rather than assume one.
|
||||
marker = "SystemCertificates\\\\Root\\\\Certificates\\\\"
|
||||
root_cert_count = user_reg_content.count(marker) + system_reg_content.count(marker)
|
||||
if root_cert_count > 0:
|
||||
r.ok(f".NET SDK trusted-root certs imported into Wine cert store ({root_cert_count} entries)")
|
||||
#
|
||||
# A raw "any entries present" check is not enough: wine regedit has been
|
||||
# observed to exit 0 while silently dropping a single cert out of a ~400
|
||||
# entry batch import - the exact root cause of a long-standing Synthesis
|
||||
# NU3028 failure. Compare against the SDK's own PEM bundles to catch a
|
||||
# partial import, not just an empty one.
|
||||
thumb_re = re.compile(r"SystemCertificates\\\\Root\\\\Certificates\\\\([0-9A-Fa-f]+)")
|
||||
present = set(thumb_re.findall(user_reg_content)) | set(thumb_re.findall(system_reg_content))
|
||||
expected = _sdk_trusted_root_thumbprints(pfx)
|
||||
|
||||
if expected is None:
|
||||
if present:
|
||||
r.ok(f".NET SDK trusted-root certs imported into Wine cert store ({len(present)} entries)")
|
||||
else:
|
||||
r.warn(".NET SDK trusted-root certs not found in Wine cert store")
|
||||
else:
|
||||
r.warn(".NET SDK trusted-root certs not found in Wine cert store")
|
||||
missing = expected - present
|
||||
if not missing:
|
||||
r.ok(f".NET SDK trusted-root certs fully imported into Wine cert store ({len(expected)} entries)")
|
||||
else:
|
||||
r.warn(
|
||||
f".NET SDK trusted-root certs incomplete in Wine cert store - "
|
||||
f"{len(missing)} of {len(expected)} missing (Synthesis restore may fail "
|
||||
f"with NU3028/NU3037; re-run Configure Tool Compatibility)"
|
||||
)
|
||||
|
||||
if "NUGET_CERT_REVOCATION_MODE" in user_reg_content and "NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY" in user_reg_content:
|
||||
r.ok("NuGet offline-revocation/retry-policy env vars set")
|
||||
|
||||
Reference in New Issue
Block a user