Sync from development - prepare for v0.5.0.3

This commit is contained in:
Omni
2026-03-23 13:46:27 +00:00
parent e52e1427f6
commit 8e4dd06f11
241 changed files with 713 additions and 441 deletions

View File

@@ -1,5 +1,18 @@
# Jackify Changelog # Jackify Changelog
## v0.5.0.3 - Hotfix
**Release Date:** 23/03/26
- Engine updated to 0.5.2.
- Fixed manual downloads getting stuck on "Browser Opened" when the expected filename has a leading numeric prefix (e.g. `1_filename.zip`) that is absent from the browser-saved file. Both the live download watcher and the startup precheck scan now handle this correctly.
- Fixed "Continue Anyway" on the disk space warning having no effect. The flag was missing from the CLI argument parser, and a separate engine-level registration bug caused it to be rejected regardless. Both are now resolved. The dialog also correctly displays separate download and install space requirements and notes when both paths share the same drive.
- Fixed FNV, FO3, and Enderal modlists losing their game registry paths after configuration. The curated registry files applied during the configuration phase overwrite the Wine prefix registry entirely, wiping the game install paths injected earlier. Jackify now re-injects the correct paths immediately after the curated files are applied.
- Improved detection and guidance for modlists that require the Skyrim Special Edition Creation Kit. If the engine reports missing Creation Kit files, Jackify now surfaces step-by-step instructions for installing and first-launching the Creation Kit via Steam so the required files are in place before retrying.
- Filesystem filename length limit (NAME_MAX) no longer hard-blocks installation on standard filesystems. The check previously triggered incorrectly on ext4/btrfs/XFS. For users on encrypted home directories where the limit is genuinely reduced, Jackify now shows a warning dialog listing the affected files with a "Continue Anyway" option.
- Archive index errors now produce an actionable failure message identifying the specific archive to delete and re-download, rather than a bare engine exception.
- TTW installer temporary working files are now cleaned up after each TTW installation run. These files were previously never removed and could accumulate several GB per install attempt.
- Each GitHub release now includes a `SHA256SUMS` file for verifying your download. See the README for instructions.
## v0.5.0.2 - Hotfix ## v0.5.0.2 - Hotfix
**Release Date:** 15/03/26 **Release Date:** 15/03/26

View File

@@ -60,6 +60,14 @@ chmod +x Jackify.AppImage
For CLI mode: `./Jackify.AppImage --cli` For CLI mode: `./Jackify.AppImage --cli`
To verify your download, each release includes a `SHA256SUMS` file on the [GitHub releases page](https://github.com/Omni-guides/Jackify/releases/latest). Download it into the same folder as the AppImage, then run:
```bash
sha256sum -c SHA256SUMS
```
You should see `Jackify.AppImage: OK`. If you see a failure, do not run the file.
For a full step-by-step guide with screenshots, see the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide). For a full step-by-step guide with screenshots, see the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide).
## Supported Games ## Supported Games

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems. Wabbajack modlists natively on Linux systems.
""" """
__version__ = "0.5.0.2" __version__ = "0.5.0.3"

View File

@@ -121,6 +121,9 @@ class ModlistOperationsConfigurationCLIMixin:
if debug_mode: if debug_mode:
cmd.append('--debug') cmd.append('--debug')
self.logger.info("Adding --debug flag to jackify-engine") self.logger.info("Adding --debug flag to jackify-engine")
if self.context.get('skip_disk_check'):
cmd.append('--skip-disk-check')
self.logger.info("Adding --skip-disk-check flag to jackify-engine")
original_env_values = { original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),

View File

