Sync from development - prepare for v0.2.1.1

This commit is contained in:
Omni
2026-01-15 18:06:02 +00:00
parent 29e1800074
commit 02f3d71a82
22 changed files with 803 additions and 193 deletions

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
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
@staticmethod
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
"""Change ownership and permissions using sudo (robust, with timeout and re-prompt)."""
def verify_ownership_and_permissions(path: Path) -> tuple[bool, str]:
"""
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():
logger.error(f"Path does not exist: {path}")
return False
# Check if all files/dirs are already owned by the user
if FileSystemHandler.all_owned_by_user(path):
logger.info(f"All files in {path} are already owned by the current user. Skipping sudo chown/chmod.")
return True
return False, f"Path does not exist: {path}"
# Check if all files/dirs are owned by the user
if not FileSystemHandler.all_owned_by_user(path):
# Files not owned by us - need sudo to fix
try:
user_name = pwd.getpwuid(os.geteuid()).pw_name
group_name = grp.getgrgid(os.geteuid()).gr_name
except KeyError:
logger.error("Could not determine current user or group name.")
return False, "Could not determine current user or group name."
logger.error(f"Ownership issue detected: Some files in {path} are not owned by {user_name}")
error_msg = (
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:
user_name = pwd.getpwuid(os.geteuid()).pw_name
group_name = grp.getgrgid(os.geteuid()).gr_name
except KeyError:
logger.error("Could not determine current user or group name.")
return False
result = subprocess.run(
['chmod', '-R', '755', str(path)],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
logger.info(f"Permissions set successfully for {path}")
return True, ""
else:
logger.warning(f"chmod returned non-zero but we'll continue: {result.stderr}")
# Non-critical if chmod fails on our own files, might be read-only filesystem or similar
return True, ""
except Exception as e:
logger.warning(f"Error running chmod: {e}, continuing anyway")
# Non-critical error, we own the files so proceed
return True, ""
log_msg = f"Applying ownership/permissions for {path} (user: {user_name}, group: {group_name}) via sudo."
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):
for attempt in range(max_retries):
try:
logger.info(f"Running sudo command (attempt {attempt+1}/{max_retries}): {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout)
if result.returncode == 0:
return True
else:
logger.error(f"sudo {desc} failed. Error: {result.stderr.strip()}")
print(f"Error: Failed to {desc}. Check logs.")
return False
except subprocess.TimeoutExpired:
logger.error(f"sudo {desc} timed out (attempt {attempt+1}/{max_retries}).")
print(f"\nSudo prompt timed out after {timeout} seconds. Please try again.")
# Flush input if possible, then retry
print(f"Failed to {desc} after {max_retries} attempts. Aborting.")
return False
# Run chown with retries
chown_command = ['sudo', 'chown', '-R', f'{user_name}:{group_name}', str(path)]
if not run_sudo_with_retries(chown_command, "change ownership"):
return False
print()
# Run chmod with retries
chmod_command = ['sudo', 'chmod', '-R', '755', str(path)]
if not run_sudo_with_retries(chmod_command, "set permissions"):
return False
print()
logger.info("Permissions set successfully.")
return True
@staticmethod
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
"""
DEPRECATED: Use verify_ownership_and_permissions() instead.
This method is kept for backwards compatibility but no longer executes sudo.
"""
logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
success, error_msg = FileSystemHandler.verify_ownership_and_permissions(path)
if not success:
logger.error(error_msg)
print(error_msg)
return success
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."""

View File

@@ -14,7 +14,7 @@ import shutil
class LoggingHandler:
"""
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).
- Handles log rotation and log directory creation.
Usage:
@@ -186,5 +186,5 @@ class LoggingHandler:
return stats
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)

View File

@@ -806,17 +806,18 @@ class ModlistHandler:
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")
# Step 5: Ensure permissions of Modlist directory
# Step 5: Verify ownership of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
self.logger.info("Step 5: Setting ownership and permissions for modlist directory...")
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
self.logger.info("Step 5: Verifying ownership of modlist directory...")
# Convert modlist_dir string to Path object for the method
modlist_path_obj = Path(self.modlist_dir)
if not self.filesystem_handler.set_ownership_and_permissions_sudo(modlist_path_obj):
self.logger.error("Failed to set ownership/permissions for modlist directory. Configuration aborted.")
print("Error: Failed to set permissions for the modlist directory.")
success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj)
if not success:
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
self.logger.info("Step 5: Setting ownership and permissions... Done")
self.logger.info("Step 5: Ownership verification... Done")
# Step 6: Backup ModOrganizer.ini
if status_callback:

View File

@@ -845,6 +845,10 @@ class ProgressStateManager:
self._wabbajack_entry_name = None
self._synthetic_flag = "_synthetic_wabbajack"
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:
"""
@@ -869,6 +873,12 @@ class ProgressStateManager:
# Phase is changing - selectively reset stale data from previous 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
# Validation phase data sizes are from .wabbajack file and shouldn't persist
if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info:
@@ -970,6 +980,39 @@ class ProgressStateManager:
if parsed.file_progress.operation == OperationType.DOWNLOAD:
self._remove_all_wabbajack_entries()
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)
# Don't add files that are already at 100% unless they're being updated
# This prevents re-adding completed files
@@ -1023,6 +1066,22 @@ class ProgressStateManager:
parsed.completed_filename = None
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
found_existing = False
for file_prog in self.state.active_files:

