2 Commits
v0.2.1 ... main

Author SHA1 Message Date
Omni
9000b1e080 Sync from development - prepare for v0.2.1.1 2026-01-15 18:07:49 +00:00
Omni
02f3d71a82 Sync from development - prepare for v0.2.1.1 2026-01-15 18:06:02 +00:00
22 changed files with 795 additions and 193 deletions

View File

@@ -1,8 +1,30 @@
# Jackify Changelog # Jackify Changelog
## v0.2.1.1 - Bug Fixes and Improvements
**Release Date:** 2026-01-15
### Critical Bug Fixes
- **AppImage Crash on Steam Deck**: Fixed `NameError: name 'Tuple' is not defined` that prevented AppImage from launching on Steam Deck. Added missing `Tuple` import to `progress_models.py`
### Bug Fixes
- **Menu Routing**: Fixed "Configure Existing Modlist (In Steam)" opening wrong section (was routing to Wabbajack Installer instead of Configure Existing screen)
- **TTW Install Dialogue**: Fixed incorrect account reference (changed "mod.db" to "ModPub" to match actual download source)
- **Duplicate Method**: Removed duplicate `_handle_missing_downloader_error` method in winetricks handler
- **Issue #142**: Removed sudo execution from modlist configuration - now auto-fixes permissions when possible, provides manual instructions only when sudo required
- **Issue #133**: Updated VDF library to 4.0 for improved Steam file format compatibility (protontricks 1.13.1+ support)
### Features
- **Wine Component Error Handling**: Enhanced error messages for missing downloaders with platform-specific installation instructions (SteamOS/Steam Deck vs other distros)
### Dependencies
- **VDF Library**: Updated from PyPI vdf 3.4 to actively maintained solsticegamestudios/vdf 4.0 (used by Gentoo)
- **Winetricks**: Removed bundled downloaders that caused segfaults on some systems - now uses system-provided downloaders (aria2c/wget/curl)
---
## v0.2.1 - Wabbajack Installer and ENB Support ## v0.2.1 - Wabbajack Installer and ENB Support
**Release Date:** 2025-01-12 **Release Date:** 2025-01-12
Y
### Major Features ### Major Features
- **Automated Wabbajack Installation**: While I work on Non-Premium support, there is still a call for Wabbajack via Proton. The existing legacy bash script has been proving troublesome for some users, so I've added this as a new feature within Jackify. My aim is still to not need this in future, once Jackify can cover Non-Premium accounts. - **Automated Wabbajack Installation**: While I work on Non-Premium support, there is still a call for Wabbajack via Proton. The existing legacy bash script has been proving troublesome for some users, so I've added this as a new feature within Jackify. My aim is still to not need this in future, once Jackify can cover Non-Premium accounts.
- **ENB Detection and Configuration**: Automatic detection and configuration of `enblocal.ini` with `LinuxVersion=true` for all supported games - **ENB Detection and Configuration**: Automatic detection and configuration of `enblocal.ini` with `LinuxVersion=true` for all supported games

View File

@@ -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.2.1" __version__ = "0.2.1.1"

View File

@@ -721,59 +721,75 @@ class FileSystemHandler:
return True return True
@staticmethod @staticmethod
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool: def verify_ownership_and_permissions(path: Path) -> tuple[bool, str]:
"""Change ownership and permissions using sudo (robust, with timeout and re-prompt).""" """
Verify and fix ownership/permissions for modlist directory.
Returns (success, error_message).
Logic:
- If files NOT owned by user: Can't fix without sudo, return error with instructions
- If files owned by user: Try to fix permissions ourselves with chmod
"""
if not path.exists(): if not path.exists():
logger.error(f"Path does not exist: {path}") logger.error(f"Path does not exist: {path}")
return False return False, f"Path does not exist: {path}"
# Check if all files/dirs are already owned by the user
if FileSystemHandler.all_owned_by_user(path): # Check if all files/dirs are owned by the user
logger.info(f"All files in {path} are already owned by the current user. Skipping sudo chown/chmod.") if not FileSystemHandler.all_owned_by_user(path):
return True # Files not owned by us - need sudo to fix
try: try:
user_name = pwd.getpwuid(os.geteuid()).pw_name user_name = pwd.getpwuid(os.geteuid()).pw_name
group_name = grp.getgrgid(os.geteuid()).gr_name group_name = grp.getgrgid(os.geteuid()).gr_name
except KeyError: except KeyError:
logger.error("Could not determine current user or group name.") logger.error("Could not determine current user or group name.")
return False return False, "Could not determine current user or group name."
log_msg = f"Applying ownership/permissions for {path} (user: {user_name}, group: {group_name}) via sudo." logger.error(f"Ownership issue detected: Some files in {path} are not owned by {user_name}")
logger.info(log_msg)
if status_callback:
status_callback(f"Setting ownership/permissions for {os.path.basename(str(path))}...")
else:
print(f'\n{COLOR_PROMPT}Adjusting permissions for {path} (may require sudo password)...{COLOR_RESET}')
def run_sudo_with_retries(cmd, desc, max_retries=3, timeout=300): error_msg = (
for attempt in range(max_retries): f"\nOwnership Issue Detected\n"
f"Some files in the modlist directory are not owned by your user account.\n"
f"This can happen if the modlist was copied from another location or installed by a different user.\n\n"
f"To fix this, open a terminal and run:\n\n"
f" sudo chown -R {user_name}:{group_name} \"{path}\"\n"
f" sudo chmod -R 755 \"{path}\"\n\n"
f"After running these commands, retry the configuration process."
)
return False, error_msg
# Files are owned by us - try to fix permissions ourselves
logger.info(f"Files in {path} are owned by current user, verifying permissions...")
try: try:
logger.info(f"Running sudo command (attempt {attempt+1}/{max_retries}): {' '.join(cmd)}") result = subprocess.run(
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout) ['chmod', '-R', '755', str(path)],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0: if result.returncode == 0:
return True logger.info(f"Permissions set successfully for {path}")
return True, ""
else: else:
logger.error(f"sudo {desc} failed. Error: {result.stderr.strip()}") logger.warning(f"chmod returned non-zero but we'll continue: {result.stderr}")
print(f"Error: Failed to {desc}. Check logs.") # Non-critical if chmod fails on our own files, might be read-only filesystem or similar
return False return True, ""
except subprocess.TimeoutExpired: except Exception as e:
logger.error(f"sudo {desc} timed out (attempt {attempt+1}/{max_retries}).") logger.warning(f"Error running chmod: {e}, continuing anyway")
print(f"\nSudo prompt timed out after {timeout} seconds. Please try again.") # Non-critical error, we own the files so proceed
# Flush input if possible, then retry return True, ""
print(f"Failed to {desc} after {max_retries} attempts. Aborting.")
return False
# Run chown with retries @staticmethod
chown_command = ['sudo', 'chown', '-R', f'{user_name}:{group_name}', str(path)] def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
if not run_sudo_with_retries(chown_command, "change ownership"): """
return False DEPRECATED: Use verify_ownership_and_permissions() instead.
print() This method is kept for backwards compatibility but no longer executes sudo.
# Run chmod with retries """
chmod_command = ['sudo', 'chmod', '-R', '755', str(path)] logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
if not run_sudo_with_retries(chmod_command, "set permissions"): success, error_msg = FileSystemHandler.verify_ownership_and_permissions(path)
return False if not success:
print() logger.error(error_msg)
logger.info("Permissions set successfully.") print(error_msg)
return True return success
def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool: def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool:
"""Downloads a file from a URL to a destination path.""" """Downloads a file from a URL to a destination path."""

View File

@@ -14,7 +14,7 @@ import shutil
class LoggingHandler: class LoggingHandler:
""" """
Central logging handler for Jackify. Central logging handler for Jackify.
- Uses ~/Jackify/logs/ as the log directory. - Uses configured Jackify data directory for logs (default: ~/Jackify/logs/).
- Supports per-function log files (e.g., jackify-install-wabbajack.log). - Supports per-function log files (e.g., jackify-install-wabbajack.log).
- Handles log rotation and log directory creation. - Handles log rotation and log directory creation.
Usage: Usage:
@@ -186,5 +186,5 @@ class LoggingHandler:
return stats return stats
def get_general_logger(self): def get_general_logger(self):
"""Get the general CLI logger (~/Jackify/logs/jackify-cli.log).""" """Get the general CLI logger ({jackify_data_dir}/logs/jackify-cli.log)."""
return self.setup_logger('jackify_cli', is_general=True) return self.setup_logger('jackify_cli', is_general=True)

