Sync from development - prepare for v0.1.5.1

This commit is contained in:
Omni
2025-09-28 12:15:44 +01:00
parent f46ed2c0fe
commit 8661f8963e
11 changed files with 502 additions and 30 deletions

View File

@@ -1,5 +1,15 @@
# Jackify Changelog
## v0.1.5.1 - Bug Fixes
**Release Date:** September 28, 2025
### Bug Fixes
- Fixed Steam user detection in multi-user environments
- Fixed controls not re-enabling after workflow errors
- Fixed screen state persistence between workflows
---
## v0.1.5 - Winetricks Integration & Enhanced Compatibility
**Release Date:** September 26, 2025

View File

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

View File

@@ -200,18 +200,26 @@ class WinetricksHandler:
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
if specific_components is not None:
components_to_install = specific_components
self.logger.info(f"Installing specific components: {components_to_install}")
all_components = specific_components
self.logger.info(f"Installing specific components: {all_components}")
else:
components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
self.logger.info(f"Installing default components: {components_to_install}")
all_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
self.logger.info(f"Installing default components: {all_components}")
if not components_to_install:
if not all_components:
self.logger.info("No Wine components to install.")
return True
self.logger.info(f"WINEPREFIX: {wineprefix}, Game: {game_var}, Components: {components_to_install}")
# Reorder components for proper installation sequence
components_to_install = self._reorder_components_for_installation(all_components)
self.logger.info(f"WINEPREFIX: {wineprefix}, Game: {game_var}, Ordered Components: {components_to_install}")
# Install components separately if dotnet40 is present (mimics protontricks behavior)
if "dotnet40" in components_to_install:
self.logger.info("dotnet40 detected - installing components separately like protontricks")
return self._install_components_separately(components_to_install, wineprefix, wine_binary, env)
# For non-dotnet40 installations, install all components together (faster)
max_attempts = 3
for attempt in range(1, max_attempts + 1):
if attempt > 1:
@@ -240,6 +248,23 @@ class WinetricksHandler:
self.logger.info("Wine Component installation command completed successfully.")
return True
else:
# Special handling for dotnet40 verification issue (mimics protontricks behavior)
if "dotnet40" in components_to_install and "ngen.exe not found" in result.stderr:
self.logger.warning("dotnet40 verification warning (common in Steam Proton prefixes)")
self.logger.info("Checking if dotnet40 was actually installed...")
# Check if dotnet40 appears in winetricks.log (indicates successful installation)
log_path = os.path.join(wineprefix, 'winetricks.log')
if os.path.exists(log_path):
try:
with open(log_path, 'r') as f:
log_content = f.read()
if 'dotnet40' in log_content:
self.logger.info("dotnet40 found in winetricks.log - installation succeeded despite verification warning")
return True
except Exception as e:
self.logger.warning(f"Could not read winetricks.log: {e}")
self.logger.error(f"Winetricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode}")
self.logger.error(f"Stdout: {result.stdout.strip()}")
self.logger.error(f"Stderr: {result.stderr.strip()}")
@@ -250,6 +275,169 @@ class WinetricksHandler:
self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.")
return False
def _reorder_components_for_installation(self, components: list) -> list:
"""
Reorder components for proper installation sequence.
Critical: dotnet40 must be installed before dotnet6/dotnet7 to avoid conflicts.
"""
# Simple reordering: dotnet40 first, then everything else
reordered = []
# Add dotnet40 first if it exists
if "dotnet40" in components:
reordered.append("dotnet40")
# Add all other components in original order
for component in components:
if component != "dotnet40":
reordered.append(component)
if reordered != components:
self.logger.info(f"Reordered for dotnet40 compatibility: {reordered}")
return reordered
def _prepare_prefix_for_dotnet(self, wineprefix: str, wine_binary: str) -> bool:
"""
Prepare the Wine prefix for .NET installation by mimicking protontricks preprocessing.
This removes mono components and specific symlinks that interfere with .NET installation.
"""
try:
env = os.environ.copy()
env['WINEDEBUG'] = '-all'
env['WINEPREFIX'] = wineprefix
# Step 1: Remove mono components (mimics protontricks behavior)
self.logger.info("Preparing prefix for .NET installation: removing mono")
mono_result = subprocess.run([
self.winetricks_path,
'-q',
'remove_mono'
], env=env, capture_output=True, text=True, timeout=300)
if mono_result.returncode != 0:
self.logger.warning(f"Mono removal warning (non-critical): {mono_result.stderr}")
# Step 2: Set Windows version to XP (protontricks uses winxp for dotnet40)
self.logger.info("Setting Windows version to XP for .NET compatibility")
winxp_result = subprocess.run([
self.winetricks_path,
'-q',
'winxp'
], env=env, capture_output=True, text=True, timeout=300)
if winxp_result.returncode != 0:
self.logger.warning(f"Windows XP setting warning: {winxp_result.stderr}")
# Step 3: Remove mscoree.dll symlinks (critical for .NET installation)
self.logger.info("Removing problematic mscoree.dll symlinks")
dosdevices_path = os.path.join(wineprefix, 'dosdevices', 'c:')
mscoree_paths = [
os.path.join(dosdevices_path, 'windows', 'syswow64', 'mscoree.dll'),
os.path.join(dosdevices_path, 'windows', 'system32', 'mscoree.dll')
]
for dll_path in mscoree_paths:
if os.path.exists(dll_path) or os.path.islink(dll_path):
try:
os.remove(dll_path)
self.logger.debug(f"Removed symlink: {dll_path}")
except Exception as e:
self.logger.warning(f"Could not remove {dll_path}: {e}")
self.logger.info("Prefix preparation complete for .NET installation")
return True
except Exception as e:
self.logger.error(f"Error preparing prefix for .NET: {e}")
return False
def _install_components_separately(self, components: list, wineprefix: str, wine_binary: str, base_env: dict) -> bool:
"""
Install components separately like protontricks does.
This is necessary when dotnet40 is present to avoid component conflicts.
"""
self.logger.info(f"Installing {len(components)} components separately (protontricks style)")
for i, component in enumerate(components, 1):
self.logger.info(f"Installing component {i}/{len(components)}: {component}")
# Prepare environment for this component
env = base_env.copy()
# Special preprocessing for dotnet40 only
if component == "dotnet40":
self.logger.info("Applying dotnet40 preprocessing")
if not self._prepare_prefix_for_dotnet(wineprefix, wine_binary):
self.logger.error("Failed to prepare prefix for dotnet40")
return False
else:
# For non-dotnet40 components, ensure we're in Windows 10 mode
self.logger.debug(f"Installing {component} in standard mode")
try:
subprocess.run([
self.winetricks_path, '-q', 'win10'
], env=env, capture_output=True, text=True, timeout=300)
except Exception as e:
self.logger.warning(f"Could not set win10 mode for {component}: {e}")
# Install this component
max_attempts = 3
component_success = False
for attempt in range(1, max_attempts + 1):
if attempt > 1:
self.logger.warning(f"Retrying {component} installation (attempt {attempt}/{max_attempts})")
self._cleanup_wine_processes()
try:
cmd = [self.winetricks_path, '--unattended', component]
env['WINEPREFIX'] = wineprefix
env['WINE'] = wine_binary
self.logger.debug(f"Running: {' '.join(cmd)}")
result = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=600
)
if result.returncode == 0:
self.logger.info(f"{component} installed successfully")
component_success = True
break
else:
# Special handling for dotnet40 verification issue
if component == "dotnet40" and "ngen.exe not found" in result.stderr:
self.logger.warning("dotnet40 verification warning (expected in Steam Proton)")
# Check winetricks.log for actual success
log_path = os.path.join(wineprefix, 'winetricks.log')
if os.path.exists(log_path):
try:
with open(log_path, 'r') as f:
if 'dotnet40' in f.read():
self.logger.info("✓ dotnet40 confirmed in winetricks.log")
component_success = True
break
except Exception as e:
self.logger.warning(f"Could not read winetricks.log: {e}")
self.logger.error(f"{component} failed (attempt {attempt}): {result.stderr.strip()[:200]}")
except Exception as e:
self.logger.error(f"Error installing {component} (attempt {attempt}): {e}")
if not component_success:
self.logger.error(f"Failed to install {component} after {max_attempts} attempts")
return False
self.logger.info("✓ All components installed successfully using separate sessions")
return True
def _cleanup_wine_processes(self):
"""
Internal method to clean up wine processes during component installation

View File

@@ -333,15 +333,18 @@ class AutomatedPrefixService:
logger.error(f"Steam userdata directory not found: {userdata_dir}")
return None
# Find user directories (excluding user 0 which is a system account)
user_dirs = [d for d in userdata_dir.iterdir() if d.is_dir() and d.name.isdigit() and d.name != "0"]
if not user_dirs:
logger.error("No valid Steam user directories found in userdata (user 0 is not valid)")
# Use NativeSteamService for proper user detection
from ..services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
if not steam_service.find_steam_user():
logger.error("Could not detect Steam user for shortcuts")
return None
# Use the first valid user directory found
user_dir = user_dirs[0]
shortcuts_path = user_dir / "config" / "shortcuts.vdf"
shortcuts_path = steam_service.get_shortcuts_vdf_path()
if not shortcuts_path:
logger.error("Could not get shortcuts.vdf path from Steam service")
return None
logger.debug(f"Looking for shortcuts.vdf at: {shortcuts_path}")
if not shortcuts_path.exists():
@@ -2527,15 +2530,31 @@ echo Prefix creation complete.
Returns:
Path to localconfig.vdf or None if not found
"""
# Try the standard Steam userdata path
steam_userdata_path = Path.home() / ".steam" / "steam" / "userdata"
if steam_userdata_path.exists():
# Find user directories (excluding user 0 which is a system account)
user_dirs = [d for d in steam_userdata_path.iterdir() if d.is_dir() and d.name.isdigit() and d.name != "0"]
if user_dirs:
localconfig_path = user_dirs[0] / "config" / "localconfig.vdf"
# Use NativeSteamService for proper user detection
try:
from ..services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
if steam_service.find_steam_user():
localconfig_path = steam_service.user_config_path / "localconfig.vdf"
if localconfig_path.exists():
return str(localconfig_path)
except Exception as e:
logger.error(f"Error using Steam service for localconfig.vdf detection: {e}")
# Fallback to manual detection
steam_userdata_path = Path.home() / ".steam" / "steam" / "userdata"
if steam_userdata_path.exists():
user_dirs = [d for d in steam_userdata_path.iterdir() if d.is_dir() and d.name.isdigit() and d.name != "0"]
if user_dirs:
# Use most recently modified directory as fallback
try:
most_recent = max(user_dirs, key=lambda d: d.stat().st_mtime)
localconfig_path = most_recent / "config" / "localconfig.vdf"
if localconfig_path.exists():
return str(localconfig_path)
except Exception:
pass
logger.error("Could not find localconfig.vdf")
return None