View File

@@ -453,13 +453,56 @@ class ProtontricksHandler:
return True
logger.info("Setting Protontricks permissions...")
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
permissions_set = []
permissions_failed = []
try:
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
# 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}")
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={modlist_dir}"], check=True, env=env)
# 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:
logger.warn("Checking for SDCard and setting permissions appropriately...")
# Find sdcard path
@@ -468,15 +511,40 @@ class ProtontricksHandler:
if "/run/media" in line:
sdcard_path = line.split()[-1]
logger.debug(f"SDCard path: {sdcard_path}")
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
"com.github.Matoking.protontricks"], check=True, env=env)
try:
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
"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
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
"com.github.Matoking.protontricks"], check=True, env=env)
logger.debug("Permissions set successfully")
return True
try:
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
"com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
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
else:
logger.error("Failed to set critical modlist directory permission")
return False
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
def create_protontricks_alias(self):
@@ -903,6 +971,9 @@ class ProtontricksHandler:
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).
"""
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"

View File

@@ -272,47 +272,45 @@ class WineUtils:
@staticmethod
def chown_chmod_modlist_dir(modlist_dir):
"""
Change ownership and permissions of modlist directory
Returns True on success, False on failure
DEPRECATED: Use FileSystemHandler.verify_ownership_and_permissions() instead.
Verify and fix ownership/permissions for modlist directory.
Returns True if successful, False if sudo required.
"""
if 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.")
return True
logger.warn("Changing Ownership and Permissions of modlist directory (may require sudo password)")
try:
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()
logger.debug(f"User is {user} and Group is {group}")
# Change ownership
result1 = subprocess.run(
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}")
if not WineUtils.all_owned_by_user(modlist_dir):
# Files not owned by us - need sudo to fix
logger.error(f"Ownership issue detected: Some files in {modlist_dir} are not owned by the current user")
try:
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()
logger.error("To fix ownership issues, open a terminal and run:")
logger.error(f" sudo chown -R {user}:{group} \"{modlist_dir}\"")
logger.error(f" sudo chmod -R 755 \"{modlist_dir}\"")
logger.error("After running these commands, retry the operation.")
return False
except Exception as e:
logger.error(f"Error checking ownership: {e}")
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.error(f"Error changing ownership and permissions: {e}")
return False
logger.warning(f"Error running chmod: {e}, continuing anyway")
return True
@staticmethod
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}")
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
# 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
bundled_tools = []
# 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:
bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False)
if bundled_tool:
@@ -280,18 +307,30 @@ class WinetricksHandler:
if tools_dir is None:
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:
env['PATH'] = f"{tools_dir}:{env.get('PATH', '')}"
self.logger.info(f"Using bundled tools directory: {tools_dir}")
self.logger.info(f"Bundled tools available: {', '.join(bundled_tools)}")
# System PATH first, then bundled tools (so system downloaders are always found first)
env['PATH'] = f"{env.get('PATH', '')}:{tools_dir}"
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:
self.logger.debug("No bundled tools found, relying on system PATH")
# CRITICAL: Check for winetricks dependencies BEFORE attempting installation
# 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 = []
bundled_tools_list = ['aria2c', 'wget', 'unzip', '7z', 'xz', 'sha256sum', 'cabextract']
dependency_checks = {
'wget': 'wget',
'curl': 'curl',
@@ -308,23 +347,30 @@ class WinetricksHandler:
if isinstance(commands, str):
commands = [commands]
# First check for bundled version
bundled_tool = None
for cmd in commands:
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
if bundled_tool:
self.logger.info(f"{dep_name}: {bundled_tool} (bundled)")
found = True
break
# Check for bundled version only for tools we bundle
if dep_name in bundled_tools_list:
bundled_tool = None
for cmd in commands:
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
if bundled_tool:
dep_msg = f"{dep_name}: {bundled_tool} (bundled)"
self.logger.info(dep_msg)
if status_callback:
status_callback(dep_msg)
found = True
break
# If not bundled, check system PATH
# Check system PATH if not found bundled
if not found:
for cmd in commands:
try:
result = subprocess.run(['which', cmd], capture_output=True, timeout=2)
if result.returncode == 0:
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
break
except Exception:
@@ -332,12 +378,41 @@ class WinetricksHandler:
if not found:
missing_deps.append(dep_name)
self.logger.warning(f"{dep_name}: NOT FOUND (neither bundled nor system)")
if dep_name in bundled_tools_list:
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:
self.logger.warning(f"Missing winetricks dependencies: {', '.join(missing_deps)}")
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)")
# 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")
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:
self.logger.info("All winetricks dependencies found")
self.logger.info("========================================")
@@ -385,11 +460,17 @@ class WinetricksHandler:
# Choose installation method based on user preference
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")
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
# else: method == 'winetricks' (default behavior continues below)
# Install all components together with winetricks (faster)
self.logger.info("=" * 80)
self.logger.info("USING WINETRICKS")
self.logger.info("=" * 80)
max_attempts = 3
winetricks_failed = False
last_error_details = None
@@ -472,8 +553,7 @@ class WinetricksHandler:
'attempt': attempt
}
# CRITICAL: Always log full error details (not just in debug mode)
# This helps diagnose failures on systems we can't replicate
# Log full error details to help diagnose failures
self.logger.error("=" * 80)
self.logger.error(f"WINETRICKS FAILED (Attempt {attempt}/{max_attempts})")
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(" - Network issue or upstream file change")
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:
self.logger.error("DIAGNOSTIC: Download tool (curl/wget/aria2c) issue")
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(" - Check dependency check output above")
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:
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(f"Last winetricks error: {last_error_details}")
self.logger.warning("=" * 80)
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
# Attempt fallback to protontricks
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
@@ -631,6 +713,53 @@ class WinetricksHandler:
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:
"""
Reorder components for proper installation sequence if needed.

