Release v0.7.2 - TTW Linux Installer 0.2.0

This commit is contained in:
Omni
2026-07-18 13:46:48 +01:00
parent d78e16f758
commit ada21f90c8
42 changed files with 1111 additions and 605 deletions
+3
View File
@@ -45,3 +45,6 @@ requirements-packaging.txt
# Development logs
logs/
# Build artifacts (from pyproject.toml / pip install -e .)
*.egg-info/
+20
View File
@@ -1,5 +1,25 @@
# Jackify Changelog
## v0.7.2 - TTW Linux Installer 0.2.0
**Release Date:** 2026-07-18
### TTW Linux Installer now uses latest available
Previously the TTW Linux Installer was locked to v0.0.7 due to the change in output format beyond that version. Now Jackify can make use of the most up to date version of TTW Linux Installer
### Fixes
- Fixed Proton prefix creation being reported as failed when it was actually still finishing in the background (most likely on the first run of a newly installed Proton build), which could block install/configure entirely even though retrying immediately would work.
- Moved the "Check for Updates" action out of the About dialog into a dedicated button in the Tools Hub, which now lights up automatically when an update is available.
- Fixed a rare case where Synthesis's required NuGet security certificate import could silently drop a certificate during setup, causing NU3028/NU3037 signature errors on patcher compilation with no clear cause. Certificate import is now verified after writing and automatically retried if incomplete, and the post-install check will flag it clearly if it ever still happens.
- Fixed Synthesis still being able to spawn a very large number of concurrent dotnet.exe patcher builds on large modlists, which could exhaust system memory. Patcher build concurrency is now capped.
- Switched .NET 9 SDK to .NET 10 SDK for compatibility with newer Synthesis and patcher versions.
- Automatically updates Synthesis version during install - This fixes an issue where Synthesis' patcher list failing to load entirely on any modlist that includes it. The cause of the issue is the list of available patchers is fetched live from a GitHub-hosted directory outside Jackify's or the modlist's control, and a recent update to that directory broke compatibility with older bundled Synthesis versions. Jackify now automatically updates a modlist's bundled Synthesis to a known-compatible version during setup, and defaults its "MO2 Mode" build-safety setting off, since Linux has no equivalent to the "launch outside MO2" step it otherwise asks for.
- Synthesis's game data path is now filled in automatically during setup, instead of sometimes being left blank until set manually in Synthesis itself.
- Fixed Enderal Special Edition modlists not receiving xEdit, Pandora, and Synthesis compatibility fixes, and the post-install check incorrectly warning about them as missing.
- Fixed an occasional crash when the modlist gallery finished loading faster than its loading overlay could appear.
- Fixed the crash-report dialog itself failing to appear after certain unhandled errors.
---
## v0.7.1.1 - Synthesis, Starfield, and Stability Fixes
**Release Date:** 2026-07-08
+1 -1
View File
@@ -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"
+21 -3
View File
@@ -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
+5 -5
View File
@@ -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
+16 -14
View File
@@ -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
+90 -124
View File
@@ -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
+8 -9
View File
@@ -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)
+3 -3
View File
@@ -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):
+14 -24
View File
@@ -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:
+4 -95
View File
@@ -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)
-1
View File
@@ -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)
+18 -6
View File
@@ -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()
+82 -9
View File
@@ -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)
+83 -22
View File
@@ -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")