From c294431a3525d6839b34a0d024cc79dfa97864a2 Mon Sep 17 00:00:00 2001 From: Omni Date: Fri, 13 Mar 2026 23:04:46 +0000 Subject: [PATCH] Sync from development - prepare for v0.5.0.1 --- CHANGELOG.md | 6 + jackify/__init__.py | 2 +- jackify/backend/handlers/path_handler_game.py | 2 +- jackify/backend/handlers/path_handler_mo2.py | 12 +- .../services/automated_prefix_creation.py | 105 +++++++++++------- .../services/automated_prefix_service.py | 19 +--- jackify/backend/services/update_service.py | 32 +++--- .../gui/screens/configure_existing_modlist.py | 52 +++++---- .../configure_existing_modlist_shortcuts.py | 23 ++-- 9 files changed, 150 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d73f617..3dccf40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/jackify/__init__.py b/jackify/__init__.py index 523edbc..d802551 100644 --- a/jackify/__init__.py +++ b/jackify/__init__.py @@ -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" diff --git a/jackify/backend/handlers/path_handler_game.py b/jackify/backend/handlers/path_handler_game.py index bc99972..01a0cd0 100644 --- a/jackify/backend/handlers/path_handler_game.py +++ b/jackify/backend/handlers/path_handler_game.py @@ -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 diff --git a/jackify/backend/handlers/path_handler_mo2.py b/jackify/backend/handlers/path_handler_mo2.py index 46b62b5..8b3509a 100644 --- a/jackify/backend/handlers/path_handler_mo2.py +++ b/jackify/backend/handlers/path_handler_mo2.py @@ -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 - 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('//', '/') + 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('"', '') diff --git a/jackify/backend/services/automated_prefix_creation.py b/jackify/backend/services/automated_prefix_creation.py index 9a88160..74a6a85 100644 --- a/jackify/backend/services/automated_prefix_creation.py +++ b/jackify/backend/services/automated_prefix_creation.py @@ -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,36 +198,24 @@ 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}" - ) - 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" - + # 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 + + 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(): logger.error(f"Compatdata root does not exist: {compatdata_dir}. Aborting prefix creation.") @@ -256,4 +286,3 @@ class PrefixCreationMixin: except Exception as e: logger.error(f"Error creating prefix: {e}") return False - diff --git a/jackify/backend/services/automated_prefix_service.py b/jackify/backend/services/automated_prefix_service.py index f1ac03f..7b87cb0 100644 --- a/jackify/backend/services/automated_prefix_service.py +++ b/jackify/backend/services/automated_prefix_service.py @@ -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)) diff --git a/jackify/backend/services/update_service.py b/jackify/backend/services/update_service.py index 211fc8f..727d41b 100644 --- a/jackify/backend/services/update_service.py +++ b/jackify/backend/services/update_service.py @@ -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) diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py index ed64f06..a23f265 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -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,13 +110,9 @@ 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 = None + 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): """Handle configuration completion""" @@ -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"], + ) diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py b/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py index c054569..be81403 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py @@ -72,14 +72,20 @@ 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(): - try: - self._shortcut_loader.finished_signal.disconnect() - except Exception: - pass - self._shortcut_loader.terminate() - if not hasattr(self, '_old_loaders'): - self._old_loaders = [] - self._old_loaders.append(self._shortcut_loader) + 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 + 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) self._shortcut_loader = None # Purge finished threads from the holding list @@ -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") -