diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5729a..6817690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Jackify Changelog +## v0.4.0 - Error Handling Rewrite +**Release Date:** 2026-02-25 + +### New Features +- Structured error handling across GUI and CLI with typed `JackifyError` dialogs (clear message, suggested action, numbered recovery steps, optional technical detail). +- Structured engine error receiver: stderr JSON errors are parsed and mapped to user-facing error types, with exit-code fallback. +- Nexus account tier indicator in Settings OAuth (`[Premium]` / `[Free]`) with cached status checks. +- Modlist metadata support via `.jackify_meta.json`, written after install and used by configure workflows. +- TTW eligibility workflow expanded: + - Configure New / Configure Existing can trigger TTW workflow when eligible. + - CLI `configure-modlist` now prompts TTW when eligible. +- FO3 support in configure workflows, including prefix/registry handling. +- Standalone MO2 setup in Additional Tasks (GUI and CLI). + +### Bug Fixes +- Proton auto-detection reliability improved, including GE-Proton ranking and fallback behavior. +- Added detection support for system-packaged Proton layouts (Issue #162). +- Download stall false positives reduced by checking byte advancement instead of speed readout alone. +- Flatpak Steam access handling improved with install-directory override support. +- TTW installer output directory is pre-populated to the modlist location. +- Unknown game fallback behavior improved so Wine component installation can continue where appropriate. + +### Improvements +- GUI debug log naming standardised to `jackify-debug.log`. +- Error reporting/logging flow cleaned up to improve user facing info and hopefully ease support. +- "Lazy" GUI screen initialization (main menu first, other screens on demand). +- Proton handling improved with Valve Proton fallback when GE-Proton is unavailable. +- FNV/FO3/Enderal registry injection now attempts canonical `C:\Program Files (x86)\Steam\steamapps\common\` paths via in-prefix symlink, with fallback to real `Z:/D:` paths if symlink creation fails. Looking forward to feedback on this one if anyone still has their FNV launcher only show "Install" instead of "Play". + +### Engine Updates +- jackify-engine updated to `0.4.8`. +- Archive download progress improvements (remaining size + ETA). +- Download speed reporting reliability improvements on Linux. +- ZIP extraction fixes for Cyrillic filenames. + +--- + ## v0.3.0 - Codebase Refactoring **Release Date:** 2026-02-06 @@ -1200,4 +1237,4 @@ This release completes the logging refactor that was blocking development workfl - Modular handler architecture for extensibility. ## v0.0.09 and Earlier -See commit history for previous versions. \ No newline at end of file +See commit history for previous versions. diff --git a/assets/images/wiki/ModlistGuides/shared/Shared/Jackify_Github_Banner.png b/assets/images/wiki/ModlistGuides/shared/Shared/Jackify_Github_Banner.png new file mode 100644 index 0000000..8309031 Binary files /dev/null and b/assets/images/wiki/ModlistGuides/shared/Shared/Jackify_Github_Banner.png differ diff --git a/assets/images/wiki/ModlistGuides/shared/Shared/mo2-run-button.png b/assets/images/wiki/ModlistGuides/shared/Shared/mo2-run-button.png new file mode 100644 index 0000000..5c83587 Binary files /dev/null and b/assets/images/wiki/ModlistGuides/shared/Shared/mo2-run-button.png differ diff --git a/assets/images/wiki/ModlistGuides/shared/Shared/nexus-jackify-download-page.png b/assets/images/wiki/ModlistGuides/shared/Shared/nexus-jackify-download-page.png new file mode 100644 index 0000000..ad31a9e Binary files /dev/null and b/assets/images/wiki/ModlistGuides/shared/Shared/nexus-jackify-download-page.png differ diff --git a/assets/images/wiki/ModlistGuides/shared/Shared/steam-library-tuxborn-premium.png b/assets/images/wiki/ModlistGuides/shared/Shared/steam-library-tuxborn-premium.png new file mode 100644 index 0000000..097a757 Binary files /dev/null and b/assets/images/wiki/ModlistGuides/shared/Shared/steam-library-tuxborn-premium.png differ diff --git a/jackify/__init__.py b/jackify/__init__.py index a3874a2..9bc7204 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.3.0" +__version__ = "0.4.0" diff --git a/jackify/backend/core/modlist_operations.py b/jackify/backend/core/modlist_operations.py index 3c64a1c..1d6b350 100644 --- a/jackify/backend/core/modlist_operations.py +++ b/jackify/backend/core/modlist_operations.py @@ -171,8 +171,7 @@ class ModlistInstallCLI( self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck) self.context = {} # Use standard logging (no file handler) - self.logger = logging.getLogger(__name__) - self.logger.propagate = False # Prevent duplicate logs if root logger is also configured + self.logger = logging.getLogger('jackify-cli') # Initialize Wabbajack parser for game detection self.wabbajack_parser = WabbajackParser() @@ -238,4 +237,3 @@ class ModlistInstallCLI( print(auth_display) print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") - diff --git a/jackify/backend/core/modlist_operations_configuration_cli.py b/jackify/backend/core/modlist_operations_configuration_cli.py index 9c2f3ab..dc845de 100644 --- a/jackify/backend/core/modlist_operations_configuration_cli.py +++ b/jackify/backend/core/modlist_operations_configuration_cli.py @@ -170,6 +170,7 @@ class ModlistOperationsConfigurationCLIMixin: proc = self._current_process buffer = b'' + inline_progress_active = False while True: chunk = proc.stdout.read(1) if not chunk: @@ -185,7 +186,16 @@ class ModlistOperationsConfigurationCLIMixin: else: buffer = b'' continue - print(line, end='') + clean_line = line.rstrip('\r\n') + if clean_line.startswith("Installing files "): + print(f"\r{clean_line}", end='') + sys.stdout.flush() + inline_progress_active = True + else: + if inline_progress_active: + print() + inline_progress_active = False + print(line, end='') buffer = b'' elif chunk == b'\r': line = buffer.decode('utf-8', errors='replace') @@ -196,7 +206,15 @@ class ModlistOperationsConfigurationCLIMixin: else: buffer = b'' continue - print(line, end='') + clean_line = line.rstrip('\r\n') + if clean_line.startswith("Installing files "): + print(f"\r{clean_line}", end='') + inline_progress_active = True + else: + if inline_progress_active: + print() + inline_progress_active = False + print(line, end='') sys.stdout.flush() buffer = b'' @@ -209,8 +227,14 @@ class ModlistOperationsConfigurationCLIMixin: else: line = '' if line: + if inline_progress_active: + print() + inline_progress_active = False print(line, end='') + if inline_progress_active: + print() + proc.wait() self._current_process = None if proc.returncode != 0: @@ -343,7 +367,10 @@ class ModlistOperationsConfigurationCLIMixin: if not is_gui_mode: self.logger.debug("configuration_phase: Not in GUI mode, prompting user for configuration...") print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now?{COLOR_RESET}") + print( + f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now? " + f"Steam will restart and close any running game.{COLOR_RESET}" + ) configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() self.logger.debug(f"configuration_phase: User choice: '{configure_choice}'") @@ -383,11 +410,30 @@ class ModlistOperationsConfigurationCLIMixin: start_time = time.time() def progress_callback(message): + noisy_patterns = ( + "using bundled tools directory", + "bundled tools available", + "checking winetricks dependencies", + "(bundled)", + "(system)", + "wget", + "curl", + "aria2c", + "sha256sum", + "cabextract", + ) + message_lc = message.lower() + if any(pattern in message_lc for pattern in noisy_patterns): + # Keep dependency/tool chatter in logs only for CLI readability. + self.logger.debug("Automated prefix detail: %s", message) + return + elapsed = time.time() - start_time hours = int(elapsed // 3600) minutes = int((elapsed % 3600) // 60) seconds = int(elapsed % 60) timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]" + self.logger.info("Automated prefix progress: %s", message) print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}") try: @@ -534,6 +580,58 @@ class ModlistOperationsConfigurationCLIMixin: if configuration_success: print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}") self.logger.info("Post-installation configuration completed successfully") + try: + # Ensure CLI install flow gets the same VNV automation behavior as GUI. + from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + + modlist_name_for_automation = self.context.get('modlist_name') or shortcut_name or "" + def _confirm_vnv(description: str) -> bool: + print(f"\n{description}\n") + try: + user_input = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower() + except (EOFError, KeyboardInterrupt): + return False + return user_input in ("", "y", "yes") + def _manual_vnv_file(title: str, instructions: str): + print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}") + print(instructions) + try: + file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip() + except (EOFError, KeyboardInterrupt): + return None + if not file_input: + return None + selected = Path(file_input).expanduser().resolve() + return selected if selected.exists() else None + automation_ran, vnv_error = run_vnv_automation_if_applicable( + modlist_name=modlist_name_for_automation, + modlist_install_location=Path(install_dir_str), + game_root=None, # Auto-detect from modlist structure. + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + progress_callback=lambda msg: print(msg), + manual_file_callback=_manual_vnv_file, + confirmation_callback=_confirm_vnv, + ) + if automation_ran and not vnv_error: + print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}") + if vnv_error: + print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}") + print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}") + except Exception as vnv_err: + self.logger.error("VNV post-install automation failed: %s", vnv_err, exc_info=True) + print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}") + try: + # v0.4.0 contract: offer TTW flow for eligible FNV lists (e.g., Begin Again). + from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible + + prompt_ttw_if_eligible( + install_dir_str, + self.context.get('modlist_name') or shortcut_name or "", + ) + except Exception as ttw_err: + self.logger.error("TTW post-install prompt failed: %s", ttw_err, exc_info=True) + print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}") else: print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}") self.logger.warning("Post-installation configuration had issues") diff --git a/jackify/backend/handlers/engine_monitor.py b/jackify/backend/handlers/engine_monitor.py index 362d35d..fb19090 100644 --- a/jackify/backend/handlers/engine_monitor.py +++ b/jackify/backend/handlers/engine_monitor.py @@ -92,7 +92,7 @@ class EnginePerformanceMonitor: # Also monitor the parent Python process for comparison try: self._parent_process = psutil.Process(os.getpid()) - except: + except Exception: self._parent_process = None self._monitoring = True @@ -220,7 +220,7 @@ class EnginePerformanceMonitor: parent_cpu_percent = self._parent_process.cpu_percent() parent_memory_info = self._parent_process.memory_info() parent_memory_mb = parent_memory_info.rss / (1024 * 1024) - except: + except Exception: pass # Get I/O info diff --git a/jackify/backend/handlers/game_detector.py b/jackify/backend/handlers/game_detector.py index 9c4f234..feeeb9e 100644 --- a/jackify/backend/handlers/game_detector.py +++ b/jackify/backend/handlers/game_detector.py @@ -15,6 +15,7 @@ class GameDetector: 'skyrim': ['Skyrim Special Edition', 'Skyrim'], 'fallout4': ['Fallout 4'], 'falloutnv': ['Fallout New Vegas'], + 'fallout3': ['Fallout 3'], 'oblivion': ['Oblivion'], 'starfield': ['Starfield'], 'oblivion_remastered': ['Oblivion Remastered'] @@ -34,6 +35,8 @@ class GameDetector: return 'fallout4' elif any(keyword in modlist_lower for keyword in ['fallout new vegas', 'fonv', 'fnv', 'new vegas', 'nvse']): return 'falloutnv' + elif any(keyword in modlist_lower for keyword in ['fallout 3', 'fo3', 'fallout3', 'fose']): + return 'fallout3' elif any(keyword in modlist_lower for keyword in ['oblivion', 'obse', 'shivering isles']): return 'oblivion' elif any(keyword in modlist_lower for keyword in ['starfield', 'sf', 'starfieldse']): @@ -108,6 +111,12 @@ class GameDetector: 'required_dlc': [], 'compatibility_tools': ['protontricks', 'winetricks'] }, + 'fallout3': { + 'launcher': 'FOSE', + 'min_proton_version': '5.0', + 'required_dlc': [], + 'compatibility_tools': ['protontricks', 'winetricks'] + }, 'oblivion': { 'launcher': 'OBSE', 'min_proton_version': '5.0', @@ -173,6 +182,7 @@ class GameDetector: 'skyrim': 'SKSE', 'fallout4': 'F4SE', 'falloutnv': 'NVSE', + 'fallout3': 'FOSE', 'oblivion': 'OBSE', 'starfield': 'SFSE', 'oblivion_remastered': 'OBSE' @@ -205,6 +215,7 @@ class GameDetector: 'skyrim': ['vcrun2019', 'dotnet48', 'dxvk'], 'fallout4': ['vcrun2019', 'dotnet48', 'dxvk'], 'falloutnv': ['vcrun2019', 'dotnet48'], + 'fallout3': ['vcrun2019', 'dotnet48'], 'oblivion': ['vcrun2019', 'dotnet48'], 'starfield': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'], 'oblivion_remastered': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'] @@ -222,6 +233,7 @@ class GameDetector: 'skyrim': ['SkyrimSE.exe', 'Skyrim.exe'], 'fallout4': ['Fallout4.exe'], 'falloutnv': ['FalloutNV.exe'], + 'fallout3': ['Fallout3.exe'], 'oblivion': ['Oblivion.exe'] } @@ -250,6 +262,11 @@ class GameDetector: 'config_dirs': ['Data', 'Saves'], 'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\FalloutNV'] }, + 'fallout3': { + 'ini_files': ['Fallout.ini', 'FalloutPrefs.ini'], + 'config_dirs': ['Data', 'Saves'], + 'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\Fallout3'] + }, 'oblivion': { 'ini_files': ['Oblivion.ini'], 'config_dirs': ['Data', 'Saves'], diff --git a/jackify/backend/handlers/menu_handler.py b/jackify/backend/handlers/menu_handler.py index 2691139..8f1b399 100644 --- a/jackify/backend/handlers/menu_handler.py +++ b/jackify/backend/handlers/menu_handler.py @@ -32,7 +32,6 @@ from .resolution_handler import ResolutionHandler from .protontricks_handler import ProtontricksHandler from .path_handler import PathHandler from .vdf_handler import VDFHandler -from .mo2_handler import MO2Handler from jackify.shared.ui_utils import print_section_header from .completers import path_completer @@ -72,7 +71,6 @@ class MenuHandler: steamdeck=self.config_handler.settings.get('steamdeck', False), verbose=False ) - self.mo2_handler = MO2Handler(self) def display_banner(self): """Display the application banner - DEPRECATED: Banner display should be handled by frontend""" diff --git a/jackify/backend/handlers/menu_handler_modlist.py b/jackify/backend/handlers/menu_handler_modlist.py index 572fa6d..125c1a9 100644 --- a/jackify/backend/handlers/menu_handler_modlist.py +++ b/jackify/backend/handlers/menu_handler_modlist.py @@ -82,22 +82,6 @@ class ModlistMenuHandler: print("\nInvalid selection. Please try again.") input("\nPress Enter to continue...") - def _display_manual_proton_steps(self, modlist_name): - """Displays the detailed manual steps required for Proton setup.""" - # Keep these as print for clear user instructions - print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}") - print("Please complete the following steps in Steam:") - print(f" 1. Locate the '{COLOR_INFO}{modlist_name}{COLOR_RESET}' entry in your Steam Library") - print(" 2. Right-click and select 'Properties'") - print(" 3. Switch to the 'Compatibility' tab") - print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'") - print(" 5. Select 'Proton - Experimental' from the dropdown menu") - print(" 6. Close the Properties window") - print(f" 7. Launch '{COLOR_INFO}{modlist_name}{COLOR_RESET}' from your Steam Library") - print(" 8. If Mod Organizer opens or produces any error message, that's normal") - print(" 9. No matter what,CLOSE Mod Organizer completely and return here") - print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}") - def _get_mo2_path(self) -> Optional[str]: """ Get the path to ModOrganizer.exe from user input. @@ -269,6 +253,16 @@ class ModlistMenuHandler: # Use automated prefix service for modern workflow print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}") + + # CLI safety warning: this workflow will restart Steam as part of shortcut/prefix setup. + print("\n" + "-" * 28) + print( + f"{COLOR_PROMPT}Configure New Modlist will restart Steam and close any running game.{COLOR_RESET}" + ) + continue_choice = input(f"{COLOR_PROMPT}Continue with Configure New now? (Y/n): {COLOR_RESET}").strip().lower() + if continue_choice == 'n': + print(f"{COLOR_INFO}Configuration cancelled before Steam restart.{COLOR_RESET}") + return True from ..services.automated_prefix_service import AutomatedPrefixService prefix_service = AutomatedPrefixService() @@ -441,7 +435,15 @@ class ModlistMenuHandler: Shared configuration phase for both new and existing modlists. Expects context dict with keys: name, appid, path (at minimum). """ + import os self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}") + # Write nxmhandler.ini to suppress MO2's NXM Handling popup on first launch. + # This must happen before MO2 runs for the first time, so do it here rather than + # relying on callers to remember. + _mo2_exe = context.get('mo2_exe_path') or os.path.join(context.get('path', ''), 'ModOrganizer.exe') + _mo2_dir = os.path.dirname(_mo2_exe) + if _mo2_dir and os.path.isdir(_mo2_dir): + self.shortcut_handler.write_nxmhandler_ini(_mo2_dir, _mo2_exe) # Robust AppID lookup for GUI/CLI: if appid missing but mo2_exe_path present, look it up if 'appid' not in context or not context.get('appid'): if 'mo2_exe_path' in context and context['mo2_exe_path']: @@ -454,7 +456,6 @@ class ModlistMenuHandler: self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}") # Check GUI mode early to avoid input() calls in GUI context - import os gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' if not set_modlist_result: @@ -501,10 +502,22 @@ class ModlistMenuHandler: self.logger.info(f"Starting configuration steps for {context.get('name')}") print() # Add padding before status line status_line = "" - import os gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' def update_status(msg): nonlocal status_line + filtered_prefixes = ( + "Using bundled tools directory (after system PATH):", + "Bundled tools available:", + ) + msg_lc = msg.lower().strip() + if msg.startswith(filtered_prefixes): + return + # Suppress per-tool dependency detail lines like: + # " wget: /usr/bin/wget (system)" / " 7z: ... (bundled)". + if msg.startswith(" ") and ( + "(system)" in msg_lc or "(bundled)" in msg_lc or "not found" in msg_lc + ): + return if status_line: print("\r" + " " * len(status_line), end="\r") if gui_mode: @@ -526,28 +539,29 @@ class ModlistMenuHandler: if status_line: print() - # Configure ENB for Linux compatibility (non-blocking, same as GUI) + # Configure ENB for Linux compatibility (non-blocking). + # In GUI mode, modlist_service.py handles ENB after this function returns, + # so skip here to avoid running it twice. enb_detected = False - try: - from ..handlers.enb_handler import ENBHandler - from pathlib import Path - - enb_handler = ENBHandler() - install_dir = Path(context.get('path', '')) - - if install_dir.exists(): - enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(install_dir) - - if enb_message: - if enb_success: - self.logger.info(enb_message) - update_status(enb_message) - else: - self.logger.warning(enb_message) - # Non-blocking: continue workflow even if ENB config fails - except Exception as e: - self.logger.warning(f"ENB configuration skipped due to error: {e}") - # Continue workflow - ENB config is optional + if not gui_mode: + try: + from ..handlers.enb_handler import ENBHandler + from pathlib import Path + + enb_handler = ENBHandler() + install_dir = Path(context.get('path', '')) + + if install_dir.exists(): + enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(install_dir) + + if enb_message: + if enb_success: + self.logger.info(enb_message) + update_status(enb_message) + else: + self.logger.warning(enb_message) + except Exception as e: + self.logger.warning(f"ENB configuration skipped due to error: {e}") # Run modlist-specific post-install automation (e.g., VNV) before showing completion # Only in CLI mode - GUI handles this in install_modlist.py @@ -560,17 +574,37 @@ class ModlistMenuHandler: modlist_path = Path(context.get('path', '')) try: - print("") - print("Running VNV post-install automation...") + def _confirm_vnv(description: str) -> bool: + print(f"\n{description}\n") + try: + user_input = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower() + except (EOFError, KeyboardInterrupt): + return False + return user_input in ("", "y", "yes") + + def _manual_vnv_file(title: str, instructions: str): + print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}") + print(instructions) + try: + file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip() + except (EOFError, KeyboardInterrupt): + return None + if not file_input: + return None + selected = Path(file_input).expanduser().resolve() + return selected if selected.exists() else None + automation_ran, error = run_vnv_automation_if_applicable( modlist_name=modlist_name, modlist_install_location=modlist_path, game_root=None, # Will be auto-detected ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), progress_callback=lambda msg: print(msg), - manual_file_callback=None, # CLI doesn't support manual file callback yet - confirmation_callback=None # Will use default confirmation in CLI + manual_file_callback=_manual_vnv_file, + confirmation_callback=_confirm_vnv ) + if automation_ran and not error: + print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}") if error: print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}") print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}") @@ -578,6 +612,22 @@ class ModlistMenuHandler: self.logger.debug(f"VNV automation check skipped: {e}") # Not an error - just means VNV automation wasn't applicable + if not gui_mode: + try: + from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible + + prompt_ttw_if_eligible( + context.get('path', ''), + context.get('name', '') or '', + ) + except Exception as ttw_err: + self.logger.error("TTW post-config prompt failed: %s", ttw_err, exc_info=True) + print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}") + + is_existing_flow = context.get("modlist_source") == "existing" + completion_title = "Modlist Configuration complete!" if is_existing_flow else "Modlist Install and Configuration complete!" + completion_log_file = "Configure_Existing_Modlist_workflow.log" if is_existing_flow else "Configure_New_Modlist_workflow.log" + print("") print("") print("") # Extra blank line before completion @@ -585,7 +635,7 @@ class ModlistMenuHandler: print("= Configuration phase complete =") print("=" * 35) print("") - print("Modlist Install and Configuration complete!") + print(completion_title) print(f"• You should now be able to Launch '{context.get('name')}' through Steam") print("• Congratulations and enjoy the game!") print("") @@ -608,7 +658,7 @@ class ModlistMenuHandler: # No ENB detected - no warning needed pass from jackify.shared.paths import get_jackify_logs_dir - print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log") + print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}") # Only wait for input in CLI mode, not GUI mode if not gui_mode: input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}") diff --git a/jackify/backend/handlers/mo2_handler.py b/jackify/backend/handlers/mo2_handler.py deleted file mode 100644 index a702bf9..0000000 --- a/jackify/backend/handlers/mo2_handler.py +++ /dev/null @@ -1,188 +0,0 @@ -import shutil -import subprocess -import requests -from pathlib import Path -import re -import time -import os -import logging -from .ui_colors import COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING -from .status_utils import show_status, clear_status -from jackify.shared.ui_utils import print_section_header, print_subsection_header - -logger = logging.getLogger(__name__) - -class MO2Handler: - """ - Handles downloading and installing Mod Organizer 2 (MO2) using system 7z. - """ - def __init__(self, menu_handler): - self.menu_handler = menu_handler - # Import shortcut handler from menu_handler if available - self.shortcut_handler = getattr(menu_handler, 'shortcut_handler', None) - self.logger = logging.getLogger(__name__) - - def _is_dangerous_path(self, path: Path) -> bool: - # Block /, /home, /root, and the user's home directory - home = Path.home().resolve() - dangerous = [Path('/'), Path('/home'), Path('/root'), home] - return any(path.resolve() == d for d in dangerous) - - def install_mo2(self): - os.system('cls' if os.name == 'nt' else 'clear') - # Banner display handled by frontend - print_section_header('Mod Organizer 2 Installation') - # 1. Check for 7z - if not shutil.which('7z'): - print(f"{COLOR_ERROR}[ERROR] 7z is not installed. Please install it (e.g., sudo apt install p7zip-full).{COLOR_RESET}") - return False - # 2. Prompt for install location - default_dir = Path.home() / "ModOrganizer2" - prompt = f"Enter the full path where Mod Organizer 2 should be installed (default: {default_dir}, enter 'q' to cancel)" - install_dir = self.menu_handler.get_directory_path( - prompt_message=prompt, - default_path=default_dir, - create_if_missing=False, - no_header=True - ) - if not install_dir: - print(f"\n{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}\n") - return False - # Safety: Block dangerous paths - if self._is_dangerous_path(install_dir): - print(f"\n{COLOR_ERROR}Refusing to install to a dangerous directory: {install_dir}{COLOR_RESET}\n") - return False - # 3. Ask if user wants to add MO2 to Steam - add_to_steam = input(f"Add Mod Organizer 2 as a custom Steam shortcut for Proton? (Y/n): ").strip().lower() - add_to_steam = (add_to_steam == '' or add_to_steam.startswith('y')) - shortcut_name = None - if add_to_steam: - shortcut_name = input(f"Enter a name for your new Steam shortcut (default: Mod Organizer 2): ").strip() - if not shortcut_name: - shortcut_name = "Mod Organizer 2" - print_subsection_header('Configuration Phase') - time.sleep(0.5) - # 4. Create directory if needed, handle existing contents - if not install_dir.exists(): - try: - install_dir.mkdir(parents=True, exist_ok=True) - show_status(f"Created directory: {install_dir}") - except Exception as e: - print(f"{COLOR_ERROR}[ERROR] Could not create directory: {e}{COLOR_RESET}") - return False - else: - files = list(install_dir.iterdir()) - if files: - print(f"{COLOR_WARNING}The directory '{install_dir}' is not empty.{COLOR_RESET}") - print("Warning: This will permanently delete all files in the folder. Type 'DELETE' to confirm:") - confirm = input("").strip() - if confirm != 'DELETE': - print(f"{COLOR_INFO}Cancelled by user. Please choose a different directory if you want to keep existing files.{COLOR_RESET}\n") - return False - for f in files: - try: - if f.is_dir(): - shutil.rmtree(f) - else: - f.unlink() - except Exception as e: - print(f"{COLOR_ERROR}Failed to delete {f}: {e}{COLOR_RESET}") - show_status(f"Deleted all contents of {install_dir}") - - # 5. Fetch latest MO2 release info from GitHub - show_status("Fetching latest Mod Organizer 2 release info...") - try: - response = requests.get("https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest", timeout=15, verify=True) - response.raise_for_status() - release = response.json() - except Exception as e: - print(f"{COLOR_ERROR}[ERROR] Failed to fetch MO2 release info: {e}{COLOR_RESET}") - return False - - # 6. Find the correct .7z asset (exclude -pdbs, -src, etc) - asset = None - for a in release.get('assets', []): - name = a['name'] - if re.match(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$", name): - asset = a - break - if not asset: - print(f"{COLOR_ERROR}[ERROR] Could not find main MO2 .7z asset in latest release.{COLOR_RESET}") - return False - - # 7. Download the archive - show_status(f"Downloading {asset['name']}...") - archive_path = install_dir / asset['name'] - try: - with requests.get(asset['browser_download_url'], stream=True, timeout=60, verify=True) as r: - r.raise_for_status() - with open(archive_path, 'wb') as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - except Exception as e: - print(f"{COLOR_ERROR}[ERROR] Failed to download MO2 archive: {e}{COLOR_RESET}") - return False - - # 8. Extract using 7z (suppress noisy output) - show_status(f"Extracting to {install_dir}...") - try: - result = subprocess.run(['7z', 'x', str(archive_path), f'-o{install_dir}'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if result.returncode != 0: - print(f"{COLOR_ERROR}[ERROR] Extraction failed: {result.stderr.decode(errors='ignore')}{COLOR_RESET}") - return False - except Exception as e: - print(f"{COLOR_ERROR}[ERROR] Extraction failed: {e}{COLOR_RESET}") - return False - - # 9. Validate extraction - mo2_exe = next(install_dir.glob('**/ModOrganizer.exe'), None) - if not mo2_exe: - print(f"{COLOR_ERROR}[ERROR] ModOrganizer.exe not found after extraction. Please check extraction.{COLOR_RESET}") - return False - else: - show_status(f"MO2 installed at: {mo2_exe.parent}") - - # 10. Add to Steam if requested - if add_to_steam and self.shortcut_handler: - show_status("Creating Steam shortcut...") - try: - from ..services.native_steam_service import NativeSteamService - steam_service = NativeSteamService() - - success, app_id = steam_service.create_shortcut_with_proton( - app_name=shortcut_name, - exe_path=str(mo2_exe), - start_dir=str(mo2_exe.parent), - launch_options="%command%", - tags=["Jackify"], - proton_version="proton_experimental" - ) - if not success or not app_id: - print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut.{COLOR_RESET}") - else: - show_status(f"Steam shortcut created for '{COLOR_INFO}{shortcut_name}{COLOR_RESET}'.") - # Restart Steam and show manual steps (reuse logic from Configure Modlist) - print("\n───────────────────────────────────────────────────────────────────") - print(f"{COLOR_INFO}Important:{COLOR_RESET} Steam needs to restart to detect the new shortcut.") - print("This process involves several manual steps after the restart.") - restart_choice = input(f"\n{COLOR_PROMPT}Restart Steam automatically now? (Y/n): {COLOR_RESET}").strip().lower() - if restart_choice != 'n': - if hasattr(self.shortcut_handler, 'secure_steam_restart'): - print("Restarting Steam...") - self.shortcut_handler.secure_steam_restart() - print("\nAfter restarting, you MUST perform the manual Proton setup steps:") - print(f" 1. Locate '{COLOR_INFO}{shortcut_name}{COLOR_RESET}' in your Steam Library") - print(" 2. Right-click and select 'Properties'") - print(" 3. Switch to the 'Compatibility' tab") - print(" 4. Check 'Force the use of a specific Steam Play compatibility tool'") - print(" 5. Select 'Proton - Experimental' from the dropdown menu") - print(" 6. Close the Properties window") - print(f" 7. Launch '{COLOR_INFO}{shortcut_name}{COLOR_RESET}' from your Steam Library") - print(" 8. If Mod Organizer opens or produces any error message, that's normal") - print(" 9. CLOSE Mod Organizer completely and return here") - print("───────────────────────────────────────────────────────────────────\n") - except Exception as e: - print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut: {e}{COLOR_RESET}") - - print(f"{COLOR_SUCCESS}Mod Organizer 2 has been installed successfully!{COLOR_RESET}\n") - return True \ No newline at end of file diff --git a/jackify/backend/handlers/modlist_detection.py b/jackify/backend/handlers/modlist_detection.py index 09cbc14..78b3504 100644 --- a/jackify/backend/handlers/modlist_detection.py +++ b/jackify/backend/handlers/modlist_detection.py @@ -329,6 +329,9 @@ class ModlistDetectionMixin: if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content: self.logger.info("Detected FNV via ModOrganizer.ini markers") return "fnv" + if 'fose' in content or 'fose_loader' in content or ('fallout 3' in content and 'fallout 4' not in content): + self.logger.info("Detected FO3 via ModOrganizer.ini markers") + return "fo3" if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']): self.logger.info("Detected Enderal via ModOrganizer.ini markers") return "enderal" @@ -353,6 +356,10 @@ class ModlistDetectionMixin: if nvse_loader.exists(): self.logger.info(f"Detected FNV modlist: found nvse_loader.exe in '{base}'") return "fnv" + fose_loader = base / "fose_loader.exe" + if fose_loader.exists(): + self.logger.info(f"Detected FO3 modlist: found fose_loader.exe in '{base}'") + return "fo3" enderal_launcher = base / "Enderal Launcher.exe" if enderal_launcher.exists(): self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'") @@ -366,6 +373,9 @@ class ModlistDetectionMixin: if 'fallout new vegas' in gt or gt == 'fnv': self.logger.info("Heuristic detection: game_var indicates FNV") return "fnv" + if 'fallout 3' in gt or gt == 'fo3': + self.logger.info("Heuristic detection: game_var indicates FO3") + return "fo3" if 'enderal' in gt: self.logger.info("Heuristic detection: game_var indicates Enderal") return "enderal" diff --git a/jackify/backend/handlers/modlist_install_cli_configuration.py b/jackify/backend/handlers/modlist_install_cli_configuration.py index 0a59694..2639c38 100644 --- a/jackify/backend/handlers/modlist_install_cli_configuration.py +++ b/jackify/backend/handlers/modlist_install_cli_configuration.py @@ -258,6 +258,7 @@ class ModlistInstallCLIConfigurationMixin: try: # Read output in binary mode to properly handle carriage returns buffer = b'' + inline_progress_active = False last_progress_time = time.time() while True: @@ -282,7 +283,16 @@ class ModlistInstallCLIConfigurationMixin: continue # Enhance Nexus download errors with modlist context enhanced_line = self._enhance_nexus_error(line) - print(enhanced_line, end='') + clean_line = enhanced_line.rstrip('\r\n') + if clean_line.startswith("Installing files "): + print(f"\r{clean_line}", end='') + sys.stdout.flush() + inline_progress_active = True + else: + if inline_progress_active: + print() + inline_progress_active = False + print(enhanced_line, end='') buffer = b'' last_progress_time = time.time() elif chunk == b'\r': @@ -300,7 +310,15 @@ class ModlistInstallCLIConfigurationMixin: continue # Enhance Nexus download errors with modlist context enhanced_line = self._enhance_nexus_error(line) - print(enhanced_line, end='') + clean_line = enhanced_line.rstrip('\r\n') + if clean_line.startswith("Installing files "): + print(f"\r{clean_line}", end='') + inline_progress_active = True + else: + if inline_progress_active: + print() + inline_progress_active = False + print(enhanced_line, end='') sys.stdout.flush() buffer = b'' last_progress_time = time.time() @@ -314,7 +332,13 @@ class ModlistInstallCLIConfigurationMixin: # Print any remaining buffer content if buffer: line = buffer.decode('utf-8', errors='replace') + if inline_progress_active: + print() + inline_progress_active = False print(line, end='') + + if inline_progress_active: + print() proc.wait() @@ -415,7 +439,10 @@ class ModlistInstallCLIConfigurationMixin: if not is_gui_mode: # Prompt user if they want to configure Steam shortcut now print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now?{COLOR_RESET}") + print( + f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now? " + f"Steam will restart and close any running game.{COLOR_RESET}" + ) configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() if configure_choice == 'n': @@ -424,71 +451,61 @@ class ModlistInstallCLIConfigurationMixin: # Proceed with Steam configuration self.logger.info(f"Starting Steam configuration for '{shortcut_name}'") - - # Step 1: Create Steam shortcut first + mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe') - - # Use the working shortcut creation process from legacy code + from .shortcut_handler import ShortcutHandler shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False) - - # Create nxmhandler.ini to suppress NXM popup shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path) - - # Create shortcut with working NativeSteamService - from ..services.native_steam_service import NativeSteamService - steam_service = NativeSteamService() - - success, app_id = steam_service.create_shortcut_with_proton( - app_name=shortcut_name, - exe_path=mo2_exe_path, - start_dir=os.path.dirname(mo2_exe_path), - launch_options="%command%", - tags=["Jackify"], - proton_version="proton_experimental" - ) - - if not success or not app_id: - self.logger.error("Failed to create Steam shortcut") - print(f"{COLOR_ERROR}Failed to create Steam shortcut. Check logs for details.{COLOR_RESET}") - return - - # Step 2: Handle Steam restart and manual steps (if not in GUI mode) - if not is_gui_mode: - print(f"\n{COLOR_INFO}Steam shortcut created successfully!{COLOR_RESET}") - print("Steam needs to restart to detect the new shortcut.") - - restart_choice = input("\nRestart Steam automatically now? (Y/n): ").strip().lower() - if restart_choice == 'n': - print("\nPlease restart Steam manually and complete the Proton setup steps.") - print("You can configure this modlist later using 'Configure Existing Modlist'.") + + from ..services.automated_prefix_service import AutomatedPrefixService + prefix_service = AutomatedPrefixService() + + def _cli_progress(message): + noisy_patterns = ( + "using bundled tools directory", + "bundled tools available", + "checking winetricks dependencies", + "(bundled)", + "(system)", + "wget", + "curl", + "aria2c", + "sha256sum", + "cabextract", + ) + message_lc = message.lower() + if any(pattern in message_lc for pattern in noisy_patterns): + self.logger.debug("Automated prefix detail: %s", message) return - - # Restart Steam - print("\nRestarting Steam...") - if shortcut_handler.secure_steam_restart(): - print(f"{COLOR_INFO}Steam restarted successfully.{COLOR_RESET}") - - # Display manual Proton steps - from .menu_handler import ModlistMenuHandler - from .config_handler import ConfigHandler - config_handler = ConfigHandler() - menu_handler = ModlistMenuHandler(config_handler) - menu_handler._display_manual_proton_steps(shortcut_name) - - input(f"\n{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") - - # Get the updated AppID after launch - new_app_id = shortcut_handler.get_appid_for_shortcut(shortcut_name, mo2_exe_path) - if new_app_id and new_app_id.isdigit() and int(new_app_id) > 0: - app_id = new_app_id - else: - print(f"{COLOR_ERROR}Could not find valid AppID after launch. Configuration may not work properly.{COLOR_RESET}") + print(f"{COLOR_INFO}{message}{COLOR_RESET}") + + try: + _result = prefix_service.run_working_workflow( + shortcut_name, install_dir_str, mo2_exe_path, _cli_progress, steamdeck=self.steamdeck + ) + except Exception as _wf_err: + from jackify.shared.errors import JackifyError + if isinstance(_wf_err, JackifyError): + self.logger.error(f"Automated prefix setup failed: {_wf_err.message}") + print(f"{COLOR_ERROR}{_wf_err.message}{COLOR_RESET}") + if _wf_err.suggestion: + print(f"{COLOR_INFO}What to do: {_wf_err.suggestion}{COLOR_RESET}") else: - print(f"{COLOR_ERROR}Steam restart failed. Please restart manually and configure later.{COLOR_RESET}") - return - - # Step 3: Build configuration context with the AppID + self.logger.error(f"Automated prefix setup failed: {_wf_err}") + print(f"{COLOR_ERROR}Automated prefix setup failed. Check logs for details.{COLOR_RESET}") + return + + if isinstance(_result, tuple) and len(_result) == 4: + success, _prefix_path, app_id, _last_ts = _result + else: + success, app_id = False, None + + if not success: + self.logger.error("Automated prefix setup failed") + print(f"{COLOR_ERROR}Automated prefix setup failed. Check logs for details.{COLOR_RESET}") + return + config_context = { 'name': shortcut_name, 'appid': app_id, @@ -496,17 +513,14 @@ class ModlistInstallCLIConfigurationMixin: 'mo2_exe_path': mo2_exe_path, 'resolution': self.context.get('resolution'), 'skip_confirmation': is_gui_mode, - 'manual_steps_completed': not is_gui_mode # True if we did manual steps above + 'manual_steps_completed': True } - - # Step 4: Use ModlistMenuHandler to run the complete configuration + from .menu_handler import ModlistMenuHandler from .config_handler import ConfigHandler - + config_handler = ConfigHandler() modlist_menu = ModlistMenuHandler(config_handler) - - self.logger.info("Running post-installation configuration phase") configuration_success = modlist_menu.run_modlist_configuration_phase(config_context) if configuration_success: @@ -524,4 +538,3 @@ class ModlistInstallCLIConfigurationMixin: else: print(f"{COLOR_WARNING}Could not detect game type from ModOrganizer.ini for automated configuration.{COLOR_RESET}") print(f"{COLOR_INFO}You may need to manually configure the modlist for Steam/Proton.{COLOR_RESET}") - diff --git a/jackify/backend/handlers/modlist_install_cli_ttw.py b/jackify/backend/handlers/modlist_install_cli_ttw.py index 2db4f5e..c1a8bf4 100644 --- a/jackify/backend/handlers/modlist_install_cli_ttw.py +++ b/jackify/backend/handlers/modlist_install_cli_ttw.py @@ -1,13 +1,59 @@ """TTW integration methods for ModlistInstallCLI (Mixin).""" import logging import os +import re +import signal +import shutil from pathlib import Path -from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET +from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING logger = logging.getLogger(__name__) +def _strip_ansi_control_codes(text: str) -> str: + """Strip ANSI escape/control sequences from CLI output lines.""" + return re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', text or '') + + +def prompt_ttw_if_eligible(install_dir: str, modlist_name: str) -> None: + """Standalone TTW prompt usable outside the mixin context (e.g. CLI configure command). + + Detects game type from ModOrganizer.ini, resolves the best available modlist name, + checks whitelist eligibility, and runs the interactive TTW prompt if applicable. + """ + try: + # Detect game type from ModOrganizer.ini + mo2_ini = Path(install_dir) / "ModOrganizer.ini" + game_type = "skyrim" + if mo2_ini.exists(): + content = mo2_ini.read_text(encoding="utf-8", errors="ignore").lower() + if "nvse_loader.exe" in content or "fallout new vegas" in content: + game_type = "falloutnv" + elif "fose_loader.exe" in content or "fallout 3" in content: + game_type = "fallout3" + + if game_type not in ("falloutnv", "fallout_new_vegas"): + return + + # Best available name: meta file, then selected_profile, then caller-supplied name + from jackify.backend.utils.modlist_meta import get_modlist_name + identified_name = get_modlist_name(install_dir) or modlist_name + if not identified_name: + return + + class _Adapter(ModlistInstallCLITTWMixin): + def __init__(self): + self.logger = logging.getLogger(__name__) + self.verbose = False + self.filesystem_handler = None + self.config_handler = None + + _Adapter()._check_and_prompt_ttw_integration(install_dir, game_type, identified_name) + except Exception as e: + logger.error("TTW post-configure check failed: %s", e, exc_info=True) + + class ModlistInstallCLITTWMixin: """Mixin providing TTW integration methods.""" @@ -26,7 +72,38 @@ class ModlistInstallCLITTWMixin: print(f"TTW combines Fallout 3 and New Vegas into a single game.") print(f"\nWould you like to install TTW now?") - user_input = input(f"{COLOR_PROMPT}Install TTW? (yes/no): {COLOR_RESET}").strip().lower() + # Some CLI entrypoint signal handlers currently call sys.exit(), which can interrupt + # this prompt unexpectedly. Temporarily convert SIGINT/SIGTERM to KeyboardInterrupt + # and keep prompting so users can answer explicitly. + original_sigint = signal.getsignal(signal.SIGINT) + original_sigterm = signal.getsignal(signal.SIGTERM) + + def _prompt_signal_handler(signum, frame): + raise KeyboardInterrupt + + try: + signal.signal(signal.SIGINT, _prompt_signal_handler) + signal.signal(signal.SIGTERM, _prompt_signal_handler) + + while True: + try: + user_input = input(f"{COLOR_PROMPT}Install TTW now? (Y/n): {COLOR_RESET}").strip().lower() + except KeyboardInterrupt: + print(f"\n{COLOR_WARNING}TTW prompt interrupted. Please type yes or no.{COLOR_RESET}") + continue + except EOFError: + print(f"\n{COLOR_WARNING}No input available. Skipping TTW installation.{COLOR_RESET}") + return + + if user_input == "": + user_input = "y" + if user_input in ['yes', 'y', 'no', 'n']: + break + + print(f"{COLOR_WARNING}Please answer yes or no.{COLOR_RESET}") + finally: + signal.signal(signal.SIGINT, original_sigint) + signal.signal(signal.SIGTERM, original_sigterm) if user_input in ['yes', 'y']: self._launch_ttw_installation(modlist_name, install_dir) @@ -106,15 +183,26 @@ class ModlistInstallCLITTWMixin: # Import TTW installation handler from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler - from jackify.backend.models.configuration import SystemInfo + from jackify.backend.handlers.config_handler import ConfigHandler + from jackify.backend.handlers.filesystem_handler import FileSystemHandler + from jackify.backend.services.platform_detection_service import PlatformDetectionService from pathlib import Path - system_info = SystemInfo() + is_steamdeck = bool(getattr(self, 'steamdeck', False)) + if not is_steamdeck: + try: + is_steamdeck = PlatformDetectionService.get_instance().is_steamdeck + except Exception: + is_steamdeck = False + + filesystem_handler = getattr(self, 'filesystem_handler', None) or FileSystemHandler() + config_handler = getattr(self, 'config_handler', None) or ConfigHandler() + ttw_installer_handler = TTWInstallerHandler( - steamdeck=system_info.is_steamdeck if hasattr(system_info, 'is_steamdeck') else False, + steamdeck=is_steamdeck, verbose=self.verbose if hasattr(self, 'verbose') else False, - filesystem_handler=self.filesystem_handler if hasattr(self, 'filesystem_handler') else None, - config_handler=self.config_handler if hasattr(self, 'config_handler') else None + filesystem_handler=filesystem_handler, + config_handler=config_handler ) # Check if TTW_Linux_Installer is installed @@ -122,7 +210,9 @@ class ModlistInstallCLITTWMixin: if not ttw_installer_handler.ttw_installer_installed: print(f"{COLOR_INFO}TTW_Linux_Installer is not installed.{COLOR_RESET}") - user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (yes/no): {COLOR_RESET}").strip().lower() + user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (Y/n): {COLOR_RESET}").strip().lower() + if user_input == "": + user_input = "y" if user_input not in ['yes', 'y']: print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}") @@ -152,7 +242,7 @@ class ModlistInstallCLITTWMixin: # Prompt for TTW installation directory print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}") - default_ttw_dir = os.path.join(install_dir, 'TTW') + default_ttw_dir = os.path.join(install_dir, 'mods', '[NoDelete] Tale of Two Wastelands') print(f"Default: {default_ttw_dir}") ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip() @@ -162,14 +252,105 @@ class ModlistInstallCLITTWMixin: # Run TTW installation print(f"\n{COLOR_INFO}Installing TTW using TTW_Linux_Installer...{COLOR_RESET}") print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}") + phase_state = {"current": "Processing", "last_rendered": ""} + progress_line_active = {"value": False} - success, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir)) + def _ttw_output_callback(line: str): + clean = _strip_ansi_control_codes(line or "").strip() + if not clean: + return + + lower = clean.lower() + rendered = "" + + # Match GUI behavior: explicit Loading manifest counter line + manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower) + if manifest_match: + current = int(manifest_match.group(1)) + total = int(manifest_match.group(2)) + phase_state["current"] = "Loading manifest" + percent = int((current / total) * 100) if total > 0 else 0 + rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)" + else: + # Match GUI behavior: generic [X/Y] counters with current phase name. + progress_match = re.search(r'\[(\d+)/(\d+)\]', clean) + if progress_match: + current = int(progress_match.group(1)) + total = int(progress_match.group(2)) + percent = int((current / total) * 100) if total > 0 else 0 + rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)" + else: + # Update phase state from milestone-like lines, then echo milestones. + if 'manifest' in lower: + phase_state["current"] = "Loading manifest" + elif any(token in lower for token in ('extract', 'decompress', 'installing', 'copying', 'merge')): + phase_state["current"] = clean + + is_milestone = any(token in lower for token in ('===', 'complete', 'finished', 'starting', 'valid')) + is_error = 'error:' in lower + is_warning = 'warning:' in lower + if is_milestone or is_error or is_warning: + rendered = f"[TTW] {clean}" + + if not rendered or rendered == phase_state["last_rendered"]: + return + phase_state["last_rendered"] = rendered + if rendered.startswith("[TTW] Loading manifest:") or re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered): + # In-place progress updates for counters/phases. + print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True) + progress_line_active["value"] = True + else: + # Non-progress milestones/errors get normal line output. + if progress_line_active["value"]: + print() + progress_line_active["value"] = False + print(f"{COLOR_INFO}{rendered}{COLOR_RESET}") + + success, message = ttw_installer_handler.install_ttw_backend_with_output_stream( + Path(mpi_path), + Path(ttw_install_dir), + output_callback=_ttw_output_callback, + ) + if progress_line_active["value"]: + print() if success: + ttw_output_path = Path(ttw_install_dir) + ttw_version = "" + version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', Path(mpi_path).stem, re.IGNORECASE) + if version_match: + ttw_version = version_match.group(1) + + skip_copy = False + mods_dir = Path(install_dir) / "mods" + if ttw_output_path.parent == mods_dir: + versioned_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}".strip() if ttw_version else "[NoDelete] Tale of Two Wastelands" + versioned_path = mods_dir / versioned_name + if ttw_output_path != versioned_path and ttw_output_path.exists(): + if versioned_path.exists(): + shutil.rmtree(versioned_path) + ttw_output_path.rename(versioned_path) + ttw_output_path = versioned_path + skip_copy = True + + print(f"\n{COLOR_INFO}Integrating TTW into modlist load order...{COLOR_RESET}") + integration_success = TTWInstallerHandler.integrate_ttw_into_modlist( + ttw_output_path=ttw_output_path, + modlist_install_dir=Path(install_dir), + ttw_version=ttw_version, + skip_copy=skip_copy, + ) + + if not integration_success: + print(f"{COLOR_ERROR}TTW installed, but integration into modlist failed.{COLOR_RESET}") + print(f"{COLOR_ERROR}Please check TTW_Install_workflow.log for details.{COLOR_RESET}") + return + print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}") print(f"{COLOR_INFO}TTW Installation Complete!{COLOR_RESET}") print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") - print(f"\nTTW has been installed to: {ttw_install_dir}") + print(f"\nTTW has been installed to: {ttw_output_path}") + print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).") print(f"The modlist '{modlist_name}' is now ready to use with TTW.") else: print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}") @@ -177,4 +358,4 @@ class ModlistInstallCLITTWMixin: except Exception as e: self.logger.error(f"Error during TTW installation: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}") \ No newline at end of file + print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}") diff --git a/jackify/backend/handlers/modlist_wine_ops.py b/jackify/backend/handlers/modlist_wine_ops.py index 907ca25..3ee60af 100644 --- a/jackify/backend/handlers/modlist_wine_ops.py +++ b/jackify/backend/handlers/modlist_wine_ops.py @@ -207,8 +207,11 @@ class ModlistWineOpsMixin: # Add game-specific extras if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game: extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"] - elif "falloutnewvegas" in game or "fnv" in game or "oblivion" in game: + elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game: extras += ["d3dx9_43", "d3dx9"] + else: + # Unknown game type — install the union of all known component sets + extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "d3dx9_43", "d3dx9"] # Add modlist-specific extras modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else "" for key, components in self.MODLIST_WINE_COMPONENTS.items(): diff --git a/jackify/backend/handlers/oauth_token_handler.py b/jackify/backend/handlers/oauth_token_handler.py index 3408170..9723399 100644 --- a/jackify/backend/handlers/oauth_token_handler.py +++ b/jackify/backend/handlers/oauth_token_handler.py @@ -66,12 +66,12 @@ class OAuthTokenHandler: # Linux machine-id with open('/etc/machine-id', 'r') as f: machine_id = f.read().strip() - except: + except (OSError, IOError): try: # Alternative locations with open('/var/lib/dbus/machine-id', 'r') as f: machine_id = f.read().strip() - except: + except (OSError, IOError): pass # Combine multiple sources of machine-specific data @@ -221,7 +221,7 @@ class OAuthTokenHandler: # Clean up temp file on error try: os.unlink(temp_path) - except: + except (OSError, IOError): pass raise e diff --git a/jackify/backend/handlers/progress_parser.py b/jackify/backend/handlers/progress_parser.py index 7651623..f00f9eb 100644 --- a/jackify/backend/handlers/progress_parser.py +++ b/jackify/backend/handlers/progress_parser.py @@ -87,10 +87,12 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres re.IGNORECASE ) - # Alternative format: "[timestamp] StatusText (current/total) - speed" + # Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]" # Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s" + # Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining" + # Timestamp prefix is now optional — engine no longer emits [HH:MM:SS]. self.timestamp_status_pattern = re.compile( - r'\[[^\]]+\]\s+(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)', + r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?', re.IGNORECASE ) @@ -230,18 +232,33 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres if speed_info: operation = self._detect_operation_from_line(status_text) result.speed_info = (operation.value, speed_info) - + + # Extract remaining size if present (engine 0.4.8+: "- 23.1GB remaining") + remaining_val = timestamp_match.group(5) + remaining_unit = timestamp_match.group(6) + if remaining_val and remaining_unit: + remaining_bytes = self._convert_to_bytes(float(remaining_val), remaining_unit) + if remaining_bytes > 0 and max_steps > 0 and current_step < max_steps: + fraction_done = current_step / max_steps + # Estimate total from remaining and fraction; clamp denominator to avoid div/0 near completion + estimated_total = remaining_bytes / max(1.0 - fraction_done, 0.01) + data_processed = int(estimated_total - remaining_bytes) + result.data_info = (max(0, data_processed), int(estimated_total)) + elif remaining_bytes > 0: + result.data_info = (0, int(remaining_bytes)) + # Calculate overall percentage from step progress if max_steps > 0: result.overall_percent = (current_step / max_steps) * 100.0 - + result.has_progress = True # Try .wabbajack download format: "[timestamp] Downloading .wabbajack (size/size) - speed" # Example: "[00:02:08] Downloading .wabbajack (739.2/1947.2MB) - 6.0MB/s" # Also handles: "[00:02:08] Downloading modlist.wabbajack (739.2/1947.2MB) - 6.0MB/s" + # Timestamp prefix is optional in newer engine output. wabbajack_download_pattern = re.compile( - r'\[[^\]]+\]\s+Downloading\s+([^\s]+\.wabbajack|\.wabbajack)\s+\(([^)]+)\)\s*-\s*([^\s]+)', + r'(?:\[[^\]]+\]\s+)?Downloading\s+([^\s]+\.wabbajack|\.wabbajack)\s+\(([^)]+)\)\s*-\s*([^\s]+)', re.IGNORECASE ) wabbajack_match = wabbajack_download_pattern.search(line) @@ -294,13 +311,15 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres # Set phase result.phase = InstallationPhase.DOWNLOAD - result.phase_name = f"Downloading {filename}" + phase_target = filename + if phase_target.lower().startswith("downloading "): + phase_target = phase_target[len("downloading "):].strip() + result.phase_name = f"Downloading {phase_target}" # Create FileProgress entry for .wabbajack file if data_info: current_bytes, total_bytes = data_info percent = (current_bytes / total_bytes) * 100.0 if total_bytes > 0 else 0.0 - from jackify.shared.progress_models import FileProgress, OperationType file_progress = FileProgress( filename=filename, operation=OperationType.DOWNLOAD, @@ -313,6 +332,50 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres result.has_progress = True + # Try to extract install progress format: + # "Installing files X/Y (GB/GB) - Converting textures: N/M" + install_match = re.match( + r'Installing files\s+(\d+)/(\d+)\s+\(([^)]+)\)(?:\s*-\s*Converting textures:\s*(\d+)/(\d+))?', + line.strip(), re.IGNORECASE) + if install_match: + result.phase = InstallationPhase.INSTALL + result.step_info = (int(install_match.group(1)), int(install_match.group(2))) + data_info = self._parse_data_string(install_match.group(3)) + if data_info: + result.data_info = data_info + current_bytes, total_bytes = data_info + if total_bytes > 0: + result.overall_percent = (current_bytes / total_bytes) * 100.0 + if install_match.group(4) and install_match.group(5): + fp = FileProgress( + filename='_tex', + operation=OperationType.INSTALL, + percent=0.0, + speed=-1.0 + ) + fp._texture_counter = (int(install_match.group(4)), int(install_match.group(5))) + fp._hidden = True + result.file_progress = fp + result.has_progress = True + + # Conversion-only status line (without "Installing files ...") + conversion_match = re.search(r'Converting textures:\s*(\d+)/(\d+)', line, re.IGNORECASE) + if conversion_match and not install_match: + if not result.phase: + result.phase = InstallationPhase.INSTALL + if not result.phase_name: + result.phase_name = "Converting textures" + fp = FileProgress( + filename='_tex', + operation=OperationType.INSTALL, + percent=0.0, + speed=-1.0 + ) + fp._texture_counter = (int(conversion_match.group(1)), int(conversion_match.group(2))) + fp._hidden = True + result.file_progress = fp + result.has_progress = True + # Try to extract step information (fallback) if not result.step_info: step_info = self._extract_step_info(line) diff --git a/jackify/backend/handlers/progress_parser_extraction.py b/jackify/backend/handlers/progress_parser_extraction.py index 0f0f859..50b52f0 100644 --- a/jackify/backend/handlers/progress_parser_extraction.py +++ b/jackify/backend/handlers/progress_parser_extraction.py @@ -24,6 +24,12 @@ class ProgressParserExtractionMixin: def _extract_step_info(self, line: str) -> Optional[Tuple[int, int]]: """Extract step information like [12/14].""" + line_lower = line.lower() + # Texture conversion counters are tracked separately; don't let generic + # step parsing overwrite the primary install counter. + if 'converting textures' in line_lower and 'installing files' not in line_lower: + return None + match = self.wabbajack_status_pattern.search(line) if match: current = int(match.group(1)) diff --git a/jackify/backend/handlers/progress_parser_phase.py b/jackify/backend/handlers/progress_parser_phase.py index 8ac6c46..9cc6a80 100644 --- a/jackify/backend/handlers/progress_parser_phase.py +++ b/jackify/backend/handlers/progress_parser_phase.py @@ -20,8 +20,13 @@ class ProgressParserPhaseMixin: phase = self._map_section_to_phase(section_name) return (phase, section_match.group(1).strip()) + # [FILE_PROGRESS] lines drive file activity only — skip phase extraction for them + if '[FILE_PROGRESS]' in line: + return None + + # Make the [timestamp] prefix optional — engine no longer emits it. action_match = re.search( - r'\[.*?\]\s*(Installing|Downloading|Extracting|Validating|Processing|Checking existing)', + r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)', line, re.IGNORECASE ) @@ -51,13 +56,18 @@ class ProgressParserPhaseMixin: return InstallationPhase.DOWNLOAD elif 'extract' in section_lower: return InstallationPhase.EXTRACT - elif 'validate' in section_lower or 'verif' in section_lower: + elif 'hash' in section_lower or 'validate' in section_lower or 'verif' in section_lower: return InstallationPhase.VALIDATE elif 'install' in section_lower: return InstallationPhase.INSTALL + elif 'bsa' in section_lower or 'building' in section_lower: + return InstallationPhase.INSTALL elif 'finaliz' in section_lower or 'complet' in section_lower: return InstallationPhase.FINALIZE - elif 'configur' in section_lower or 'initializ' in section_lower: + elif ('configur' in section_lower or 'initializ' in section_lower + or 'looking' in section_lower or 'cleaning' in section_lower + or 'unmodified' in section_lower or 'updating' in section_lower + or 'folder' in section_lower or 'delete' in section_lower): return InstallationPhase.INITIALIZATION else: return InstallationPhase.UNKNOWN diff --git a/jackify/backend/handlers/progress_state_processing.py b/jackify/backend/handlers/progress_state_processing.py index a2810d1..9094641 100644 --- a/jackify/backend/handlers/progress_state_processing.py +++ b/jackify/backend/handlers/progress_state_processing.py @@ -94,9 +94,6 @@ class ProgressStateProcessingMixin: updated = True if parsed.file_progress: - if hasattr(parsed.file_progress, '_hidden') and parsed.file_progress._hidden: - return updated - if hasattr(parsed.file_progress, '_texture_counter'): tex_current, tex_total = parsed.file_progress._texture_counter self.state.texture_conversion_current = tex_current @@ -109,6 +106,9 @@ class ProgressStateProcessingMixin: self.state.bsa_building_total = bsa_total updated = True + if hasattr(parsed.file_progress, '_hidden') and parsed.file_progress._hidden: + return updated + if parsed.file_progress.filename.lower().endswith('.wabbajack'): self._wabbajack_entry_name = parsed.file_progress.filename self._remove_synthetic_wabbajack() diff --git a/jackify/backend/handlers/shortcut_launch_options.py b/jackify/backend/handlers/shortcut_launch_options.py index d775d87..f3b6415 100644 --- a/jackify/backend/handlers/shortcut_launch_options.py +++ b/jackify/backend/handlers/shortcut_launch_options.py @@ -153,7 +153,7 @@ class ShortcutLaunchOptionsMixin: content = ( "[handlers]\n" "size=1\n" - "1\\games=\"skyrimse,skyrim\"\n" + "1\\games=\"skyrimse,skyrim,fallout4,falloutnv,fallout3,oblivion,enderal,starfield\"\n" f"1\\executable={win_path}\n" "1\\arguments=\n" ) diff --git a/jackify/backend/handlers/subprocess_utils.py b/jackify/backend/handlers/subprocess_utils.py index 22f92de..db23d2b 100644 --- a/jackify/backend/handlers/subprocess_utils.py +++ b/jackify/backend/handlers/subprocess_utils.py @@ -145,7 +145,7 @@ def increase_file_descriptor_limit(target_limit=1048576): # Get current limit for reporting try: soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE) - except: + except (OSError, ValueError): soft_limit = "unknown" return False, soft_limit, soft_limit, f"Failed to increase file descriptor limit: {e}" @@ -154,7 +154,7 @@ class ProcessManager: """ Shared process manager for robust subprocess launching, tracking, and cancellation. """ - def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0): + def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False): self.cmd = cmd # Default to cleaned environment if None to prevent AppImage variable inheritance if env is None: @@ -164,15 +164,17 @@ class ProcessManager: self.cwd = cwd self.text = text self.bufsize = bufsize + self.separate_stderr = separate_stderr self.proc = None self.process_group_pid = None self._start_process() def _start_process(self): + stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT self.proc = subprocess.Popen( self.cmd, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + stderr=stderr_arg, env=self.env, cwd=self.cwd, text=self.text, @@ -186,38 +188,48 @@ class ProcessManager: Attempt to robustly terminate the process and its children. """ cleanup_attempts = 0 - if self.proc: - try: - self.proc.terminate() + try: + if self.proc: try: - self.proc.wait(timeout=timeout_terminate) - return - except subprocess.TimeoutExpired: - pass - except Exception: - pass - try: - self.proc.kill() - try: - self.proc.wait(timeout=timeout_kill) - return - except subprocess.TimeoutExpired: - pass - except Exception: - pass - # Kill process group if possible - if self.process_group_pid: - try: - os.killpg(self.process_group_pid, signal.SIGKILL) + self.proc.terminate() + try: + self.proc.wait(timeout=timeout_terminate) + return + except subprocess.TimeoutExpired: + pass except Exception: pass - # Last resort: pkill by command name - while cleanup_attempts < max_cleanup_attempts: try: - subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True) + self.proc.kill() + try: + self.proc.wait(timeout=timeout_kill) + return + except subprocess.TimeoutExpired: + pass except Exception: pass - cleanup_attempts += 1 + # Kill entire process group (catches 7zz and other child processes) + if self.process_group_pid: + try: + os.killpg(self.process_group_pid, signal.SIGKILL) + except Exception: + pass + # Last resort: pkill by command name + while cleanup_attempts < max_cleanup_attempts: + try: + subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True) + except Exception: + pass + cleanup_attempts += 1 + finally: + # Always close pipes — unblocks threads blocked on read(1) or iterating stderr + if self.proc: + for pipe in (self.proc.stdout, self.proc.stderr): + if pipe: + try: + pipe.close() + except Exception: + pass def is_running(self): return self.proc and self.proc.poll() is None @@ -234,5 +246,8 @@ class ProcessManager: def read_stdout_char(self): if self.proc and self.proc.stdout: - return self.proc.stdout.read(1) - return None \ No newline at end of file + try: + return self.proc.stdout.read(1) + except (ValueError, OSError): + return None + return None \ No newline at end of file diff --git a/jackify/backend/handlers/ttw_installer_backend.py b/jackify/backend/handlers/ttw_installer_backend.py index 8bccca1..532a527 100644 --- a/jackify/backend/handlers/ttw_installer_backend.py +++ b/jackify/backend/handlers/ttw_installer_backend.py @@ -229,7 +229,7 @@ class TTWInstallerBackendMixin: return False, f"Error executing TTW_Linux_Installer: {e}" @staticmethod - def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str) -> bool: + def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str, skip_copy: bool = False) -> bool: """Integrate TTW output into a modlist's MO2 structure.""" import shutil logging_handler = LoggingHandler() @@ -246,12 +246,16 @@ class TTWInstallerBackendMixin: return False mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands" target_mod_dir = mods_dir / mod_folder_name - logger.info("Copying TTW output to %s", target_mod_dir) - if target_mod_dir.exists(): - logger.info("Removing existing TTW mod at %s", target_mod_dir) - shutil.rmtree(target_mod_dir) - shutil.copytree(ttw_output_path, target_mod_dir) - logger.info("TTW output copied successfully") + if skip_copy: + # TTW was installed directly to target_mod_dir — no copy needed + logger.info("TTW already at target location, skipping copy: %s", target_mod_dir) + else: + logger.info("Copying TTW output to %s", target_mod_dir) + if target_mod_dir.exists(): + logger.info("Removing existing TTW mod at %s", target_mod_dir) + shutil.rmtree(target_mod_dir) + shutil.copytree(ttw_output_path, target_mod_dir) + logger.info("TTW output copied successfully") ttw_esms = [ "Fallout3.esm", "Anchorage.esm", "ThePitt.esm", "BrokenSteel.esm", "PointLookout.esm", "Zeta.esm", "TaleOfTwoWastelands.esm", "YUPTTW.esm" diff --git a/jackify/backend/handlers/wabbajack_parser.py b/jackify/backend/handlers/wabbajack_parser.py index 951540c..6f261d3 100644 --- a/jackify/backend/handlers/wabbajack_parser.py +++ b/jackify/backend/handlers/wabbajack_parser.py @@ -36,7 +36,7 @@ class WabbajackParser: # List of supported games in Jackify self.supported_games = [ - 'skyrim', 'fallout4', 'falloutnv', 'oblivion', + 'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion', 'starfield', 'oblivion_remastered', 'enderal' ] diff --git a/jackify/backend/handlers/wine_utils_proton.py b/jackify/backend/handlers/wine_utils_proton.py index e177f84..f5065bc 100644 --- a/jackify/backend/handlers/wine_utils_proton.py +++ b/jackify/backend/handlers/wine_utils_proton.py @@ -218,7 +218,9 @@ class WineUtilsProtonMixin: Path.home() / ".steam/root/compatibilitytools.d", Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d", Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton" + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton", + Path("/usr/share/steam/compatibilitytools.d"), + Path("/usr/lib/steam/compatibilitytools.d"), ] return [path for path in compat_paths if path.exists()] @@ -357,7 +359,7 @@ class WineUtilsProtonMixin: if version_match: major_ver = int(version_match.group(1)) minor_ver = int(version_match.group(2)) - priority = 200 + (major_ver * 10) + minor_ver + priority = 200 + (major_ver * 10) + minor_ver # kept for backward compat; sort uses tuple key compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir) or dir_name found_versions.append({ 'name': dir_name, @@ -374,7 +376,7 @@ class WineUtilsProtonMixin: logger.debug(f"Skipping {dir_name} - unknown GE-Proton version format") except Exception as e: logger.warning(f"Error scanning {compat_path}: {e}") - found_versions.sort(key=lambda x: x['priority'], reverse=True) + found_versions.sort(key=lambda x: (x['major_version'], x['minor_version']), reverse=True) logger.info(f"Found {len(found_versions)} GE-Proton version(s)") return found_versions @@ -427,7 +429,16 @@ class WineUtilsProtonMixin: all_versions.extend(WineUtilsProtonMixin.scan_ge_proton_versions()) all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions()) all_versions.extend(WineUtilsProtonMixin.scan_valve_proton_versions()) - all_versions.sort(key=lambda x: x['priority'], reverse=True) + _TYPE_RANK = {'GE-Proton': 2, 'ThirdParty-Proton': 1, 'Valve-Proton': 0} + all_versions.sort( + key=lambda x: ( + _TYPE_RANK.get(x.get('type', ''), 0), + x.get('major_version', 0), + x.get('minor_version', 0), + x.get('priority', 0), + ), + reverse=True, + ) unique_versions = [] seen_names = set() for version in all_versions: @@ -443,17 +454,16 @@ class WineUtilsProtonMixin: @staticmethod def select_best_proton() -> Optional[Dict[str, Any]]: - """Select the best available Proton (GE or Valve). Excludes third-party builds.""" + """Select the best available Proton version. Prefers GE-Proton, then Valve, then any third-party build.""" available_versions = WineUtilsProtonMixin.scan_all_proton_versions() if not available_versions: - logger.warning("No compatible Proton versions found") + logger.warning("No Proton versions found") return None - compatible_versions = [v for v in available_versions if v.get('type') in ('GE-Proton', 'Valve-Proton')] - if not compatible_versions: - logger.warning("No compatible Proton versions found (only third-party builds available)") - return None - best_version = compatible_versions[0] - logger.info(f"Selected best Proton version: {best_version['name']} ({best_version['type']})") + best_version = available_versions[0] + if best_version.get('type') == 'ThirdParty-Proton': + logger.debug(f"No GE/Valve Proton found; using third-party build: {best_version['name']}") + else: + logger.info(f"Selected Proton: {best_version['name']} ({best_version['type']})") return best_version @staticmethod diff --git a/jackify/backend/handlers/winetricks_env.py b/jackify/backend/handlers/winetricks_env.py index 0b4c7db..a755c64 100644 --- a/jackify/backend/handlers/winetricks_env.py +++ b/jackify/backend/handlers/winetricks_env.py @@ -83,7 +83,7 @@ class WinetricksEnvMixin: self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}") if not wine_binary: - if user_proton_path == 'auto': + if not user_proton_path or user_proton_path == 'auto': self.logger.info("Auto-detecting Proton (user selected 'auto')") best_proton = WineUtils.select_best_proton() if best_proton: diff --git a/jackify/backend/services/automated_prefix_creation.py b/jackify/backend/services/automated_prefix_creation.py index 8e05f0c..9a88160 100644 --- a/jackify/backend/services/automated_prefix_creation.py +++ b/jackify/backend/services/automated_prefix_creation.py @@ -8,250 +8,9 @@ import subprocess logger = logging.getLogger(__name__) - -def debug_print(message): - """Log debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - logger.debug(message) - - class PrefixCreationMixin: """Mixin providing prefix creation methods for AutomatedPrefixService.""" - def detect_actual_prefix_appid(self, initial_appid: int, shortcut_name: str) -> Optional[int]: - """ - After Steam restart, detect the actual prefix AppID that was created. - Uses direct VDF file reading to find the actual AppID. - - Args: - initial_appid: The initial (negative) AppID from shortcuts.vdf - shortcut_name: Name of the shortcut for logging - - Returns: - The actual (positive) AppID of the created prefix, or None if not found - """ - try: - logger.info(f"Using VDF to detect actual AppID for shortcut: {shortcut_name}") - - # Wait up to 30 seconds for Steam to process the shortcut - for i in range(30): - try: - from ..handlers.shortcut_handler import ShortcutHandler - from ..handlers.path_handler import PathHandler - - path_handler = PathHandler() - shortcuts_path = path_handler._find_shortcuts_vdf() - - if shortcuts_path: - from ..handlers.vdf_handler import VDFHandler - shortcuts_data = VDFHandler.load(shortcuts_path, binary=True) - - if shortcuts_data and 'shortcuts' in shortcuts_data: - for idx, shortcut in shortcuts_data['shortcuts'].items(): - app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip() - - if app_name.lower() == shortcut_name.lower(): - appid = shortcut.get('appid') - if appid: - actual_appid = int(appid) & 0xFFFFFFFF - logger.info(f"Found shortcut '{app_name}' in shortcuts.vdf") - logger.info(f" Initial AppID (signed): {initial_appid}") - logger.info(f" Actual AppID (unsigned): {actual_appid}") - return actual_appid - - logger.debug(f"Shortcut '{shortcut_name}' not found in VDF yet (attempt {i+1}/30)") - time.sleep(1) - - except Exception as e: - logger.warning(f"Error reading shortcuts.vdf on attempt {i+1}: {e}") - time.sleep(1) - - logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf after 30 seconds") - return None - - except Exception as e: - logger.error(f"Error detecting actual prefix AppID: {e}") - return None - - def launch_shortcut_to_trigger_prefix(self, initial_appid: int) -> bool: - """ - Launch the shortcut using rungameid to trigger prefix creation. - This follows the same pattern as the working test script. - - Args: - initial_appid: The initial (negative) AppID from shortcuts.vdf - - Returns: - True if successful, False otherwise - """ - try: - # Convert signed AppID to unsigned AppID (same as STL's generateSteamShortID) - unsigned_appid = self.generate_steam_short_id(initial_appid) - - # Calculate rungameid using the unsigned AppID - rungameid = (unsigned_appid << 32) | 0x02000000 - - logger.info(f"Launching shortcut with rungameid: {rungameid}") - debug_print(f"[DEBUG] Launching shortcut with rungameid: {rungameid}") - debug_print(f"[DEBUG] Initial signed AppID: {initial_appid}") - debug_print(f"[DEBUG] Unsigned AppID: {unsigned_appid}") - - # Launch using rungameid - cmd = ['steam', f'steam://rungameid/{rungameid}'] - debug_print(f"[DEBUG] About to run launch command: {' '.join(cmd)}") - - # Use subprocess.Popen to launch asynchronously (steam command returns immediately) - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - # Wait a moment for the process to start - time.sleep(1) - - # Check if the process is still running (steam command should exit quickly) - try: - return_code = process.poll() - if return_code is None: - # Process is still running, wait a bit more - time.sleep(2) - return_code = process.poll() - - debug_print(f"[DEBUG] Steam launch process return code: {return_code}") - - # Get any output - stdout, stderr = process.communicate(timeout=1) - if stdout: - debug_print(f"[DEBUG] Steam launch stdout: {stdout}") - if stderr: - debug_print(f"[DEBUG] Steam launch stderr: {stderr}") - - except subprocess.TimeoutExpired: - debug_print("[DEBUG] Steam launch process timed out, but that's OK") - process.kill() - - logger.info(f"Launch command executed: {' '.join(cmd)}") - - # Give it a moment for the shortcut to actually start - time.sleep(5) - - return True - - except subprocess.TimeoutExpired: - logger.error("Launch command timed out") - debug_print("[DEBUG] Launch command timed out") - return False - except Exception as e: - logger.error(f"Error launching shortcut: {e}") - debug_print(f"[DEBUG] Error launching shortcut: {e}") - return False - - def create_prefix_directly(self, appid: int, batch_file_path: str) -> Optional[Path]: - """ - Create prefix directly using Proton wrapper. - - Args: - appid: The AppID from the shortcut - batch_file_path: Path to the temporary batch file - - Returns: - Path to the created prefix, or None if failed - """ - proton_path = self.find_proton_experimental() - if not proton_path: - return None - - # Steam uses negative AppIDs for non-Steam shortcuts, but we need the positive value for the prefix path - positive_appid = abs(appid) - logger.info(f"Using positive AppID {positive_appid} for prefix creation (original: {appid})") - - # Create the prefix directory structure - prefix_path = self._get_compatdata_path_for_appid(positive_appid) - if not prefix_path: - logger.error(f"Could not determine compatdata path for AppID {positive_appid}") - return None - - # Create the prefix directory structure - prefix_path.mkdir(parents=True, exist_ok=True) - pfx_dir = prefix_path / "pfx" - pfx_dir.mkdir(exist_ok=True) - - # Set up environment - env = os.environ.copy() - env['STEAM_COMPAT_DATA_PATH'] = str(prefix_path) - env['STEAM_COMPAT_APP_ID'] = str(positive_appid) # Use positive AppID for environment - - # Determine correct Steam root based on installation type - from ..handlers.path_handler import PathHandler - path_handler = PathHandler() - steam_library = path_handler.find_steam_library() - if steam_library and steam_library.name == "common": - # Extract Steam root from library path: .../Steam/steamapps/common -> .../Steam - steam_root = steam_library.parent.parent - env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root) - else: - # Fallback to legacy path if detection fails - env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(Path.home() / ".local/share/Steam") - - # Build the command - cmd = [ - str(proton_path / "proton"), - "run", - batch_file_path - ] - - logger.info(f"Creating prefix with command: {' '.join(cmd)}") - logger.info(f"Prefix path: {prefix_path}") - logger.info(f"Using AppID: {positive_appid} (original: {appid})") - - try: - # Run the command with a timeout - result = subprocess.run( - cmd, - env=env, - capture_output=True, - text=True, - timeout=30 - ) - - # Check if prefix was created - time.sleep(2) # Give it a moment to settle - - prefix_created = prefix_path.exists() - pfx_exists = (prefix_path / "pfx").exists() - - logger.info(f"Return code: {result.returncode}") - logger.info(f"Prefix created: {prefix_created}") - logger.info(f"pfx directory exists: {pfx_exists}") - - if result.stderr: - logger.debug(f"stderr: {result.stderr.strip()}") - - success = prefix_created and pfx_exists - - if success: - logger.info(f"Prefix created successfully at: {prefix_path}") - return prefix_path - else: - logger.error("Failed to create prefix") - return None - - except subprocess.TimeoutExpired: - logger.warning("Command timed out, but this might be normal") - # Check if prefix was created despite timeout - prefix_created = prefix_path.exists() - pfx_exists = (prefix_path / "pfx").exists() - - if prefix_created and pfx_exists: - logger.info(f"Prefix created successfully despite timeout at: {prefix_path}") - return prefix_path - else: - logger.error("No prefix created") - return None - - except Exception as e: - logger.error(f"Error creating prefix: {e}") - return None - def _get_compatdata_path_for_appid(self, appid: int) -> Optional[Path]: """ Get the compatdata path for a given AppID. diff --git a/jackify/backend/services/automated_prefix_game_utils.py b/jackify/backend/services/automated_prefix_game_utils.py index 59a21da..2d728f2 100644 --- a/jackify/backend/services/automated_prefix_game_utils.py +++ b/jackify/backend/services/automated_prefix_game_utils.py @@ -151,6 +151,7 @@ class GameUtilsMixin: game_dir_names = { "skyrim": "Skyrim Special Edition", "fnv": "FalloutNV", + "fo3": "Fallout3", "fo4": "Fallout4", "oblivion": "Oblivion", "oblivion_remastered": "Oblivion Remastered", diff --git a/jackify/backend/services/automated_prefix_proton.py b/jackify/backend/services/automated_prefix_proton.py index 50d8a60..be95843 100644 --- a/jackify/backend/services/automated_prefix_proton.py +++ b/jackify/backend/services/automated_prefix_proton.py @@ -7,15 +7,6 @@ import vdf logger = logging.getLogger(__name__) - -def debug_print(message): - """Log debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - logger.debug(message) - - class ProtonOperationsMixin: """Mixin providing Proton and compatibility tool methods for AutomatedPrefixService.""" @@ -112,7 +103,7 @@ class ProtonOperationsMixin: # STL sets the compatibility tool in config.vdf, not shortcuts.vdf # We know this works from manual testing, so just log that we're skipping this check logger.info(f"Skipping Proton version check for '{shortcut_name}' - STL handles this correctly") - debug_print(f"[DEBUG] Skipping Proton version check for '{shortcut_name}' - STL handles this correctly") + logger.debug(f"[DEBUG] Skipping Proton version check for '{shortcut_name}' - STL handles this correctly") def set_proton_version_for_shortcut(self, appid: int, proton_version: str) -> bool: """ @@ -165,7 +156,7 @@ class ProtonOperationsMixin: os.fsync(f.fileno()) if hasattr(f, 'fileno') else None logger.info(f"Set Proton version {proton_version} for AppID {appid}") - debug_print(f"[DEBUG] Set Proton version {proton_version} for AppID {appid} in config.vdf") + logger.debug(f"[DEBUG] Set Proton version {proton_version} for AppID {appid} in config.vdf") # Small delay to ensure filesystem write completes import time @@ -175,7 +166,7 @@ class ProtonOperationsMixin: with open(config_path, 'r') as f: verify_data = vdf.load(f) compat_mapping = verify_data.get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid)) - debug_print(f"[DEBUG] Verification: AppID {appid} -> {compat_mapping}") + logger.debug(f"[DEBUG] Verification: AppID {appid} -> {compat_mapping}") return True @@ -324,14 +315,14 @@ class ProtonOperationsMixin: config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(unsigned_appid)] = compat_entry logger.info(f"Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}") - debug_print(f"[DEBUG] Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}") + logger.debug(f"[DEBUG] Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}") # Write back to file (text format) with open(config_path, 'w') as f: vdf.dump(config_data, f) logger.info(f"Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}") - debug_print(f"[DEBUG] Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}") + logger.debug(f"[DEBUG] Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}") return True @@ -564,7 +555,7 @@ class ProtonOperationsMixin: f.writelines(lines) logger.info(f"Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1") - debug_print(f"[DEBUG] Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1") + logger.debug(f"[DEBUG] Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1") return True diff --git a/jackify/backend/services/automated_prefix_registry.py b/jackify/backend/services/automated_prefix_registry.py index cf6487d..eb8c0fe 100644 --- a/jackify/backend/services/automated_prefix_registry.py +++ b/jackify/backend/services/automated_prefix_registry.py @@ -72,7 +72,12 @@ class RegistryOperationsMixin: return False def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str): - """Apply universal dotnet4.x compatibility registry fixes to ALL modlists""" + """Apply universal dotnet4.x compatibility registry fixes to ALL modlists. + + Direct file editing is preferred over `wine reg add` — faster, no Wine + process overhead, and works even when Proton isn't on PATH. Falls back + to subprocess wine reg add when the reg files haven't been created yet. + """ try: prefix_path = os.path.join(modlist_compatdata_path, "pfx") if not os.path.exists(prefix_path): @@ -81,59 +86,99 @@ class RegistryOperationsMixin: logger.info("Applying universal dotnet4.x compatibility registry fixes...") - # Find the appropriate Wine binary to use for registry operations + user_reg = os.path.join(prefix_path, "user.reg") + system_reg = os.path.join(prefix_path, "system.reg") + + fix1 = fix2 = False + + if os.path.exists(user_reg): + fix1 = self._reg_set_value( + user_reg, + "[Software\\\\Wine\\\\DllOverrides]", + '"*mscoree"', + '"native"', + ) + if os.path.exists(system_reg): + fix2 = self._reg_set_value( + system_reg, + "[Software\\\\Microsoft\\\\.NETFramework]", + '"OnlyUseLatestCLR"', + "dword:00000001", + ) + + if fix1 and fix2: + logger.info("Universal dotnet4.x compatibility fixes applied via direct reg file editing") + return True + + # Fall back to wine reg add when reg files are not present yet + logger.debug("Reg files not ready; falling back to wine reg add") wine_binary = self._find_wine_binary_for_registry(modlist_compatdata_path) if not wine_binary: - logger.error("Could not find Wine binary for registry operations") + logger.error("Could not find Wine binary for registry fallback") return False - # Set environment for Wine registry operations env = os.environ.copy() env['WINEPREFIX'] = prefix_path - env['WINEDEBUG'] = '-all' # Suppress Wine debug output + env['WINEDEBUG'] = '-all' - # Registry fix 1: Set *mscoree=native DLL override (asterisk for full override) - # Use native .NET runtime instead of Wine's - logger.debug("Setting *mscoree=native DLL override...") - cmd1 = [ - wine_binary, 'reg', 'add', - 'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides', - '/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f' - ] + r1 = subprocess.run( + [wine_binary, 'reg', 'add', + 'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides', + '/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'], + env=env, capture_output=True, text=True, errors='replace', + ) + r2 = subprocess.run( + [wine_binary, 'reg', 'add', + 'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework', + '/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'], + env=env, capture_output=True, text=True, errors='replace', + ) - result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace') - if result1.returncode == 0: - logger.info("Successfully applied *mscoree=native DLL override") + ok = r1.returncode == 0 and r2.returncode == 0 + if ok: + logger.info("Universal dotnet4.x fixes applied via wine reg add fallback") else: - logger.warning(f"Failed to set *mscoree DLL override: {result1.stderr}") - - # Registry fix 2: Set OnlyUseLatestCLR=1 - # Use latest CLR to avoid .NET version conflicts - logger.debug("Setting OnlyUseLatestCLR=1 registry entry...") - cmd2 = [ - wine_binary, 'reg', 'add', - 'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework', - '/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f' - ] - - result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace') - if result2.returncode == 0: - logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry") - else: - logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}") - - # Both fixes applied - this should eliminate dotnet4.x installation requirements - if result1.returncode == 0 and result2.returncode == 0: - logger.info("Universal dotnet4.x compatibility fixes applied successfully") - return True - else: - logger.warning("Some dotnet4.x registry fixes failed, but continuing...") - return False + logger.warning("Some dotnet4.x registry fixes failed") + return ok except Exception as e: logger.error(f"Failed to apply universal dotnet4.x fixes: {e}") return False + def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool: + """Set or add a key=value pair in a Wine .reg text file.""" + try: + with open(reg_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + in_section = False + updated = False + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.lower() == section.lower(): + in_section = True + elif stripped.startswith('[') and in_section: + # Reached next section without finding key; insert before it + lines.insert(i, f'{key}={value}\n') + updated = True + break + elif in_section and stripped.startswith(key.lower()) or (in_section and stripped.lower().startswith(key.lower())): + lines[i] = f'{key}={value}\n' + updated = True + break + + if not updated: + if not in_section: + lines.append(f'\n{section}\n') + lines.append(f'{key}={value}\n') + + with open(reg_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + return True + except Exception as e: + logger.debug(f"_reg_set_value failed for {reg_path}: {e}") + return False + def _find_wine_binary_for_registry(self, modlist_compatdata_path: str) -> Optional[str]: """Find the appropriate Wine binary for registry operations""" try: @@ -227,8 +272,41 @@ class RegistryOperationsMixin: logger.debug(f"Error during recursive wine search in {proton_path}: {e}") return None + def _create_canonical_game_symlink(self, pfx_path: Path, real_game_path: str) -> bool: + """Symlink the real game dir into the prefix at the canonical Windows Steam path. + + The Bethesda launcher validates that Installed Path looks like a proper + Windows Steam path (C:\\Program Files...). A raw Z:\\ or D:\\ path passes + the existence check on the user's own machine but fails for other users + whose Wine path translation differs. By symlinking the real directory into + drive_c/Program Files (x86)/Steam/steamapps/common/, we write a canonical + C:\\ path to the registry that satisfies the launcher, while NVSE follows + the symlink to reach the actual executable. + """ + try: + real_path = Path(real_game_path) + game_dir_name = real_path.name + + symlink_parent = pfx_path / "drive_c" / "Program Files (x86)" / "Steam" / "steamapps" / "common" + symlink_parent.mkdir(parents=True, exist_ok=True) + + symlink_path = symlink_parent / game_dir_name + + if symlink_path.is_symlink(): + symlink_path.unlink() + elif symlink_path.exists(): + logger.warning(f"Real directory already exists at symlink target {symlink_path}, skipping") + return False + + symlink_path.symlink_to(real_path) + logger.info(f"Created game symlink: {symlink_path} -> {real_path}") + return True + except Exception as e: + logger.warning(f"Failed to create canonical game symlink: {e}") + return False + def _inject_game_registry_entries(self, modlist_compatdata_path: str, special_game_type: str): - """Detect and inject FNV/Enderal game paths and apply universal dotnet4.x compatibility fixes""" + """Detect and inject FNV/FO3/Enderal game paths into the modlist prefix registry.""" system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg") if not os.path.exists(system_reg_path): logger.warning("system.reg not found, skipping game path injection") @@ -236,41 +314,74 @@ class RegistryOperationsMixin: logger.info("Detecting game registry entries...") - # Universal dotnet4.x registry fixes applied in modlist_handler.py after .reg downloads - - # Game configurations games_config = { "22380": { # Fallout New Vegas AppID "name": "Fallout New Vegas", "common_names": ["Fallout New Vegas", "FalloutNV"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]", - "path_key": "Installed Path" + "path_key": "Installed Path", + }, + "22300": { # Fallout 3 AppID + "name": "Fallout 3", + "common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"], + "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]", + "path_key": "Installed Path", + }, + "22370": { # Fallout 3 GOTY AppID alias + "name": "Fallout 3", + "common_names": ["Fallout 3 GOTY", "Fallout 3"], + "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]", + "path_key": "Installed Path", }, "976620": { # Enderal Special Edition AppID "name": "Enderal", "common_names": ["Enderal: Forgotten Stories (Special Edition)", "Enderal Special Edition", "Enderal"], - "registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]", - "path_key": "installed path" - } + "registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]", + "path_key": "installed path", + }, } - - # Detect and inject each game + + pfx_path = Path(modlist_compatdata_path) / "pfx" + for app_id, config in games_config.items(): game_path = self._find_steam_game(app_id, config["common_names"]) - if game_path: - logger.info(f"Detected {config['name']} at: {game_path}") + if not game_path: + logger.debug(f"{config['name']} not found in Steam libraries") + continue + + logger.info(f"Detected {config['name']} at: {game_path}") + + # Create a symlink inside the prefix at the canonical Windows Steam path so the + # Bethesda launcher sees a proper C:\ path while NVSE can still resolve the exe. + symlink_ok = self._create_canonical_game_symlink(pfx_path, game_path) + + if symlink_ok: + game_dir_name = Path(game_path).name + canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}" + wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\" + success = self._reg_set_value( + system_reg_path, + config["registry_section"], + f'"{config["path_key"]}"', + f'"{wine_val}"', + ) + if success: + logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}") + else: + logger.warning(f"Failed to set canonical registry path for {config['name']}") + else: + # Symlink failed — fall back to writing the real Z:/D: path + logger.warning(f"Symlink failed for {config['name']}, writing real path to registry") success = self._update_registry_path( system_reg_path, - config["registry_section"], + config["registry_section"], config["path_key"], game_path ) if success: - logger.info(f"Updated registry entry for {config['name']}") + logger.info(f"Updated registry entry for {config['name']} (real path fallback)") else: logger.warning(f"Failed to update registry entry for {config['name']}") - else: - logger.debug(f"{config['name']} not found in Steam libraries") - + logger.info("Game registry injection completed") diff --git a/jackify/backend/services/automated_prefix_service.py b/jackify/backend/services/automated_prefix_service.py index cbed67b..2d1ef2f 100644 --- a/jackify/backend/services/automated_prefix_service.py +++ b/jackify/backend/services/automated_prefix_service.py @@ -16,13 +16,6 @@ import vdf logger = logging.getLogger(__name__) -def debug_print(message): - """Log debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - logger.debug(message) - from .automated_prefix_shortcuts import ShortcutOperationsMixin from .automated_prefix_proton import ProtonOperationsMixin from .automated_prefix_creation import PrefixCreationMixin @@ -170,7 +163,6 @@ exit""" logger.error(f"Error getting config path: {e}") return None - def kill_running_processes(self) -> bool: """ Kill any running processes that might interfere with prefix creation. diff --git a/jackify/backend/services/automated_prefix_shortcuts.py b/jackify/backend/services/automated_prefix_shortcuts.py index 737b795..c3bdac6 100644 --- a/jackify/backend/services/automated_prefix_shortcuts.py +++ b/jackify/backend/services/automated_prefix_shortcuts.py @@ -11,15 +11,6 @@ from .automated_prefix_shortcuts_cleanup import AutomatedPrefixShortcutsCleanupM logger = logging.getLogger(__name__) - -def debug_print(message): - """Log debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - logger.debug(message) - - class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin): """Mixin providing shortcut operation methods for AutomatedPrefixService.""" @@ -148,10 +139,10 @@ class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin): True if successful, False otherwise """ try: - debug_print(f"[DEBUG] create_shortcut_directly called for '{shortcut_name}' - this is the fallback method") + logger.debug(f"[DEBUG] create_shortcut_directly called for '{shortcut_name}' - this is the fallback method") shortcuts_path = self._get_shortcuts_path() if not shortcuts_path: - debug_print("[DEBUG] No shortcuts path found") + logger.debug("[DEBUG] No shortcuts path found") return False # Read current shortcuts @@ -207,191 +198,6 @@ class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin): logger.error(f"Error creating shortcut directly: {e}") return False - def create_shortcut_directly_with_proton(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool: - """ - Create a Steam shortcut with temporary batch file for invisible prefix creation. - This uses the CRC32-based AppID calculation for predictable results. - - Args: - shortcut_name: Name for the shortcut - exe_path: Path to the final ModOrganizer.exe executable - modlist_install_dir: Directory where the modlist is installed - - Returns: - True if successful, False otherwise - """ - try: - debug_print(f"[DEBUG] create_shortcut_directly_with_proton called for '{shortcut_name}' - using temporary batch file approach") - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - debug_print("[DEBUG] No shortcuts path found") - return False - - # Calculate predictable AppID using CRC32 (based on FINAL exe_path) - from zlib import crc32 - combined_string = exe_path + shortcut_name - crc = crc32(combined_string.encode('utf-8')) - appid = -(crc & 0x7FFFFFFF) # Make it negative and within 32-bit range (like other shortcuts) - - debug_print(f"[DEBUG] Calculated AppID: {appid} from '{combined_string}'") - - # Create temporary batch file for invisible prefix creation - batch_content = """@echo off -echo Creating Proton prefix... -timeout /t 3 /nobreak >nul -echo Prefix creation complete. -""" - from jackify.shared.paths import get_jackify_data_dir - batch_path = get_jackify_data_dir() / "temp_prefix_creation.bat" - batch_path.parent.mkdir(parents=True, exist_ok=True) - - with open(batch_path, 'w') as f: - f.write(batch_content) - - debug_print(f"[DEBUG] Created temporary batch file: {batch_path}") - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Check if shortcut already exists (idempotent) - found = False - new_shortcuts_list = [] - shortcuts_list = list(shortcuts.values()) - - for shortcut in shortcuts_list: - if shortcut.get('AppName') == shortcut_name: - debug_print(f"[DEBUG] Updating existing shortcut for '{shortcut_name}'") - # Update existing shortcut with temporary batch file - shortcut.update({ - 'Exe': f'"{batch_path}"', # Point to temporary batch file - 'StartDir': f'"{batch_path.parent}"', # Batch file directory - 'appid': appid, - 'LaunchOptions': '', # Empty like working shortcuts - 'tags': {}, # Empty tags like working shortcuts - 'CompatTool': 'proton_experimental' # Set Proton version directly in shortcut - }) - new_shortcuts_list.append(shortcut) - found = True - else: - new_shortcuts_list.append(shortcut) - - if not found: - debug_print(f"[DEBUG] Creating new shortcut for '{shortcut_name}'") - # Create new shortcut entry pointing to temporary batch file - new_shortcut = { - 'AppName': shortcut_name, - 'Exe': f'"{batch_path}"', # Point to temporary batch file - 'StartDir': f'"{batch_path.parent}"', # Batch file directory - 'appid': appid, - 'icon': '', - 'ShortcutPath': '', - 'LaunchOptions': '', # Empty like working shortcuts - 'IsHidden': 0, - 'AllowDesktopConfig': 1, - 'AllowOverlay': 1, - 'OpenVR': 0, - 'Devkit': 0, - 'DevkitGameID': '', - 'LastPlayTime': 0, - 'FlatpakAppID': '', - 'tags': {}, # Empty tags like working shortcuts - 'sortas': '', - 'CompatTool': 'proton_experimental' # Set Proton version directly in shortcut - } - new_shortcuts_list.append(new_shortcut) - - # Rebuild shortcuts dict with new order - shortcuts_data['shortcuts'] = {str(i): s for i, s in enumerate(new_shortcuts_list)} - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - logger.info(f"Created/updated shortcut with temporary batch file: {shortcut_name} with AppID {appid}") - debug_print(f"[DEBUG] Shortcut created/updated with temporary batch file, AppID {appid}") - - # Set Proton version in config.vdf BEFORE creating shortcut - if self.set_proton_version_for_shortcut(appid, 'proton_experimental'): - logger.info(f"Set Proton Experimental for shortcut {shortcut_name}") - return True - else: - logger.warning(f"Failed to set Proton version for shortcut {shortcut_name}") - return False - - except Exception as e: - logger.error(f"Error creating shortcut with temporary batch file: {e}") - return False - - def replace_shortcut_with_final_exe(self, shortcut_name: str, final_exe_path: str, modlist_install_dir: str) -> bool: - """ - Replace the temporary batch file shortcut with the final ModOrganizer.exe. - This should be called after the prefix has been created. - - Args: - shortcut_name: Name of the shortcut to update - final_exe_path: Path to the final ModOrganizer.exe executable - modlist_install_dir: Directory where the modlist is installed - - Returns: - True if successful, False otherwise - """ - try: - debug_print(f"[DEBUG] replace_shortcut_with_final_exe called for '{shortcut_name}'") - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - debug_print("[DEBUG] No shortcuts path found") - return False - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Find and update the shortcut - found = False - new_shortcuts_list = [] - shortcuts_list = list(shortcuts.values()) - - for shortcut in shortcuts_list: - if shortcut.get('AppName') == shortcut_name: - debug_print(f"[DEBUG] Replacing temporary batch file with final exe for '{shortcut_name}'") - # Update shortcut to point to final ModOrganizer.exe - shortcut.update({ - 'Exe': f'"{final_exe_path}"', # Point to final ModOrganizer.exe - 'StartDir': modlist_install_dir, # ModOrganizer directory - 'LaunchOptions': '', # Empty like working shortcuts - 'tags': {}, # Empty tags like working shortcuts - # Keep existing appid and CompatibilityTool - }) - new_shortcuts_list.append(shortcut) - found = True - else: - new_shortcuts_list.append(shortcut) - - if not found: - logger.error(f"Shortcut '{shortcut_name}' not found for replacement") - return False - - # Rebuild shortcuts dict with new order - shortcuts_data['shortcuts'] = {str(i): s for i, s in enumerate(new_shortcuts_list)} - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - logger.info(f"Replaced shortcut with final exe: {shortcut_name}") - debug_print(f"[DEBUG] Shortcut replaced with final ModOrganizer.exe") - - return True - - except Exception as e: - logger.error(f"Error replacing shortcut with final exe: {e}") - return False - def modify_shortcut_to_final_exe(self, shortcut_name: str, final_exe_path: str, final_start_dir: str) -> bool: """ diff --git a/jackify/backend/services/automated_prefix_workflow.py b/jackify/backend/services/automated_prefix_workflow.py index 10dac0f..b1c874b 100644 --- a/jackify/backend/services/automated_prefix_workflow.py +++ b/jackify/backend/services/automated_prefix_workflow.py @@ -3,21 +3,10 @@ from pathlib import Path from typing import Optional, Union, List, Dict, Tuple import logging import os -import time -import subprocess import vdf logger = logging.getLogger(__name__) - -def debug_print(message): - """Log debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - logger.debug(message) - - class WorkflowMixin: """Mixin providing workflow methods for AutomatedPrefixService.""" @@ -110,166 +99,6 @@ class WorkflowMixin: return message - def run_complete_workflow(self, shortcut_name: str, modlist_install_dir: str, - final_exe_path: str, progress_callback=None) -> Tuple[bool, Optional[Path], Optional[int]]: - """ - Run the simple automated prefix creation workflow. - - Args: - shortcut_name: Name for the Steam shortcut - modlist_install_dir: Directory where the modlist is installed - final_exe_path: Path to ModOrganizer.exe - - Returns: - Tuple of (success, prefix_path, appid) - """ - debug_print(f"[DEBUG] run_complete_workflow called with shortcut_name={shortcut_name}, modlist_install_dir={modlist_install_dir}, final_exe_path={final_exe_path}") - logger.info("Starting simple automated prefix creation workflow") - - # Initialize shared timing to continue from jackify-engine - from jackify.shared.timing import initialize_from_console_output - # TODO: Pass console output if available to continue timeline - initialize_from_console_output() - - # Show immediate feedback to user - if progress_callback: - progress_callback("Starting automated Steam setup...") - - try: - # Step 1: Create shortcut directly (NO STL needed!) - logger.info("Step 1: Creating shortcut directly to ModOrganizer.exe") - if progress_callback: - progress_callback("Creating Steam shortcut...") - if not self.create_shortcut_directly_with_proton(shortcut_name, final_exe_path, modlist_install_dir): - logger.error("Failed to create shortcut directly") - return False, None, None, None - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully") - logger.info("Step 1 completed: Shortcut created directly") - - # Step 2: Calculate the predictable AppID and rungameid - logger.info("Step 2: Calculating predictable AppID") - if progress_callback: - progress_callback("Calculating AppID...") - - # Calculate AppID using the same method as create_shortcut_directly_with_proton - from zlib import crc32 - combined_string = final_exe_path + shortcut_name - crc = crc32(combined_string.encode('utf-8')) - initial_appid = -(crc & 0x7FFFFFFF) # Make it negative and within 32-bit range - - # Calculate rungameid for launching - rungameid = (initial_appid << 32) | 0x02000000 - - # Convert AppID to positive prefix ID - expected_prefix_id = str(abs(initial_appid)) - - if progress_callback: - progress_callback("AppID calculated") - logger.info(f"Step 2 completed: AppID = {initial_appid}, rungameid = {rungameid}, expected_prefix_id = {expected_prefix_id}") - - # Step 3: Restart Steam - logger.info("Step 3: Restarting Steam") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Restarting Steam...") - if not self.restart_steam(): - logger.error("Failed to restart Steam") - return False, None, None, None - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully") - logger.info("Step 3 completed: Steam restarted") - - # Step 4: Launch temporary batch file to create prefix invisibly - logger.info("Step 4: Launching temporary batch file to create prefix") - debug_print(f"[DEBUG] About to launch temporary batch file with rungameid={rungameid}") - - # Launch using rungameid (this will run the batch file invisibly) - try: - result = subprocess.run(['steam', f'steam://rungameid/{rungameid}'], - capture_output=True, text=True, timeout=5) - debug_print(f"[DEBUG] Launch result: return_code={result.returncode}") - if result.returncode != 0: - logger.error(f"Failed to launch temporary batch file: {result.stderr}") - return False, None, None, None - except subprocess.TimeoutExpired: - debug_print("[DEBUG] Launch timed out (expected)") - except Exception as e: - logger.error(f"Error launching temporary batch file: {e}") - return False, None, None, None - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Temporary batch file launched") - logger.info("Step 4 completed: Temporary batch file launched") - - # Step 5: Wait for temporary batch file to complete (invisible) - logger.info("Step 5: Waiting for temporary batch file to complete") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix (please wait)...") - - # Wait for batch file to complete (3 seconds + buffer) - time.sleep(5) - logger.info("Step 5 completed: Temporary batch file completed") - - # Step 6: Verify prefix was created - logger.info("Step 6: Verifying prefix creation") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Verifying prefix creation...") - - compatdata_path = Path.home() / ".local/share/Steam/steamapps/compatdata" / expected_prefix_id - if not compatdata_path.exists(): - logger.error(f"Prefix not found at {compatdata_path}") - return False, None, None, None - - logger.info(f"Step 6 completed: Prefix verified at {compatdata_path}") - - # Step 7: Replace temporary batch file with final ModOrganizer.exe - logger.info("Step 7: Replacing temporary batch file with final ModOrganizer.exe") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Updating shortcut...") - - if not self.replace_shortcut_with_final_exe(shortcut_name, final_exe_path, modlist_install_dir): - logger.error("Failed to replace shortcut with final exe") - return False, None, None, None - - logger.info("Step 7 completed: Shortcut updated with final ModOrganizer.exe") - - # Step 8: Detect actual AppID using protontricks -l - logger.info("Step 8: Detecting actual AppID") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Detecting actual AppID...") - actual_appid = self.detect_actual_prefix_appid(initial_appid, shortcut_name) - if actual_appid is None: - logger.error("Failed to detect actual AppID") - return False, None, None, None - logger.info(f"Step 8 completed: Actual AppID = {actual_appid}") - - # Step 9: Verify prefix was created successfully - logger.info("Step 9: Verifying prefix creation") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Verifying prefix creation...") - prefix_path = self._get_compatdata_path_for_appid(actual_appid) - if not prefix_path or not prefix_path.exists(): - logger.error(f"Prefix path not found: {prefix_path}") - return False, None, None, None - - if not self.verify_prefix_creation(prefix_path): - logger.error("Prefix verification failed") - return False, None, None, None - logger.info(f"Step 9 completed: Prefix verified at {prefix_path}") - - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Steam Configuration complete!") - # Show Proton override notification if applicable - self._show_proton_override_notification(progress_callback) - - logger.info(" Simple automated prefix creation workflow completed successfully") - return True, prefix_path, actual_appid - - except Exception as e: - logger.error(f"Error in automated prefix creation workflow: {e}") - import traceback - logger.error(f"Full traceback: {traceback.format_exc()}") - return False, None, None, None - def run_working_workflow(self, shortcut_name: str, modlist_install_dir: str, final_exe_path: str, progress_callback=None, steamdeck: Optional[bool] = None, download_dir=None, auto_restart: bool = True) -> Tuple[bool, Optional[Path], Optional[int], Optional[str]]: @@ -323,9 +152,9 @@ class WorkflowMixin: modlist_handler = ModlistHandler() special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir) - # No launch options needed - both FNV and Enderal use registry injection + # No launch options needed - FNV, FO3 and Enderal use registry injection custom_launch_options = None - if special_game_type in ["fnv", "enderal"]: + if special_game_type in ["fnv", "fo3", "enderal"]: logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist") else: logger.debug("Standard modlist - no special game handling needed") @@ -372,7 +201,8 @@ class WorkflowMixin: ) if not success: logger.error("Failed to create shortcut with native Steam service") - return False, None, None, None + from jackify.shared.errors import shortcut_write_failed + raise shortcut_write_failed("create_shortcut_with_native_service returned failure") logger.info(f"Step 1 completed: Shortcut created with native service, AppID: {appid}") if progress_callback: @@ -398,7 +228,8 @@ class WorkflowMixin: logger.info("Step 2: restart_steam() returned %s", restart_ok) if not restart_ok: logger.error("Failed to start Steam") - return False, None, None, None + from jackify.shared.errors import steam_restart_failed + raise steam_restart_failed("Steam did not come back within the expected time") logger.info("Step 2 completed: Steam started") if progress_callback: @@ -415,7 +246,8 @@ class WorkflowMixin: if not self.create_prefix_with_proton_wrapper(appid): logger.error("Failed to create Proton prefix") - return False, None, None, None + from jackify.shared.errors import prefix_creation_failed + raise prefix_creation_failed("create_prefix_with_proton_wrapper returned failure") logger.info("Step 3 completed: Proton prefix created") if progress_callback: @@ -437,7 +269,7 @@ class WorkflowMixin: # Get prefix path (needed for logging regardless of game type) prefix_path = self.get_prefix_path(appid) - if special_game_type in ["fnv", "enderal"]: + if special_game_type in ["fnv", "fo3", "enderal"]: logger.info(f"Step 5: Injecting {special_game_type.upper()} game registry entries") if progress_callback: progress_callback(f"{self._get_progress_timestamp()} Injecting {special_game_type.upper()} game registry entries...") @@ -448,8 +280,6 @@ class WorkflowMixin: logger.warning("Could not find prefix path for registry injection") else: logger.info("Step 5: Skipping registry injection for standard modlist") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} No special game registry injection needed") # Step 5.5: Pre-create game-specific directories for all modlists logger.info(f"Step 5.5: Creating game-specific user directories") @@ -477,10 +307,13 @@ class WorkflowMixin: return True, prefix_path, appid, last_timestamp except Exception as e: - logger.error(f"Error in working workflow: {e}") + logger.error(f"Error in working workflow: {e}", exc_info=True) if progress_callback: progress_callback(f"Error: {str(e)}") - return False, None, None, None + from jackify.shared.errors import JackifyError, prefix_creation_failed + if isinstance(e, JackifyError): + raise + raise prefix_creation_failed(str(e)) from e def continue_workflow_after_conflict_resolution(self, shortcut_name: str, modlist_install_dir: str, final_exe_path: str, appid: int, progress_callback=None) -> Tuple[bool, Optional[Path], Optional[int]]: @@ -520,7 +353,8 @@ class WorkflowMixin: if not self.create_prefix_with_proton_wrapper(appid): logger.error("Failed to create Proton prefix") - return False, None, None, None + from jackify.shared.errors import prefix_creation_failed + raise prefix_creation_failed("create_prefix_with_proton_wrapper returned failure") logger.info("Step 3 completed: Proton prefix created") if progress_callback: diff --git a/jackify/backend/services/mo2_setup_service.py b/jackify/backend/services/mo2_setup_service.py new file mode 100644 index 0000000..b2a24bc --- /dev/null +++ b/jackify/backend/services/mo2_setup_service.py @@ -0,0 +1,166 @@ +""" +MO2 Setup Service + +Downloads and configures a standalone Mod Organizer 2 instance: + - Fetches latest release from GitHub + - Extracts with 7z + - Creates a Steam shortcut and Proton prefix via AutomatedPrefixService +""" + +import re +import shutil +import logging +import subprocess +from pathlib import Path +from typing import Callable, Optional, Tuple + +import requests + +logger = logging.getLogger(__name__) + + +def _is_dangerous_path(path: Path) -> bool: + home = Path.home().resolve() + dangerous = [Path('/'), Path('/home'), Path('/root'), home] + return any(path.resolve() == d for d in dangerous) + + +class MO2SetupService: + """Download, extract, and configure a standalone MO2 instance.""" + + GITHUB_API = "https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest" + ASSET_PATTERN = re.compile(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$") + + def setup_mo2( + self, + install_dir: Path, + shortcut_name: str = "Mod Organizer 2", + progress_callback: Optional[Callable[[str], None]] = None, + should_cancel: Optional[Callable[[], bool]] = None, + ) -> Tuple[bool, Optional[int], Optional[str]]: + """ + Download, extract, and configure MO2. + + Returns (success, app_id, error_message). + """ + + def _progress(msg: str): + logger.info(msg) + if progress_callback: + progress_callback(msg) + + def _cancel_requested() -> bool: + try: + return bool(should_cancel and should_cancel()) + except Exception: + return False + + if not shutil.which('7z'): + return False, None, "7z not found. Install p7zip-full (or equivalent) first." + + if _is_dangerous_path(install_dir): + return False, None, f"Refusing to install to dangerous path: {install_dir}" + + # Create directory + try: + install_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + return False, None, f"Could not create directory: {e}" + + # Fetch release info + _progress("Fetching latest MO2 release info...") + if _cancel_requested(): + return False, None, "MO2 setup cancelled." + try: + resp = requests.get(self.GITHUB_API, timeout=15, verify=True) + resp.raise_for_status() + release = resp.json() + except Exception as e: + return False, None, f"Failed to fetch MO2 release info: {e}" + + # Find asset + asset = None + for a in release.get('assets', []): + if self.ASSET_PATTERN.match(a['name']): + asset = a + break + if not asset: + return False, None, "Could not find main MO2 .7z asset in latest release." + + # Download + archive_path = install_dir / asset['name'] + _progress(f"Downloading {asset['name']}...") + if _cancel_requested(): + return False, None, "MO2 setup cancelled." + try: + with requests.get(asset['browser_download_url'], stream=True, timeout=120, verify=True) as r: + r.raise_for_status() + with open(archive_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + if _cancel_requested(): + return False, None, "MO2 setup cancelled." + f.write(chunk) + except Exception as e: + return False, None, f"Failed to download MO2 archive: {e}" + + # Extract + _progress(f"Extracting to {install_dir}...") + if _cancel_requested(): + return False, None, "MO2 setup cancelled." + try: + result = subprocess.run( + ['7z', 'x', str(archive_path), f'-o{install_dir}'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=1200, + ) + if result.returncode != 0: + err = result.stderr.decode(errors='ignore') + return False, None, f"Extraction failed: {err}" + except Exception as e: + return False, None, f"Extraction failed: {e}" + + # Validate + mo2_exe = install_dir / "ModOrganizer.exe" + if not mo2_exe.exists(): + # MO2 release archives usually extract into a single top-level folder. + # Limit search depth to direct children to avoid expensive recursive scans. + mo2_exe = None + for child in install_dir.iterdir(): + candidate = child / "ModOrganizer.exe" + if candidate.exists(): + mo2_exe = candidate + break + if not mo2_exe: + return False, None, "ModOrganizer.exe not found after extraction." + + # Cleanup archive + try: + archive_path.unlink() + except Exception: + pass + + _progress(f"MO2 installed at: {mo2_exe.parent}") + + # Set up Steam shortcut and Proton prefix + _progress("Creating Steam shortcut and Proton prefix...") + if _cancel_requested(): + return False, None, "MO2 setup cancelled." + try: + from .automated_prefix_service import AutomatedPrefixService + svc = AutomatedPrefixService() + success, prefix_path, app_id, _last_ts = svc.run_working_workflow( + shortcut_name=shortcut_name, + modlist_install_dir=str(install_dir), + final_exe_path=str(mo2_exe), + progress_callback=_progress, + ) + except Exception as e: + logger.error(f"AutomatedPrefixService failed: {e}") + return False, None, f"Prefix setup failed: {e}" + + if not success: + return False, None, "Failed to create Steam shortcut or Proton prefix." + + _progress("MO2 setup complete.") + return True, app_id, None diff --git a/jackify/backend/services/modlist_service.py b/jackify/backend/services/modlist_service.py index 5237ce6..f15cef8 100644 --- a/jackify/backend/services/modlist_service.py +++ b/jackify/backend/services/modlist_service.py @@ -246,6 +246,7 @@ class ModlistService(ModlistServiceInstallationMixin): 'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam 'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag 'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None, + 'modlist_source': getattr(context, 'modlist_source', None), } debug_callback(f"Configuration context built: {config_context}") @@ -479,4 +480,4 @@ class ModlistService(ModlistServiceInstallationMixin): logger.error("Game type is required") return False - return True \ No newline at end of file + return True diff --git a/jackify/backend/services/nexus_premium_service.py b/jackify/backend/services/nexus_premium_service.py new file mode 100644 index 0000000..4e548c5 --- /dev/null +++ b/jackify/backend/services/nexus_premium_service.py @@ -0,0 +1,98 @@ +"""Nexus Premium status detection service.""" +import time +import logging +from typing import Tuple, Optional + +import requests + +logger = logging.getLogger(__name__) + +NEXUS_VALIDATE_URL = "https://api.nexusmods.com/v1/users/validate.json" +NEXUS_OAUTH_USERINFO_URL = "https://users.nexusmods.com/oauth/userinfo" +_CACHE_TTL_SECONDS = 3600 + + +class NexusPremiumService: + """Check and cache Nexus Premium status for the authenticated user.""" + + def check_premium_status( + self, auth_token: str, is_oauth: bool = False + ) -> Tuple[bool, Optional[str]]: + """ + Query Nexus API for premium status. + + Args: + auth_token: Nexus API key or OAuth access token. + is_oauth: True when auth_token is an OAuth Bearer token. + + Returns: + (is_premium, username) — both None/False on failure. + """ + cached = self._read_cache(auth_token, is_oauth=is_oauth) + if cached is not None: + return cached + + result = self._fetch(auth_token, is_oauth=is_oauth) + if result[1] is not None: + self._write_cache(auth_token, result, is_oauth=is_oauth) + return result + + def _fetch(self, auth_token: str, is_oauth: bool = False) -> Tuple[bool, Optional[str]]: + try: + if is_oauth: + # OAuth path: userinfo endpoint returns membership_roles array. + # The validate endpoint is for API keys only. + resp = requests.get( + NEXUS_OAUTH_USERINFO_URL, + headers={"Authorization": f"Bearer {auth_token}", "Accept": "application/json"}, + timeout=8, + ) + resp.raise_for_status() + data = resp.json() + roles = data.get("membership_roles") or [] + is_premium = "premium" in roles + username = data.get("name") or data.get("sub") + else: + resp = requests.get( + NEXUS_VALIDATE_URL, + headers={"apikey": auth_token, "Accept": "application/json"}, + timeout=8, + ) + resp.raise_for_status() + data = resp.json() + is_premium = bool(data.get("is_premium") or data.get("is_supporter")) + username = data.get("name") + logger.debug(f"Nexus user: {username}, premium={is_premium}, roles={data.get('membership_roles')}") + return is_premium, username + except Exception as e: + logger.debug(f"Nexus premium check failed: {e}") + return False, None + + def _cache_key(self, token: str, is_oauth: bool = False) -> str: + suffix = "oauth" if is_oauth else "apikey" + return f"nexus_premium_cache_{token[:8]}_{suffix}" + + def _read_cache(self, token: str, is_oauth: bool = False) -> Optional[Tuple[bool, Optional[str]]]: + try: + from jackify.backend.handlers.config_handler import ConfigHandler + cfg = ConfigHandler() + entry = cfg.get(self._cache_key(token, is_oauth)) + if not entry: + return None + if time.time() - entry.get("ts", 0) > _CACHE_TTL_SECONDS: + return None + return entry["is_premium"], entry.get("username") + except Exception: + return None + + def _write_cache(self, token: str, result: Tuple[bool, Optional[str]], is_oauth: bool = False) -> None: + try: + from jackify.backend.handlers.config_handler import ConfigHandler + cfg = ConfigHandler() + cfg.set(self._cache_key(token, is_oauth), { + "is_premium": result[0], + "username": result[1], + "ts": time.time(), + }) + except Exception: + pass diff --git a/jackify/backend/services/steam_restart_service.py b/jackify/backend/services/steam_restart_service.py index d979bca..b90ed6d 100644 --- a/jackify/backend/services/steam_restart_service.py +++ b/jackify/backend/services/steam_restart_service.py @@ -200,6 +200,52 @@ def is_flatpak_steam() -> bool: return False +def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool: + """Grant Flatpak Steam filesystem access to the parent of the given path. + + Safe to call on non-Flatpak systems — returns True immediately. + Skips if the path is already covered by an existing override. + Returns True if access was already present or successfully granted, False on error. + """ + from pathlib import Path as _Path + if not is_flatpak_steam(): + return True + flatpak_cmd = _get_flatpak_command() + if not flatpak_cmd: + logger.warning("Flatpak Steam detected but flatpak command not found — cannot grant filesystem access") + return False + grant_path = str(_Path(path).parent) + env = _get_clean_subprocess_env() + try: + # Check existing overrides to avoid redundant changes + result = subprocess.run( + [flatpak_cmd, "override", "--user", "--show", "com.valvesoftware.Steam"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, timeout=10, env=env, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + if "filesystems" in line.lower() and grant_path in line: + logger.debug(f"Flatpak Steam already has access to {grant_path}") + return True + except Exception as e: + logger.debug(f"Could not check existing Flatpak overrides: {e}") + try: + result = subprocess.run( + [flatpak_cmd, "override", "--user", f"--filesystem={grant_path}", "com.valvesoftware.Steam"], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + text=True, timeout=15, env=env, + ) + if result.returncode == 0: + logger.info(f"Granted Flatpak Steam filesystem access to {grant_path}") + return True + logger.warning(f"flatpak override failed (exit {result.returncode}): {result.stderr.strip()}") + return False + except Exception as e: + logger.warning(f"Failed to grant Flatpak Steam filesystem access: {e}") + return False + + def _get_steam_executable(env=None): """Resolve steam executable path for native Steam. Prefer PATH, then common locations.""" env = env or os.environ diff --git a/jackify/backend/services/update_service.py b/jackify/backend/services/update_service.py index a5459d0..09bc560 100644 --- a/jackify/backend/services/update_service.py +++ b/jackify/backend/services/update_service.py @@ -31,6 +31,7 @@ class UpdateInfo: release_date: str changelog: str download_url: str + source: str = "github" file_size: Optional[int] = None is_critical: bool = False is_delta_update: bool = False @@ -100,9 +101,17 @@ class UpdateService: break if download_url: + # Prefer Nexus CDN for Premium users when release embeds nexus_file_id + release_body = release_data.get('body', '') + nexus_url = self._try_nexus_download_url(release_body) + update_source = "github" + if nexus_url: + download_url = nexus_url + update_source = "nexus" + # Determine if this is a delta update is_delta = '.delta' in download_url or 'delta' in download_url.lower() - + # Safety checks to prevent segfault try: # Sanitize string fields @@ -111,9 +120,9 @@ class UpdateService: safe_date = str(release_data.get('published_at', '')) safe_changelog = str(release_data.get('body', ''))[:1000] # Limit size safe_url = str(download_url) - + logger.debug(f"Creating UpdateInfo for version {safe_version}") - + update_info = UpdateInfo( version=safe_version, tag_name=safe_tag, @@ -121,7 +130,8 @@ class UpdateService: changelog=safe_changelog, download_url=safe_url, file_size=file_size, - is_delta_update=is_delta + is_delta_update=is_delta, + source=update_source, ) logger.debug(f"UpdateInfo created successfully") @@ -142,6 +152,56 @@ class UpdateService: logger.error(f"Unexpected error checking for updates: {e}") return None + def _try_nexus_download_url(self, release_body: str) -> Optional[str]: + """ + If the user is Nexus Premium and the release body embeds nexus_file_id, + return a Nexus CDN download URL. Returns None on any failure. + + Release body format expected: + nexus_mod_id: 12345 + nexus_file_id: 67890 + """ + import re + try: + mod_match = re.search(r'nexus_mod_id:\s*(\d+)', release_body, re.IGNORECASE) + file_match = re.search(r'nexus_file_id:\s*(\d+)', release_body, re.IGNORECASE) + if not file_match: + return None + nexus_file_id = int(file_match.group(1)) + nexus_mod_id = int(mod_match.group(1)) if mod_match else None + + from jackify.backend.services.nexus_auth_service import NexusAuthService + auth_service = NexusAuthService() + token = auth_service.get_auth_token() + if not token: + return None + + from jackify.backend.services.nexus_premium_service import NexusPremiumService + is_premium, _ = NexusPremiumService().check_premium_status(token) + if not is_premium: + logger.debug("Nexus download skipped: user is not Premium") + return None + + if nexus_mod_id is None: + return None + + api_url = f"https://api.nexusmods.com/v1/games/site/mods/{nexus_mod_id}/files/{nexus_file_id}/download_link.json" + resp = requests.get( + api_url, + headers={"apikey": token, "Accept": "application/json"}, + timeout=8, + ) + resp.raise_for_status() + links = resp.json() + if isinstance(links, list) and links: + cdn_url = links[0].get("URI") + if cdn_url: + logger.debug(f"Using Nexus CDN URL for update") + return cdn_url + except Exception as e: + logger.debug(f"Nexus download URL lookup failed: {e}") + return None + def _is_newer_version(self, version: str) -> bool: """ Compare versions to determine if update is newer. diff --git a/jackify/backend/services/vnv_integration_helper.py b/jackify/backend/services/vnv_integration_helper.py index 9c54c5b..d23c0cd 100644 --- a/jackify/backend/services/vnv_integration_helper.py +++ b/jackify/backend/services/vnv_integration_helper.py @@ -114,7 +114,7 @@ def should_offer_vnv_automation(modlist_name: str, modlist_install_location: Opt def run_vnv_automation_if_applicable( modlist_name: str, modlist_install_location: Path, - game_root: Path, + game_root: Optional[Path], ttw_installer_path: Optional[Path] = None, progress_callback: Optional[Callable[[str], None]] = None, manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None, @@ -144,10 +144,27 @@ def run_vnv_automation_if_applicable( logger.info(f"VNV detected: {modlist_name}") + # Resolve game root for Fallout New Vegas if caller didn't provide one. + # CLI flows may pass None and rely on auto-detection. + resolved_game_root = game_root + if resolved_game_root is None: + try: + from jackify.backend.handlers.path_handler import PathHandler + game_paths = PathHandler().find_vanilla_game_paths() + resolved_game_root = game_paths.get('Fallout New Vegas') + except Exception as detect_err: + logger.debug(f"VNV game root auto-detection failed: {detect_err}") + + if resolved_game_root is None: + logger.warning("VNV detected but Fallout New Vegas game root could not be resolved") + if progress_callback: + progress_callback("VNV automation skipped: Fallout New Vegas path not found") + return False, None + # Initialize service vnv_service = VNVPostInstallService( modlist_install_location=modlist_install_location, - game_root=game_root, + game_root=resolved_game_root, ttw_installer_path=ttw_installer_path ) diff --git a/jackify/backend/services/wabbajack_installer_service.py b/jackify/backend/services/wabbajack_installer_service.py index 35a9a19..2e02357 100644 --- a/jackify/backend/services/wabbajack_installer_service.py +++ b/jackify/backend/services/wabbajack_installer_service.py @@ -16,7 +16,8 @@ from ..handlers.config_handler import ConfigHandler from ..handlers.wine_utils import WineUtils from .native_steam_service import NativeSteamService from .steam_restart_service import ( - start_steam, is_flatpak_steam, is_steam_deck, _get_clean_subprocess_env, robust_steam_restart + start_steam, is_flatpak_steam, is_steam_deck, _get_clean_subprocess_env, robust_steam_restart, + ensure_flatpak_steam_filesystem_access, ) from .automated_prefix_service import AutomatedPrefixService @@ -48,8 +49,14 @@ class WabbajackInstallerService: if not steam_name.startswith('proton'): steam_name = f"proton_{steam_name}" return path, steam_name - path = self.handler.find_proton_experimental() - return path, "proton_experimental" if path else None + best = WineUtils.select_best_proton() + if best: + return Path(best['path']), best['steam_compat_name'] + valve = WineUtils.select_best_valve_proton() + if valve: + return Path(valve['path']), valve.get('steam_compat_name', 'proton_experimental') + logger.error("No Proton version found") + return None, None def install_wabbajack( self, @@ -93,6 +100,9 @@ class WabbajackInstallerService: _is_steam_deck = is_steam_deck() _is_flatpak = is_flatpak_steam() + if _is_flatpak: + ensure_flatpak_steam_filesystem_access(install_folder) + try: # Step 1: Check requirements update_progress("Checking requirements...", 1, 5) diff --git a/jackify/backend/utils/engine_error_parser.py b/jackify/backend/utils/engine_error_parser.py new file mode 100644 index 0000000..53c4d81 --- /dev/null +++ b/jackify/backend/utils/engine_error_parser.py @@ -0,0 +1,142 @@ +import json +from typing import Optional +from jackify.shared.errors import ( + JackifyError, InstallError, OAuthError, + oauth_expired, wabbajack_install_failed, format_technical_context, +) + + +def _ctx_detail(ctx: dict) -> Optional[str]: + if not ctx: + return None + return format_technical_context(context=ctx) + + +_TYPE_MAP = { + "auth_failed": lambda msg, ctx: oauth_expired(), + "premium_required": lambda msg, ctx: InstallError( + "Nexus Premium Required", + msg, + suggestion="Jackify requires a Nexus Premium account for automated installs.", + solutions=[ + "Log in to Nexus Mods with a Premium account.", + "Non-premium support is planned for a future release.", + ], + ), + "network_error": lambda msg, ctx: InstallError( + "Network or Download Failure", + msg, + suggestion="Check your internet connection and retry.", + solutions=[ + "Verify your internet connection.", + "Re-run the install — Wabbajack resumes from where it stopped.", + "Check if Nexus Mods is reachable at nexusmods.com.", + "Disable VPN or proxy if active.", + ], + technical=_ctx_detail(ctx), + ), + "disk_full": lambda msg, ctx: InstallError( + "Disk Full", + msg, + suggestion="Free space on the target drive and retry.", + solutions=[ + "Run: df -h to see available space.", + "Delete old modlist downloads or backups.", + "Move the install to a larger drive.", + ], + technical=_ctx_detail(ctx), + ), + "permission_denied": lambda msg, ctx: InstallError( + "Permission Denied", + msg, + suggestion="Check write permissions on the target path.", + solutions=[ + "Ensure Jackify and Steam are run as the same user.", + "Avoid install paths under /usr, /var, or /opt.", + f"Check permissions: ls -la {ctx.get('path', '')}", + ], + technical=_ctx_detail(ctx), + ), + "archive_corrupt": lambda msg, ctx: InstallError( + "Corrupted Archive", + msg, + suggestion="Re-run the install — Wabbajack will re-download and re-verify the file.", + solutions=[ + "Re-run the install.", + "Check available disk space (partial downloads appear corrupt).", + "Check Modlist_Install_workflow.log for the specific filename.", + ], + technical=_ctx_detail(ctx), + ), + "file_not_found": lambda msg, ctx: InstallError( + "File Not Found", + msg, + suggestion="Check the modlist URL and your game installation paths.", + solutions=[ + "Verify the modlist name is correct.", + "Ensure the target game is installed.", + "Re-run — the modlist index may have been temporarily unavailable.", + ], + technical=_ctx_detail(ctx), + ), + "validation_failed": lambda msg, ctx: InstallError( + "Validation Failed", + msg, + suggestion="Re-run the install to re-download any failed files.", + solutions=[ + "Re-run the install — Wabbajack resumes and re-validates.", + "Check available disk space.", + "Check Modlist_Install_workflow.log for specific failures.", + ], + technical=_ctx_detail(ctx), + ), + "download_stalled": lambda msg, ctx: InstallError( + "Downloads Stalled", + msg, + suggestion="Check your connection and OAuth status, then retry.", + solutions=[ + "Check your internet connection.", + "In Settings, confirm Nexus OAuth is active.", + "Re-run the install.", + ], + ), +} + +_EXIT_CODE_MAP = { + 2: lambda d, c: _TYPE_MAP["auth_failed"](d, c or {}), + 3: lambda d, c: _TYPE_MAP["network_error"](d, c or {}), + 4: lambda d, c: _TYPE_MAP["disk_full"](d, c or {}), + 5: lambda d, c: _TYPE_MAP["validation_failed"](d, c or {}), + 6: lambda d, c: wabbajack_install_failed(format_technical_context(detail=d, context=c) or d), +} + + +def parse_engine_error_line(line: str) -> Optional[JackifyError]: + """Parse one stderr line. Returns JackifyError or None.""" + line = line.strip() + if not line: + return None + try: + obj = json.loads(line) + except (json.JSONDecodeError, ValueError): + return None + if obj.get("je") != "1": + return None + if obj.get("level") == "warning": + return None + error_type = obj.get("type", "engine_error") + message = obj.get("message", "An unknown engine error occurred.") + context = obj.get("context") or {} + factory = _TYPE_MAP.get(error_type) + if factory: + return factory(message, context) + return wabbajack_install_failed(f"[{error_type}] {message}") + + +def error_from_exit_code(exit_code: int, detail: str = "", context: Optional[dict] = None) -> Optional[JackifyError]: + """Return a JackifyError based on exit code alone (fallback when no stderr line received).""" + factory = _EXIT_CODE_MAP.get(exit_code) + if factory: + detail_message = detail or f"Engine exited with code {exit_code}." + return factory(detail_message, context or {}) + return None diff --git a/jackify/backend/utils/modlist_meta.py b/jackify/backend/utils/modlist_meta.py new file mode 100644 index 0000000..4fa73b2 --- /dev/null +++ b/jackify/backend/utils/modlist_meta.py @@ -0,0 +1,87 @@ +import json +import logging +import re +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +JACKIFY_META_FILE = "jackify_meta.json" + +_BYTEARRAY_RE = re.compile(r"@ByteArray\((.+)\)", re.DOTALL) + + +def write_modlist_meta( + install_dir: str, + modlist_name: str, + game_type: Optional[str], + install_mode: str = "online", + modlist_version: Optional[str] = None, +) -> bool: + """Write jackify_meta.json into install_dir. Returns True on success.""" + from jackify import __version__ as jackify_version + import datetime + + try: + meta = { + "modlist_name": modlist_name, + "game_type": game_type or "", + "install_mode": install_mode, + "install_date": datetime.datetime.now().isoformat(timespec="seconds"), + "jackify_version": jackify_version, + } + if modlist_version: + meta["modlist_version"] = modlist_version + + out = Path(install_dir) / JACKIFY_META_FILE + out.write_text(json.dumps(meta, indent=2), encoding="utf-8") + logger.debug(f"Wrote modlist meta to {out}") + return True + except Exception as e: + logger.debug(f"Failed to write modlist meta: {e}") + return False + + +def read_modlist_meta(install_dir: str) -> Optional[dict]: + """Read jackify_meta.json from install_dir. Returns dict or None.""" + try: + meta_path = Path(install_dir) / JACKIFY_META_FILE + if not meta_path.exists(): + return None + return json.loads(meta_path.read_text(encoding="utf-8")) + except Exception as e: + logger.debug(f"Failed to read modlist meta from {install_dir}: {e}") + return None + + +def _read_selected_profile(install_dir: str) -> Optional[str]: + """Read selected_profile from ModOrganizer.ini, stripping @ByteArray() wrapper.""" + try: + mo2_ini = Path(install_dir) / "ModOrganizer.ini" + if not mo2_ini.exists(): + return None + for line in mo2_ini.read_text(encoding="utf-8", errors="ignore").splitlines(): + if not line.startswith("selected_profile"): + continue + _, _, value = line.partition("=") + value = value.strip() + m = _BYTEARRAY_RE.match(value) + if m: + return m.group(1).strip() + return value or None + except Exception as e: + logger.debug(f"Failed to read selected_profile from {install_dir}: {e}") + return None + + +def get_modlist_name(install_dir: str) -> Optional[str]: + """Return the best available modlist name for install_dir. + + Priority: + 1. jackify_meta.json (written by Jackify at install time) + 2. selected_profile from ModOrganizer.ini (set by modlist author) + """ + meta = read_modlist_meta(install_dir) + if meta and meta.get("modlist_name"): + return meta["modlist_name"] + return _read_selected_profile(install_dir) diff --git a/jackify/engine/Microsoft.CSharp.dll b/jackify/engine/Microsoft.CSharp.dll index 1194971..3c45f04 100755 Binary files a/jackify/engine/Microsoft.CSharp.dll and b/jackify/engine/Microsoft.CSharp.dll differ diff --git a/jackify/engine/Microsoft.VisualBasic.Core.dll b/jackify/engine/Microsoft.VisualBasic.Core.dll index 4150fd3..a269789 100755 Binary files a/jackify/engine/Microsoft.VisualBasic.Core.dll and b/jackify/engine/Microsoft.VisualBasic.Core.dll differ diff --git a/jackify/engine/Microsoft.VisualBasic.dll b/jackify/engine/Microsoft.VisualBasic.dll index 447455a..8dbae02 100755 Binary files a/jackify/engine/Microsoft.VisualBasic.dll and b/jackify/engine/Microsoft.VisualBasic.dll differ diff --git a/jackify/engine/Microsoft.Win32.Primitives.dll b/jackify/engine/Microsoft.Win32.Primitives.dll index 426b387..437d014 100755 Binary files a/jackify/engine/Microsoft.Win32.Primitives.dll and b/jackify/engine/Microsoft.Win32.Primitives.dll differ diff --git a/jackify/engine/Microsoft.Win32.Registry.dll b/jackify/engine/Microsoft.Win32.Registry.dll index 1bdec09..00aebe1 100755 Binary files a/jackify/engine/Microsoft.Win32.Registry.dll and b/jackify/engine/Microsoft.Win32.Registry.dll differ diff --git a/jackify/engine/System.AppContext.dll b/jackify/engine/System.AppContext.dll index 10d7ecf..c04b17e 100755 Binary files a/jackify/engine/System.AppContext.dll and b/jackify/engine/System.AppContext.dll differ diff --git a/jackify/engine/System.Buffers.dll b/jackify/engine/System.Buffers.dll index 8e2e46d..bfd092d 100755 Binary files a/jackify/engine/System.Buffers.dll and b/jackify/engine/System.Buffers.dll differ diff --git a/jackify/engine/System.Collections.Concurrent.dll b/jackify/engine/System.Collections.Concurrent.dll index 7ff4973..e281bc2 100755 Binary files a/jackify/engine/System.Collections.Concurrent.dll and b/jackify/engine/System.Collections.Concurrent.dll differ diff --git a/jackify/engine/System.Collections.Immutable.dll b/jackify/engine/System.Collections.Immutable.dll index 2401b44..d2f3035 100755 Binary files a/jackify/engine/System.Collections.Immutable.dll and b/jackify/engine/System.Collections.Immutable.dll differ diff --git a/jackify/engine/System.Collections.NonGeneric.dll b/jackify/engine/System.Collections.NonGeneric.dll index d28696c..e873c0b 100755 Binary files a/jackify/engine/System.Collections.NonGeneric.dll and b/jackify/engine/System.Collections.NonGeneric.dll differ diff --git a/jackify/engine/System.Collections.Specialized.dll b/jackify/engine/System.Collections.Specialized.dll index 5214470..4997f76 100755 Binary files a/jackify/engine/System.Collections.Specialized.dll and b/jackify/engine/System.Collections.Specialized.dll differ diff --git a/jackify/engine/System.Collections.dll b/jackify/engine/System.Collections.dll index f563bb1..62bdcff 100755 Binary files a/jackify/engine/System.Collections.dll and b/jackify/engine/System.Collections.dll differ diff --git a/jackify/engine/System.ComponentModel.Annotations.dll b/jackify/engine/System.ComponentModel.Annotations.dll index f05e788..ff4d577 100755 Binary files a/jackify/engine/System.ComponentModel.Annotations.dll and b/jackify/engine/System.ComponentModel.Annotations.dll differ diff --git a/jackify/engine/System.ComponentModel.DataAnnotations.dll b/jackify/engine/System.ComponentModel.DataAnnotations.dll index 46bf187..d4f1876 100755 Binary files a/jackify/engine/System.ComponentModel.DataAnnotations.dll and b/jackify/engine/System.ComponentModel.DataAnnotations.dll differ diff --git a/jackify/engine/System.ComponentModel.EventBasedAsync.dll b/jackify/engine/System.ComponentModel.EventBasedAsync.dll index 7e941c7..48934d3 100755 Binary files a/jackify/engine/System.ComponentModel.EventBasedAsync.dll and b/jackify/engine/System.ComponentModel.EventBasedAsync.dll differ diff --git a/jackify/engine/System.ComponentModel.Primitives.dll b/jackify/engine/System.ComponentModel.Primitives.dll index e6dbe1a..9a74dcb 100755 Binary files a/jackify/engine/System.ComponentModel.Primitives.dll and b/jackify/engine/System.ComponentModel.Primitives.dll differ diff --git a/jackify/engine/System.ComponentModel.TypeConverter.dll b/jackify/engine/System.ComponentModel.TypeConverter.dll index 97c6e1c..dee5eea 100755 Binary files a/jackify/engine/System.ComponentModel.TypeConverter.dll and b/jackify/engine/System.ComponentModel.TypeConverter.dll differ diff --git a/jackify/engine/System.ComponentModel.dll b/jackify/engine/System.ComponentModel.dll index a6dfc6e..d6d2256 100755 Binary files a/jackify/engine/System.ComponentModel.dll and b/jackify/engine/System.ComponentModel.dll differ diff --git a/jackify/engine/System.Configuration.dll b/jackify/engine/System.Configuration.dll index 73543da..92636da 100755 Binary files a/jackify/engine/System.Configuration.dll and b/jackify/engine/System.Configuration.dll differ diff --git a/jackify/engine/System.Console.dll b/jackify/engine/System.Console.dll index 0285114..c02fb3c 100755 Binary files a/jackify/engine/System.Console.dll and b/jackify/engine/System.Console.dll differ diff --git a/jackify/engine/System.Core.dll b/jackify/engine/System.Core.dll index 76e9033..26b2ccf 100755 Binary files a/jackify/engine/System.Core.dll and b/jackify/engine/System.Core.dll differ diff --git a/jackify/engine/System.Data.Common.dll b/jackify/engine/System.Data.Common.dll index b0ca6bc..abdaec5 100755 Binary files a/jackify/engine/System.Data.Common.dll and b/jackify/engine/System.Data.Common.dll differ diff --git a/jackify/engine/System.Data.DataSetExtensions.dll b/jackify/engine/System.Data.DataSetExtensions.dll index e0fb4a1..57ceb58 100755 Binary files a/jackify/engine/System.Data.DataSetExtensions.dll and b/jackify/engine/System.Data.DataSetExtensions.dll differ diff --git a/jackify/engine/System.Data.dll b/jackify/engine/System.Data.dll index 75f98df..25abe31 100755 Binary files a/jackify/engine/System.Data.dll and b/jackify/engine/System.Data.dll differ diff --git a/jackify/engine/System.Diagnostics.Contracts.dll b/jackify/engine/System.Diagnostics.Contracts.dll index ef2a6b3..a58428a 100755 Binary files a/jackify/engine/System.Diagnostics.Contracts.dll and b/jackify/engine/System.Diagnostics.Contracts.dll differ diff --git a/jackify/engine/System.Diagnostics.Debug.dll b/jackify/engine/System.Diagnostics.Debug.dll index 1c527bc..f936bcb 100755 Binary files a/jackify/engine/System.Diagnostics.Debug.dll and b/jackify/engine/System.Diagnostics.Debug.dll differ diff --git a/jackify/engine/System.Diagnostics.FileVersionInfo.dll b/jackify/engine/System.Diagnostics.FileVersionInfo.dll index 02968ed..7d25682 100755 Binary files a/jackify/engine/System.Diagnostics.FileVersionInfo.dll and b/jackify/engine/System.Diagnostics.FileVersionInfo.dll differ diff --git a/jackify/engine/System.Diagnostics.Process.dll b/jackify/engine/System.Diagnostics.Process.dll index 043b4e5..f17dc7f 100755 Binary files a/jackify/engine/System.Diagnostics.Process.dll and b/jackify/engine/System.Diagnostics.Process.dll differ diff --git a/jackify/engine/System.Diagnostics.StackTrace.dll b/jackify/engine/System.Diagnostics.StackTrace.dll index 4d0d6ba..0aca72a 100755 Binary files a/jackify/engine/System.Diagnostics.StackTrace.dll and b/jackify/engine/System.Diagnostics.StackTrace.dll differ diff --git a/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll b/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll index 88907fd..8c5ee26 100755 Binary files a/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll and b/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll differ diff --git a/jackify/engine/System.Diagnostics.Tools.dll b/jackify/engine/System.Diagnostics.Tools.dll index 72530be..04a443a 100755 Binary files a/jackify/engine/System.Diagnostics.Tools.dll and b/jackify/engine/System.Diagnostics.Tools.dll differ diff --git a/jackify/engine/System.Diagnostics.TraceSource.dll b/jackify/engine/System.Diagnostics.TraceSource.dll index 0b4e082..0617028 100755 Binary files a/jackify/engine/System.Diagnostics.TraceSource.dll and b/jackify/engine/System.Diagnostics.TraceSource.dll differ diff --git a/jackify/engine/System.Diagnostics.Tracing.dll b/jackify/engine/System.Diagnostics.Tracing.dll index 924bea5..0f261e4 100755 Binary files a/jackify/engine/System.Diagnostics.Tracing.dll and b/jackify/engine/System.Diagnostics.Tracing.dll differ diff --git a/jackify/engine/System.Drawing.Primitives.dll b/jackify/engine/System.Drawing.Primitives.dll index 6084dd6..7381d38 100755 Binary files a/jackify/engine/System.Drawing.Primitives.dll and b/jackify/engine/System.Drawing.Primitives.dll differ diff --git a/jackify/engine/System.Drawing.dll b/jackify/engine/System.Drawing.dll index 0bbba15..52d2a1b 100755 Binary files a/jackify/engine/System.Drawing.dll and b/jackify/engine/System.Drawing.dll differ diff --git a/jackify/engine/System.Dynamic.Runtime.dll b/jackify/engine/System.Dynamic.Runtime.dll index 05f12f8..b67d4f1 100755 Binary files a/jackify/engine/System.Dynamic.Runtime.dll and b/jackify/engine/System.Dynamic.Runtime.dll differ diff --git a/jackify/engine/System.Formats.Asn1.dll b/jackify/engine/System.Formats.Asn1.dll index 3a5f9a9..242253a 100755 Binary files a/jackify/engine/System.Formats.Asn1.dll and b/jackify/engine/System.Formats.Asn1.dll differ diff --git a/jackify/engine/System.Formats.Tar.dll b/jackify/engine/System.Formats.Tar.dll index dbac593..0897822 100755 Binary files a/jackify/engine/System.Formats.Tar.dll and b/jackify/engine/System.Formats.Tar.dll differ diff --git a/jackify/engine/System.Globalization.Calendars.dll b/jackify/engine/System.Globalization.Calendars.dll index 7f7aa63..785a026 100755 Binary files a/jackify/engine/System.Globalization.Calendars.dll and b/jackify/engine/System.Globalization.Calendars.dll differ diff --git a/jackify/engine/System.Globalization.Extensions.dll b/jackify/engine/System.Globalization.Extensions.dll index 280a302..2024da0 100755 Binary files a/jackify/engine/System.Globalization.Extensions.dll and b/jackify/engine/System.Globalization.Extensions.dll differ diff --git a/jackify/engine/System.Globalization.dll b/jackify/engine/System.Globalization.dll index a2e8b63..b6ab2b3 100755 Binary files a/jackify/engine/System.Globalization.dll and b/jackify/engine/System.Globalization.dll differ diff --git a/jackify/engine/System.IO.Compression.Brotli.dll b/jackify/engine/System.IO.Compression.Brotli.dll index 7d1e5f0..dc66dfa 100755 Binary files a/jackify/engine/System.IO.Compression.Brotli.dll and b/jackify/engine/System.IO.Compression.Brotli.dll differ diff --git a/jackify/engine/System.IO.Compression.FileSystem.dll b/jackify/engine/System.IO.Compression.FileSystem.dll index fd9d8d4..3d89f05 100755 Binary files a/jackify/engine/System.IO.Compression.FileSystem.dll and b/jackify/engine/System.IO.Compression.FileSystem.dll differ diff --git a/jackify/engine/System.IO.Compression.ZipFile.dll b/jackify/engine/System.IO.Compression.ZipFile.dll index fd44249..87f9880 100755 Binary files a/jackify/engine/System.IO.Compression.ZipFile.dll and b/jackify/engine/System.IO.Compression.ZipFile.dll differ diff --git a/jackify/engine/System.IO.Compression.dll b/jackify/engine/System.IO.Compression.dll index 66ecbbe..3806354 100755 Binary files a/jackify/engine/System.IO.Compression.dll and b/jackify/engine/System.IO.Compression.dll differ diff --git a/jackify/engine/System.IO.FileSystem.AccessControl.dll b/jackify/engine/System.IO.FileSystem.AccessControl.dll index 686ab69..d1b6aca 100755 Binary files a/jackify/engine/System.IO.FileSystem.AccessControl.dll and b/jackify/engine/System.IO.FileSystem.AccessControl.dll differ diff --git a/jackify/engine/System.IO.FileSystem.DriveInfo.dll b/jackify/engine/System.IO.FileSystem.DriveInfo.dll index ce5ffe3..12e46d2 100755 Binary files a/jackify/engine/System.IO.FileSystem.DriveInfo.dll and b/jackify/engine/System.IO.FileSystem.DriveInfo.dll differ diff --git a/jackify/engine/System.IO.FileSystem.Primitives.dll b/jackify/engine/System.IO.FileSystem.Primitives.dll index 164ae13..64a32af 100755 Binary files a/jackify/engine/System.IO.FileSystem.Primitives.dll and b/jackify/engine/System.IO.FileSystem.Primitives.dll differ diff --git a/jackify/engine/System.IO.FileSystem.Watcher.dll b/jackify/engine/System.IO.FileSystem.Watcher.dll index 2e43824..fa79e26 100755 Binary files a/jackify/engine/System.IO.FileSystem.Watcher.dll and b/jackify/engine/System.IO.FileSystem.Watcher.dll differ diff --git a/jackify/engine/System.IO.FileSystem.dll b/jackify/engine/System.IO.FileSystem.dll index 2bfdf7c..c4e9a83 100755 Binary files a/jackify/engine/System.IO.FileSystem.dll and b/jackify/engine/System.IO.FileSystem.dll differ diff --git a/jackify/engine/System.IO.IsolatedStorage.dll b/jackify/engine/System.IO.IsolatedStorage.dll index 6058a41..0d2f33e 100755 Binary files a/jackify/engine/System.IO.IsolatedStorage.dll and b/jackify/engine/System.IO.IsolatedStorage.dll differ diff --git a/jackify/engine/System.IO.MemoryMappedFiles.dll b/jackify/engine/System.IO.MemoryMappedFiles.dll index e95918d..02eb5cc 100755 Binary files a/jackify/engine/System.IO.MemoryMappedFiles.dll and b/jackify/engine/System.IO.MemoryMappedFiles.dll differ diff --git a/jackify/engine/System.IO.Pipes.AccessControl.dll b/jackify/engine/System.IO.Pipes.AccessControl.dll index 2ff6467..e3fb2d6 100755 Binary files a/jackify/engine/System.IO.Pipes.AccessControl.dll and b/jackify/engine/System.IO.Pipes.AccessControl.dll differ diff --git a/jackify/engine/System.IO.Pipes.dll b/jackify/engine/System.IO.Pipes.dll index b82c7ce..61201b2 100755 Binary files a/jackify/engine/System.IO.Pipes.dll and b/jackify/engine/System.IO.Pipes.dll differ diff --git a/jackify/engine/System.IO.UnmanagedMemoryStream.dll b/jackify/engine/System.IO.UnmanagedMemoryStream.dll index 8399c26..7efdb5d 100755 Binary files a/jackify/engine/System.IO.UnmanagedMemoryStream.dll and b/jackify/engine/System.IO.UnmanagedMemoryStream.dll differ diff --git a/jackify/engine/System.IO.dll b/jackify/engine/System.IO.dll index 92886e1..0a9b8b3 100755 Binary files a/jackify/engine/System.IO.dll and b/jackify/engine/System.IO.dll differ diff --git a/jackify/engine/System.Linq.Expressions.dll b/jackify/engine/System.Linq.Expressions.dll index 4be0204..a5ccfa7 100755 Binary files a/jackify/engine/System.Linq.Expressions.dll and b/jackify/engine/System.Linq.Expressions.dll differ diff --git a/jackify/engine/System.Linq.Parallel.dll b/jackify/engine/System.Linq.Parallel.dll index 887c799..65ebad0 100755 Binary files a/jackify/engine/System.Linq.Parallel.dll and b/jackify/engine/System.Linq.Parallel.dll differ diff --git a/jackify/engine/System.Linq.Queryable.dll b/jackify/engine/System.Linq.Queryable.dll index 4dca185..0960ff9 100755 Binary files a/jackify/engine/System.Linq.Queryable.dll and b/jackify/engine/System.Linq.Queryable.dll differ diff --git a/jackify/engine/System.Linq.dll b/jackify/engine/System.Linq.dll index 3705c62..add4858 100755 Binary files a/jackify/engine/System.Linq.dll and b/jackify/engine/System.Linq.dll differ diff --git a/jackify/engine/System.Memory.dll b/jackify/engine/System.Memory.dll index 9b55a91..72ffc6e 100755 Binary files a/jackify/engine/System.Memory.dll and b/jackify/engine/System.Memory.dll differ diff --git a/jackify/engine/System.Net.Http.Json.dll b/jackify/engine/System.Net.Http.Json.dll index 11d80f0..ea868c0 100755 Binary files a/jackify/engine/System.Net.Http.Json.dll and b/jackify/engine/System.Net.Http.Json.dll differ diff --git a/jackify/engine/System.Net.Http.dll b/jackify/engine/System.Net.Http.dll index e3aaf65..142c558 100755 Binary files a/jackify/engine/System.Net.Http.dll and b/jackify/engine/System.Net.Http.dll differ diff --git a/jackify/engine/System.Net.HttpListener.dll b/jackify/engine/System.Net.HttpListener.dll index ff67b69..735ff84 100755 Binary files a/jackify/engine/System.Net.HttpListener.dll and b/jackify/engine/System.Net.HttpListener.dll differ diff --git a/jackify/engine/System.Net.Mail.dll b/jackify/engine/System.Net.Mail.dll index 35ef9f1..6def9eb 100755 Binary files a/jackify/engine/System.Net.Mail.dll and b/jackify/engine/System.Net.Mail.dll differ diff --git a/jackify/engine/System.Net.NameResolution.dll b/jackify/engine/System.Net.NameResolution.dll index 9e21a3d..0778409 100755 Binary files a/jackify/engine/System.Net.NameResolution.dll and b/jackify/engine/System.Net.NameResolution.dll differ diff --git a/jackify/engine/System.Net.NetworkInformation.dll b/jackify/engine/System.Net.NetworkInformation.dll index 56e4f45..e1a933f 100755 Binary files a/jackify/engine/System.Net.NetworkInformation.dll and b/jackify/engine/System.Net.NetworkInformation.dll differ diff --git a/jackify/engine/System.Net.Ping.dll b/jackify/engine/System.Net.Ping.dll index 795aa2e..1bcca16 100755 Binary files a/jackify/engine/System.Net.Ping.dll and b/jackify/engine/System.Net.Ping.dll differ diff --git a/jackify/engine/System.Net.Primitives.dll b/jackify/engine/System.Net.Primitives.dll index cebf262..22d2928 100755 Binary files a/jackify/engine/System.Net.Primitives.dll and b/jackify/engine/System.Net.Primitives.dll differ diff --git a/jackify/engine/System.Net.Quic.dll b/jackify/engine/System.Net.Quic.dll index b696b78..13a5a65 100755 Binary files a/jackify/engine/System.Net.Quic.dll and b/jackify/engine/System.Net.Quic.dll differ diff --git a/jackify/engine/System.Net.Requests.dll b/jackify/engine/System.Net.Requests.dll index fc433e2..c808b88 100755 Binary files a/jackify/engine/System.Net.Requests.dll and b/jackify/engine/System.Net.Requests.dll differ diff --git a/jackify/engine/System.Net.Security.dll b/jackify/engine/System.Net.Security.dll index 42f2133..527e4c2 100755 Binary files a/jackify/engine/System.Net.Security.dll and b/jackify/engine/System.Net.Security.dll differ diff --git a/jackify/engine/System.Net.ServicePoint.dll b/jackify/engine/System.Net.ServicePoint.dll index 104f202..f9d88da 100755 Binary files a/jackify/engine/System.Net.ServicePoint.dll and b/jackify/engine/System.Net.ServicePoint.dll differ diff --git a/jackify/engine/System.Net.Sockets.dll b/jackify/engine/System.Net.Sockets.dll index 99b2a0d..f1ef520 100755 Binary files a/jackify/engine/System.Net.Sockets.dll and b/jackify/engine/System.Net.Sockets.dll differ diff --git a/jackify/engine/System.Net.WebClient.dll b/jackify/engine/System.Net.WebClient.dll index 956c881..741be37 100755 Binary files a/jackify/engine/System.Net.WebClient.dll and b/jackify/engine/System.Net.WebClient.dll differ diff --git a/jackify/engine/System.Net.WebHeaderCollection.dll b/jackify/engine/System.Net.WebHeaderCollection.dll index b1437a4..d6bae00 100755 Binary files a/jackify/engine/System.Net.WebHeaderCollection.dll and b/jackify/engine/System.Net.WebHeaderCollection.dll differ diff --git a/jackify/engine/System.Net.WebProxy.dll b/jackify/engine/System.Net.WebProxy.dll index 9ce1961..9ed652b 100755 Binary files a/jackify/engine/System.Net.WebProxy.dll and b/jackify/engine/System.Net.WebProxy.dll differ diff --git a/jackify/engine/System.Net.WebSockets.Client.dll b/jackify/engine/System.Net.WebSockets.Client.dll index 94eeec5..2514395 100755 Binary files a/jackify/engine/System.Net.WebSockets.Client.dll and b/jackify/engine/System.Net.WebSockets.Client.dll differ diff --git a/jackify/engine/System.Net.WebSockets.dll b/jackify/engine/System.Net.WebSockets.dll index 3a8fa24..1943411 100755 Binary files a/jackify/engine/System.Net.WebSockets.dll and b/jackify/engine/System.Net.WebSockets.dll differ diff --git a/jackify/engine/System.Net.dll b/jackify/engine/System.Net.dll index 6c98268..696b281 100755 Binary files a/jackify/engine/System.Net.dll and b/jackify/engine/System.Net.dll differ diff --git a/jackify/engine/System.Numerics.Vectors.dll b/jackify/engine/System.Numerics.Vectors.dll index ed436ad..caa9a52 100755 Binary files a/jackify/engine/System.Numerics.Vectors.dll and b/jackify/engine/System.Numerics.Vectors.dll differ diff --git a/jackify/engine/System.Numerics.dll b/jackify/engine/System.Numerics.dll index e043815..5ef5799 100755 Binary files a/jackify/engine/System.Numerics.dll and b/jackify/engine/System.Numerics.dll differ diff --git a/jackify/engine/System.ObjectModel.dll b/jackify/engine/System.ObjectModel.dll index 17d97b6..98ff918 100755 Binary files a/jackify/engine/System.ObjectModel.dll and b/jackify/engine/System.ObjectModel.dll differ diff --git a/jackify/engine/System.Private.CoreLib.dll b/jackify/engine/System.Private.CoreLib.dll index d1658f5..143a2b7 100755 Binary files a/jackify/engine/System.Private.CoreLib.dll and b/jackify/engine/System.Private.CoreLib.dll differ diff --git a/jackify/engine/System.Private.DataContractSerialization.dll b/jackify/engine/System.Private.DataContractSerialization.dll index 8181e15..8822e63 100755 Binary files a/jackify/engine/System.Private.DataContractSerialization.dll and b/jackify/engine/System.Private.DataContractSerialization.dll differ diff --git a/jackify/engine/System.Private.Uri.dll b/jackify/engine/System.Private.Uri.dll index 8bee67a..d872db2 100755 Binary files a/jackify/engine/System.Private.Uri.dll and b/jackify/engine/System.Private.Uri.dll differ diff --git a/jackify/engine/System.Private.Xml.Linq.dll b/jackify/engine/System.Private.Xml.Linq.dll index 00b9384..ef40b59 100755 Binary files a/jackify/engine/System.Private.Xml.Linq.dll and b/jackify/engine/System.Private.Xml.Linq.dll differ diff --git a/jackify/engine/System.Private.Xml.dll b/jackify/engine/System.Private.Xml.dll index 5800b14..2eda5d6 100755 Binary files a/jackify/engine/System.Private.Xml.dll and b/jackify/engine/System.Private.Xml.dll differ diff --git a/jackify/engine/System.Reflection.DispatchProxy.dll b/jackify/engine/System.Reflection.DispatchProxy.dll index dee21f1..4e7e983 100755 Binary files a/jackify/engine/System.Reflection.DispatchProxy.dll and b/jackify/engine/System.Reflection.DispatchProxy.dll differ diff --git a/jackify/engine/System.Reflection.Emit.ILGeneration.dll b/jackify/engine/System.Reflection.Emit.ILGeneration.dll index b1faec8..545b139 100755 Binary files a/jackify/engine/System.Reflection.Emit.ILGeneration.dll and b/jackify/engine/System.Reflection.Emit.ILGeneration.dll differ diff --git a/jackify/engine/System.Reflection.Emit.Lightweight.dll b/jackify/engine/System.Reflection.Emit.Lightweight.dll index b5a8136..80d1ec6 100755 Binary files a/jackify/engine/System.Reflection.Emit.Lightweight.dll and b/jackify/engine/System.Reflection.Emit.Lightweight.dll differ diff --git a/jackify/engine/System.Reflection.Emit.dll b/jackify/engine/System.Reflection.Emit.dll index a256c16..158d41c 100755 Binary files a/jackify/engine/System.Reflection.Emit.dll and b/jackify/engine/System.Reflection.Emit.dll differ diff --git a/jackify/engine/System.Reflection.Extensions.dll b/jackify/engine/System.Reflection.Extensions.dll index 53c5fa6..81d057a 100755 Binary files a/jackify/engine/System.Reflection.Extensions.dll and b/jackify/engine/System.Reflection.Extensions.dll differ diff --git a/jackify/engine/System.Reflection.Metadata.dll b/jackify/engine/System.Reflection.Metadata.dll index 51fb69c..9820bd8 100755 Binary files a/jackify/engine/System.Reflection.Metadata.dll and b/jackify/engine/System.Reflection.Metadata.dll differ diff --git a/jackify/engine/System.Reflection.Primitives.dll b/jackify/engine/System.Reflection.Primitives.dll index f5b0136..ff7af1c 100755 Binary files a/jackify/engine/System.Reflection.Primitives.dll and b/jackify/engine/System.Reflection.Primitives.dll differ diff --git a/jackify/engine/System.Reflection.TypeExtensions.dll b/jackify/engine/System.Reflection.TypeExtensions.dll index 0f086b6..fe14ab2 100755 Binary files a/jackify/engine/System.Reflection.TypeExtensions.dll and b/jackify/engine/System.Reflection.TypeExtensions.dll differ diff --git a/jackify/engine/System.Reflection.dll b/jackify/engine/System.Reflection.dll index 4b9739d..ee0a35d 100755 Binary files a/jackify/engine/System.Reflection.dll and b/jackify/engine/System.Reflection.dll differ diff --git a/jackify/engine/System.Resources.Reader.dll b/jackify/engine/System.Resources.Reader.dll index d52dc6a..239924d 100755 Binary files a/jackify/engine/System.Resources.Reader.dll and b/jackify/engine/System.Resources.Reader.dll differ diff --git a/jackify/engine/System.Resources.ResourceManager.dll b/jackify/engine/System.Resources.ResourceManager.dll index 37c3fa7..602d715 100755 Binary files a/jackify/engine/System.Resources.ResourceManager.dll and b/jackify/engine/System.Resources.ResourceManager.dll differ diff --git a/jackify/engine/System.Resources.Writer.dll b/jackify/engine/System.Resources.Writer.dll index dc328c0..1911b02 100755 Binary files a/jackify/engine/System.Resources.Writer.dll and b/jackify/engine/System.Resources.Writer.dll differ diff --git a/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll b/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll index 5a01aad..d40f9ff 100755 Binary files a/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll and b/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll differ diff --git a/jackify/engine/System.Runtime.CompilerServices.VisualC.dll b/jackify/engine/System.Runtime.CompilerServices.VisualC.dll index b175495..d0183ae 100755 Binary files a/jackify/engine/System.Runtime.CompilerServices.VisualC.dll and b/jackify/engine/System.Runtime.CompilerServices.VisualC.dll differ diff --git a/jackify/engine/System.Runtime.Extensions.dll b/jackify/engine/System.Runtime.Extensions.dll index 2a50a53..460096c 100755 Binary files a/jackify/engine/System.Runtime.Extensions.dll and b/jackify/engine/System.Runtime.Extensions.dll differ diff --git a/jackify/engine/System.Runtime.Handles.dll b/jackify/engine/System.Runtime.Handles.dll index b3cd2c7..d45781e 100755 Binary files a/jackify/engine/System.Runtime.Handles.dll and b/jackify/engine/System.Runtime.Handles.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.JavaScript.dll b/jackify/engine/System.Runtime.InteropServices.JavaScript.dll index 0d43900..07a805b 100755 Binary files a/jackify/engine/System.Runtime.InteropServices.JavaScript.dll and b/jackify/engine/System.Runtime.InteropServices.JavaScript.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll b/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll index 88769b5..c2f08ca 100755 Binary files a/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll and b/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.dll b/jackify/engine/System.Runtime.InteropServices.dll index abf7d83..f3c2b68 100755 Binary files a/jackify/engine/System.Runtime.InteropServices.dll and b/jackify/engine/System.Runtime.InteropServices.dll differ diff --git a/jackify/engine/System.Runtime.Intrinsics.dll b/jackify/engine/System.Runtime.Intrinsics.dll index 067a033..19f4fcc 100755 Binary files a/jackify/engine/System.Runtime.Intrinsics.dll and b/jackify/engine/System.Runtime.Intrinsics.dll differ diff --git a/jackify/engine/System.Runtime.Loader.dll b/jackify/engine/System.Runtime.Loader.dll index 4cf7420..4d780dc 100755 Binary files a/jackify/engine/System.Runtime.Loader.dll and b/jackify/engine/System.Runtime.Loader.dll differ diff --git a/jackify/engine/System.Runtime.Numerics.dll b/jackify/engine/System.Runtime.Numerics.dll index 6c335d9..71c257b 100755 Binary files a/jackify/engine/System.Runtime.Numerics.dll and b/jackify/engine/System.Runtime.Numerics.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Formatters.dll b/jackify/engine/System.Runtime.Serialization.Formatters.dll index 748d89f..e2b0837 100755 Binary files a/jackify/engine/System.Runtime.Serialization.Formatters.dll and b/jackify/engine/System.Runtime.Serialization.Formatters.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Json.dll b/jackify/engine/System.Runtime.Serialization.Json.dll index c4bc99e..8b04e6e 100755 Binary files a/jackify/engine/System.Runtime.Serialization.Json.dll and b/jackify/engine/System.Runtime.Serialization.Json.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Primitives.dll b/jackify/engine/System.Runtime.Serialization.Primitives.dll index 621e5dc..3f484d1 100755 Binary files a/jackify/engine/System.Runtime.Serialization.Primitives.dll and b/jackify/engine/System.Runtime.Serialization.Primitives.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Xml.dll b/jackify/engine/System.Runtime.Serialization.Xml.dll index 4c6d27f..0c00c7a 100755 Binary files a/jackify/engine/System.Runtime.Serialization.Xml.dll and b/jackify/engine/System.Runtime.Serialization.Xml.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.dll b/jackify/engine/System.Runtime.Serialization.dll index 207e2a4..4a31c54 100755 Binary files a/jackify/engine/System.Runtime.Serialization.dll and b/jackify/engine/System.Runtime.Serialization.dll differ diff --git a/jackify/engine/System.Runtime.dll b/jackify/engine/System.Runtime.dll index 5fee157..9ff8503 100755 Binary files a/jackify/engine/System.Runtime.dll and b/jackify/engine/System.Runtime.dll differ diff --git a/jackify/engine/System.Security.AccessControl.dll b/jackify/engine/System.Security.AccessControl.dll index f2c0475..8de40a8 100755 Binary files a/jackify/engine/System.Security.AccessControl.dll and b/jackify/engine/System.Security.AccessControl.dll differ diff --git a/jackify/engine/System.Security.Claims.dll b/jackify/engine/System.Security.Claims.dll index d5d90f6..e6ca5b7 100755 Binary files a/jackify/engine/System.Security.Claims.dll and b/jackify/engine/System.Security.Claims.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Algorithms.dll b/jackify/engine/System.Security.Cryptography.Algorithms.dll index 2e975c5..93af952 100755 Binary files a/jackify/engine/System.Security.Cryptography.Algorithms.dll and b/jackify/engine/System.Security.Cryptography.Algorithms.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Cng.dll b/jackify/engine/System.Security.Cryptography.Cng.dll index 80ef977..1882a50 100755 Binary files a/jackify/engine/System.Security.Cryptography.Cng.dll and b/jackify/engine/System.Security.Cryptography.Cng.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Csp.dll b/jackify/engine/System.Security.Cryptography.Csp.dll index 18a9768..f92d8bb 100755 Binary files a/jackify/engine/System.Security.Cryptography.Csp.dll and b/jackify/engine/System.Security.Cryptography.Csp.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Encoding.dll b/jackify/engine/System.Security.Cryptography.Encoding.dll index 99b861c..1f6898f 100755 Binary files a/jackify/engine/System.Security.Cryptography.Encoding.dll and b/jackify/engine/System.Security.Cryptography.Encoding.dll differ diff --git a/jackify/engine/System.Security.Cryptography.OpenSsl.dll b/jackify/engine/System.Security.Cryptography.OpenSsl.dll index 63392d6..9c13b04 100755 Binary files a/jackify/engine/System.Security.Cryptography.OpenSsl.dll and b/jackify/engine/System.Security.Cryptography.OpenSsl.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Primitives.dll b/jackify/engine/System.Security.Cryptography.Primitives.dll index 30229a9..08d80b7 100755 Binary files a/jackify/engine/System.Security.Cryptography.Primitives.dll and b/jackify/engine/System.Security.Cryptography.Primitives.dll differ diff --git a/jackify/engine/System.Security.Cryptography.X509Certificates.dll b/jackify/engine/System.Security.Cryptography.X509Certificates.dll index 67ff8d2..ec476d7 100755 Binary files a/jackify/engine/System.Security.Cryptography.X509Certificates.dll and b/jackify/engine/System.Security.Cryptography.X509Certificates.dll differ diff --git a/jackify/engine/System.Security.Cryptography.dll b/jackify/engine/System.Security.Cryptography.dll index 8ba0cfa..a56bd03 100755 Binary files a/jackify/engine/System.Security.Cryptography.dll and b/jackify/engine/System.Security.Cryptography.dll differ diff --git a/jackify/engine/System.Security.Principal.Windows.dll b/jackify/engine/System.Security.Principal.Windows.dll index 0e66d61..2083e2d 100755 Binary files a/jackify/engine/System.Security.Principal.Windows.dll and b/jackify/engine/System.Security.Principal.Windows.dll differ diff --git a/jackify/engine/System.Security.Principal.dll b/jackify/engine/System.Security.Principal.dll index 77cd74d..66b56c1 100755 Binary files a/jackify/engine/System.Security.Principal.dll and b/jackify/engine/System.Security.Principal.dll differ diff --git a/jackify/engine/System.Security.SecureString.dll b/jackify/engine/System.Security.SecureString.dll index 54e869c..1e857fe 100755 Binary files a/jackify/engine/System.Security.SecureString.dll and b/jackify/engine/System.Security.SecureString.dll differ diff --git a/jackify/engine/System.Security.dll b/jackify/engine/System.Security.dll index 24d89d7..7955424 100755 Binary files a/jackify/engine/System.Security.dll and b/jackify/engine/System.Security.dll differ diff --git a/jackify/engine/System.ServiceModel.Web.dll b/jackify/engine/System.ServiceModel.Web.dll index e2e36cb..66c4f84 100755 Binary files a/jackify/engine/System.ServiceModel.Web.dll and b/jackify/engine/System.ServiceModel.Web.dll differ diff --git a/jackify/engine/System.ServiceProcess.dll b/jackify/engine/System.ServiceProcess.dll index aa1841d..50ffaff 100755 Binary files a/jackify/engine/System.ServiceProcess.dll and b/jackify/engine/System.ServiceProcess.dll differ diff --git a/jackify/engine/System.Text.Encoding.CodePages.dll b/jackify/engine/System.Text.Encoding.CodePages.dll index 9815a03..e1e2be0 100755 Binary files a/jackify/engine/System.Text.Encoding.CodePages.dll and b/jackify/engine/System.Text.Encoding.CodePages.dll differ diff --git a/jackify/engine/System.Text.Encoding.Extensions.dll b/jackify/engine/System.Text.Encoding.Extensions.dll index e5d2f6c..ddc1741 100755 Binary files a/jackify/engine/System.Text.Encoding.Extensions.dll and b/jackify/engine/System.Text.Encoding.Extensions.dll differ diff --git a/jackify/engine/System.Text.Encoding.dll b/jackify/engine/System.Text.Encoding.dll index b04bfbe..def2782 100755 Binary files a/jackify/engine/System.Text.Encoding.dll and b/jackify/engine/System.Text.Encoding.dll differ diff --git a/jackify/engine/System.Text.RegularExpressions.dll b/jackify/engine/System.Text.RegularExpressions.dll index 1d3e5f0..559dfee 100755 Binary files a/jackify/engine/System.Text.RegularExpressions.dll and b/jackify/engine/System.Text.RegularExpressions.dll differ diff --git a/jackify/engine/System.Threading.Channels.dll b/jackify/engine/System.Threading.Channels.dll index 5a43e75..9229f38 100755 Binary files a/jackify/engine/System.Threading.Channels.dll and b/jackify/engine/System.Threading.Channels.dll differ diff --git a/jackify/engine/System.Threading.Overlapped.dll b/jackify/engine/System.Threading.Overlapped.dll index 8214db0..87e8128 100755 Binary files a/jackify/engine/System.Threading.Overlapped.dll and b/jackify/engine/System.Threading.Overlapped.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Dataflow.dll b/jackify/engine/System.Threading.Tasks.Dataflow.dll index f76b202..bc382a1 100755 Binary files a/jackify/engine/System.Threading.Tasks.Dataflow.dll and b/jackify/engine/System.Threading.Tasks.Dataflow.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Extensions.dll b/jackify/engine/System.Threading.Tasks.Extensions.dll index 0a23436..e76a76c 100755 Binary files a/jackify/engine/System.Threading.Tasks.Extensions.dll and b/jackify/engine/System.Threading.Tasks.Extensions.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Parallel.dll b/jackify/engine/System.Threading.Tasks.Parallel.dll index ea574f4..f566f1f 100755 Binary files a/jackify/engine/System.Threading.Tasks.Parallel.dll and b/jackify/engine/System.Threading.Tasks.Parallel.dll differ diff --git a/jackify/engine/System.Threading.Tasks.dll b/jackify/engine/System.Threading.Tasks.dll index d997344..7f1b42c 100755 Binary files a/jackify/engine/System.Threading.Tasks.dll and b/jackify/engine/System.Threading.Tasks.dll differ diff --git a/jackify/engine/System.Threading.Thread.dll b/jackify/engine/System.Threading.Thread.dll index bf7d934..e30445c 100755 Binary files a/jackify/engine/System.Threading.Thread.dll and b/jackify/engine/System.Threading.Thread.dll differ diff --git a/jackify/engine/System.Threading.ThreadPool.dll b/jackify/engine/System.Threading.ThreadPool.dll index 2cc6f1f..cf5057e 100755 Binary files a/jackify/engine/System.Threading.ThreadPool.dll and b/jackify/engine/System.Threading.ThreadPool.dll differ diff --git a/jackify/engine/System.Threading.Timer.dll b/jackify/engine/System.Threading.Timer.dll index b51fed7..ef64917 100755 Binary files a/jackify/engine/System.Threading.Timer.dll and b/jackify/engine/System.Threading.Timer.dll differ diff --git a/jackify/engine/System.Threading.dll b/jackify/engine/System.Threading.dll index 90906d3..c35edd7 100755 Binary files a/jackify/engine/System.Threading.dll and b/jackify/engine/System.Threading.dll differ diff --git a/jackify/engine/System.Transactions.Local.dll b/jackify/engine/System.Transactions.Local.dll index e327994..c5eea43 100755 Binary files a/jackify/engine/System.Transactions.Local.dll and b/jackify/engine/System.Transactions.Local.dll differ diff --git a/jackify/engine/System.Transactions.dll b/jackify/engine/System.Transactions.dll index 4cc4a53..57beddd 100755 Binary files a/jackify/engine/System.Transactions.dll and b/jackify/engine/System.Transactions.dll differ diff --git a/jackify/engine/System.ValueTuple.dll b/jackify/engine/System.ValueTuple.dll index 142e502..d2cb755 100755 Binary files a/jackify/engine/System.ValueTuple.dll and b/jackify/engine/System.ValueTuple.dll differ diff --git a/jackify/engine/System.Web.HttpUtility.dll b/jackify/engine/System.Web.HttpUtility.dll index c3c3865..56bd4be 100755 Binary files a/jackify/engine/System.Web.HttpUtility.dll and b/jackify/engine/System.Web.HttpUtility.dll differ diff --git a/jackify/engine/System.Web.dll b/jackify/engine/System.Web.dll index d715d70..8bcc8bc 100755 Binary files a/jackify/engine/System.Web.dll and b/jackify/engine/System.Web.dll differ diff --git a/jackify/engine/System.Windows.dll b/jackify/engine/System.Windows.dll index 506fb1b..dc1a63e 100755 Binary files a/jackify/engine/System.Windows.dll and b/jackify/engine/System.Windows.dll differ diff --git a/jackify/engine/System.Xml.Linq.dll b/jackify/engine/System.Xml.Linq.dll index 479b687..6749f1e 100755 Binary files a/jackify/engine/System.Xml.Linq.dll and b/jackify/engine/System.Xml.Linq.dll differ diff --git a/jackify/engine/System.Xml.ReaderWriter.dll b/jackify/engine/System.Xml.ReaderWriter.dll index 3cc2e7e..f6a4af2 100755 Binary files a/jackify/engine/System.Xml.ReaderWriter.dll and b/jackify/engine/System.Xml.ReaderWriter.dll differ diff --git a/jackify/engine/System.Xml.Serialization.dll b/jackify/engine/System.Xml.Serialization.dll index 614f897..d68bc99 100755 Binary files a/jackify/engine/System.Xml.Serialization.dll and b/jackify/engine/System.Xml.Serialization.dll differ diff --git a/jackify/engine/System.Xml.XDocument.dll b/jackify/engine/System.Xml.XDocument.dll index d45ad65..3726ef4 100755 Binary files a/jackify/engine/System.Xml.XDocument.dll and b/jackify/engine/System.Xml.XDocument.dll differ diff --git a/jackify/engine/System.Xml.XPath.XDocument.dll b/jackify/engine/System.Xml.XPath.XDocument.dll index 95f6388..730c037 100755 Binary files a/jackify/engine/System.Xml.XPath.XDocument.dll and b/jackify/engine/System.Xml.XPath.XDocument.dll differ diff --git a/jackify/engine/System.Xml.XPath.dll b/jackify/engine/System.Xml.XPath.dll index 5ca76ed..1bbe2ea 100755 Binary files a/jackify/engine/System.Xml.XPath.dll and b/jackify/engine/System.Xml.XPath.dll differ diff --git a/jackify/engine/System.Xml.XmlDocument.dll b/jackify/engine/System.Xml.XmlDocument.dll index 6c936bd..0c44f17 100755 Binary files a/jackify/engine/System.Xml.XmlDocument.dll and b/jackify/engine/System.Xml.XmlDocument.dll differ diff --git a/jackify/engine/System.Xml.XmlSerializer.dll b/jackify/engine/System.Xml.XmlSerializer.dll index 1bb884f..56c6d65 100755 Binary files a/jackify/engine/System.Xml.XmlSerializer.dll and b/jackify/engine/System.Xml.XmlSerializer.dll differ diff --git a/jackify/engine/System.Xml.dll b/jackify/engine/System.Xml.dll index fd2edd7..cb666c8 100755 Binary files a/jackify/engine/System.Xml.dll and b/jackify/engine/System.Xml.dll differ diff --git a/jackify/engine/System.dll b/jackify/engine/System.dll index 7e816d8..534f7ea 100755 Binary files a/jackify/engine/System.dll and b/jackify/engine/System.dll differ diff --git a/jackify/engine/Wabbajack.CLI.Builder.dll b/jackify/engine/Wabbajack.CLI.Builder.dll index 4539bd1..4594648 100644 Binary files a/jackify/engine/Wabbajack.CLI.Builder.dll and b/jackify/engine/Wabbajack.CLI.Builder.dll differ diff --git a/jackify/engine/Wabbajack.Common.dll b/jackify/engine/Wabbajack.Common.dll index 78171da..6bd9eb9 100644 Binary files a/jackify/engine/Wabbajack.Common.dll and b/jackify/engine/Wabbajack.Common.dll differ diff --git a/jackify/engine/Wabbajack.Compiler.dll b/jackify/engine/Wabbajack.Compiler.dll index 77ea24a..6931ba8 100644 Binary files a/jackify/engine/Wabbajack.Compiler.dll and b/jackify/engine/Wabbajack.Compiler.dll differ diff --git a/jackify/engine/Wabbajack.Compression.BSA.dll b/jackify/engine/Wabbajack.Compression.BSA.dll index fdc373f..387a203 100644 Binary files a/jackify/engine/Wabbajack.Compression.BSA.dll and b/jackify/engine/Wabbajack.Compression.BSA.dll differ diff --git a/jackify/engine/Wabbajack.Compression.Zip.dll b/jackify/engine/Wabbajack.Compression.Zip.dll index 077e239..cf5f03d 100644 Binary files a/jackify/engine/Wabbajack.Compression.Zip.dll and b/jackify/engine/Wabbajack.Compression.Zip.dll differ diff --git a/jackify/engine/Wabbajack.Configuration.dll b/jackify/engine/Wabbajack.Configuration.dll index 4d0baf7..005de8d 100644 Binary files a/jackify/engine/Wabbajack.Configuration.dll and b/jackify/engine/Wabbajack.Configuration.dll differ diff --git a/jackify/engine/Wabbajack.DTOs.dll b/jackify/engine/Wabbajack.DTOs.dll index cce4cb7..9c5581f 100644 Binary files a/jackify/engine/Wabbajack.DTOs.dll and b/jackify/engine/Wabbajack.DTOs.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll index fd0962f..e926c84 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll and b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll index 164bb69..f7a38c0 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll and b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.GameFile.dll b/jackify/engine/Wabbajack.Downloaders.GameFile.dll index 6fb14b4..a54cf0f 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.GameFile.dll and b/jackify/engine/Wabbajack.Downloaders.GameFile.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll index 770953c..95d808b 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll and b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Http.dll b/jackify/engine/Wabbajack.Downloaders.Http.dll index 349d912..61b33cb 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Http.dll and b/jackify/engine/Wabbajack.Downloaders.Http.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll index be3779b..40cad4e 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll and b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll index 8f9fa31..29bebb9 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll and b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Manual.dll b/jackify/engine/Wabbajack.Downloaders.Manual.dll index c4a0495..a55ff84 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Manual.dll and b/jackify/engine/Wabbajack.Downloaders.Manual.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll index bea33d1..f57b1ee 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll and b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Mega.dll b/jackify/engine/Wabbajack.Downloaders.Mega.dll index 718cb05..4a10116 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Mega.dll and b/jackify/engine/Wabbajack.Downloaders.Mega.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.ModDB.dll b/jackify/engine/Wabbajack.Downloaders.ModDB.dll index 8c63bfa..51609f6 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.ModDB.dll and b/jackify/engine/Wabbajack.Downloaders.ModDB.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Nexus.dll b/jackify/engine/Wabbajack.Downloaders.Nexus.dll index aed0518..0cc552f 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Nexus.dll and b/jackify/engine/Wabbajack.Downloaders.Nexus.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll index e878578..0ff8b8a 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll and b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll index 0ee0ace..2368fa0 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll and b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll differ diff --git a/jackify/engine/Wabbajack.FileExtractor.dll b/jackify/engine/Wabbajack.FileExtractor.dll index d9eacb8..e825b62 100644 Binary files a/jackify/engine/Wabbajack.FileExtractor.dll and b/jackify/engine/Wabbajack.FileExtractor.dll differ diff --git a/jackify/engine/Wabbajack.Hashing.PHash.dll b/jackify/engine/Wabbajack.Hashing.PHash.dll index c4c4ace..bf18fec 100644 Binary files a/jackify/engine/Wabbajack.Hashing.PHash.dll and b/jackify/engine/Wabbajack.Hashing.PHash.dll differ diff --git a/jackify/engine/Wabbajack.Hashing.xxHash64.dll b/jackify/engine/Wabbajack.Hashing.xxHash64.dll index b91a480..20e6c5b 100644 Binary files a/jackify/engine/Wabbajack.Hashing.xxHash64.dll and b/jackify/engine/Wabbajack.Hashing.xxHash64.dll differ diff --git a/jackify/engine/Wabbajack.IO.Async.dll b/jackify/engine/Wabbajack.IO.Async.dll index e95a72a..c38169a 100644 Binary files a/jackify/engine/Wabbajack.IO.Async.dll and b/jackify/engine/Wabbajack.IO.Async.dll differ diff --git a/jackify/engine/Wabbajack.Installer.dll b/jackify/engine/Wabbajack.Installer.dll index 34e1009..fee334c 100644 Binary files a/jackify/engine/Wabbajack.Installer.dll and b/jackify/engine/Wabbajack.Installer.dll differ diff --git a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll index fd3a72b..4783fda 100644 Binary files a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll and b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Discord.dll b/jackify/engine/Wabbajack.Networking.Discord.dll index 987e085..c6b12f4 100644 Binary files a/jackify/engine/Wabbajack.Networking.Discord.dll and b/jackify/engine/Wabbajack.Networking.Discord.dll differ diff --git a/jackify/engine/Wabbajack.Networking.GitHub.dll b/jackify/engine/Wabbajack.Networking.GitHub.dll index 0137437..ca45db9 100644 Binary files a/jackify/engine/Wabbajack.Networking.GitHub.dll and b/jackify/engine/Wabbajack.Networking.GitHub.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll index d0da755..1573515 100644 Binary files a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll and b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Http.dll b/jackify/engine/Wabbajack.Networking.Http.dll index 93526b9..2b4783a 100644 Binary files a/jackify/engine/Wabbajack.Networking.Http.dll and b/jackify/engine/Wabbajack.Networking.Http.dll differ diff --git a/jackify/engine/Wabbajack.Networking.NexusApi.dll b/jackify/engine/Wabbajack.Networking.NexusApi.dll index 970ee47..8435fc0 100644 Binary files a/jackify/engine/Wabbajack.Networking.NexusApi.dll and b/jackify/engine/Wabbajack.Networking.NexusApi.dll differ diff --git a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll index a6e6021..8ed123b 100644 Binary files a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll and b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll differ diff --git a/jackify/engine/Wabbajack.Paths.IO.dll b/jackify/engine/Wabbajack.Paths.IO.dll index e5d66e3..e15b759 100644 Binary files a/jackify/engine/Wabbajack.Paths.IO.dll and b/jackify/engine/Wabbajack.Paths.IO.dll differ diff --git a/jackify/engine/Wabbajack.Paths.dll b/jackify/engine/Wabbajack.Paths.dll index 37545c1..ffb9646 100644 Binary files a/jackify/engine/Wabbajack.Paths.dll and b/jackify/engine/Wabbajack.Paths.dll differ diff --git a/jackify/engine/Wabbajack.RateLimiter.dll b/jackify/engine/Wabbajack.RateLimiter.dll index 04fecd2..32a3cd9 100644 Binary files a/jackify/engine/Wabbajack.RateLimiter.dll and b/jackify/engine/Wabbajack.RateLimiter.dll differ diff --git a/jackify/engine/Wabbajack.Server.Lib.dll b/jackify/engine/Wabbajack.Server.Lib.dll index 97a7a93..9955c9e 100644 Binary files a/jackify/engine/Wabbajack.Server.Lib.dll and b/jackify/engine/Wabbajack.Server.Lib.dll differ diff --git a/jackify/engine/Wabbajack.Services.OSIntegrated.dll b/jackify/engine/Wabbajack.Services.OSIntegrated.dll index 4f70842..ebffed5 100644 Binary files a/jackify/engine/Wabbajack.Services.OSIntegrated.dll and b/jackify/engine/Wabbajack.Services.OSIntegrated.dll differ diff --git a/jackify/engine/Wabbajack.VFS.Interfaces.dll b/jackify/engine/Wabbajack.VFS.Interfaces.dll index d4a3fad..327e2bd 100644 Binary files a/jackify/engine/Wabbajack.VFS.Interfaces.dll and b/jackify/engine/Wabbajack.VFS.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.VFS.dll b/jackify/engine/Wabbajack.VFS.dll index 9d54bed..9d6a36f 100644 Binary files a/jackify/engine/Wabbajack.VFS.dll and b/jackify/engine/Wabbajack.VFS.dll differ diff --git a/jackify/engine/WindowsBase.dll b/jackify/engine/WindowsBase.dll index 808fe04..20d79da 100755 Binary files a/jackify/engine/WindowsBase.dll and b/jackify/engine/WindowsBase.dll differ diff --git a/jackify/engine/createdump b/jackify/engine/createdump index 68b031c..2d8e0cd 100755 Binary files a/jackify/engine/createdump and b/jackify/engine/createdump differ diff --git a/jackify/engine/jackify-engine b/jackify/engine/jackify-engine index c6d3343..07facb9 100755 Binary files a/jackify/engine/jackify-engine and b/jackify/engine/jackify-engine differ diff --git a/jackify/engine/jackify-engine.deps.json b/jackify/engine/jackify-engine.deps.json index 2f42057..812caa4 100644 --- a/jackify/engine/jackify-engine.deps.json +++ b/jackify/engine/jackify-engine.deps.json @@ -7,7 +7,7 @@ "targets": { ".NETCoreApp,Version=v8.0": {}, ".NETCoreApp,Version=v8.0/linux-x64": { - "jackify-engine/0.4.7": { + "jackify-engine/0.4.8": { "dependencies": { "Markdig": "0.40.0", "Microsoft.Extensions.Configuration.Json": "9.0.1", @@ -22,684 +22,684 @@ "SixLabors.ImageSharp": "3.1.6", "System.CommandLine": "2.0.0-beta4.22272.1", "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", - "Wabbajack.CLI.Builder": "0.4.7", - "Wabbajack.Downloaders.Bethesda": "0.4.7", - "Wabbajack.Downloaders.Dispatcher": "0.4.7", - "Wabbajack.Hashing.xxHash64": "0.4.7", - "Wabbajack.Networking.Discord": "0.4.7", - "Wabbajack.Networking.GitHub": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7", - "Wabbajack.Server.Lib": "0.4.7", - "Wabbajack.Services.OSIntegrated": "0.4.7", - "Wabbajack.VFS": "0.4.7", + "Wabbajack.CLI.Builder": "0.4.8", + "Wabbajack.Downloaders.Bethesda": "0.4.8", + "Wabbajack.Downloaders.Dispatcher": "0.4.8", + "Wabbajack.Hashing.xxHash64": "0.4.8", + "Wabbajack.Networking.Discord": "0.4.8", + "Wabbajack.Networking.GitHub": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8", + "Wabbajack.Server.Lib": "0.4.8", + "Wabbajack.Services.OSIntegrated": "0.4.8", + "Wabbajack.VFS": "0.4.8", "MegaApiClient": "1.0.0.0", - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.22" + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.23" }, "runtime": { "jackify-engine.dll": {} } }, - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.22": { + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.23": { "runtime": { "Microsoft.CSharp.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "Microsoft.VisualBasic.Core.dll": { "assemblyVersion": "13.0.0.0", - "fileVersion": "13.0.2225.52707" + "fileVersion": "13.0.2325.60607" }, "Microsoft.VisualBasic.dll": { "assemblyVersion": "10.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "Microsoft.Win32.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "Microsoft.Win32.Registry.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.AppContext.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Buffers.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Collections.Concurrent.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Collections.Immutable.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Collections.NonGeneric.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Collections.Specialized.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Collections.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ComponentModel.Annotations.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ComponentModel.DataAnnotations.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ComponentModel.EventBasedAsync.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ComponentModel.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ComponentModel.TypeConverter.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ComponentModel.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Configuration.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Console.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Core.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Data.Common.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Data.DataSetExtensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Data.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.Contracts.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.Debug.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.FileVersionInfo.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.Process.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.StackTrace.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.TextWriterTraceListener.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.Tools.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.TraceSource.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Diagnostics.Tracing.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Drawing.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Drawing.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Dynamic.Runtime.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Formats.Asn1.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Formats.Tar.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Globalization.Calendars.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Globalization.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Globalization.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.Compression.Brotli.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.Compression.FileSystem.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.Compression.ZipFile.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.Compression.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.FileSystem.AccessControl.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.FileSystem.DriveInfo.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.FileSystem.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.FileSystem.Watcher.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.FileSystem.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.IsolatedStorage.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.MemoryMappedFiles.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.Pipes.AccessControl.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.Pipes.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.UnmanagedMemoryStream.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.IO.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Linq.Expressions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Linq.Parallel.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Linq.Queryable.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Linq.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Memory.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Http.Json.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Http.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.HttpListener.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Mail.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.NameResolution.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.NetworkInformation.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Ping.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Quic.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Requests.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Security.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.ServicePoint.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.Sockets.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.WebClient.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.WebHeaderCollection.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.WebProxy.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.WebSockets.Client.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.WebSockets.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Net.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Numerics.Vectors.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Numerics.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ObjectModel.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Private.CoreLib.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Private.DataContractSerialization.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Private.Uri.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Private.Xml.Linq.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Private.Xml.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.DispatchProxy.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.Emit.ILGeneration.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.Emit.Lightweight.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.Emit.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.Metadata.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.TypeExtensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Reflection.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Resources.Reader.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Resources.ResourceManager.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Resources.Writer.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.CompilerServices.Unsafe.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.CompilerServices.VisualC.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Handles.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.InteropServices.JavaScript.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.InteropServices.RuntimeInformation.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.InteropServices.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Intrinsics.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Loader.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Numerics.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Serialization.Formatters.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Serialization.Json.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Serialization.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Serialization.Xml.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.Serialization.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Runtime.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.AccessControl.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Claims.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Cryptography.Algorithms.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Cryptography.Cng.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Cryptography.Csp.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Cryptography.Encoding.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Cryptography.OpenSsl.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Cryptography.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Cryptography.X509Certificates.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Cryptography.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Principal.Windows.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.Principal.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.SecureString.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Security.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ServiceModel.Web.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ServiceProcess.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Text.Encoding.CodePages.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Text.Encoding.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Text.Encoding.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Text.RegularExpressions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.Channels.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.Overlapped.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.Tasks.Dataflow.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.Tasks.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.Tasks.Parallel.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.Tasks.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.Thread.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.ThreadPool.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.Timer.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Threading.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Transactions.Local.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Transactions.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.ValueTuple.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Web.HttpUtility.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Web.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Windows.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.Linq.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.ReaderWriter.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.Serialization.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.XDocument.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.XPath.XDocument.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.XPath.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.XmlDocument.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.XmlSerializer.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.Xml.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "System.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "WindowsBase.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "mscorlib.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" }, "netstandard.dll": { "assemblyVersion": "2.1.0.0", - "fileVersion": "8.0.2225.52707" + "fileVersion": "8.0.2325.60607" } }, "native": { @@ -1781,7 +1781,7 @@ } } }, - "Wabbajack.CLI.Builder/0.4.7": { + "Wabbajack.CLI.Builder/0.4.8": { "dependencies": { "Microsoft.Extensions.Configuration.Json": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1", @@ -1791,109 +1791,109 @@ "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "System.CommandLine": "2.0.0-beta4.22272.1", "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", - "Wabbajack.Paths": "0.4.7" + "Wabbajack.Paths": "0.4.8" }, "runtime": { "Wabbajack.CLI.Builder.dll": {} } }, - "Wabbajack.Common/0.4.7": { + "Wabbajack.Common/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "System.Reactive": "6.0.1", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Networking.Http": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7" + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Networking.Http": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8" }, "runtime": { "Wabbajack.Common.dll": {} } }, - "Wabbajack.Compiler/0.4.7": { + "Wabbajack.Compiler/0.4.8": { "dependencies": { "F23.StringSimilarity": "6.0.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Dispatcher": "0.4.7", - "Wabbajack.Installer": "0.4.7", - "Wabbajack.VFS": "0.4.7", + "Wabbajack.Downloaders.Dispatcher": "0.4.8", + "Wabbajack.Installer": "0.4.8", + "Wabbajack.VFS": "0.4.8", "ini-parser-netstandard": "2.5.2" }, "runtime": { "Wabbajack.Compiler.dll": {} } }, - "Wabbajack.Compression.BSA/0.4.7": { + "Wabbajack.Compression.BSA/0.4.8": { "dependencies": { "K4os.Compression.LZ4.Streams": "1.3.8", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "SharpZipLib": "1.4.2", - "Wabbajack.Common": "0.4.7", - "Wabbajack.DTOs": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.DTOs": "0.4.8" }, "runtime": { "Wabbajack.Compression.BSA.dll": {} } }, - "Wabbajack.Compression.Zip/0.4.7": { + "Wabbajack.Compression.Zip/0.4.8": { "dependencies": { - "Wabbajack.IO.Async": "0.4.7" + "Wabbajack.IO.Async": "0.4.8" }, "runtime": { "Wabbajack.Compression.Zip.dll": {} } }, - "Wabbajack.Configuration/0.4.7": { + "Wabbajack.Configuration/0.4.8": { "runtime": { "Wabbajack.Configuration.dll": {} } }, - "Wabbajack.Downloaders.Bethesda/0.4.7": { + "Wabbajack.Downloaders.Bethesda/0.4.8": { "dependencies": { "LibAES-CTR": "1.1.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "SharpZipLib": "1.4.2", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Networking.BethesdaNet": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Networking.BethesdaNet": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.Bethesda.dll": {} } }, - "Wabbajack.Downloaders.Dispatcher/0.4.7": { + "Wabbajack.Downloaders.Dispatcher/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Bethesda": "0.4.7", - "Wabbajack.Downloaders.GameFile": "0.4.7", - "Wabbajack.Downloaders.GoogleDrive": "0.4.7", - "Wabbajack.Downloaders.Http": "0.4.7", - "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Downloaders.Manual": "0.4.7", - "Wabbajack.Downloaders.MediaFire": "0.4.7", - "Wabbajack.Downloaders.Mega": "0.4.7", - "Wabbajack.Downloaders.ModDB": "0.4.7", - "Wabbajack.Downloaders.Nexus": "0.4.7", - "Wabbajack.Downloaders.VerificationCache": "0.4.7", - "Wabbajack.Downloaders.WabbajackCDN": "0.4.7", - "Wabbajack.Networking.WabbajackClientApi": "0.4.7" + "Wabbajack.Downloaders.Bethesda": "0.4.8", + "Wabbajack.Downloaders.GameFile": "0.4.8", + "Wabbajack.Downloaders.GoogleDrive": "0.4.8", + "Wabbajack.Downloaders.Http": "0.4.8", + "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Downloaders.Manual": "0.4.8", + "Wabbajack.Downloaders.MediaFire": "0.4.8", + "Wabbajack.Downloaders.Mega": "0.4.8", + "Wabbajack.Downloaders.ModDB": "0.4.8", + "Wabbajack.Downloaders.Nexus": "0.4.8", + "Wabbajack.Downloaders.VerificationCache": "0.4.8", + "Wabbajack.Downloaders.WabbajackCDN": "0.4.8", + "Wabbajack.Networking.WabbajackClientApi": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.Dispatcher.dll": {} } }, - "Wabbajack.Downloaders.GameFile/0.4.7": { + "Wabbajack.Downloaders.GameFile/0.4.8": { "dependencies": { "GameFinder.StoreHandlers.EADesktop": "4.5.0", "GameFinder.StoreHandlers.EGS": "4.5.0", @@ -1903,360 +1903,360 @@ "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.VFS": "0.4.7" + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.VFS": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.GameFile.dll": {} } }, - "Wabbajack.Downloaders.GoogleDrive/0.4.7": { + "Wabbajack.Downloaders.GoogleDrive/0.4.8": { "dependencies": { "HtmlAgilityPack": "1.11.72", "Microsoft.AspNetCore.Http.Extensions": "2.3.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.4.7", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Networking.Http": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Networking.Http": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.GoogleDrive.dll": {} } }, - "Wabbajack.Downloaders.Http/0.4.7": { + "Wabbajack.Downloaders.Http/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.4.7", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Networking.BethesdaNet": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Networking.BethesdaNet": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.Http.dll": {} } }, - "Wabbajack.Downloaders.Interfaces/0.4.7": { + "Wabbajack.Downloaders.Interfaces/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Compression.Zip": "0.4.7", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7" + "Wabbajack.Compression.Zip": "0.4.8", + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.Interfaces.dll": {} } }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.7": { + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.8": { "dependencies": { "F23.StringSimilarity": "6.0.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Networking.Http": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Networking.Http": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {} } }, - "Wabbajack.Downloaders.Manual/0.4.7": { + "Wabbajack.Downloaders.Manual/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.Manual.dll": {} } }, - "Wabbajack.Downloaders.MediaFire/0.4.7": { + "Wabbajack.Downloaders.MediaFire/0.4.8": { "dependencies": { "HtmlAgilityPack": "1.11.72", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.MediaFire.dll": {} } }, - "Wabbajack.Downloaders.Mega/0.4.7": { + "Wabbajack.Downloaders.Mega/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.Mega.dll": {} } }, - "Wabbajack.Downloaders.ModDB/0.4.7": { + "Wabbajack.Downloaders.ModDB/0.4.8": { "dependencies": { "HtmlAgilityPack": "1.11.72", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Networking.Http": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Networking.Http": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.ModDB.dll": {} } }, - "Wabbajack.Downloaders.Nexus/0.4.7": { + "Wabbajack.Downloaders.Nexus/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Hashing.xxHash64": "0.4.7", - "Wabbajack.Networking.Http": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7", - "Wabbajack.Networking.NexusApi": "0.4.7", - "Wabbajack.Paths": "0.4.7" + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Hashing.xxHash64": "0.4.8", + "Wabbajack.Networking.Http": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8", + "Wabbajack.Networking.NexusApi": "0.4.8", + "Wabbajack.Paths": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.Nexus.dll": {} } }, - "Wabbajack.Downloaders.VerificationCache/0.4.7": { + "Wabbajack.Downloaders.VerificationCache/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Stub.System.Data.SQLite.Core.NetStandard": "1.0.119", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7" + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.VerificationCache.dll": {} } }, - "Wabbajack.Downloaders.WabbajackCDN/0.4.7": { + "Wabbajack.Downloaders.WabbajackCDN/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Toolkit.HighPerformance": "7.1.2", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Networking.Http": "0.4.7", - "Wabbajack.RateLimiter": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Networking.Http": "0.4.8", + "Wabbajack.RateLimiter": "0.4.8" }, "runtime": { "Wabbajack.Downloaders.WabbajackCDN.dll": {} } }, - "Wabbajack.DTOs/0.4.7": { + "Wabbajack.DTOs/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Hashing.xxHash64": "0.4.7", - "Wabbajack.Paths": "0.4.7" + "Wabbajack.Hashing.xxHash64": "0.4.8", + "Wabbajack.Paths": "0.4.8" }, "runtime": { "Wabbajack.DTOs.dll": {} } }, - "Wabbajack.FileExtractor/0.4.7": { + "Wabbajack.FileExtractor/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "OMODFramework": "3.0.1", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Compression.BSA": "0.4.7", - "Wabbajack.Hashing.PHash": "0.4.7", - "Wabbajack.Paths": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Compression.BSA": "0.4.8", + "Wabbajack.Hashing.PHash": "0.4.8", + "Wabbajack.Paths": "0.4.8" }, "runtime": { "Wabbajack.FileExtractor.dll": {} } }, - "Wabbajack.Hashing.PHash/0.4.7": { + "Wabbajack.Hashing.PHash/0.4.8": { "dependencies": { "BCnEncoder.Net.ImageSharp": "1.1.1", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Shipwreck.Phash": "0.5.0", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Common": "0.4.7", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Paths": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Paths": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8" }, "runtime": { "Wabbajack.Hashing.PHash.dll": {} } }, - "Wabbajack.Hashing.xxHash64/0.4.7": { + "Wabbajack.Hashing.xxHash64/0.4.8": { "dependencies": { - "Wabbajack.Paths": "0.4.7", - "Wabbajack.RateLimiter": "0.4.7" + "Wabbajack.Paths": "0.4.8", + "Wabbajack.RateLimiter": "0.4.8" }, "runtime": { "Wabbajack.Hashing.xxHash64.dll": {} } }, - "Wabbajack.Installer/0.4.7": { + "Wabbajack.Installer/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", "Octopus.Octodiff": "2.0.548", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Downloaders.Dispatcher": "0.4.7", - "Wabbajack.Downloaders.GameFile": "0.4.7", - "Wabbajack.FileExtractor": "0.4.7", - "Wabbajack.Networking.WabbajackClientApi": "0.4.7", - "Wabbajack.Paths": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7", - "Wabbajack.VFS": "0.4.7", + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Downloaders.Dispatcher": "0.4.8", + "Wabbajack.Downloaders.GameFile": "0.4.8", + "Wabbajack.FileExtractor": "0.4.8", + "Wabbajack.Networking.WabbajackClientApi": "0.4.8", + "Wabbajack.Paths": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8", + "Wabbajack.VFS": "0.4.8", "ini-parser-netstandard": "2.5.2" }, "runtime": { "Wabbajack.Installer.dll": {} } }, - "Wabbajack.IO.Async/0.4.7": { + "Wabbajack.IO.Async/0.4.8": { "runtime": { "Wabbajack.IO.Async.dll": {} } }, - "Wabbajack.Networking.BethesdaNet/0.4.7": { + "Wabbajack.Networking.BethesdaNet/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Networking.Http": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7" + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Networking.Http": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.Networking.BethesdaNet.dll": {} } }, - "Wabbajack.Networking.Discord/0.4.7": { + "Wabbajack.Networking.Discord/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Networking.Http.Interfaces": "0.4.7" + "Wabbajack.Networking.Http.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.Networking.Discord.dll": {} } }, - "Wabbajack.Networking.GitHub/0.4.7": { + "Wabbajack.Networking.GitHub/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Octokit": "14.0.0", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7" + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.Networking.GitHub.dll": {} } }, - "Wabbajack.Networking.Http/0.4.7": { + "Wabbajack.Networking.Http/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Http": "9.0.1", "Microsoft.Extensions.Logging": "9.0.1", - "Wabbajack.Configuration": "0.4.7", - "Wabbajack.Downloaders.Interfaces": "0.4.7", - "Wabbajack.Hashing.xxHash64": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7", - "Wabbajack.Paths": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7" + "Wabbajack.Configuration": "0.4.8", + "Wabbajack.Downloaders.Interfaces": "0.4.8", + "Wabbajack.Hashing.xxHash64": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8", + "Wabbajack.Paths": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8" }, "runtime": { "Wabbajack.Networking.Http.dll": {} } }, - "Wabbajack.Networking.Http.Interfaces/0.4.7": { + "Wabbajack.Networking.Http.Interfaces/0.4.8": { "dependencies": { - "Wabbajack.Hashing.xxHash64": "0.4.7" + "Wabbajack.Hashing.xxHash64": "0.4.8" }, "runtime": { "Wabbajack.Networking.Http.Interfaces.dll": {} } }, - "Wabbajack.Networking.NexusApi/0.4.7": { + "Wabbajack.Networking.NexusApi/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Networking.Http": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7", - "Wabbajack.Networking.WabbajackClientApi": "0.4.7" + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Networking.Http": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8", + "Wabbajack.Networking.WabbajackClientApi": "0.4.8" }, "runtime": { "Wabbajack.Networking.NexusApi.dll": {} } }, - "Wabbajack.Networking.WabbajackClientApi/0.4.7": { + "Wabbajack.Networking.WabbajackClientApi/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Octokit": "14.0.0", - "Wabbajack.Common": "0.4.7", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7", - "Wabbajack.VFS.Interfaces": "0.4.7", + "Wabbajack.Common": "0.4.8", + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8", + "Wabbajack.VFS.Interfaces": "0.4.8", "YamlDotNet": "16.3.0" }, "runtime": { "Wabbajack.Networking.WabbajackClientApi.dll": {} } }, - "Wabbajack.Paths/0.4.7": { + "Wabbajack.Paths/0.4.8": { "runtime": { "Wabbajack.Paths.dll": {} } }, - "Wabbajack.Paths.IO/0.4.7": { + "Wabbajack.Paths.IO/0.4.8": { "dependencies": { - "Wabbajack.Paths": "0.4.7", + "Wabbajack.Paths": "0.4.8", "shortid": "4.0.0" }, "runtime": { "Wabbajack.Paths.IO.dll": {} } }, - "Wabbajack.RateLimiter/0.4.7": { + "Wabbajack.RateLimiter/0.4.8": { "runtime": { "Wabbajack.RateLimiter.dll": {} } }, - "Wabbajack.Server.Lib/0.4.7": { + "Wabbajack.Server.Lib/0.4.8": { "dependencies": { "FluentFTP": "52.0.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", @@ -2264,58 +2264,58 @@ "Nettle": "3.0.0", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Common": "0.4.7", - "Wabbajack.Networking.Http.Interfaces": "0.4.7", - "Wabbajack.Services.OSIntegrated": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.Networking.Http.Interfaces": "0.4.8", + "Wabbajack.Services.OSIntegrated": "0.4.8" }, "runtime": { "Wabbajack.Server.Lib.dll": {} } }, - "Wabbajack.Services.OSIntegrated/0.4.7": { + "Wabbajack.Services.OSIntegrated/0.4.8": { "dependencies": { "DeviceId": "6.8.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Compiler": "0.4.7", - "Wabbajack.Downloaders.Dispatcher": "0.4.7", - "Wabbajack.Installer": "0.4.7", - "Wabbajack.Networking.BethesdaNet": "0.4.7", - "Wabbajack.Networking.Discord": "0.4.7", - "Wabbajack.VFS": "0.4.7" + "Wabbajack.Compiler": "0.4.8", + "Wabbajack.Downloaders.Dispatcher": "0.4.8", + "Wabbajack.Installer": "0.4.8", + "Wabbajack.Networking.BethesdaNet": "0.4.8", + "Wabbajack.Networking.Discord": "0.4.8", + "Wabbajack.VFS": "0.4.8" }, "runtime": { "Wabbajack.Services.OSIntegrated.dll": {} } }, - "Wabbajack.VFS/0.4.7": { + "Wabbajack.VFS/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "SixLabors.ImageSharp": "3.1.6", "System.Data.SQLite.Core": "1.0.119", - "Wabbajack.Common": "0.4.7", - "Wabbajack.FileExtractor": "0.4.7", - "Wabbajack.Hashing.PHash": "0.4.7", - "Wabbajack.Hashing.xxHash64": "0.4.7", - "Wabbajack.Paths": "0.4.7", - "Wabbajack.Paths.IO": "0.4.7", - "Wabbajack.VFS.Interfaces": "0.4.7" + "Wabbajack.Common": "0.4.8", + "Wabbajack.FileExtractor": "0.4.8", + "Wabbajack.Hashing.PHash": "0.4.8", + "Wabbajack.Hashing.xxHash64": "0.4.8", + "Wabbajack.Paths": "0.4.8", + "Wabbajack.Paths.IO": "0.4.8", + "Wabbajack.VFS.Interfaces": "0.4.8" }, "runtime": { "Wabbajack.VFS.dll": {} } }, - "Wabbajack.VFS.Interfaces/0.4.7": { + "Wabbajack.VFS.Interfaces/0.4.8": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.4.7", - "Wabbajack.Hashing.xxHash64": "0.4.7", - "Wabbajack.Paths": "0.4.7" + "Wabbajack.DTOs": "0.4.8", + "Wabbajack.Hashing.xxHash64": "0.4.8", + "Wabbajack.Paths": "0.4.8" }, "runtime": { "Wabbajack.VFS.Interfaces.dll": {} @@ -2332,12 +2332,12 @@ } }, "libraries": { - "jackify-engine/0.4.7": { + "jackify-engine/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.22": { + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.23": { "type": "runtimepack", "serviceable": false, "sha512": "" @@ -3021,202 +3021,202 @@ "path": "yamldotnet/16.3.0", "hashPath": "yamldotnet.16.3.0.nupkg.sha512" }, - "Wabbajack.CLI.Builder/0.4.7": { + "Wabbajack.CLI.Builder/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Common/0.4.7": { + "Wabbajack.Common/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compiler/0.4.7": { + "Wabbajack.Compiler/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compression.BSA/0.4.7": { + "Wabbajack.Compression.BSA/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compression.Zip/0.4.7": { + "Wabbajack.Compression.Zip/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Configuration/0.4.7": { + "Wabbajack.Configuration/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Bethesda/0.4.7": { + "Wabbajack.Downloaders.Bethesda/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Dispatcher/0.4.7": { + "Wabbajack.Downloaders.Dispatcher/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.GameFile/0.4.7": { + "Wabbajack.Downloaders.GameFile/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.GoogleDrive/0.4.7": { + "Wabbajack.Downloaders.GoogleDrive/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Http/0.4.7": { + "Wabbajack.Downloaders.Http/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Interfaces/0.4.7": { + "Wabbajack.Downloaders.Interfaces/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.7": { + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Manual/0.4.7": { + "Wabbajack.Downloaders.Manual/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.MediaFire/0.4.7": { + "Wabbajack.Downloaders.MediaFire/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Mega/0.4.7": { + "Wabbajack.Downloaders.Mega/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.ModDB/0.4.7": { + "Wabbajack.Downloaders.ModDB/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Nexus/0.4.7": { + "Wabbajack.Downloaders.Nexus/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.VerificationCache/0.4.7": { + "Wabbajack.Downloaders.VerificationCache/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.WabbajackCDN/0.4.7": { + "Wabbajack.Downloaders.WabbajackCDN/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.DTOs/0.4.7": { + "Wabbajack.DTOs/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.FileExtractor/0.4.7": { + "Wabbajack.FileExtractor/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Hashing.PHash/0.4.7": { + "Wabbajack.Hashing.PHash/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Hashing.xxHash64/0.4.7": { + "Wabbajack.Hashing.xxHash64/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Installer/0.4.7": { + "Wabbajack.Installer/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.IO.Async/0.4.7": { + "Wabbajack.IO.Async/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.BethesdaNet/0.4.7": { + "Wabbajack.Networking.BethesdaNet/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Discord/0.4.7": { + "Wabbajack.Networking.Discord/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.GitHub/0.4.7": { + "Wabbajack.Networking.GitHub/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Http/0.4.7": { + "Wabbajack.Networking.Http/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Http.Interfaces/0.4.7": { + "Wabbajack.Networking.Http.Interfaces/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.NexusApi/0.4.7": { + "Wabbajack.Networking.NexusApi/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.WabbajackClientApi/0.4.7": { + "Wabbajack.Networking.WabbajackClientApi/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Paths/0.4.7": { + "Wabbajack.Paths/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Paths.IO/0.4.7": { + "Wabbajack.Paths.IO/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.RateLimiter/0.4.7": { + "Wabbajack.RateLimiter/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Server.Lib/0.4.7": { + "Wabbajack.Server.Lib/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Services.OSIntegrated/0.4.7": { + "Wabbajack.Services.OSIntegrated/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.VFS/0.4.7": { + "Wabbajack.VFS/0.4.8": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.VFS.Interfaces/0.4.7": { + "Wabbajack.VFS.Interfaces/0.4.8": { "type": "project", "serviceable": false, "sha512": "" diff --git a/jackify/engine/jackify-engine.dll b/jackify/engine/jackify-engine.dll index 88c460a..79bcbc8 100644 Binary files a/jackify/engine/jackify-engine.dll and b/jackify/engine/jackify-engine.dll differ diff --git a/jackify/engine/jackify-engine.runtimeconfig.json b/jackify/engine/jackify-engine.runtimeconfig.json index 1d609e2..df47417 100644 --- a/jackify/engine/jackify-engine.runtimeconfig.json +++ b/jackify/engine/jackify-engine.runtimeconfig.json @@ -4,7 +4,7 @@ "includedFrameworks": [ { "name": "Microsoft.NETCore.App", - "version": "8.0.22" + "version": "8.0.23" } ], "configProperties": { diff --git a/jackify/engine/libSystem.Globalization.Native.so b/jackify/engine/libSystem.Globalization.Native.so index 1ff8293..8f1bdc8 100755 Binary files a/jackify/engine/libSystem.Globalization.Native.so and b/jackify/engine/libSystem.Globalization.Native.so differ diff --git a/jackify/engine/libSystem.IO.Compression.Native.so b/jackify/engine/libSystem.IO.Compression.Native.so index 8a9c8e0..253e011 100755 Binary files a/jackify/engine/libSystem.IO.Compression.Native.so and b/jackify/engine/libSystem.IO.Compression.Native.so differ diff --git a/jackify/engine/libSystem.Native.so b/jackify/engine/libSystem.Native.so index 8b956e8..43b2dc8 100755 Binary files a/jackify/engine/libSystem.Native.so and b/jackify/engine/libSystem.Native.so differ diff --git a/jackify/engine/libSystem.Net.Security.Native.so b/jackify/engine/libSystem.Net.Security.Native.so index 3b3e7f2..5aec61d 100755 Binary files a/jackify/engine/libSystem.Net.Security.Native.so and b/jackify/engine/libSystem.Net.Security.Native.so differ diff --git a/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so b/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so index fbe9b7d..0665499 100755 Binary files a/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so and b/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so differ diff --git a/jackify/engine/libclrgc.so b/jackify/engine/libclrgc.so index bdfcfb1..23182a7 100755 Binary files a/jackify/engine/libclrgc.so and b/jackify/engine/libclrgc.so differ diff --git a/jackify/engine/libclrjit.so b/jackify/engine/libclrjit.so index 31a0633..0a13c82 100755 Binary files a/jackify/engine/libclrjit.so and b/jackify/engine/libclrjit.so differ diff --git a/jackify/engine/libcoreclr.so b/jackify/engine/libcoreclr.so index 5605bcb..9dc207d 100755 Binary files a/jackify/engine/libcoreclr.so and b/jackify/engine/libcoreclr.so differ diff --git a/jackify/engine/libcoreclrtraceptprovider.so b/jackify/engine/libcoreclrtraceptprovider.so index 974798c..c59e60b 100755 Binary files a/jackify/engine/libcoreclrtraceptprovider.so and b/jackify/engine/libcoreclrtraceptprovider.so differ diff --git a/jackify/engine/libhostfxr.so b/jackify/engine/libhostfxr.so index 3a68339..f7f0462 100755 Binary files a/jackify/engine/libhostfxr.so and b/jackify/engine/libhostfxr.so differ diff --git a/jackify/engine/libhostpolicy.so b/jackify/engine/libhostpolicy.so index 0507092..1def8e9 100755 Binary files a/jackify/engine/libhostpolicy.so and b/jackify/engine/libhostpolicy.so differ diff --git a/jackify/engine/libmscordaccore.so b/jackify/engine/libmscordaccore.so index 6e180fd..521899e 100755 Binary files a/jackify/engine/libmscordaccore.so and b/jackify/engine/libmscordaccore.so differ diff --git a/jackify/engine/libmscordbi.so b/jackify/engine/libmscordbi.so index 42b1e97..5bc5589 100755 Binary files a/jackify/engine/libmscordbi.so and b/jackify/engine/libmscordbi.so differ diff --git a/jackify/engine/mscorlib.dll b/jackify/engine/mscorlib.dll index d3d9f5a..4e9d2fb 100755 Binary files a/jackify/engine/mscorlib.dll and b/jackify/engine/mscorlib.dll differ diff --git a/jackify/engine/netstandard.dll b/jackify/engine/netstandard.dll index 55613c6..04297a1 100755 Binary files a/jackify/engine/netstandard.dll and b/jackify/engine/netstandard.dll differ diff --git a/jackify/frontends/cli/commands/configure_modlist.py b/jackify/frontends/cli/commands/configure_modlist.py index 2329910..7b0eddc 100644 --- a/jackify/frontends/cli/commands/configure_modlist.py +++ b/jackify/frontends/cli/commands/configure_modlist.py @@ -91,12 +91,17 @@ class ConfigureModlistCommand: try: # Build configuration context from args context = self._build_context_from_args(args) - + # Use legacy implementation for now - will migrate to backend services later result = self._execute_legacy_configuration(context) - + logger.info("Finished non-interactive modlist configuration") - return 0 if result is not True else 1 + + if not getattr(args, 'skip_confirmation', False) and context.get('install_dir'): + from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible + prompt_ttw_if_eligible(context['install_dir'], context.get('modlist_name') or '') + + return 0 if result is True else 1 except Exception as e: logger.error(f"Failed to configure modlist: {e}") @@ -156,4 +161,4 @@ class ConfigureModlistCommand: # The _configure_new_modlist method already calls run_modlist_configuration_phase internally # So we don't need to call it again here - return result \ No newline at end of file + return result diff --git a/jackify/frontends/cli/commands/setup_mo2.py b/jackify/frontends/cli/commands/setup_mo2.py new file mode 100644 index 0000000..846fcca --- /dev/null +++ b/jackify/frontends/cli/commands/setup_mo2.py @@ -0,0 +1,89 @@ +""" +Setup Mod Organizer 2 Command + +CLI interface for downloading and configuring a standalone MO2 instance. +""" + +import logging +from pathlib import Path + +from jackify.backend.services.mo2_setup_service import MO2SetupService, _is_dangerous_path +from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SUCCESS, COLOR_ERROR + +logger = logging.getLogger(__name__) + + +class SetupMO2Command: + """CLI command for standalone MO2 setup""" + + def run(self): + """Execute the MO2 setup workflow""" + print(f"\n{COLOR_INFO}=== Setup Mod Organizer 2 ==={COLOR_RESET}\n") + print("Downloads the latest MO2 release, adds it to Steam, and configures a Proton prefix.") + print("Steam will be restarted during this process.\n") + + # Install directory + default_dir = str(Path.home() / "ModOrganizer2") + dir_input = input( + f"{COLOR_PROMPT}Installation directory [{default_dir}]: {COLOR_RESET}" + ).strip() + install_dir = Path(dir_input) if dir_input else Path(default_dir) + + # Danger check + if _is_dangerous_path(install_dir): + print(f"{COLOR_ERROR}Refusing to install to a dangerous directory: {install_dir}{COLOR_RESET}") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return + + # Non-empty directory warning + if install_dir.exists() and any(install_dir.iterdir()): + print(f"\n{COLOR_ERROR}[WARN] Directory is not empty: {install_dir}{COLOR_RESET}") + confirm = input( + f"{COLOR_PROMPT}Files may be overwritten. Continue anyway? (y/N): {COLOR_RESET}" + ).strip().lower() + if confirm != 'y': + print("Cancelled.") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return + + # Shortcut name + default_name = "Mod Organizer 2" + name_input = input( + f"{COLOR_PROMPT}Steam shortcut name [{default_name}]: {COLOR_RESET}" + ).strip() + shortcut_name = name_input if name_input else default_name + + # Confirm + print(f"\n{COLOR_INFO}Install directory: {install_dir}{COLOR_RESET}") + print(f"{COLOR_INFO}Shortcut name: {shortcut_name}{COLOR_RESET}") + confirm = input( + f"\n{COLOR_PROMPT}Proceed? (Y/n): {COLOR_RESET}" + ).strip().lower() + if confirm == 'n': + print("Cancelled.") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return + + print(f"\n{COLOR_INFO}Starting MO2 setup...{COLOR_RESET}\n") + + def _progress(msg: str): + print(f"{COLOR_INFO} {msg}{COLOR_RESET}") + + service = MO2SetupService() + success, app_id, error_msg = service.setup_mo2( + install_dir=install_dir, + shortcut_name=shortcut_name, + progress_callback=_progress, + ) + + if success: + print(f"\n{COLOR_SUCCESS}{'='*60}{COLOR_RESET}") + print(f"{COLOR_SUCCESS}MO2 setup complete!{COLOR_RESET}") + print(f"{COLOR_SUCCESS}{'='*60}{COLOR_RESET}") + print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}") + print(f"{COLOR_INFO}Launch Mod Organizer 2 from your Steam library.{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}MO2 setup failed: {error_msg}{COLOR_RESET}") + print(f"{COLOR_INFO}Check logs for details.{COLOR_RESET}") + + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") diff --git a/jackify/frontends/cli/main.py b/jackify/frontends/cli/main.py index 092499b..b0402ec 100755 --- a/jackify/frontends/cli/main.py +++ b/jackify/frontends/cli/main.py @@ -33,11 +33,9 @@ from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.path_handler import PathHandler from jackify.backend.handlers.shortcut_handler import ShortcutHandler from jackify.backend.handlers.menu_handler import MenuHandler -from jackify.backend.handlers.mo2_handler import MO2Handler logger = logging.getLogger(__name__) - class JackifyCLI: """Main application class for Jackify CLI Frontend""" @@ -92,10 +90,6 @@ class JackifyCLI: self.selected_modlist = None self.setup_complete = False - def _debug_print(self, message): - """Print debug message only if debug mode is enabled""" - if hasattr(self, '_debug_mode') and self._debug_mode: - logger.debug(message) def _configure_logging_early(self): """Configure logging to be quiet during initialization, will be adjusted after arg parsing""" @@ -113,22 +107,40 @@ class JackifyCLI: """Configure final logging level based on parsed arguments""" # Use the existing LoggingHandler for proper log rotation from jackify.backend.handlers.logging_handler import LoggingHandler + from jackify.shared.paths import get_jackify_logs_dir # Set up CLI-specific logging with rotation logging_handler = LoggingHandler() - logging_handler.rotate_log_for_logger('jackify-cli', 'Modlist_Install_workflow_cli.log') - cli_logger = logging_handler.setup_logger('jackify-cli', 'Modlist_Install_workflow_cli.log') + # Keep CLI logging in the canonical modlist workflow log file. + logging_handler.rotate_log_for_logger('jackify-cli', 'Modlist_Install_workflow.log') + cli_logger = logging_handler.setup_logger('jackify-cli', 'Modlist_Install_workflow.log') + + # Remove legacy CLI log artifact if present (old naming path no longer used). + try: + legacy_cli_log = get_jackify_logs_dir() / "Modlist_Install_workflow_cli.log" + if legacy_cli_log.exists(): + legacy_cli_log.unlink() + except Exception: + pass # Configure logging level if self.args.debug: cli_logger.setLevel(logging.DEBUG) + root_level = logging.DEBUG print("Debug logging enabled for console and file") elif self.args.verbose: cli_logger.setLevel(logging.INFO) + root_level = logging.INFO print("Verbose logging enabled for console and file") else: - # Keep it at WARNING level for clean startup + # Keep console clean in normal mode; details remain in workflow log. cli_logger.setLevel(logging.WARNING) + root_level = logging.ERROR + + root_logger = logging.getLogger() + root_logger.setLevel(root_level) + for handler in root_logger.handlers: + handler.setLevel(root_level) def _is_steamdeck(self): """Check if running on Steam Deck""" @@ -192,7 +204,7 @@ class JackifyCLI: def _check_for_updates_on_startup(self): """Check for updates on startup in background thread""" try: - self._debug_print("Checking for updates on startup...") + logger.debug("Checking for updates on startup...") def update_check_callback(update_info): """Handle update check results""" @@ -207,15 +219,15 @@ class JackifyCLI: print(f"\nTo update, run: jackify --update") print("Or visit: https://github.com/Omni-guides/Jackify/releases") else: - self._debug_print("No updates available") + logger.debug("No updates available") except Exception as e: - self._debug_print(f"Error showing update info: {e}") + logger.debug(f"Error showing update info: {e}") # Check for updates in background self.backend_services['update_service'].check_for_updates_async(update_check_callback) except Exception as e: - self._debug_print(f"Error checking for updates on startup: {e}") + logger.debug(f"Error checking for updates on startup: {e}") # Continue anyway - don't block startup on update check errors def _handle_update(self): @@ -326,7 +338,6 @@ class JackifyCLI: self.menu_handler = self.menu # Alias for backend compatibility # Add MO2 handler to the menu handler for additional tasks menu - self.menu.mo2_handler = MO2Handler(self.menu) # Set steamdeck attribute that menus expect self.steamdeck = self.system_info.is_steamdeck @@ -359,24 +370,24 @@ class JackifyCLI: # Now that we have args, configure logging properly self._configure_logging_final() - self._debug_print('Initializing Jackify CLI Frontend') - self._debug_print('JackifyCLI.run() called') - self._debug_print(f'Parsed args: {self.args}') + logger.debug('Initializing Jackify CLI Frontend') + logger.debug('JackifyCLI.run() called') + logger.debug(f'Parsed args: {self.args}') # Handle update functionality if getattr(self.args, 'update', False): - self._debug_print('Entering update workflow') + logger.debug('Entering update workflow') return self._handle_update() # Handle legacy restart-steam functionality (temporary) if getattr(self.args, 'restart_steam', False): - self._debug_print('Entering restart_steam workflow') + logger.debug('Entering restart_steam workflow') return self._handle_restart_steam() # Handle install-modlist top-level functionality if getattr(self.args, 'install_modlist', False): - self._debug_print('Entering install_modlist workflow') + logger.debug('Entering install_modlist workflow') return self.commands['install_modlist'].execute_top_level(self.args) # Handle subcommands @@ -514,12 +525,10 @@ class JackifyCLI: command_instance.run() return 0 - def main(): """Legacy main function (not used in new structure)""" pass - if __name__ == "__main__": # Do not call directly -- use __main__.py print("Please use: python -m jackify.frontends.cli") diff --git a/jackify/frontends/cli/menus/additional_menu.py b/jackify/frontends/cli/menus/additional_menu.py index eae6df9..299a86b 100644 --- a/jackify/frontends/cli/menus/additional_menu.py +++ b/jackify/frontends/cli/menus/additional_menu.py @@ -37,8 +37,10 @@ class AdditionalMenuHandler: print(f" {COLOR_ACTION}→ Install TTW using TTW_Linux_Installer{COLOR_RESET}") print(f"{COLOR_SELECTION}3.{COLOR_RESET} Install Wabbajack Application") print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via Proton){COLOR_RESET}") + print(f"{COLOR_SELECTION}4.{COLOR_RESET} Setup Mod Organizer 2") + print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}") print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") - selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip() + selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip() if selection.lower() == 'q': # Allow 'q' to re-display menu continue @@ -48,21 +50,14 @@ class AdditionalMenuHandler: self._execute_ttw_install(cli_instance) elif selection == "3": self._execute_install_wabbajack(cli_instance) + elif selection == "4": + self._execute_setup_mo2(cli_instance) elif selection == "0": break else: print("Invalid selection. Please try again.") time.sleep(1) - def _execute_legacy_install_mo2(self, cli_instance): - """LEGACY BRIDGE: Execute MO2 installation""" - # LEGACY BRIDGE: Use legacy imports until backend migration complete - if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'mo2_handler'): - cli_instance.menu.mo2_handler.install_mo2() - else: - print(f"{COLOR_INFO}MO2 handler not available - this will be implemented in Phase 2.3{COLOR_RESET}") - input("\nPress Enter to continue...") - def _execute_legacy_recovery_menu(self, cli_instance): """LEGACY BRIDGE: Execute recovery menu""" # Handled by RecoveryMenuHandler @@ -314,3 +309,12 @@ class AdditionalMenuHandler: if self.logger: self.logger.debug("AdditionalMenuHandler: Executing Install Wabbajack command") command.run() + + def _execute_setup_mo2(self, cli_instance): + """Execute standalone MO2 setup""" + from jackify.frontends.cli.commands.setup_mo2 import SetupMO2Command + + command = SetupMO2Command() + if self.logger: + self.logger.debug("AdditionalMenuHandler: Executing Setup MO2 command") + command.run() diff --git a/jackify/frontends/gui/__main__.py b/jackify/frontends/gui/__main__.py index 15dbfa3..334dfe6 100644 --- a/jackify/frontends/gui/__main__.py +++ b/jackify/frontends/gui/__main__.py @@ -8,6 +8,7 @@ Usage: python -m jackify.frontends.gui import sys from pathlib import Path + def main(): # Check if launched with jackify:// protocol URL (OAuth callback) if len(sys.argv) > 1 and sys.argv[1].startswith('jackify://'): @@ -18,110 +19,57 @@ def main(): from jackify.frontends.gui.main import main as gui_main gui_main() + def handle_protocol_url(url: str): - """Handle jackify:// protocol URL (OAuth callback)""" - import os - import sys - - # Enhanced logging with system information + """Handle jackify:// protocol URL (OAuth callback).""" + from urllib.parse import urlparse, parse_qs + + parsed = urlparse(url) + full_path = f"/{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path + + if full_path != '/oauth/callback': + _log_error(f"Unknown protocol path: {full_path}") + return + + params = parse_qs(parsed.query) + code = params.get('code', [None])[0] + state = params.get('state', [None])[0] + error = params.get('error', [None])[0] + + if error: + error_description = params.get('error_description', ['No description'])[0] + _log_error(f"OAuth error: {error} — {error_description}") + return + + if not code or not state: + _log_error("OAuth callback missing required parameters (code or state)") + return + + callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp" + try: + callback_file.parent.mkdir(parents=True, exist_ok=True) + callback_file.write_text(f"{code}\n{state}") + except Exception as e: + _log_error(f"Failed to write OAuth callback file: {e}") + + +def _log_error(message: str): + """Write an error entry to protocol_handler.log. Only called on failure.""" + import datetime try: from jackify.shared.paths import get_jackify_logs_dir log_dir = get_jackify_logs_dir() - except Exception as e: - # Fallback if config system fails + except Exception: log_dir = Path.home() / ".config" / "jackify" / "logs" - - log_dir.mkdir(parents=True, exist_ok=True) - log_file = log_dir / "protocol_handler.log" - - def log(msg): - with open(log_file, 'a') as f: - import datetime - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - f.write(f"[{timestamp}] {msg}\n") - f.flush() # Ensure immediate write - try: - # Log system information for debugging - log(f"=== Protocol Handler Invoked ===") - log(f"URL: {url}") - log(f"Python executable: {sys.executable}") - log(f"Script path: {sys.argv[0]}") - log(f"Working directory: {os.getcwd()}") - log(f"APPIMAGE env: {os.environ.get('APPIMAGE', 'Not set')}") - log(f"APPDIR env: {os.environ.get('APPDIR', 'Not set')}") - - from urllib.parse import urlparse, parse_qs + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "protocol_handler.log" + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(log_file, 'a') as f: + f.write(f"[{timestamp}] ERROR: {message}\n") + except Exception: + pass - parsed = urlparse(url) - log(f"Parsed URL - scheme: {parsed.scheme}, netloc: {parsed.netloc}, path: {parsed.path}, query: {parsed.query}") - - # URL format: jackify://oauth/callback?code=XXX&state=YYY - # urlparse treats "oauth" as netloc, so reconstruct full path - full_path = f"/{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path - log(f"Reconstructed path: {full_path}") - - if full_path == '/oauth/callback': - params = parse_qs(parsed.query) - code = params.get('code', [None])[0] - state = params.get('state', [None])[0] - error = params.get('error', [None])[0] - - log(f"OAuth parameters - Code: {'Present' if code else 'Missing'}, State: {'Present' if state else 'Missing'}, Error: {error}") - - if error: - log(f"ERROR: OAuth error received: {error}") - error_description = params.get('error_description', ['No description'])[0] - log(f"ERROR: OAuth error description: {error_description}") - print(f"OAuth authorization failed: {error} - {error_description}") - elif code and state: - # Write to callback file for OAuth service to pick up - callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp" - log(f"Creating callback file: {callback_file}") - - try: - callback_file.parent.mkdir(parents=True, exist_ok=True) - callback_content = f"{code}\n{state}" - callback_file.write_text(callback_content) - - # Verify file was written - if callback_file.exists(): - written_content = callback_file.read_text() - log(f"Callback file created successfully, size: {len(written_content)} bytes") - print("OAuth callback received and saved successfully") - else: - log("ERROR: Callback file was not created") - print("Error: Failed to create callback file") - - except Exception as callback_error: - log(f"ERROR: Failed to write callback file: {callback_error}") - print(f"Error writing callback file: {callback_error}") - else: - log("ERROR: Missing required OAuth parameters (code or state)") - print("Invalid OAuth callback - missing required parameters") - else: - log(f"ERROR: Unknown protocol path: {full_path}") - print(f"Unknown protocol path: {full_path}") - - log("=== Protocol Handler Completed ===") - - except Exception as e: - log(f"CRITICAL EXCEPTION: {e}") - import traceback - log(f"TRACEBACK:\n{traceback.format_exc()}") - print(f"Critical error handling protocol URL: {e}") - - # Try to log to a fallback location if main logging fails - try: - fallback_log = Path.home() / "jackify_protocol_error.log" - with open(fallback_log, 'a') as f: - import datetime - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - f.write(f"[{timestamp}] CRITICAL ERROR: {e}\n") - f.write(f"URL: {url}\n") - f.write(f"Traceback:\n{traceback.format_exc()}\n\n") - except: - pass # If even fallback logging fails, just continue if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/jackify/frontends/gui/dialogs/completion_dialog.py b/jackify/frontends/gui/dialogs/completion_dialog.py index b6b3ac4..97bf9d4 100644 --- a/jackify/frontends/gui/dialogs/completion_dialog.py +++ b/jackify/frontends/gui/dialogs/completion_dialog.py @@ -28,7 +28,7 @@ class NextStepsDialog(QDialog): Displays the same information as the CLI completion message but in a proper GUI format. """ - def __init__(self, modlist_name: str, parent=None): + def __init__(self, modlist_name: str, workflow_type: str = "configure_new", parent=None): """ Initialize the Next Steps dialog. @@ -38,6 +38,7 @@ class NextStepsDialog(QDialog): """ super().__init__(parent) self.modlist_name = modlist_name + self.workflow_type = workflow_type self.setWindowTitle("Next Steps") self.setModal(True) self.setFixedSize(600, 400) @@ -189,10 +190,13 @@ class NextStepsDialog(QDialog): Returns: Formatted completion text string """ - # Match the CLI completion text from menu_handler.py lines 627-631 + is_existing = self.workflow_type == "configure_existing" + completion_title = "Modlist Configuration complete!" if is_existing else "Modlist Install and Configuration complete!" + completion_log = "Configure_Existing_Modlist_workflow.log" if is_existing else "Configure_New_Modlist_workflow.log" + completion_text = f"""✓ Configuration completed successfully! -Modlist Install and Configuration complete!: +{completion_title} • You should now be able to Launch '{self.modlist_name}' through Steam. • Congratulations and enjoy the game! @@ -200,6 +204,6 @@ Modlist Install and Configuration complete!: NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10). -Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log""" +Detailed log available at: {get_jackify_logs_dir()}/{completion_log}""" - return completion_text \ No newline at end of file + return completion_text diff --git a/jackify/frontends/gui/dialogs/settings_dialog.py b/jackify/frontends/gui/dialogs/settings_dialog.py index 3c02603..90d3f22 100644 --- a/jackify/frontends/gui/dialogs/settings_dialog.py +++ b/jackify/frontends/gui/dialogs/settings_dialog.py @@ -94,7 +94,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog def _pick_directory(self, line_edit): dir_path = QFileDialog.getExistingDirectory(self, "Select Directory", line_edit.text() or os.path.expanduser("~")) if dir_path: - line_edit.setText(dir_path) + line_edit.setText(os.path.realpath(dir_path)) def _show_help(self): MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low") @@ -130,7 +130,17 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog auth_service = NexusAuthService() authenticated, method, username = auth_service.get_auth_status() if authenticated and method == 'oauth': - self.oauth_status_label.setText(f"Authorised as {username}" if username else "Authorised") + tier_label = "" + try: + token = auth_service.get_auth_token() + if token: + from jackify.backend.services.nexus_premium_service import NexusPremiumService + is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=True) + tier_label = " [Premium]" if is_premium else " [Free]" + except Exception: + pass + display = f"Authorised as {username}{tier_label}" if username else "Authorised" + self.oauth_status_label.setText(display) self.oauth_status_label.setStyleSheet("color: #3fd0ea;") self.oauth_btn.setText("Revoke") elif method == 'oauth_expired': @@ -323,7 +333,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog # Check if debug mode changed and prompt for restart new_debug_mode = self.debug_checkbox.isChecked() if new_debug_mode != self._original_debug_mode: - reply = MessageService.question(self, "Restart Required", "Debug mode change requires a restart. Restart Jackify now?", safety_level="low") + reply = MessageService.question(self, "Restart Required", "Debug mode change requires a restart. Restart Jackify now?", safety_level="medium") if reply == QMessageBox.Yes: import os, sys # User requested restart - do it regardless of execution environment @@ -383,4 +393,3 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog label.setStyleSheet("font-weight: bold; color: #fff;") return label - diff --git a/jackify/frontends/gui/dialogs/settings_dialog_proton.py b/jackify/frontends/gui/dialogs/settings_dialog_proton.py index ba417a2..35a7a65 100644 --- a/jackify/frontends/gui/dialogs/settings_dialog_proton.py +++ b/jackify/frontends/gui/dialogs/settings_dialog_proton.py @@ -15,7 +15,7 @@ class SettingsDialogProtonMixin: from jackify.backend.handlers.wine_utils import WineUtils available_protons = WineUtils.scan_valve_proton_versions() for proton in available_protons: - if proton['version'].startswith('10.'): + if proton['name'].startswith('Proton 10.'): return proton['path'] return 'auto' except Exception: diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py index 7d71748..0fafb67 100644 --- a/jackify/frontends/gui/main.py +++ b/jackify/frontends/gui/main.py @@ -93,7 +93,6 @@ if '-v' in sys.argv or '--version' in sys.argv or '-V' in sys.argv: print(f"Jackify version {jackify_version}") sys.exit(0) - from jackify import __version__ # Add src directory to Python path @@ -125,13 +124,6 @@ from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder ENABLE_WINDOW_HEIGHT_ANIMATION = False -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - # Constants for styling and disclaimer DISCLAIMER_TEXT = ( "Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, " @@ -147,7 +139,6 @@ MENU_ITEMS = [ ("Exit Jackify", "exit_jackify"), ] - class JackifyMainWindow( MainWindowGeometryMixin, MainWindowBackendMixin, @@ -201,8 +192,6 @@ class JackifyMainWindow( def showEvent(self, event): self._geometry_show_event(event) - - def resource_path(relative_path): """Get path to resource file, handling both AppImage and dev modes.""" # AppImage mode - use APPDIR if available @@ -221,7 +210,6 @@ def resource_path(relative_path): jackify_dir = os.path.dirname(os.path.dirname(current_dir)) return os.path.join(jackify_dir, relative_path) - def main(): """Main entry point for the GUI application""" # CRITICAL: Enable faulthandler for segfault debugging @@ -265,8 +253,8 @@ def main(): logging_handler = LoggingHandler() # 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, debug_mode=debug_mode) # Empty name = root logger + logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-debug.log') + root_logger = logging_handler.setup_logger('', 'jackify-debug.log', is_general=True, debug_mode=debug_mode) # Empty name = root logger # CRITICAL: Set root logger level BEFORE any child loggers are used # DEBUG messages from child loggers must propagate @@ -294,7 +282,7 @@ def main(): # Global cleanup function for signal handling def emergency_cleanup(): - debug_print("Cleanup: terminating jackify-engine processes") + logger.debug("Cleanup: terminating jackify-engine processes") try: import subprocess subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True) @@ -379,6 +367,5 @@ def main(): return app.exec() - if __name__ == "__main__": sys.exit(main()) \ No newline at end of file diff --git a/jackify/frontends/gui/mixins/main_window_backend.py b/jackify/frontends/gui/mixins/main_window_backend.py index 0e85421..f96709d 100644 --- a/jackify/frontends/gui/mixins/main_window_backend.py +++ b/jackify/frontends/gui/mixins/main_window_backend.py @@ -7,14 +7,9 @@ import os from jackify.backend.models.configuration import SystemInfo from jackify.backend.services.modlist_service import ModlistService +import logging - -def _debug_print(message): - from jackify.backend.handlers.config_handler import ConfigHandler - ch = ConfigHandler() - if ch.get('debug_mode', False): - print(message) - +logger = logging.getLogger(__name__) class MainWindowBackendMixin: """Mixin for backend service initialization.""" @@ -37,7 +32,7 @@ class MainWindowBackendMixin: from jackify.backend.services.update_service import UpdateService from jackify import __version__ self.update_service = UpdateService(__version__) - _debug_print(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}") + logger.debug(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}") def _is_steamdeck(self): try: @@ -58,7 +53,7 @@ class MainWindowBackendMixin: if success: status = resource_manager.get_limit_status() if status['target_achieved']: - _debug_print(f"Resource limits optimized: file descriptors set to {status['current_soft']}") + logger.debug(f"Resource limits optimized: file descriptors set to {status['current_soft']}") else: print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})") else: diff --git a/jackify/frontends/gui/mixins/main_window_dialogs.py b/jackify/frontends/gui/mixins/main_window_dialogs.py index 818865c..42e5d43 100644 --- a/jackify/frontends/gui/mixins/main_window_dialogs.py +++ b/jackify/frontends/gui/mixins/main_window_dialogs.py @@ -11,6 +11,43 @@ from jackify.frontends.gui.dialogs.settings_dialog import SettingsDialog class MainWindowDialogsMixin: """Mixin for settings/about dialogs, open URL, and cleanup.""" + def _stop_qthread(self, thread, thread_name: str, cooperative_timeout_ms: int = 5000): + """Stop a QThread robustly to avoid teardown crashes on app exit.""" + if thread is None: + return None + try: + if not thread.isRunning(): + return None + except RuntimeError: + return None + + try: + thread.requestInterruption() + except Exception: + pass + + try: + thread.quit() + except Exception: + pass + + try: + if thread.wait(cooperative_timeout_ms): + return None + except Exception: + pass + + try: + thread.terminate() + except Exception: + pass + + try: + if not thread.wait(10000): + print(f"WARNING: {thread_name} still running during shutdown") + except Exception: + pass + return None def open_settings_dialog(self): try: @@ -83,27 +120,35 @@ class MainWindowDialogsMixin: def cleanup_processes(self): try: if hasattr(self, '_update_thread') and self._update_thread is not None: - if self._update_thread.isRunning(): - self._update_thread.quit() - self._update_thread.wait(2000) - self._update_thread = None + self._update_thread = self._stop_qthread(self._update_thread, "_update_thread") if hasattr(self, '_gallery_cache_preload_thread') and self._gallery_cache_preload_thread is not None: - if self._gallery_cache_preload_thread.isRunning(): - self._gallery_cache_preload_thread.quit() - self._gallery_cache_preload_thread.wait(2000) - self._gallery_cache_preload_thread = None + self._gallery_cache_preload_thread = self._stop_qthread( + self._gallery_cache_preload_thread, + "_gallery_cache_preload_thread", + ) for service in self.gui_services.values(): if hasattr(service, 'cleanup'): service.cleanup() screens = [ - self.modlist_tasks_screen, self.install_modlist_screen, - self.configure_new_modlist_screen, self.configure_existing_modlist_screen, + getattr(self, 'modlist_tasks_screen', None), + getattr(self, 'additional_tasks_screen', None), + getattr(self, 'install_modlist_screen', None), + getattr(self, 'install_ttw_screen', None), + getattr(self, 'configure_new_modlist_screen', None), + getattr(self, 'wabbajack_installer_screen', None), + getattr(self, 'configure_existing_modlist_screen', None), + getattr(self, 'install_mo2_screen', None), ] for screen in screens: + if screen is None: + continue if hasattr(screen, 'cleanup_processes'): screen.cleanup_processes() elif hasattr(screen, 'cleanup'): screen.cleanup() + elif hasattr(screen, 'worker'): + worker = getattr(screen, 'worker', None) + setattr(screen, 'worker', self._stop_qthread(worker, f"{screen.__class__.__name__}.worker")) try: subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True) except Exception: diff --git a/jackify/frontends/gui/mixins/main_window_geometry.py b/jackify/frontends/gui/mixins/main_window_geometry.py index 097ee22..ad8d60a 100644 --- a/jackify/frontends/gui/mixins/main_window_geometry.py +++ b/jackify/frontends/gui/mixins/main_window_geometry.py @@ -7,17 +7,11 @@ from PySide6.QtWidgets import QMainWindow, QApplication from PySide6.QtCore import Qt, QTimer, QRect from jackify.frontends.gui.utils import get_screen_geometry, set_responsive_minimum +import logging +logger = logging.getLogger(__name__) ENABLE_WINDOW_HEIGHT_ANIMATION = False - -def _debug_print(message): - from jackify.backend.handlers.config_handler import ConfigHandler - ch = ConfigHandler() - if ch.get('debug_mode', False): - print(message) - - class MainWindowGeometryMixin: """Mixin for window geometry, save/restore, compact mode, and resize behavior.""" @@ -135,10 +129,10 @@ class MainWindowGeometryMixin: self.showMaximized() def _on_child_resize_request(self, mode: str): - _debug_print(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}") + logger.debug(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}") try: if self.system_info and self.system_info.is_steamdeck: - _debug_print("DEBUG: Steam Deck detected, ignoring resize request") + logger.debug("DEBUG: Steam Deck detected, ignoring resize request") try: if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox: self.install_ttw_screen.show_details_checkbox.setVisible(False) @@ -183,7 +177,7 @@ class MainWindowGeometryMixin: before = self.size() self._programmatic_resize = True self.resize(self.size().width(), target_height) - _debug_print(f"DEBUG: Animated fallback resize from {before} to {self.size()}") + logger.debug(f"DEBUG: Animated fallback resize from {before} to {self.size()}") QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False)) return start_rect = self.geometry() diff --git a/jackify/frontends/gui/mixins/main_window_startup.py b/jackify/frontends/gui/mixins/main_window_startup.py index 670e8ee..f822d6a 100644 --- a/jackify/frontends/gui/mixins/main_window_startup.py +++ b/jackify/frontends/gui/mixins/main_window_startup.py @@ -7,14 +7,9 @@ import sys from PySide6.QtCore import QThread, Signal, QTimer from PySide6.QtWidgets import QDialog +import logging - -def _debug_print(message): - from jackify.backend.handlers.config_handler import ConfigHandler - ch = ConfigHandler() - if ch.get('debug_mode', False): - print(message) - +logger = logging.getLogger(__name__) class MainWindowStartupMixin: """Mixin for startup and background tasks.""" @@ -38,23 +33,23 @@ class MainWindowStartupMixin: if metadata: modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods) if modlists_with_mods > 0: - _debug_print(f"Gallery cache ready ({modlists_with_mods} modlists with mods)") + logger.debug(f"Gallery cache ready ({modlists_with_mods} modlists with mods)") else: - _debug_print("Gallery cache updated") + logger.debug("Gallery cache updated") else: - _debug_print("Failed to load gallery cache") + logger.debug("Failed to load gallery cache") except Exception as e: - _debug_print(f"Gallery cache preload error: {str(e)}") + logger.debug(f"Gallery cache preload error: {str(e)}") self._gallery_cache_preload_thread = GalleryCachePreloadThread() self._gallery_cache_preload_thread.start() - _debug_print("Started background gallery cache preload") + logger.debug("Started background gallery cache preload") def _check_protontricks_on_startup(self): try: method = self.config_handler.get('component_installation_method', 'winetricks') if method != 'system_protontricks': - _debug_print(f"Skipping protontricks check (current method: {method}).") + logger.debug(f"Skipping protontricks check (current method: {method}).") return is_installed, installation_type, details = self.protontricks_service.detect_protontricks() if not is_installed: @@ -66,13 +61,13 @@ class MainWindowStartupMixin: print("User chose to exit due to missing protontricks") sys.exit(1) else: - _debug_print(f"Protontricks detected: {details}") + logger.debug(f"Protontricks detected: {details}") except Exception as e: print(f"Error checking protontricks: {e}") def _check_for_updates_on_startup(self): try: - _debug_print("Checking for updates on startup...") + logger.debug("Checking for updates on startup...") class UpdateCheckThread(QThread): update_available = Signal(object) @@ -87,7 +82,7 @@ class MainWindowStartupMixin: self.update_available.emit(update_info) def on_update_available(update_info): - _debug_print(f"Update available: v{update_info.version}") + logger.debug(f"Update available: v{update_info.version}") def show_update_dialog(): from jackify.frontends.gui.dialogs.update_dialog import UpdateDialog @@ -99,4 +94,4 @@ class MainWindowStartupMixin: self._update_thread.update_available.connect(on_update_available) self._update_thread.start() except Exception as e: - _debug_print(f"Error setting up update check: {e}") + logger.debug(f"Error setting up update check: {e}") diff --git a/jackify/frontends/gui/mixins/main_window_ui.py b/jackify/frontends/gui/mixins/main_window_ui.py index c324439..6f24791 100644 --- a/jackify/frontends/gui/mixins/main_window_ui.py +++ b/jackify/frontends/gui/mixins/main_window_ui.py @@ -1,9 +1,13 @@ """ Main window UI setup mixin. Stacked widget, screens, bottom bar, screen change handling. + +Screens 1-9 are lazy-initialised: placeholder QWidgets are inserted at startup +and swapped for real screens on first navigation. Only index 0 (MainMenu) is +created eagerly because it is always visible first. """ -import sys +import logging from PySide6.QtWidgets import ( QWidget, QLabel, QVBoxLayout, QHBoxLayout, @@ -15,81 +19,43 @@ from jackify import __version__ from jackify.frontends.gui.shared_theme import DEBUG_BORDERS from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder +logger = logging.getLogger(__name__) -def _debug_print(message): - from jackify.backend.handlers.config_handler import ConfigHandler - ch = ConfigHandler() - if ch.get('debug_mode', False): - print(message) + +class _LazyPlaceholder(QWidget): + """Sentinel widget used in place of a not-yet-initialised screen.""" class MainWindowUIMixin: """Mixin for main window UI: stacked widget, screens, bottom bar.""" def _setup_ui(self, dev_mode=False): + self._dev_mode = dev_mode self.stacked_widget = QStackedWidget() - from jackify.frontends.gui.screens import ( - MainMenu, ModlistTasksScreen, AdditionalTasksScreen, - InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen, - ) - from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen - from jackify.frontends.gui.screens.wabbajack_installer import WabbajackInstallerScreen + # Only MainMenu is created eagerly (always shown first). + from jackify.frontends.gui.screens import MainMenu self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode) - self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget) - self.modlist_tasks_screen = ModlistTasksScreen( - stacked_widget=self.stacked_widget, main_menu_index=0, dev_mode=dev_mode - ) - self.additional_tasks_screen = AdditionalTasksScreen( - stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info - ) - self.install_modlist_screen = InstallModlistScreen( - stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info - ) - self.configure_new_modlist_screen = ConfigureNewModlistScreen( - stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info - ) - self.configure_existing_modlist_screen = ConfigureExistingModlistScreen( - stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info - ) - self.install_ttw_screen = InstallTTWScreen( - stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info - ) - self.wabbajack_installer_screen = WabbajackInstallerScreen( - stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info - ) + self.stacked_widget.addWidget(self.main_menu) # index 0 - try: - self.install_ttw_screen.resize_request.connect(self._on_child_resize_request) - except Exception: - pass - try: - self.install_modlist_screen.resize_request.connect(self._on_child_resize_request) - except Exception: - pass - try: - self.configure_new_modlist_screen.resize_request.connect(self._on_child_resize_request) - except Exception: - pass - try: - self.configure_existing_modlist_screen.resize_request.connect(self._on_child_resize_request) - except Exception: - pass - try: - self.wabbajack_installer_screen.resize_request.connect(self._on_child_resize_request) - except Exception: - pass + # Indexes 1-9: insert lightweight placeholders now; real screens on demand. + for _ in range(9): + self.stacked_widget.addWidget(_LazyPlaceholder()) - self.stacked_widget.addWidget(self.main_menu) - self.stacked_widget.addWidget(self.feature_placeholder) - self.stacked_widget.addWidget(self.modlist_tasks_screen) - self.stacked_widget.addWidget(self.additional_tasks_screen) - self.stacked_widget.addWidget(self.install_modlist_screen) - self.stacked_widget.addWidget(self.install_ttw_screen) - self.stacked_widget.addWidget(self.configure_new_modlist_screen) - self.stacked_widget.addWidget(self.wabbajack_installer_screen) - self.stacked_widget.addWidget(self.configure_existing_modlist_screen) + # Factory map: index -> callable that creates and caches the real screen. + self._screen_factories = { + 1: self._make_feature_placeholder, + 2: self._make_modlist_tasks_screen, + 3: self._make_additional_tasks_screen, + 4: self._make_install_modlist_screen, + 5: self._make_install_ttw_screen, + 6: self._make_configure_new_modlist_screen, + 7: self._make_wabbajack_installer_screen, + 8: self._make_configure_existing_modlist_screen, + 9: self._make_install_mo2_screen, + } + self.stacked_widget.currentChanged.connect(self._lazy_init_screen) self.stacked_widget.currentChanged.connect(self._debug_screen_change) self.stacked_widget.currentChanged.connect(self._maintain_fullscreen_on_deck) @@ -141,6 +107,121 @@ class MainWindowUIMixin: self.stacked_widget.setCurrentIndex(0) self._check_protontricks_on_startup() + def _lazy_init_screen(self, index: int) -> None: + """Swap placeholder at *index* for the real screen on first visit.""" + if index == 0: + return + widget = self.stacked_widget.widget(index) + if not isinstance(widget, _LazyPlaceholder): + return + factory = self._screen_factories.get(index) + if factory is None: + return + real_screen = factory() + # Block signals for the entire swap including setCurrentWidget so that: + # (a) Qt's auto-current-change on removeWidget doesn't cascade into the + # other placeholders via a re-entrant _lazy_init_screen call, and + # (b) setCurrentWidget does not fire a second currentChanged — the outer + # currentChanged (which triggered this lazy init) is still being + # dispatched and will reach _debug_screen_change with the real screen + # already in place, so reset_screen_to_defaults runs exactly once. + self.stacked_widget.blockSignals(True) + self.stacked_widget.removeWidget(widget) + widget.deleteLater() + self.stacked_widget.insertWidget(index, real_screen) + self.stacked_widget.setCurrentWidget(real_screen) + self.stacked_widget.blockSignals(False) + + def _make_feature_placeholder(self): + screen = FeaturePlaceholder(stacked_widget=self.stacked_widget) + self.feature_placeholder = screen + return screen + + def _make_modlist_tasks_screen(self): + from jackify.frontends.gui.screens import ModlistTasksScreen + screen = ModlistTasksScreen( + stacked_widget=self.stacked_widget, main_menu_index=0, dev_mode=self._dev_mode + ) + self.modlist_tasks_screen = screen + return screen + + def _make_additional_tasks_screen(self): + from jackify.frontends.gui.screens import AdditionalTasksScreen + screen = AdditionalTasksScreen( + stacked_widget=self.stacked_widget, main_menu_index=0, + system_info=self.system_info, install_mo2_screen_index=9, + ) + self.additional_tasks_screen = screen + return screen + + def _make_install_modlist_screen(self): + from jackify.frontends.gui.screens import InstallModlistScreen + screen = InstallModlistScreen( + stacked_widget=self.stacked_widget, main_menu_index=2, system_info=self.system_info + ) + self.install_modlist_screen = screen + try: + screen.resize_request.connect(self._on_child_resize_request) + except Exception: + pass + return screen + + def _make_install_ttw_screen(self): + from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen + screen = InstallTTWScreen( + stacked_widget=self.stacked_widget, main_menu_index=3, system_info=self.system_info + ) + self.install_ttw_screen = screen + try: + screen.resize_request.connect(self._on_child_resize_request) + except Exception: + pass + return screen + + def _make_configure_new_modlist_screen(self): + from jackify.frontends.gui.screens import ConfigureNewModlistScreen + screen = ConfigureNewModlistScreen( + stacked_widget=self.stacked_widget, main_menu_index=2, system_info=self.system_info + ) + self.configure_new_modlist_screen = screen + try: + screen.resize_request.connect(self._on_child_resize_request) + except Exception: + pass + return screen + + def _make_wabbajack_installer_screen(self): + from jackify.frontends.gui.screens.wabbajack_installer import WabbajackInstallerScreen + screen = WabbajackInstallerScreen( + stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info + ) + self.wabbajack_installer_screen = screen + try: + screen.resize_request.connect(self._on_child_resize_request) + except Exception: + pass + return screen + + def _make_configure_existing_modlist_screen(self): + from jackify.frontends.gui.screens import ConfigureExistingModlistScreen + screen = ConfigureExistingModlistScreen( + stacked_widget=self.stacked_widget, main_menu_index=2, system_info=self.system_info + ) + self.configure_existing_modlist_screen = screen + try: + screen.resize_request.connect(self._on_child_resize_request) + except Exception: + pass + return screen + + def _make_install_mo2_screen(self): + from jackify.frontends.gui.screens.install_mo2_screen import InstallMO2Screen + screen = InstallMO2Screen( + stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info + ) + self.install_mo2_screen = screen + return screen + def _debug_screen_change(self, index): try: idx = int(index) if index is not None else 0 @@ -167,21 +248,22 @@ class MainWindowUIMixin: 6: "Configure New Modlist", 7: "Wabbajack Installer", 8: "Configure Existing Modlist", + 9: "Install MO2 Screen", } screen_name = screen_names.get(idx, f"Unknown Screen (Index {idx})") widget = self.stacked_widget.widget(idx) except (OverflowError, TypeError, ValueError): return widget_class = widget.__class__.__name__ if widget else "None" - print(f"[DEBUG] Screen changed to Index {idx}: {screen_name} (Widget: {widget_class})", file=sys.stderr) + logger.debug(f"Screen changed to Index {idx}: {screen_name} (Widget: {widget_class})") if idx == 4: - print(" Install Modlist Screen details:", file=sys.stderr) - print(f" - Widget type: {type(widget)}", file=sys.stderr) - print(f" - Widget file: {widget.__class__.__module__}", file=sys.stderr) + logger.debug("Install Modlist Screen details:") + logger.debug(f" Widget type: {type(widget)}") + logger.debug(f" Widget file: {widget.__class__.__module__}") if hasattr(widget, 'windowTitle'): - print(f" - Window title: {widget.windowTitle()}", file=sys.stderr) + logger.debug(f" Window title: {widget.windowTitle()}") if hasattr(widget, 'layout'): layout = widget.layout() if layout: - print(f" - Layout type: {type(layout)}", file=sys.stderr) - print(f" - Layout children count: {layout.count()}", file=sys.stderr) + logger.debug(f" Layout type: {type(layout)}") + logger.debug(f" Layout children count: {layout.count()}") diff --git a/jackify/frontends/gui/screens/additional_tasks.py b/jackify/frontends/gui/screens/additional_tasks.py index 8b5ee61..cbff1cb 100644 --- a/jackify/frontends/gui/screens/additional_tasks.py +++ b/jackify/frontends/gui/screens/additional_tasks.py @@ -25,11 +25,13 @@ logger = logging.getLogger(__name__) class AdditionalTasksScreen(QWidget): """Simple Additional Tasks screen for TTW only""" - def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None): + def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None, + install_mo2_screen_index: int = 9): super().__init__() self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index self.system_info = system_info or SystemInfo(is_steamdeck=False) + self.install_mo2_screen_index = install_mo2_screen_index self._setup_ui() @@ -93,6 +95,7 @@ class AdditionalTasksScreen(QWidget): MENU_ITEMS = [ ("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"), ("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"), + ("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"), ("Return to Main Menu", "return_main_menu", "Go back to the main menu"), ] @@ -148,6 +151,8 @@ class AdditionalTasksScreen(QWidget): self._show_ttw_info() elif action_id == "wabbajack_install": self._show_wabbajack_installer() + elif action_id == "setup_mo2": + self._show_mo2_setup() elif action_id == "coming_soon": self._show_coming_soon_info() elif action_id == "return_main_menu": @@ -165,6 +170,11 @@ class AdditionalTasksScreen(QWidget): # Navigate to Wabbajack installer screen (index 7) self.stacked_widget.setCurrentIndex(7) + def _show_mo2_setup(self): + """Navigate to standalone MO2 setup screen""" + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(self.install_mo2_screen_index) + def _show_coming_soon_info(self): """Show coming soon info""" from ..services.message_service import MessageService diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py index c5bfdab..ed03b4f 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -8,6 +8,7 @@ from ..utils import ansi_to_html, set_responsive_minimum 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 +from jackify.shared.errors import configuration_failed import os import subprocess import sys @@ -23,28 +24,24 @@ from jackify.backend.services.resolution_service import ResolutionService from jackify.backend.handlers.config_handler import ConfigHandler from ..dialogs import SuccessDialog from jackify.frontends.gui.services.message_service import MessageService +import logging +logger = logging.getLogger(__name__) from .configure_existing_modlist_ui import ConfigureExistingModlistUIMixin from .configure_existing_modlist_workflow import ConfigureExistingModlistWorkflowMixin from .configure_existing_modlist_shortcuts import ConfigureExistingModlistShortcutsMixin from .configure_existing_modlist_console import ConfigureExistingModlistConsoleMixin from .screen_back_mixin import ScreenBackMixin - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) +from .install_modlist_ttw import TTWIntegrationMixin class ConfigureExistingModlistScreen( ScreenBackMixin, + TTWIntegrationMixin, ConfigureExistingModlistUIMixin, ConfigureExistingModlistWorkflowMixin, ConfigureExistingModlistShortcutsMixin, ConfigureExistingModlistConsoleMixin, QWidget, ): - steam_restart_finished = Signal(bool, str) resize_request = Signal(str) def cleanup_processes(self): @@ -86,14 +83,11 @@ class ConfigureExistingModlistScreen( except Exception as e: print(f"Warning: Failed to set initial collapsed state: {e}") - # Load shortcuts after layout is done so we don't block or re-enter during showEvent - if not self._shortcuts_loaded: - from PySide6.QtCore import QTimer - QTimer.singleShot(150, self._load_shortcuts_async) - self._shortcuts_loaded = True + # Shortcut loading is handled by reset_screen_to_defaults() → refresh_modlist_list() + # which fires via _debug_screen_change on every navigation to this screen. def hideEvent(self, event): - """Clean up thread when screen is hidden (terminate without blocking main thread)""" + """Clean up thread when screen is hidden.""" super().hideEvent(event) if self._shortcut_loader is not None: if self._shortcut_loader.isRunning(): @@ -102,6 +96,7 @@ class ConfigureExistingModlistScreen( except Exception: pass self._shortcut_loader.terminate() + self._shortcut_loader.wait(2000) self._shortcut_loader = None def on_configuration_complete(self, success, message, modlist_name, enb_detected=False): @@ -110,8 +105,19 @@ class ConfigureExistingModlistScreen( self._enable_controls_after_operation() if success: - # Check for VNV post-install automation after configuration install_dir = getattr(self, '_current_install_dir', None) + + if install_dir: + game_type = self._detect_game_type_from_mo2_ini(install_dir) + if game_type in ('falloutnv', 'fallout_new_vegas'): + from jackify.backend.utils.modlist_meta import get_modlist_name + identified_name = get_modlist_name(install_dir) + if identified_name and self._check_ttw_eligibility(identified_name, game_type, install_dir): + self._cleanup_config_thread() + self._initiate_ttw_workflow(identified_name, install_dir) + return + + # Check for VNV post-install automation after configuration if install_dir: self._check_and_run_vnv_automation(modlist_name, install_dir) @@ -142,8 +148,8 @@ class ConfigureExistingModlistScreen( logging.getLogger(__name__).warning("Failed to show ENB dialog: %s", e) else: self._safe_append_text(f"Configuration failed: {message}") - MessageService.critical(self, "Configuration Failed", - f"Configuration failed: {message}", safety_level="medium") + MessageService.show_error(self, configuration_failed(str(message))) + self._cleanup_config_thread() def on_configuration_error(self, error_message): """Handle configuration error""" @@ -151,7 +157,27 @@ class ConfigureExistingModlistScreen( self._enable_controls_after_operation() self._safe_append_text(f"Configuration error: {error_message}") - MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") + MessageService.show_error(self, configuration_failed(str(error_message))) + self._cleanup_config_thread() + + def _cleanup_config_thread(self): + """Safely stop and release configuration thread.""" + if not hasattr(self, 'config_thread') or self.config_thread is None: + return + + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except (RuntimeError, TypeError): + pass + + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) + + self.config_thread.deleteLater() + self.config_thread = None def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" @@ -179,16 +205,16 @@ class ConfigureExistingModlistScreen( def cleanup(self): """Clean up any running threads when the screen is closed""" - debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread") + logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread") # Clean up config thread if running if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): - debug_print("DEBUG: Terminating ConfigurationThread") + logger.debug("DEBUG: Terminating ConfigurationThread") try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() - except: + except (RuntimeError, TypeError): pass self.config_thread.terminate() - self.config_thread.wait(2000) # Wait up to 2 seconds \ No newline at end of file + self.config_thread.wait(2000) # Wait up to 2 seconds diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py b/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py index 341eaf3..c054569 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py @@ -1,14 +1,8 @@ """Shortcut loading for ConfigureExistingModlistScreen (Mixin).""" from PySide6.QtCore import QThread, Signal, QObject +import logging -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class ConfigureExistingModlistShortcutsMixin: """Mixin providing shortcut loading for ConfigureExistingModlistScreen.""" @@ -73,20 +67,31 @@ class ConfigureExistingModlistShortcutsMixin: self.shortcut_combo.addItem("Loading modlists...") self.shortcut_combo.setEnabled(False) - # Clean up any existing thread first (defer so we don't block main thread) + # Clean up any existing thread: disconnect its signal so results are ignored, + # terminate it, and park it in a holding list so the QThread object is not + # GC'd while still running (which would cause Qt to abort). if self._shortcut_loader is not None: if self._shortcut_loader.isRunning(): - self._shortcut_loader.finished_signal.disconnect() + try: + self._shortcut_loader.finished_signal.disconnect() + except Exception: + pass self._shortcut_loader.terminate() + if not hasattr(self, '_old_loaders'): + self._old_loaders = [] + self._old_loaders.append(self._shortcut_loader) self._shortcut_loader = None + # Purge finished threads from the holding list + if hasattr(self, '_old_loaders'): + self._old_loaders = [t for t in self._old_loaders if t.isRunning()] + # Start background thread self._shortcut_loader = ShortcutLoaderThread() self._shortcut_loader.finished_signal.connect(self._on_shortcuts_loaded) self._shortcut_loader.error_signal.connect(self._on_shortcuts_error) self._shortcut_loader.start() - def _on_shortcuts_loaded(self, shortcuts): """Update UI when shortcuts are loaded""" self.mo2_shortcuts = shortcuts @@ -103,15 +108,13 @@ class ConfigureExistingModlistShortcutsMixin: self.shortcut_combo.addItem(display) self.shortcut_map.append(shortcut) - def _on_shortcuts_error(self, error_msg): """Handle errors from shortcut loading thread""" # Log error from main thread (safe to write to stderr here) - debug_print(f"Warning: Failed to load shortcuts: {error_msg}") + logger.debug(f"Warning: Failed to load shortcuts: {error_msg}") # Update UI to show error state if hasattr(self, 'shortcut_combo'): self.shortcut_combo.clear() self.shortcut_combo.setEnabled(True) self.shortcut_combo.addItem("Error loading modlists - please try again") - diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_ui.py b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py index 2097780..b50567c 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_ui.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py @@ -11,22 +11,15 @@ from ..utils import set_responsive_minimum from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator from jackify.frontends.gui.widgets.file_progress_list import FileProgressList from jackify.backend.handlers.shortcut_handler import ShortcutHandler +import logging -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class ConfigureExistingModlistUIMixin: """Mixin providing UI setup and control management for ConfigureExistingModlistScreen.""" - def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None): super().__init__() - debug_print("DEBUG: ConfigureExistingModlistScreen __init__ called") + logger.debug("DEBUG: ConfigureExistingModlistScreen __init__ called") self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index from jackify.backend.models.configuration import SystemInfo @@ -184,7 +177,7 @@ class ConfigureExistingModlistUIMixin: combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) self.resolution_combo.setCurrentIndex(resolution_index) - debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") elif is_steam_deck: # Set default to 1280x800 (Steam Deck) combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] @@ -335,7 +328,6 @@ class ConfigureExistingModlistUIMixin: self.top_timer.timeout.connect(self.update_top_panel) self.top_timer.start(2000) self.start_btn.clicked.connect(self.validate_and_start_configure) - self.steam_restart_finished.connect(self._on_steam_restart_finished) # Scroll tracking for professional auto-scroll behavior self._user_manually_scrolled = False @@ -361,34 +353,29 @@ class ConfigureExistingModlistUIMixin: self.resolution_combo, ] - def _disable_controls_during_operation(self): """Disable all actionable controls during configure operations (except Cancel)""" for control in self._actionable_controls: if control: control.setEnabled(False) - def _enable_controls_after_operation(self): """Re-enable all actionable controls after configure operations complete""" for control in self._actionable_controls: if control: control.setEnabled(True) - def refresh_paths(self): """Refresh cached paths when config changes.""" from jackify.shared.paths import get_jackify_logs_dir self.modlist_log_path = get_jackify_logs_dir() / 'Configure_Existing_Modlist_workflow.log' os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) - def resizeEvent(self, event): """Handle window resize to prioritize form over console""" super().resizeEvent(event) self._adjust_console_for_form_priority() - def _adjust_console_for_form_priority(self): """Console now dynamically fills available space with stretch=1, no manual calculation needed""" # The console automatically fills remaining space due to stretch=1 in the layout @@ -396,7 +383,6 @@ class ConfigureExistingModlistUIMixin: self.console.setMaximumHeight(16777215) # Reset to default maximum self.console.setMinimumHeight(50) # Keep minimum height for usability - def _setup_scroll_tracking(self): """Set up scroll tracking for professional auto-scroll behavior""" scrollbar = self.console.verticalScrollBar() @@ -404,17 +390,14 @@ class ConfigureExistingModlistUIMixin: scrollbar.sliderReleased.connect(self._on_scrollbar_released) scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) - def _on_scrollbar_pressed(self): """User started manually scrolling""" self._user_manually_scrolled = True - def _on_scrollbar_released(self): """User finished manually scrolling""" self._user_manually_scrolled = False - def _on_scrollbar_value_changed(self): """Track if user is at bottom of scroll area""" scrollbar = self.console.verticalScrollBar() @@ -427,19 +410,16 @@ class ConfigureExistingModlistUIMixin: from PySide6.QtCore import QTimer QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) - def _reset_manual_scroll_if_at_bottom(self): """Reset manual scroll flag if user is still at bottom after delay""" scrollbar = self.console.verticalScrollBar() if scrollbar.value() >= scrollbar.maximum() - 1: self._user_manually_scrolled = False - def _on_show_details_toggled(self, checked): """Handle Show Details checkbox toggle""" self._toggle_console_visibility(checked) - def _toggle_console_visibility(self, is_checked): """Toggle console visibility and window size""" main_window = None @@ -518,7 +498,6 @@ class ConfigureExistingModlistUIMixin: except Exception: pass - def update_top_panel(self): try: result = subprocess.run([ @@ -561,4 +540,3 @@ class ConfigureExistingModlistUIMixin: except Exception as e: self.process_monitor.setPlainText(f"[process info unavailable: {e}]") - diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py index 6c9672b..ec45cf8 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py @@ -6,17 +6,10 @@ import logging from pathlib import Path from typing import Optional from jackify.shared.resolution_utils import get_resolution_fallback +from jackify.shared.errors import configuration_failed logger = logging.getLogger(__name__) -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - class ConfigureExistingModlistWorkflowMixin: """Mixin providing workflow management for ConfigureExistingModlistScreen.""" @@ -37,6 +30,8 @@ class ConfigureExistingModlistWorkflowMixin: return 'fallout4' elif 'nvse_loader.exe' in content or 'fallout new vegas' in content: return 'falloutnv' + elif 'fose_loader.exe' in content or 'fallout 3' in content: + return 'fallout3' elif 'obse_loader.exe' in content or 'oblivion' in content: return 'oblivion' elif 'starfield' in content: @@ -49,7 +44,6 @@ class ConfigureExistingModlistWorkflowMixin: logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}") return 'skyrim' - def validate_and_start_configure(self): # Reload config to pick up any settings changes made in Settings dialog self.config_handler.reload_config() @@ -88,18 +82,17 @@ class ConfigureExistingModlistWorkflowMixin: if resolution and resolution != "Leave unchanged": success = self.resolution_service.save_resolution(resolution) if success: - debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") else: - debug_print("DEBUG: Failed to save resolution") + logger.debug("DEBUG: Failed to save resolution") else: # Clear saved resolution if "Leave unchanged" is selected if self.resolution_service.has_saved_resolution(): self.resolution_service.clear_saved_resolution() - debug_print("DEBUG: Saved resolution cleared") + logger.debug("DEBUG: Saved resolution cleared") # Start the workflow (no shortcut creation needed) self.start_workflow(modlist_name, install_dir, resolution) - def start_workflow(self, modlist_name, install_dir, resolution): """Start the configuration workflow using backend service directly""" # CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog @@ -211,8 +204,7 @@ class ConfigureExistingModlistWorkflowMixin: except Exception as e: self._safe_append_text(f"[ERROR] Failed to start configuration: {e}") - MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium") - + MessageService.show_error(self, configuration_failed(str(e))) def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str): """Check if VNV automation should run and execute if applicable @@ -237,7 +229,7 @@ class ConfigureExistingModlistWorkflowMixin: game_root = game_paths.get('Fallout New Vegas') if not game_root: - debug_print("DEBUG: VNV automation skipped - FNV game root not found") + logger.debug("DEBUG: VNV automation skipped - FNV game root not found") return # Confirmation callback - show dialog to user @@ -269,7 +261,7 @@ class ConfigureExistingModlistWorkflowMixin: ) if file_path: - return Path(file_path) + return Path(file_path).resolve() return None # Run automation @@ -294,10 +286,9 @@ class ConfigureExistingModlistWorkflowMixin: ) except Exception as e: - debug_print(f"ERROR: Failed to run VNV automation: {e}") + logger.debug(f"ERROR: Failed to run VNV automation: {e}") import traceback - debug_print(f"Traceback: {traceback.format_exc()}") - + logger.debug(f"Traceback: {traceback.format_exc()}") def show_manual_steps_dialog(self, extra_warning=""): modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist" @@ -334,7 +325,6 @@ class ConfigureExistingModlistWorkflowMixin: self._enable_controls_after_operation() self.cancel_btn.setVisible(True) - def show_next_steps_dialog(self, message): from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication dlg = QDialog(self) @@ -360,18 +350,12 @@ class ConfigureExistingModlistWorkflowMixin: btn_exit.clicked.connect(on_exit) dlg.exec() - - def _on_steam_restart_finished(self, success, message): - pass - - def refresh_modlist_list(self): """Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts (async)""" # Use async loading to avoid blocking UI self._shortcuts_loaded = False # Allow reload self._load_shortcuts_async() - def _calculate_time_taken(self) -> str: """Calculate and format the time taken for the workflow""" if self._workflow_start_time is None: @@ -389,4 +373,3 @@ class ConfigureExistingModlistWorkflowMixin: else: return f"{elapsed_seconds_remainder} seconds" - diff --git a/jackify/frontends/gui/screens/configure_new_modlist.py b/jackify/frontends/gui/screens/configure_new_modlist.py index 3549b22..d358200 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist.py +++ b/jackify/frontends/gui/screens/configure_new_modlist.py @@ -28,22 +28,17 @@ from ..dialogs import SuccessDialog from PySide6.QtWidgets import QApplication from jackify.frontends.gui.services.message_service import MessageService from jackify.shared.resolution_utils import get_resolution_fallback +from jackify.shared.errors import configuration_failed from .configure_new_modlist_ui_setup import ConfigureNewModlistUISetupMixin from .configure_new_modlist_console import ConfigureNewModlistConsoleMixin from .configure_new_modlist_workflow import ConfigureNewModlistWorkflowMixin from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, ModlistFetchThread, SelectionDialog from .screen_back_mixin import ScreenBackMixin +from .install_modlist_ttw import TTWIntegrationMixin logger = logging.getLogger(__name__) -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - -class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, QWidget): +class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, QWidget): resize_request = Signal(str) def cancel_and_cleanup(self): @@ -79,8 +74,20 @@ class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin self._enable_controls_after_operation() if success: + raw = self.install_dir_edit.text().strip() + install_dir = os.path.dirname(raw) if raw.endswith('ModOrganizer.exe') else raw + + if install_dir: + game_type = self._detect_game_type_from_mo2_ini(install_dir) + if game_type in ('falloutnv', 'fallout_new_vegas'): + from jackify.backend.utils.modlist_meta import get_modlist_name + identified_name = get_modlist_name(install_dir) + if identified_name and self._check_ttw_eligibility(identified_name, game_type, install_dir): + self._cleanup_config_thread() + self._initiate_ttw_workflow(identified_name, install_dir) + return + # Check for VNV post-install automation after configuration - install_dir = self.install_dir_edit.text().strip() if install_dir: self._check_and_run_vnv_automation(modlist_name, install_dir) @@ -111,8 +118,8 @@ class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin logger.warning(f"Failed to show ENB dialog: {e}") else: self._safe_append_text(f"Configuration failed: {message}") - MessageService.critical(self, "Configuration Failed", - f"Configuration failed: {message}", safety_level="medium") + MessageService.show_error(self, configuration_failed(str(message))) + self._cleanup_config_thread() def on_configuration_error(self, error_message): """Handle configuration error""" @@ -120,11 +127,27 @@ class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin self._enable_controls_after_operation() self._safe_append_text(f"Configuration error: {error_message}") - MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") + MessageService.show_error(self, configuration_failed(str(error_message))) + self._cleanup_config_thread() + def _cleanup_config_thread(self): + """Safely stop and release configuration thread.""" + if not hasattr(self, 'config_thread') or self.config_thread is None: + return + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except (RuntimeError, TypeError): + pass + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) + self.config_thread.deleteLater() + self.config_thread = None def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" @@ -149,28 +172,28 @@ class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin def cleanup(self): """Clean up any running threads when the screen is closed""" - debug_print("DEBUG: cleanup called - cleaning up threads") + logger.debug("DEBUG: cleanup called - cleaning up threads") # Clean up automated prefix thread if running if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread and self.automated_prefix_thread.isRunning(): - debug_print("DEBUG: Terminating AutomatedPrefixThread") + logger.debug("DEBUG: Terminating AutomatedPrefixThread") try: self.automated_prefix_thread.progress_update.disconnect() self.automated_prefix_thread.workflow_complete.disconnect() self.automated_prefix_thread.error_occurred.disconnect() - except: + except (RuntimeError, TypeError): pass self.automated_prefix_thread.terminate() self.automated_prefix_thread.wait(2000) # Wait up to 2 seconds # Clean up config thread if running if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): - debug_print("DEBUG: Terminating ConfigThread") + logger.debug("DEBUG: Terminating ConfigThread") try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() - except: + except (RuntimeError, TypeError): pass self.config_thread.terminate() - self.config_thread.wait(2000) # Wait up to 2 seconds \ No newline at end of file + self.config_thread.wait(2000) # Wait up to 2 seconds diff --git a/jackify/frontends/gui/screens/configure_new_modlist_console.py b/jackify/frontends/gui/screens/configure_new_modlist_console.py index 92afa81..a5d0ac3 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_console.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_console.py @@ -167,6 +167,6 @@ class ConfigureNewModlistConsoleMixin: def browse_install_dir(self): file, _ = QFileDialog.getOpenFileName(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)") if file: - self.install_dir_edit.setText(file) + self.install_dir_edit.setText(os.path.realpath(file)) diff --git a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py index 7706817..01a0035 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py @@ -6,15 +6,10 @@ from pathlib import Path from typing import Optional import subprocess from jackify.frontends.gui.services.message_service import MessageService +from jackify.shared.errors import manual_steps_incomplete +import logging -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class ModlistFetchThread(QThread): result = Signal(list, str) def __init__(self, cli_path, game_type, project_root, log_path, mode='list-modlists', modlist_name=None, install_dir=None, download_dir=None): @@ -56,7 +51,6 @@ class ModlistFetchThread(QThread): except Exception as e: self.result.emit([], str(e)) - class SelectionDialog(QDialog): def __init__(self, title, items, parent=None): super().__init__(parent) @@ -78,7 +72,6 @@ class SelectionDialog(QDialog): self.selected_item = item.text() self.accept() - class ConfigureNewModlistDialogsMixin: """Mixin providing dialog management for ConfigureNewModlistScreen.""" @@ -98,7 +91,6 @@ class ConfigureNewModlistDialogsMixin: self.config_thread.terminate() self.config_thread.wait(1000) - def show_shortcut_conflict_dialog(self, conflicts): """Show dialog to resolve shortcut name conflicts""" conflict_names = [c['name'] for c in conflicts] @@ -209,7 +201,6 @@ class ConfigureNewModlistDialogsMixin: dialog.exec() - def retry_automated_workflow_with_new_name(self, new_name): """Retry the automated workflow with a new shortcut name""" # Update the modlist name field temporarily @@ -220,16 +211,13 @@ class ConfigureNewModlistDialogsMixin: self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") self._start_automated_prefix_workflow(new_name, os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip(), self.install_dir_edit.text().strip(), self.resolution_combo.currentText()) - def handle_validation_failure(self, missing_text): """Handle manual steps validation failure with retry logic""" self._manual_steps_retry_count += 1 if self._manual_steps_retry_count < 3: # Show retry dialog - MessageService.critical(self, "Manual Steps Incomplete", - f"Manual steps validation failed:\n\n{missing_text}\n\n" - "Please complete the manual steps and try again.", safety_level="medium") + MessageService.show_error(self, manual_steps_incomplete()) # Show manual steps dialog again extra_warning = "" if self._manual_steps_retry_count >= 2: @@ -237,11 +225,9 @@ class ConfigureNewModlistDialogsMixin: self.show_manual_steps_dialog(extra_warning) else: # Max retries reached - MessageService.critical(self, "Manual Steps Failed", - "Manual steps validation failed after multiple attempts.", safety_level="medium") + MessageService.show_error(self, manual_steps_incomplete()) self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self.modlist_name_edit.text().strip()) - def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str): """Check if VNV automation should run and execute if applicable @@ -265,7 +251,7 @@ class ConfigureNewModlistDialogsMixin: game_root = game_paths.get('Fallout New Vegas') if not game_root: - debug_print("DEBUG: VNV automation skipped - FNV game root not found") + logger.debug("DEBUG: VNV automation skipped - FNV game root not found") return # Confirmation callback - show dialog to user @@ -297,7 +283,7 @@ class ConfigureNewModlistDialogsMixin: ) if file_path: - return Path(file_path) + return Path(file_path).resolve() return None # Run automation @@ -322,10 +308,9 @@ class ConfigureNewModlistDialogsMixin: ) except Exception as e: - debug_print(f"ERROR: Failed to run VNV automation: {e}") + logger.debug(f"ERROR: Failed to run VNV automation: {e}") import traceback - debug_print(f"Traceback: {traceback.format_exc()}") - + logger.debug(f"Traceback: {traceback.format_exc()}") def show_next_steps_dialog(self, message): dlg = QDialog(self) @@ -351,4 +336,3 @@ class ConfigureNewModlistDialogsMixin: btn_exit.clicked.connect(on_exit) dlg.exec() - diff --git a/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py b/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py index 99757d9..d931ce1 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py @@ -10,22 +10,15 @@ from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from ..utils import set_responsive_minimum from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator from jackify.frontends.gui.widgets.file_progress_list import FileProgressList +import logging -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class ConfigureNewModlistUISetupMixin: """Mixin providing UI setup and control management for ConfigureNewModlistScreen.""" - def __init__(self, stacked_widget=None, main_menu_index=0, dev_mode=False, system_info=None): super().__init__() - debug_print("DEBUG: ConfigureNewModlistScreen __init__ called") + logger.debug("DEBUG: ConfigureNewModlistScreen __init__ called") self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index self.dev_mode = dev_mode @@ -179,7 +172,7 @@ class ConfigureNewModlistUISetupMixin: combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) self.resolution_combo.setCurrentIndex(resolution_index) - debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") elif is_steam_deck: # Set default to 1280x800 (Steam Deck) combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] @@ -363,11 +356,6 @@ class ConfigureNewModlistUISetupMixin: # Now collect all actionable controls after UI is fully built self._collect_actionable_controls() - - - - - def _collect_actionable_controls(self): """Collect all actionable controls that should be disabled during operations (except Cancel)""" self._actionable_controls = [ @@ -382,34 +370,29 @@ class ConfigureNewModlistUISetupMixin: self.auto_restart_checkbox, ] - def _disable_controls_during_operation(self): """Disable all actionable controls during configure operations (except Cancel)""" for control in self._actionable_controls: if control: control.setEnabled(False) - def _enable_controls_after_operation(self): """Re-enable all actionable controls after configure operations complete""" for control in self._actionable_controls: if control: control.setEnabled(True) - def refresh_paths(self): """Refresh cached paths when config changes.""" from jackify.shared.paths import get_jackify_logs_dir self.modlist_log_path = get_jackify_logs_dir() / 'Configure_New_Modlist_workflow.log' os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) - def resizeEvent(self, event): """Handle window resize to prioritize form over console""" super().resizeEvent(event) self._adjust_console_for_form_priority() - def _adjust_console_for_form_priority(self): """Console now dynamically fills available space with stretch=1, no manual calculation needed""" # The console automatically fills remaining space due to stretch=1 in the layout @@ -417,7 +400,6 @@ class ConfigureNewModlistUISetupMixin: self.console.setMaximumHeight(16777215) # Reset to default maximum self.console.setMinimumHeight(50) # Keep minimum height for usability - def _setup_scroll_tracking(self): """Set up scroll tracking for professional auto-scroll behavior""" scrollbar = self.console.verticalScrollBar() @@ -425,17 +407,14 @@ class ConfigureNewModlistUISetupMixin: scrollbar.sliderReleased.connect(self._on_scrollbar_released) scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) - def _on_scrollbar_pressed(self): """User started manually scrolling""" self._user_manually_scrolled = True - def _on_scrollbar_released(self): """User finished manually scrolling""" self._user_manually_scrolled = False - def _on_scrollbar_value_changed(self): """Track if user is at bottom of scroll area""" scrollbar = self.console.verticalScrollBar() @@ -448,19 +427,16 @@ class ConfigureNewModlistUISetupMixin: from PySide6.QtCore import QTimer QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) - def _reset_manual_scroll_if_at_bottom(self): """Reset manual scroll flag if user is still at bottom after delay""" scrollbar = self.console.verticalScrollBar() if scrollbar.value() >= scrollbar.maximum() - 1: self._user_manually_scrolled = False - def _on_show_details_toggled(self, checked): """Handle Show Details checkbox toggle""" self._toggle_console_visibility(checked) - def _toggle_console_visibility(self, is_checked): """Toggle console visibility and window size - matches pattern from other screens""" main_window = None @@ -553,7 +529,6 @@ class ConfigureNewModlistUISetupMixin: # Notify parent to collapse self.resize_request.emit("compact") - def update_top_panel(self): try: result = subprocess.run([ @@ -596,7 +571,6 @@ class ConfigureNewModlistUISetupMixin: except Exception as e: self.process_monitor.setPlainText(f"[process info unavailable: {e}]") - def _check_protontricks(self): """Check if protontricks is available before critical operations""" try: @@ -628,4 +602,3 @@ class ConfigureNewModlistUISetupMixin: "Continuing anyway, but some features may not work correctly.") return True # Continue anyway - diff --git a/jackify/frontends/gui/screens/configure_new_modlist_workflow.py b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py index eece443..b08d586 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py @@ -1,20 +1,15 @@ """Workflow management for ConfigureNewModlistScreen (Mixin).""" +from pathlib import Path from PySide6.QtCore import QThread, Signal import os import time import logging from jackify.shared.resolution_utils import get_resolution_fallback +from jackify.shared.errors import configuration_failed +from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access logger = logging.getLogger(__name__) -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - class ConfigureNewModlistWorkflowMixin: """Mixin providing workflow management for ConfigureNewModlistScreen.""" @@ -35,6 +30,8 @@ class ConfigureNewModlistWorkflowMixin: return 'fallout4' elif 'nvse_loader.exe' in content or 'fallout new vegas' in content: return 'falloutnv' + elif 'fose_loader.exe' in content or 'fallout 3' in content: + return 'fallout3' elif 'obse_loader.exe' in content or 'oblivion' in content: return 'oblivion' elif 'starfield' in content: @@ -47,7 +44,6 @@ class ConfigureNewModlistWorkflowMixin: logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}") return 'skyrim' - def validate_and_start_configure(self): # Reload config to pick up any settings changes made in Settings dialog self.config_handler.reload_config() @@ -99,27 +95,27 @@ class ConfigureNewModlistWorkflowMixin: if resolution and resolution != "Leave unchanged": success = self.resolution_service.save_resolution(resolution) if success: - debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") else: - debug_print("DEBUG: Failed to save resolution") + logger.debug("DEBUG: Failed to save resolution") else: # Clear saved resolution if "Leave unchanged" is selected if self.resolution_service.has_saved_resolution(): self.resolution_service.clear_saved_resolution() - debug_print("DEBUG: Saved resolution cleared") + logger.debug("DEBUG: Saved resolution cleared") # Start configuration - automated workflow handles Steam restart internally self.configure_modlist() - def configure_modlist(self): # CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog # Refresh Proton version and winetricks settings self.config_handler._load_config() - install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip() + _raw_mo2_path = os.path.realpath(self.install_dir_edit.text().strip()) + install_dir = os.path.dirname(_raw_mo2_path) if _raw_mo2_path.endswith('ModOrganizer.exe') else _raw_mo2_path modlist_name = self.modlist_name_edit.text().strip() - mo2_exe_path = self.install_dir_edit.text().strip() + mo2_exe_path = _raw_mo2_path resolution = self.resolution_combo.currentText() if not install_dir or not modlist_name: MessageService.warning(self, "Missing Info", "Install directory or modlist name is missing.", safety_level="low") @@ -128,18 +124,18 @@ class ConfigureNewModlistWorkflowMixin: # Use automated prefix service instead of manual steps self._safe_append_text("") self._safe_append_text("=== Steam Integration Phase ===") - self._safe_append_text("Starting automated Steam setup workflow...") + logger.info("Starting automated Steam setup workflow...") # Start automated prefix workflow self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path, resolution) - def _start_automated_prefix_workflow(self, modlist_name, install_dir, mo2_exe_path, resolution): """Start the automated prefix workflow using AutomatedPrefixService in a background thread""" + ensure_flatpak_steam_filesystem_access(Path(install_dir)) from jackify import __version__ as jackify_version - self._safe_append_text(f"Jackify v{jackify_version}") - self._safe_append_text(f"Initializing automated Steam setup for '{modlist_name}'...") - self._safe_append_text("Starting automated Steam shortcut creation and configuration...") + logger.info("Jackify v%s", jackify_version) + logger.info("Initializing automated Steam setup for '%s'...", modlist_name) + logger.info("Starting automated Steam shortcut creation and configuration...") # Disable the start button to prevent multiple workflows self.start_btn.setEnabled(False) @@ -148,7 +144,7 @@ class ConfigureNewModlistWorkflowMixin: class AutomatedPrefixThread(QThread): progress_update = Signal(str) workflow_complete = Signal(object) # Will emit the result tuple - error_occurred = Signal(str) + error_occurred = Signal(object) # error (JackifyError or str) def __init__(self, modlist_name, install_dir, mo2_exe_path, steamdeck, auto_restart): super().__init__() @@ -179,7 +175,10 @@ class ConfigureNewModlistWorkflowMixin: self.workflow_complete.emit(result) except Exception as e: - self.error_occurred.emit(str(e)) + from jackify.shared.errors import JackifyError, prefix_creation_failed + if not isinstance(e, JackifyError): + e = prefix_creation_failed(str(e)) + self.error_occurred.emit(e) # Detect Steam Deck once using centralized service from jackify.backend.services.platform_detection_service import PlatformDetectionService @@ -210,7 +209,6 @@ class ConfigureNewModlistWorkflowMixin: self.automated_prefix_thread.error_occurred.connect(self._on_automated_prefix_error) self.automated_prefix_thread.start() - def _on_automated_prefix_complete(self, result): """Handle completion of the automated prefix workflow""" try: @@ -233,8 +231,8 @@ class ConfigureNewModlistWorkflowMixin: os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip(), last_timestamp) else: - self._safe_append_text(f"Automated Steam setup failed") - self._safe_append_text("Please check the logs for details.") + error_reason = last_timestamp or "Unknown error" + self._safe_append_text(f"Automated Steam setup failed: {error_reason}") self.start_btn.setEnabled(True) elif isinstance(result, tuple) and len(result) == 3: # Fallback for old format (backward compatibility) @@ -242,49 +240,42 @@ class ConfigureNewModlistWorkflowMixin: if success: self._safe_append_text(f"Automated Steam setup completed successfully!") self._safe_append_text(f"New AppID assigned: {new_appid}") - + # Continue with post-Steam configuration - self.continue_configuration_after_automated_prefix(new_appid, self.modlist_name_edit.text().strip(), + self.continue_configuration_after_automated_prefix(new_appid, self.modlist_name_edit.text().strip(), os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()) else: self._safe_append_text(f"Automated Steam setup failed") - self._safe_append_text("Please check the logs for details.") self.start_btn.setEnabled(True) else: # Handle unexpected result format self._safe_append_text(f"Automated Steam setup failed - unexpected result format") - self._safe_append_text("Please check the logs for details.") self.start_btn.setEnabled(True) except Exception as e: + logger.error("Error handling automated prefix result: %s", e) self._safe_append_text(f"Error handling automated prefix result: {str(e)}") self.start_btn.setEnabled(True) - - def _on_automated_prefix_error(self, error_message): + def _on_automated_prefix_error(self, error): """Handle error from the automated prefix workflow""" - self._safe_append_text(f"Error during automated Steam setup: {error_message}") - self._safe_append_text("Please check the logs for details.") - - # Show critical error dialog to user (don't silently fail) - from jackify.backend.services.message_service import MessageService - MessageService.critical( - self, - "Steam Setup Error", - f"Error during automated Steam setup:\n\n{error_message}\n\nPlease check the console output for details.", - safety_level="medium" - ) + from jackify.shared.errors import JackifyError, classify_exception + from jackify.frontends.gui.services.message_service import MessageService + if not isinstance(error, JackifyError): + error = classify_exception(str(error)) + logger.error(f"Automated prefix error: {error.message}") + self._safe_append_text(f"[FAILED] {error.message}") + MessageService.show_error(self, error) self._enable_controls_after_operation() - def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None): """Continue the configuration process with the new AppID after automated prefix creation""" # Headers are now shown at start of Steam Integration # No need to show them again here - debug_print("Configuration phase continues after Steam Integration") + logger.debug("Configuration phase continues after Steam Integration") - debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") + logger.debug(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") try: # Get resolution from UI resolution = self.resolution_combo.currentText() @@ -305,7 +296,7 @@ class ConfigureNewModlistWorkflowMixin: 'game_name': 'Skyrim Special Edition' # Default for new modlist } self.context = updated_context # Ensure context is always set - debug_print(f"Updated context with new AppID: {new_appid}") + logger.debug(f"Updated context with new AppID: {new_appid}") # Create new config thread with updated context from PySide6.QtCore import QThread, Signal @@ -391,12 +382,10 @@ class ConfigureNewModlistWorkflowMixin: self.config_thread.start() except Exception as e: + logger.error("Error continuing configuration: %s", e, exc_info=True) self._safe_append_text(f"Error continuing configuration: {e}") - import traceback - self._safe_append_text(f"Full traceback: {traceback.format_exc()}") self.on_configuration_error(str(e)) - def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir): """Continue the configuration process with the corrected AppID after manual steps validation""" try: @@ -414,7 +403,7 @@ class ConfigureNewModlistWorkflowMixin: 'appid': new_appid, # Use the NEW AppID from Steam 'game_name': 'Skyrim Special Edition' # Default for new modlist } - debug_print(f"Updated context with new AppID: {new_appid}") + logger.debug(f"Updated context with new AppID: {new_appid}") # Create new config thread with updated context (same as Tuxborn) from PySide6.QtCore import QThread, Signal @@ -500,9 +489,9 @@ class ConfigureNewModlistWorkflowMixin: self.config_thread.start() except Exception as e: + logger.error("Error continuing configuration: %s", e, exc_info=True) self._safe_append_text(f"Error continuing configuration: {e}") - MessageService.critical(self, "Configuration Error", f"Failed to continue configuration: {e}", safety_level="medium") - + MessageService.show_error(self, configuration_failed(str(e))) def _calculate_time_taken(self) -> str: """Calculate and format the time taken for the workflow""" @@ -521,4 +510,3 @@ class ConfigureNewModlistWorkflowMixin: else: return f"{elapsed_seconds_remainder} seconds" - diff --git a/jackify/frontends/gui/screens/install_mo2_screen.py b/jackify/frontends/gui/screens/install_mo2_screen.py new file mode 100644 index 0000000..014e080 --- /dev/null +++ b/jackify/frontends/gui/screens/install_mo2_screen.py @@ -0,0 +1,454 @@ +""" +Install MO2 Screen + +Downloads and configures a standalone Mod Organizer 2 instance via +MO2SetupService. No Wabbajack modlist required. +""" + +import logging +import os +from pathlib import Path +from typing import Optional + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QFileDialog, QLineEdit, QGridLayout, QTextEdit, QCheckBox, + QMessageBox, QSizePolicy, +) +from PySide6.QtCore import Qt, QThread, Signal, QSize + +from jackify.backend.models.configuration import SystemInfo +from jackify.shared.errors import mo2_setup_failed +from jackify.shared.progress_models import FileProgress, OperationType +from ..services.message_service import MessageService +from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS +from ..utils import set_responsive_minimum +from ..widgets.progress_indicator import OverallProgressIndicator +from ..widgets.file_progress_list import FileProgressList +from .screen_back_mixin import ScreenBackMixin + +logger = logging.getLogger(__name__) + + +class MO2SetupWorker(QThread): + """Background worker for standalone MO2 setup""" + + progress_update = Signal(str) + log_output = Signal(str) + setup_complete = Signal(bool, object, str) # success, app_id (int|None), error_msg + + def __init__(self, install_dir: Path, shortcut_name: str): + super().__init__() + self.install_dir = install_dir + self.shortcut_name = shortcut_name + + def run(self): + from jackify.backend.services.mo2_setup_service import MO2SetupService + + def _progress(msg: str): + if self.isInterruptionRequested(): + return + self.progress_update.emit(msg) + self.log_output.emit(msg) + + try: + service = MO2SetupService() + success, app_id, error_msg = service.setup_mo2( + install_dir=self.install_dir, + shortcut_name=self.shortcut_name, + progress_callback=_progress, + should_cancel=self.isInterruptionRequested, + ) + if self.isInterruptionRequested(): + self.setup_complete.emit(False, None, "MO2 setup cancelled.") + return + self.setup_complete.emit(success, app_id, error_msg or "") + except Exception as e: + logger.exception("Unhandled exception in MO2 setup worker") + self.setup_complete.emit(False, None, str(e)) + + +class InstallMO2Screen(ScreenBackMixin, QWidget): + """Standalone MO2 setup screen""" + + resize_request = Signal(str) + + def __init__( + self, + stacked_widget=None, + additional_tasks_index: int = 3, + system_info: Optional[SystemInfo] = None, + ): + super().__init__() + self.stacked_widget = stacked_widget + self.main_menu_index = additional_tasks_index + self.additional_tasks_index = additional_tasks_index + self.system_info = system_info or SystemInfo(is_steamdeck=False) + self.debug = DEBUG_BORDERS + self.worker = None + + self._user_manually_scrolled = False + self._was_at_bottom = True + + self.progress_indicator = OverallProgressIndicator(show_progress_bar=False) + self.progress_indicator.set_status("Ready", 0) + + self.file_progress_list = FileProgressList() + + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_layout.setContentsMargins(50, 50, 50, 0) + main_layout.setSpacing(12) + + self._setup_header(main_layout) + self._setup_upper_section(main_layout) + self._setup_status_banner(main_layout) + self._setup_console(main_layout) + self._setup_buttons(main_layout) + + def _setup_header(self, layout): + header_layout = QVBoxLayout() + header_layout.setSpacing(1) + + title = QLabel("Setup Mod Organizer 2") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") + title.setAlignment(Qt.AlignHCenter) + title.setMaximumHeight(30) + header_layout.addWidget(title) + + desc = QLabel("Download and configure a standalone MO2 instance with a Proton prefix") + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(40) + header_layout.addWidget(desc) + + header_widget = QWidget() + header_widget.setLayout(header_layout) + header_widget.setMaximumHeight(75) + layout.addWidget(header_widget) + + def _setup_upper_section(self, layout): + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + + # Left: form + form_widget = self._build_form_widget() + upper_hbox.addWidget(form_widget, stretch=11) + + # Right: activity window + activity_header = QLabel("[Activity]") + activity_header.setStyleSheet( + f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;" + ) + activity_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + + self.file_progress_list.setMinimumSize(QSize(300, 20)) + self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + activity_vbox = QVBoxLayout() + activity_vbox.setContentsMargins(0, 0, 0, 0) + activity_vbox.setSpacing(2) + activity_vbox.addWidget(activity_header) + activity_vbox.addWidget(self.file_progress_list, stretch=1) + + activity_widget = QWidget() + activity_widget.setLayout(activity_vbox) + activity_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + upper_hbox.addWidget(activity_widget, stretch=9) + + upper_section = QWidget() + upper_section.setLayout(upper_hbox) + upper_section.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + upper_section.setMaximumHeight(240) + layout.addWidget(upper_section) + + def _build_form_widget(self): + form_vbox = QVBoxLayout() + form_vbox.setAlignment(Qt.AlignTop) + form_vbox.setContentsMargins(0, 0, 0, 0) + form_vbox.setSpacing(8) + + options_header = QLabel("[Options]") + options_header.setStyleSheet( + f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;" + ) + options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + form_vbox.addWidget(options_header) + + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(8) + form_grid.setContentsMargins(0, 0, 0, 0) + + # Shortcut name + form_grid.addWidget(QLabel("Shortcut Name:"), 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + self.shortcut_name_edit = QLineEdit("Mod Organizer 2") + self.shortcut_name_edit.setMaximumHeight(25) + form_grid.addWidget(self.shortcut_name_edit, 0, 1) + + # Install directory + form_grid.addWidget(QLabel("Install Directory:"), 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + default_dir = str(Path.home() / "ModOrganizer2") + self.install_dir_edit = QLineEdit(default_dir) + self.install_dir_edit.setMaximumHeight(25) + + browse_btn = QPushButton("Browse") + browse_btn.setFixedSize(80, 25) + browse_btn.clicked.connect(self._browse_folder) + + dir_hbox = QHBoxLayout() + dir_hbox.addWidget(self.install_dir_edit) + dir_hbox.addWidget(browse_btn) + form_grid.addLayout(dir_hbox, 1, 1) + + form_vbox.addLayout(form_grid) + + info = QLabel( + "Jackify will download the latest Mod Organizer 2 release from GitHub, extract it to the " + "chosen directory, add it as a non-Steam game, and configure a Proton prefix automatically. " + "Steam will be restarted during this process." + ) + info.setWordWrap(True) + info.setStyleSheet("color: #999; font-size: 11px;") + form_vbox.addWidget(info) + + form_widget = QWidget() + form_widget.setLayout(form_vbox) + form_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + return form_widget + + def _setup_status_banner(self, layout): + banner_row = QHBoxLayout() + banner_row.setContentsMargins(0, 0, 0, 0) + banner_row.setSpacing(8) + banner_row.addWidget(self.progress_indicator, 1) + banner_row.addStretch() + + self.show_details_checkbox = QCheckBox("Show details") + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) + banner_row.addWidget(self.show_details_checkbox) + + banner_widget = QWidget() + banner_widget.setLayout(banner_row) + banner_widget.setMaximumHeight(45) + banner_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + layout.addWidget(banner_widget) + + def _setup_console(self, layout): + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.console.setMinimumHeight(50) + self.console.setMaximumHeight(1000) + self.console.setFontFamily('monospace') + self.console.setVisible(False) + + scrollbar = self.console.verticalScrollBar() + scrollbar.sliderPressed.connect(lambda: setattr(self, '_user_manually_scrolled', True)) + scrollbar.sliderReleased.connect(lambda: setattr(self, '_user_manually_scrolled', False)) + scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) + + layout.addWidget(self.console, stretch=1) + + def _on_scrollbar_value_changed(self): + scrollbar = self.console.verticalScrollBar() + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + def _setup_buttons(self, layout): + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + + self.start_btn = QPushButton("Start Setup") + self.start_btn.setFixedHeight(35) + self.start_btn.clicked.connect(self._start_setup) + btn_row.addWidget(self.start_btn) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setFixedHeight(35) + self.cancel_btn.clicked.connect(self._go_back) + btn_row.addWidget(self.cancel_btn) + + btn_widget = QWidget() + btn_widget.setLayout(btn_row) + btn_widget.setMaximumHeight(50) + layout.addWidget(btn_widget) + + def _on_show_details_toggled(self, checked): + self.console.setVisible(checked) + self.resize_request.emit("expand" if checked else "collapse") + + def _browse_folder(self): + folder = QFileDialog.getExistingDirectory( + self, "Select MO2 Installation Folder", str(Path.home()), QFileDialog.ShowDirsOnly + ) + if folder: + self.install_dir_edit.setText(os.path.realpath(folder)) + + # ------------------------------------------------------------------ + # Activity window helpers + # ------------------------------------------------------------------ + + # Maps a substring of the progress message to (item_id, display_label, OperationType, percent) + _ACTIVITY_MAP = [ + ("Fetching latest MO2", "fetch", "Fetching release info", OperationType.UNKNOWN, 0.0), + ("Downloading ", "download", "Downloading MO2 archive", OperationType.DOWNLOAD, 0.0), + ("Extracting to ", "extract", "Extracting archive", OperationType.EXTRACT, 0.0), + ("MO2 installed at", "extract", "Extracting archive", OperationType.EXTRACT, 100.0), + ("Creating Steam shortcut", "prefix", "Creating Steam shortcut & prefix", OperationType.INSTALL, 0.0), + ("MO2 setup complete", "complete", "Setup complete", OperationType.INSTALL, 100.0), + ] + + def _on_activity_progress(self, message: str): + for trigger, item_id, label, op_type, pct in self._ACTIVITY_MAP: + if trigger in message: + fp = FileProgress( + filename=label, + operation=op_type, + percent=pct, + current_size=0, + total_size=0, + ) + self.file_progress_list.update_files([fp]) + break + + # ------------------------------------------------------------------ + + def _start_setup(self): + install_dir_text = self.install_dir_edit.text().strip() + if not install_dir_text: + MessageService.warning(self, "No Directory", "Please select an installation directory.") + return + + install_dir = Path(install_dir_text).resolve() + shortcut_name = self.shortcut_name_edit.text().strip() or "Mod Organizer 2" + + confirm = MessageService.question( + self, + "Confirm MO2 Setup", + f"Install Mod Organizer 2 to:\n{install_dir}\n\n" + "Jackify will download MO2, add it to Steam, and configure a Proton prefix.\n" + "Steam will be restarted during this process.\n\nContinue?", + safety_level="medium", + ) + if confirm != QMessageBox.Yes: + return + + self.console.clear() + self.file_progress_list.clear() + self.file_progress_list.start_cpu_tracking() + + self.start_btn.setEnabled(False) + self.cancel_btn.setEnabled(False) + self.progress_indicator.set_status("Starting...", 0) + + self.worker = MO2SetupWorker(install_dir, shortcut_name) + self.worker.progress_update.connect(self._on_progress_update) + self.worker.progress_update.connect(self._on_activity_progress) + self.worker.log_output.connect(self._on_log_output) + self.worker.setup_complete.connect(self._on_setup_complete) + self.worker.start() + + def _on_progress_update(self, message: str): + self.progress_indicator.set_status(message, 0) + + def _on_log_output(self, message: str): + scrollbar = self.console.verticalScrollBar() + was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + self.console.append(message) + if was_at_bottom and not self._user_manually_scrolled: + scrollbar.setValue(scrollbar.maximum()) + + def _on_setup_complete(self, success: bool, app_id, error_msg: str): + self.file_progress_list.stop_cpu_tracking() + + if success: + self.progress_indicator.set_status("Setup complete!", 100) + MessageService.information( + self, + "MO2 Setup Complete", + f"Mod Organizer 2 has been installed and configured.\n\n" + f"Steam AppID: {app_id}\n\n" + "Launch MO2 from your Steam library.", + ) + self.install_dir_edit.setText(str(Path.home() / "ModOrganizer2")) + self.shortcut_name_edit.setText("Mod Organizer 2") + else: + self.progress_indicator.set_status("Setup failed", 0) + MessageService.show_error(self, mo2_setup_failed(error_msg or "Setup failed.")) + + self.start_btn.setEnabled(True) + self.cancel_btn.setEnabled(True) + if self.worker is not None: + try: + self.worker.deleteLater() + except Exception: + pass + self.worker = None + + def _go_back(self): + if self.worker and self.worker.isRunning(): + reply = MessageService.question( + self, + "MO2 Setup In Progress", + "MO2 setup is still running. Leave this screen and cancel setup?", + critical=False, + safety_level="medium", + ) + if reply != QMessageBox.Yes: + return + self.cleanup_processes() + self.collapse_show_details_before_leave() + self.go_back() + + def cleanup_processes(self): + """Stop active MO2 worker and CPU tracking before screen/app shutdown.""" + try: + self.file_progress_list.stop_cpu_tracking() + except Exception: + pass + + if self.worker is not None: + try: + if self.worker.isRunning(): + self.worker.requestInterruption() + if not self.worker.wait(5000): + self.worker.terminate() + self.worker.wait(10000) + self.worker.deleteLater() + except Exception: + pass + self.worker = None + + def reset_screen_to_defaults(self): + self.file_progress_list.clear() + self.console.clear() + self.progress_indicator.set_status("Ready", 0) + if self.show_details_checkbox.isChecked(): + self.show_details_checkbox.blockSignals(True) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.blockSignals(False) + self.console.setVisible(False) + self.resize_request.emit("collapse") + + def showEvent(self, event): + super().showEvent(event) + # Keep MO2 screen consistent with other workflows: details collapsed by default. + if self.show_details_checkbox.isChecked(): + self.show_details_checkbox.blockSignals(True) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.blockSignals(False) + self.console.setVisible(False) + self.resize_request.emit("collapse") + try: + main_window = self.window() + if main_window: + main_window.setMaximumSize(QSize(16777215, 16777215)) + set_responsive_minimum(main_window, min_width=960, min_height=420) + except Exception: + pass diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py index 4bdfe4c..ff73fbc 100644 --- a/jackify/frontends/gui/screens/install_modlist.py +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -31,8 +31,11 @@ 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, FileProgress +from jackify.shared.errors import manual_steps_incomplete # Modlist gallery (imported at module level to avoid import delay when opening dialog) from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog +import logging +logger = logging.getLogger(__name__) from .install_modlist_dialogs import ModlistFetchThread, SelectionDialog from .install_modlist_ui_setup import InstallModlistUISetupMixin from .install_modlist_console import ConsoleOutputMixin @@ -47,15 +50,7 @@ from .install_modlist_nexus import NexusAuthMixin from .install_modlist_selection import ModlistSelectionMixin from .screen_back_mixin import ScreenBackMixin -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin): - steam_restart_finished = Signal(bool, str) resize_request = Signal(str) # Signal for expand/collapse like TTW screen def _collect_actionable_controls(self): """Collect all actionable controls that should be disabled during operations (except Cancel)""" @@ -220,7 +215,7 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO set_responsive_minimum(main_window, min_width=960, min_height=420) # DO NOT resize - let window stay at current size except Exception as e: - debug_print(f"DEBUG: showEvent exception: {e}") + logger.debug(f"DEBUG: showEvent exception: {e}") def _start_gallery_cache_preload(self): """DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow""" @@ -252,22 +247,22 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO # Check if we got mods modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods) if modlists_with_mods > 0: - debug_print(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)") + logger.debug(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)") else: # Cache didn't have mods, but we fetched fresh - should have mods now - debug_print("DEBUG: Gallery cache updated") + logger.debug("DEBUG: Gallery cache updated") else: - debug_print("DEBUG: Failed to load gallery cache") + logger.debug("DEBUG: Failed to load gallery cache") except Exception as e: - debug_print(f"DEBUG: Gallery cache preload error: {str(e)}") + logger.debug(f"DEBUG: Gallery cache preload error: {str(e)}") # Start thread (non-blocking, invisible to user) self._gallery_cache_preload_thread = GalleryCachePreloadThread() # Don't connect finished signal - we don't need to do anything, just let it run self._gallery_cache_preload_thread.start() - debug_print("DEBUG: Started background gallery cache preload") + logger.debug("DEBUG: Started background gallery cache preload") def hideEvent(self, event): """Called when the widget is hidden. Do not clear main window constraints so collapse from go_back() sticks.""" @@ -288,17 +283,17 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO if saved_install_parent: suggested_install_dir = os.path.join(saved_install_parent, modlist_name) self.install_dir_edit.setText(suggested_install_dir) - debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") + logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") # Update download directory suggestion saved_download_parent = self.config_handler.get_default_download_parent_dir() if saved_download_parent: suggested_download_dir = os.path.join(saved_download_parent, "Downloads") self.downloads_dir_edit.setText(suggested_download_dir) - debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") + logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") except Exception as e: - debug_print(f"DEBUG: Error updating directory suggestions: {e}") + logger.debug(f"DEBUG: Error updating directory suggestions: {e}") def _save_parent_directories(self, install_dir, downloads_dir): """Removed automatic saving - user should set defaults in settings""" @@ -380,9 +375,7 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO elif self._manual_steps_retry_count == 2: retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location." - MessageService.critical(self, "Manual Steps Incomplete", - f"Manual steps validation failed:\n\n{missing_text}\n\n" - f"Please complete the missing steps and try again.{retry_guidance}") + MessageService.show_error(self, manual_steps_incomplete()) # Show manual steps dialog again extra_warning = "" if self._manual_steps_retry_count >= 2: @@ -390,13 +383,7 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO self.show_manual_steps_dialog(extra_warning) else: # Max retries reached - MessageService.critical(self, "Manual Steps Failed", - "Manual steps validation failed after multiple attempts.\n\n" - "Common issues:\n" - "• Steam not fully restarted\n" - "• Shortcut not launched from Steam\n" - "• Flatpak Steam using different file paths\n" - "• Proton - Experimental not selected") + MessageService.show_error(self, manual_steps_incomplete()) self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) def show_next_steps_dialog(self, message): @@ -426,34 +413,77 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" - debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") - - # Clean up InstallationThread if running - if hasattr(self, 'install_thread') and self.install_thread.isRunning(): - debug_print("DEBUG: Cancelling running InstallationThread") - self.install_thread.cancel() - self.install_thread.wait(3000) # Wait up to 3 seconds - if self.install_thread.isRunning(): - self.install_thread.terminate() - - # Clean up other threads - threads = [ - 'prefix_thread', 'config_thread', 'fetch_thread' - ] - for thread_name in threads: - if hasattr(self, thread_name): - thread = getattr(self, thread_name) - if thread and thread.isRunning(): - debug_print(f"DEBUG: Terminating {thread_name}") - thread.terminate() - thread.wait(1000) # Wait up to 1 second + logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") + + def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000): + thread = getattr(self, attr_name, None) + if thread is None: + return + try: + running = thread.isRunning() + except RuntimeError: + setattr(self, attr_name, None) + return + + if not running: + setattr(self, attr_name, None) + return + + logger.debug(f"DEBUG: Stopping {attr_name}") + + if cancel_method and hasattr(thread, cancel_method): + try: + getattr(thread, cancel_method)() + except Exception: + pass + else: + try: + thread.requestInterruption() + except Exception: + pass + try: + thread.quit() + except Exception: + pass + + try: + if thread.wait(cooperative_ms): + setattr(self, attr_name, None) + return + except Exception: + pass + + logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, forcing terminate") + try: + if cancel_method and hasattr(thread, cancel_method): + getattr(thread, cancel_method)() + except Exception: + pass + try: + thread.terminate() + except Exception: + pass + try: + if not thread.wait(force_ms): + logger.error(f"ERROR: {attr_name} still running after forced shutdown window") + except Exception: + pass + setattr(self, attr_name, None) + + # Always stop installer thread first; this is the most likely source of QThread teardown aborts. + _stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000) + + # Stop remaining worker threads. + for thread_name in ('prefix_thread', 'config_thread', 'fetch_thread', '_gallery_cache_preload_thread'): + _stop_thread(thread_name) def cancel_installation(self): """Cancel the currently running installation""" reply = MessageService.question( self, "Cancel Installation", "Are you sure you want to cancel the installation?", - critical=False # Non-critical, won't steal focus + critical=False, # Non-critical, won't steal focus + safety_level="medium", ) if reply == QMessageBox.Yes: @@ -463,19 +493,20 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO self._cancellation_requested = True try: - # Clear Active Files window and reset progress indicator + # Clear Active Files window and update progress indicator if hasattr(self, 'file_progress_list'): self.file_progress_list.clear() if hasattr(self, 'progress_indicator'): - self.progress_indicator.reset() + self.progress_indicator.set_status("Cancelled", None) # Cancel the installation thread if it exists if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning(): self.install_thread.cancel() - self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown + self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python if self.install_thread.isRunning(): - self.install_thread.terminate() # Force terminate if needed - self.install_thread.wait(1000) + logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying") + self.install_thread.cancel() + self.install_thread.wait(5000) # Cancel the automated prefix thread if it exists if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning(): @@ -509,7 +540,7 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO self.show_details_checkbox.blockSignals(False) except Exception as e: - debug_print(f"ERROR: Exception during cancellation cleanup: {e}") + logger.debug(f"ERROR: Exception during cancellation cleanup: {e}") import traceback traceback.print_exc() @@ -573,4 +604,4 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html""" # Re-enable controls (in case they were disabled from previous errors) self._enable_controls_after_operation() - \ No newline at end of file + diff --git a/jackify/frontends/gui/screens/install_modlist_automated_prefix.py b/jackify/frontends/gui/screens/install_modlist_automated_prefix.py index 8b422f1..bded83a 100644 --- a/jackify/frontends/gui/screens/install_modlist_automated_prefix.py +++ b/jackify/frontends/gui/screens/install_modlist_automated_prefix.py @@ -9,183 +9,12 @@ import threading import subprocess import time import os +import logging - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class AutomatedPrefixHandlersMixin: """Mixin providing automated prefix workflow event handlers for InstallModlistScreen.""" - def restart_steam_and_configure(self): - """Restart Steam using backend service directly - DECOUPLED FROM CLI""" - debug_print("DEBUG: restart_steam_and_configure called - using direct backend service") - progress = QProgressDialog("Restarting Steam...", None, 0, 0, self) - progress.setWindowTitle("Restarting Steam") - progress.setWindowModality(Qt.WindowModal) - progress.setMinimumDuration(0) - progress.setValue(0) - progress.show() - - def do_restart(): - debug_print("DEBUG: do_restart thread started - using direct backend service") - try: - from jackify.backend.handlers.shortcut_handler import ShortcutHandler - - # Use backend service directly instead of CLI subprocess - # Get system_info from parent screen - system_info = getattr(self, 'system_info', None) - is_steamdeck = system_info.is_steamdeck if system_info else False - shortcut_handler = ShortcutHandler(steamdeck=is_steamdeck) - - debug_print("DEBUG: About to call secure_steam_restart()") - success = shortcut_handler.secure_steam_restart() - debug_print(f"DEBUG: secure_steam_restart() returned: {success}") - - out = "Steam restart completed successfully." if success else "Steam restart failed." - - except Exception as e: - debug_print(f"DEBUG: Exception in do_restart: {e}") - success = False - out = str(e) - - self.steam_restart_finished.emit(success, out) - - threading.Thread(target=do_restart, daemon=True).start() - self._steam_restart_progress = progress # Store to close later - - def _on_steam_restart_finished(self, success, out): - debug_print("DEBUG: _on_steam_restart_finished called") - # Safely cleanup progress dialog on main thread - if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress: - try: - self._steam_restart_progress.close() - self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup - except Exception as e: - debug_print(f"DEBUG: Error closing progress dialog: {e}") - finally: - self._steam_restart_progress = None - - # Controls are managed by the proper control management system - if success: - self._safe_append_text("Steam restarted successfully.") - - # Force Steam GUI to start after restart - # Ensure Steam GUI is visible after restart - # start_steam() now uses -foreground, but we'll also try to bring GUI to front - debug_print("DEBUG: Ensuring Steam GUI is visible after restart") - try: - # Wait a moment for Steam processes to stabilize - time.sleep(3) - # Try multiple methods to ensure GUI opens - # Method 1: steam:// protocol (works if Steam is running) - try: - subprocess.Popen(['xdg-open', 'steam://open/main'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - debug_print("DEBUG: Issued steam://open/main command") - time.sleep(1) - except Exception as e: - debug_print(f"DEBUG: steam://open/main failed: {e}") - - # Method 2: Direct steam -foreground command (redundant but ensures GUI) - try: - subprocess.Popen(['steam', '-foreground'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - debug_print("DEBUG: Issued steam -foreground command") - except Exception as e2: - debug_print(f"DEBUG: steam -foreground failed: {e2}") - except Exception as e: - debug_print(f"DEBUG: Error ensuring Steam GUI visibility: {e}") - - # CRITICAL: Bring Jackify window back to focus after Steam restart - # Let user continue with installation - debug_print("DEBUG: Bringing Jackify window back to focus") - try: - from PySide6.QtWidgets import QApplication - # Get the main window - use window() to get top-level widget, then find QMainWindow - top_level = self.window() - main_window = None - - # Try to find QMainWindow in the widget hierarchy - if isinstance(top_level, QMainWindow): - main_window = top_level - else: - # Walk up the parent chain - current = self - while current: - if isinstance(current, QMainWindow): - main_window = current - break - current = current.parent() - - # Last resort: use top-level widget - if not main_window and top_level: - main_window = top_level - - if main_window: - # Restore window if minimized - if hasattr(main_window, 'isMinimized') and main_window.isMinimized(): - main_window.showNormal() - - # Bring to front and activate - use multiple methods for reliability - main_window.raise_() - main_window.activateWindow() - main_window.show() - - # Aggressive focus restoration with multiple attempts - # Steam may steal focus, so we retry multiple times over several seconds - def restore_focus(): - if main_window: - try: - main_window.raise_() - main_window.activateWindow() - app = QApplication.instance() - if app and app.activeWindow() != main_window: - debug_print("DEBUG: Window not active, retrying focus restoration") - except Exception: - pass - - # Immediate attempts - QTimer.singleShot(50, restore_focus) - QTimer.singleShot(200, restore_focus) - QTimer.singleShot(500, restore_focus) - # Delayed attempts in case Steam steals focus after initial restoration - QTimer.singleShot(1000, restore_focus) - QTimer.singleShot(2000, restore_focus) - QTimer.singleShot(3000, restore_focus) - - debug_print(f"DEBUG: Jackify window focus restoration scheduled (type: {type(main_window).__name__})") - else: - debug_print("DEBUG: Could not find main window to bring to focus") - except Exception as e: - debug_print(f"DEBUG: Error bringing Jackify to focus: {e}") - - # Save context for later use in configuration - self._manual_steps_retry_count = 0 - self._current_modlist_name = self.modlist_name_edit.text().strip() - - # Save resolution for later use in configuration - resolution = self.resolution_combo.currentText() - # Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)") - if resolution != "Leave unchanged": - if " (" in resolution: - self._current_resolution = resolution.split(" (")[0] - else: - self._current_resolution = resolution - else: - self._current_resolution = None - - # Use automated prefix creation instead of manual steps - debug_print("DEBUG: Starting automated prefix creation workflow") - self._safe_append_text("Starting automated prefix creation workflow...") - self.start_automated_prefix_workflow() - else: - self._safe_append_text("Failed to restart Steam.\n" + out) - MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.") - def start_automated_prefix_workflow(self): """Start the automated prefix creation workflow""" # CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog @@ -208,7 +37,7 @@ class AutomatedPrefixHandlersMixin: # Disable controls during installation self._disable_controls_during_operation() modlist_name = self.modlist_name_edit.text().strip() - install_dir = self.install_dir_edit.text().strip() + install_dir = os.path.realpath(self.install_dir_edit.text().strip()) final_exe_path = os.path.join(install_dir, "ModOrganizer.exe") if not os.path.exists(final_exe_path): @@ -239,7 +68,7 @@ class AutomatedPrefixHandlersMixin: class AutomatedPrefixThread(QThread): finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp progress = Signal(str) # progress messages - error = Signal(str) # error messages + error = Signal(object) # error (JackifyError or str) show_progress_dialog = Signal(str) # show progress dialog with message hide_progress_dialog = Signal() # hide progress dialog conflict_detected = Signal(list) # conflicts list @@ -313,10 +142,14 @@ class AutomatedPrefixHandlersMixin: except Exception as e: # Ensure progress dialog is hidden on error self.hide_progress_dialog.emit() - self.error.emit(str(e)) + from jackify.shared.errors import JackifyError, prefix_creation_failed + if not isinstance(e, JackifyError): + e = prefix_creation_failed(str(e)) + self.error.emit(e) # Create and start thread (pass downloads_dir for STEAM_COMPAT_MOUNTS) - downloads_dir = self.downloads_dir_edit.text().strip() if getattr(self, 'downloads_dir_edit', None) else None + _dl_raw = self.downloads_dir_edit.text().strip() if getattr(self, 'downloads_dir_edit', None) else None + downloads_dir = os.path.realpath(_dl_raw) if _dl_raw else None self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path, downloads_dir) self.prefix_thread.finished.connect(self.on_automated_prefix_finished) self.prefix_thread.error.connect(self.on_automated_prefix_error) @@ -327,8 +160,8 @@ class AutomatedPrefixHandlersMixin: self.prefix_thread.start() except Exception as e: - debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}") - debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") + logger.debug(f"DEBUG: Exception in start_automated_prefix_workflow: {e}") + logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}") self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}") # Re-enable controls on exception self._enable_controls_after_operation() @@ -337,23 +170,23 @@ class AutomatedPrefixHandlersMixin: """Handle completion of automated prefix creation""" try: if success: - debug_print(f"SUCCESS: Automated prefix creation completed!") - debug_print(f"Prefix created at: {prefix_path}") + logger.debug(f"SUCCESS: Automated prefix creation completed!") + logger.debug(f"Prefix created at: {prefix_path}") if new_appid_str and new_appid_str != "0": - debug_print(f"AppID: {new_appid_str}") + logger.debug(f"AppID: {new_appid_str}") # Convert string AppID back to integer for configuration new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None # Continue with configuration using the new AppID and timestamp modlist_name = self.modlist_name_edit.text().strip() - install_dir = self.install_dir_edit.text().strip() + install_dir = os.path.realpath(self.install_dir_edit.text().strip()) self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp) else: - self._safe_append_text(f"ERROR: Automated prefix creation failed") - self._safe_append_text("Please check the logs for details") - MessageService.critical(self, "Automated Setup Failed", - "Automated prefix creation failed. Please check the console output for details.") + error_reason = last_timestamp or "Unknown error" + self._safe_append_text(f"ERROR: Automated prefix creation failed: {error_reason}") + from jackify.shared.errors import prefix_creation_failed + MessageService.show_error(self, prefix_creation_failed(str(error_reason))) # Re-enable controls on failure self._enable_controls_after_operation() self._end_post_install_feedback(success=False) @@ -361,12 +194,14 @@ class AutomatedPrefixHandlersMixin: # Always ensure controls are re-enabled when workflow truly completes pass - def on_automated_prefix_error(self, error_msg): + def on_automated_prefix_error(self, error): """Handle error in automated prefix creation""" - self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}") - MessageService.critical(self, "Automated Setup Error", - f"Error during automated prefix creation: {error_msg}") - # Re-enable controls on error + from jackify.shared.errors import JackifyError, classify_exception + if not isinstance(error, JackifyError): + error = classify_exception(str(error)) + logger.error(f"Automated prefix error: {error.message}") + self._safe_append_text(f"[FAILED] {error.message}") + MessageService.show_error(self, error) self._enable_controls_after_operation() self._end_post_install_feedback(success=False) diff --git a/jackify/frontends/gui/screens/install_modlist_configuration.py b/jackify/frontends/gui/screens/install_modlist_configuration.py index 7d6a212..26547aa 100644 --- a/jackify/frontends/gui/screens/install_modlist_configuration.py +++ b/jackify/frontends/gui/screens/install_modlist_configuration.py @@ -3,6 +3,7 @@ from PySide6.QtWidgets import QMessageBox, QProgressDialog from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QFont from jackify.frontends.gui.services.message_service import MessageService +from jackify.shared.errors import manual_steps_incomplete, configuration_failed from jackify.frontends.gui.dialogs import SuccessDialog from jackify.backend.handlers.validation_handler import ValidationHandler from jackify.backend.models.modlist import ModlistContext @@ -10,18 +11,11 @@ from pathlib import Path import traceback import os import time +import logging +logger = logging.getLogger(__name__) from .install_modlist_shortcut_dialog import InstallModlistShortcutDialogMixin - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): """Mixin providing configuration phase workflow and dialog management for InstallModlistScreen.""" @@ -50,13 +44,17 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): finally: self.steam_restart_progress = None # Controls are managed by the proper control management system + # Delay focus reclaim so Steam's window finishes painting before we steal it back + try: + from PySide6.QtCore import QTimer + win = self.window() + QTimer.singleShot(10000, lambda: (win.raise_(), win.activateWindow())) + except Exception: + pass def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str: """Detect game type by checking ModOrganizer.ini for loader executables.""" from pathlib import Path - import logging - logger = logging.getLogger(__name__) - mo2_ini = Path(install_dir) / "ModOrganizer.ini" if not mo2_ini.exists(): return 'skyrim' # Fallback to most common @@ -116,12 +114,21 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): # Check for TTW eligibility before showing final success dialog install_dir = self.install_dir_edit.text().strip() - if self._check_ttw_eligibility(modlist_name, self._current_game_type, install_dir): + ttw_modlist_name = modlist_name + try: + from jackify.backend.utils.modlist_meta import get_modlist_name + canonical_name = get_modlist_name(install_dir) + if canonical_name: + ttw_modlist_name = canonical_name + except Exception: + pass + + if self._check_ttw_eligibility(ttw_modlist_name, self._current_game_type, install_dir): # Offer TTW installation reply = MessageService.question( self, "Install TTW?", - f"{modlist_name} requires Tale of Two Wastelands!\n\n" + f"{ttw_modlist_name} requires Tale of Two Wastelands!\n\n" "Would you like to install TTW now?\n\n" "This will:\n" "• Guide you through TTW installation\n" @@ -136,14 +143,16 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): ) if reply == QMessageBox.Yes: + self._cleanup_config_thread() # Navigate to TTW screen - self._initiate_ttw_workflow(modlist_name, install_dir) + self._initiate_ttw_workflow(ttw_modlist_name, install_dir) return # Don't show success dialog yet, will show after TTW completes # Check for VNV post-install automation after TTW check vnv_automation_running = self._check_and_run_vnv_automation(modlist_name, install_dir) if vnv_automation_running: + self._cleanup_config_thread() # Store success dialog params for later (after VNV automation completes) self._pending_success_dialog_params = { 'modlist_name': modlist_name, @@ -179,60 +188,49 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): enb_dialog.exec() # Modal dialog - blocks until user clicks OK except Exception as e: # Non-blocking: if dialog fails, just log and continue - import logging - logger = logging.getLogger(__name__) logger.warning(f"Failed to show ENB dialog: {e}") elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3: # Max retries reached - show failure message self._end_post_install_feedback(False) - MessageService.critical(self, "Manual Steps Failed", - "Manual steps validation failed after multiple attempts.") + MessageService.show_error(self, manual_steps_incomplete()) else: # Configuration failed for other reasons self._end_post_install_feedback(False) - MessageService.critical(self, "Configuration Failed", - "Post-install configuration failed. Please check the console output.") + MessageService.show_error(self, configuration_failed("Post-install configuration failed.")) except Exception as e: # Ensure controls are re-enabled even on unexpected errors self._enable_controls_after_operation() raise - # Clean up thread - if hasattr(self, 'config_thread') and self.config_thread is not None: - # Disconnect all signals to prevent "Internal C++ object already deleted" errors - try: - self.config_thread.progress_update.disconnect() - self.config_thread.configuration_complete.disconnect() - self.config_thread.error_occurred.disconnect() - except: - pass # Ignore errors if already disconnected - if self.config_thread.isRunning(): - self.config_thread.quit() - self.config_thread.wait(5000) # Wait up to 5 seconds - self.config_thread.deleteLater() - self.config_thread = None + self._cleanup_config_thread() def on_configuration_error(self, error_message): """Handle configuration error on main thread""" self._safe_append_text(f"Configuration failed with error: {error_message}") - MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}") + MessageService.show_error(self, configuration_failed(str(error_message))) # Re-enable all controls on error self._enable_controls_after_operation() - # Clean up thread - if hasattr(self, 'config_thread') and self.config_thread is not None: - # Disconnect all signals to prevent "Internal C++ object already deleted" errors - try: - self.config_thread.progress_update.disconnect() - self.config_thread.configuration_complete.disconnect() - self.config_thread.error_occurred.disconnect() - except: - pass # Ignore errors if already disconnected - if self.config_thread.isRunning(): - self.config_thread.quit() - self.config_thread.wait(5000) # Wait up to 5 seconds - self.config_thread.deleteLater() - self.config_thread = None + self._cleanup_config_thread() + + def _cleanup_config_thread(self): + """Safely stop and release the configuration worker thread.""" + if not hasattr(self, 'config_thread') or self.config_thread is None: + return + + try: + self.config_thread.progress_update.disconnect() + self.config_thread.configuration_complete.disconnect() + self.config_thread.error_occurred.disconnect() + except (RuntimeError, TypeError): + pass + + if self.config_thread.isRunning(): + self.config_thread.quit() + self.config_thread.wait(5000) + + self.config_thread.deleteLater() + self.config_thread = None def show_manual_steps_dialog(self, extra_warning=""): modlist_name = self.modlist_name_edit.text().strip() or "your modlist" @@ -278,12 +276,12 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): mo2_exe_path = self._get_mo2_path(install_dir, modlist_name) # Add delay to allow Steam filesystem updates to complete - self._safe_append_text("Waiting for Steam filesystem updates to complete...") + logger.info("Waiting for Steam filesystem updates to complete...") time.sleep(2) # CRITICAL: Re-detect the AppID after Steam restart and manual steps # Steam assigns a NEW AppID during restart, different from the one we initially created - self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...") + logger.info(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...") from jackify.backend.handlers.shortcut_handler import ShortcutHandler from jackify.backend.services.platform_detection_service import PlatformDetectionService @@ -299,7 +297,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): return self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}") - self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}") + logger.info(f"Validating manual steps completion for AppID: {current_appid}") # Check 1: Proton version proton_ok = False @@ -326,12 +324,12 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): modlist_handler.compat_data_path = Path(compat_data_path_str) # Check Proton version - self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...") + logger.info(f"Attempting to detect Proton version for AppID {current_appid}...") if modlist_handler._detect_proton_version(): - self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'") + logger.info(f"Raw detected Proton version: '{modlist_handler.proton_ver}'") if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower(): proton_ok = True - self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}") + logger.info(f"Proton version validated: {modlist_handler.proton_ver}") else: self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") else: @@ -347,14 +345,14 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): from jackify.backend.handlers.path_handler import PathHandler path_handler = PathHandler() - self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...") - self._safe_append_text("Checking standard Steam locations and Flatpak Steam...") + logger.info(f"Searching for compatdata directory for AppID {current_appid}...") + logger.info("Checking standard Steam locations and Flatpak Steam...") prefix_path_str = path_handler.find_compat_data(current_appid) - self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'") + logger.info(f"Compatdata search result: '{prefix_path_str}'") if prefix_path_str and os.path.isdir(prefix_path_str): compatdata_ok = True - self._safe_append_text(f"Compatdata directory found: {prefix_path_str}") + logger.info(f"Compatdata directory found: {prefix_path_str}") else: if prefix_path_str: self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") @@ -370,7 +368,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): # Handle validation results if proton_ok and compatdata_ok: self._safe_append_text("Manual steps validation passed!") - self._safe_append_text("Continuing configuration with updated AppID...") + logger.info("Continuing configuration with updated AppID...") # Continue configuration with the corrected AppID and context self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) @@ -390,9 +388,9 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): """Continue the configuration process with the new AppID after automated prefix creation""" # Headers are now shown at start of Steam Integration # No need to show them again here - debug_print("Configuration phase continues after Steam Integration") + logger.debug("Configuration phase continues after Steam Integration") - debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") + logger.debug(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") try: # Update the context with the new AppID (same format as manual steps) updated_context = { @@ -408,7 +406,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition' } self.context = updated_context # Ensure context is always set - debug_print(f"Updated context with new AppID: {new_appid}") + logger.debug(f"Updated context with new AppID: {new_appid}") # Get Steam Deck detection once and pass to ConfigThread from jackify.backend.services.platform_detection_service import PlatformDetectionService @@ -514,7 +512,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): 'appid': new_appid # Use the NEW AppID from Steam } - debug_print(f"Updated context with new AppID: {new_appid}") + logger.debug(f"Updated context with new AppID: {new_appid}") # Clean up old thread if exists and wait for it to finish if hasattr(self, 'config_thread') and self.config_thread is not None: @@ -523,7 +521,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() - except: + except (RuntimeError, TypeError): pass # Ignore errors if already disconnected if self.config_thread.isRunning(): self.config_thread.quit() @@ -622,4 +620,3 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): self.error_occurred.emit(str(e)) return ConfigThread(context, is_steamdeck, detect_game_type_func, parent=self) - diff --git a/jackify/frontends/gui/screens/install_modlist_console.py b/jackify/frontends/gui/screens/install_modlist_console.py index 173f5c1..e5e108f 100644 --- a/jackify/frontends/gui/screens/install_modlist_console.py +++ b/jackify/frontends/gui/screens/install_modlist_console.py @@ -3,7 +3,6 @@ from PySide6.QtCore import Qt, QTimer from PySide6.QtWidgets import QSizePolicy, QApplication from PySide6.QtGui import QTextCursor from jackify.frontends.gui.services.message_service import MessageService -import re class ConsoleOutputMixin: @@ -147,8 +146,16 @@ class ConsoleOutputMixin: self._write_to_log_file(message) return - # CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode) msg_lower = message.lower() + # Engine informational line; keep in debug log only to reduce user-facing noise. + if ( + 'contains files with foreign characters' in msg_lower and + 'using proton 7z.exe for extraction' in msg_lower + ): + self._write_to_log_file(message) + return + + # CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode) token_error_keywords = [ 'token has expired', 'token expired', @@ -165,11 +172,9 @@ class ConsoleOutputMixin: is_token_error = any(keyword in msg_lower for keyword in token_error_keywords) if is_token_error: - # CRITICAL ERROR - always show, even if console is hidden - if not hasattr(self, '_token_error_notified'): + if not self._token_error_notified: self._token_error_notified = True - # Show error dialog immediately - MessageService.error( + MessageService.critical( self, "Authentication Error", ( @@ -268,7 +273,7 @@ class ConsoleOutputMixin: scrollbar = self.console.verticalScrollBar() # Check if user was at bottom BEFORE adding text - was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Add the text self.console.append(clean_text) @@ -365,4 +370,3 @@ class ConsoleOutputMixin: except Exception: # Logging should never break the workflow pass - diff --git a/jackify/frontends/gui/screens/install_modlist_installer_thread.py b/jackify/frontends/gui/screens/install_modlist_installer_thread.py index 104ebf6..1979d3c 100644 --- a/jackify/frontends/gui/screens/install_modlist_installer_thread.py +++ b/jackify/frontends/gui/screens/install_modlist_installer_thread.py @@ -5,10 +5,17 @@ Signals are defined at class level (required for Qt signal/slot). import os import re +import threading +from typing import Optional from PySide6.QtCore import QThread, Signal +import logging + +from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code +from jackify.shared.errors import JackifyError +logger = logging.getLogger(__name__) class InstallerThread(QThread): """Runs jackify-engine install in a background thread. Signals at class level.""" @@ -35,28 +42,46 @@ class InstallerThread(QThread): self._premium_signal_sent = False self._engine_output_buffer = [] self._buffer_size = 10 + self.last_error: Optional[JackifyError] = None + self._raw_stderr_lines: list = [] # bounded ring buffer for non-JSON stderr def cancel(self): self.cancelled = True if self.process_manager: self.process_manager.cancel() + def _read_stderr(self): + try: + for raw in self.process_manager.proc.stderr: + line = raw.decode('utf-8', errors='replace').strip() + if not line: + continue + logger.debug(f"Engine stderr: {line}") + error = parse_engine_error_line(line) + if error and self.last_error is None: + self.last_error = error + else: + self._raw_stderr_lines.append(line) + if len(self._raw_stderr_lines) > 20: + self._raw_stderr_lines.pop(0) + except Exception as e: + logger.debug(f"Stderr reader error: {e}") + def run(self): - from .install_modlist import debug_print try: from jackify.backend.core.modlist_operations import get_jackify_engine_path engine_path = get_jackify_engine_path() if not os.path.exists(engine_path): error_msg = f"Engine not found at: {engine_path}" - debug_print(f"DEBUG: {error_msg}") + logger.debug(f"DEBUG: {error_msg}") self.installation_finished.emit(False, error_msg) return if not os.access(engine_path, os.X_OK): error_msg = f"Engine is not executable: {engine_path}" - debug_print(f"DEBUG: {error_msg}") + logger.debug(f"DEBUG: {error_msg}") self.installation_finished.emit(False, error_msg) return - debug_print(f"DEBUG: Using engine at: {engine_path}") + logger.debug(f"DEBUG: Using engine at: {engine_path}") if self.install_mode == 'file': cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] else: @@ -66,9 +91,9 @@ class InstallerThread(QThread): debug_mode = config_handler.get('debug_mode', False) if debug_mode: cmd.append('--debug') - debug_print("DEBUG: Added --debug flag to jackify-engine command") - debug_print(f"DEBUG: FULL Engine command: {' '.join(cmd)}") - debug_print(f"DEBUG: modlist value being passed: '{self.modlist}'") + logger.debug("DEBUG: Added --debug flag to jackify-engine command") + logger.debug(f"DEBUG: FULL Engine command: {' '.join(cmd)}") + logger.debug(f"DEBUG: modlist value being passed: '{self.modlist}'") from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env env_vars = {'NEXUS_API_KEY': self.api_key} if self.oauth_info: @@ -77,7 +102,9 @@ class InstallerThread(QThread): env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID env = get_clean_subprocess_env(env_vars) from jackify.backend.handlers.subprocess_utils import ProcessManager - self.process_manager = ProcessManager(cmd, env=env, text=False) + self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True) + stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) + stderr_thread.start() ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') buffer = b'' last_was_blank = False @@ -100,8 +127,6 @@ class InstallerThread(QThread): is_premium_error, matched_pattern = is_non_premium_indicator(decoded) if not self._premium_signal_sent and is_premium_error: self._premium_signal_sent = True - import logging - logger = logging.getLogger(__name__) logger.warning("=" * 80) logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)") logger.warning("=" * 80) @@ -141,7 +166,7 @@ class InstallerThread(QThread): if updated: progress_state = self.progress_state_manager.get_state() if progress_state.active_files and debug_mode: - debug_print(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") + logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") self.progress_updated.emit(progress_state) if '[FILE_PROGRESS]' in decoded: parts = decoded.split('[FILE_PROGRESS]', 1) @@ -157,8 +182,6 @@ class InstallerThread(QThread): is_premium_error, matched_pattern = is_non_premium_indicator(decoded) if not self._premium_signal_sent and is_premium_error: self._premium_signal_sent = True - import logging - logger = logging.getLogger(__name__) logger.warning("=" * 80) logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)") logger.warning("=" * 80) @@ -200,7 +223,7 @@ class InstallerThread(QThread): if updated: progress_state = self.progress_state_manager.get_state() if progress_state.active_files and debug_mode: - debug_print(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") + logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") self.progress_updated.emit(progress_state) if '[FILE_PROGRESS]' in decoded: parts = decoded.split('[FILE_PROGRESS]', 1) @@ -224,6 +247,7 @@ class InstallerThread(QThread): self.output_received.emit(parts[0].rstrip()) else: self.output_received.emit(decoded) + stderr_thread.join(timeout=5) returncode = self.process_manager.wait() if self.process_manager.proc and self.process_manager.proc.stdout: try: @@ -231,7 +255,7 @@ class InstallerThread(QThread): if remaining: decoded_remaining = remaining.decode('utf-8', errors='replace') if decoded_remaining.strip(): - debug_print(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}") + logger.debug(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}") if '[FILE_PROGRESS]' in decoded_remaining: parts = decoded_remaining.split('[FILE_PROGRESS]', 1) if parts[0].strip(): @@ -239,16 +263,28 @@ class InstallerThread(QThread): else: self.output_received.emit(decoded_remaining) except Exception as e: - debug_print(f"DEBUG: Error reading remaining output: {e}") + logger.debug(f"DEBUG: Error reading remaining output: {e}") + if returncode != 0 and not self.cancelled and self.last_error is None: + stderr_detail = "\n".join(self._raw_stderr_lines[-10:]) if self._raw_stderr_lines else "" + detail = f"Exit code {returncode}.\n\nEngine output:\n{stderr_detail}" if stderr_detail else f"Exit code {returncode}." + fallback = error_from_exit_code( + returncode, + detail, + context={ + "exit_code": returncode, + "stderr_tail_lines": len(self._raw_stderr_lines[-10:]), + }, + ) + if fallback: + self.last_error = fallback + if self.cancelled: self.installation_finished.emit(False, "Installation cancelled by user") elif returncode == 0: self.installation_finished.emit(True, "Installation completed successfully") else: error_msg = f"Installation failed (exit code {returncode})" - debug_print(f"DEBUG: Engine exited with code {returncode}") - if self.process_manager.proc: - debug_print("DEBUG: Process stderr/stdout may contain error details") + logger.debug(f"DEBUG: Engine exited with code {returncode}") self.installation_finished.emit(False, error_msg) except Exception as e: self.installation_finished.emit(False, f"Installation error: {str(e)}") diff --git a/jackify/frontends/gui/screens/install_modlist_output_mixin.py b/jackify/frontends/gui/screens/install_modlist_output_mixin.py index 5152603..5bd5483 100644 --- a/jackify/frontends/gui/screens/install_modlist_output_mixin.py +++ b/jackify/frontends/gui/screens/install_modlist_output_mixin.py @@ -6,8 +6,10 @@ on_installation_output, on_installation_progress, on_premium_required_detected, import time from jackify.shared.progress_models import InstallationPhase, OperationType, FileProgress +import logging +logger = logging.getLogger(__name__) class InstallModlistOutputMixin: """Mixin providing signal handlers for InstallerThread output/progress/premium/progress_updated.""" @@ -17,6 +19,12 @@ class InstallModlistOutputMixin: self._write_to_log_file(message) return msg_lower = message.lower() + if ( + 'contains files with foreign characters' in msg_lower and + 'using proton 7z.exe for extraction' in msg_lower + ): + self._write_to_log_file(message) + return token_error_keywords = [ 'token has expired', 'token expired', 'oauth token', 'authentication failed', 'unauthorized', '401', '403', 'refresh token', 'authorization failed', @@ -24,10 +32,10 @@ class InstallModlistOutputMixin: ] is_token_error = any(keyword in msg_lower for keyword in token_error_keywords) if is_token_error: - if not hasattr(self, '_token_error_notified'): + if not self._token_error_notified: self._token_error_notified = True from jackify.frontends.gui.services.message_service import MessageService - MessageService.error( + MessageService.critical( self, "Authentication Error", ( @@ -104,6 +112,10 @@ class InstallModlistOutputMixin: if is_stalled and has_active_downloads: if self._stalled_download_start_time is None: self._stalled_download_start_time = time.time() + self._stalled_data_snapshot = progress_state.data_processed + elif progress_state.data_processed > self._stalled_data_snapshot: + self._stalled_download_start_time = time.time() + self._stalled_data_snapshot = progress_state.data_processed else: stalled_duration = time.time() - self._stalled_download_start_time if stalled_duration > 120 and not self._stalled_download_notified: @@ -133,6 +145,7 @@ class InstallModlistOutputMixin: else: self._stalled_download_start_time = None self._stalled_download_notified = False + self._stalled_data_snapshot = 0 self.progress_indicator.update_progress(progress_state) phase_label = progress_state.get_phase_label() is_installation_phase = ( @@ -206,14 +219,12 @@ class InstallModlistOutputMixin: except RuntimeError as e: if "already deleted" in str(e): if getattr(self, 'debug', False): - from .install_modlist import debug_print - debug_print(f"DEBUG: Ignoring widget deletion error: {e}") + logger.debug(f"DEBUG: Ignoring widget deletion error: {e}") return raise except Exception as e: if getattr(self, 'debug', False): - from .install_modlist import debug_print - debug_print(f"DEBUG: Error updating file progress list: {e}") + logger.debug(f"DEBUG: Error updating file progress list: {e}") import logging logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) else: diff --git a/jackify/frontends/gui/screens/install_modlist_progress.py b/jackify/frontends/gui/screens/install_modlist_progress.py index 128eb00..3ec1429 100644 --- a/jackify/frontends/gui/screens/install_modlist_progress.py +++ b/jackify/frontends/gui/screens/install_modlist_progress.py @@ -3,19 +3,13 @@ from PySide6.QtCore import QProcess from PySide6.QtWidgets import QMessageBox from PySide6.QtGui import QTextCursor from jackify.frontends.gui.services.message_service import MessageService +from jackify.shared.errors import wabbajack_install_failed from jackify.shared.progress_models import InstallationPhase, OperationType, InstallationProgress, FileProgress from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator import time +import logging - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class ProgressHandlersMixin: """Mixin providing progress tracking and installation event handlers for InstallModlistScreen.""" @@ -44,7 +38,9 @@ class ProgressHandlersMixin: ) if engine_line: + logger.warning(f"Nexus Premium required, engine message: {engine_line}") self._safe_append_text(f"[Jackify] Engine message: {engine_line}") + logger.warning("Nexus Premium required for this modlist install") self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.") MessageService.critical( @@ -87,11 +83,17 @@ class ProgressHandlersMixin: if is_stalled and has_active_downloads: if self._stalled_download_start_time is None: self._stalled_download_start_time = time.time() + self._stalled_data_snapshot = progress_state.data_processed + elif progress_state.data_processed > self._stalled_data_snapshot: + # Bytes are advancing despite 0 speed readout — engine reporting lag, not a real stall + self._stalled_download_start_time = time.time() + self._stalled_data_snapshot = progress_state.data_processed else: stalled_duration = time.time() - self._stalled_download_start_time # Warn after 2 minutes of stalled downloads if stalled_duration > 120 and not self._stalled_download_notified: self._stalled_download_notified = True + logger.warning("Downloads stalled (0.0MB/s for 2+ minutes)") MessageService.warning( self, "Download Stalled", @@ -119,6 +121,7 @@ class ProgressHandlersMixin: # Downloads are active - reset stall timer self._stalled_download_start_time = None self._stalled_download_notified = False + self._stalled_data_snapshot = 0 # Update progress indicator widget self.progress_indicator.update_progress(progress_state) @@ -259,9 +262,9 @@ class ProgressHandlersMixin: return elif progress_state.active_files: if self.debug: - debug_print(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files") + logger.debug(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files") for fp in progress_state.active_files: - debug_print(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})") + logger.debug(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})") # Pass phase label to update header (e.g., "[Activity - Downloading]") # Explicitly clear summary_info when showing file list try: @@ -270,13 +273,13 @@ class ProgressHandlersMixin: # Widget was deleted - ignore to prevent coredump if "already deleted" in str(e): if self.debug: - debug_print(f"DEBUG: Ignoring widget deletion error: {e}") + logger.debug(f"DEBUG: Ignoring widget deletion error: {e}") return raise except Exception as e: # Catch any other exceptions to prevent coredump if self.debug: - debug_print(f"DEBUG: Error updating file progress list: {e}") + logger.debug(f"DEBUG: Error updating file progress list: {e}") import logging logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) else: @@ -295,7 +298,7 @@ class ProgressHandlersMixin: def on_installation_finished(self, success, message): """Handle installation completion""" - debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}") + logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}") # R&D: Clear all progress displays when installation completes self.progress_state_manager.reset() # Clear file list but keep CPU tracking running for configuration phase @@ -313,7 +316,21 @@ class ProgressHandlersMixin: overall_percent=100.0 ) self.progress_indicator.update_progress(final_state) - + + try: + from jackify.backend.utils.modlist_meta import write_modlist_meta + thread = getattr(self, 'install_thread', None) + if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'modlist_name', None): + write_modlist_meta( + thread.install_dir, + thread.modlist_name, + getattr(self, '_current_game_type', None), + install_mode=getattr(thread, 'install_mode', 'online'), + ) + except Exception as _meta_err: + logger.debug(f"Modlist meta write skipped: {_meta_err}") + + logger.info(f"Installation succeeded: {message}") if self.show_details_checkbox.isChecked(): self._safe_append_text(f"\nSuccess: {message}") self.process_finished(0, QProcess.NormalExit) # Simulate successful completion @@ -323,18 +340,25 @@ class ProgressHandlersMixin: if self._premium_failure_active: message = "Installation stopped because Nexus Premium is required for automated downloads." - + + if not self._premium_failure_active: + engine_error = getattr(self.install_thread, 'last_error', None) + if engine_error: + self._engine_error = engine_error + self._failure_message = message + + logger.error(f"Installation failed: {message}") if self.show_details_checkbox.isChecked(): self._safe_append_text(f"\nError: {message}") self.process_finished(1, QProcess.CrashExit) # Simulate error def process_finished(self, exit_code, exit_status): - debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") + logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") # Reset button states self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) - debug_print("DEBUG: Button states reset in process_finished") + logger.debug("DEBUG: Button states reset in process_finished") if exit_code == 0: @@ -350,6 +374,7 @@ class ProgressHandlersMixin: f"Note: Post-install configuration was skipped for unsupported game type: {game_name or game_type}\n\n" f"You will need to manually configure Steam shortcuts and other post-install steps." ) + logger.warning(f"Post-install configuration skipped for unsupported game: {game_name or game_type}") self._safe_append_text(f"\nModlist installation completed successfully.") self._safe_append_text(f"\nWarning: Post-install configuration skipped for unsupported game: {game_name or game_type}") else: @@ -358,14 +383,15 @@ class ProgressHandlersMixin: if auto_restart_enabled: # Auto-accept Steam restart - proceed without dialog - self._safe_append_text("\nAuto-accepting Steam restart (unattended mode enabled)") + logger.info("Auto-accepting Steam restart (unattended mode enabled)") reply = QMessageBox.Yes # Simulate user clicking Yes else: # Show the normal install complete dialog for supported games reply = MessageService.question( self, "Modlist Install Complete!", "Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!", - critical=False # Non-critical, won't steal focus + critical=False, # Non-critical, won't steal focus + safety_level="medium", ) if reply == QMessageBox.Yes: @@ -395,6 +421,7 @@ class ProgressHandlersMixin: "Automatic installs currently require Nexus Premium. Non-premium support is planned.", safety_level="medium" ) + logger.warning("Install stopped: Nexus Premium required") self._safe_append_text("\nInstall stopped: Nexus Premium required.") self._premium_failure_active = False elif hasattr(self, '_cancellation_requested') and self._cancellation_requested: @@ -407,7 +434,14 @@ class ProgressHandlersMixin: if "cancelled by user" in last_output.lower(): MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") else: - MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.") + logger.error(f"Install failed (exit code {exit_code})") + engine_error = getattr(self, '_engine_error', None) + if engine_error: + self._engine_error = None + MessageService.show_error(self, engine_error) + else: + failure_msg = getattr(self, '_failure_message', None) or f"Exit code {exit_code}." + self._failure_message = None + MessageService.show_error(self, wabbajack_install_failed(failure_msg)) self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") self.console.moveCursor(QTextCursor.End) - diff --git a/jackify/frontends/gui/screens/install_modlist_selection.py b/jackify/frontends/gui/screens/install_modlist_selection.py index 8217665..347195a 100644 --- a/jackify/frontends/gui/screens/install_modlist_selection.py +++ b/jackify/frontends/gui/screens/install_modlist_selection.py @@ -181,15 +181,15 @@ class ModlistSelectionMixin: def browse_wabbajack_file(self): file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)") if file: - self.file_edit.setText(file) + self.file_edit.setText(os.path.realpath(file)) def browse_install_dir(self): dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text()) if dir: - self.install_dir_edit.setText(dir) + self.install_dir_edit.setText(os.path.realpath(dir)) def browse_downloads_dir(self): dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text()) if dir: - self.downloads_dir_edit.setText(dir) + self.downloads_dir_edit.setText(os.path.realpath(dir)) diff --git a/jackify/frontends/gui/screens/install_modlist_ttw.py b/jackify/frontends/gui/screens/install_modlist_ttw.py index 4032e5a..d89f004 100644 --- a/jackify/frontends/gui/screens/install_modlist_ttw.py +++ b/jackify/frontends/gui/screens/install_modlist_ttw.py @@ -33,15 +33,13 @@ class TTWIntegrationMixin: # Check 3: TTW must not already be installed if self._detect_existing_ttw(install_dir): - from .install_modlist import debug_print - debug_print("DEBUG: TTW already installed, skipping prompt") + logger.debug("DEBUG: TTW already installed, skipping prompt") return False return True except Exception as e: - from .install_modlist import debug_print - debug_print(f"DEBUG: Error checking TTW eligibility: {e}") + logger.debug(f"DEBUG: Error checking TTW eligibility: {e}") return False def _detect_existing_ttw(self, install_dir: str) -> bool: @@ -75,18 +73,15 @@ class TTWIntegrationMixin: # Verify it has actual TTW content by checking for the main ESM ttw_esm = folder / "TaleOfTwoWastelands.esm" if ttw_esm.exists(): - from .install_modlist import debug_print - debug_print(f"DEBUG: Found existing TTW installation: {folder.name}") + logger.debug(f"DEBUG: Found existing TTW installation: {folder.name}") return True else: - from .install_modlist import debug_print - debug_print(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}") + logger.debug(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}") return False except Exception as e: - from .install_modlist import debug_print - debug_print(f"DEBUG: Error detecting existing TTW: {e}") + logger.debug(f"DEBUG: Error detecting existing TTW: {e}") return False # Assume not installed on error def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str): @@ -103,9 +98,16 @@ class TTWIntegrationMixin: # Get reference to TTW screen BEFORE navigation if self.stacked_widget: + # Remember which screen to return to after TTW completes + self._ttw_return_screen_index = self.stacked_widget.currentIndex() + + # Navigate first — triggers lazy init and reset_screen_to_defaults. + # set_modlist_integration_mode must be called AFTER so it overwrites + # the default dir that reset_screen_to_defaults populates. + self.stacked_widget.setCurrentIndex(5) + ttw_screen = self.stacked_widget.widget(5) - # Set integration mode BEFORE navigating to avoid showEvent race condition if hasattr(ttw_screen, 'set_modlist_integration_mode'): ttw_screen.set_modlist_integration_mode(modlist_name, install_dir) @@ -113,11 +115,7 @@ class TTWIntegrationMixin: if hasattr(ttw_screen, 'integration_complete'): ttw_screen.integration_complete.connect(self._on_ttw_integration_complete) else: - from .install_modlist import debug_print - debug_print("WARNING: TTW screen does not support modlist integration mode yet") - - # Navigate to TTW screen AFTER setting integration mode - self.stacked_widget.setCurrentIndex(5) + logger.debug("WARNING: TTW screen does not support modlist integration mode yet") # Force collapsed state shortly after navigation to avoid any # showEvent/layout timing races that may leave it expanded @@ -127,8 +125,7 @@ class TTWIntegrationMixin: pass except Exception as e: - from .install_modlist import debug_print - debug_print(f"ERROR: Failed to initiate TTW workflow: {e}") + logger.debug(f"ERROR: Failed to initiate TTW workflow: {e}") from jackify.frontends.gui.services.message_service import MessageService MessageService.critical( self, @@ -153,9 +150,9 @@ class TTWIntegrationMixin: ) return - # Navigate back to this screen to show success dialog + # Navigate back to the screen that initiated TTW if self.stacked_widget: - self.stacked_widget.setCurrentIndex(4) + self.stacked_widget.setCurrentIndex(getattr(self, '_ttw_return_screen_index', 4)) # Calculate elapsed time from workflow start import time @@ -211,8 +208,7 @@ class TTWIntegrationMixin: success_dialog.show() except Exception as e: - from .install_modlist import debug_print - debug_print(f"ERROR: Failed to show final success dialog: {e}") + logger.debug(f"ERROR: Failed to show final success dialog: {e}") from jackify.frontends.gui.services.message_service import MessageService MessageService.critical( self, diff --git a/jackify/frontends/gui/screens/install_modlist_ui_setup.py b/jackify/frontends/gui/screens/install_modlist_ui_setup.py index 7e171c0..bfddabc 100644 --- a/jackify/frontends/gui/screens/install_modlist_ui_setup.py +++ b/jackify/frontends/gui/screens/install_modlist_ui_setup.py @@ -9,16 +9,9 @@ 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 import os +import logging - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class InstallModlistUISetupMixin: """Mixin providing UI initialization for InstallModlistScreen.""" @@ -76,8 +69,9 @@ class InstallModlistUISetupMixin: self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed) self._premium_notice_shown = False self._premium_failure_active = False - self._stalled_download_start_time = None # Track when downloads stall + self._stalled_download_start_time = None self._stalled_download_notified = False + self._stalled_data_snapshot = 0 self._post_install_sequence = self._build_post_install_sequence() self._post_install_total_steps = len(self._post_install_sequence) self._post_install_current_step = 0 @@ -294,7 +288,7 @@ class InstallModlistUISetupMixin: combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) self.resolution_combo.setCurrentIndex(resolution_index) - debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") + logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") elif is_steam_deck: # Set default to 1280x800 (Steam Deck) combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] @@ -504,7 +498,6 @@ class InstallModlistUISetupMixin: self.top_timer.start(2000) # --- Start Installation button --- self.start_btn.clicked.connect(self.validate_and_start_install) - self.steam_restart_finished.connect(self._on_steam_restart_finished) diff --git a/jackify/frontends/gui/screens/install_modlist_vnv.py b/jackify/frontends/gui/screens/install_modlist_vnv.py index 89fe9fc..76b98f4 100644 --- a/jackify/frontends/gui/screens/install_modlist_vnv.py +++ b/jackify/frontends/gui/screens/install_modlist_vnv.py @@ -39,8 +39,7 @@ class VNVAutomationMixin: game_root = game_paths.get('Fallout New Vegas') if not game_root: - from .install_modlist import debug_print - debug_print("DEBUG: VNV automation skipped - FNV game root not found") + logger.debug("DEBUG: VNV automation skipped - FNV game root not found") return False # Initialize service to check completion status @@ -91,10 +90,9 @@ class VNVAutomationMixin: return True # VNV automation is running, defer success dialog except Exception as e: - from .install_modlist import debug_print - debug_print(f"ERROR: Failed to start VNV automation: {e}") + logger.debug(f"ERROR: Failed to start VNV automation: {e}") import traceback - debug_print(f"Traceback: {traceback.format_exc()}") + logger.debug(f"Traceback: {traceback.format_exc()}") return False # Error - show success dialog anyway def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root): diff --git a/jackify/frontends/gui/screens/install_modlist_workflow.py b/jackify/frontends/gui/screens/install_modlist_workflow.py index 3a32b72..74e80a5 100644 --- a/jackify/frontends/gui/screens/install_modlist_workflow.py +++ b/jackify/frontends/gui/screens/install_modlist_workflow.py @@ -9,6 +9,8 @@ import time from .install_modlist_installer_thread import InstallerThread from .install_modlist_output_mixin import InstallModlistOutputMixin +from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access +from jackify.shared.errors import install_dir_create_failed logger = logging.getLogger(__name__) @@ -19,8 +21,7 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): def validate_and_start_install(self): import time self._install_workflow_start_time = time.time() - from .install_modlist import debug_print - debug_print('DEBUG: validate_and_start_install called') + logger.debug('DEBUG: validate_and_start_install called') # Immediately show "Initialising" status to provide feedback self.progress_indicator.set_status("Initialising...", 0) @@ -90,8 +91,6 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): return # Log authentication status at install start (Issue #111 diagnostics) - import logging - logger = logging.getLogger(__name__) auth_method = self.auth_service.get_auth_method() logger.info("=" * 60) logger.info("Authentication Status at Install Start") @@ -144,7 +143,7 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): try: os.makedirs(install_dir, exist_ok=True) except Exception as e: - MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}") + MessageService.show_error(self, install_dir_create_failed(install_dir, str(e))) self._abort_install_validation() return else: @@ -160,7 +159,7 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): try: os.makedirs(downloads_dir, exist_ok=True) except Exception as e: - MessageService.critical(self, "Error", f"Failed to create downloads directory:\n{e}") + MessageService.show_error(self, install_dir_create_failed(downloads_dir, str(e))) self._abort_install_validation() return else: @@ -172,18 +171,17 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): if resolution and resolution != "Leave unchanged": success = self.resolution_service.save_resolution(resolution) if success: - from .install_modlist import debug_print - debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + logger.debug(f"DEBUG: Resolution saved successfully: {resolution}") else: - from .install_modlist import debug_print - debug_print("DEBUG: Failed to save resolution") + logger.debug("DEBUG: Failed to save resolution") else: # Clear saved resolution if "Leave unchanged" is selected if self.resolution_service.has_saved_resolution(): self.resolution_service.clear_saved_resolution() - from .install_modlist import debug_print - debug_print("DEBUG: Saved resolution cleared") + logger.debug("DEBUG: Saved resolution cleared") + ensure_flatpak_steam_filesystem_access(Path(install_dir)) + # Handle parent directory saving self._save_parent_directories(install_dir, downloads_dir) @@ -228,8 +226,7 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): # For online modlists, try to get game type from selected modlist if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: game_name = self.selected_modlist_info.get('game', '') - from .install_modlist import debug_print - debug_print(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'") + logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'") # Map game name to game type game_mapping = { @@ -244,15 +241,12 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): 'enderal special edition': 'enderal' } game_type = game_mapping.get(game_name.lower()) - from .install_modlist import debug_print - debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") + logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") if not game_type: game_type = 'unknown' - from .install_modlist import debug_print - debug_print(f"DEBUG: Game type not found in mapping, setting to 'unknown'") + logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'") else: - from .install_modlist import debug_print - debug_print(f"DEBUG: No selected_modlist_info found") + logger.debug(f"DEBUG: No selected_modlist_info found") game_type = 'unknown' # Store game type and name for later use @@ -260,15 +254,13 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): self._current_game_name = game_name # Check if game is supported - from .install_modlist import debug_print - debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported") - debug_print(f"DEBUG: game_type='{game_type}', game_name='{game_name}'") + logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported") + logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'") is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False - debug_print(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}") + logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}") if game_type and not is_supported: - from .install_modlist import debug_print - debug_print(f"DEBUG: Game '{game_type}' is not supported, showing dialog") + logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog") # Show unsupported game dialog from ..widgets.unsupported_game_dialog import UnsupportedGameDialog dialog = UnsupportedGameDialog(self, game_name) @@ -285,8 +277,9 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): self.file_progress_list.clear() self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation self._premium_notice_shown = False - self._stalled_download_start_time = None # Reset stall detection + self._stalled_download_start_time = None self._stalled_download_notified = False + self._stalled_data_snapshot = 0 self._token_error_notified = False # Reset token error notification self._premium_failure_active = False self._post_install_active = False @@ -319,30 +312,26 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): ) return - from .install_modlist import debug_print - debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}') + logger.debug(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}') self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info) except Exception as e: - from .install_modlist import debug_print - debug_print(f"DEBUG: Exception in validate_and_start_install: {e}") + logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}") import traceback - debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") + logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}") # Re-enable all controls after exception self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) - from .install_modlist import debug_print - debug_print(f"DEBUG: Controls re-enabled in exception handler") + logger.debug(f"DEBUG: Controls re-enabled in exception handler") def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None): - from .install_modlist import debug_print - debug_print('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER') + logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER') # Rotate log file at start of each workflow run (keep 5 backups) from jackify.backend.handlers.logging_handler import LoggingHandler log_handler = LoggingHandler() log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) - + # Clear console for fresh installation output self.console.clear() from jackify import __version__ as jackify_version @@ -368,4 +357,3 @@ class InstallWorkflowMixin(InstallModlistOutputMixin): # R&D: Pass progress state manager to thread self.install_thread.progress_state_manager = self.progress_state_manager self.install_thread.start() - diff --git a/jackify/frontends/gui/screens/install_ttw.py b/jackify/frontends/gui/screens/install_ttw.py index 1439cf5..91590eb 100644 --- a/jackify/frontends/gui/screens/install_ttw.py +++ b/jackify/frontends/gui/screens/install_ttw.py @@ -26,23 +26,18 @@ from ..dialogs import SuccessDialog from jackify.backend.handlers.validation_handler import ValidationHandler from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog from jackify.frontends.gui.services.message_service import MessageService +from jackify.shared.errors import manual_steps_incomplete +import logging +logger = logging.getLogger(__name__) from .install_ttw_ui_setup import TTWUISetupMixin from .install_ttw_integration import TTWIntegrationMixin from .install_ttw_requirements import TTWRequirementsMixin from .install_ttw_lifecycle import TTWLifecycleMixin -from .install_ttw_installer import TTWInstallerMixin from .install_ttw_workflow import TTWWorkflowMixin +from .install_ttw_output import TTWOutputMixin from .install_ttw_ui import TTWUIMixin -from .install_ttw_config import TTWConfigMixin from .screen_back_mixin import ScreenBackMixin -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - class ModlistFetchThread(QThread): result = Signal(list, str) def __init__(self, game_type, log_path, mode='list-modlists'): @@ -82,9 +77,7 @@ class ModlistFetchThread(QThread): # Don't write to log file before workflow starts - just return error self.result.emit([], error_msg) - -class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWInstallerMixin, TTWWorkflowMixin, TTWUIMixin, TTWConfigMixin): - steam_restart_finished = Signal(bool, str) +class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWWorkflowMixin, TTWOutputMixin, TTWUIMixin): resize_request = Signal(str) integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version) @@ -142,26 +135,21 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT if saved_install_parent: suggested_install_dir = os.path.join(saved_install_parent, modlist_name) self.install_dir_edit.setText(suggested_install_dir) - debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") + logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") # Update download directory suggestion saved_download_parent = self.config_handler.get_default_download_parent_dir() if saved_download_parent: suggested_download_dir = os.path.join(saved_download_parent, "Downloads") - debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") + logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") except Exception as e: - debug_print(f"DEBUG: Error updating directory suggestions: {e}") + logger.debug(f"DEBUG: Error updating directory suggestions: {e}") def _save_parent_directories(self, install_dir, downloads_dir): """Removed automatic saving - user should set defaults in settings""" pass - - - - - def browse_wabbajack_file(self): # Use QFileDialog instance to ensure consistent dialog style start_path = self.file_edit.text() if self.file_edit.text() else os.path.expanduser("~") @@ -188,7 +176,6 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT if dirs: self.install_dir_edit.setText(dirs[0]) - def update_top_panel(self): try: result = subprocess.run([ @@ -249,14 +236,20 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT return True # Continue anyway def _write_to_log_file(self, message): - """Write message to workflow log file with timestamp""" + """Write message to workflow log file with timestamp.""" try: + import re from datetime import datetime + clean = re.sub(r'<[^>]+>', '', str(message)) + if not clean.strip(): + return timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') with open(self.modlist_log_path, 'a', encoding='utf-8') as f: - f.write(f"[{timestamp}] {message}\n") + for line in clean.splitlines(): + stripped = line.rstrip() + if stripped: + f.write(f"[{timestamp}] {stripped}\n") except Exception: - # Logging should never break the workflow pass def handle_validation_failure(self, missing_text): @@ -271,9 +264,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT elif self._manual_steps_retry_count == 2: retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location." - MessageService.critical(self, "Manual Steps Incomplete", - f"Manual steps validation failed:\n\n{missing_text}\n\n" - f"Please complete the missing steps and try again.{retry_guidance}") + MessageService.show_error(self, manual_steps_incomplete()) # Show manual steps dialog again extra_warning = "" if self._manual_steps_retry_count >= 2: @@ -281,13 +272,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT self.show_manual_steps_dialog(extra_warning) else: # Max retries reached - MessageService.critical(self, "Manual Steps Failed", - "Manual steps validation failed after multiple attempts.\n\n" - "Common issues:\n" - "• Steam not fully restarted\n" - "• Shortcut not launched from Steam\n" - "• Flatpak Steam using different file paths\n" - "• Proton - Experimental not selected") + MessageService.show_error(self, manual_steps_incomplete()) self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) def show_next_steps_dialog(self, message): @@ -317,11 +302,11 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" - debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") + logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") # Clean up InstallationThread if running if hasattr(self, 'install_thread') and self.install_thread.isRunning(): - debug_print("DEBUG: Cancelling running InstallationThread") + logger.debug("DEBUG: Cancelling running InstallationThread") self.install_thread.cancel() self.install_thread.wait(3000) # Wait up to 3 seconds if self.install_thread.isRunning(): @@ -335,7 +320,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT if hasattr(self, thread_name): thread = getattr(self, thread_name) if thread and thread.isRunning(): - debug_print(f"DEBUG: Terminating {thread_name}") + logger.debug(f"DEBUG: Terminating {thread_name}") thread.terminate() thread.wait(1000) # Wait up to 1 second @@ -344,7 +329,8 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT reply = MessageService.question( self, "Cancel Installation", "Are you sure you want to cancel the installation?", - critical=False # Non-critical, won't steal focus + critical=False, # Non-critical, won't steal focus + safety_level="medium", ) if reply == QMessageBox.Yes: @@ -435,13 +421,12 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html""" def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" - # Reset form fields - self.file_edit.setText("") - self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir()) - - # Clear console and process monitor - self.console.clear() - self.process_monitor.clear() + if not getattr(self, '_integration_mode', False): + # Reset form fields only when not pre-populated by a caller + self.file_edit.setText("") + self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir()) + self.console.clear() + self.process_monitor.clear() # Re-enable controls (in case they were disabled from previous errors) self._enable_controls_after_operation() @@ -449,4 +434,4 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html""" # Check requirements when screen is actually shown (not on app startup) self.check_requirements() - \ No newline at end of file + diff --git a/jackify/frontends/gui/screens/install_ttw_config.py b/jackify/frontends/gui/screens/install_ttw_config.py deleted file mode 100644 index 2b000ae..0000000 --- a/jackify/frontends/gui/screens/install_ttw_config.py +++ /dev/null @@ -1,657 +0,0 @@ -"""Configuration workflow methods for InstallTTWScreen (Mixin).""" -from pathlib import Path -from PySide6.QtCore import QTimer, Qt, QThread, Signal -from PySide6.QtWidgets import QMessageBox, QProgressDialog -import logging -import os -import threading -import traceback -# Runtime imports to avoid circular dependencies -from jackify.frontends.gui.services.message_service import MessageService # Runtime import - -logger = logging.getLogger(__name__) - - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - -class TTWConfigMixin: - """Mixin providing configuration workflow methods for InstallTTWScreen.""" - - def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str: - """Detect game type by checking ModOrganizer.ini for loader executables.""" - from pathlib import Path - import logging - logger = logging.getLogger(__name__) - - mo2_ini = Path(install_dir) / "ModOrganizer.ini" - if not mo2_ini.exists(): - return 'skyrim' # Fallback to most common - - try: - content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower() - - if 'skse64_loader.exe' in content or 'skyrim special edition' in content: - return 'skyrim' - elif 'f4se_loader.exe' in content or 'fallout 4' in content: - return 'fallout4' - elif 'nvse_loader.exe' in content or 'fallout new vegas' in content: - return 'falloutnv' - elif 'obse_loader.exe' in content or 'oblivion' in content: - return 'oblivion' - elif 'starfield' in content: - return 'starfield' - elif 'enderal' in content: - return 'enderal' - else: - return 'skyrim' - except Exception as e: - logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}") - return 'skyrim' - - def restart_steam_and_configure(self): - """Restart Steam using backend service directly - DECOUPLED FROM CLI""" - debug_print("DEBUG: restart_steam_and_configure called - using direct backend service") - progress = QProgressDialog("Restarting Steam...", None, 0, 0, self) - progress.setWindowTitle("Restarting Steam") - progress.setWindowModality(Qt.WindowModal) - progress.setMinimumDuration(0) - progress.setValue(0) - progress.show() - - def do_restart(): - debug_print("DEBUG: do_restart thread started - using direct backend service") - try: - from jackify.backend.handlers.shortcut_handler import ShortcutHandler - - # Use backend service directly instead of CLI subprocess - # Get system_info from parent screen - system_info = getattr(self, 'system_info', None) - is_steamdeck = system_info.is_steamdeck if system_info else False - shortcut_handler = ShortcutHandler(steamdeck=is_steamdeck) - - debug_print("DEBUG: About to call secure_steam_restart()") - success = shortcut_handler.secure_steam_restart() - debug_print(f"DEBUG: secure_steam_restart() returned: {success}") - - out = "Steam restart completed successfully." if success else "Steam restart failed." - - except Exception as e: - debug_print(f"DEBUG: Exception in do_restart: {e}") - success = False - out = str(e) - - self.steam_restart_finished.emit(success, out) - - threading.Thread(target=do_restart, daemon=True).start() - self._steam_restart_progress = progress # Store to close later - - def _on_steam_restart_finished(self, success, out): - debug_print("DEBUG: _on_steam_restart_finished called") - # Safely cleanup progress dialog on main thread - if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress: - try: - self._steam_restart_progress.close() - self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup - except Exception as e: - debug_print(f"DEBUG: Error closing progress dialog: {e}") - finally: - self._steam_restart_progress = None - - # Controls are managed by the proper control management system - if success: - self._safe_append_text("Steam restarted successfully.") - - # Save context for later use in configuration - self._manual_steps_retry_count = 0 - self._current_modlist_name = "TTW Installation" # Fixed name for TTW - self._current_resolution = None # TTW doesn't need resolution changes - - # Use automated prefix creation instead of manual steps - debug_print("DEBUG: Starting automated prefix creation workflow") - self._safe_append_text("Starting automated prefix creation workflow...") - self.start_automated_prefix_workflow() - else: - self._safe_append_text("Failed to restart Steam.\n" + out) - MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.") - - def start_automated_prefix_workflow(self): - # Ensure _current_resolution is always set before starting workflow - if not hasattr(self, '_current_resolution') or self._current_resolution is None: - resolution = None # TTW doesn't need resolution changes - # Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)") - if resolution and resolution != "Leave unchanged": - if " (" in resolution: - self._current_resolution = resolution.split(" (")[0] - else: - self._current_resolution = resolution - else: - self._current_resolution = None - """Start the automated prefix creation workflow""" - try: - # Disable controls during installation - self._disable_controls_during_operation() - modlist_name = "TTW Installation" - install_dir = self.install_dir_edit.text().strip() - final_exe_path = os.path.join(install_dir, "ModOrganizer.exe") - - if not os.path.exists(final_exe_path): - # Check if this is Somnium specifically (uses files/ subdirectory) - modlist_name_lower = modlist_name.lower() - if "somnium" in modlist_name_lower: - somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe") - if os.path.exists(somnium_exe_path): - final_exe_path = somnium_exe_path - self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup") - # Show Somnium guidance popup after automated workflow completes - self._show_somnium_guidance = True - self._somnium_install_dir = install_dir - else: - self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}") - MessageService.critical(self, "Somnium ModOrganizer.exe Not Found", - f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.") - return - else: - self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}") - MessageService.critical(self, "ModOrganizer.exe Not Found", - f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.") - return - - # Run automated prefix creation in separate thread - from PySide6.QtCore import QThread, Signal - - class AutomatedPrefixThread(QThread): - finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp - progress = Signal(str) # progress messages - error = Signal(str) # error messages - show_progress_dialog = Signal(str) # show progress dialog with message - hide_progress_dialog = Signal() # hide progress dialog - conflict_detected = Signal(list) # conflicts list - - def __init__(self, modlist_name, install_dir, final_exe_path): - super().__init__() - self.modlist_name = modlist_name - self.install_dir = install_dir - self.final_exe_path = final_exe_path - - def run(self): - try: - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - - def progress_callback(message): - self.progress.emit(message) - # Show progress dialog during Steam restart - if "Steam restarted successfully" in message: - self.hide_progress_dialog.emit() - elif "Restarting Steam..." in message: - self.show_progress_dialog.emit("Restarting Steam...") - - prefix_service = AutomatedPrefixService() - # Determine Steam Deck once and pass through the workflow - try: - import os - _is_steamdeck = False - if os.path.exists('/etc/os-release'): - with open('/etc/os-release') as f: - if 'steamdeck' in f.read().lower(): - _is_steamdeck = True - except Exception: - _is_steamdeck = False - result = prefix_service.run_working_workflow( - self.modlist_name, self.install_dir, self.final_exe_path, progress_callback, steamdeck=_is_steamdeck - ) - - # Handle the result - check for conflicts - if isinstance(result, tuple) and len(result) == 4: - if result[0] == "CONFLICT": - # Conflict detected - emit signal to main GUI - conflicts = result[1] - self.hide_progress_dialog.emit() - self.conflict_detected.emit(conflicts) - return - else: - # Normal result with timestamp - success, prefix_path, new_appid, last_timestamp = result - elif isinstance(result, tuple) and len(result) == 3: - # Fallback for old format (backward compatibility) - if result[0] == "CONFLICT": - # Conflict detected - emit signal to main GUI - conflicts = result[1] - self.hide_progress_dialog.emit() - self.conflict_detected.emit(conflicts) - return - else: - # Normal result (old format) - success, prefix_path, new_appid = result - last_timestamp = None - else: - # Handle non-tuple result - success = result - prefix_path = "" - new_appid = "0" - last_timestamp = None - - # Ensure progress dialog is hidden when workflow completes - self.hide_progress_dialog.emit() - self.finished.emit(success, prefix_path or "", str(new_appid) if new_appid else "0", last_timestamp) - - except Exception as e: - # Ensure progress dialog is hidden on error - self.hide_progress_dialog.emit() - self.error.emit(str(e)) - - # Create and start thread - self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path) - self.prefix_thread.finished.connect(self.on_automated_prefix_finished) - self.prefix_thread.error.connect(self.on_automated_prefix_error) - self.prefix_thread.progress.connect(self.on_automated_prefix_progress) - self.prefix_thread.show_progress_dialog.connect(self.show_steam_restart_progress) - self.prefix_thread.hide_progress_dialog.connect(self.hide_steam_restart_progress) - self.prefix_thread.conflict_detected.connect(self.show_shortcut_conflict_dialog) - self.prefix_thread.start() - - except Exception as e: - debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}") - import traceback - debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") - self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}") - # Re-enable controls on exception - self._enable_controls_after_operation() - - def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None): - """Handle completion of automated prefix creation""" - try: - if success: - debug_print(f"SUCCESS: Automated prefix creation completed!") - debug_print(f"Prefix created at: {prefix_path}") - if new_appid_str and new_appid_str != "0": - debug_print(f"AppID: {new_appid_str}") - - # Convert string AppID back to integer for configuration - new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None - - # Continue with configuration using the new AppID and timestamp - modlist_name = "TTW Installation" - install_dir = self.install_dir_edit.text().strip() - self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp) - else: - self._safe_append_text(f"ERROR: Automated prefix creation failed") - self._safe_append_text("Please check the logs for details") - MessageService.critical(self, "Automated Setup Failed", - "Automated prefix creation failed. Please check the console output for details.") - # Re-enable controls on failure - self._enable_controls_after_operation() - finally: - # Always ensure controls are re-enabled when workflow truly completes - pass - - def on_automated_prefix_error(self, error_msg): - """Handle error in automated prefix creation""" - self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}") - MessageService.critical(self, "Automated Setup Error", - f"Error during automated prefix creation: {error_msg}") - # Re-enable controls on error - self._enable_controls_after_operation() - - def on_automated_prefix_progress(self, progress_msg): - """Handle progress updates from automated prefix creation""" - self._safe_append_text(progress_msg) - - def on_configuration_progress(self, progress_msg): - """Handle progress updates from modlist configuration""" - self._safe_append_text(progress_msg) - - def show_steam_restart_progress(self, message): - """Show Steam restart progress dialog""" - from PySide6.QtWidgets import QProgressDialog - from PySide6.QtCore import Qt - - self.steam_restart_progress = QProgressDialog(message, None, 0, 0, self) - self.steam_restart_progress.setWindowTitle("Restarting Steam") - self.steam_restart_progress.setWindowModality(Qt.WindowModal) - self.steam_restart_progress.setMinimumDuration(0) - self.steam_restart_progress.setValue(0) - self.steam_restart_progress.show() - - def hide_steam_restart_progress(self): - """Hide Steam restart progress dialog""" - if hasattr(self, 'steam_restart_progress') and self.steam_restart_progress: - try: - self.steam_restart_progress.close() - self.steam_restart_progress.deleteLater() - except Exception: - pass - finally: - self.steam_restart_progress = None - # Controls are managed by the proper control management system - - def on_configuration_complete(self, success, message, modlist_name, enb_detected=False): - """Handle configuration completion on main thread""" - try: - # Re-enable controls now that installation/configuration is complete - self._enable_controls_after_operation() - - if success: - # Check if we need to show Somnium guidance - if self._show_somnium_guidance: - self._show_somnium_post_install_guidance() - - # Show celebration SuccessDialog after the entire workflow - from ..dialogs import SuccessDialog - import time - if not hasattr(self, '_install_workflow_start_time'): - self._install_workflow_start_time = time.time() - time_taken = int(time.time() - self._install_workflow_start_time) - mins, secs = divmod(time_taken, 60) - time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds" - display_names = { - 'skyrim': 'Skyrim', - 'fallout4': 'Fallout 4', - 'falloutnv': 'Fallout New Vegas', - 'oblivion': 'Oblivion', - 'starfield': 'Starfield', - 'oblivion_remastered': 'Oblivion Remastered', - 'enderal': 'Enderal' - } - game_name = display_names.get(self._current_game_type, self._current_game_name) - success_dialog = SuccessDialog( - modlist_name=modlist_name, - workflow_type="install", - time_taken=time_str, - game_name=game_name, - parent=self - ) - success_dialog.show() - - # TTW workflow does NOT need ENB detection/dialog - elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3: - # Max retries reached - show failure message - MessageService.critical(self, "Manual Steps Failed", - "Manual steps validation failed after multiple attempts.") - else: - # Configuration failed for other reasons - MessageService.critical(self, "Configuration Failed", - "Post-install configuration failed. Please check the console output.") - except Exception as e: - # Ensure controls are re-enabled even on unexpected errors - self._enable_controls_after_operation() - raise - # Clean up thread - if hasattr(self, 'config_thread') and self.config_thread is not None: - # Disconnect all signals to prevent "Internal C++ object already deleted" errors - try: - self.config_thread.progress_update.disconnect() - self.config_thread.configuration_complete.disconnect() - self.config_thread.error_occurred.disconnect() - except: - pass # Ignore errors if already disconnected - if self.config_thread.isRunning(): - self.config_thread.quit() - self.config_thread.wait(5000) # Wait up to 5 seconds - self.config_thread.deleteLater() - self.config_thread = None - - def on_configuration_error(self, error_message): - """Handle configuration error on main thread""" - self._safe_append_text(f"Configuration failed with error: {error_message}") - MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}") - - # Re-enable all controls on error - self._enable_controls_after_operation() - - # Clean up thread - if hasattr(self, 'config_thread') and self.config_thread is not None: - # Disconnect all signals to prevent "Internal C++ object already deleted" errors - try: - self.config_thread.progress_update.disconnect() - self.config_thread.configuration_complete.disconnect() - self.config_thread.error_occurred.disconnect() - except: - pass # Ignore errors if already disconnected - if self.config_thread.isRunning(): - self.config_thread.quit() - self.config_thread.wait(5000) # Wait up to 5 seconds - self.config_thread.deleteLater() - self.config_thread = None - - def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None): - """Continue the configuration process with the new AppID after automated prefix creation""" - # Headers are now shown at start of Steam Integration - # No need to show them again here - debug_print("Configuration phase continues after Steam Integration") - - debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") - try: - # Update the context with the new AppID (same format as manual steps) - updated_context = { - 'name': modlist_name, - 'path': install_dir, - 'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name), - 'modlist_value': None, - 'modlist_source': None, - 'resolution': getattr(self, '_current_resolution', None), - 'skip_confirmation': True, - 'manual_steps_completed': True, # Mark as completed since automated prefix is done - 'appid': new_appid, # Use the NEW AppID from automated prefix creation - 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition' - } - self.context = updated_context # Ensure context is always set - debug_print(f"Updated context with new AppID: {new_appid}") - - # Get Steam Deck detection once and pass to ConfigThread - from jackify.backend.services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - is_steamdeck = platform_service.is_steamdeck - - # Create new config thread with updated context - class ConfigThread(QThread): - progress_update = Signal(str) - configuration_complete = Signal(bool, str, str) - error_occurred = Signal(str) - - def __init__(self, context, is_steamdeck): - super().__init__() - self.context = context - self.is_steamdeck = is_steamdeck - - def run(self): - try: - from jackify.backend.services.modlist_service import ModlistService - from jackify.backend.models.configuration import SystemInfo - from jackify.backend.models.modlist import ModlistContext - from pathlib import Path - - # Initialize backend service with passed Steam Deck detection - system_info = SystemInfo(is_steamdeck=self.is_steamdeck) - modlist_service = ModlistService(system_info) - - # Detect game type from ModOrganizer.ini - detected_game_type = self._detect_game_type_from_mo2_ini(self.context['path']) - - # Convert context to ModlistContext for service - modlist_context = ModlistContext( - name=self.context['name'], - install_dir=Path(self.context['path']), - download_dir=Path(self.context['path']).parent / 'Downloads', # Default - game_type=detected_game_type, - nexus_api_key='', # Not needed for configuration - modlist_value=self.context.get('modlist_value'), - modlist_source=self.context.get('modlist_source', 'identifier'), - resolution=self.context.get('resolution'), - skip_confirmation=True, - engine_installed=True # Skip path manipulation for engine workflows - ) - - # Add app_id to context - modlist_context.app_id = self.context['appid'] - - # Define callbacks - def progress_callback(message): - self.progress_update.emit(message) - - 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): - # This shouldn't happen since automated prefix creation is complete - self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") - - # Call the service method for post-Steam configuration - result = modlist_service.configure_modlist_post_steam( - context=modlist_context, - progress_callback=progress_callback, - manual_steps_callback=manual_steps_callback, - completion_callback=completion_callback - ) - - if not result: - self.progress_update.emit("Configuration failed to start") - self.error_occurred.emit("Configuration failed to start") - - except Exception as e: - self.error_occurred.emit(str(e)) - - # Start configuration thread - self.config_thread = ConfigThread(updated_context, is_steamdeck) - self.config_thread.progress_update.connect(self.on_configuration_progress) - self.config_thread.configuration_complete.connect(self.on_configuration_complete) - self.config_thread.error_occurred.connect(self.on_configuration_error) - self.config_thread.start() - - except Exception as e: - self._safe_append_text(f"Error continuing configuration: {e}") - import traceback - self._safe_append_text(f"Full traceback: {traceback.format_exc()}") - self.on_configuration_error(str(e)) - - - def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir): - """Continue the configuration process with the corrected AppID after manual steps validation""" - try: - # Update the context with the new AppID - updated_context = { - 'name': modlist_name, - 'path': install_dir, - 'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name), - 'modlist_value': None, - 'modlist_source': None, - 'resolution': getattr(self, '_current_resolution', None), - 'skip_confirmation': True, - 'manual_steps_completed': True, # Mark as completed - 'appid': new_appid # Use the NEW AppID from Steam - } - - debug_print(f"Updated context with new AppID: {new_appid}") - - # Clean up old thread if exists and wait for it to finish - if hasattr(self, 'config_thread') and self.config_thread is not None: - # Disconnect all signals to prevent "Internal C++ object already deleted" errors - try: - self.config_thread.progress_update.disconnect() - self.config_thread.configuration_complete.disconnect() - self.config_thread.error_occurred.disconnect() - except: - pass # Ignore errors if already disconnected - if self.config_thread.isRunning(): - self.config_thread.quit() - self.config_thread.wait(5000) # Wait up to 5 seconds - self.config_thread.deleteLater() - self.config_thread = None - - # Start new config thread - self.config_thread = self._create_config_thread(updated_context) - self.config_thread.progress_update.connect(self.on_configuration_progress) - self.config_thread.configuration_complete.connect(self.on_configuration_complete) - self.config_thread.error_occurred.connect(self.on_configuration_error) - self.config_thread.start() - - except Exception as e: - self._safe_append_text(f"Error continuing configuration: {e}") - self.on_configuration_error(str(e)) - - def _create_config_thread(self, context): - """Create a new ConfigThread with proper lifecycle management""" - from PySide6.QtCore import QThread, Signal - - # Get Steam Deck detection once - from jackify.backend.services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - is_steamdeck = platform_service.is_steamdeck - - class ConfigThread(QThread): - progress_update = Signal(str) - configuration_complete = Signal(bool, str, str) - error_occurred = Signal(str) - - def __init__(self, context, is_steamdeck, parent=None): - super().__init__(parent) - self.context = context - self.is_steamdeck = is_steamdeck - - def run(self): - try: - from jackify.backend.models.configuration import SystemInfo - from jackify.backend.services.modlist_service import ModlistService - from jackify.backend.models.modlist import ModlistContext - from pathlib import Path - - # Initialize backend service with passed Steam Deck detection - system_info = SystemInfo(is_steamdeck=self.is_steamdeck) - modlist_service = ModlistService(system_info) - - # Detect game type from ModOrganizer.ini - detected_game_type = self._detect_game_type_from_mo2_ini(self.context['path']) - - # Convert context to ModlistContext for service - modlist_context = ModlistContext( - name=self.context['name'], - install_dir=Path(self.context['path']), - download_dir=Path(self.context['path']).parent / 'Downloads', # Default - game_type=detected_game_type, - nexus_api_key='', # Not needed for configuration - modlist_value=self.context.get('modlist_value', ''), - modlist_source=self.context.get('modlist_source', 'identifier'), - resolution=self.context.get('resolution'), # Pass resolution from GUI - skip_confirmation=True, - engine_installed=True # Skip path manipulation for engine workflows - ) - - # Add app_id to context - if 'appid' in self.context: - modlist_context.app_id = self.context['appid'] - - # Define callbacks - 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 manual_steps_callback(modlist_name, retry_count): - # Should not reach here -- manual steps already complete - self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") - - # Call the new service method for post-Steam configuration - result = modlist_service.configure_modlist_post_steam( - context=modlist_context, - progress_callback=progress_callback, - manual_steps_callback=manual_steps_callback, - completion_callback=completion_callback - ) - - if not result: - self.progress_update.emit("WARNING: configure_modlist_post_steam returned False") - - except Exception as e: - import traceback - error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}" - self.progress_update.emit(f"DEBUG: {error_details}") - self.error_occurred.emit(str(e)) - - return ConfigThread(context, is_steamdeck, parent=self) - diff --git a/jackify/frontends/gui/screens/install_ttw_installer.py b/jackify/frontends/gui/screens/install_ttw_installer.py deleted file mode 100644 index d14fc58..0000000 --- a/jackify/frontends/gui/screens/install_ttw_installer.py +++ /dev/null @@ -1,290 +0,0 @@ -"""TTW installer management methods for InstallTTWScreen (Mixin).""" -from pathlib import Path -from PySide6.QtCore import QTimer -import logging -import os -# Runtime imports to avoid circular dependencies -from jackify.frontends.gui.services.message_service import MessageService # Runtime import - -logger = logging.getLogger(__name__) - - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - -class TTWInstallerMixin: - """Mixin providing TTW installer management methods for InstallTTWScreen.""" - - def check_requirements(self): - """Check and display requirements status""" - from jackify.backend.handlers.path_handler import PathHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.models.configuration import SystemInfo - - path_handler = PathHandler() - - # Check game detection - detected_games = path_handler.find_vanilla_game_paths() - - # Fallout 3 - if 'Fallout 3' in detected_games: - self.fallout3_status.setText("Fallout 3: Detected") - self.fallout3_status.setStyleSheet("color: #3fd0ea;") - else: - self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam") - self.fallout3_status.setStyleSheet("color: #f44336;") - - # Fallout New Vegas - if 'Fallout New Vegas' in detected_games: - self.fnv_status.setText("Fallout New Vegas: Detected") - self.fnv_status.setStyleSheet("color: #3fd0ea;") - else: - self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam") - self.fnv_status.setStyleSheet("color: #f44336;") - - # Update Start button state after checking requirements - self._update_start_button_state() - - def _check_ttw_installer_status(self): - """Check TTW_Linux_Installer installation status and update UI""" - try: - from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.models.configuration import SystemInfo - - # Create handler instances - filesystem_handler = FileSystemHandler() - config_handler = ConfigHandler() - system_info = SystemInfo(is_steamdeck=False) - ttw_installer_handler = TTWInstallerHandler( - steamdeck=False, - verbose=False, - filesystem_handler=filesystem_handler, - config_handler=config_handler - ) - - # Check if TTW_Linux_Installer is installed - ttw_installer_handler._check_installation() - - if ttw_installer_handler.ttw_installer_installed: - # Check version against latest - update_available, installed_v, latest_v = ttw_installer_handler.is_ttw_installer_update_available() - if update_available: - version_text = f"Out of date (v{installed_v} → v{latest_v})" if installed_v and latest_v else "Out of date" - self.ttw_installer_status.setText(version_text) - self.ttw_installer_status.setStyleSheet("color: #f44336;") - self.ttw_installer_btn.setText("Update now") - self.ttw_installer_btn.setEnabled(True) - self.ttw_installer_btn.setVisible(True) - else: - version_text = f"Ready (v{installed_v})" if installed_v else "Ready" - self.ttw_installer_status.setText(version_text) - self.ttw_installer_status.setStyleSheet("color: #3fd0ea;") - self.ttw_installer_btn.setText("Update now") - self.ttw_installer_btn.setEnabled(False) # Greyed out when ready - self.ttw_installer_btn.setVisible(True) - else: - self.ttw_installer_status.setText("Not Found") - self.ttw_installer_status.setStyleSheet("color: #f44336;") - self.ttw_installer_btn.setText("Install now") - self.ttw_installer_btn.setEnabled(True) - self.ttw_installer_btn.setVisible(True) - - except Exception as e: - self.ttw_installer_status.setText("Check Failed") - self.ttw_installer_status.setStyleSheet("color: #f44336;") - self.ttw_installer_btn.setText("Install now") - self.ttw_installer_btn.setEnabled(True) - self.ttw_installer_btn.setVisible(True) - debug_print(f"DEBUG: TTW_Linux_Installer status check failed: {e}") - - def install_ttw_installer(self): - """Install or update TTW_Linux_Installer""" - # If not detected, show info dialog - try: - current_status = self.ttw_installer_status.text().strip() - except Exception: - current_status = "" - if current_status == "Not Found": - MessageService.information( - self, - "TTW_Linux_Installer Installation", - ( - "TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.

" - "Project: github.com/SulfurNitride/TTW_Linux_Installer
" - "Please star the repository and thank the developer.

" - "Jackify will now download and install the pinned TTW_Linux_Installer version (0.0.7)." - ), - safety_level="low", - ) - - # Update button to show installation in progress - self.ttw_installer_btn.setText("Installing...") - self.ttw_installer_btn.setEnabled(False) - - self.console.append("Installing/updating TTW_Linux_Installer...") - - # Create background thread for installation - from PySide6.QtCore import QThread, Signal - - class InstallerDownloadThread(QThread): - finished = Signal(bool, str) # success, message - progress = Signal(str) # progress message - - def run(self): - try: - from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.models.configuration import SystemInfo - - # Create handler instances - filesystem_handler = FileSystemHandler() - config_handler = ConfigHandler() - system_info = SystemInfo(is_steamdeck=False) - ttw_installer_handler = TTWInstallerHandler( - steamdeck=False, - verbose=False, - filesystem_handler=filesystem_handler, - config_handler=config_handler - ) - - # Install TTW_Linux_Installer (this will download and extract) - self.progress.emit("Downloading TTW_Linux_Installer...") - success, message = ttw_installer_handler.install_ttw_installer() - - if success: - install_path = ttw_installer_handler.ttw_installer_dir - self.progress.emit(f"Installation complete: {install_path}") - else: - self.progress.emit(f"Installation failed: {message}") - - self.finished.emit(success, message) - - except Exception as e: - error_msg = f"Error installing TTW_Linux_Installer: {str(e)}" - self.progress.emit(error_msg) - debug_print(f"DEBUG: TTW_Linux_Installer installation error: {e}") - self.finished.emit(False, error_msg) - - # Create and start thread - self.installer_download_thread = InstallerDownloadThread() - self.installer_download_thread.progress.connect(self._on_installer_download_progress) - self.installer_download_thread.finished.connect(self._on_installer_download_finished) - self.installer_download_thread.start() - - # Update Activity window to show download in progress - self.file_progress_list.clear() - self.file_progress_list.update_or_add_item( - item_id="ttw_installer_download", - label="Downloading TTW_Linux_Installer...", - progress=0 - ) - - def _on_installer_download_progress(self, message): - """Handle installer download progress updates""" - self.console.append(message) - # Update Activity window based on progress message - if "Downloading" in message: - self.file_progress_list.update_or_add_item( - item_id="ttw_installer_download", - label="Downloading TTW_Linux_Installer...", - progress=0 # Indeterminate progress - ) - elif "Extracting" in message or "extracting" in message.lower(): - self.file_progress_list.update_or_add_item( - item_id="ttw_installer_download", - label="Extracting TTW_Linux_Installer...", - progress=50 - ) - elif "complete" in message.lower() or "successfully" in message.lower(): - self.file_progress_list.update_or_add_item( - item_id="ttw_installer_download", - label="TTW_Linux_Installer ready", - progress=100 - ) - - def _on_installer_download_finished(self, success, message): - """Handle installer download completion""" - if success: - self.console.append("TTW_Linux_Installer installed successfully") - # Clear Activity window after successful installation - self.file_progress_list.clear() - # Re-check status after installation (this will update button state correctly) - self._check_ttw_installer_status() - self._update_start_button_state() - else: - self.console.append(f"Installation failed: {message}") - # Clear Activity window on failure - self.file_progress_list.clear() - # Re-enable button on failure so user can retry - self.ttw_installer_btn.setText("Install now") - self.ttw_installer_btn.setEnabled(True) - - def _check_ttw_requirements(self): - """Check TTW requirements before installation""" - from jackify.backend.handlers.path_handler import PathHandler - - path_handler = PathHandler() - - # Check game detection - detected_games = path_handler.find_vanilla_game_paths() - missing_games = [] - - if 'Fallout 3' not in detected_games: - missing_games.append("Fallout 3") - if 'Fallout New Vegas' not in detected_games: - missing_games.append("Fallout New Vegas") - - if missing_games: - MessageService.warning( - self, - "Missing Required Games", - f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}" - ) - return False - - # Check TTW_Linux_Installer using the status we already checked - status_text = self.ttw_installer_status.text() - if status_text in ("Not Found", "Check Failed"): - MessageService.warning( - self, - "TTW_Linux_Installer Required", - "TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer using the 'Install now' button." - ) - return False - - return True - - # Now collect all actionable controls after UI is fully built - self._collect_actionable_controls() - - # Check if all requirements are met and enable/disable Start button - self._update_start_button_state() - - def _update_start_button_state(self): - """Enable/disable Start button based on requirements and file selection""" - # Check if all requirements are met - requirements_met = self._check_ttw_requirements() - - # Check if .mpi file is selected - mpi_file_selected = bool(self.file_edit.text().strip()) - - # Enable Start button only if both requirements are met and file is selected - self.start_btn.setEnabled(requirements_met and mpi_file_selected) - - # Update button text to indicate what's missing - if not requirements_met: - self.start_btn.setText("Requirements Not Met") - elif not mpi_file_selected: - self.start_btn.setText("Select TTW .mpi File") - else: - self.start_btn.setText("Start Installation") - diff --git a/jackify/frontends/gui/screens/install_ttw_integration.py b/jackify/frontends/gui/screens/install_ttw_integration.py index 484b16f..c8103d0 100644 --- a/jackify/frontends/gui/screens/install_ttw_integration.py +++ b/jackify/frontends/gui/screens/install_ttw_integration.py @@ -8,16 +8,9 @@ import os import json import shutil import re +import logging - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class TTWIntegrationMixin: """Mixin providing modlist integration workflow for InstallTTWScreen.""" @@ -36,13 +29,17 @@ class TTWIntegrationMixin: self._integration_modlist_name = modlist_name self._integration_install_dir = install_dir + # Pre-populate output dir to install TTW directly into the modlist mods folder, + # avoiding the wasteful copy step during integration. + ttw_target = Path(install_dir) / "mods" / "[NoDelete] Tale of Two Wastelands" + self.install_dir_edit.setText(str(ttw_target)) + # Reset saved geometry so showEvent can properly collapse from current window size self._saved_geometry = None self._saved_min_size = None - # Update UI to show integration mode - debug_print(f"TTW screen set to integration mode for modlist: {modlist_name}") - debug_print(f"Installation directory: {install_dir}") + logger.debug(f"TTW screen set to integration mode for modlist: {modlist_name}") + logger.debug(f"TTW output pre-populated to: {ttw_target}") def _perform_modlist_integration(self): """Integrate TTW into the modlist automatically @@ -75,16 +72,31 @@ class TTWIntegrationMixin: if version_match: ttw_version = version_match.group(1) + # If TTW was installed directly into the modlist mods dir (integration mode + # pre-populate), rename to the versioned folder name and skip the copy step. + skip_copy = False + mods_dir = Path(self._integration_install_dir) / "mods" + if ttw_output_dir.parent == mods_dir: + versioned_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}".strip() if ttw_version else "[NoDelete] Tale of Two Wastelands" + versioned_path = mods_dir / versioned_name + if ttw_output_dir != versioned_path and ttw_output_dir.exists(): + logger.debug(f"Renaming TTW output: {ttw_output_dir.name} -> {versioned_name}") + ttw_output_dir.rename(versioned_path) + ttw_output_dir = versioned_path + skip_copy = True + logger.debug("TTW already in mods dir — skipping copy step") + # Create background thread for integration class IntegrationThread(QThread): finished = Signal(bool, str) # success, ttw_version progress = Signal(str) # progress message - def __init__(self, ttw_output_path, modlist_install_dir, ttw_version): + def __init__(self, ttw_output_path, modlist_install_dir, ttw_version, skip_copy): super().__init__() self.ttw_output_path = ttw_output_path self.modlist_install_dir = modlist_install_dir self.ttw_version = ttw_version + self.skip_copy = skip_copy def run(self): try: @@ -94,11 +106,12 @@ class TTWIntegrationMixin: success = TTWInstallerHandler.integrate_ttw_into_modlist( ttw_output_path=self.ttw_output_path, modlist_install_dir=self.modlist_install_dir, - ttw_version=self.ttw_version + ttw_version=self.ttw_version, + skip_copy=self.skip_copy, ) self.finished.emit(success, self.ttw_version) except Exception as e: - debug_print(f"ERROR: Integration thread failed: {e}") + logger.debug(f"ERROR: Integration thread failed: {e}") import traceback traceback.print_exc() self.finished.emit(False, self.ttw_version) @@ -142,7 +155,8 @@ class TTWIntegrationMixin: self.integration_thread = IntegrationThread( ttw_output_dir, Path(self._integration_install_dir), - ttw_version + ttw_version, + skip_copy, ) self.integration_thread.progress.connect(self._safe_append_text) self.integration_thread.finished.connect(self._on_integration_thread_finished) @@ -156,7 +170,7 @@ class TTWIntegrationMixin: error_msg = f"Integration error: {str(e)}" self._safe_append_text(f"\nError: {error_msg}") - debug_print(f"ERROR: {error_msg}") + logger.debug(f"ERROR: {error_msg}") import traceback traceback.print_exc() self.integration_complete.emit(False, "") @@ -213,7 +227,7 @@ class TTWIntegrationMixin: ) self.integration_complete.emit(False, ttw_version) except Exception as e: - debug_print(f"ERROR: Failed to handle integration completion: {e}") + logger.debug(f"ERROR: Failed to handle integration completion: {e}") self.integration_complete.emit(False, ttw_version) def _create_ttw_mod_archive(self, automated=False): diff --git a/jackify/frontends/gui/screens/install_ttw_lifecycle.py b/jackify/frontends/gui/screens/install_ttw_lifecycle.py index 6cbe3b9..8a88186 100644 --- a/jackify/frontends/gui/screens/install_ttw_lifecycle.py +++ b/jackify/frontends/gui/screens/install_ttw_lifecycle.py @@ -1,17 +1,10 @@ """Window lifecycle and resize handlers for InstallTTWScreen (Mixin).""" from PySide6.QtCore import QTimer, QSize, Qt from PySide6.QtGui import QResizeEvent +import logging +logger = logging.getLogger(__name__) from ..utils import set_responsive_minimum - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - class TTWLifecycleMixin: """Mixin providing window lifecycle and resize management for InstallTTWScreen.""" @@ -58,7 +51,7 @@ class TTWLifecycleMixin: def showEvent(self, event): """Called when the widget becomes visible""" super().showEvent(event) - debug_print(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}") + logger.debug(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}") # Check TTW_Linux_Installer status asynchronously (non-blocking) after screen opens from PySide6.QtCore import QTimer @@ -80,7 +73,7 @@ class TTWLifecycleMixin: is_steamdeck = True if is_steamdeck: - debug_print("DEBUG: Steam Deck detected, keeping expanded") + logger.debug("DEBUG: Steam Deck detected, keeping expanded") # Force expanded state and hide checkbox if self.show_details_checkbox.isVisible(): self.show_details_checkbox.setVisible(False) @@ -91,27 +84,27 @@ class TTWLifecycleMixin: self.console.setMaximumHeight(16777215) # Remove height limit return except Exception as e: - debug_print(f"DEBUG: Steam Deck check exception: {e}") + logger.debug(f"DEBUG: Steam Deck check exception: {e}") pass - debug_print(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}") + logger.debug(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}") if self.show_details_checkbox.isChecked(): self.show_details_checkbox.blockSignals(True) self.show_details_checkbox.setChecked(False) self.show_details_checkbox.blockSignals(False) - debug_print("DEBUG: Calling _toggle_console_visibility(Unchecked)") + logger.debug("DEBUG: Calling _toggle_console_visibility(Unchecked)") self._toggle_console_visibility(_Qt.Unchecked) # Force the window to compact height to eliminate bottom whitespace main_window = self.window() - debug_print(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}") + logger.debug(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}") if main_window: # Save original geometry once if self._saved_geometry is None: self._saved_geometry = main_window.geometry() - debug_print(f"DEBUG: Saved geometry: {self._saved_geometry}") + logger.debug(f"DEBUG: Saved geometry: {self._saved_geometry}") if self._saved_min_size is None: self._saved_min_size = main_window.minimumSize() - debug_print(f"DEBUG: Saved min size: {self._saved_min_size}") + logger.debug(f"DEBUG: Saved min size: {self._saved_min_size}") # Fixed compact size - same as menu screens from PySide6.QtCore import QSize @@ -127,14 +120,14 @@ class TTWLifecycleMixin: # Notify parent to ensure compact try: self.resize_request.emit('collapse') - debug_print("DEBUG: Emitted resize_request collapse signal") + logger.debug("DEBUG: Emitted resize_request collapse signal") except Exception as e: - debug_print(f"DEBUG: Exception emitting signal: {e}") + logger.debug(f"DEBUG: Exception emitting signal: {e}") pass except Exception as e: - debug_print(f"DEBUG: showEvent exception: {e}") + logger.debug(f"DEBUG: showEvent exception: {e}") import traceback - debug_print(f"DEBUG: {traceback.format_exc()}") + logger.debug(f"DEBUG: {traceback.format_exc()}") pass def hideEvent(self, event): @@ -148,8 +141,8 @@ class TTWLifecycleMixin: # Important when console is expanded main_window.setMaximumSize(QSize(16777215, 16777215)) main_window.setMinimumSize(QSize(0, 0)) - debug_print("DEBUG: Install TTW hideEvent - cleared window size constraints") + logger.debug("DEBUG: Install TTW hideEvent - cleared window size constraints") except Exception as e: - debug_print(f"DEBUG: hideEvent exception: {e}") + logger.debug(f"DEBUG: hideEvent exception: {e}") pass diff --git a/jackify/frontends/gui/screens/install_ttw_output.py b/jackify/frontends/gui/screens/install_ttw_output.py new file mode 100644 index 0000000..b93bc4d --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_output.py @@ -0,0 +1,177 @@ +"""TTW output processing mixin for InstallTTWScreen.""" +import re +import time + +from ..utils import strip_ansi_control_codes + + +class TTWOutputMixin: + """Mixin providing output and progress signal handlers for InstallTTWScreen.""" + + def on_installation_output_batch(self, messages): + """Handle batched output from TTW_Linux_Installer (pre-cleaned in worker thread).""" + if not hasattr(self, '_ttw_seen_lines'): + self._ttw_seen_lines = set() + self._ttw_current_phase = None + self._ttw_last_progress = 0 + self._ttw_last_activity_update = 0 + self.ttw_start_time = time.time() + + lines_to_display = [] + html_fragments = [] + show_details_due_to_error = False + latest_progress = None + + for cleaned in messages: + if not cleaned: + continue + + lower_cleaned = cleaned.lower() + + try: + progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned) + if progress_match: + current = int(progress_match.group(1)) + total = int(progress_match.group(2)) + percent = int((current / total) * 100) if total > 0 else 0 + latest_progress = (current, total, percent) + + if 'loading manifest:' in lower_cleaned: + manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned) + if manifest_match: + current = int(manifest_match.group(1)) + total = int(manifest_match.group(2)) + self._ttw_current_phase = "Loading manifest" + except Exception: + pass + + is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned + is_warning = 'warning:' in lower_cleaned + is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid']) + is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx']) + is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.'] + + should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise) + + if should_show: + if is_error or is_warning: + color = '#f44336' if is_error else '#ff9800' + prefix = "WARNING: " if is_warning else "ERROR: " + escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>') + html_fragments.append(f'{escaped}') + show_details_due_to_error = True + else: + lines_to_display.append(cleaned) + + if latest_progress: + current, total, percent = latest_progress + current_time = time.time() + if abs(percent - self._ttw_last_progress) >= 1 or (current_time - self._ttw_last_activity_update) >= 0.5: + self._update_ttw_activity(current, total, percent) + self._ttw_last_progress = percent + self._ttw_last_activity_update = current_time + + if html_fragments or lines_to_display: + try: + if html_fragments: + self.console.insertHtml('
'.join(html_fragments) + '
') + if lines_to_display: + self.console.append('\n'.join(lines_to_display)) + if show_details_due_to_error and not self.show_details_checkbox.isChecked(): + self.show_details_checkbox.setChecked(True) + except Exception: + pass + + def on_installation_output(self, message): + """Single-message output handler (not currently wired to the batch thread).""" + if not hasattr(self, '_ttw_seen_lines'): + self._ttw_seen_lines = set() + self._ttw_last_extraction_progress = 0 + self._ttw_last_file_operation_time = 0 + self._ttw_file_operation_count = 0 + self._ttw_current_phase = None + self._ttw_last_progress_line = None + self._ttw_progress_line_text = None + + if message.strip().startswith('[Jackify]'): + self._write_to_log_file(message) + return + + cleaned = strip_ansi_control_codes(message).strip() + + filtered_chars = [] + for char in cleaned: + code = ord(char) + is_emoji = ( + (0x1F300 <= code <= 0x1F9FF) or + (0x1F600 <= code <= 0x1F64F) or + (0x2600 <= code <= 0x26FF) or + (0x2700 <= code <= 0x27BF) + ) + if not is_emoji: + filtered_chars.append(char) + cleaned = ''.join(filtered_chars).strip() + + if not cleaned: + return + + if not hasattr(self, 'ttw_start_time'): + self.ttw_start_time = time.time() + + lower_cleaned = cleaned.lower() + + try: + self._write_to_log_file(cleaned) + except Exception: + pass + + try: + progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned) + if progress_match: + current = int(progress_match.group(1)) + total = int(progress_match.group(2)) + percent = int((current / total) * 100) if total > 0 else 0 + self._update_ttw_activity(current, total, percent) + + if 'loading manifest:' in lower_cleaned: + manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned) + if manifest_match: + current = int(manifest_match.group(1)) + total = int(manifest_match.group(2)) + percent = int((current / total) * 100) if total > 0 else 0 + self._ttw_current_phase = "Loading manifest" + self._update_ttw_activity(current, total, percent) + except Exception: + pass + + is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned + is_warning = 'warning:' in lower_cleaned + is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid']) + is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx']) + is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.'] + + should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise) + + if should_show: + try: + if is_error or is_warning: + color = '#f44336' if is_error else '#ff9800' + prefix = "WARNING: " if is_warning else "ERROR: " + escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>') + html = f'{escaped}
' + self.console.insertHtml(html) + if not self.show_details_checkbox.isChecked(): + self.show_details_checkbox.setChecked(True) + else: + self.console.append(cleaned) + except Exception: + pass + + def on_installation_progress(self, progress_message): + """Replace the last console line for in-place progress updates.""" + from PySide6.QtGui import QTextCursor + cursor = self.console.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.insertText(progress_message) diff --git a/jackify/frontends/gui/screens/install_ttw_requirements.py b/jackify/frontends/gui/screens/install_ttw_requirements.py index 0b534d4..da2c6b5 100644 --- a/jackify/frontends/gui/screens/install_ttw_requirements.py +++ b/jackify/frontends/gui/screens/install_ttw_requirements.py @@ -6,16 +6,9 @@ from pathlib import Path import os import requests import traceback +import logging - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - +logger = logging.getLogger(__name__) class TTWRequirementsMixin: """Mixin providing TTW installer requirement checking and validation for InstallTTWScreen.""" @@ -117,7 +110,7 @@ class TTWRequirementsMixin: self.ttw_installer_btn.setText("Install now") self.ttw_installer_btn.setEnabled(True) self.ttw_installer_btn.setVisible(True) - debug_print(f"DEBUG: TTW_Linux_Installer status check failed: {e}") + logger.debug(f"DEBUG: TTW_Linux_Installer status check failed: {e}") def install_ttw_installer(self): """Install or update TTW_Linux_Installer""" @@ -185,7 +178,7 @@ class TTWRequirementsMixin: except Exception as e: error_msg = f"Error installing TTW_Linux_Installer: {str(e)}" self.progress.emit(error_msg) - debug_print(f"DEBUG: TTW_Linux_Installer installation error: {e}") + logger.debug(f"DEBUG: TTW_Linux_Installer installation error: {e}") self.finished.emit(False, error_msg) # Create and start thread diff --git a/jackify/frontends/gui/screens/install_ttw_thread.py b/jackify/frontends/gui/screens/install_ttw_thread.py new file mode 100644 index 0000000..b2e7917 --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_thread.py @@ -0,0 +1,151 @@ +"""TTW installation worker thread.""" +from PySide6.QtCore import QThread, Signal +import time + +from ..utils import strip_ansi_control_codes + + +class TTWInstallationThread(QThread): + output_batch_received = Signal(list) + progress_received = Signal(str) + installation_finished = Signal(bool, str) + + def __init__(self, mpi_path, install_dir): + super().__init__() + self.mpi_path = mpi_path + self.install_dir = install_dir + self.cancelled = False + self.proc = None + self.output_buffer = [] + self.last_emit_time = 0 + + def cancel(self): + self.cancelled = True + try: + if self.proc and self.proc.poll() is None: + self.proc.terminate() + except Exception: + pass + + def process_and_buffer_line(self, raw_line): + """Clean one output line and queue it for batched emit.""" + cleaned = strip_ansi_control_codes(raw_line).strip() + + filtered_chars = [] + for char in cleaned: + code = ord(char) + is_emoji = ( + (0x1F300 <= code <= 0x1F9FF) or + (0x1F600 <= code <= 0x1F64F) or + (0x2600 <= code <= 0x26FF) or + (0x2700 <= code <= 0x27BF) + ) + if not is_emoji: + filtered_chars.append(char) + cleaned = ''.join(filtered_chars).strip() + + if cleaned: + self.output_buffer.append(cleaned) + + def flush_output_buffer(self): + """Emit buffered lines as a batch.""" + if self.output_buffer: + self.output_batch_received.emit(self.output_buffer[:]) + self.output_buffer.clear() + self.last_emit_time = time.time() + + def run(self): + try: + from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler + from jackify.backend.handlers.filesystem_handler import FileSystemHandler + from jackify.backend.handlers.config_handler import ConfigHandler + from pathlib import Path + import tempfile + + self.process_and_buffer_line("Initializing TTW installation...") + self.flush_output_buffer() + + filesystem_handler = FileSystemHandler() + config_handler = ConfigHandler() + ttw_handler = TTWInstallerHandler( + steamdeck=False, + verbose=False, + filesystem_handler=filesystem_handler, + config_handler=config_handler, + ) + + output_file = tempfile.NamedTemporaryFile( + mode='w+', delete=False, suffix='.ttw_output', encoding='utf-8' + ) + output_file_path = Path(output_file.name) + output_file.close() + + self.process_and_buffer_line("Starting TTW installation...") + self.flush_output_buffer() + + self.proc, error_msg = ttw_handler.start_ttw_installation( + Path(self.mpi_path), + Path(self.install_dir), + output_file_path, + ) + + if not self.proc: + self.installation_finished.emit(False, error_msg or "Failed to start TTW installation") + return + + self.process_and_buffer_line("TTW_Linux_Installer process started, monitoring output...") + self.flush_output_buffer() + + last_position = 0 + BATCH_INTERVAL = 0.3 + + while self.proc.poll() is None: + if self.cancelled: + break + + try: + with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f: + f.seek(last_position) + new_lines = f.readlines() + last_position = f.tell() + + for line in new_lines: + if self.cancelled: + break + self.process_and_buffer_line(line.rstrip()) + + if time.time() - self.last_emit_time >= BATCH_INTERVAL: + self.flush_output_buffer() + except Exception: + pass + + time.sleep(0.1) + + try: + with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f: + f.seek(last_position) + for line in f.readlines(): + self.process_and_buffer_line(line.rstrip()) + self.flush_output_buffer() + except Exception: + pass + + try: + output_file_path.unlink(missing_ok=True) + except Exception: + pass + + ttw_handler.cleanup_ttw_process(self.proc) + + returncode = self.proc.returncode if self.proc else -1 + if self.cancelled: + self.installation_finished.emit(False, "Installation cancelled by user") + elif returncode == 0: + self.installation_finished.emit(True, "TTW installation completed successfully!") + else: + self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}") + + except Exception as e: + import traceback + traceback.print_exc() + self.installation_finished.emit(False, f"Installation error: {str(e)}") diff --git a/jackify/frontends/gui/screens/install_ttw_ui.py b/jackify/frontends/gui/screens/install_ttw_ui.py index e0e0a0b..22a30c2 100644 --- a/jackify/frontends/gui/screens/install_ttw_ui.py +++ b/jackify/frontends/gui/screens/install_ttw_ui.py @@ -9,15 +9,6 @@ from ..utils import set_responsive_minimum # Runtime import logger = logging.getLogger(__name__) - -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - class TTWUIMixin: """Mixin providing UI helper methods for InstallTTWScreen.""" @@ -93,7 +84,7 @@ class TTWUIMixin: # On Steam Deck, skip window resizing - keep default Steam Deck window size if is_steamdeck: - debug_print("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility") + logger.debug("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility") return # Restore main window to normal size (clear any compact constraints) @@ -146,7 +137,7 @@ class TTWUIMixin: # On Steam Deck, skip window resizing to keep maximized state if is_steamdeck: - debug_print("DEBUG: Steam Deck detected, skipping window resize in collapse branch") + logger.debug("DEBUG: Steam Deck detected, skipping window resize in collapse branch") return # Use fixed compact height for consistency across all workflow screens diff --git a/jackify/frontends/gui/screens/install_ttw_ui_setup.py b/jackify/frontends/gui/screens/install_ttw_ui_setup.py index 66b51d1..fac7f42 100644 --- a/jackify/frontends/gui/screens/install_ttw_ui_setup.py +++ b/jackify/frontends/gui/screens/install_ttw_ui_setup.py @@ -358,7 +358,6 @@ class TTWUISetupMixin: self.top_timer.start(2000) # --- Start Installation button --- self.start_btn.clicked.connect(self.validate_and_start_install) - self.steam_restart_finished.connect(self._on_steam_restart_finished) # Initialize process tracking self.process = None diff --git a/jackify/frontends/gui/screens/install_ttw_workflow.py b/jackify/frontends/gui/screens/install_ttw_workflow.py index dca6782..45a0221 100644 --- a/jackify/frontends/gui/screens/install_ttw_workflow.py +++ b/jackify/frontends/gui/screens/install_ttw_workflow.py @@ -1,66 +1,49 @@ """TTW installation workflow methods for InstallTTWScreen (Mixin).""" from pathlib import Path -from PySide6.QtCore import QTimer, Qt, QThread, Signal, QProcess +from PySide6.QtCore import QTimer, Qt, QProcess from PySide6.QtWidgets import QMessageBox, QApplication from PySide6.QtGui import QTextCursor import logging import os -import re import time import traceback -import shutil -import tempfile -# Runtime imports to avoid circular dependencies -from jackify.frontends.gui.services.message_service import MessageService # Runtime import -from jackify.backend.handlers.validation_handler import ValidationHandler # Runtime import -from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog # Runtime import -from ..shared_theme import JACKIFY_COLOR_BLUE # Runtime import -from ..utils import strip_ansi_control_codes # Runtime import + +from jackify.frontends.gui.services.message_service import MessageService +from jackify.shared.errors import install_dir_create_failed, wabbajack_install_failed +from jackify.backend.handlers.validation_handler import ValidationHandler +from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog +from ..shared_theme import JACKIFY_COLOR_BLUE logger = logging.getLogger(__name__) -def debug_print(message): - """Print debug message only if debug mode is enabled""" - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - print(message) - - class TTWWorkflowMixin: """Mixin providing installation workflow methods for InstallTTWScreen.""" def validate_and_start_install(self): import time self._install_workflow_start_time = time.time() - debug_print('DEBUG: validate_and_start_install called') + logger.debug('DEBUG: validate_and_start_install called') - # Reload config to pick up any settings changes made in Settings dialog self.config_handler.reload_config() - debug_print('DEBUG: Reloaded config from disk') + logger.debug('DEBUG: Reloaded config from disk') - # Check TTW requirements first if not self._check_ttw_requirements(): return - - # Check protontricks before proceeding + if not self._check_protontricks(): return - - # Disable all controls during installation (except Cancel) + self._disable_controls_during_operation() - + try: - # TTW only needs .mpi file mpi_path = self.file_edit.text().strip() if not mpi_path or not os.path.isfile(mpi_path) or not mpi_path.endswith('.mpi'): MessageService.warning(self, "Invalid TTW File", "Please select a valid TTW .mpi file.") self._enable_controls_after_operation() return install_dir = self.install_dir_edit.text().strip() - - # Validate required fields + missing_fields = [] if not install_dir: missing_fields.append("Install Directory") @@ -68,13 +51,12 @@ class TTWWorkflowMixin: MessageService.warning(self, "Missing Required Fields", f"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields)) self._enable_controls_after_operation() return - - # Validate install directory + + mpi_path = os.path.realpath(mpi_path) + install_dir = os.path.realpath(install_dir) validation_handler = ValidationHandler() - from pathlib import Path install_dir_path = Path(install_dir) - - # Check for dangerous directories first (system roots, etc.) + if validation_handler.is_dangerous_directory(install_dir_path): dlg = WarningDialog( f"The directory '{install_dir}' is a system or user root and cannot be used for TTW installation.", @@ -83,14 +65,11 @@ class TTWWorkflowMixin: if not dlg.exec() or not dlg.confirmed: self._enable_controls_after_operation() return - - # Check if directory exists and is not empty - TTW_Linux_Installer will overwrite existing files + if install_dir_path.exists() and install_dir_path.is_dir(): - # Check if directory contains any files try: has_files = any(install_dir_path.iterdir()) if has_files: - # Directory exists and is not empty - warn user about deletion dlg = WarningDialog( f"The TTW output directory already exists and contains files:\n{install_dir}\n\n" f"All files in this directory will be deleted before installation.\n\n" @@ -100,8 +79,7 @@ class TTWWorkflowMixin: if not dlg.exec() or not dlg.confirmed: self._enable_controls_after_operation() return - - # User confirmed - delete all contents of the directory + import shutil try: for item in install_dir_path.iterdir(): @@ -109,81 +87,67 @@ class TTWWorkflowMixin: shutil.rmtree(item) else: item.unlink() - debug_print(f"DEBUG: Deleted all contents of {install_dir}") + logger.debug(f"DEBUG: Deleted all contents of {install_dir}") except Exception as e: - MessageService.critical(self, "Error", f"Failed to delete directory contents:\n{e}") + MessageService.show_error(self, install_dir_create_failed(str(install_dir), str(e))) self._enable_controls_after_operation() return except Exception as e: - debug_print(f"DEBUG: Error checking directory contents: {e}") - # If we can't check, proceed - + logger.debug(f"DEBUG: Error checking directory contents: {e}") + if not os.path.isdir(install_dir): create = MessageService.question(self, "Create Directory?", f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?", - critical=False # Non-critical, won't steal focus + critical=False ) if create == QMessageBox.Yes: try: os.makedirs(install_dir, exist_ok=True) except Exception as e: - MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}") + MessageService.show_error(self, install_dir_create_failed(install_dir, str(e))) self._enable_controls_after_operation() return else: self._enable_controls_after_operation() return - - # Start TTW installation + self.console.clear() self.process_monitor.clear() - - # Update button states for installation + self.start_btn.setEnabled(False) self.cancel_btn.setVisible(False) self.cancel_install_btn.setVisible(True) - - debug_print(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}') + + logger.debug(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}') self.run_ttw_installer(mpi_path, install_dir) except Exception as e: - debug_print(f"DEBUG: Exception in validate_and_start_install: {e}") - import traceback - debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") - # Re-enable all controls after exception + logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}") + logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}") self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) - debug_print(f"DEBUG: Controls re-enabled in exception handler") + logger.debug("DEBUG: Controls re-enabled in exception handler") def run_ttw_installer(self, mpi_path, install_dir): - debug_print('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER') + logger.debug('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER') - # CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog - # Refresh Proton version and winetricks settings self.config_handler._load_config() - # Rotate log file at start of each workflow run (keep 5 backups) from jackify.backend.handlers.logging_handler import LoggingHandler - from pathlib import Path log_handler = LoggingHandler() log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) - # Clear console for fresh installation output self.console.clear() self._safe_append_text("Starting TTW installation...") - # Initialize Activity window with immediate feedback self.file_progress_list.clear() self._update_ttw_phase("Initializing TTW installation", 0, 0, 0) - # Force UI update immediately QApplication.processEvents() - # Show status banner and show details checkbox self.status_banner.setVisible(True) self.status_banner.setText("Initializing TTW installation...") self.show_details_checkbox.setVisible(True) - # Reset banner to default blue color for new installation self.status_banner.setStyleSheet(f""" background-color: #2a2a2a; color: {JACKIFY_COLOR_BLUE}; @@ -195,420 +159,36 @@ class TTWWorkflowMixin: self.ttw_start_time = time.time() - # Start a timer to update elapsed time self.ttw_elapsed_timer = QTimer() self.ttw_elapsed_timer.timeout.connect(self._update_ttw_elapsed_time) - self.ttw_elapsed_timer.start(1000) # Update every second + self.ttw_elapsed_timer.start(1000) - # Update UI state for installation self.start_btn.setEnabled(False) self.cancel_btn.setVisible(False) self.cancel_install_btn.setVisible(True) - - # Create installation thread - from PySide6.QtCore import QThread, Signal - - class TTWInstallationThread(QThread): - output_batch_received = Signal(list) # Batched output lines - progress_received = Signal(str) - installation_finished = Signal(bool, str) - def __init__(self, mpi_path, install_dir): - super().__init__() - self.mpi_path = mpi_path - self.install_dir = install_dir - self.cancelled = False - self.proc = None - self.output_buffer = [] # Buffer for batching output - self.last_emit_time = 0 # Track when we last emitted - - def cancel(self): - self.cancelled = True - try: - if self.proc and self.proc.poll() is None: - self.proc.terminate() - except Exception: - pass - - def process_and_buffer_line(self, raw_line): - """Process line in worker thread and add to buffer""" - # Strip ANSI codes - cleaned = strip_ansi_control_codes(raw_line).strip() - - # Strip emojis (do this in worker thread, not UI thread) - filtered_chars = [] - for char in cleaned: - code = ord(char) - is_emoji = ( - (0x1F300 <= code <= 0x1F9FF) or - (0x1F600 <= code <= 0x1F64F) or - (0x2600 <= code <= 0x26FF) or - (0x2700 <= code <= 0x27BF) - ) - if not is_emoji: - filtered_chars.append(char) - cleaned = ''.join(filtered_chars).strip() - - # Only buffer non-empty lines - if cleaned: - self.output_buffer.append(cleaned) - - def flush_output_buffer(self): - """Emit buffered lines as a batch""" - if self.output_buffer: - self.output_batch_received.emit(self.output_buffer[:]) - self.output_buffer.clear() - self.last_emit_time = time.time() - - def run(self): - try: - from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler - from pathlib import Path - import tempfile - - # Emit startup message - self.process_and_buffer_line("Initializing TTW installation...") - self.flush_output_buffer() - - # Create backend handler - filesystem_handler = FileSystemHandler() - config_handler = ConfigHandler() - ttw_handler = TTWInstallerHandler( - steamdeck=False, - verbose=False, - filesystem_handler=filesystem_handler, - config_handler=config_handler - ) - - # Create temporary output file - output_file = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.ttw_output', encoding='utf-8') - output_file_path = Path(output_file.name) - output_file.close() - - # Start installation via backend (non-blocking) - self.process_and_buffer_line("Starting TTW installation...") - self.flush_output_buffer() - - self.proc, error_msg = ttw_handler.start_ttw_installation( - Path(self.mpi_path), - Path(self.install_dir), - output_file_path - ) - - if not self.proc: - self.installation_finished.emit(False, error_msg or "Failed to start TTW installation") - return - - self.process_and_buffer_line("TTW_Linux_Installer process started, monitoring output...") - self.flush_output_buffer() - - # Poll output file with batching for UI responsiveness - last_position = 0 - BATCH_INTERVAL = 0.3 # Emit batches every 300ms - - while self.proc.poll() is None: - if self.cancelled: - break - - try: - # Read new content from file - with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f: - f.seek(last_position) - new_lines = f.readlines() - last_position = f.tell() - - # Process lines in worker thread (heavy work done here, not UI thread) - for line in new_lines: - if self.cancelled: - break - self.process_and_buffer_line(line.rstrip()) - - # Emit batch if enough time has passed - current_time = time.time() - if current_time - self.last_emit_time >= BATCH_INTERVAL: - self.flush_output_buffer() - - except Exception: - pass - - # Sleep longer since we're batching - time.sleep(0.1) - - # Read any remaining output - try: - with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f: - f.seek(last_position) - remaining_lines = f.readlines() - for line in remaining_lines: - self.process_and_buffer_line(line.rstrip()) - self.flush_output_buffer() - except Exception: - pass - - # Clean up - try: - output_file_path.unlink(missing_ok=True) - except Exception: - pass - - ttw_handler.cleanup_ttw_process(self.proc) - - # Check result - returncode = self.proc.returncode if self.proc else -1 - if self.cancelled: - self.installation_finished.emit(False, "Installation cancelled by user") - elif returncode == 0: - self.installation_finished.emit(True, "TTW installation completed successfully!") - else: - self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}") - - except Exception as e: - import traceback - traceback.print_exc() - self.installation_finished.emit(False, f"Installation error: {str(e)}") - - # Start the installation thread + from .install_ttw_thread import TTWInstallationThread self.install_thread = TTWInstallationThread(mpi_path, install_dir) - # Use QueuedConnection to ensure signals are processed asynchronously and don't block UI self.install_thread.output_batch_received.connect(self.on_installation_output_batch, Qt.QueuedConnection) self.install_thread.progress_received.connect(self.on_installation_progress, Qt.QueuedConnection) self.install_thread.installation_finished.connect(self.on_installation_finished, Qt.QueuedConnection) - # Start thread and immediately process events to show initial UI state self.install_thread.start() - QApplication.processEvents() # Process any pending events to update UI immediately - - def on_installation_output_batch(self, messages): - """Handle batched output from TTW_Linux_Installer (already processed in worker thread)""" - # Lines are already cleaned (ANSI codes stripped, emojis removed) in worker thread - # CRITICAL: Accumulate all console updates and do ONE widget update per batch - - if not hasattr(self, '_ttw_seen_lines'): - self._ttw_seen_lines = set() - self._ttw_current_phase = None - self._ttw_last_progress = 0 - self._ttw_last_activity_update = 0 - self.ttw_start_time = time.time() - - # Accumulate lines to display (do ONE console update at end) - lines_to_display = [] - html_fragments = [] - show_details_due_to_error = False - latest_progress = None # Track latest progress to update activity ONCE per batch - - for cleaned in messages: - if not cleaned: - continue - - lower_cleaned = cleaned.lower() - - # Extract progress (but don't update UI yet - wait until end of batch) - try: - progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned) - if progress_match: - current = int(progress_match.group(1)) - total = int(progress_match.group(2)) - percent = int((current / total) * 100) if total > 0 else 0 - latest_progress = (current, total, percent) - - if 'loading manifest:' in lower_cleaned: - manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned) - if manifest_match: - current = int(manifest_match.group(1)) - total = int(manifest_match.group(2)) - self._ttw_current_phase = "Loading manifest" - except Exception: - pass - - # Determine if we should show this line - is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned - is_warning = 'warning:' in lower_cleaned - is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid']) - is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx']) - - # Filter out meaningless standalone messages (just "OK", etc.) - is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.'] - - should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise) - - if should_show: - if is_error or is_warning: - color = '#f44336' if is_error else '#ff9800' - prefix = "WARNING: " if is_warning else "ERROR: " - escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>') - html_fragments.append(f'{escaped}') - show_details_due_to_error = True - else: - lines_to_display.append(cleaned) - - # Update activity widget ONCE per batch (if progress changed significantly) - if latest_progress: - current, total, percent = latest_progress - current_time = time.time() - percent_changed = abs(percent - self._ttw_last_progress) >= 1 - time_passed = (current_time - self._ttw_last_activity_update) >= 0.5 # 500ms throttle - - if percent_changed or time_passed: - self._update_ttw_activity(current, total, percent) - self._ttw_last_progress = percent - self._ttw_last_activity_update = current_time - - # Now do ONE console update for entire batch - if html_fragments or lines_to_display: - try: - # Update console with all accumulated output in one operation - if html_fragments: - combined_html = '
'.join(html_fragments) - self.console.insertHtml(combined_html + '
') - - if lines_to_display: - combined_text = '\n'.join(lines_to_display) - self.console.append(combined_text) - - if show_details_due_to_error and not self.show_details_checkbox.isChecked(): - self.show_details_checkbox.setChecked(True) - except Exception: - pass - - def on_installation_output(self, message): - """Handle regular output from TTW_Linux_Installer with comprehensive filtering and smart parsing""" - # Initialize tracking structures - if not hasattr(self, '_ttw_seen_lines'): - self._ttw_seen_lines = set() - self._ttw_last_extraction_progress = 0 - self._ttw_last_file_operation_time = 0 - self._ttw_file_operation_count = 0 - self._ttw_current_phase = None - self._ttw_last_progress_line = None - self._ttw_progress_line_text = None - - # Filter out internal status messages from user console - if message.strip().startswith('[Jackify]'): - # Log internal messages to file but don't show in console - self._write_to_log_file(message) - return - - # Strip ANSI terminal control codes - cleaned = strip_ansi_control_codes(message).strip() - - # Strip emojis from output (TTW_Linux_Installer includes emojis) - # Use character-by-character filtering to avoid regex recursion issues - # Safer than regex for emoji removal - filtered_chars = [] - for char in cleaned: - code = ord(char) - # Check if character is in emoji ranges - skip emojis - is_emoji = ( - (0x1F300 <= code <= 0x1F9FF) or # Miscellaneous Symbols and Pictographs - (0x1F600 <= code <= 0x1F64F) or # Emoticons - (0x2600 <= code <= 0x26FF) or # Miscellaneous Symbols - (0x2700 <= code <= 0x27BF) # Dingbats - ) - if not is_emoji: - filtered_chars.append(char) - cleaned = ''.join(filtered_chars).strip() - - # Filter out empty lines - if not cleaned: - return - - # Initialize start time if not set - if not hasattr(self, 'ttw_start_time'): - self.ttw_start_time = time.time() - - lower_cleaned = cleaned.lower() - - # === MINIMAL PROCESSING: Match standalone behavior as closely as possible === - # When running standalone: output goes directly to terminal, no processing - # Here: We must process each line, but do it as efficiently as possible - - # Always log to file (simple, no recursion risk) - try: - self._write_to_log_file(cleaned) - except Exception: - pass - - # Extract progress for Activity window (minimal regex, wrapped in try/except) - try: - # Try [X/Y] pattern - progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned) - if progress_match: - current = int(progress_match.group(1)) - total = int(progress_match.group(2)) - percent = int((current / total) * 100) if total > 0 else 0 - phase = self._ttw_current_phase or "Processing" - self._update_ttw_activity(current, total, percent) - - # Try "Loading manifest: X/Y" - if 'loading manifest:' in lower_cleaned: - manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned) - if manifest_match: - current = int(manifest_match.group(1)) - total = int(manifest_match.group(2)) - percent = int((current / total) * 100) if total > 0 else 0 - self._ttw_current_phase = "Loading manifest" - self._update_ttw_activity(current, total, percent) - except Exception: - pass # Skip if regex fails - - # Determine if we should show this line - # By default: only show errors, warnings, milestones - # Everything else: only in details mode - is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned - is_warning = 'warning:' in lower_cleaned - is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid']) - is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx']) - - # Filter out meaningless standalone messages (just "OK", etc.) - is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.'] - - should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise) - - if should_show: - # Direct console append - no recursion, no complex processing - try: - if is_error or is_warning: - # Color code errors/warnings - color = '#f44336' if is_error else '#ff9800' - prefix = "WARNING: " if is_warning else "ERROR: " - escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>') - html = f'{escaped}
' - self.console.insertHtml(html) - if not self.show_details_checkbox.isChecked(): - self.show_details_checkbox.setChecked(True) - else: - self.console.append(cleaned) - except Exception: - pass # Don't break on console errors - - return - - def on_installation_progress(self, progress_message): - """Replace the last line in the console for progress updates""" - cursor = self.console.textCursor() - cursor.movePosition(QTextCursor.End) - cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor) - cursor.removeSelectedText() - cursor.insertText(progress_message) - # Don't force scroll for progress updates - let user control + QApplication.processEvents() def on_installation_finished(self, success, message): - """Handle installation completion""" - debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}") + """Handle installation completion.""" + logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}") - # Stop elapsed timer if hasattr(self, 'ttw_elapsed_timer'): self.ttw_elapsed_timer.stop() - # Update status banner if success: elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0 minutes = elapsed // 60 seconds = elapsed % 60 self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s") - self.status_banner.setStyleSheet(f""" + self.status_banner.setStyleSheet(""" background-color: #1a4d1a; color: #4CAF50; padding: 8px; @@ -620,7 +200,7 @@ class TTWWorkflowMixin: self.process_finished(0, QProcess.NormalExit) else: self.status_banner.setText(f"Installation failed: {message}") - self.status_banner.setStyleSheet(f""" + self.status_banner.setStyleSheet(""" background-color: #4d1a1a; color: #f44336; padding: 8px; @@ -632,32 +212,28 @@ class TTWWorkflowMixin: self.process_finished(1, QProcess.CrashExit) def process_finished(self, exit_code, exit_status): - debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") - # Reset button states + logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) - debug_print("DEBUG: Button states reset in process_finished") - + logger.debug("DEBUG: Button states reset in process_finished") if exit_code == 0: - # TTW installation complete self._safe_append_text("\nTTW installation completed successfully!") self._safe_append_text("The merged TTW files have been created in the output directory.") - # Check if we're in modlist integration mode if self._integration_mode: self._safe_append_text("\nIntegrating TTW into modlist...") self._perform_modlist_integration() else: - # Standard mode - ask user if they want to create a mod archive for MO2 reply = MessageService.question( self, "TTW Installation Complete!", "Tale of Two Wastelands installation completed successfully!\n\n" f"Output location: {self.install_dir_edit.text()}\n\n" "Would you like to create a zipped mod archive for MO2?\n" "This will package the TTW files for easy installation into Mod Organizer 2.", - critical=False + critical=False, + safety_level="medium", ) if reply == QMessageBox.Yes: @@ -670,12 +246,10 @@ class TTWWorkflowMixin: safety_level="medium" ) else: - # Check for user cancellation first last_output = self.console.toPlainText() if "cancelled by user" in last_output.lower(): MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") else: - MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.") + MessageService.show_error(self, wabbajack_install_failed(f"Exit code {exit_code}. Check the console output for details.")) self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") self.console.moveCursor(QTextCursor.End) - diff --git a/jackify/frontends/gui/screens/wabbajack_installer.py b/jackify/frontends/gui/screens/wabbajack_installer.py index ff94332..33fe25b 100644 --- a/jackify/frontends/gui/screens/wabbajack_installer.py +++ b/jackify/frontends/gui/screens/wabbajack_installer.py @@ -20,6 +20,7 @@ from PySide6.QtCore import Qt, QThread, Signal, QSize from PySide6.QtGui import QTextCursor from jackify.backend.models.configuration import SystemInfo +from jackify.shared.errors import wabbajack_install_failed from ..services.message_service import MessageService from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from ..utils import set_responsive_minimum @@ -362,7 +363,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): ) if folder: - self.install_folder = Path(folder) + self.install_folder = Path(folder).resolve() self.install_dir_edit.setText(str(self.install_folder)) self.start_btn.setEnabled(True) @@ -377,7 +378,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): MessageService.warning(self, "No Folder Selected", "Please select an installation folder first.") return - self.install_folder = Path(install_dir_text) + self.install_folder = Path(install_dir_text).resolve() # Get shortcut name self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack" @@ -390,7 +391,8 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): "This will download Wabbajack, add to Steam, install WebView2,\n" "and configure the Wine prefix automatically.\n\n" "Steam will be restarted during installation.\n\n" - "Continue?" + "Continue?", + safety_level="medium", ) if confirm != QMessageBox.Yes: @@ -555,7 +557,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget): self.cancel_btn.setEnabled(True) else: self.progress_indicator.set_status("Installation failed", 0) - MessageService.critical(self, "Installation Failed", message) + MessageService.show_error(self, wabbajack_install_failed(message)) self.start_btn.setEnabled(True) self.cancel_btn.setEnabled(True) diff --git a/jackify/frontends/gui/services/message_service.py b/jackify/frontends/gui/services/message_service.py index 7987d32..8e81374 100644 --- a/jackify/frontends/gui/services/message_service.py +++ b/jackify/frontends/gui/services/message_service.py @@ -6,8 +6,13 @@ Provides message boxes that don't steal focus from the current application import random import string from typing import Optional -from PySide6.QtWidgets import QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QCheckBox +from PySide6.QtWidgets import ( + QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, + QCheckBox, QTextEdit, QPushButton, QDialog, QDialogButtonBox, QSizePolicy, + QStyle, +) from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QFont class NonFocusMessageBox(QMessageBox): @@ -118,6 +123,7 @@ class SafeMessageBox(NonFocusMessageBox): def _setup_medium_safety(self, danger_action: str, safe_action: str): """Medium safety: requires wait period""" + self._danger_action_text = danger_action self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole) self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole) self.setDefaultButton(self.cancel_btn) @@ -143,7 +149,8 @@ class SafeMessageBox(NonFocusMessageBox): if self.safety_level == "high": self.proceed_btn.setText(f"Please wait {self.countdown_remaining}s...") else: - self.proceed_btn.setText(f"OK ({self.countdown_remaining}s)") + action_label = getattr(self, "_danger_action_text", "OK") + self.proceed_btn.setText(f"{action_label} ({self.countdown_remaining}s)") self.proceed_btn.setEnabled(False) if hasattr(self, 'cancel_btn'): self.cancel_btn.setEnabled(False) @@ -154,7 +161,7 @@ class SafeMessageBox(NonFocusMessageBox): if self.safety_level == "high": self.proceed_btn.setText("Proceed") else: - self.proceed_btn.setText("OK") + self.proceed_btn.setText(getattr(self, "_danger_action_text", "OK")) self.proceed_btn.setEnabled(True) if hasattr(self, 'cancel_btn'): self.cancel_btn.setEnabled(True) @@ -284,4 +291,147 @@ class MessageService: clicked = msg_box.clickedButton() if clicked and clicked.text() == "Yes": return QMessageBox.Yes - return QMessageBox.No \ No newline at end of file + return QMessageBox.No + + @staticmethod + def show_error(parent: Optional[QWidget], error) -> None: + """Show a structured error dialog for a JackifyError. + + Displays title, plain-English message, optional "what to do" suggestion, + and an optional collapsible technical detail pane. + + Args: + parent: Parent widget (may be None). + error: A JackifyError instance (imported inside to preserve + backend/frontend separation). + """ + from jackify.shared.errors import JackifyError + + if not isinstance(error, JackifyError): + # Fallback for plain exceptions + dialog = _ErrorDialog(parent, str(error), str(error), None, [], None) + dialog.exec() + return + + dialog = _ErrorDialog( + parent, + error.title, + error.message, + error.suggestion, + getattr(error, 'solutions', []), + error.technical, + ) + dialog.exec() + + +class _ErrorDialog(QDialog): + """Internal dialog used by MessageService.show_error().""" + + _DETAIL_HEIGHT = 140 + + def __init__(self, parent, title: str, message: str, + suggestion: Optional[str], solutions, technical: Optional[str]): + super().__init__(parent) + self.setWindowTitle(title) + self.setWindowModality(Qt.ApplicationModal) + self.setAttribute(Qt.WA_DeleteOnClose) + self._technical = technical + self._detail_visible = False + + layout = QVBoxLayout(self) + layout.setSpacing(10) + + # Icon + message row + icon_label = QLabel() + icon_label.setPixmap( + self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxCritical).pixmap(32, 32) + ) + icon_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + msg_label = QLabel(message) + msg_label.setWordWrap(True) + msg_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + top_row = QHBoxLayout() + top_row.addWidget(icon_label) + top_row.addWidget(msg_label, 1) + layout.addLayout(top_row) + + # Suggestion row + if suggestion: + sug_label = QLabel(f"What to do: {suggestion}") + sug_label.setWordWrap(True) + sug_label.setStyleSheet("color: #aaaaaa; padding-left: 42px;") + layout.addWidget(sug_label) + + # Numbered solutions list + if solutions: + steps_label = QLabel("Things to try:") + steps_label.setStyleSheet("color: #cccccc; padding-left: 42px; font-weight: bold;") + layout.addWidget(steps_label) + for i, step in enumerate(solutions, start=1): + step_label = QLabel(f" {i}. {step}") + step_label.setWordWrap(True) + step_label.setStyleSheet("color: #aaaaaa; padding-left: 52px;") + layout.addWidget(step_label) + + # Technical detail toggle + if technical: + self._toggle_btn = QPushButton("Show technical detail") + self._toggle_btn.setCheckable(False) + self._toggle_btn.setStyleSheet( + "QPushButton { text-align: left; border: none; color: #888888; " + "padding: 0; font-size: 11px; } " + "QPushButton:hover { color: #cccccc; }" + ) + self._toggle_btn.clicked.connect(self._toggle_detail) + layout.addWidget(self._toggle_btn) + + self._detail_edit = QTextEdit() + self._detail_edit.setReadOnly(True) + self._detail_edit.setPlainText(technical) + mono = QFont("Monospace") + mono.setStyleHint(QFont.TypeWriter) + self._detail_edit.setFont(mono) + self._detail_edit.setStyleSheet( + "background-color: #1a1a1a; color: #cccccc; " + "border: 1px solid #333333; border-radius: 4px;" + ) + self._detail_edit.setFixedHeight(self._DETAIL_HEIGHT) + self._detail_edit.hide() + layout.addWidget(self._detail_edit) + + # OK button — disabled for 3s to prevent accidental dismissal + buttons = QDialogButtonBox(QDialogButtonBox.Ok) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) + + self._ok_btn = buttons.button(QDialogButtonBox.Ok) + self._ok_countdown = 3 + self._ok_btn.setEnabled(False) + self._ok_btn.setText(f"OK ({self._ok_countdown}s)") + self._ok_timer = QTimer(self) + self._ok_timer.timeout.connect(self._tick_ok_countdown) + self._ok_timer.start(1000) + + self.setMinimumWidth(440) + self.adjustSize() + + def _tick_ok_countdown(self): + self._ok_countdown -= 1 + if self._ok_countdown > 0: + self._ok_btn.setText(f"OK ({self._ok_countdown}s)") + else: + self._ok_timer.stop() + self._ok_btn.setText("OK") + self._ok_btn.setEnabled(True) + + def _toggle_detail(self): + self._detail_visible = not self._detail_visible + if self._detail_visible: + self._detail_edit.show() + self._toggle_btn.setText("Hide technical detail") + else: + self._detail_edit.hide() + self._toggle_btn.setText("Show technical detail") + self.adjustSize() diff --git a/jackify/frontends/gui/widgets/file_progress_item.py b/jackify/frontends/gui/widgets/file_progress_item.py index 9569c69..6f5eda3 100644 --- a/jackify/frontends/gui/widgets/file_progress_item.py +++ b/jackify/frontends/gui/widgets/file_progress_item.py @@ -5,7 +5,7 @@ File progress item widget for a single file's progress display. from PySide6.QtWidgets import ( QWidget, QHBoxLayout, QLabel, QProgressBar, QSizePolicy ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt from jackify.shared.progress_models import FileProgress, OperationType from ..shared_theme import JACKIFY_COLOR_BLUE @@ -17,13 +17,8 @@ class FileProgressItem(QWidget): def __init__(self, file_progress: FileProgress, parent=None): super().__init__(parent) self.file_progress = file_progress - self._target_percent = file_progress.percent - self._current_display_percent = file_progress.percent - self._spinner_position = 0 self._is_indeterminate = False - self._animation_timer = QTimer(self) - self._animation_timer.timeout.connect(self._animate_progress) - self._animation_timer.setInterval(16) + self._is_queued = False self._setup_ui() self._update_display() @@ -73,22 +68,24 @@ class FileProgressItem(QWidget): def _get_operation_symbol(self) -> str: symbols = { OperationType.DOWNLOAD: "↓", - OperationType.EXTRACT: "↻", + OperationType.EXTRACT: "↻", OperationType.VALIDATE: "✓", - OperationType.INSTALL: "→", + OperationType.INSTALL: "→", } return symbols.get(self.file_progress.operation, "•") def _truncate_filename(self, filename: str, max_length: int = 40) -> str: if len(filename) <= max_length: return filename - return filename[:max_length-3] + "..." + return filename[:max_length - 3] + "..." def _update_display(self): - is_summary = hasattr(self.file_progress, '_is_summary') and self.file_progress._is_summary - no_progress_bar = hasattr(self.file_progress, '_no_progress_bar') and self.file_progress._no_progress_bar + is_summary = getattr(self.file_progress, '_is_summary', False) + no_progress_bar = getattr(self.file_progress, '_no_progress_bar', False) - if 'Installing Files' in self.file_progress.filename or 'Converting Texture' in self.file_progress.filename or 'BSA:' in self.file_progress.filename: + if ('Installing Files' in self.file_progress.filename + or 'Converting Texture' in self.file_progress.filename + or 'BSA:' in self.file_progress.filename): name_display = self.file_progress.filename elif self.file_progress.filename.startswith('Wine component:'): rest = self.file_progress.filename.split(':', 1)[1].strip() @@ -106,7 +103,8 @@ class FileProgressItem(QWidget): self.filename_label.setToolTip(self.file_progress.filename) if no_progress_bar: - self._animation_timer.stop() + self._is_indeterminate = False + self._is_queued = False self.percent_label.setText("") self.progress_bar.setVisible(False) return @@ -116,80 +114,58 @@ class FileProgressItem(QWidget): if is_summary: summary_step = getattr(self.file_progress, '_summary_step', 0) summary_max = getattr(self.file_progress, '_summary_max', 0) - + self._is_queued = False if summary_max > 0: - percent = (summary_step / summary_max) * 100.0 - self._target_percent = max(0, min(100, percent)) - if not self._animation_timer.isActive(): - self._animation_timer.start() - self.progress_bar.setRange(0, 100) + self._set_determinate((summary_step / summary_max) * 100.0) else: - self._is_indeterminate = True - self.percent_label.setText("") - self.progress_bar.setRange(0, 100) - if not self._animation_timer.isActive(): - self._animation_timer.start() + self._set_indeterminate() return is_queued = ( - self.file_progress.total_size > 0 and - self.file_progress.percent == 0 and - self.file_progress.current_size == 0 and - self.file_progress.speed <= 0 + self.file_progress.total_size > 0 + and self.file_progress.percent == 0 + and self.file_progress.current_size == 0 + and self.file_progress.speed <= 0 ) - if is_queued: + self._is_queued = True self._is_indeterminate = False - self._animation_timer.stop() self.percent_label.setText("Queued") self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) return + self._is_queued = False has_meaningful_progress = ( - self.file_progress.percent > 0 or - (self.file_progress.total_size > 0 and self.file_progress.current_size > 0) or - (self.file_progress.speed > 0 and self.file_progress.percent >= 0) + self.file_progress.percent > 0 + or (self.file_progress.total_size > 0 and self.file_progress.current_size > 0) + or (self.file_progress.speed > 0 and self.file_progress.percent >= 0) ) - if has_meaningful_progress: - self._is_indeterminate = False - self._target_percent = max(0, self.file_progress.percent) - if not self._animation_timer.isActive(): - self._animation_timer.start() - self.progress_bar.setRange(0, 100) + self._set_determinate(max(0.0, self.file_progress.percent)) else: + self._set_indeterminate() + + def _set_indeterminate(self): + if not self._is_indeterminate: self._is_indeterminate = True - self.percent_label.setText("") - self.progress_bar.setRange(0, 100) - if not self._animation_timer.isActive(): - self._animation_timer.start() + # Qt's QProgressStyleAnimation drives this automatically — no manual timer needed + self.progress_bar.setRange(0, 0) + self.percent_label.setText("") - def _animate_progress(self): + def _set_determinate(self, percent: float): if self._is_indeterminate: - self._spinner_position = (self._spinner_position + 4) % 200 - if self._spinner_position < 100: - display_value = self._spinner_position - else: - display_value = 200 - self._spinner_position - self.progress_bar.setValue(display_value) + self._is_indeterminate = False + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(int(max(0.0, min(100.0, percent)))) + if self.file_progress.percent > 0: + self.percent_label.setText(f"{percent:.0f}%") else: - diff = self._target_percent - self._current_display_percent - if abs(diff) >= 0.1: - self._current_display_percent += diff * 0.2 - self._current_display_percent = max(0, min(100, self._current_display_percent)) - - display_percent = self._current_display_percent - self.progress_bar.setValue(int(display_percent)) - if self.file_progress.percent > 0: - self.percent_label.setText(f"{display_percent:.0f}%") - else: - self.percent_label.setText("") + self.percent_label.setText("") def update_progress(self, file_progress: FileProgress): self.file_progress = file_progress self._update_display() def cleanup(self): - if self._animation_timer.isActive(): - self._animation_timer.stop() + pass diff --git a/jackify/frontends/gui/widgets/file_progress_list.py b/jackify/frontends/gui/widgets/file_progress_list.py index 2f116b1..bb31204 100644 --- a/jackify/frontends/gui/widgets/file_progress_list.py +++ b/jackify/frontends/gui/widgets/file_progress_list.py @@ -12,9 +12,9 @@ import time from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem, - QProgressBar, QHBoxLayout, QSizePolicy + QHBoxLayout, QSizePolicy ) -from PySide6.QtCore import Qt, QSize, QTimer +from PySide6.QtCore import Qt, QSize, QTimer, QThread, Signal from jackify.shared.progress_models import FileProgress, OperationType @@ -24,11 +24,95 @@ from .file_progress_item import FileProgressItem __all__ = ['SummaryProgressWidget', 'FileProgressItem', 'FileProgressList'] +class _CpuWorker(QThread): + """Background worker for CPU usage sampling — keeps psutil off the main thread.""" + result = Signal(str) + caches_updated = Signal(object, object, float) # process_cache, child_cache, smoothed_pct + + def __init__(self, last_pct, process_cache, child_cache): + super().__init__() + self._last_pct = last_pct + self._process_cache = process_cache + self._child_cache = dict(child_cache) if child_cache else {} + + def run(self): + try: + import psutil, os + + if self._process_cache is None: + self._process_cache = psutil.Process(os.getpid()) + # Establish baseline (blocking, but only once and in background) + self._process_cache.cpu_percent(interval=0.1) + + num_cpus = psutil.cpu_count() or 1 + total_cpu = self._process_cache.cpu_percent(interval=None) / num_cpus + + current_child_pids = set() + try: + for child in self._process_cache.children(recursive=True): + try: + current_child_pids.add(child.pid) + if child.pid not in self._child_cache: + # Baseline in background — no longer blocks main thread + child.cpu_percent(interval=0.1) + self._child_cache[child.pid] = child + continue + total_cpu += self._child_cache[child.pid].cpu_percent(interval=None) / num_cpus + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + for pid in set(self._child_cache.keys()) - current_child_pids: + del self._child_cache[pid] + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + jackify_names = [ + 'jackify-engine', 'texconv', 'texdiag', 'directxtex', + 'texconv_jackify', 'texdiag_jackify', 'directxtex_jackify', + '7z', '7zz', 'bsarch', 'wine', 'wine64', 'wine64-preloader', + 'steam-run', 'proton', + ] + tracked_pids = {self._process_cache.pid} | current_child_pids + try: + for proc in psutil.process_iter(['name', 'pid', 'cmdline']): + try: + if proc.pid in tracked_pids: + continue + proc_name = proc.info.get('name', '').lower() + cmdline_str = ' '.join(proc.info.get('cmdline', []) or []).lower() + is_jackify = any(n in proc_name for n in jackify_names) + if not is_jackify and cmdline_str: + is_jackify = any(n in cmdline_str for n in jackify_names) + if not is_jackify: + is_jackify = any(f'{n}.exe' in cmdline_str for n in jackify_names) + if not is_jackify: + is_jackify = 'jackify' in cmdline_str and any( + t in cmdline_str for t in ['engine', 'tools', 'binaries'] + ) + if is_jackify: + if proc.pid not in self._child_cache: + proc.cpu_percent(interval=0.1) + self._child_cache[proc.pid] = proc + continue + total_cpu += self._child_cache[proc.pid].cpu_percent(interval=None) / num_cpus + tracked_pids.add(proc.pid) + except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError): + pass + except Exception: + pass + + if self._last_pct > 0: + total_cpu = self._last_pct * 0.3 + total_cpu * 0.7 + display = min(100.0, total_cpu) + self.result.emit(f"CPU: {display:.0f}%") + self.caches_updated.emit(self._process_cache, self._child_cache, total_cpu) + + except Exception: + self.result.emit("") + + def _debug_log(message): - """Log message only if debug mode is enabled""" from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): + if ConfigHandler().get('debug_mode', False): print(message) @@ -37,49 +121,36 @@ class FileProgressList(QWidget): Widget displaying a list of files currently being processed. Shows individual progress for each file. """ - - def __init__(self, parent=None): - """ - Initialize file progress list. - Args: - parent: Parent widget - """ + def __init__(self, parent=None): super().__init__(parent) self._file_items: dict[str, FileProgressItem] = {} self._summary_widget: Optional[SummaryProgressWidget] = None - self._last_phase: Optional[str] = None # Track phase changes for transition messages - self._transition_label: Optional[QLabel] = None # Label for "Preparing..." message - self._last_summary_time: float = 0.0 # Track when summary widget was last shown - self._summary_hold_duration: float = 0.5 # Hold summary for minimum 0.5s to prevent flicker - self._last_summary_update: float = 0.0 # Track last summary update for throttling - self._summary_update_interval: float = 0.1 # Update summary every 100ms (simple throttling) - + self._last_phase: Optional[str] = None + self._transition_label: Optional[QLabel] = None + self._last_summary_time: float = 0.0 + self._summary_hold_duration: float = 0.5 + self._last_summary_update: float = 0.0 + self._summary_update_interval: float = 0.1 + self._setup_ui() - # Set size policy to match Process Monitor - expand to fill available space self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - + def _setup_ui(self): - """Set up the UI - match Process Monitor layout structure exactly.""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) # Match Process Monitor spacing (was 4, now 2) + layout.setSpacing(2) - # Header row with CPU usage only (tab label replaces "[Activity]" header) header_layout = QHBoxLayout() header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(8) - - # CPU usage indicator (right-aligned) self.cpu_label = QLabel("") self.cpu_label.setStyleSheet("color: #888; font-size: 11px; margin-bottom: 2px;") self.cpu_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - header_layout.addStretch() # Push CPU label to the right + header_layout.addStretch() header_layout.addWidget(self.cpu_label, 0) - layout.addLayout(header_layout) - - # List widget for file items - match Process Monitor size constraints + self.list_widget = QListWidget() self.list_widget.setStyleSheet(""" QListWidget { @@ -95,86 +166,55 @@ class FileProgressList(QWidget): background-color: #2a2a2a; } """) - # Match Process Monitor minimum size: QSize(300, 20) self.list_widget.setMinimumSize(QSize(300, 20)) - # Match Process Monitor - no maximum height constraint, expand to fill available space - # The list will scroll if there are more items than can fit self.list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - # Match Process Monitor size policy - expand to fill available space self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - layout.addWidget(self.list_widget, stretch=1) # Match Process Monitor stretch - - # Throttle timer for updates when there are many files - import time + layout.addWidget(self.list_widget, stretch=1) + self._last_update_time = 0.0 - # CPU usage tracking + # CPU usage tracking — worker thread to avoid blocking the main thread self._cpu_timer = QTimer(self) - self._cpu_timer.timeout.connect(self._update_cpu_usage) - self._cpu_timer.setInterval(2000) # Update every 2 seconds + self._cpu_timer.timeout.connect(self._start_cpu_worker) + self._cpu_timer.setInterval(2000) self._last_cpu_percent = 0.0 - self._cpu_process_cache = None # Cache the process object for better performance - self._child_process_cache = {} # Cache child Process objects by PID for persistent CPU tracking - + self._cpu_process_cache = None + self._child_process_cache = {} + self._cpu_worker = None + def update_files(self, file_progresses: list[FileProgress], current_phase: str = None, summary_info: dict = None): - """ - Update the list with current file progresses. - - Args: - file_progresses: List of FileProgress objects for active files - current_phase: Optional phase name to display in header (e.g., "Downloading", "Extracting") - summary_info: Optional dict with 'current_step' and 'max_steps' for summary display (e.g., Installing phase) - """ - # Throttle updates to prevent UI freezing with many files - # If we have many files (>50), throttle updates to every 100ms - import time current_time = time.time() + + # Throttle for large file lists if len(file_progresses) > 50: - if current_time - self._last_update_time < 0.1: # 100ms throttle - return # Skip this update + if current_time - self._last_update_time < 0.1: + return self._last_update_time = current_time - - # If we have summary info (e.g., Installing phase), show summary widget instead of file list + + # Summary widget path (Installing phase etc.) if summary_info and not file_progresses: - current_time = time.time() - - # Get new values current_step = summary_info.get('current_step', 0) - max_steps = summary_info.get('max_steps', 0) - phase_name = current_phase or "Installing files" + max_steps = summary_info.get('max_steps', 0) + phase_name = current_phase or "Installing files" - # Check if summary widget already exists and is valid summary_widget_valid = self._summary_widget and shiboken6.isValid(self._summary_widget) if not summary_widget_valid: self._summary_widget = None - # If widget exists, check if we should throttle the update if self._summary_widget: - # Throttle updates to prevent flickering with rapidly changing counters if current_time - self._last_summary_update < self._summary_update_interval: - return # Skip update, too soon - - # Update existing summary widget (no clearing needed) + return self._summary_widget.update_progress(current_step, max_steps) - # Update phase name if it changed if self._summary_widget.phase_name != phase_name: self._summary_widget.phase_name = phase_name self._summary_widget._update_display() self._last_summary_update = current_time return - # Widget doesn't exist - create it (only clear when creating new widget) - # CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets - for i in range(self.list_widget.count()): - item = self.list_widget.item(i) - if item: - widget = self.list_widget.itemWidget(item) - if widget: - self.list_widget.removeItemWidget(item) + self._clear_item_widgets() self.list_widget.clear() self._file_items.clear() - # Create new summary widget self._summary_widget = SummaryProgressWidget(phase_name, current_step, max_steps) summary_item = QListWidgetItem() summary_item.setSizeHint(self._summary_widget.sizeHint()) @@ -183,453 +223,195 @@ class FileProgressList(QWidget): self.list_widget.setItemWidget(summary_item, self._summary_widget) self._last_summary_time = current_time self._last_summary_update = current_time - return - - # Clear summary widget and transition label when showing file list - # But only if enough time has passed to prevent flickering - current_time = time.time() + # Remove stale summary widget if self._summary_widget: - # Hold summary widget for minimum duration to prevent rapid flickering if current_time - self._last_summary_time >= self._summary_hold_duration: - # Remove summary widget from list - for i in range(self.list_widget.count()): - item = self.list_widget.item(i) - if item and item.data(Qt.UserRole) == "__summary__": - # CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets - widget = self.list_widget.itemWidget(item) - if widget: - self.list_widget.removeItemWidget(item) - self.list_widget.takeItem(i) - break + self._remove_keyed_item("__summary__") self._summary_widget = None else: - # Too soon to clear summary, keep it visible return - # Clear transition label if it exists + # Remove transition label if self._transition_label: - for i in range(self.list_widget.count()): - item = self.list_widget.item(i) - if item and item.data(Qt.UserRole) == "__transition__": - # CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets - widget = self.list_widget.itemWidget(item) - if widget: - self.list_widget.removeItemWidget(item) - self.list_widget.takeItem(i) - break + self._remove_keyed_item("__transition__") self._transition_label = None - + if not file_progresses: - # No files - check if this is a phase transition if current_phase and self._last_phase and current_phase != self._last_phase: - # Phase changed - show transition message briefly self._show_transition_message(current_phase) else: - # Show empty state but keep header stable - # CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets - for i in range(self.list_widget.count()): - item = self.list_widget.item(i) - if item: - widget = self.list_widget.itemWidget(item) - if widget: - self.list_widget.removeItemWidget(item) + self._clear_item_widgets() self.list_widget.clear() self._file_items.clear() - - # Update last phase tracker if current_phase: self._last_phase = current_phase return - - # Determine phase from file operations if not provided + + # Resolve phase label from operations if not provided if not current_phase and file_progresses: - # Get the most common operation type operations = [fp.operation for fp in file_progresses if fp.operation != OperationType.UNKNOWN] if operations: - operation_counts = {} + counts = {} for op in operations: - operation_counts[op] = operation_counts.get(op, 0) + 1 - most_common = max(operation_counts.items(), key=lambda x: x[1])[0] + counts[op] = counts.get(op, 0) + 1 phase_map = { OperationType.DOWNLOAD: "Downloading", - OperationType.EXTRACT: "Extracting", + OperationType.EXTRACT: "Extracting", OperationType.VALIDATE: "Validating", - OperationType.INSTALL: "Installing", + OperationType.INSTALL: "Installing", } - current_phase = phase_map.get(most_common, "") - - # Remove completed files - # Build set of current item keys (using stable keys for counters) + current_phase = phase_map.get(max(counts, key=counts.get), "") + + # Build stable key set from incoming data current_keys = set() for fp in file_progresses: - if 'Installing Files:' in fp.filename: - current_keys.add("__installing_files__") - elif 'Converting Texture:' in fp.filename: - base_name = fp.filename.split('(')[0].strip() - current_keys.add(f"__texture_{base_name}__") - elif fp.filename.startswith('BSA:'): - bsa_name = fp.filename.split('(')[0].strip() - current_keys.add(f"__bsa_{bsa_name}__") - elif fp.filename.startswith('Wine component:'): - rest = fp.filename.split(':', 1)[1].strip() - comp_id = rest.split('|')[0].strip() if '|' in rest else rest - current_keys.add(f"__wine_comp_{comp_id}__") - else: - current_keys.add(fp.filename) - + current_keys.add(self._stable_key(fp)) + + # Remove items no longer active for item_key in list(self._file_items.keys()): if item_key not in current_keys: - # Find and remove the item for i in range(self.list_widget.count()): item = self.list_widget.item(i) if item and item.data(Qt.UserRole) == item_key: - # CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets widget = self.list_widget.itemWidget(item) if widget: self.list_widget.removeItemWidget(item) self.list_widget.takeItem(i) break del self._file_items[item_key] - - # Update or add files - maintain specific ordering - # Use stable identifiers for special items (like "Installing Files: X/Y") - for idx, file_progress in enumerate(file_progresses): - # For items with changing counters in filename, use a stable key - if 'Installing Files:' in file_progress.filename: - item_key = "__installing_files__" - elif 'Converting Texture:' in file_progress.filename: - base_name = file_progress.filename.split('(')[0].strip() - item_key = f"__texture_{base_name}__" - elif file_progress.filename.startswith('BSA:'): - bsa_name = file_progress.filename.split('(')[0].strip() - item_key = f"__bsa_{bsa_name}__" - elif file_progress.filename.startswith('Wine component:'): - rest = file_progress.filename.split(':', 1)[1].strip() - comp_id = rest.split('|')[0].strip() if '|' in rest else rest - item_key = f"__wine_comp_{comp_id}__" - else: - item_key = file_progress.filename - + + # Update existing or add new items + for file_progress in file_progresses: + item_key = self._stable_key(file_progress) + if item_key in self._file_items: - # Update existing widget - DO NOT reorder items (causes segfaults) - # Reordering with takeItem/insertItem can delete widgets and cause crashes - # Order is less important than stability - just update the widget in place item_widget = self._file_items[item_key] - # CRITICAL: Check widget is still valid before updating if shiboken6.isValid(item_widget): try: item_widget.update_progress(file_progress) - except RuntimeError: - # Widget was deleted - remove from dict and create new one below - del self._file_items[item_key] - # Fall through to create new widget - else: - # Update successful - skip creating new widget continue + except RuntimeError: + del self._file_items[item_key] else: - # Widget invalid - remove from dict and create new one del self._file_items[item_key] - # Fall through to create new widget - # Create new widget (either because it didn't exist or was invalid) - # CRITICAL: Use addItem instead of insertItem to avoid position conflicts - # Order is less important than stability - addItem is safer than insertItem + item_widget = FileProgressItem(file_progress) list_item = QListWidgetItem() list_item.setSizeHint(item_widget.sizeHint()) - list_item.setData(Qt.UserRole, item_key) # Use stable key - self.list_widget.addItem(list_item) # Use addItem for safety (avoids segfaults) + list_item.setData(Qt.UserRole, item_key) + self.list_widget.addItem(list_item) self.list_widget.setItemWidget(list_item, item_widget) self._file_items[item_key] = item_widget - # Update last phase tracker if current_phase: self._last_phase = current_phase - def _show_transition_message(self, new_phase: str): - """Show a brief 'Preparing...' message during phase transitions.""" - # CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets + def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0): + file_progress = FileProgress( + filename=label, + operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN, + percent=progress, + current_size=0, + total_size=0, + ) + self.update_files([file_progress], current_phase=None) + + def clear_summary(self): + if self._summary_widget: + self._remove_keyed_item("__summary__") + self._summary_widget = None + + def clear(self): + self._clear_item_widgets() + self.list_widget.clear() + self._file_items.clear() + self._summary_widget = None + self._transition_label = None + self._last_phase = None + self.stop_cpu_tracking() + self.cpu_label.setText("") + + def start_cpu_tracking(self): + if not self._cpu_timer.isActive(): + self._cpu_timer.start() + self._start_cpu_worker() + + def stop_cpu_tracking(self): + self._cpu_timer.stop() + if self._cpu_worker and self._cpu_worker.isRunning(): + self._cpu_worker.quit() + self._cpu_worker.wait(500) + self._cpu_worker = None + + def _start_cpu_worker(self): + # Skip if a worker is already running to avoid pileup + if self._cpu_worker and self._cpu_worker.isRunning(): + return + self._cpu_worker = _CpuWorker(self._last_cpu_percent, self._cpu_process_cache, self._child_process_cache) + self._cpu_worker.result.connect(self._on_cpu_result) + self._cpu_worker.caches_updated.connect(self._on_cpu_caches) + self._cpu_worker.start() + + def _on_cpu_result(self, text: str): + self.cpu_label.setText(text) + + def _on_cpu_caches(self, process_cache, child_cache, smoothed_pct): + self._cpu_process_cache = process_cache + self._child_process_cache = child_cache + self._last_cpu_percent = smoothed_pct + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _stable_key(fp: FileProgress) -> str: + if 'Installing Files:' in fp.filename: + return "__installing_files__" + if 'Converting Texture:' in fp.filename: + return f"__texture_{fp.filename.split('(')[0].strip()}__" + if fp.filename.startswith('BSA:'): + return f"__bsa_{fp.filename.split('(')[0].strip()}__" + if fp.filename.startswith('Wine component:'): + rest = fp.filename.split(':', 1)[1].strip() + comp_id = rest.split('|')[0].strip() if '|' in rest else rest + return f"__wine_comp_{comp_id}__" + return fp.filename + + def _clear_item_widgets(self): for i in range(self.list_widget.count()): item = self.list_widget.item(i) if item: widget = self.list_widget.itemWidget(item) if widget: self.list_widget.removeItemWidget(item) + + def _remove_keyed_item(self, key: str): + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item and item.data(Qt.UserRole) == key: + widget = self.list_widget.itemWidget(item) + if widget: + self.list_widget.removeItemWidget(item) + self.list_widget.takeItem(i) + break + + def _show_transition_message(self, new_phase: str): + self._clear_item_widgets() self.list_widget.clear() self._file_items.clear() - # Header removed - tab label provides context - - # Create or update transition label if self._transition_label is None or not shiboken6.isValid(self._transition_label): self._transition_label = QLabel() self._transition_label.setAlignment(Qt.AlignCenter) self._transition_label.setStyleSheet("color: #888; font-style: italic; padding: 20px;") - self._transition_label.setText(f"Preparing {new_phase.lower()}...") - # Add to list widget transition_item = QListWidgetItem() transition_item.setSizeHint(self._transition_label.sizeHint()) transition_item.setData(Qt.UserRole, "__transition__") self.list_widget.addItem(transition_item) self.list_widget.setItemWidget(transition_item, self._transition_label) - # Remove transition message after brief delay (will be replaced by actual content) - # The next update_files call with actual content will clear this automatically - - def clear_summary(self): - """Remove the summary widget so file-list items can take over immediately.""" - if self._summary_widget: - for i in range(self.list_widget.count()): - item = self.list_widget.item(i) - if item and item.data(Qt.UserRole) == "__summary__": - widget = self.list_widget.itemWidget(item) - if widget: - self.list_widget.removeItemWidget(item) - self.list_widget.takeItem(i) - break - self._summary_widget = None - - def clear(self): - """Clear all file items.""" - # CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets - for i in range(self.list_widget.count()): - item = self.list_widget.item(i) - if item: - widget = self.list_widget.itemWidget(item) - if widget: - self.list_widget.removeItemWidget(item) - self.list_widget.clear() - self._file_items.clear() - self._summary_widget = None - self._transition_label = None - self._last_phase = None - # Header removed - tab label provides context - # Stop CPU timer and clear CPU label - self.stop_cpu_tracking() - self.cpu_label.setText("") - - def start_cpu_tracking(self): - """Start tracking CPU usage.""" - if not self._cpu_timer.isActive(): - # Initialize process and take first measurement to establish baseline - try: - import psutil - import os - self._cpu_process_cache = psutil.Process(os.getpid()) - # First call with interval to establish baseline - self._cpu_process_cache.cpu_percent(interval=0.1) - # Cache child processes - self._child_process_cache = {} - for child in self._cpu_process_cache.children(recursive=True): - try: - child.cpu_percent(interval=0.1) - self._child_process_cache[child.pid] = child - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - except Exception: - pass - - self._cpu_timer.start() - self._update_cpu_usage() # Update immediately after baseline - - def stop_cpu_tracking(self): - """Stop tracking CPU usage.""" - if self._cpu_timer.isActive(): - self._cpu_timer.stop() - - def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0): - """ - Add or update a single status item in the Activity window. - Useful for simple status messages like "Downloading...", "Extracting...", etc. - - Args: - item_id: Unique identifier for this item - label: Display label for the item - progress: Progress percentage (0-100), or 0 for indeterminate - """ - from jackify.shared.progress_models import FileProgress, OperationType - - # Create a FileProgress object for this status item - file_progress = FileProgress( - filename=label, - operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN, - percent=progress, - current_size=0, - total_size=0 - ) - - # Use update_files with a single-item list - self.update_files([file_progress], current_phase=None) - - def _update_cpu_usage(self): - """ - Update CPU usage display with Jackify-related processes. - - Shows total CPU usage across all cores as a percentage of system capacity. - E.g., on an 8-core system: - - 100% = using all 8 cores fully - - 50% = using 4 cores fully (or 8 cores at half capacity) - - 12.5% = using 1 core fully - """ - try: - import psutil - import os - import sys - - # Get or create process cache - if self._cpu_process_cache is None: - self._cpu_process_cache = psutil.Process(os.getpid()) - - # Get current process CPU (Jackify GUI) - # cpu_percent() returns percentage relative to one core - # We need to divide by num_cpus to get system-wide percentage - num_cpus = psutil.cpu_count() - - main_cpu_raw = self._cpu_process_cache.cpu_percent(interval=None) - main_cpu = main_cpu_raw / num_cpus - total_cpu = main_cpu - - # Add CPU usage from ALL child processes recursively - # Includes jackify-engine, texconv.exe, wine processes, etc. - child_count = 0 - child_cpu_sum = 0.0 - try: - children = self._cpu_process_cache.children(recursive=True) - current_child_pids = set() - - for child in children: - try: - current_child_pids.add(child.pid) - - # Check if this is a new process we haven't cached - if child.pid not in self._child_process_cache: - # Cache new process and establish baseline - child.cpu_percent(interval=0.1) - self._child_process_cache[child.pid] = child - # Skip this iteration since baseline was just set - continue - - # Use cached process object for consistent cpu_percent tracking - cached_child = self._child_process_cache[child.pid] - child_cpu_raw = cached_child.cpu_percent(interval=None) - child_cpu = child_cpu_raw / num_cpus - total_cpu += child_cpu - child_count += 1 - child_cpu_sum += child_cpu_raw - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - - # Clean up cache for processes that no longer exist - dead_pids = set(self._child_process_cache.keys()) - current_child_pids - for pid in dead_pids: - del self._child_process_cache[pid] - - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - - # Also search for ALL Jackify-related processes by name/cmdline - # Catches non-direct children: shell launches, Proton/wine wrappers, etc. - # children() is recursive, so typically only finds Proton spawn cases - tracked_pids = {self._cpu_process_cache.pid} # Avoid double-counting - tracked_pids.update(current_child_pids) - - extra_count = 0 - extra_cpu_sum = 0.0 - try: - for proc in psutil.process_iter(['name', 'pid', 'cmdline']): - try: - if proc.pid in tracked_pids: - continue - - proc_name = proc.info.get('name', '').lower() - cmdline = proc.info.get('cmdline', []) - cmdline_str = ' '.join(cmdline).lower() if cmdline else '' - - # Match Jackify-related process names (include Proton/wine wrappers) - # Include all tools that jackify-engine uses during installation - jackify_names = [ - 'jackify-engine', # Main engine - 'texconv', # Texture conversion - 'texdiag', # Texture diagnostics - 'directxtex', # DirectXTex helper binaries - 'texconv_jackify', # Bundled texconv build - 'texdiag_jackify', # Bundled texdiag build - 'directxtex_jackify', # Bundled DirectXTex build - '7z', # Archive extraction (7z) - '7zz', # Archive extraction (7zz) - 'bsarch', # BSA archive tool - 'wine', # Proton/wine launcher - 'wine64', # Proton/wine 64-bit launcher - 'wine64-preloader', # Proton/wine preloader - 'steam-run', # Steam runtime wrapper - 'proton', # Proton launcher scripts - ] - - # Check process name - is_jackify = any(name in proc_name for name in jackify_names) - - # Check command line (e.g., wine running jackify tools, or paths containing jackify) - if not is_jackify and cmdline_str: - # Check for jackify tool names in command line (catches wine running texconv.exe, etc.) - # Includes texconv, texconv.exe, texdiag, 7z, 7zz, bsarch, jackify-engine - is_jackify = any(name in cmdline_str for name in jackify_names) - - # Also check for .exe variants (wine runs .exe files) - if not is_jackify: - exe_names = [f'{name}.exe' for name in jackify_names] - is_jackify = any(exe_name in cmdline_str for exe_name in exe_names) - - # Also check if command line contains jackify paths - if not is_jackify: - is_jackify = 'jackify' in cmdline_str and any( - tool in cmdline_str for tool in ['engine', 'tools', 'binaries'] - ) - - if is_jackify: - # Check if this is a new process we haven't cached - if proc.pid not in self._child_process_cache: - # Establish baseline for new process and cache it - proc.cpu_percent(interval=0.1) - self._child_process_cache[proc.pid] = proc - # Skip this iteration since baseline was just set - continue - - # Use cached process object - cached_proc = self._child_process_cache[proc.pid] - proc_cpu_raw = cached_proc.cpu_percent(interval=None) - proc_cpu = proc_cpu_raw / num_cpus - total_cpu += proc_cpu - tracked_pids.add(proc.pid) - extra_count += 1 - extra_cpu_sum += proc_cpu_raw - - except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError): - pass - except Exception: - pass - - # Smooth the value slightly to reduce jitter (less aggressive than before) - if self._last_cpu_percent > 0: - total_cpu = (self._last_cpu_percent * 0.3) + (total_cpu * 0.7) - self._last_cpu_percent = total_cpu - - # Always show CPU percentage when tracking is active - # Cap at 100% for display (shouldn't exceed but just in case) - display_percent = min(100.0, total_cpu) - - if display_percent >= 0.1: - self.cpu_label.setText(f"CPU: {display_percent:.0f}%") - else: - # Show 0% instead of hiding to indicate tracking is active - self.cpu_label.setText("CPU: 0%") - - except Exception as e: - # Show error indicator if tracking fails - import sys - print(f"CPU tracking error: {e}", file=sys.stderr) - self.cpu_label.setText("") - - diff --git a/jackify/frontends/gui/widgets/progress_indicator.py b/jackify/frontends/gui/widgets/progress_indicator.py index 91a129c..5863cf3 100644 --- a/jackify/frontends/gui/widgets/progress_indicator.py +++ b/jackify/frontends/gui/widgets/progress_indicator.py @@ -103,11 +103,22 @@ class OverallProgressIndicator(QWidget): """ # Update status text display_text = progress.display_text - 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 not display_text or display_text == "Processing...": + if progress.phase == InstallationPhase.UNKNOWN: + # Don't overwrite the banner with "Unknown" for unrecognized section headers; + # preserve whatever was showing before. + current = self.status_label.text() + if current and current not in ("Ready to install", "Unknown", "Processing...", ""): + display_text = current + else: + display_text = "Processing..." + else: + display_text = progress.phase_name or progress.phase.value.title() or "Processing..." + if progress.phase == InstallationPhase.DOWNLOAD and progress.phase_max_steps > 0 and progress.phase_step <= 0: + display_text = display_text.replace(f"[{progress.phase_step}/{progress.phase_max_steps}]", "").replace(" ", " ").strip() + + # Add total download size, remaining size (MB/GB), and ETA for download phase 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 @@ -188,20 +199,30 @@ class OverallProgressIndicator(QWidget): from jackify.shared.progress_models import InstallationPhase is_bsa_building = progress.get_phase_label() == "Building BSAs" - # For install/extract/download/BSA building phases, prefer step-based progress (more accurate) - # Prevent carrying over 100% from previous phases - if progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT, InstallationPhase.DOWNLOAD) or is_bsa_building: + # Download phase often has byte-level progress before step counters move. + # Prefer byte progress first to avoid misleading 0% while downloading. + if progress.phase == InstallationPhase.DOWNLOAD: + if progress.data_total > 0: + display_percent = (progress.data_processed / progress.data_total) * 100.0 + elif progress.active_files: + aggregate_total = sum(f.total_size for f in progress.active_files if f.total_size > 0) + aggregate_current = sum(f.current_size for f in progress.active_files if f.current_size > 0) + if aggregate_total > 0: + display_percent = (aggregate_current / aggregate_total) * 100.0 + if display_percent <= 0 and progress.phase_max_steps > 0 and progress.phase_step > 0: + display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0 + elif display_percent <= 0 and progress.overall_percent > 0 and progress.overall_percent < 100.0: + display_percent = progress.overall_percent + # For install/extract/BSA phases, prefer step progress, then bytes. + elif progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT) or is_bsa_building: if progress.phase_max_steps > 0: display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0 elif progress.data_total > 0 and progress.data_processed > 0: display_percent = (progress.data_processed / progress.data_total) * 100.0 + elif progress.overall_percent > 0 and progress.overall_percent < 100.0: + display_percent = progress.overall_percent else: - # If no step/data info, use overall_percent but only if it's reasonable - # Don't carry over 100% from previous phase - if progress.overall_percent > 0 and progress.overall_percent < 100.0: - display_percent = progress.overall_percent - else: - display_percent = 0.0 # Reset if we don't have valid progress + display_percent = 0.0 # Reset if we don't have valid progress else: # For other phases, prefer data progress, then overall_percent, then step progress if progress.data_total > 0 and progress.data_processed > 0: @@ -211,6 +232,8 @@ class OverallProgressIndicator(QWidget): elif progress.phase_max_steps > 0: display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0 + # Clamp to avoid transient parser values creating invalid percentages. + display_percent = max(0.0, min(100.0, display_percent)) self.progress_bar.setValue(int(display_percent)) # Update tooltip with detailed information @@ -264,4 +287,3 @@ class OverallProgressIndicator(QWidget): self.progress_bar.setValue(0) self.progress_bar.setToolTip("") self.status_label.setToolTip("") - diff --git a/jackify/shared/errors.py b/jackify/shared/errors.py new file mode 100644 index 0000000..36ae1ea --- /dev/null +++ b/jackify/shared/errors.py @@ -0,0 +1,382 @@ +"""Structured error types for Jackify. + +All user-facing failures should raise a JackifyError subclass so callers +can display a consistent, plain-English error dialog with actionable advice +and a numbered list of things to try. +""" +import re +from typing import Optional, List + + +class JackifyError(Exception): + """Base class for all user-facing Jackify errors.""" + + def __init__(self, title: str, message: str, + suggestion: Optional[str] = None, + solutions: Optional[List[str]] = None, + technical: Optional[str] = None): + self.title = title + self.message = message + self.suggestion = suggestion + self.solutions = solutions or [] + self.technical = technical + super().__init__(message) + + +class SteamError(JackifyError): + pass + + +class PrefixCreationError(JackifyError): + pass + + +class ProtonNotFoundError(JackifyError): + pass + + +class ModlistError(JackifyError): + pass + + +class ConfigError(JackifyError): + pass + + +class InstallError(JackifyError): + pass + + +class TTWError(JackifyError): + pass + + +class OAuthError(JackifyError): + pass + + +_SENSITIVE_KEYWORDS = ( + "token", + "api_key", + "apikey", + "secret", + "authorization", + "oauth", + "bearer", + "password", +) + + +def _looks_sensitive_key(key: str) -> bool: + lowered = key.lower() + return any(word in lowered for word in _SENSITIVE_KEYWORDS) + + +def _scrub_sensitive_text(text: str) -> str: + """Best-effort redaction for key=value style sensitive fragments.""" + scrubbed = text + patterns = [ + r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|password|secret)\b\s*[:=]\s*([^\s,;]+)", + r"(?i)\b(bearer)\s+([A-Za-z0-9\-._~+/]+=*)", + ] + for pattern in patterns: + scrubbed = re.sub(pattern, r"\1=[REDACTED]", scrubbed) + return scrubbed + + +def format_technical_context(detail: Optional[str] = None, context: Optional[dict] = None) -> Optional[str]: + """Format technical context into a readable block with secret redaction.""" + lines: List[str] = [] + + if detail: + safe_detail = _scrub_sensitive_text(str(detail).strip()) + if safe_detail: + lines.append("Detail:") + lines.append(safe_detail) + + if context: + ctx_lines: List[str] = [] + for key, value in context.items(): + if _looks_sensitive_key(str(key)): + safe_value = "[REDACTED]" + else: + safe_value = _scrub_sensitive_text(str(value)) + ctx_lines.append(f"- {key}: {safe_value}") + if ctx_lines: + if lines: + lines.append("") + lines.append("Context:") + lines.extend(ctx_lines) + + if not lines: + return None + return "\n".join(lines) + + +def _logs_dir_display() -> str: + """Return the active Jackify logs directory for user-facing guidance.""" + try: + from jackify.shared.paths import get_jackify_logs_dir + return str(get_jackify_logs_dir()) + except Exception: + return "~/Jackify/logs" + + +# --------------------------------------------------------------------------- +# Factory functions for known failure modes. +# No GUI imports allowed here — backend code raises these directly. +# --------------------------------------------------------------------------- + +def steam_still_running() -> SteamError: + return SteamError( + title="Steam Could Not Be Shut Down", + message="Jackify attempted to close Steam automatically but it did not respond in time.", + suggestion="Close Steam fully, then continue from the correct Jackify workflow.", + solutions=[ + "Exit Steam from the Steam UI or system tray icon.", + "Wait 10-15 seconds before continuing.", + "If the install phase completed successfully and there is a shortcut in Steam for your modlist after restarting Steam, run 'Configure Existing Modlist' in Jackify.", + "If the Steam shortcut is not present after restarting Steam, run 'Configure New Modlist' in Jackify.", + f"Check Jackify logs ({_logs_dir_display()}) for the specific shutdown failure.", + "If this repeats, open a GitHub issue and include your Jackify logs.", + ], + ) + + +def proton_not_found() -> ProtonNotFoundError: + return ProtonNotFoundError( + title="No Proton Version Found", + message="Jackify could not find a Proton installation to create the game prefix.", + suggestion="Make sure Steam has registered at least one Proton version, then select it in Jackify.", + solutions=[ + "In Steam, open Settings > Compatibility and enable Steam Play for supported/all titles.", + "Launch any Windows game once in Steam to let Steam finish Proton setup and registration.", + "In Jackify Settings, select your installed Proton under 'Proton Version'.", + "If you want GE-Proton, install it with ProtonPlus or ProtonUp-Qt.", + f"If detection still fails, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue.", + ], + ) + + +def shortcut_write_failed(detail: str) -> SteamError: + return SteamError( + title="Steam Shortcut Could Not Be Created", + message="Jackify was unable to write the Steam shortcut for this modlist.", + suggestion="Close Steam fully, verify userdata permissions, then continue with the correct configure flow.", + solutions=[ + "Close Steam completely (check system tray) and retry.", + "Check that your home directory has write permissions: ls -la ~/.steam/steam/userdata/", + "If running Steam as Flatpak, confirm Jackify has access to the Flatpak data directory.", + "Check available disk space: df -h ~", + "If modlist install files are already complete, relaunch Steam manually and use 'Configure New Modlist' in Jackify.", + f"Check Jackify logs ({_logs_dir_display()}) for the specific write error.", + "If this keeps failing, open a GitHub issue and include your Jackify logs.", + ], + technical=format_technical_context(detail=detail), + ) + + +def prefix_creation_failed(detail: str) -> PrefixCreationError: + return PrefixCreationError( + title="Proton Prefix Creation Failed", + message="Jackify could not create the Proton compatibility prefix for this modlist.", + suggestion="Check Proton is installed and the modlist directory is accessible.", + solutions=[ + "Confirm Steam Play is enabled in Steam > Settings > Compatibility.", + "Launch a Windows game once in Steam so Proton is fully initialized.", + "Confirm a Proton version is selected in Jackify Settings.", + "Check available disk space on the modlist drive: df -h", + "Ensure the modlist directory exists and is readable.", + "Try closing all other Steam/Proton processes before retrying.", + f"Check Jackify logs ({_logs_dir_display()}) for the specific failure point.", + "If this fails consistently, open a GitHub issue and include your Jackify logs.", + ], + technical=format_technical_context(detail=detail), + ) + + +def steam_restart_failed(detail: str) -> SteamError: + return SteamError( + title="Steam Did Not Restart", + message="Jackify could not confirm Steam came back after the install/configuration step.", + suggestion="Start Steam manually, then continue with the appropriate Jackify configure flow.", + solutions=[ + "Launch Steam manually and wait until the library is fully loaded.", + "If Steam is showing an update prompt, complete or cancel it first.", + "If the modlist shortcut is visible in Steam, use 'Configure Existing Modlist' in Jackify.", + "If the shortcut is missing but install files are present, use 'Configure New Modlist' in Jackify.", + "Do not rerun the full download/install unless you are missing modlist files.", + f"If recovery still fails, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue.", + ], + technical=format_technical_context(detail=detail), + ) + + +def modlist_not_found(path: str) -> ModlistError: + return ModlistError( + title="Modlist Directory Not Found", + message=f"The modlist directory does not exist: {path}", + suggestion="Check the path is correct and the drive is mounted.", + solutions=[ + "Verify the path is correct and has not been moved or deleted.", + "If the modlist is on an external drive or SD card, ensure it is mounted.", + "On Steam Deck, SD card paths are typically under /run/media/", + "Re-select the modlist directory in Jackify.", + ], + ) + + +def configuration_failed(detail: str) -> ConfigError: + return ConfigError( + title="Post-Install Configuration Failed", + message="Jackify could not complete the post-installation configuration for this modlist.", + suggestion=f"Check Jackify logs ({_logs_dir_display()}) for the specific step that failed.", + solutions=[ + "Confirm Steam is running and fully loaded before retrying.", + "Check that the modlist AppID appears in your Steam library (look for the shortcut).", + "Try 'Configure Existing Modlist' from the main menu to re-run configuration.", + "Verify Proton is set correctly in Jackify Settings.", + "If the error mentions registry or prefix, ensure sufficient disk space.", + f"If this still fails, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue with modlist name.", + ], + technical=format_technical_context(detail=detail), + ) + + +def ttw_install_failed(detail: str) -> TTWError: + return TTWError( + title="TTW Installation Failed", + message="Tale of Two Wastelands could not be installed.", + suggestion="Check that your vanilla Fallout 3 and Fallout New Vegas installs are clean and accessible.", + solutions=[ + "Confirm vanilla Fallout 3 and Fallout New Vegas are both installed and launch correctly.", + "If either game was previously modded, restore a clean vanilla install before retrying TTW.", + "Ensure TTW_Linux_Installer is installed — use 'Install TTW Installer' in Additional Tasks.", + "Check available disk space — TTW requires ~15GB free.", + "Verify the TTW .mpi file is not corrupted (try re-downloading it).", + f"Check Jackify logs ({_logs_dir_display()}) and TTW_Install_workflow.log for the specific failure.", + f"If this still fails, open a GitHub issue and include logs from {_logs_dir_display()}.", + ], + technical=format_technical_context(detail=detail), + ) + + +def wabbajack_install_failed(detail: str) -> InstallError: + return InstallError( + title="Wabbajack Installation Failed", + message="The modlist installation did not complete successfully.", + suggestion=f"Check the console output and Jackify logs ({_logs_dir_display()}) for the failure reason.", + solutions=[ + "Ensure you are logged in to Nexus Mods — check Settings > OAuth.", + "Confirm your Nexus account has Premium access for automated downloads.", + "Check available disk space on both the install and download drives.", + "Re-run the install — Wabbajack resumes from where it stopped.", + "If a specific file failed repeatedly, try downloading it manually from Nexus.", + "Check Modlist_Install_workflow.log for the specific file that failed.", + "If the same failure repeats with no clear workaround, open a GitHub issue with logs.", + ], + technical=format_technical_context(detail=detail), + ) + + +def oauth_expired() -> OAuthError: + return OAuthError( + title="Nexus Authentication Expired", + message="Your Nexus Mods authorisation has expired or is no longer valid.", + suggestion="In Settings, revoke the current Nexus authorisation first, then authorise again.", + solutions=[ + "Open Jackify Settings and click 'Revoke Nexus Authorisation' first.", + "Then click 'Authorise with Nexus Mods'.", + "Complete the browser authorisation flow and return to Jackify.", + "If the browser does not open automatically, copy the URL from the console and open it manually.", + "After re-authorising, retry the failed operation.", + f"If this keeps failing, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue.", + ], + ) + + +def install_dir_create_failed(path: str, detail: str) -> InstallError: + return InstallError( + title="Could Not Create Install Directory", + message=f"Jackify could not create the installation directory: {path}", + suggestion="Check you have write permission to the target drive.", + solutions=[ + "Confirm the target drive is mounted and writable.", + "Check available disk space: df -h", + "Try creating the folder manually first, then retry.", + "On Steam Deck, avoid paths under /usr or /var — use /home/deck or an SD card.", + ], + technical=format_technical_context(detail=detail, context={"path": path}), + ) + + +def manual_steps_incomplete() -> ConfigError: + return ConfigError( + title="Unexpected Internal Setup State", + message="Jackify reached a setup state that should not occur in normal workflows.", + suggestion="Restart Steam and retry once. If this appears again, treat it as a Jackify bug and report it.", + solutions=[ + "Restart Steam and verify your modlist shortcut is visible.", + "Retry your last Jackify action once after Steam restarts.", + "Do not perform manual Steam shortcut or prefix setup steps.", + f"If this state appears again, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue.", + ], + ) + + +def mo2_setup_failed(detail: str) -> InstallError: + return InstallError( + title="Mod Organizer 2 Setup Failed", + message="Jackify could not complete the Mod Organizer 2 setup.", + suggestion=f"Check Jackify logs ({_logs_dir_display()}) for the specific failure.", + solutions=[ + "Ensure you have an active internet connection — MO2 is downloaded from GitHub.", + "Check available disk space in the install directory.", + "Try selecting a different install directory with full write permissions.", + "If the download failed, check GitHub is accessible (try opening it in a browser).", + ], + technical=format_technical_context(detail=detail), + ) + + +# --------------------------------------------------------------------------- +# Exception classifier. +# Maps known error substrings to specific JackifyError factory functions. +# Called by callers that catch a bare Exception and want to produce a +# structured error rather than wrapping with prefix_creation_failed(). +# --------------------------------------------------------------------------- + +_PATTERNS: List[tuple] = [ + # Steam / prefix / Proton + ("no space left on device", lambda d: InstallError("Disk Full", "There is no space left on the target drive.", suggestion="Free up disk space and retry.", solutions=["Run: df -h to see available space.", "Delete old modlist downloads or backups.", "Move the install to a larger drive."], technical=format_technical_context(detail=d))), + ("permission denied", lambda d: SteamError("Permission Denied", "Jackify was refused access to a required file or directory.", suggestion="Check file permissions on the target path.", solutions=["Run: ls -la to inspect permissions.", "Ensure Steam and Jackify are run as the same user.", "Avoid install paths under /usr, /var or /opt."], technical=format_technical_context(detail=d))), + ("steamwebhelper", lambda d: steam_still_running()), + ("no such file or directory.*compatdata", lambda d: proton_not_found()), + ("proton.*not found|no proton", lambda d: proton_not_found()), + ("vdf.*error|binary_vdf|invalid vdf", lambda d: SteamError("Steam VDF File Error", "A Steam configuration file (VDF) could not be read or written.", suggestion="Ensure Steam is closed and try again.", solutions=["Close Steam completely before retrying.", "Restart Steam and retry the same action.", f"Check Jackify logs ({_logs_dir_display()}) for the specific VDF path.", f"If this still fails, open a GitHub issue and include logs from {_logs_dir_display()}."], technical=format_technical_context(detail=d))), + ("connection.*refused|connection.*timed out|network.*unreachable", lambda d: InstallError("Network Error", "Jackify could not reach a required network resource.", suggestion="Check your internet connection and retry.", solutions=["Verify your internet connection is active.", "Check if Nexus Mods is reachable at nexusmods.com.", "Disable VPN or proxy if active.", "Retry — transient network errors often resolve on the second attempt."], technical=format_technical_context(detail=d))), + ("401|unauthorized|forbidden.*nexus", lambda d: oauth_expired()), + ("7z.*error|bad archive|cannot open.*archive", lambda d: InstallError("Archive Error", "A downloaded archive file is corrupted or unreadable.", suggestion="Delete the corrupted file and re-run the install to re-download it.", solutions=["Re-run the install — Wabbajack will re-download files that fail verification.", "Check available disk space (partial downloads look corrupt).", "Check Modlist_Install_workflow.log for the specific file name."], technical=format_technical_context(detail=d))), + ("timeout", lambda d: SteamError("Operation Timed Out", "An operation took longer than expected and was stopped.", suggestion="Retry — timeouts are often transient.", solutions=["Retry the operation.", "If Steam is slow to start, give it more time before retrying.", "Check system load: close other applications.", f"Check Jackify logs ({_logs_dir_display()}) for which step timed out."], technical=format_technical_context(detail=d))), +] + + +def classify_exception(exc_str: str, fallback_factory=None) -> JackifyError: + """Return a structured JackifyError for a raw exception string. + + Checks known error patterns in order. Returns the first match. + Falls back to fallback_factory(exc_str) if provided, otherwise + returns a generic prefix_creation_failed error. + """ + import re + lowered = exc_str.lower() + for pattern, factory in _PATTERNS: + if re.search(pattern, lowered): + result = factory(exc_str) + if not result.technical: + result.technical = exc_str + return result + + if fallback_factory is not None: + return fallback_factory(exc_str) + return prefix_creation_failed(exc_str) diff --git a/jackify/shared/logging.py b/jackify/shared/logging.py index 3fd302c..cac2495 100644 --- a/jackify/shared/logging.py +++ b/jackify/shared/logging.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Optional, Dict, List from datetime import datetime import shutil +import sys class LoggingHandler: """ @@ -35,7 +36,7 @@ class LoggingHandler: try: self.log_dir.mkdir(parents=True, exist_ok=True) except Exception as e: - print(f"Failed to create log directory: {e}") + sys.stderr.write(f"Failed to create log directory: {e}\n") def rotate_log_file_per_run(self, log_file_path: Path, backup_count: int = 5): """Rotate the log file on every run, keeping up to backup_count backups.""" @@ -131,7 +132,7 @@ class LoggingHandler: for old_backup in backups[:-backup_count]: old_backup.unlink() except Exception as e: - print(f"Failed to rotate log file {log_file}: {e}") + sys.stderr.write(f"Failed to rotate log file {log_file}: {e}\n") def cleanup_old_logs(self, days: int = 30) -> None: """Clean up log files older than specified days.""" @@ -141,7 +142,7 @@ class LoggingHandler: if log_file.stat().st_mtime < cutoff: log_file.unlink() except Exception as e: - print(f"Failed to clean up log file {log_file}: {e}") + sys.stderr.write(f"Failed to clean up log file {log_file}: {e}\n") def get_log_files(self) -> List[Path]: """Get a list of all log files.""" @@ -153,7 +154,7 @@ class LoggingHandler: with open(log_file, 'r') as f: return f.readlines()[-lines:] except Exception as e: - print(f"Failed to read log file {log_file}: {e}") + sys.stderr.write(f"Failed to read log file {log_file}: {e}\n") return [] def search_logs(self, pattern: str) -> Dict[Path, List[str]]: @@ -166,7 +167,7 @@ class LoggingHandler: if matches: results[log_file] = matches except Exception as e: - print(f"Failed to search log file {log_file}: {e}") + sys.stderr.write(f"Failed to search log file {log_file}: {e}\n") return results def export_logs(self, output_dir: Path) -> bool: @@ -177,7 +178,7 @@ class LoggingHandler: shutil.copy2(log_file, output_dir / log_file.name) return True except Exception as e: - print(f"Failed to export logs: {e}") + sys.stderr.write(f"Failed to export logs: {e}\n") return False def set_log_level(self, level: int) -> None: @@ -207,7 +208,7 @@ class LoggingHandler: stats['newest_file'] = max(log_files, key=lambda x: x.stat().st_mtime) except Exception as e: - print(f"Failed to get log stats: {e}") + sys.stderr.write(f"Failed to get log stats: {e}\n") return stats diff --git a/jackify/shared/progress_models.py b/jackify/shared/progress_models.py index 48fdb7b..1273aeb 100644 --- a/jackify/shared/progress_models.py +++ b/jackify/shared/progress_models.py @@ -110,7 +110,7 @@ class InstallationProgress: @property def phase_progress_text(self) -> str: """Get phase progress text like '[12/14]'.""" - if self.phase_max_steps > 0: + if self.phase_max_steps > 0 and self.phase_step > 0: return f"[{self.phase_step}/{self.phase_max_steps}]" elif self.phase_step > 0: return f"[{self.phase_step}]" @@ -273,15 +273,9 @@ class InstallationProgress: secs = int(seconds % 60) if hours > 0: - if minutes > 0: - return f"{hours}h {minutes}m" - else: - return f"{hours}h" + return f"{hours}h {minutes:02d}m" elif minutes > 0: - if secs > 0: - return f"{minutes}m {secs}s" - else: - return f"{minutes}m" + return f"{minutes}m {secs:02d}s" else: return f"{secs}s" @@ -348,6 +342,14 @@ class InstallationProgress: def get_phase_label(self) -> str: """Return a short, stable label for the current phase.""" + # During install+texture conversion, keep a stable combined label to avoid + # rapid banner flipping between install and conversion text. + if self.phase == InstallationPhase.INSTALL and self.texture_conversion_total > 0: + has_install_counter = self.phase_max_steps > 0 and self.phase_max_steps != self.texture_conversion_total + if has_install_counter: + return "Installing + Converting Textures" + return "Converting Textures" + # Check for specific operations first (more specific than generic phase labels) if self.phase_name: phase_lower = self.phase_name.lower() @@ -358,9 +360,12 @@ class InstallationProgress: if 'bsa' in phase_lower or ('building' in phase_lower and self.phase == InstallationPhase.INSTALL): return "Building BSAs" - # For FINALIZE phase, always prefer phase_name over generic "Finalising" label - # Post-install steps can show specific labels - if self.phase == InstallationPhase.FINALIZE and self.phase_name: + # For FINALIZE and INITIALIZATION phases, prefer phase_name over the generic label. + # INITIALIZATION cycles through many short sections ("Configuring Installer", + # "Looking for unmodified files", etc.) that are more informative than "Preparing". + if self.phase in (InstallationPhase.FINALIZE, InstallationPhase.INITIALIZATION) and self.phase_name: + return self.phase_name + if self.phase == InstallationPhase.DOWNLOAD and self.phase_name: return self.phase_name phase_labels = { @@ -397,11 +402,26 @@ class InstallationProgress: else: # Normal phase - show phase progress phase_prog = self.phase_progress_text + # For download phase, hide zero-step counters (e.g. [0/2]) because + # they are confusing when byte/speed progress is already active. + if self.phase == InstallationPhase.DOWNLOAD and self.phase_step <= 0: + phase_prog = "" + # If current step reflects texture counters, don't duplicate that as + # the primary install counter. + if self.phase == InstallationPhase.INSTALL and self.texture_conversion_total > 0: + if self.phase_max_steps > 0 and self.phase_max_steps == self.texture_conversion_total: + phase_prog = "" if phase_prog: parts.append(phase_prog) # Data progress (but not during BSA building) data_prog = self.data_progress_text + if data_prog: + # Some engine versions report a changing remaining total while keeping + # processed bytes at 0. Avoid showing misleading "(0B/YYY)" pairs. + if self.phase == InstallationPhase.DOWNLOAD and self.data_total > 0 and self.data_processed <= 0: + data_prog = "" + if data_prog: # Don't show if it's 100% complete (adds no value) if self.data_total > 0 and self.data_processed < self.data_total: @@ -409,6 +429,10 @@ class InstallationProgress: elif self.data_total == 0 and self.data_processed > 0: # Show partial progress even without total parts.append(f"({data_prog})") + if self.phase == InstallationPhase.INSTALL and self.texture_conversion_total > 0: + tex_total = self.texture_conversion_total + tex_current = max(0, min(self.texture_conversion_current, tex_total)) + parts.append(f"Converting textures: {tex_current}/{tex_total}") # Overall speed (if available, but not during BSA building) if self.bsa_building_total == 0: @@ -500,4 +524,3 @@ class InstallationProgress: # Update speed history for ETA smoothing if speed > 0: self._update_speed_history(op_key, speed) - diff --git a/jackify/tools/winetricks b/jackify/tools/winetricks index 5acdd8d..e627f71 100755 --- a/jackify/tools/winetricks +++ b/jackify/tools/winetricks @@ -432,7 +432,7 @@ _w_get_broken_messages() # win64 broken messages broken_good_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 上存在问题。可以使用 WINEARCH=win32 创建的前缀,或升级 wine 至 >=${good_version} 来解决此问题。或者使用 --force 仍可尝试。更多信息请参见 ${bug_link}。" - broken_good_and_bad_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 中已损坏,自 ${bad_version} 起出现该问题。请使用使用 WINEARCH=win32 创建的前缀,或升级 wine 至 >=${good_version} 来解决此问题。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" + broken_good_and_bad_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 中已损坏,自 ${bad_version} 起出现该问题。请使用 WINEARCH=win32 创建的前缀,或升级 wine 至 >=${good_version} 来解决此问题。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" broken_only_bad_version_known_win64="此软件包(${W_PACKAGE})在 64 位 wine-${_wine_version_stripped} 上已损坏,自 ${bad_version} 起出现该问题。请使用 WINEARCH=win32 创建的前缀作为解决方法。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" broken_no_version_known_win64="此软件包(${W_PACKAGE})在 wine 未使用 mingw 构建时已损坏。更多信息请参见 ${bug_link}。如仍要尝试,请使用 --force。" ;; @@ -645,7 +645,7 @@ w_package_unsupported_win64() pl*) w_warn "Ten pakiet (${W_PACKAGE}) nie działa z 64-bitową instalacją. Musisz użyć prefiksu utworzonego z WINEARCH=win32." ;; pt*) w_warn "Este pacote (${W_PACKAGE}) não funciona em instalação de 64-bit. Você precisa usar um prefixo feito com WINEARCH=win32." ;; ru*) w_warn "Данный пакет не работает в 64-битном окружении. Используйте префикс, созданный с помощью WINEARCH=win32." ;; - zh_CN*) w_warn "(${W_PACKAGE}) 无法在64位下工作,只能将容器变量设置为 WINEARCH=win32。" ;; + zh_CN*) w_warn "(${W_PACKAGE}) 无法在 64 位环境下工作,您必须使用 WINEARCH=win32 创建的容器。" ;; zh_TW*|zh_HK*) w_warn "(${W_PACKAGE}) 無法在64元下工作,只能將容器變數設定為 WINEARCH=win32 安装。" ;; *) w_warn "This package (${W_PACKAGE}) does not work on a 64-bit installation. You must use a prefix made with WINEARCH=win32." ;; esac @@ -1029,11 +1029,11 @@ w_read_key() uk*) _W_keymsg="Будь ласка, введіть ключ для додатка '${W_PACKAGE}'" _W_nokeymsg="Ключ не надано" ;; - zh_CN*) _W_keymsg="按任意键为 '${W_PACKAGE}'" - _W_nokeymsg="未检测到按键" + zh_CN*) _W_keymsg="请输入 '${W_PACKAGE}' 的产品密钥" + _W_nokeymsg="未提供密钥" ;; - zh_TW*|zh_HK*) _W_keymsg="按任意鍵為 '${W_PACKAGE}'" - _W_nokeymsg="No key given" + zh_TW*|zh_HK*) _W_keymsg="請輸入應用程式 '${W_PACKAGE}' 的產品金鑰" + _W_nokeymsg="未提供金鑰" ;; *) _W_keymsg="Please enter the key for app '${W_PACKAGE}'" _W_nokeymsg="No key given" @@ -1362,7 +1362,7 @@ w_dotnet_verify() dotnet471) version="4.7.1" ;; dotnet472) version="4.7.2" ;; dotnet48) version="4.8" ;; - *) echo error ; exit 1 ;; + *) echo "error: w_dotnet_verify unknown dotnet version"; exit 1 ;; esac w_call dotnet_verifier @@ -2469,12 +2469,8 @@ w_set_app_winver() _W_app="$1" _W_version="$2" echo "Setting ${_W_app} to ${_W_version} mode" - ( - echo REGEDIT4 - echo "" - echo "[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\${_W_app}]" - echo "\"Version\"=\"${_W_version}\"" - ) > "${W_TMP}"/set-winver.reg + # Use printf to avoid echo interpreting \n in app names like "node.exe" + printf '%s\n' "REGEDIT4" "" "[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\${_W_app}]" "\"Version\"=\"${_W_version}\"" > "${W_TMP}"/set-winver.reg w_try_regedit "${W_TMP_WIN}"\\set-winver.reg rm "${W_TMP}"/set-winver.reg @@ -3124,11 +3120,11 @@ winetricks_latest_version_check() ;; zh_CN*) w_warn "你正在使用 winetricks-${WINETRICKS_VERSION},最新版本是 winetricks-${latest_version}!" - w_warn "你应该使用你的发行版软件管理器、--self-update 或者手动来更新。" + w_warn "建议通过发行版的包管理器、--self-update 或者手动方式进行更新。" ;; zh_TW*|zh_HK*) w_warn "你正在使用 winetricks-${WINETRICKS_VERSION},最新版本是 winetricks-${latest_version}!" - w_warn "你應該使用你的發行版軟體管理者、--self-update 或者手動更新。" + w_warn "建議通過發行版的套件管理員、--self-update 或者手動方式進行更新。" ;; *) w_warn "You are running winetricks-${WINETRICKS_VERSION}, latest upstream is winetricks-${latest_version}!" @@ -5653,15 +5649,21 @@ load_atmlib() w_metadata avifil32 dlls \ title="MS avifil32" \ publisher="Microsoft" \ - year="2004" \ + year="2009" \ media="download" \ - file1="../winxpsp3/WindowsXP-KB936929-SP3-x86-ENU.exe" \ + file1="windowsserver2003.windowsxp-kb971557-x64-enu_32943d879a20893dace5191677c4e499aaef0ef8.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/avifil32.dll" load_avifil32() { - helper_winxpsp3 i386/avifil32.dl_ - w_try_cabextract --directory="${W_SYSTEM32_DLLS}" "${W_TMP}"/i386/avifil32.dl_ + w_download https://catalog.s.download.windowsupdate.com/msdownload/update/software/secu/2009/07/windowsserver2003.windowsxp-kb971557-x64-enu_32943d879a20893dace5191677c4e499aaef0ef8.exe e20a2ee0d35e4338549b37badcd5eebe9dc0be86fbbd9b8809191fc315745a8f + w_try_cabextract --directory="${W_TMP}" "${W_CACHE}/${W_PACKAGE}/${file1}" + + w_try_cp_dll "${W_TMP}"/SP2GDR/wow/wavifil32.dll "${W_SYSTEM32_DLLS}"/avifil32.dll + + if [ "${W_ARCH}" = "win64" ]; then + w_try_cp_dll "${W_TMP}"/SP2GDR/avifil32.dll "${W_SYSTEM64_DLLS}"/avifil32.dll + fi w_override_dlls native avifil32 } @@ -11529,17 +11531,43 @@ w_metadata msls31 dlls \ publisher="Microsoft" \ year="2001" \ media="download" \ + file1="IE8-WindowsServer2003-x64-ENU.exe" \ + installed_file1="${W_SYSTEM32_DLLS_WIN}/msls31.dll" \ + installed_file2="${W_SYSTEM64_DLLS_WIN64}/msls31.dll" + +load_msls31() +{ + # Needed by native RichEdit and Internet Explorer + # Installs newer version of msls31.dll as well as both 32-bit and 64-bit versions as needed + + w_download https://download.microsoft.com/download/7/5/4/754D6601-662D-4E39-9788-6F90D8E5C097/IE8-WindowsServer2003-x64-ENU.exe bcff753e92ceabf31cfefaa6def146335c7cb27a50b95cd4f4658a0c3326f499 + + w_try_cabextract --directory="${W_CACHE}"/msls31 "${W_CACHE}"/msls31/IE8-WindowsServer2003-x64-ENU.exe -F wow/wmsls31.dll + w_try_cp_dll "${W_CACHE}"/msls31/wow/wmsls31.dll "${W_SYSTEM32_DLLS}"/msls31.dll + + if [ "${W_ARCH}" = "win64" ]; then + w_try_cabextract --directory="${W_SYSTEM64_DLLS}" "${W_CACHE}"/msls31/IE8-WindowsServer2003-x64-ENU.exe -F msls31.dll + fi +} + +#---------------------------------------------------------------- + +w_metadata msls31_nt4 dlls \ + title="MS Line Services (32-bit only)" \ + publisher="Microsoft" \ + year="2001" \ + media="download" \ file1="InstMsiW.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/msls31.dll" -load_msls31() +load_msls31_nt4() { # Needed by native RichEdit and Internet Explorer # Originally at https://download.microsoft.com/download/WindowsInstaller/Install/2.0/NT45/EN-US/InstMsiW.exe # Old mirror at https://ftp.hp.com/pub/softlib/software/msi/InstMsiW.exe w_download https://web.archive.org/web/20160710055851if_/http://download.microsoft.com/download/WindowsInstaller/Install/2.0/NT45/EN-US/InstMsiW.exe 4c3516c0b5c2b76b88209b22e3bf1cb82d8e2de7116125e97e128952372eed6b - w_try_cabextract --directory="${W_TMP}" "${W_CACHE}"/msls31/InstMsiW.exe + w_try_cabextract --directory="${W_TMP}" "${W_CACHE}"/msls31_nt4/InstMsiW.exe w_try_cp_dll "${W_TMP}"/msls31.dll "${W_SYSTEM32_DLLS}" } @@ -12479,7 +12507,7 @@ load_riched20() w_override_dlls native,builtin riched20 # https://github.com/Winetricks/winetricks/issues/292 - w_call msls31 + w_call msls31_nt4 } #---------------------------------------------------------------- @@ -12878,7 +12906,7 @@ w_metadata vb5run dlls \ load_vb5run() { - w_package_broken "https://bugs.winehq.org/show_bug.cgi?id=56209" 8.10 + w_package_broken "https://bugs.winehq.org/show_bug.cgi?id=56209" 8.10 10.18 w_download https://download.microsoft.com/download/vb50pro/utility/1/win98/en-us/msvbvm50.exe b5f8ea5b9d8b30822a2be2cdcb89cda99ec0149832659ad81f45360daa6e6965 w_try_cd "${W_CACHE}/${W_PACKAGE}" @@ -13405,7 +13433,7 @@ w_metadata vcrun2015 dlls \ publisher="Microsoft" \ year="2015" \ media="download" \ - conflicts="vcrun2017 vcrun2019 ucrtbase2019 vcrun2022" \ + conflicts="vcrun2017 vcrun2019 ucrtbase2019 vcrun2022 vcrun2026" \ file1="vc_redist.x86.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/mfc140.dll" @@ -13481,7 +13509,7 @@ w_metadata vcrun2017 dlls \ publisher="Microsoft" \ year="2017" \ media="download" \ - conflicts="vcrun2015 vcrun2019 ucrtbase2019 vcrun2022" \ + conflicts="vcrun2015 vcrun2019 ucrtbase2019 vcrun2022 vcrun2026" \ file1="vc_redist.x86.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/mfc140.dll" @@ -13531,7 +13559,7 @@ w_metadata vcrun2019 dlls \ publisher="Microsoft" \ year="2019" \ media="download" \ - conflicts="vcrun2015 vcrun2017 vcrun2022" \ + conflicts="vcrun2015 vcrun2017 vcrun2022 vcrun2026" \ file1="vc_redist.x86.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/mfc140.dll" @@ -13641,7 +13669,7 @@ w_metadata vcrun2022 dlls \ publisher="Microsoft" \ year="2022" \ media="download" \ - conflicts="vcrun2015 vcrun2017 vcrun2019" \ + conflicts="vcrun2015 vcrun2017 vcrun2019 vcrun2026" \ file1="vc_redist.x86.exe" \ installed_file1="${W_SYSTEM32_DLLS_WIN}/vcruntime140.dll" @@ -13699,6 +13727,54 @@ load_vcrun2022() #---------------------------------------------------------------- +w_metadata vcrun2026 dlls \ + title="Visual C++ 2017-2026 libraries (concrt140.dll,mfc140.dll,mfc140chs.dll,mfc140cht.dll,mfc140deu.dll,mfc140enu.dll,mfc140esn.dll,mfc140fra.dll,mfc140ita.dll,mfc140jpn.dll,mfc140kor.dll,mfc140rus.dll,mfc140u.dll,mfcm140.dll,mfcm140u.dll,msvcp140.dll,msvcp140_1.dll,msvcp140_2.dll,msvcp140_atomic_wait.dll,msvcp140_codecvt_ids.dll,vcamp140.dll,vccorlib140.dll,vcomp140.dll,vcruntime140.dll,vcruntime140_1.dll)" \ + publisher="Microsoft" \ + year="2026" \ + media="download" \ + conflicts="vcrun2015 vcrun2017 vcrun2019 vcrun2022" \ + file1="vc_redist.x86.exe" \ + installed_file1="${W_SYSTEM32_DLLS_WIN}/vcruntime140.dll" + +load_vcrun2026() +{ + # https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist + # 2026-02-01: 14.50.35719 @ https://download.visualstudio.microsoft.com/download/pr/6f02464a-5e9b-486d-a506-c99a17db9a83/E7267C1BDF9237C0B4A28CF027C382B97AA909934F84F1C92D3FB9F04173B33E/VC_redist.x86.exe + w_override_dlls native,builtin concrt140 msvcp140 msvcp140_1 msvcp140_2 msvcp140_atomic_wait msvcp140_codecvt_ids vcamp140 vccorlib140 vcomp140 vcruntime140 + + w_download https://aka.ms/vc14/vc_redist.x86.exe e7267c1bdf9237c0b4a28cf027c382b97aa909934f84f1c92d3fb9f04173b33e + w_try_cd "${W_CACHE}"/"${W_PACKAGE}" + w_try_ms_installer "${WINE}" vc_redist.x86.exe ${W_OPT_UNATTENDED:+/q} + + # Setup will refuse to install msvcp140 & msvcp140_2 because the builtin's version number is higher, so manually replace them + # See https://bugs.winehq.org/show_bug.cgi?id=57518 + w_try_cabextract --directory="${W_TMP}/win32" "${W_CACHE}"/"${W_PACKAGE}"/vc_redist.x86.exe -F 'a2' + w_try_cabextract --directory="${W_TMP}/win32" "${W_TMP}/win32/a2" + w_try mv -f "${W_TMP}/win32/msvcp140.dll_x86" "${W_SYSTEM32_DLLS}/msvcp140.dll" + w_try mv -f "${W_TMP}/win32/msvcp140_2.dll_x86" "${W_SYSTEM32_DLLS}/msvcp140_2.dll" + + case "${W_ARCH}" in + win64) + # Also install the 64-bit version + # 2026-02-01: 14.50.35719 @ https://download.visualstudio.microsoft.com/download/pr/6f02464a-5e9b-486d-a506-c99a17db9a83/8995548DFFFCDE7C49987029C764355612BA6850EE09A7B6F0FDDC85BDC5C280/VC_redist.x64.exe + # vcruntime140_1 is only shipped on x64: + w_override_dlls native,builtin vcruntime140_1 + + w_download https://aka.ms/vc14/vc_redist.x64.exe 8995548dfffcde7c49987029c764355612ba6850ee09a7b6f0fddc85bdc5c280 + w_try_ms_installer "${WINE}" vc_redist.x64.exe ${W_OPT_UNATTENDED:+/q} + + # Also replace 64-bit msvcp140.dll, msvcp140_2.dll & vcruntime140_1.dll + w_try_cabextract --directory="${W_TMP}/win64" "${W_CACHE}"/"${W_PACKAGE}"/vc_redist.x64.exe -F 'a4' + w_try_cabextract --directory="${W_TMP}/win64" "${W_TMP}/win64/a4" + w_try mv -f "${W_TMP}/win64/msvcp140.dll_amd64" "${W_SYSTEM64_DLLS}/msvcp140.dll" + w_try mv -f "${W_TMP}/win64/msvcp140_2.dll_amd64" "${W_SYSTEM64_DLLS}/msvcp140_2.dll" + w_try mv -f "${W_TMP}/win64/vcruntime140_1.dll_amd64" "${W_SYSTEM64_DLLS}/vcruntime140_1.dll" + ;; + esac +} + +#---------------------------------------------------------------- + w_metadata vjrun20 dlls \ title="MS Visual J# 2.0 SE libraries (requires dotnet20)" \ publisher="Microsoft" \ @@ -13731,11 +13807,15 @@ load_vstools2019() { w_call dotnet472 - # 2021/12/17: e653e715ddb8a08873e50a2fe091fca2ce77726b8b6ed2b99ed916d0e03c1fbe - # 2025/04/03: 0f0cc11f000593a064d419462a8467b529fed8075b21a605a40785baa3d2f611 - w_download https://aka.ms/vs/16/release/installer 0f0cc11f000593a064d419462a8467b529fed8075b21a605a40785baa3d2f611 vstools2019.zip - w_try_unzip "${W_TMP}/vs_installer_16" "${W_CACHE}/${W_PACKAGE}/vstools2019.zip" - w_try "${WINE}" "${W_TMP}"/vs_installer_16/Contents/vs_installer.exe install \ + if test ! "${W_OPT_UNATTENDED}"; then + # The installer GUI needs at least one registered font family for System.Windows.Media.FontFamily.get_FirstFontFamily() + w_call arial + fi + + # 2026/02/09: b7a70e4acdf18aaaba4e13e17c7c157a12d6512458d60d5c2001a373741329cb + # Use a newer bootstrap binary to prevent hang during installer self-update + w_download https://aka.ms/vs/18/stable/vs_buildtools.exe b7a70e4acdf18aaaba4e13e17c7c157a12d6512458d60d5c2001a373741329cb vs_buildtools.exe + w_try "${WINE}" "${W_CACHE}/${W_PACKAGE}/vs_buildtools.exe" install \ --channelId VisualStudio.16.Release \ --channelUri "https://aka.ms/vs/16/release/channel" \ --productId "Microsoft.VisualStudio.Product.BuildTools" \ @@ -13767,6 +13847,48 @@ load_webio() w_override_dlls native,builtin webio } +#---------------------------------------------------------------- + +w_metadata webview2 dlls \ + title="Microsoft Edge WebView2 Evergreen Runtime" \ + publisher="Microsoft" \ + year="2020" \ + media="download" \ + file1="MicrosoftEdgeWebview2Setup.exe" \ + installed_file1="${W_PROGRAMS_X86_WIN}/Microsoft/EdgeUpdate/MicrosoftEdgeUpdate.exe" + +load_webview2() +{ + # https://developer.microsoft.com/en-us/microsoft-edge/webview2/ + # Evergreen Bootstrapper - downloads and installs appropriate architecture + # https://go.microsoft.com/fwlink/p/?LinkId=2124703 + # Skip checksum verification since this file updates frequently + w_download https://go.microsoft.com/fwlink/p/?LinkId=2124703 "" MicrosoftEdgeWebview2Setup.exe + + w_try_cd "${W_CACHE}"/"${W_PACKAGE}" + w_try_ms_installer "${WINE}" MicrosoftEdgeWebview2Setup.exe ${W_OPT_UNATTENDED:+/silent /install} + + if w_workaround_wine_bug 53925 "Setting edgeupdate service to manual startup"; then + cat > "${W_TMP}"/edgeupdate-service.reg <<_EOF_ +REGEDIT4 + +[HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Services\\edgeupdate] +"Start"=dword:00000003 + +_EOF_ + w_try_regedit "${W_TMP_WIN}"\\edgeupdate-service.reg + + # Kill the running service since it was started during installation + # Use pgrep -f to match full command line since process name is >15 chars + # shellcheck disable=SC2046,SC2086 + sleep 5 # Make sure we don't accidentally kill any other MicrosoftEdgeUpdate instance (shouldn't be needed, but better safe than sorry) + kill -s KILL $(pgrep -f "MicrosoftEdgeUpdate.exe /c") 2>/dev/null || true + fi + + if w_workaround_wine_bug 58921 "Setting Windows 7 mode"; then + w_set_app_winver msedgewebview2.exe win7 + fi +} #---------------------------------------------------------------- @@ -15803,7 +15925,7 @@ load_ie6() # the ie6 we use these days lacks pngfilt, so grab that w_call pngfilt - w_call msls31 + w_call msls31_nt } #---------------------------------------------------------------- @@ -19418,10 +19540,10 @@ winetricks_stats_init() declined="Надсилання звітності Winetricks вимкнено. Ви більше не отримуватиме це питання знову." ;; zh_CN*) - title="有关是否协助 Winetricks 开发的一次性提问。" + title="有关是否协助 Winetricks 开发的一次性询问。" question="您是否愿意通过允许 winetricks 报告统计信息来协助 winetricks 的开发?您可以随时通过命令 'winetricks --optout' 关闭统计信息上报。" - thanks="谢谢!您将不会被要求再次回答此问题。切记,您可随时使用命令 'winetricks --optout' 关闭统计信息上报功能。" - declined="好的,winetricks 将不会报告统计信息。您将不会被要求再次回答此问题。" + thanks="谢谢!此问题将不会再次出现。请注意,您可随时使用命令 'winetricks --optout' 关闭统计信息上报。" + declined="好的,winetricks 将不会报告统计信息。此问题将不会再次出现。" ;; *) title="One-time question about helping Winetricks development"