View File

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

View File

@@ -628,7 +628,7 @@ class ModlistService:
except Exception as e:
logger.error(f"Failed to configure modlist {context.name}: {e}")
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
if gui_log_handler:
@@ -695,11 +695,11 @@ class ModlistService:
if success:
logger.info("Modlist configuration completed successfully")
if completion_callback:
completion_callback(True, "Configuration completed successfully", context.name)
completion_callback(True, "Configuration completed successfully", context.name, False)
else:
logger.warning("Modlist configuration had issues")
if completion_callback:
completion_callback(False, "Configuration failed", context.name)
completion_callback(False, "Configuration failed", context.name, False)
return success

View File

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

View File

@@ -45,19 +45,20 @@ class SuccessDialog(QDialog):
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
layout = QVBoxLayout(self)
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 = QFrame(self)
card.setObjectName("successCard")
card.setFrameShape(QFrame.StyledPanel)
card.setFrameShadow(QFrame.Raised)
card.setFixedWidth(440)
card.setMinimumHeight(380)
# Increase card width and reduce margins to maximize text width for 800p screens
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_layout = QVBoxLayout(card)
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(
"QFrame#successCard { "
" background: #23272e; "
@@ -81,29 +82,38 @@ class SuccessDialog(QDialog):
card_layout.addWidget(title_label)
# 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>'
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:
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)
# Center the success message within the wider card for all screen sizes
message_label.setAlignment(Qt.AlignCenter)
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(
"QLabel { "
" font-size: 15px; "
" color: #e0e0e0; "
" line-height: 1.3; "
" margin-bottom: 6px; "
" word-wrap: break-word; "
" padding: 0px; "
"}"
)
message_label.setTextFormat(Qt.RichText)
# Ensure the label itself is centered in the card layout and uses full width
card_layout.addWidget(message_label, alignment=Qt.AlignCenter)
# Ensure the label uses full width of the card before wrapping
card_layout.addWidget(message_label)
# 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.setAlignment(Qt.AlignCenter)
next_steps_label.setWordWrap(True)
next_steps_label.setMinimumHeight(100)
# Remove fixed minimum height to allow natural sizing
next_steps_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "

View File

