Sync from development - prepare for v0.5.0.1

This commit is contained in:
Omni
2026-03-13 23:04:46 +00:00
parent 7278efd4cd
commit c294431a35
9 changed files with 150 additions and 103 deletions

View File

@@ -1,5 +1,11 @@
# Jackify Changelog
## v0.5.0.1 - Hotfix
**Release Date:** 13/03/26
- Fixed Proton prefix creation failing for users who previously had Flatpak Steam installed but have since switched to native Steam.
- Fixed Configure Existing Modlist mangling binary and working directory paths for modlists using a `StockGame` folder (no space variant).
## v0.5.0 - Non-Premium Support, Modlist Update Handling and Overall Reliability Improvements
**Release Date:** 13/03/26

View File

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

View File

@@ -166,7 +166,7 @@ class PathHandlerGameMixin:
return False
modlist_path = Path(self.modlist_dir)
preferred_order = [
"Stock Game", "STOCK GAME", "Skyrim Stock", "Stock Game Folder",
"Stock Game", "StockGame", "STOCK GAME", "Skyrim Stock", "Stock Game Folder",
"Stock Folder", Path("root/Skyrim Special Edition"), "Game Root"
]
found_path = None

View File

@@ -21,7 +21,7 @@ TARGET_EXECUTABLES_LOWER = [
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe",
"sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"
]
STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"]
STOCK_GAME_FOLDERS = ["Stock Game", "StockGame", "Game Root", "Stock Folder", "Skyrim Stock"]
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
@@ -433,10 +433,16 @@ class PathHandlerMO2Mixin:
if "/mods/" in cleaned_value:
idx = cleaned_value.index("/mods/")
rel_path = cleaned_value[idx:].lstrip('/')
elif existing_game_path:
rel_path = None
game_path_base = existing_game_path
else:
rel_path = exe_name
if rel_path is not None:
processed_modlist_path = self._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
else:
new_binary_path = f"{drive_prefix}/{game_path_base}/{exe_name}".replace('\\', '/').replace('//', '/')
formatted_binary_path = PathHandlerMO2Mixin._format_binary_for_mo2(new_binary_path)
if '"' in formatted_binary_path:
formatted_binary_path = formatted_binary_path.replace('"', '')

View File