View File

@@ -806,17 +806,18 @@ class ModlistHandler:
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)") self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done") self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
# Step 5: Ensure permissions of Modlist directory # Step 5: Verify ownership of Modlist directory
if status_callback: if status_callback:
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory") status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
self.logger.info("Step 5: Setting ownership and permissions for modlist directory...") self.logger.info("Step 5: Verifying ownership of modlist directory...")
# Convert modlist_dir string to Path object for the method # Convert modlist_dir string to Path object for the method
modlist_path_obj = Path(self.modlist_dir) modlist_path_obj = Path(self.modlist_dir)
if not self.filesystem_handler.set_ownership_and_permissions_sudo(modlist_path_obj): success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj)
self.logger.error("Failed to set ownership/permissions for modlist directory. Configuration aborted.") if not success:
print("Error: Failed to set permissions for the modlist directory.") self.logger.error("Ownership verification failed for modlist directory. Configuration aborted.")
print(f"\n{COLOR_ERROR}{error_msg}{COLOR_RESET}")
return False # Abort on failure return False # Abort on failure
self.logger.info("Step 5: Setting ownership and permissions... Done") self.logger.info("Step 5: Ownership verification... Done")
# Step 6: Backup ModOrganizer.ini # Step 6: Backup ModOrganizer.ini
if status_callback: if status_callback:

View File

@@ -845,6 +845,10 @@ class ProgressStateManager:
self._wabbajack_entry_name = None self._wabbajack_entry_name = None
self._synthetic_flag = "_synthetic_wabbajack" self._synthetic_flag = "_synthetic_wabbajack"
self._previous_phase = None # Track phase changes to reset stale data self._previous_phase = None # Track phase changes to reset stale data
# Track total download size from all files seen during download phase
self._download_files_seen = {} # filename -> (total_size, max_current_size)
self._download_total_bytes = 0 # Running total of all file sizes seen
self._download_processed_bytes = 0 # Running total of bytes processed
def process_line(self, line: str) -> bool: def process_line(self, line: str) -> bool:
""" """
@@ -869,6 +873,12 @@ class ProgressStateManager:
# Phase is changing - selectively reset stale data from previous phase # Phase is changing - selectively reset stale data from previous phase
previous_phase = self.state.phase previous_phase = self.state.phase
# Reset download tracking when leaving download phase
if previous_phase == InstallationPhase.DOWNLOAD:
self._download_files_seen = {}
self._download_total_bytes = 0
self._download_processed_bytes = 0
# Only reset data sizes when transitioning FROM VALIDATE phase # Only reset data sizes when transitioning FROM VALIDATE phase
# Validation phase data sizes are from .wabbajack file and shouldn't persist # Validation phase data sizes are from .wabbajack file and shouldn't persist
if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info: if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info:
@@ -970,6 +980,39 @@ class ProgressStateManager:
if parsed.file_progress.operation == OperationType.DOWNLOAD: if parsed.file_progress.operation == OperationType.DOWNLOAD:
self._remove_all_wabbajack_entries() self._remove_all_wabbajack_entries()
self._has_real_wabbajack = True # Prevent re-adding self._has_real_wabbajack = True # Prevent re-adding
# Track download totals from all files seen during download phase
# This allows us to calculate overall remaining/ETA even when engine doesn't report data_total
from jackify.shared.progress_models import OperationType
if self.state.phase == InstallationPhase.DOWNLOAD and parsed.file_progress.operation == OperationType.DOWNLOAD:
filename = parsed.file_progress.filename
total_size = parsed.file_progress.total_size or 0
current_size = parsed.file_progress.current_size or 0
# Track this file's max size and current progress
if filename not in self._download_files_seen:
# New file - add its total size to our running total
if total_size > 0:
self._download_total_bytes += total_size
self._download_files_seen[filename] = (total_size, current_size)
self._download_processed_bytes += current_size
else:
# Existing file - update current size and track max
old_total, old_current = self._download_files_seen[filename]
# If total_size increased (file size discovered), update our total
if total_size > old_total:
self._download_total_bytes += (total_size - old_total)
# Update processed bytes (only count increases)
if current_size > old_current:
self._download_processed_bytes += (current_size - old_current)
self._download_files_seen[filename] = (max(old_total, total_size), current_size)
# If engine didn't provide data_total, use our aggregated total
if self.state.data_total == 0 and self._download_total_bytes > 0:
self.state.data_total = self._download_total_bytes
self.state.data_processed = self._download_processed_bytes
updated = True
self._augment_file_metrics(parsed.file_progress) self._augment_file_metrics(parsed.file_progress)
# Don't add files that are already at 100% unless they're being updated # Don't add files that are already at 100% unless they're being updated
# This prevents re-adding completed files # This prevents re-adding completed files
@@ -1023,6 +1066,22 @@ class ProgressStateManager:
parsed.completed_filename = None parsed.completed_filename = None
if parsed.completed_filename: if parsed.completed_filename:
# Track completed files in download totals
if self.state.phase == InstallationPhase.DOWNLOAD:
filename = parsed.completed_filename
# If we were tracking this file, mark it as complete (100% of total)
if filename in self._download_files_seen:
old_total, old_current = self._download_files_seen[filename]
# Ensure processed bytes equals total for completed file
if old_current < old_total:
self._download_processed_bytes += (old_total - old_current)
self._download_files_seen[filename] = (old_total, old_total)
# Update state if needed
if self.state.data_total == 0 and self._download_total_bytes > 0:
self.state.data_total = self._download_total_bytes
self.state.data_processed = self._download_processed_bytes
updated = True
# Try to find existing file in the list # Try to find existing file in the list
found_existing = False found_existing = False
for file_prog in self.state.active_files: for file_prog in self.state.active_files:

View File

@@ -453,13 +453,56 @@ class ProtontricksHandler:
return True return True
logger.info("Setting Protontricks permissions...") logger.info("Setting Protontricks permissions...")
try:
# Bundled-runtime fix: Use cleaned environment # Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env() env = self._get_clean_subprocess_env()
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", permissions_set = []
f"--filesystem={modlist_dir}"], check=True, env=env) permissions_failed = []
try:
# 1. Set permission for modlist directory (required for wine component installation)
logger.debug(f"Setting permission for modlist directory: {modlist_dir}")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={modlist_dir}"], check=True, env=env, capture_output=True)
permissions_set.append(f"modlist directory: {modlist_dir}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"modlist directory: {modlist_dir} ({e})")
logger.warning(f"Failed to set permission for modlist directory: {e}")
# 2. Set permission for main Steam directory (required for accessing compatdata, config, etc.)
steam_dir = self._get_steam_dir_from_libraryfolders()
if steam_dir and steam_dir.exists():
logger.info(f"Setting permission for Steam directory: {steam_dir}")
logger.debug("This allows protontricks to access Steam compatdata, config, and steamapps directories")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={steam_dir}"], check=True, env=env, capture_output=True)
permissions_set.append(f"Steam directory: {steam_dir}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"Steam directory: {steam_dir} ({e})")
logger.warning(f"Failed to set permission for Steam directory: {e}")
else:
logger.warning("Could not determine Steam directory - protontricks may not have access to Steam directories")
# 3. Set permissions for all additional Steam library folders (compatdata can be in any library)
from ..handlers.path_handler import PathHandler
all_library_paths = PathHandler.get_all_steam_library_paths()
for lib_path in all_library_paths:
# Skip if this is the main Steam directory (already set above)
if steam_dir and lib_path.resolve() == steam_dir.resolve():
continue
if lib_path.exists():
logger.debug(f"Setting permission for Steam library folder: {lib_path}")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={lib_path}"], check=True, env=env, capture_output=True)
permissions_set.append(f"Steam library: {lib_path}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"Steam library: {lib_path} ({e})")
logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
# 4. Set SD card permissions (Steam Deck only)
if steamdeck: if steamdeck:
logger.warn("Checking for SDCard and setting permissions appropriately...") logger.warn("Checking for SDCard and setting permissions appropriately...")
# Find sdcard path # Find sdcard path
@@ -468,15 +511,40 @@ class ProtontricksHandler:
if "/run/media" in line: if "/run/media" in line:
sdcard_path = line.split()[-1] sdcard_path = line.split()[-1]
logger.debug(f"SDCard path: {sdcard_path}") logger.debug(f"SDCard path: {sdcard_path}")
try:
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}", subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
"com.github.Matoking.protontricks"], check=True, env=env) "com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
permissions_set.append(f"SD card: {sdcard_path}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"SD card: {sdcard_path} ({e})")
logger.warning(f"Failed to set permission for SD card {sdcard_path}: {e}")
# Add standard Steam Deck SD card path as fallback # Add standard Steam Deck SD card path as fallback
try:
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1", subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
"com.github.Matoking.protontricks"], check=True, env=env) "com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
logger.debug("Permissions set successfully") permissions_set.append("SD card: /run/media/mmcblk0p1")
except subprocess.CalledProcessError as e:
# This is expected to fail if the path doesn't exist, so only log at debug level
logger.debug(f"Could not set permission for fallback SD card path (may not exist): {e}")
# Report results
if permissions_set:
logger.info(f"Successfully set {len(permissions_set)} permission(s) for protontricks")
logger.debug(f"Permissions set: {', '.join(permissions_set)}")
if permissions_failed:
logger.warning(f"Failed to set {len(permissions_failed)} permission(s)")
logger.debug(f"Failed permissions: {', '.join(permissions_failed)}")
# Return True if at least modlist directory permission was set (critical)
if any("modlist directory" in p for p in permissions_set):
logger.info("Protontricks permissions configured (at least modlist directory access granted)")
return True return True
else:
logger.error("Failed to set critical modlist directory permission")
return False
except Exception as e: except Exception as e:
logger.error(f"Failed to set Protontricks permissions: {e}") logger.error(f"Unexpected error while setting Protontricks permissions: {e}")
return False return False
def create_protontricks_alias(self): def create_protontricks_alias(self):
@@ -903,6 +971,9 @@ class ProtontricksHandler:
Install the specified Wine components into the given prefix using protontricks. Install the specified Wine components into the given prefix using protontricks.
If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022). If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022).
""" """
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
env = self._get_clean_subprocess_env() env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all" env["WINEDEBUG"] = "-all"