@@ -50,10 +50,20 @@ class ModlistConfigurationMixin:
return True return True
def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False, skip_manual_for_existing=False): def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False, skip_manual_for_existing=False):
"""Run the configuration steps for the selected modlist.""" """
Runs the actual configuration steps for the selected modlist.
Args:
status_callback (callable, optional): A function to call with status updates during configuration.
manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow).
skip_manual_for_existing (bool): If True, always skip manual steps (for existing modlists that are already configured).
"""
try: try:
# Store status_callback for Configuration Summary
self._current_status_callback = status_callback self._current_status_callback = status_callback
self.logger.info("Executing configuration steps...") self.logger.info("Executing configuration steps...")
# Ensure required context is set
if not all([self.modlist_dir, self.appid, self.game_var, self.steamdeck is not None]): if not all([self.modlist_dir, self.appid, self.game_var, self.steamdeck is not None]):
self.logger.error("Cannot execute configuration steps: Missing required context (modlist_dir, appid, game_var, steamdeck status).") self.logger.error("Cannot execute configuration steps: Missing required context (modlist_dir, appid, game_var, steamdeck status).")
self.logger.error("Missing required information to start configuration.") self.logger.error("Missing required information to start configuration.")
@@ -79,10 +89,14 @@ class ModlistConfigurationMixin:
return False # Abort on failure return False # Abort on failure
self.logger.info("Step 1: Setting Protontricks permissions... Done") self.logger.info("Step 1: Setting Protontricks permissions... Done")
# Step 2: Prompt user for manual steps and wait for compatdata
skip_manual_prompt = skip_manual_for_existing # Existing modlists skip manual steps skip_manual_prompt = skip_manual_for_existing # Existing modlists skip manual steps
if not manual_steps_completed and not skip_manual_for_existing: if not manual_steps_completed and not skip_manual_for_existing:
# Check if Proton Experimental is already set and compatdata exists
proton_ok = False proton_ok = False
compatdata_ok = False compatdata_ok = False
# Check Proton version
self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}") self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}")
if self._detect_proton_version(): if self._detect_proton_version():
self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}") self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}")
@@ -92,6 +106,7 @@ class ModlistConfigurationMixin:
else: else:
self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version") self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version")
# Check compatdata/prefix
prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}") self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}")
@@ -158,6 +173,9 @@ class ModlistConfigurationMixin:
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}") self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}")
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}") self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}")
return False return False
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
# The curated registry files overwrite the entire Wine registry, so any
# game-specific entries injected earlier must be re-applied immediately after.
special_game_type = self.detect_special_game_type(self.modlist_dir) special_game_type = self.detect_special_game_type(self.modlist_dir)
if special_game_type in ["fnv", "fo3", "enderal"]: if special_game_type in ["fnv", "fo3", "enderal"]:
self.logger.info( self.logger.info(
@@ -166,7 +184,6 @@ class ModlistConfigurationMixin:
) )
try: try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
AutomatedPrefixService()._inject_game_registry_entries(prefix_path_str, special_game_type) AutomatedPrefixService()._inject_game_registry_entries(prefix_path_str, special_game_type)
except Exception as e: except Exception as e:
self.logger.error( self.logger.error(
@@ -176,7 +193,6 @@ class ModlistConfigurationMixin:
) )
self.logger.error("Could not restore required game registry entries after applying curated registry files.") self.logger.error("Could not restore required game registry entries after applying curated registry files.")
return False return False
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
# Step 4: Install Wine Components # Step 4: Install Wine Components
if status_callback: if status_callback:
@@ -546,7 +562,9 @@ class ModlistConfigurationMixin:
status_callback("") # Blank line after final Prefix Configuration step status_callback("") # Blank line after final Prefix Configuration step
self.logger.info("Step 12: Checking for modlist-specific steps...") self.logger.info("Step 12: Checking for modlist-specific steps...")
# Step 13: Launch options for special games are now set during automated workflow # Step 13: Launch options for special games are now set during automated prefix workflow (before Steam restart)
# Avoids a second Steam restart
special_game_type = self.detect_special_game_type(self.modlist_dir)
if special_game_type: if special_game_type:
self.logger.info(f"Step 13: Launch options for {special_game_type.upper()} were set during automated workflow") self.logger.info(f"Step 13: Launch options for {special_game_type.upper()} were set during automated workflow")
else: else:
@@ -569,12 +587,18 @@ class ModlistConfigurationMixin:
return True # Return True on success return True # Return True on success
def run_modlist_configuration_phase(self, context: dict = None) -> bool: def run_modlist_configuration_phase(self, context: dict = None) -> bool:
"""Run the full modlist configuration sequence.""" """
Main entry point to run the full modlist configuration sequence.
This orchestrates all the individual steps.
"""
self.logger.info(f"Starting configuration phase for modlist: {self.game_name}") self.logger.info(f"Starting configuration phase for modlist: {self.game_name}")
# Call the private method that contains the actual steps
# Pass along the status_callback if it was provided in the context
status_callback = context.get('status_callback') if context else None status_callback = context.get('status_callback') if context else None
return self._execute_configuration_steps(status_callback=status_callback) return self._execute_configuration_steps(status_callback=status_callback)
def _prompt_or_set_resolution(self): def _prompt_or_set_resolution(self):
# If on Steam Deck, set 1280x800 automatically
if self._is_steam_deck(): if self._is_steam_deck():
self.selected_resolution = "1280x800" self.selected_resolution = "1280x800"
self.logger.info("Steam Deck detected: setting resolution to 1280x800.") self.logger.info("Steam Deck detected: setting resolution to 1280x800.")