@@ -5,12 +5,56 @@ import logging
import os
import time
import subprocess
import re
logger = logging.getLogger(__name__)
class PrefixCreationMixin:
"""Mixin providing prefix creation methods for AutomatedPrefixService."""
def _get_preferred_steam_root_and_type(self) -> tuple[Optional[Path], Optional[str]]:
"""Resolve the active Steam root/type using the shared v0.5 selector."""
from jackify.shared.steam_utils import (
STEAM_PREFERENCE_AUTO,
resolve_preferred_steam_installation,
)
from ..handlers.config_handler import ConfigHandler
preference = STEAM_PREFERENCE_AUTO
try:
preference = ConfigHandler().get("steam_install_preference", STEAM_PREFERENCE_AUTO)
except Exception:
logger.debug("Could not read steam_install_preference; falling back to auto", exc_info=True)
preferred_type, preferred_root = resolve_preferred_steam_installation(preference)
return preferred_root, preferred_type
def _get_library_roots_for_steam_root(self, steam_root: Path) -> list[Path]:
"""
Read library roots for one chosen Steam install only.
This avoids mixing native and Flatpak libraries in dual-install environments.
"""
roots: list[Path] = [steam_root]
vdf_path = steam_root / "config" / "libraryfolders.vdf"
if not vdf_path.is_file():
return roots
try:
text = vdf_path.read_text(encoding="utf-8", errors="ignore")
for match in re.finditer(r'"path"\s*"([^"]+)"', text):
raw_path = match.group(1).replace("\\\\", "\\")
lib_root = Path(raw_path).expanduser()
try:
resolved = lib_root.resolve()
except (OSError, RuntimeError):
resolved = lib_root
if resolved not in roots:
roots.append(resolved)
except Exception:
logger.debug("Failed reading libraryfolders.vdf for %s", steam_root, exc_info=True)
return roots
def _get_compatdata_path_for_appid(self, appid: int) -> Optional[Path]:
"""
Get the compatdata path for a given AppID.
@@ -31,13 +75,11 @@ class PrefixCreationMixin:
if compatdata_path:
return compatdata_path
# Prefix doesn't exist yet - determine where to create it from libraryfolders.vdf
library_paths = PathHandler.get_all_steam_library_paths()
if library_paths:
# Use the first library (typically the default library)
# Construct compatdata path: library_path/steamapps/compatdata/appid
first_library = library_paths[0]
compatdata_base = first_library / "steamapps" / "compatdata"
# Prefix doesn't exist yet - derive it from the selected active Steam root,
# not from a mixed native/Flatpak library list.
preferred_root, _preferred_type = self._get_preferred_steam_root_and_type()
if preferred_root:
compatdata_base = preferred_root / "steamapps" / "compatdata"
return compatdata_base / str(appid)
# Only fallback if VDF parsing completely fails
@@ -156,35 +198,23 @@ class PrefixCreationMixin:
True if successful, False otherwise
"""
try:
# Determine Steam locations based on installation type
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
all_libraries = path_handler.get_all_steam_library_paths()
# Check if we have Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
if is_flatpak_steam and all_libraries:
# Flatpak Steam: Use the actual library root from libraryfolders.vdf
# Compatdata should be in the library root, not the client root
flatpak_library_root = all_libraries[0] # Use first library (typically the default)
flatpak_client_root = flatpak_library_root.parent.parent / ".steam/steam"
if not flatpak_library_root.is_dir():
logger.error(
f"Flatpak Steam library root does not exist: {flatpak_library_root}"
)
# Determine Steam locations from the selected active Steam install only.
steam_root, steam_type = self._get_preferred_steam_root_and_type()
if not steam_root:
logger.error("Could not determine active Steam root for prefix creation")
return False
steam_root = flatpak_client_root if flatpak_client_root.is_dir() else flatpak_library_root
# CRITICAL: compatdata must be in the library root, not client root
compatdata_dir = flatpak_library_root / "steamapps/compatdata"
proton_common_dir = flatpak_library_root / "steamapps/common"
else:
# Native Steam (or unknown): fall back to legacy ~/.steam/steam layout
steam_root = Path.home() / ".steam/steam"
compatdata_dir = steam_root / "steamapps/compatdata"
proton_common_dir = steam_root / "steamapps/common"
if not steam_root.is_dir():
logger.error("Preferred Steam root does not exist: %s", steam_root)
return False
compatdata_dir = steam_root / "steamapps" / "compatdata"
proton_common_dir = steam_root / "steamapps" / "common"
logger.info(
"Prefix creation using preferred Steam install: type=%s root=%s",
steam_type or "unknown",
steam_root,
)
# Ensure compatdata root exists and is a directory we actually want to use
if not compatdata_dir.is_dir():
@@ -256,4 +286,3 @@ class PrefixCreationMixin:
except Exception as e:
logger.error(f"Error creating prefix: {e}")
return False

View File

@@ -373,7 +373,7 @@ exit"""
def get_prefix_path(self, appid: int) -> Optional[Path]:
"""
Get the path to the Proton prefix for the given AppID.
Uses same Flatpak detection as create_prefix_with_proton_wrapper.
Uses the same preferred Steam install selection as create_prefix_with_proton_wrapper.
Args:
appid: The AppID (unsigned, positive number)
@@ -381,20 +381,11 @@ exit"""
Returns:
Path to the prefix directory, or None if not found
"""
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
all_libraries = path_handler.get_all_steam_library_paths()
steam_root, _steam_type = self._get_preferred_steam_root_and_type()
if not steam_root:
return None
# Check if Flatpak Steam
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
if is_flatpak_steam and all_libraries:
# Flatpak Steam: use first library root
library_root = all_libraries[0]
compatdata_dir = library_root / "steamapps/compatdata"
else:
# Native Steam
compatdata_dir = Path.home() / ".steam/steam/steamapps/compatdata"
compatdata_dir = steam_root / "steamapps" / "compatdata"
# Ensure we use the absolute value (unsigned AppID)
prefix_dir = compatdata_dir / str(abs(appid))