View File

@@ -272,48 +272,46 @@ class WineUtils:
@staticmethod @staticmethod
def chown_chmod_modlist_dir(modlist_dir): def chown_chmod_modlist_dir(modlist_dir):
""" """
Change ownership and permissions of modlist directory DEPRECATED: Use FileSystemHandler.verify_ownership_and_permissions() instead.
Returns True on success, False on failure Verify and fix ownership/permissions for modlist directory.
Returns True if successful, False if sudo required.
""" """
if WineUtils.all_owned_by_user(modlist_dir): if not WineUtils.all_owned_by_user(modlist_dir):
logger.info(f"All files in {modlist_dir} are already owned by the current user. Skipping sudo chown/chmod.") # Files not owned by us - need sudo to fix
return True logger.error(f"Ownership issue detected: Some files in {modlist_dir} are not owned by the current user")
logger.warn("Changing Ownership and Permissions of modlist directory (may require sudo password)")
try: try:
user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip() user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip()
group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip() group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip()
logger.debug(f"User is {user} and Group is {group}") logger.error("To fix ownership issues, open a terminal and run:")
logger.error(f" sudo chown -R {user}:{group} \"{modlist_dir}\"")
# Change ownership logger.error(f" sudo chmod -R 755 \"{modlist_dir}\"")
result1 = subprocess.run( logger.error("After running these commands, retry the operation.")
f"sudo chown -R {user}:{group} \"{modlist_dir}\"",
shell=True,
capture_output=True,
text=True
)
# Change permissions
result2 = subprocess.run(
f"sudo chmod -R 755 \"{modlist_dir}\"",
shell=True,
capture_output=True,
text=True
)
if result1.returncode != 0 or result2.returncode != 0:
logger.error("Failed to change ownership/permissions")
logger.error(f"chown output: {result1.stderr}")
logger.error(f"chmod output: {result2.stderr}")
return False return False
return True
except Exception as e: except Exception as e:
logger.error(f"Error changing ownership and permissions: {e}") logger.error(f"Error checking ownership: {e}")
return False return False
# Files are owned by us - try to fix permissions ourselves
logger.info(f"Files in {modlist_dir} are owned by current user, verifying permissions...")
try:
result = subprocess.run(
['chmod', '-R', '755', modlist_dir],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
logger.info(f"Permissions set successfully for {modlist_dir}")
else:
logger.warning(f"chmod returned non-zero but continuing: {result.stderr}")
return True
except Exception as e:
logger.warning(f"Error running chmod: {e}, continuing anyway")
return True
@staticmethod @staticmethod
def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full): def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full):
""" """

View File