View File

@@ -15,6 +15,8 @@ import vdf
from pathlib import Path
from typing import Optional, Tuple, Dict, Any, List
from ..handlers.vdf_handler import VDFHandler
logger = logging.getLogger(__name__)
class NativeSteamService:
@@ -46,8 +48,12 @@ class NativeSteamService:
logger.error("No valid Steam user directories found (user 0 is not valid for shortcuts)")
return False
# Use the first valid user directory
user_dir = user_dirs[0]
# Detect the correct Steam user
user_dir = self._detect_active_steam_user(user_dirs)
if not user_dir:
logger.error("Could not determine active Steam user")
return False
self.user_id = user_dir.name
self.user_config_path = user_dir / "config"
@@ -59,6 +65,96 @@ class NativeSteamService:
logger.error(f"Error finding Steam user: {e}")
return False
def _detect_active_steam_user(self, user_dirs: List[Path]) -> Optional[Path]:
"""
Detect the active Steam user from available user directories.
Priority:
1. Single non-0 user: Use automatically
2. Multiple users: Parse loginusers.vdf to find logged-in user
3. Fallback: Most recently active user directory
Args:
user_dirs: List of valid user directories
Returns:
Path to the active user directory, or None if detection fails
"""
if len(user_dirs) == 1:
logger.info(f"Single Steam user found: {user_dirs[0].name}")
return user_dirs[0]
logger.info(f"Multiple Steam users found: {[d.name for d in user_dirs]}")
# Try to parse loginusers.vdf to find logged-in user
loginusers_path = self.steam_path / "loginusers.vdf"
active_user = self._parse_loginusers_vdf(loginusers_path)
if active_user:
# Find matching user directory
for user_dir in user_dirs:
if user_dir.name == active_user:
logger.info(f"Found logged-in Steam user from loginusers.vdf: {active_user}")
return user_dir
logger.warning(f"Logged-in user {active_user} from loginusers.vdf not found in user directories")
# Fallback: Use most recently modified user directory
try:
most_recent = max(user_dirs, key=lambda d: d.stat().st_mtime)
logger.info(f"Using most recently active Steam user directory: {most_recent.name}")
return most_recent
except Exception as e:
logger.error(f"Error determining most recent user directory: {e}")
# Final fallback: Use first user
logger.warning(f"Using first user directory as final fallback: {user_dirs[0].name}")
return user_dirs[0]
def _parse_loginusers_vdf(self, loginusers_path: Path) -> Optional[str]:
"""
Parse loginusers.vdf to find the currently logged-in Steam user.
Args:
loginusers_path: Path to loginusers.vdf
Returns:
Steam user ID as string, or None if parsing fails
"""
try:
if not loginusers_path.exists():
logger.debug(f"loginusers.vdf not found at {loginusers_path}")
return None
# Load VDF data
vdf_data = VDFHandler.load(str(loginusers_path), binary=False)
if not vdf_data:
logger.error("Failed to parse loginusers.vdf")
return None
users_section = vdf_data.get("users", {})
if not users_section:
logger.debug("No users section found in loginusers.vdf")
return None
# Find user marked as logged in
for user_id, user_data in users_section.items():
if isinstance(user_data, dict):
# Check for indicators of logged-in status
if user_data.get("MostRecent") == "1" or user_data.get("WantsOfflineMode") == "0":
logger.debug(f"Found most recent/logged-in user: {user_id}")
return user_id
# If no specific user found, try to get the first valid user
if users_section:
first_user = next(iter(users_section.keys()))
logger.debug(f"No specific logged-in user found, using first user: {first_user}")
return first_user
except Exception as e:
logger.error(f"Error parsing loginusers.vdf: {e}")
return None
def get_shortcuts_vdf_path(self) -> Optional[Path]:
"""Get the path to shortcuts.vdf"""
if not self.user_config_path:

View File

@@ -884,7 +884,12 @@ class JackifyMainWindow(QMainWindow):
self._check_protontricks_on_startup()
def _debug_screen_change(self, index):
"""Debug method to track screen changes"""
"""Handle screen changes - debug logging and state reset"""
# Reset screen state when switching to workflow screens
widget = self.stacked_widget.widget(index)
if widget and hasattr(widget, 'reset_screen_to_defaults'):
widget.reset_screen_to_defaults()
# Only show debug info if debug mode is enabled
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GUI Mixins Package
Reusable mixins for GUI functionality
"""
from .operation_lock_mixin import OperationLockMixin
__all__ = ['OperationLockMixin']

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Operation Lock Mixin
Provides reliable button state management for GUI operations
"""
from contextlib import contextmanager
class OperationLockMixin:
"""
Mixin that provides reliable button state management.
Ensures controls are always re-enabled after operations, even if exceptions occur.
"""
def operation_lock(self):
"""
Context manager that ensures controls are always re-enabled after operations.
Usage:
with self.operation_lock():
# Perform operation that might fail
risky_operation()
# Controls are guaranteed to be re-enabled here
"""
@contextmanager
def lock_manager():
try:
if hasattr(self, '_disable_controls_during_operation'):
self._disable_controls_during_operation()
yield
finally:
# Ensure controls are re-enabled even if exceptions occur
if hasattr(self, '_enable_controls_after_operation'):
self._enable_controls_after_operation()
return lock_manager()
def safe_operation(self, operation_func, *args, **kwargs):
"""
Execute an operation with automatic button state management.
Args:
operation_func: Function to execute
*args, **kwargs: Arguments to pass to operation_func
Returns:
Result of operation_func or None if exception occurred
"""
try:
with self.operation_lock():
return operation_func(*args, **kwargs)
except Exception as e:
# Log the error but don't re-raise - controls are already re-enabled
if hasattr(self, 'logger'):
self.logger.error(f"Operation failed: {e}", exc_info=True)
# Could also show user error dialog here if needed
return None
def reset_screen_to_defaults(self):
"""
Reset the screen to default state when navigating back from main menu.
Override this method in subclasses to implement screen-specific reset logic.
"""
pass # Default implementation does nothing - subclasses should override