View File

@@ -107,9 +107,9 @@ class UpdateService:
if nexus_url:
download_url = nexus_url
update_source = "nexus"
logger.debug(f"UPD-1001 update_source_selected source=nexus version={latest_version}")
logger.info("Update source: Nexus CDN (version %s)", latest_version)
else:
logger.debug(f"UPD-1001 update_source_selected source=github version={latest_version}")
logger.info("Update source: GitHub Releases (version %s)", latest_version)
# Determine if this is a delta update
is_delta = '.delta' in download_url or 'delta' in download_url.lower()
@@ -167,7 +167,7 @@ class UpdateService:
auth_service = NexusAuthService()
token = auth_service.get_auth_token()
if not token:
logger.debug("UPD-1002 nexus_lookup_skipped reason=missing_auth_token")
logger.info("Nexus update lookup skipped: no auth token")
return None
auth_method = auth_service.get_auth_method()
is_oauth = auth_method == "oauth"
@@ -175,7 +175,7 @@ class UpdateService:
from jackify.backend.services.nexus_premium_service import NexusPremiumService
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth)
if not is_premium:
logger.debug("UPD-1002 nexus_lookup_skipped reason=not_premium")
logger.info("Nexus update lookup skipped: not Premium")
return None
auth_headers = {"Accept": "application/json"}
@@ -201,7 +201,7 @@ class UpdateService:
match = match or f
if match is None:
logger.debug(f"UPD-1002 nexus_lookup_skipped reason=version_not_on_nexus version={target_version}")
logger.info("Nexus update lookup: version %s not found on Nexus", target_version)
return None
nexus_file_id = match["file_id"]
@@ -212,11 +212,11 @@ class UpdateService:
if isinstance(links, list) and links:
cdn_url = links[0].get("URI")
if cdn_url:
logger.debug(f"UPD-1003 nexus_lookup_success file_id={nexus_file_id} version={target_version}")
logger.info("Nexus update CDN link obtained for version %s (file_id=%s)", target_version, nexus_file_id)
return cdn_url
logger.debug("UPD-1002 nexus_lookup_skipped reason=empty_download_links")
logger.info("Nexus update lookup: empty download links for version %s", target_version)
except Exception as e:
logger.debug(f"UPD-1004 nexus_lookup_failed error={e}")
logger.info("Nexus update lookup failed, falling back to GitHub: %s", e)
return None
def _is_newer_version(self, version: str) -> bool:
@@ -320,8 +320,13 @@ class UpdateService:
Path to downloaded file, or None if download failed
"""
try:
logger.info(f"Downloading update {update_info.version} (full replacement)")
return self._download_update_manual(update_info, progress_callback)
logger.info("Downloading update %s from %s (full replacement)", update_info.version, update_info.source)
result = self._download_update_manual(update_info, progress_callback)
if result:
logger.info("Update download complete: %s from %s -> %s", update_info.version, update_info.source, result)
else:
logger.error("Update download failed: %s from %s", update_info.version, update_info.source)
return result
except Exception as e:
logger.error(f"Failed to download update: {e}")
@@ -340,7 +345,7 @@ class UpdateService:
Path to downloaded file, or None if download failed
"""
try:
logger.info(f"Manual download of update {update_info.version} from {update_info.download_url}")
logger.info("Downloading update %s from %s (%s)", update_info.version, update_info.source, update_info.download_url)
response = requests.get(update_info.download_url, stream=True)
response.raise_for_status()
@@ -367,7 +372,7 @@ class UpdateService:
# Make executable
temp_file.chmod(0o755)
logger.info(f"Manual update downloaded successfully to {temp_file}")
logger.info("Update downloaded successfully: %s from %s -> %s", update_info.version, update_info.source, temp_file)
return temp_file
except Exception as e:
@@ -397,8 +402,7 @@ class UpdateService:
helper_script = self._create_update_helper(current_appimage, new_appimage_path)
if helper_script:
# Launch helper script and exit
logger.info("Launching update helper and exiting")
logger.info("Applying update: replacing %s with %s", current_appimage, new_appimage_path)
subprocess.Popen(['nohup', 'bash', str(helper_script)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)

View File

@@ -46,6 +46,22 @@ class ConfigureExistingModlistScreen(
):
resize_request = Signal(str)
def _park_thread(self, thread, signal_names=None):
"""Disconnect a running thread from this screen and keep it alive until it finishes."""
if thread is None:
return None
signal_names = signal_names or []
for signal_name in signal_names:
try:
getattr(thread, signal_name).disconnect()
except Exception:
pass
if not hasattr(self, "_parked_threads"):
self._parked_threads = []
self._parked_threads.append(thread)
self._parked_threads = [t for t in self._parked_threads if getattr(t, "isRunning", lambda: False)()]
return None
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
if hasattr(self, 'file_progress_list'):
@@ -55,13 +71,11 @@ class ConfigureExistingModlistScreen(
for attr_name, value in list(vars(self).items()):
try:
if isinstance(value, QThread) and value.isRunning():
try:
value.finished_signal.disconnect()
except Exception:
pass
value.terminate()
value.wait(2000)
setattr(self, attr_name, None)
signal_names = []
for candidate in ("finished_signal", "progress_update", "configuration_complete", "error_occurred"):
if hasattr(value, candidate):
signal_names.append(candidate)
setattr(self, attr_name, self._park_thread(value, signal_names))
except Exception:
pass
@@ -96,12 +110,8 @@ class ConfigureExistingModlistScreen(
super().hideEvent(event)
if self._shortcut_loader is not None:
if self._shortcut_loader.isRunning():
try:
self._shortcut_loader.finished_signal.disconnect()
except Exception:
pass
self._shortcut_loader.terminate()
self._shortcut_loader.wait(2000)
self._shortcut_loader = self._park_thread(self._shortcut_loader, ["finished_signal", "error_signal"])
else:
self._shortcut_loader = None
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
@@ -226,12 +236,8 @@ class ConfigureExistingModlistScreen(
# Clean up config thread if running
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
logger.debug("DEBUG: Terminating ConfigurationThread")
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass
self.config_thread.terminate()
self.config_thread.wait(2000) # Wait up to 2 seconds
logger.debug("DEBUG: Parking ConfigurationThread")
self.config_thread = self._park_thread(
self.config_thread,
["progress_update", "configuration_complete", "error_occurred"],
)

View File

@@ -72,11 +72,17 @@ class ConfigureExistingModlistShortcutsMixin:
# GC'd while still running (which would cause Qt to abort).
if self._shortcut_loader is not None:
if self._shortcut_loader.isRunning():
if hasattr(self, '_park_thread'):
self._park_thread(self._shortcut_loader, ["finished_signal", "error_signal"])
else:
try:
self._shortcut_loader.finished_signal.disconnect()
except Exception:
pass
self._shortcut_loader.terminate()
try:
self._shortcut_loader.error_signal.disconnect()
except Exception:
pass
if not hasattr(self, '_old_loaders'):
self._old_loaders = []
self._old_loaders.append(self._shortcut_loader)
@@ -117,4 +123,3 @@ class ConfigureExistingModlistShortcutsMixin:
self.shortcut_combo.clear()
self.shortcut_combo.setEnabled(True)
self.shortcut_combo.addItem("Error loading modlists - please try again")