From 02f3d71a82964edc455d18a61facc04aae65b5b2 Mon Sep 17 00:00:00 2001 From: Omni Date: Thu, 15 Jan 2026 18:06:02 +0000 Subject: [PATCH] Sync from development - prepare for v0.2.1.1 --- CHANGELOG.md | 32 ++- jackify/__init__.py | 2 +- .../backend/handlers/filesystem_handler.py | 114 ++++++----- jackify/backend/handlers/logging_handler.py | 4 +- jackify/backend/handlers/modlist_handler.py | 15 +- jackify/backend/handlers/progress_parser.py | 59 ++++++ .../backend/handlers/protontricks_handler.py | 93 ++++++++- jackify/backend/handlers/wine_utils.py | 72 ++++--- .../backend/handlers/winetricks_handler.py | 185 ++++++++++++++--- .../services/automated_prefix_service.py | 9 +- jackify/backend/services/modlist_service.py | 6 +- .../services/platform_detection_service.py | 4 +- .../frontends/gui/dialogs/success_dialog.py | 34 ++-- jackify/frontends/gui/main.py | 27 ++- .../gui/screens/configure_existing_modlist.py | 8 +- .../frontends/gui/screens/install_modlist.py | 3 +- jackify/frontends/gui/screens/install_ttw.py | 2 +- .../frontends/gui/screens/modlist_tasks.py | 4 +- .../gui/widgets/progress_indicator.py | 88 ++++++++ jackify/shared/logging.py | 41 ++-- jackify/shared/progress_models.py | 190 +++++++++++++++++- requirements.txt | 4 +- 22 files changed, 803 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e5a36..f664429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,38 @@ # 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 +- **Download Size Display**: Added total download size display in GB during modlist installation +- **Download Size Formatting**: Improved display to show MB when remaining download is less than 1GB (fixes "0.0GB remaining" for small files like .wabbajack) +- **ETA Display**: Added estimated time remaining (ETA) during downloads with smoothing to prevent wild fluctuations +- **Concurrent Download ETA**: Improved ETA calculation for concurrent downloads by summing speeds from all active files +- **Wine Component Error Handling**: Enhanced error messages for missing downloaders with platform-specific installation instructions (SteamOS/Steam Deck vs other distros) + +### Improvements +- **Download Progress Tracking**: Enhanced parser to track running totals from all files seen during download phase, improving remaining size/ETA accuracy when engine doesn't report overall totals +- **Note**: Archive downloads still require engine changes to show complete overall totals (see `docs/PlanOfAction.md` for details) + +### 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 **Release Date:** 2025-01-12 - +Y ### 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. - **ENB Detection and Configuration**: Automatic detection and configuration of `enblocal.ini` with `LinuxVersion=true` for all supported games diff --git a/jackify/__init__.py b/jackify/__init__.py index 48b1f2f..b23d850 100644 --- a/jackify/__init__.py +++ b/jackify/__init__.py @@ -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" diff --git a/jackify/backend/handlers/filesystem_handler.py b/jackify/backend/handlers/filesystem_handler.py index b080825..daafc57 100644 --- a/jackify/backend/handlers/filesystem_handler.py +++ b/jackify/backend/handlers/filesystem_handler.py @@ -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.""" diff --git a/jackify/backend/handlers/logging_handler.py b/jackify/backend/handlers/logging_handler.py index 8a26a86..4f2c9fd 100644 --- a/jackify/backend/handlers/logging_handler.py +++ b/jackify/backend/handlers/logging_handler.py @@ -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) \ No newline at end of file diff --git a/jackify/backend/handlers/modlist_handler.py b/jackify/backend/handlers/modlist_handler.py index c22e577..e8fcbb4 100644 --- a/jackify/backend/handlers/modlist_handler.py +++ b/jackify/backend/handlers/modlist_handler.py @@ -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: diff --git a/jackify/backend/handlers/progress_parser.py b/jackify/backend/handlers/progress_parser.py index ff5d500..ab781a8 100644 --- a/jackify/backend/handlers/progress_parser.py +++ b/jackify/backend/handlers/progress_parser.py @@ -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: diff --git a/jackify/backend/handlers/protontricks_handler.py b/jackify/backend/handlers/protontricks_handler.py index 8ff5430..89137d9 100644 --- a/jackify/backend/handlers/protontricks_handler.py +++ b/jackify/backend/handlers/protontricks_handler.py @@ -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" diff --git a/jackify/backend/handlers/wine_utils.py b/jackify/backend/handlers/wine_utils.py index 44ffde1..4a6b4ef 100644 --- a/jackify/backend/handlers/wine_utils.py +++ b/jackify/backend/handlers/wine_utils.py @@ -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): diff --git a/jackify/backend/handlers/winetricks_handler.py b/jackify/backend/handlers/winetricks_handler.py index dbf6973..caa0b98 100644 --- a/jackify/backend/handlers/winetricks_handler.py +++ b/jackify/backend/handlers/winetricks_handler.py @@ -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. diff --git a/jackify/backend/services/automated_prefix_service.py b/jackify/backend/services/automated_prefix_service.py index b915d84..0591931 100644 --- a/jackify/backend/services/automated_prefix_service.py +++ b/jackify/backend/services/automated_prefix_service.py @@ -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 diff --git a/jackify/backend/services/modlist_service.py b/jackify/backend/services/modlist_service.py index e691764..2ee3e79 100644 --- a/jackify/backend/services/modlist_service.py +++ b/jackify/backend/services/modlist_service.py @@ -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 diff --git a/jackify/backend/services/platform_detection_service.py b/jackify/backend/services/platform_detection_service.py index 5a21e46..11fb0ae 100644 --- a/jackify/backend/services/platform_detection_service.py +++ b/jackify/backend/services/platform_detection_service.py @@ -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: diff --git a/jackify/frontends/gui/dialogs/success_dialog.py b/jackify/frontends/gui/dialogs/success_dialog.py index dba0ff6..57bb699 100644 --- a/jackify/frontends/gui/dialogs/success_dialog.py +++ b/jackify/frontends/gui/dialogs/success_dialog.py @@ -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'{self.modlist_name}' if self.workflow_type == "install": - message_html = f"{modlist_name_html} installed successfully!" + 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} {suffix_text}' 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; " diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py index 4096e8b..59c2502 100644 --- a/jackify/frontends/gui/main.py +++ b/jackify/frontends/gui/main.py @@ -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) diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py index b8b487f..bbbc3cc 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -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() diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py index b52f538..2163713 100644 --- a/jackify/frontends/gui/screens/install_modlist.py +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -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 = [] diff --git a/jackify/frontends/gui/screens/install_ttw.py b/jackify/frontends/gui/screens/install_ttw.py index 4c6779d..b5b7b23 100644 --- a/jackify/frontends/gui/screens/install_ttw.py +++ b/jackify/frontends/gui/screens/install_ttw.py @@ -175,7 +175,7 @@ class InstallTTWScreen(QWidget): instruction_text = QLabel( "Tale of Two Wastelands installation requires a .mpi file you can get from: " 'https://mod.pub/ttw/133/files ' - "(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;") diff --git a/jackify/frontends/gui/screens/modlist_tasks.py b/jackify/frontends/gui/screens/modlist_tasks.py index 3126706..ddd7e9b 100644 --- a/jackify/frontends/gui/screens/modlist_tasks.py +++ b/jackify/frontends/gui/screens/modlist_tasks.py @@ -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""" diff --git a/jackify/frontends/gui/widgets/progress_indicator.py b/jackify/frontends/gui/widgets/progress_indicator.py index d1ee8e9..2ffcc74 100644 --- a/jackify/frontends/gui/widgets/progress_indicator.py +++ b/jackify/frontends/gui/widgets/progress_indicator.py @@ -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}%") diff --git a/jackify/shared/logging.py b/jackify/shared/logging.py index f4056f8..c9d9faa 100644 --- a/jackify/shared/logging.py +++ b/jackify/shared/logging.py @@ -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) \ No newline at end of file diff --git a/jackify/shared/progress_models.py b/jackify/shared/progress_models.py index 00ad3c4..3d17632 100644 --- a/jackify/shared/progress_models.py +++ b/jackify/shared/progress_models.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 12c1573..588268a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,8 +14,8 @@ pycryptodome>=3.19.0 # Configuration file handling PyYAML>=6.0 -# Steam VDF file parsing (latest available version) -vdf>=3.4 +# Steam VDF file parsing (actively maintained fork, used by Gentoo) +vdf @ git+https://github.com/solsticegamestudios/vdf.git # Package version handling packaging>=21.0