View File

@@ -738,6 +738,30 @@ class ConfigureExistingModlistScreen(QWidget):
else:
return f"{elapsed_seconds_remainder} seconds"
def reset_screen_to_defaults(self):
"""Reset the screen to default state when navigating back from main menu"""
# Clear the shortcut selection
self.shortcut_combo.clear()
self.shortcut_map.clear()
# Auto-refresh modlist list when screen is entered
self.refresh_modlist_list()
# Clear console and process monitor
self.console.clear()
self.process_monitor.clear()
# Reset resolution combo to saved config preference
saved_resolution = self.resolution_service.get_saved_resolution()
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
elif self.resolution_combo.count() > 0:
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")

View File

@@ -1326,6 +1326,27 @@ class ConfigureNewModlistScreen(QWidget):
btn_exit.clicked.connect(on_exit)
dlg.exec()
def reset_screen_to_defaults(self):
"""Reset the screen to default state when navigating back from main menu"""
# Reset form fields
self.install_dir_edit.setText("/path/to/Modlist/ModOrganizer.exe")
# Clear console and process monitor
self.console.clear()
self.process_monitor.clear()
# Reset resolution combo to saved config preference
saved_resolution = self.resolution_service.get_saved_resolution()
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
elif self.resolution_combo.count() > 0:
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
debug_print("DEBUG: cleanup called - cleaning up threads")

View File

@@ -2024,6 +2024,9 @@ class InstallModlistScreen(QWidget):
self._safe_append_text(f"Configuration failed with error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}")
# Re-enable all controls on error
self._enable_controls_after_operation()
# Clean up thread
if hasattr(self, 'config_thread') and self.config_thread is not None:
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
@@ -2676,6 +2679,36 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
self.cleanup_processes()
self.go_back()
def reset_screen_to_defaults(self):
"""Reset the screen to default state when navigating back from main menu"""
# Reset form fields
self.modlist_btn.setText("Select Modlist")
self.modlist_btn.setEnabled(False)
self.file_edit.setText("")
self.modlist_name_edit.setText("")
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
# Reset game type button
self.game_type_btn.setText("Please Select...")
# Clear console and process monitor
self.console.clear()
self.process_monitor.clear()
# Reset tabs to first tab (Online)
self.source_tabs.setCurrentIndex(0)
# Reset resolution combo to saved config preference
saved_resolution = self.resolution_service.get_saved_resolution()
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
elif self.resolution_combo.count() > 0:
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()
def closeEvent(self, event):
"""Handle window close event - clean up processes"""
self.cleanup_processes()