mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-07 22:57:45 +02:00
Sync from development - prepare for v0.5.0.1
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('"', '')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user