View File

@@ -91,6 +91,9 @@ class TTWInstallerBackendMixin:
except Exception as e: except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True) self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
return False, f"Error executing TTW_Linux_Installer: {e}" return False, f"Error executing TTW_Linux_Installer: {e}"
finally:
from jackify.shared.paths import cleanup_stale_tmp
cleanup_stale_tmp()
def start_ttw_installation(self, ttw_mpi_path: Path, ttw_output_path: Path, output_file: Path): def start_ttw_installation(self, ttw_mpi_path: Path, ttw_output_path: Path, output_file: Path):
"""Start TTW installation process (non-blocking). Returns (process, error_message).""" """Start TTW installation process (non-blocking). Returns (process, error_message)."""
@@ -168,6 +171,8 @@ class TTWInstallerBackendMixin:
process.kill() process.kill()
except Exception: except Exception:
pass pass
from jackify.shared.paths import cleanup_stale_tmp
cleanup_stale_tmp()
def install_ttw_backend_with_output_stream(self, ttw_mpi_path: Path, ttw_output_path: Path, output_callback=None): def install_ttw_backend_with_output_stream(self, ttw_mpi_path: Path, ttw_output_path: Path, output_callback=None):
"""Install TTW with streaming output (DEPRECATED - use start_ttw_installation instead).""" """Install TTW with streaming output (DEPRECATED - use start_ttw_installation instead)."""
@@ -251,6 +256,9 @@ class TTWInstallerBackendMixin:
except Exception as e: except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True) self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
return False, f"Error executing TTW_Linux_Installer: {e}" return False, f"Error executing TTW_Linux_Installer: {e}"
finally:
from jackify.shared.paths import cleanup_stale_tmp
cleanup_stale_tmp()
@staticmethod @staticmethod
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str, skip_copy: bool = False) -> bool: def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str, skip_copy: bool = False) -> bool:

View File

@@ -4,6 +4,7 @@ list of pending manual download items by lax filename comparison.
""" """
import os import os
import re
import time import time
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -106,6 +107,14 @@ class DownloadWatcherService:
logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}") logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}")
self._debounce_and_emit(path, item) self._debounce_and_emit(path, item)
return return
# Some modlist metadata stores filenames with a leading numeric prefix
# (e.g. "1_filename.zip") that is absent from the browser-saved file.
for expected_name, item in self._pending_exact:
stripped = re.sub(r'^\d+_', '', expected_name)
if stripped != expected_name and stripped == candidate_name:
logger.debug(f"Candidate numeric-prefix match: {path.name} -> {expected_name}")
self._debounce_and_emit(path, item)
return
def _debounce_and_emit(self, path: Path, item: dict) -> None: def _debounce_and_emit(self, path: Path, item: dict) -> None:
def _wait_and_emit(): def _wait_and_emit():

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import logging import logging
import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -380,6 +381,13 @@ class ManualDownloadManagerRuntimeMixin:
stripped = name.lower().lstrip('.') stripped = name.lower().lstrip('.')
if stripped != name.lower(): if stripped != name.lower():
exact = exact_map.get(stripped) exact = exact_map.get(stripped)
if exact is None:
# Numeric prefix normalization: engine may store filenames with a
# leading numeric prefix (e.g. "1_filename.zip") absent from the
# browser-saved file.
stripped_num = re.sub(r'^\d+_', '', name.lower())
if stripped_num != name.lower():
exact = exact_map.get(stripped_num)
if exact is None or exact in used_paths: if exact is None or exact in used_paths:
continue continue
used_paths.add(exact) used_paths.add(exact)