@@ -266,13 +266,40 @@ class WinetricksHandler:
self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}") self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}")
return False return False
# CRITICAL: NEVER add bundled downloaders to PATH - they segfault on some systems
# Let winetricks auto-detect system downloaders (aria2c > wget > curl > fetch)
# Winetricks will automatically fall back if preferred tool isn't available
# We verify at least one exists before proceeding
# Quick check: does system have at least one downloader?
has_downloader = False
for tool in ['aria2c', 'curl', 'wget']:
try:
result = subprocess.run(['which', tool], capture_output=True, timeout=2, env=os.environ.copy())
if result.returncode == 0:
has_downloader = True
self.logger.info(f"System has {tool} available - winetricks will auto-select best option")
break
except Exception:
pass
if not has_downloader:
self._handle_missing_downloader_error()
return False
# Don't set WINETRICKS_DOWNLOADER - let winetricks auto-detect and fall back
# This ensures it uses the best available tool and handles fallbacks automatically
# Set up bundled tools directory for winetricks # Set up bundled tools directory for winetricks
# Get tools directory from any bundled tool (winetricks, cabextract, etc.) # NEVER add bundled downloaders to PATH - they segfault on some systems
# Only bundle non-downloader tools: cabextract, unzip, 7z, xz, sha256sum
tools_dir = None tools_dir = None
bundled_tools = [] bundled_tools = []
# Check for bundled tools and collect their directory # Check for bundled tools and collect their directory
tool_names = ['cabextract', 'wget', 'unzip', '7z', 'xz', 'sha256sum'] # Downloaders (aria2c, wget, curl) are NEVER bundled - always use system tools
tool_names = ['cabextract', 'unzip', '7z', 'xz', 'sha256sum']
for tool_name in tool_names: for tool_name in tool_names:
bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False) bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False)
if bundled_tool: if bundled_tool:
@@ -280,18 +307,30 @@ class WinetricksHandler:
if tools_dir is None: if tools_dir is None:
tools_dir = os.path.dirname(bundled_tool) tools_dir = os.path.dirname(bundled_tool)
# Prepend tools directory to PATH if we have any bundled tools # Add bundled tools to PATH (system PATH first, so system downloaders are found first)
# NEVER add bundled downloaders - only archive/utility tools
if tools_dir: if tools_dir:
env['PATH'] = f"{tools_dir}:{env.get('PATH', '')}" # System PATH first, then bundled tools (so system downloaders are always found first)
self.logger.info(f"Using bundled tools directory: {tools_dir}") env['PATH'] = f"{env.get('PATH', '')}:{tools_dir}"
self.logger.info(f"Bundled tools available: {', '.join(bundled_tools)}") bundling_msg = f"Using bundled tools directory (after system PATH): {tools_dir}"
self.logger.info(bundling_msg)
if status_callback:
status_callback(bundling_msg)
tools_msg = f"Bundled tools available: {', '.join(bundled_tools)}"
self.logger.info(tools_msg)
if status_callback:
status_callback(tools_msg)
else: else:
self.logger.debug("No bundled tools found, relying on system PATH") self.logger.debug("No bundled tools found, relying on system PATH")
# CRITICAL: Check for winetricks dependencies BEFORE attempting installation # CRITICAL: Check for winetricks dependencies BEFORE attempting installation
# This helps diagnose failures on systems where dependencies are missing # This helps diagnose failures on systems where dependencies are missing
self.logger.info("=== Checking winetricks dependencies ===") deps_check_msg = "=== Checking winetricks dependencies ==="
self.logger.info(deps_check_msg)
if status_callback:
status_callback(deps_check_msg)
missing_deps = [] missing_deps = []
bundled_tools_list = ['aria2c', 'wget', 'unzip', '7z', 'xz', 'sha256sum', 'cabextract']
dependency_checks = { dependency_checks = {
'wget': 'wget', 'wget': 'wget',
'curl': 'curl', 'curl': 'curl',
@@ -308,23 +347,30 @@ class WinetricksHandler:
if isinstance(commands, str): if isinstance(commands, str):
commands = [commands] commands = [commands]
# First check for bundled version # Check for bundled version only for tools we bundle
if dep_name in bundled_tools_list:
bundled_tool = None bundled_tool = None
for cmd in commands: for cmd in commands:
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False) bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
if bundled_tool: if bundled_tool:
self.logger.info(f"{dep_name}: {bundled_tool} (bundled)") dep_msg = f"{dep_name}: {bundled_tool} (bundled)"
self.logger.info(dep_msg)
if status_callback:
status_callback(dep_msg)
found = True found = True
break break
# If not bundled, check system PATH # Check system PATH if not found bundled
if not found: if not found:
for cmd in commands: for cmd in commands:
try: try:
result = subprocess.run(['which', cmd], capture_output=True, timeout=2) result = subprocess.run(['which', cmd], capture_output=True, timeout=2)
if result.returncode == 0: if result.returncode == 0:
cmd_path = result.stdout.decode().strip() cmd_path = result.stdout.decode().strip()
self.logger.info(f"{dep_name}: {cmd_path} (system)") dep_msg = f"{dep_name}: {cmd_path} (system)"
self.logger.info(dep_msg)
if status_callback:
status_callback(dep_msg)
found = True found = True
break break
except Exception: except Exception:
@@ -332,12 +378,41 @@ class WinetricksHandler:
if not found: if not found:
missing_deps.append(dep_name) missing_deps.append(dep_name)
if dep_name in bundled_tools_list:
self.logger.warning(f"{dep_name}: NOT FOUND (neither bundled nor system)") self.logger.warning(f"{dep_name}: NOT FOUND (neither bundled nor system)")
else:
self.logger.warning(f"{dep_name}: NOT FOUND (system only - not bundled)")
if missing_deps: if missing_deps:
self.logger.warning(f"Missing winetricks dependencies: {', '.join(missing_deps)}") # Separate critical vs optional dependencies
download_deps = [d for d in missing_deps if d in ['wget', 'curl', 'aria2c']]
critical_deps = [d for d in missing_deps if d not in ['aria2c']]
optional_deps = [d for d in missing_deps if d in ['aria2c']]
if critical_deps:
self.logger.warning(f"Missing critical winetricks dependencies: {', '.join(critical_deps)}")
self.logger.warning("Winetricks may fail if these are required for component installation") self.logger.warning("Winetricks may fail if these are required for component installation")
self.logger.warning("Critical dependencies: wget/curl/aria2c (download), unzip/7z (extract)")
if optional_deps:
self.logger.info(f"Optional dependencies not found (will use alternatives): {', '.join(optional_deps)}")
self.logger.info("aria2c is optional - winetricks will use wget/curl if available")
# Special warning if ALL downloaders are missing
all_downloaders = {'wget', 'curl', 'aria2c'}
missing_downloaders = set(download_deps)
if missing_downloaders == all_downloaders:
self.logger.error("=" * 80)
self.logger.error("CRITICAL: No download tools found (wget, curl, or aria2c)")
self.logger.error("Winetricks requires at least ONE download tool to install components")
self.logger.error("")
self.logger.error("SOLUTION: Install one of the following:")
self.logger.error(" - aria2c (preferred): sudo apt install aria2 # or equivalent for your distro")
self.logger.error(" - curl: sudo apt install curl # or equivalent for your distro")
self.logger.error(" - wget: sudo apt install wget # or equivalent for your distro")
self.logger.error("=" * 80)
else:
self.logger.warning("Critical dependencies: wget/curl (download), unzip/7z (extract)")
self.logger.info("Optional dependencies: aria2c (preferred but not required)")
else: else:
self.logger.info("All winetricks dependencies found") self.logger.info("All winetricks dependencies found")
self.logger.info("========================================") self.logger.info("========================================")
@@ -385,11 +460,17 @@ class WinetricksHandler:
# Choose installation method based on user preference # Choose installation method based on user preference
if method == 'system_protontricks': if method == 'system_protontricks':
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
self.logger.info("Using system protontricks for all components") self.logger.info("Using system protontricks for all components")
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback) return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
# else: method == 'winetricks' (default behavior continues below) # else: method == 'winetricks' (default behavior continues below)
# Install all components together with winetricks (faster) # Install all components together with winetricks (faster)
self.logger.info("=" * 80)
self.logger.info("USING WINETRICKS")
self.logger.info("=" * 80)
max_attempts = 3 max_attempts = 3
winetricks_failed = False winetricks_failed = False
last_error_details = None last_error_details = None
@@ -472,8 +553,7 @@ class WinetricksHandler:
'attempt': attempt 'attempt': attempt
} }
# CRITICAL: Always log full error details (not just in debug mode) # Log full error details to help diagnose failures
# This helps diagnose failures on systems we can't replicate
self.logger.error("=" * 80) self.logger.error("=" * 80)
self.logger.error(f"WINETRICKS FAILED (Attempt {attempt}/{max_attempts})") self.logger.error(f"WINETRICKS FAILED (Attempt {attempt}/{max_attempts})")
self.logger.error(f"Return Code: {result.returncode}") self.logger.error(f"Return Code: {result.returncode}")
@@ -521,6 +601,10 @@ class WinetricksHandler:
self.logger.error(" - Component download may be corrupted") self.logger.error(" - Component download may be corrupted")
self.logger.error(" - Network issue or upstream file change") self.logger.error(" - Network issue or upstream file change")
diagnostic_found = True diagnostic_found = True
elif ("please install" in stderr_lower or "please install" in stdout_lower) and ("wget" in stderr_lower or "aria2c" in stderr_lower or "curl" in stderr_lower or "wget" in stdout_lower or "aria2c" in stdout_lower or "curl" in stdout_lower):
# Winetricks explicitly says to install a downloader
self._handle_missing_downloader_error()
diagnostic_found = True
elif "curl" in stderr_lower or "wget" in stderr_lower or "aria2c" in stderr_lower: elif "curl" in stderr_lower or "wget" in stderr_lower or "aria2c" in stderr_lower:
self.logger.error("DIAGNOSTIC: Download tool (curl/wget/aria2c) issue") self.logger.error("DIAGNOSTIC: Download tool (curl/wget/aria2c) issue")
self.logger.error(" - Network connectivity problem or missing download tool") self.logger.error(" - Network connectivity problem or missing download tool")
@@ -536,11 +620,6 @@ class WinetricksHandler:
self.logger.error(" - Required for extracting zip/7z archives") self.logger.error(" - Required for extracting zip/7z archives")
self.logger.error(" - Check dependency check output above") self.logger.error(" - Check dependency check output above")
diagnostic_found = True diagnostic_found = True
elif "please install" in stderr_lower:
self.logger.error("DIAGNOSTIC: Winetricks explicitly requesting dependency installation")
self.logger.error(" - Winetricks detected missing required tool")
self.logger.error(" - Check dependency check output above")
diagnostic_found = True
if not diagnostic_found: if not diagnostic_found:
self.logger.error("DIAGNOSTIC: Unknown winetricks failure pattern") self.logger.error("DIAGNOSTIC: Unknown winetricks failure pattern")
@@ -611,6 +690,9 @@ class WinetricksHandler:
self.logger.warning("AUTOMATIC FALLBACK: Winetricks failed, attempting protontricks fallback...") self.logger.warning("AUTOMATIC FALLBACK: Winetricks failed, attempting protontricks fallback...")
self.logger.warning(f"Last winetricks error: {last_error_details}") self.logger.warning(f"Last winetricks error: {last_error_details}")
self.logger.warning("=" * 80) self.logger.warning("=" * 80)
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
# Attempt fallback to protontricks # Attempt fallback to protontricks
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback) fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
@@ -631,6 +713,53 @@ class WinetricksHandler:
return False return False
def _handle_missing_downloader_error(self):
"""Handle winetricks error indicating missing downloader - provide platform-specific instructions"""
from ..services.platform_detection_service import PlatformDetectionService
platform = PlatformDetectionService.get_instance()
is_steamos = platform.is_steamdeck
self.logger.error("=" * 80)
self.logger.error("CRITICAL: Winetricks cannot find a downloader (curl, wget, or aria2c)")
self.logger.error("")
if is_steamos:
self.logger.error("STEAMOS/STEAM DECK DETECTED")
self.logger.error("")
self.logger.error("SteamOS has a read-only filesystem. To install packages:")
self.logger.error("")
self.logger.error("1. Disable read-only mode (required for package installation):")
self.logger.error(" sudo steamos-readonly disable")
self.logger.error("")
self.logger.error("2. Install curl (recommended - most reliable):")
self.logger.error(" sudo pacman -S curl")
self.logger.error("")
self.logger.error("3. (Optional) Re-enable read-only mode after installation:")
self.logger.error(" sudo steamos-readonly enable")
self.logger.error("")
self.logger.error("Note: curl is usually pre-installed on SteamOS. If missing,")
self.logger.error(" the above steps will install it.")
else:
self.logger.error("SOLUTION: Install one of the following downloaders:")
self.logger.error("")
self.logger.error(" For Debian/Ubuntu/PopOS:")
self.logger.error(" sudo apt install curl # or: sudo apt install wget")
self.logger.error("")
self.logger.error(" For Fedora/RHEL/CentOS:")
self.logger.error(" sudo dnf install curl # or: sudo dnf install wget")
self.logger.error("")
self.logger.error(" For Arch/Manjaro:")
self.logger.error(" sudo pacman -S curl # or: sudo pacman -S wget")
self.logger.error("")
self.logger.error(" For openSUSE:")
self.logger.error(" sudo zypper install curl # or: sudo zypper install wget")
self.logger.error("")
self.logger.error("Note: Most Linux distributions include curl by default.")
self.logger.error(" If curl is missing, install it using your package manager.")
self.logger.error("=" * 80)
def _reorder_components_for_installation(self, components: list) -> list: def _reorder_components_for_installation(self, components: list) -> list:
""" """
Reorder components for proper installation sequence if needed. Reorder components for proper installation sequence if needed.

