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