@@ -1495,7 +1495,8 @@ class JackifyMainWindow(QMainWindow):
4: "Install Modlist Screen",
5: "Install TTW Screen",
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})")
widget = self.stacked_widget.widget(index)
@@ -1919,7 +1920,6 @@ def main():
debug_mode = True
# Temporarily save CLI debug flag to config so engine can see it
config_handler.set('debug_mode', True)
print("[DEBUG] CLI --debug flag detected, saved debug_mode=True to config")
import logging
# Initialize file logging on root logger so all modules inherit it
@@ -1928,13 +1928,22 @@ def main():
# Only rotate log file when debug mode is enabled
if debug_mode:
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:
logging.getLogger().setLevel(logging.DEBUG)
print("[Jackify] Debug mode enabled (from config or CLI)")
root_logger.setLevel(logging.DEBUG)
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:
root_logger.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
@@ -1990,7 +1999,7 @@ def main():
icon = QIcon(path)
if not icon.isNull():
if debug_mode:
print(f"[DEBUG] Using AppImage icon: {path}")
logging.getLogger().debug(f"Using AppImage icon: {path}")
break
# Priority 3: Fallback to any PNG in assets directory
@@ -2001,8 +2010,8 @@ def main():
icon = QIcon(try_path)
if debug_mode:
print(f"[DEBUG] Final icon path: {icon_path}")
print(f"[DEBUG] Icon is null: {icon.isNull()}")
logging.getLogger().debug(f"Final icon path: {icon_path}")
logging.getLogger().debug(f"Icon is null: {icon.isNull()}")
app.setWindowIcon(icon)
window = JackifyMainWindow(dev_mode=dev_mode)

View File

@@ -648,7 +648,7 @@ class ConfigureExistingModlistScreen(QWidget):
class ConfigurationThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
def __init__(self, modlist_name, install_dir, resolution):
@@ -691,8 +691,8 @@ class ConfigureExistingModlistScreen(QWidget):
def progress_callback(message):
self.progress_update.emit(message)
def completion_callback(success, message, modlist_name):
self.configuration_complete.emit(success, message, modlist_name)
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# 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}")
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"""
# Re-enable all controls when workflow completes
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.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
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)
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
current_step = progress_state.phase_step
from jackify.shared.progress_models import FileProgress, OperationType
display_items = []

View File

@@ -175,7 +175,7 @@ class InstallTTWScreen(QWidget):
instruction_text = QLabel(
"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> '
"(requires a user account for mod.db)"
"(requires a user account for ModPub)"
)
instruction_text.setWordWrap(True)
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":
self.stacked_widget.setCurrentIndex(6) # Configure New Modlist Screen
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):
"""Return to main menu"""

View File

@@ -106,6 +106,77 @@ class OverallProgressIndicator(QWidget):
if not display_text or display_text == "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)
# Update progress bar if enabled
@@ -150,6 +221,23 @@ class OverallProgressIndicator(QWidget):
tooltip_parts.append(f"Step: {progress.phase_progress_text}")
if 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:
tooltip_parts.append(f"Overall: {progress.overall_percent:.1f}%")

View File

@@ -14,7 +14,7 @@ import shutil
class LoggingHandler:
"""
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).
- Handles log rotation and log directory creation.
Usage:
@@ -61,8 +61,15 @@ class LoggingHandler:
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)
def setup_logger(self, name: str, log_file: Optional[str] = None, is_general: bool = False) -> logging.Logger:
"""Set up a logger with file and console handlers. Call rotate_log_for_logger before this if you want per-run rotation."""
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.
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.setLevel(logging.DEBUG)
logger.propagate = False
@@ -75,20 +82,21 @@ class LoggingHandler:
'%(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()
# Check if debug mode is enabled
try:
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
console_handler.setLevel(logging.DEBUG)
else:
console_handler.setLevel(logging.ERROR)
except Exception:
# Fallback to ERROR level if config can't be loaded
if debug_mode is None:
# Check if debug mode is enabled from config
try:
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
except Exception:
debug_mode = False
if debug_mode:
console_handler.setLevel(logging.DEBUG)
else:
console_handler.setLevel(logging.ERROR)
console_handler.setFormatter(console_formatter)
if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
@@ -100,6 +108,7 @@ class LoggingHandler:
file_handler = logging.handlers.RotatingFileHandler(
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.setFormatter(file_formatter)
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
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)

View File

@@ -6,7 +6,7 @@ Used by both parser and GUI components.
"""
from dataclasses import dataclass, field
from typing import List, Dict, Optional
from typing import List, Dict, Optional, Tuple
from enum import Enum
import time
@@ -97,6 +97,11 @@ class InstallationProgress:
texture_conversion_total: int = 0 # Total textures to convert
bsa_building_current: int = 0 # Current BSA being built
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):
"""Ensure percent is in valid range."""
@@ -122,6 +127,185 @@ class InstallationProgress:
else:
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:
"""Get overall speed display from aggregate speeds reported by engine."""
def _fresh_speed(op_key: str) -> float:
@@ -312,4 +496,8 @@ class InstallationProgress:
self.speeds[op_key] = max(0.0, speed)
self.speed_timestamps[op_key] = time.time()
self.timestamp = time.time()
# Update speed history for ETA smoothing
if speed > 0:
self._update_speed_history(op_key, speed)