mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
Sync from development - prepare for v0.1.5.1
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
|||||||
# Jackify Changelog
|
# 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
|
## v0.1.5 - Winetricks Integration & Enhanced Compatibility
|
||||||
**Release Date:** September 26, 2025
|
**Release Date:** September 26, 2025
|
||||||
|
|
||||||
|
|||||||
@@ -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.1.5"
|
__version__ = "0.1.5.1"
|
||||||
|
|||||||
@@ -200,18 +200,26 @@ class WinetricksHandler:
|
|||||||
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
|
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
|
||||||
|
|
||||||
if specific_components is not None:
|
if specific_components is not None:
|
||||||
components_to_install = specific_components
|
all_components = specific_components
|
||||||
self.logger.info(f"Installing specific components: {components_to_install}")
|
self.logger.info(f"Installing specific components: {all_components}")
|
||||||
else:
|
else:
|
||||||
components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
|
all_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
|
||||||
self.logger.info(f"Installing default components: {components_to_install}")
|
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.")
|
self.logger.info("No Wine components to install.")
|
||||||
return True
|
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
|
max_attempts = 3
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
if attempt > 1:
|
if attempt > 1:
|
||||||
@@ -240,6 +248,23 @@ class WinetricksHandler:
|
|||||||
self.logger.info("Wine Component installation command completed successfully.")
|
self.logger.info("Wine Component installation command completed successfully.")
|
||||||
return True
|
return True
|
||||||
else:
|
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"Winetricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode}")
|
||||||
self.logger.error(f"Stdout: {result.stdout.strip()}")
|
self.logger.error(f"Stdout: {result.stdout.strip()}")
|
||||||
self.logger.error(f"Stderr: {result.stderr.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.")
|
self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.")
|
||||||
return False
|
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):
|
def _cleanup_wine_processes(self):
|
||||||
"""
|
"""
|
||||||
Internal method to clean up wine processes during component installation
|
Internal method to clean up wine processes during component installation
|
||||||
|
|||||||
@@ -333,15 +333,18 @@ class AutomatedPrefixService:
|
|||||||
logger.error(f"Steam userdata directory not found: {userdata_dir}")
|
logger.error(f"Steam userdata directory not found: {userdata_dir}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Find user directories (excluding user 0 which is a system account)
|
# Use NativeSteamService for proper user detection
|
||||||
user_dirs = [d for d in userdata_dir.iterdir() if d.is_dir() and d.name.isdigit() and d.name != "0"]
|
from ..services.native_steam_service import NativeSteamService
|
||||||
if not user_dirs:
|
steam_service = NativeSteamService()
|
||||||
logger.error("No valid Steam user directories found in userdata (user 0 is not valid)")
|
|
||||||
|
if not steam_service.find_steam_user():
|
||||||
|
logger.error("Could not detect Steam user for shortcuts")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Use the first valid user directory found
|
shortcuts_path = steam_service.get_shortcuts_vdf_path()
|
||||||
user_dir = user_dirs[0]
|
if not shortcuts_path:
|
||||||
shortcuts_path = user_dir / "config" / "shortcuts.vdf"
|
logger.error("Could not get shortcuts.vdf path from Steam service")
|
||||||
|
return None
|
||||||
|
|
||||||
logger.debug(f"Looking for shortcuts.vdf at: {shortcuts_path}")
|
logger.debug(f"Looking for shortcuts.vdf at: {shortcuts_path}")
|
||||||
if not shortcuts_path.exists():
|
if not shortcuts_path.exists():
|
||||||
@@ -2527,15 +2530,31 @@ echo Prefix creation complete.
|
|||||||
Returns:
|
Returns:
|
||||||
Path to localconfig.vdf or None if not found
|
Path to localconfig.vdf or None if not found
|
||||||
"""
|
"""
|
||||||
# Try the standard Steam userdata path
|
# Use NativeSteamService for proper user detection
|
||||||
steam_userdata_path = Path.home() / ".steam" / "steam" / "userdata"
|
try:
|
||||||
if steam_userdata_path.exists():
|
from ..services.native_steam_service import NativeSteamService
|
||||||
# Find user directories (excluding user 0 which is a system account)
|
steam_service = NativeSteamService()
|
||||||
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:
|
if steam_service.find_steam_user():
|
||||||
localconfig_path = user_dirs[0] / "config" / "localconfig.vdf"
|
localconfig_path = steam_service.user_config_path / "localconfig.vdf"
|
||||||
if localconfig_path.exists():
|
if localconfig_path.exists():
|
||||||
return str(localconfig_path)
|
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")
|
logger.error("Could not find localconfig.vdf")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import vdf
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple, Dict, Any, List
|
from typing import Optional, Tuple, Dict, Any, List
|
||||||
|
|
||||||
|
from ..handlers.vdf_handler import VDFHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class NativeSteamService:
|
class NativeSteamService:
|
||||||
@@ -46,8 +48,12 @@ class NativeSteamService:
|
|||||||
logger.error("No valid Steam user directories found (user 0 is not valid for shortcuts)")
|
logger.error("No valid Steam user directories found (user 0 is not valid for shortcuts)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Use the first valid user directory
|
# Detect the correct Steam user
|
||||||
user_dir = user_dirs[0]
|
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_id = user_dir.name
|
||||||
self.user_config_path = user_dir / "config"
|
self.user_config_path = user_dir / "config"
|
||||||
|
|
||||||
@@ -59,6 +65,96 @@ class NativeSteamService:
|
|||||||
logger.error(f"Error finding Steam user: {e}")
|
logger.error(f"Error finding Steam user: {e}")
|
||||||
return False
|
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]:
|
def get_shortcuts_vdf_path(self) -> Optional[Path]:
|
||||||
"""Get the path to shortcuts.vdf"""
|
"""Get the path to shortcuts.vdf"""
|
||||||
if not self.user_config_path:
|
if not self.user_config_path:
|
||||||
|
|||||||
@@ -884,7 +884,12 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
self._check_protontricks_on_startup()
|
self._check_protontricks_on_startup()
|
||||||
|
|
||||||
def _debug_screen_change(self, index):
|
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
|
# Only show debug info if debug mode is enabled
|
||||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||||
config_handler = ConfigHandler()
|
config_handler = ConfigHandler()
|
||||||
|
|||||||
10
jackify/frontends/gui/mixins/__init__.py
Normal file
10
jackify/frontends/gui/mixins/__init__.py
Normal 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']
|
||||||
66
jackify/frontends/gui/mixins/operation_lock_mixin.py
Normal file
66
jackify/frontends/gui/mixins/operation_lock_mixin.py
Normal 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
|
||||||
@@ -738,6 +738,30 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
else:
|
else:
|
||||||
return f"{elapsed_seconds_remainder} seconds"
|
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):
|
def cleanup(self):
|
||||||
"""Clean up any running threads when the screen is closed"""
|
"""Clean up any running threads when the screen is closed"""
|
||||||
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")
|
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")
|
||||||
|
|||||||
@@ -1326,6 +1326,27 @@ class ConfigureNewModlistScreen(QWidget):
|
|||||||
btn_exit.clicked.connect(on_exit)
|
btn_exit.clicked.connect(on_exit)
|
||||||
dlg.exec()
|
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):
|
def cleanup(self):
|
||||||
"""Clean up any running threads when the screen is closed"""
|
"""Clean up any running threads when the screen is closed"""
|
||||||
debug_print("DEBUG: cleanup called - cleaning up threads")
|
debug_print("DEBUG: cleanup called - cleaning up threads")
|
||||||
|
|||||||
@@ -2024,6 +2024,9 @@ class InstallModlistScreen(QWidget):
|
|||||||
self._safe_append_text(f"Configuration failed with error: {error_message}")
|
self._safe_append_text(f"Configuration failed with error: {error_message}")
|
||||||
MessageService.critical(self, "Configuration Error", f"Configuration failed: {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
|
# Clean up thread
|
||||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
# 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.cleanup_processes()
|
||||||
self.go_back()
|
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):
|
def closeEvent(self, event):
|
||||||
"""Handle window close event - clean up processes"""
|
"""Handle window close event - clean up processes"""
|
||||||
self.cleanup_processes()
|
self.cleanup_processes()
|
||||||
|
|||||||
Reference in New Issue
Block a user