View File

@@ -3057,19 +3057,20 @@ echo Prefix creation complete.
# SD card paths use D: drive # SD card paths use D: drive
# Strip SD card prefix using the same method as other handlers # Strip SD card prefix using the same method as other handlers
relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path) relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path)
wine_path = relative_sd_path_str.replace('/', '\\') wine_path = relative_sd_path_str.replace('/', '\\\\')
wine_drive = "D:" wine_drive = "D:"
logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}") logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}")
else: else:
# Regular paths use Z: drive with full path # Regular paths use Z: drive with full path
wine_path = new_path.strip('/').replace('/', '\\') wine_path = new_path.strip('/').replace('/', '\\\\')
wine_drive = "Z:" wine_drive = "Z:"
logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}") logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}")
# Update existing path if found # Update existing path if found
for i, line in enumerate(lines): for i, line in enumerate(lines):
stripped_line = line.strip() stripped_line = line.strip()
if stripped_line == section_name: # Case-insensitive comparison for section name (Wine registry is case-insensitive)
if stripped_line.split(']')[0].lower() + ']' == section_name.lower() if ']' in stripped_line else stripped_line.lower() == section_name.lower():
in_target_section = True in_target_section = True
elif stripped_line.startswith('[') and in_target_section: elif stripped_line.startswith('[') and in_target_section:
in_target_section = False in_target_section = False
@@ -3265,7 +3266,7 @@ echo Prefix creation complete.
"22380": { # Fallout New Vegas AppID "22380": { # Fallout New Vegas AppID
"name": "Fallout New Vegas", "name": "Fallout New Vegas",
"common_names": ["Fallout New Vegas", "FalloutNV"], "common_names": ["Fallout New Vegas", "FalloutNV"],
"registry_section": "[Software\\\\WOW6432Node\\\\bethesda softworks\\\\falloutnv]", "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
"path_key": "Installed Path" "path_key": "Installed Path"
}, },
"976620": { # Enderal Special Edition AppID "976620": { # Enderal Special Edition AppID

View File

@@ -628,7 +628,7 @@ class ModlistService:
except Exception as e: except Exception as e:
logger.error(f"Failed to configure modlist {context.name}: {e}") logger.error(f"Failed to configure modlist {context.name}: {e}")
if completion_callback: if completion_callback:
completion_callback(False, f"Configuration failed: {e}", context.name) completion_callback(False, f"Configuration failed: {e}", context.name, False)
# Clean up GUI log handler on exception # Clean up GUI log handler on exception
if gui_log_handler: if gui_log_handler:
@@ -695,11 +695,11 @@ class ModlistService:
if success: if success:
logger.info("Modlist configuration completed successfully") logger.info("Modlist configuration completed successfully")
if completion_callback: if completion_callback:
completion_callback(True, "Configuration completed successfully", context.name) completion_callback(True, "Configuration completed successfully", context.name, False)
else: else:
logger.warning("Modlist configuration had issues") logger.warning("Modlist configuration had issues")
if completion_callback: if completion_callback:
completion_callback(False, "Configuration failed", context.name) completion_callback(False, "Configuration failed", context.name, False)
return success return success

View File

@@ -41,9 +41,9 @@ class PlatformDetectionService:
if os.path.exists('/etc/os-release'): if os.path.exists('/etc/os-release'):
with open('/etc/os-release', 'r') as f: with open('/etc/os-release', 'r') as f:
content = f.read().lower() content = f.read().lower()
if 'steamdeck' in content: if 'steamdeck' in content or 'steamos' in content:
self._is_steamdeck = True self._is_steamdeck = True
logger.info("Steam Deck platform detected") logger.info("Steam Deck/SteamOS platform detected")
else: else:
logger.debug("Non-Steam Deck Linux platform detected") logger.debug("Non-Steam Deck Linux platform detected")
else: else:

View File

@@ -45,19 +45,20 @@ class SuccessDialog(QDialog):
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" ) self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setSpacing(0) layout.setSpacing(0)
layout.setContentsMargins(30, 30, 30, 30) layout.setContentsMargins(30, 20, 30, 20) # Reduced top/bottom margins to prevent truncation
# --- Card background for content --- # --- Card background for content ---
card = QFrame(self) card = QFrame(self)
card.setObjectName("successCard") card.setObjectName("successCard")
card.setFrameShape(QFrame.StyledPanel) card.setFrameShape(QFrame.StyledPanel)
card.setFrameShadow(QFrame.Raised) card.setFrameShadow(QFrame.Raised)
card.setFixedWidth(440) # Increase card width and reduce margins to maximize text width for 800p screens
card.setMinimumHeight(380) card.setFixedWidth(460)
# Remove fixed minimum height to allow natural sizing based on content
card.setMaximumHeight(16777215) # Remove max height constraint to allow expansion card.setMaximumHeight(16777215) # Remove max height constraint to allow expansion
card_layout = QVBoxLayout(card) card_layout = QVBoxLayout(card)
card_layout.setSpacing(12) card_layout.setSpacing(12)
card_layout.setContentsMargins(28, 28, 28, 28) card_layout.setContentsMargins(20, 28, 20, 28) # Reduced left/right margins to give more text width
card.setStyleSheet( card.setStyleSheet(
"QFrame#successCard { " "QFrame#successCard { "
" background: #23272e; " " background: #23272e; "
@@ -81,29 +82,38 @@ class SuccessDialog(QDialog):
card_layout.addWidget(title_label) card_layout.addWidget(title_label)
# Personalized success message (modlist name in Jackify Blue, but less bold) # Personalized success message (modlist name in Jackify Blue, but less bold)
message_text = self._build_success_message()
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>' modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
if self.workflow_type == "install": if self.workflow_type == "install":
message_html = f"<span style='font-size:15px;'>{modlist_name_html} installed successfully!</span>" suffix_text = "installed successfully!"
elif self.workflow_type == "configure_new":
suffix_text = "configured successfully!"
elif self.workflow_type == "configure_existing":
suffix_text = "configuration updated successfully!"
else: else:
message_html = message_text # Fallback for other workflow types
message_text = self._build_success_message()
suffix_text = message_text.replace(self.modlist_name, "").strip()
# Build complete message with proper HTML formatting - ensure both parts are visible
message_html = f'{modlist_name_html} <span style="font-size:15px; color:#e0e0e0;">{suffix_text}</span>'
message_label = QLabel(message_html) message_label = QLabel(message_html)
# Center the success message within the wider card for all screen sizes # Center the success message within the wider card for all screen sizes
message_label.setAlignment(Qt.AlignCenter) message_label.setAlignment(Qt.AlignCenter)
message_label.setWordWrap(True) message_label.setWordWrap(True)
message_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding) message_label.setMinimumHeight(30) # Ensure label has minimum height to be visible
message_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
message_label.setStyleSheet( message_label.setStyleSheet(
"QLabel { " "QLabel { "
" font-size: 15px; " " font-size: 15px; "
" color: #e0e0e0; " " color: #e0e0e0; "
" line-height: 1.3; " " line-height: 1.3; "
" margin-bottom: 6px; " " margin-bottom: 6px; "
" word-wrap: break-word; " " padding: 0px; "
"}" "}"
) )
message_label.setTextFormat(Qt.RichText) message_label.setTextFormat(Qt.RichText)
# Ensure the label itself is centered in the card layout and uses full width # Ensure the label uses full width of the card before wrapping
card_layout.addWidget(message_label, alignment=Qt.AlignCenter) card_layout.addWidget(message_label)
# Time taken # Time taken
time_label = QLabel(f"Completed in {self.time_taken}") time_label = QLabel(f"Completed in {self.time_taken}")
@@ -123,7 +133,7 @@ class SuccessDialog(QDialog):
next_steps_label = QLabel(next_steps_text) next_steps_label = QLabel(next_steps_text)
next_steps_label.setAlignment(Qt.AlignCenter) next_steps_label.setAlignment(Qt.AlignCenter)
next_steps_label.setWordWrap(True) next_steps_label.setWordWrap(True)
next_steps_label.setMinimumHeight(100) # Remove fixed minimum height to allow natural sizing
next_steps_label.setStyleSheet( next_steps_label.setStyleSheet(
"QLabel { " "QLabel { "
" font-size: 13px; " " font-size: 13px; "

View File

@@ -1495,7 +1495,8 @@ class JackifyMainWindow(QMainWindow):
4: "Install Modlist Screen", 4: "Install Modlist Screen",
5: "Install TTW Screen", 5: "Install TTW Screen",
6: "Configure New Modlist", 6: "Configure New Modlist",
7: "Configure Existing Modlist", 7: "Wabbajack Installer",
8: "Configure Existing Modlist",
} }
screen_name = screen_names.get(index, f"Unknown Screen (Index {index})") screen_name = screen_names.get(index, f"Unknown Screen (Index {index})")
widget = self.stacked_widget.widget(index) widget = self.stacked_widget.widget(index)
@@ -1919,7 +1920,6 @@ def main():
debug_mode = True debug_mode = True
# Temporarily save CLI debug flag to config so engine can see it # Temporarily save CLI debug flag to config so engine can see it
config_handler.set('debug_mode', True) config_handler.set('debug_mode', True)
print("[DEBUG] CLI --debug flag detected, saved debug_mode=True to config")
import logging import logging
# Initialize file logging on root logger so all modules inherit it # Initialize file logging on root logger so all modules inherit it
@@ -1928,14 +1928,23 @@ def main():
# Only rotate log file when debug mode is enabled # Only rotate log file when debug mode is enabled
if debug_mode: if debug_mode:
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log') logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log')
root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True) # Empty name = root logger root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True, debug_mode=debug_mode) # Empty name = root logger
# CRITICAL: Set root logger level BEFORE any child loggers are used
# This ensures DEBUG messages from child loggers propagate correctly
if debug_mode: if debug_mode:
logging.getLogger().setLevel(logging.DEBUG) root_logger.setLevel(logging.DEBUG)
print("[Jackify] Debug mode enabled (from config or CLI)") logging.getLogger().setLevel(logging.DEBUG) # Also set on root via getLogger() for compatibility
root_logger.debug("CLI --debug flag detected, saved debug_mode=True to config")
root_logger.info("Debug mode enabled (from config or CLI)")
else: else:
root_logger.setLevel(logging.WARNING)
logging.getLogger().setLevel(logging.WARNING) logging.getLogger().setLevel(logging.WARNING)
# Root logger should not propagate (it's the top level)
# Child loggers will propagate to root logger by default (unless they explicitly set propagate=False)
root_logger.propagate = False
dev_mode = '--dev' in sys.argv dev_mode = '--dev' in sys.argv
# Launch GUI application # Launch GUI application
@@ -1990,7 +1999,7 @@ def main():
icon = QIcon(path) icon = QIcon(path)
if not icon.isNull(): if not icon.isNull():
if debug_mode: if debug_mode:
print(f"[DEBUG] Using AppImage icon: {path}") logging.getLogger().debug(f"Using AppImage icon: {path}")
break break
# Priority 3: Fallback to any PNG in assets directory # Priority 3: Fallback to any PNG in assets directory
@@ -2001,8 +2010,8 @@ def main():
icon = QIcon(try_path) icon = QIcon(try_path)
if debug_mode: if debug_mode:
print(f"[DEBUG] Final icon path: {icon_path}") logging.getLogger().debug(f"Final icon path: {icon_path}")
print(f"[DEBUG] Icon is null: {icon.isNull()}") logging.getLogger().debug(f"Icon is null: {icon.isNull()}")
app.setWindowIcon(icon) app.setWindowIcon(icon)
window = JackifyMainWindow(dev_mode=dev_mode) window = JackifyMainWindow(dev_mode=dev_mode)