View File

@@ -145,6 +145,8 @@ class ModlistServiceInstallationMixin:
elif context.get('machineid'): elif context.get('machineid'):
cmd += ['-m', context['machineid']] cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str] cmd += ['-o', install_dir_str, '-d', download_dir_str]
if context.get('skip_disk_check'):
cmd.append('--skip-disk-check')
original_env_values = { original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
@@ -199,9 +201,10 @@ class ModlistServiceInstallationMixin:
except (OSError, BrokenPipeError): except (OSError, BrokenPipeError):
return False return False
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error
import json as _json import json as _json
_cc_filename = None _cc_filename = None
_ck_missing = False
_pending_manual: list = [] _pending_manual: list = []
buffer = b'' buffer = b''
while True: while True:
@@ -263,6 +266,8 @@ class ModlistServiceInstallationMixin:
output_callback(decoded) output_callback(decoded)
if _cc_filename is None and is_cc_content_error(decoded): if _cc_filename is None and is_cc_content_error(decoded):
_cc_filename = extract_cc_filename(decoded) or "" _cc_filename = extract_cc_filename(decoded) or ""
if not _ck_missing and is_creation_kit_missing_error(decoded):
_ck_missing = True
if buffer: if buffer:
line = buffer.decode('utf-8', errors='replace') line = buffer.decode('utf-8', errors='replace')
@@ -271,6 +276,8 @@ class ModlistServiceInstallationMixin:
output_callback(decoded) output_callback(decoded)
if _cc_filename is None and is_cc_content_error(decoded): if _cc_filename is None and is_cc_content_error(decoded):
_cc_filename = extract_cc_filename(decoded) or "" _cc_filename = extract_cc_filename(decoded) or ""
if not _ck_missing and is_creation_kit_missing_error(decoded):
_ck_missing = True
proc.wait() proc.wait()
if proc.returncode != 0: if proc.returncode != 0:
@@ -285,6 +292,16 @@ class ModlistServiceInstallationMixin:
output_callback(" - If specific files are still missing, search for and download them from the Creations menu.") output_callback(" - If specific files are still missing, search for and download them from the Creations menu.")
output_callback(" - If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.") output_callback(" - If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.")
output_callback(" - Note: Skyrim AE via Steam Family Sharing does not transfer DLC content.") output_callback(" - Note: Skyrim AE via Steam Family Sharing does not transfer DLC content.")
if _ck_missing and output_callback:
output_callback("")
output_callback("[WARN] Creation Kit Files Missing")
output_callback(" This modlist requires the Skyrim Special Edition Creation Kit.")
output_callback(" - In Steam, search for 'Skyrim Special Edition: Creation Kit' and install it.")
output_callback(" - Right-click it in Steam > Properties > Compatibility and set a Proton version.")
output_callback(" - Click Play to launch the Creation Kit.")
output_callback(" - When asked whether to unzip Scripts.zip, select NO.")
output_callback(" - Once the Creation Kit opens successfully, close it.")
output_callback(" - Re-run the modlist install in Jackify.")
return False return False
if output_callback: if output_callback:
output_callback("Installation completed successfully") output_callback("Installation completed successfully")

View File

@@ -369,15 +369,70 @@ class UpdateService:
if progress_callback: if progress_callback:
progress_callback(downloaded_size, total_size) progress_callback(downloaded_size, total_size)
# Nexus delivers a 7z archive — extract the AppImage before handing off
if self._is_7z_archive(temp_file):
logger.info("Downloaded file is a 7z archive, extracting AppImage")
extracted = self._extract_appimage_from_7z(temp_file, update_dir, update_info.version)
temp_file.unlink(missing_ok=True)
if not extracted:
logger.error("Failed to extract AppImage from 7z archive")
return None
temp_file = extracted
# Make executable # Make executable
temp_file.chmod(0o755) temp_file.chmod(0o755)
logger.info("Update downloaded successfully: %s from %s -> %s", update_info.version, update_info.source, temp_file) logger.info("Update downloaded successfully: %s from %s -> %s", update_info.version, update_info.source, temp_file)
return temp_file return temp_file
except Exception as e: except Exception as e:
logger.error(f"Failed to download update manually: {e}") logger.error(f"Failed to download update manually: {e}")
return None return None
def _is_7z_archive(self, path: Path) -> bool:
"""Detect 7z archive by magic bytes (37 7A BC AF 27 1C)."""
try:
with open(path, 'rb') as f:
magic = f.read(6)
return magic == b'7z\xbc\xaf\x27\x1c'
except Exception:
return False
def _get_bundled_7z_path(self) -> Optional[Path]:
"""Return path to bundled 7z binary (AppImage or dev)."""
import os
candidates = []
appdir = os.environ.get('APPDIR')
if appdir:
candidates.append(Path(appdir) / 'opt' / 'jackify' / 'tools' / '7z')
candidates.append(Path(__file__).parent.parent.parent / 'tools' / '7z')
for p in candidates:
if p.exists() and os.access(p, os.X_OK):
return p
return None
def _extract_appimage_from_7z(self, archive: Path, dest_dir: Path, version: str) -> Optional[Path]:
"""Extract Jackify.AppImage from a 7z archive into dest_dir."""
seven_z = self._get_bundled_7z_path()
if not seven_z:
logger.error("Bundled 7z not found, cannot extract update archive")
return None
out_path = dest_dir / f"Jackify-{version}.AppImage"
try:
result = subprocess.run(
[str(seven_z), 'e', str(archive), 'Jackify.AppImage', f'-o{dest_dir}', '-y'],
capture_output=True, text=True, timeout=120
)
extracted = dest_dir / 'Jackify.AppImage'
if result.returncode != 0 or not extracted.exists():
logger.error("7z extraction failed (rc=%d): %s", result.returncode, result.stderr.strip())
return None
extracted.rename(out_path)
logger.info("Extracted AppImage from archive: %s", out_path)
return out_path
except Exception as e:
logger.error("Exception during 7z extraction: %s", e)
return None
def apply_update(self, new_appimage_path: Path) -> bool: def apply_update(self, new_appimage_path: Path) -> bool:
""" """

View File

@@ -32,3 +32,28 @@ def extract_cc_filename(line: str) -> Optional[str]:
"""Return the CC filename from a line, or None if not found.""" """Return the CC filename from a line, or None if not found."""
m = _CC_FILE_RE.search(line) m = _CC_FILE_RE.search(line)
return m.group(0) if m else None return m.group(0) if m else None
# Files that only exist inside the Skyrim SE Creation Kit install.
# Used to detect modlists that require the CK as a game file source.
_CK_INDICATORS = (
'creationkit',
'papyrus compiler',
'scriptcompile',
'lipgen',
'assetwatcher',
'havokbehaviorpostprocess',
'skyrimreservedaddonindexes',
'p4com64',
'lex_ssce',
)
def is_creation_kit_missing_error(line: str) -> bool:
"""Return True if line indicates a missing Creation Kit file (GameFileSource)."""
if not line:
return False
normalized = line.strip().lower()
if 'gamefilesource' not in normalized:
return False
return any(ind in normalized for ind in _CK_INDICATORS)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More