View File

@@ -648,7 +648,7 @@ class ConfigureExistingModlistScreen(QWidget):
class ConfigurationThread(QThread): class ConfigurationThread(QThread):
progress_update = Signal(str) progress_update = Signal(str)
configuration_complete = Signal(bool, str, str) configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str) error_occurred = Signal(str)
def __init__(self, modlist_name, install_dir, resolution): def __init__(self, modlist_name, install_dir, resolution):
@@ -691,8 +691,8 @@ class ConfigureExistingModlistScreen(QWidget):
def progress_callback(message): def progress_callback(message):
self.progress_update.emit(message) self.progress_update.emit(message)
def completion_callback(success, message, modlist_name): def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name) self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count): def manual_steps_callback(modlist_name, retry_count):
# Existing modlists shouldn't need manual steps, but handle gracefully # Existing modlists shouldn't need manual steps, but handle gracefully
@@ -729,7 +729,7 @@ class ConfigureExistingModlistScreen(QWidget):
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}") self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium") MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium")
def on_configuration_complete(self, success, message, modlist_name): def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion""" """Handle configuration completion"""
# Re-enable all controls when workflow completes # Re-enable all controls when workflow completes
self._enable_controls_after_operation() self._enable_controls_after_operation()

View File

@@ -30,7 +30,7 @@ from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicato
from jackify.backend.handlers.progress_parser import ProgressStateManager from jackify.backend.handlers.progress_parser import ProgressStateManager
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType, FileProgress
# Modlist gallery (imported at module level to avoid import delay when opening dialog) # Modlist gallery (imported at module level to avoid import delay when opening dialog)
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog
@@ -2718,7 +2718,6 @@ class InstallModlistScreen(QWidget):
# Render loop handles smooth updates - just set target state # Render loop handles smooth updates - just set target state
current_step = progress_state.phase_step current_step = progress_state.phase_step
from jackify.shared.progress_models import FileProgress, OperationType
display_items = [] display_items = []

View File

@@ -175,7 +175,7 @@ class InstallTTWScreen(QWidget):
instruction_text = QLabel( instruction_text = QLabel(
"Tale of Two Wastelands installation requires a .mpi file you can get from: " "Tale of Two Wastelands installation requires a .mpi file you can get from: "
'<a href="https://mod.pub/ttw/133/files">https://mod.pub/ttw/133/files</a> ' '<a href="https://mod.pub/ttw/133/files">https://mod.pub/ttw/133/files</a> '
"(requires a user account for mod.db)" "(requires a user account for ModPub)"
) )
instruction_text.setWordWrap(True) instruction_text.setWordWrap(True)
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;") instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")

View File

@@ -209,7 +209,9 @@ class ModlistTasksScreen(QWidget):
elif action_id == "configure_new_modlist": elif action_id == "configure_new_modlist":
self.stacked_widget.setCurrentIndex(6) # Configure New Modlist Screen self.stacked_widget.setCurrentIndex(6) # Configure New Modlist Screen
elif action_id == "configure_existing_modlist": elif action_id == "configure_existing_modlist":
self.stacked_widget.setCurrentIndex(7) # Configure Existing Modlist Screen self.stacked_widget.setCurrentIndex(8) # Configure Existing Modlist Screen
elif action_id == "install_wabbajack":
self.stacked_widget.setCurrentIndex(7) # Wabbajack Installer Screen
def go_back(self): def go_back(self):
"""Return to main menu""" """Return to main menu"""

View File

@@ -106,6 +106,77 @@ class OverallProgressIndicator(QWidget):
if not display_text or display_text == "Processing...": if not display_text or display_text == "Processing...":
display_text = progress.phase_name or progress.phase.value.title() or "Processing..." display_text = progress.phase_name or progress.phase.value.title() or "Processing..."
# Add total download size, remaining size (MB/GB), and ETA for download phase
from jackify.shared.progress_models import InstallationPhase, FileProgress
if progress.phase == InstallationPhase.DOWNLOAD:
# Try to get overall download totals - either from data_total or aggregate from active_files
total_bytes = progress.data_total
processed_bytes = progress.data_processed
using_aggregated = False
# If data_total is 0, try to aggregate from active_files
if total_bytes == 0 and progress.active_files:
total_bytes = sum(f.total_size for f in progress.active_files if f.total_size > 0)
processed_bytes = sum(f.current_size for f in progress.active_files if f.current_size > 0)
using_aggregated = True
# Add remaining download size (MB or GB) if available
if total_bytes > 0:
remaining_bytes = total_bytes - processed_bytes
if remaining_bytes > 0:
# Format as MB if less than 1GB, otherwise GB
if remaining_bytes < (1024.0 ** 3):
remaining_mb = remaining_bytes / (1024.0 ** 2)
display_text += f" | {remaining_mb:.1f}MB remaining"
else:
remaining_gb = remaining_bytes / (1024.0 ** 3)
display_text += f" | {remaining_gb:.1f}GB remaining"
# Calculate ETA - prefer aggregated calculation for concurrent downloads
eta_seconds = -1.0
if using_aggregated:
# For concurrent downloads: sum all active download speeds (not average)
# This gives us the combined throughput
active_speeds = [f.speed for f in progress.active_files if f.speed > 0]
if active_speeds:
combined_speed = sum(active_speeds) # Sum speeds for concurrent downloads
if combined_speed > 0:
eta_seconds = remaining_bytes / combined_speed
else:
# Use the standard ETA calculation from progress model
eta_seconds = progress.get_eta_seconds(use_smoothing=True)
# Format and display ETA
if eta_seconds > 0:
if eta_seconds < 60:
display_text += f" | ETA: {int(eta_seconds)}s"
elif eta_seconds < 3600:
mins = int(eta_seconds // 60)
secs = int(eta_seconds % 60)
if secs > 0:
display_text += f" | ETA: {mins}m {secs}s"
else:
display_text += f" | ETA: {mins}m"
else:
hours = int(eta_seconds // 3600)
mins = int((eta_seconds % 3600) // 60)
if mins > 0:
display_text += f" | ETA: {hours}h {mins}m"
else:
display_text += f" | ETA: {hours}h"
else:
# No total size available - try to show ETA if we have speed info from active files
if progress.active_files:
active_speeds = [f.speed for f in progress.active_files if f.speed > 0]
if active_speeds:
# Can't calculate accurate ETA without total size, but could show speed
pass
# Fallback to standard ETA if available
if not using_aggregated:
eta_display = progress.eta_display
if eta_display:
display_text += f" | ETA: {eta_display}"
self.status_label.setText(display_text) self.status_label.setText(display_text)
# Update progress bar if enabled # Update progress bar if enabled
@@ -150,6 +221,23 @@ class OverallProgressIndicator(QWidget):
tooltip_parts.append(f"Step: {progress.phase_progress_text}") tooltip_parts.append(f"Step: {progress.phase_progress_text}")
if progress.data_progress_text: if progress.data_progress_text:
tooltip_parts.append(f"Data: {progress.data_progress_text}") tooltip_parts.append(f"Data: {progress.data_progress_text}")
# Add total download size in GB for download phase
from jackify.shared.progress_models import InstallationPhase
if progress.phase == InstallationPhase.DOWNLOAD and progress.data_total > 0:
total_gb = progress.total_download_size_gb
remaining_gb = progress.remaining_download_size_gb
if total_gb > 0:
tooltip_parts.append(f"Total Download: {total_gb:.2f}GB")
if remaining_gb > 0:
tooltip_parts.append(f"Remaining: {remaining_gb:.2f}GB")
# Add ETA for download phase
if progress.phase == InstallationPhase.DOWNLOAD:
eta_display = progress.eta_display
if eta_display:
tooltip_parts.append(f"Estimated Time Remaining: {eta_display}")
if progress.overall_percent > 0: if progress.overall_percent > 0:
tooltip_parts.append(f"Overall: {progress.overall_percent:.1f}%") tooltip_parts.append(f"Overall: {progress.overall_percent:.1f}%")

View File

@@ -14,7 +14,7 @@ import shutil
class LoggingHandler: class LoggingHandler:
""" """
Central logging handler for Jackify. Central logging handler for Jackify.
- Uses configurable Jackify data directory for logs (default: ~/Jackify/logs/). - Uses configured Jackify data directory for logs (default: ~/Jackify/logs/).
- Supports per-function log files (e.g., jackify-install-wabbajack.log). - Supports per-function log files (e.g., jackify-install-wabbajack.log).
- Handles log rotation and log directory creation. - Handles log rotation and log directory creation.
Usage: Usage:
@@ -61,8 +61,15 @@ class LoggingHandler:
file_path = self.log_dir / (log_file if log_file else "jackify-cli.log") file_path = self.log_dir / (log_file if log_file else "jackify-cli.log")
self.rotate_log_file_per_run(file_path, backup_count=backup_count) self.rotate_log_file_per_run(file_path, backup_count=backup_count)
def setup_logger(self, name: str, log_file: Optional[str] = None, is_general: bool = False) -> logging.Logger: def setup_logger(self, name: str, log_file: Optional[str] = None, is_general: bool = False, debug_mode: Optional[bool] = None) -> logging.Logger:
"""Set up a logger with file and console handlers. Call rotate_log_for_logger before this if you want per-run rotation.""" """Set up a logger with file and console handlers. Call rotate_log_for_logger before this if you want per-run rotation.
Args:
name: Logger name (empty string for root logger)
log_file: Optional log file name
is_general: If True, use default log file name
debug_mode: Optional debug mode override. If None, reads from config.
"""
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
logger.propagate = False logger.propagate = False
@@ -75,21 +82,22 @@ class LoggingHandler:
'%(levelname)s: %(message)s' '%(levelname)s: %(message)s'
) )
# Add console handler - check debug mode from config # Add console handler - use provided debug_mode or check config
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
# Check if debug mode is enabled if debug_mode is None:
# Check if debug mode is enabled from config
try: try:
from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler() config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False) debug_mode = config_handler.get('debug_mode', False)
except Exception:
debug_mode = False
if debug_mode: if debug_mode:
console_handler.setLevel(logging.DEBUG) console_handler.setLevel(logging.DEBUG)
else: else:
console_handler.setLevel(logging.ERROR) console_handler.setLevel(logging.ERROR)
except Exception:
# Fallback to ERROR level if config can't be loaded
console_handler.setLevel(logging.ERROR)
console_handler.setFormatter(console_formatter) console_handler.setFormatter(console_formatter)
if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers): if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
logger.addHandler(console_handler) logger.addHandler(console_handler)
@@ -100,6 +108,7 @@ class LoggingHandler:
file_handler = logging.handlers.RotatingFileHandler( file_handler = logging.handlers.RotatingFileHandler(
file_path, mode='a', encoding='utf-8', maxBytes=1024*1024, backupCount=5 file_path, mode='a', encoding='utf-8', maxBytes=1024*1024, backupCount=5
) )
# File handler always accepts DEBUG - root logger level controls what gets through
file_handler.setLevel(logging.DEBUG) file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter) file_handler.setFormatter(file_formatter)
if not any(isinstance(h, logging.handlers.RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(file_path) for h in logger.handlers): if not any(isinstance(h, logging.handlers.RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(file_path) for h in logger.handlers):
@@ -203,5 +212,5 @@ class LoggingHandler:
return stats return stats
def get_general_logger(self): def get_general_logger(self):
"""Get the general CLI logger (~/Jackify/logs/jackify-cli.log).""" """Get the general CLI logger ({jackify_data_dir}/logs/jackify-cli.log)."""
return self.setup_logger('jackify_cli', is_general=True) return self.setup_logger('jackify_cli', is_general=True)

View File

@@ -6,7 +6,7 @@ Used by both parser and GUI components.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Dict, Optional from typing import List, Dict, Optional, Tuple
from enum import Enum from enum import Enum
import time import time
@@ -97,6 +97,11 @@ class InstallationProgress:
texture_conversion_total: int = 0 # Total textures to convert texture_conversion_total: int = 0 # Total textures to convert
bsa_building_current: int = 0 # Current BSA being built bsa_building_current: int = 0 # Current BSA being built
bsa_building_total: int = 0 # Total BSAs to build bsa_building_total: int = 0 # Total BSAs to build
# ETA smoothing: track speed and data history for stable ETA calculation
_speed_history: List[Tuple[float, float]] = field(default_factory=list) # [(timestamp, speed_bytes_per_sec), ...]
_data_history: List[Tuple[float, int]] = field(default_factory=list) # [(timestamp, data_processed_bytes), ...]
_last_eta_update: float = 0.0 # Last time ETA was calculated/displayed
_smoothed_eta_seconds: float = -1.0 # Cached smoothed ETA value
def __post_init__(self): def __post_init__(self):
"""Ensure percent is in valid range.""" """Ensure percent is in valid range."""
@@ -122,6 +127,185 @@ class InstallationProgress:
else: else:
return "" return ""
@property
def total_download_size_gb(self) -> float:
"""Get total download size in GB (0 if unknown)."""
if self.data_total > 0:
return self.data_total / (1024.0 ** 3)
return 0.0
@property
def remaining_download_size_gb(self) -> float:
"""Get remaining download size in GB (0 if unknown or complete)."""
if self.data_total > 0 and self.data_processed < self.data_total:
return (self.data_total - self.data_processed) / (1024.0 ** 3)
return 0.0
def _update_speed_history(self, operation: str, speed: float):
"""Update speed history for ETA smoothing."""
if operation.lower() != 'download':
return
current_time = time.time()
# Add current speed to history
self._speed_history.append((current_time, speed))
# Keep only last 60 seconds of history
cutoff_time = current_time - 60.0
self._speed_history = [(t, s) for t, s in self._speed_history if t >= cutoff_time]
def _update_data_history(self):
"""Update data history for calculating average speed from data processed over time."""
if self.data_processed <= 0:
return
current_time = time.time()
# Only add if data has changed or enough time has passed (avoid spam)
if self._data_history:
last_time, last_data = self._data_history[-1]
# Only add if data changed by at least 1MB or 5 seconds passed
if self.data_processed == last_data and (current_time - last_time) < 5.0:
return
self._data_history.append((current_time, self.data_processed))
# Keep only last 60 seconds
cutoff_time = current_time - 60.0
self._data_history = [(t, d) for t, d in self._data_history if t >= cutoff_time]
def _get_average_speed(self, window_seconds: float = 30.0) -> float:
"""
Get average download speed over the last N seconds.
Uses both speed history and data history for more accurate calculation.
Args:
window_seconds: Time window to average over (default 30 seconds)
Returns:
Average speed in bytes per second, or -1 if insufficient data
"""
current_time = time.time()
cutoff_time = current_time - window_seconds
# Method 1: Use speed history if available
recent_speeds = [s for t, s in self._speed_history if t >= cutoff_time]
if len(recent_speeds) >= 3: # Need at least 3 samples
return sum(recent_speeds) / len(recent_speeds)
# Method 2: Calculate from data history (more accurate for varying speeds)
recent_data = [(t, d) for t, d in self._data_history if t >= cutoff_time]
if len(recent_data) >= 2:
# Calculate average speed from data processed over time
oldest = recent_data[0]
newest = recent_data[-1]
time_diff = newest[0] - oldest[0]
data_diff = newest[1] - oldest[1]
if time_diff > 0:
return data_diff / time_diff
# Fallback: Use current instantaneous speed
return self.get_speed('download')
def get_eta_seconds(self, use_smoothing: bool = True) -> float:
"""
Calculate estimated time remaining in seconds.
Uses smoothed/averaged speed to prevent wild fluctuations.
Args:
use_smoothing: If True, use averaged speed over last 30 seconds (default True)
Returns:
ETA in seconds, or -1 if ETA cannot be calculated
"""
# Only calculate ETA during download phase
if self.phase != InstallationPhase.DOWNLOAD:
return -1.0
# Need both remaining data and current speed
if self.data_total <= 0 or self.data_processed >= self.data_total:
return -1.0
# Update data history for speed calculation
self._update_data_history()
remaining_bytes = self.data_total - self.data_processed
# Get speed (smoothed or instantaneous)
if use_smoothing:
download_speed = self._get_average_speed(window_seconds=30.0)
else:
download_speed = self.get_speed('download')
if download_speed <= 0:
return -1.0
# Calculate ETA
eta_seconds = remaining_bytes / download_speed
# Apply exponential smoothing to ETA itself to prevent wild jumps
# Only update if we have a previous value and the change isn't too extreme
if use_smoothing and self._smoothed_eta_seconds > 0:
# If new ETA is wildly different (>50% change), use weighted average
# This prevents temporary speed drops from causing huge ETA jumps
change_ratio = abs(eta_seconds - self._smoothed_eta_seconds) / max(self._smoothed_eta_seconds, 1.0)
if change_ratio > 0.5:
# Large change - use 70% old, 30% new (smooth transition)
eta_seconds = 0.7 * self._smoothed_eta_seconds + 0.3 * eta_seconds
else:
# Small change - use 85% old, 15% new (quick but stable)
eta_seconds = 0.85 * self._smoothed_eta_seconds + 0.15 * eta_seconds
# Update cached value
self._smoothed_eta_seconds = eta_seconds
return eta_seconds
@staticmethod
def _format_eta(seconds: float) -> str:
"""Format ETA seconds into human-readable string like '2h 15m' or '45m 30s'."""
if seconds < 0:
return ""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
if minutes > 0:
return f"{hours}h {minutes}m"
else:
return f"{hours}h"
elif minutes > 0:
if secs > 0:
return f"{minutes}m {secs}s"
else:
return f"{minutes}m"
else:
return f"{secs}s"
@property
def eta_display(self) -> str:
"""
Get formatted ETA display string.
Only updates every 5 seconds to prevent UI flicker from rapid changes.
"""
current_time = time.time()
# Only recalculate ETA every 5 seconds to prevent wild fluctuations in display
if current_time - self._last_eta_update < 5.0 and self._smoothed_eta_seconds > 0:
# Use cached value if recently calculated
eta_seconds = self._smoothed_eta_seconds
else:
# Recalculate with smoothing
eta_seconds = self.get_eta_seconds(use_smoothing=True)
self._last_eta_update = current_time
if eta_seconds < 0:
return ""
return self._format_eta(eta_seconds)
def get_overall_speed_display(self) -> str: def get_overall_speed_display(self) -> str:
"""Get overall speed display from aggregate speeds reported by engine.""" """Get overall speed display from aggregate speeds reported by engine."""
def _fresh_speed(op_key: str) -> float: def _fresh_speed(op_key: str) -> float:
@@ -313,3 +497,7 @@ class InstallationProgress:
self.speed_timestamps[op_key] = time.time() self.speed_timestamps[op_key] = time.time()
self.timestamp = time.time() self.timestamp = time.time()
# Update speed history for ETA smoothing
if speed > 0:
self._update_speed_history(op_key, speed)

View File

@@ -14,8 +14,8 @@ pycryptodome>=3.19.0
# Configuration file handling # Configuration file handling
PyYAML>=6.0 PyYAML>=6.0
# Steam VDF file parsing (latest available version) # Steam VDF file parsing (actively maintained fork, used by Gentoo)
vdf>=3.4 vdf @ git+https://github.com/solsticegamestudios/vdf.git
# Package version handling # Package version handling
packaging>=21.0 packaging>=21.0