From 12294d318669970dd7e893fb409c72913fe550df Mon Sep 17 00:00:00 2001 From: Omni Date: Sat, 7 Feb 2026 18:26:54 +0000 Subject: [PATCH] Sync from development - prepare for v0.3.0 --- CHANGELOG.md | 21 + jackify/__init__.py | 2 +- jackify/backend/core/modlist_operations.py | 1415 +----- .../modlist_operations_configuration_cli.py | 546 ++ .../modlist_operations_configuration_gui.py | 170 + .../core/modlist_operations_discovery.py | 368 ++ .../core/modlist_operations_game_detection.py | 67 + .../backend/core/modlist_operations_nexus.py | 99 + jackify/backend/handlers/completers.py | 12 +- jackify/backend/handlers/config_handler.py | 489 +- .../handlers/config_handler_directories.py | 108 + .../handlers/config_handler_encryption.py | 137 + .../backend/handlers/config_handler_proton.py | 76 + jackify/backend/handlers/diagnostic_helper.py | 2 +- jackify/backend/handlers/engine_monitor.py | 2 +- .../backend/handlers/filesystem_handler.py | 398 +- .../handlers/filesystem_handler_download.py | 55 + .../handlers/filesystem_handler_ownership.py | 89 + .../handlers/filesystem_handler_steam.py | 124 + .../handlers/install_wabbajack_handler.py | 1664 ------ jackify/backend/handlers/menu_handler.py | 776 +-- .../backend/handlers/menu_handler_input.py | 98 + .../backend/handlers/menu_handler_modlist.py | 615 +++ jackify/backend/handlers/mo2_handler.py | 26 +- .../backend/handlers/modlist_configuration.py | 584 +++ jackify/backend/handlers/modlist_detection.py | 376 ++ jackify/backend/handlers/modlist_handler.py | 1495 +----- .../backend/handlers/modlist_install_cli.py | 1265 +---- .../modlist_install_cli_configuration.py | 527 ++ .../handlers/modlist_install_cli_discovery.py | 451 ++ .../handlers/modlist_install_cli_nexus.py | 144 + .../handlers/modlist_install_cli_ttw.py | 180 + jackify/backend/handlers/modlist_wine_ops.py | 543 ++ jackify/backend/handlers/path_handler.py | 1251 +---- jackify/backend/handlers/path_handler_dxvk.py | 149 + jackify/backend/handlers/path_handler_game.py | 184 + jackify/backend/handlers/path_handler_mo2.py | 492 ++ .../backend/handlers/path_handler_steam.py | 226 + jackify/backend/handlers/progress_parser.py | 966 +--- .../handlers/progress_parser_extraction.py | 143 + .../backend/handlers/progress_parser_files.py | 235 + .../backend/handlers/progress_parser_phase.py | 96 + .../handlers/progress_state_metrics.py | 167 + .../handlers/progress_state_processing.py | 239 + .../backend/handlers/protontricks_commands.py | 147 + .../handlers/protontricks_detection.py | 195 + .../backend/handlers/protontricks_handler.py | 1187 +---- .../backend/handlers/protontricks_prefix.py | 271 + .../backend/handlers/protontricks_steam.py | 267 + .../backend/handlers/resolution_handler.py | 6 +- jackify/backend/handlers/shortcut_creation.py | 156 + .../backend/handlers/shortcut_discovery.py | 340 ++ jackify/backend/handlers/shortcut_handler.py | 1431 +----- .../handlers/shortcut_launch_options.py | 162 + .../handlers/shortcut_steam_restart.py | 293 ++ .../handlers/shortcut_vdf_management.py | 318 ++ jackify/backend/handlers/subprocess_utils.py | 8 +- .../backend/handlers/ttw_installer_backend.py | 318 ++ .../backend/handlers/ttw_installer_handler.py | 557 +- jackify/backend/handlers/ui_handler.py | 1 - jackify/backend/handlers/vdf_handler.py | 5 +- .../backend/handlers/wabbajack_directory.py | 296 ++ jackify/backend/handlers/wabbajack_handler.py | 1212 +---- .../handlers/wabbajack_installer_handler.py | 112 +- .../handlers/wabbajack_prefix_setup.py | 347 ++ .../handlers/wabbajack_steam_integration.py | 148 + .../handlers/wabbajack_verification.py | 151 + jackify/backend/handlers/wabbajack_webview.py | 140 + jackify/backend/handlers/wine_utils.py | 1218 +---- jackify/backend/handlers/wine_utils_config.py | 117 + jackify/backend/handlers/wine_utils_proton.py | 482 ++ jackify/backend/handlers/wine_wrapper.py | 140 + .../backend/handlers/winetricks_discovery.py | 84 + jackify/backend/handlers/winetricks_env.py | 301 ++ .../backend/handlers/winetricks_handler.py | 909 +--- .../handlers/winetricks_installation.py | 262 + .../handlers/winetricks_verification.py | 43 + .../services/automated_prefix_creation.py | 500 ++ .../services/automated_prefix_game_utils.py | 272 + .../services/automated_prefix_proton.py | 673 +++ .../services/automated_prefix_registry.py | 276 + .../services/automated_prefix_service.py | 2978 +---------- .../services/automated_prefix_shortcuts.py | 534 ++ .../automated_prefix_shortcuts_cleanup.py | 138 + .../backend/services/automated_prefix_stl.py | 190 + .../services/automated_prefix_workflow.py | 556 ++ .../services/modlist_gallery_service.py | 11 +- jackify/backend/services/modlist_service.py | 271 +- .../services/modlist_service_installation.py | 237 + .../backend/services/native_steam_service.py | 5 +- .../backend/services/nexus_auth_service.py | 2 +- .../services/nexus_download_service.py | 4 +- .../backend/services/nexus_oauth_callback.py | 147 + .../backend/services/nexus_oauth_protocol.py | 127 + .../backend/services/nexus_oauth_service.py | 511 +- .../protontricks_detection_service.py | 4 +- .../backend/services/steam_restart_service.py | 269 +- jackify/backend/services/update_service.py | 2 +- .../services/vnv_post_install_service.py | 5 +- .../services/wabbajack_installer_service.py | 270 + .../cli/commands/install_wabbajack.py | 106 + jackify/frontends/cli/main.py | 19 +- .../frontends/cli/menus/additional_menu.py | 50 +- jackify/frontends/cli/menus/main_menu.py | 2 +- jackify/frontends/cli/menus/wabbajack_menu.py | 6 - .../frontends/gui/dialogs/settings_dialog.py | 386 ++ .../gui/dialogs/settings_dialog_proton.py | 114 + .../gui/dialogs/settings_dialog_tabs.py | 280 ++ .../frontends/gui/dialogs/success_dialog.py | 2 +- jackify/frontends/gui/main.py | 1725 +------ .../gui/mixins/main_window_backend.py | 73 + .../gui/mixins/main_window_dialogs.py | 117 + .../gui/mixins/main_window_geometry.py | 207 + .../gui/mixins/main_window_startup.py | 102 + .../frontends/gui/mixins/main_window_ui.py | 187 + .../frontends/gui/screens/additional_tasks.py | 8 +- .../gui/screens/configure_existing_modlist.py | 1157 +---- .../configure_existing_modlist_console.py | 150 + .../configure_existing_modlist_shortcuts.py | 117 + .../screens/configure_existing_modlist_ui.py | 564 +++ .../configure_existing_modlist_workflow.py | 392 ++ .../gui/screens/configure_new_modlist.py | 1548 +----- .../screens/configure_new_modlist_console.py | 172 + .../screens/configure_new_modlist_dialogs.py | 354 ++ .../screens/configure_new_modlist_ui_setup.py | 631 +++ .../screens/configure_new_modlist_workflow.py | 524 ++ .../frontends/gui/screens/install_modlist.py | 4462 +---------------- .../install_modlist_automated_prefix.py | 377 ++ .../screens/install_modlist_configuration.py | 625 +++ .../gui/screens/install_modlist_console.py | 368 ++ .../gui/screens/install_modlist_dialogs.py | 327 ++ .../install_modlist_installer_thread.py | 257 + .../gui/screens/install_modlist_nexus.py | 260 + .../screens/install_modlist_output_mixin.py | 228 + .../screens/install_modlist_postinstall.py | 470 ++ .../gui/screens/install_modlist_progress.py | 413 ++ .../gui/screens/install_modlist_selection.py | 195 + .../install_modlist_shortcut_dialog.py | 114 + .../gui/screens/install_modlist_ttw.py | 222 + .../gui/screens/install_modlist_ui_setup.py | 519 ++ .../gui/screens/install_modlist_vnv.py | 208 + .../gui/screens/install_modlist_workflow.py | 371 ++ jackify/frontends/gui/screens/install_ttw.py | 3180 +----------- .../gui/screens/install_ttw_config.py | 657 +++ .../gui/screens/install_ttw_installer.py | 290 ++ .../gui/screens/install_ttw_integration.py | 322 ++ .../gui/screens/install_ttw_lifecycle.py | 155 + .../gui/screens/install_ttw_requirements.py | 298 ++ .../frontends/gui/screens/install_ttw_ui.py | 275 + .../gui/screens/install_ttw_ui_setup.py | 368 ++ .../gui/screens/install_ttw_workflow.py | 681 +++ jackify/frontends/gui/screens/main_menu.py | 13 +- .../frontends/gui/screens/modlist_gallery.py | 1445 +----- .../gui/screens/modlist_gallery_card.py | 208 + .../gui/screens/modlist_gallery_detail.py | 451 ++ .../gui/screens/modlist_gallery_filters.py | 302 ++ .../screens/modlist_gallery_image_manager.py | 141 + .../gui/screens/modlist_gallery_loading.py | 401 ++ .../frontends/gui/screens/modlist_tasks.py | 8 +- .../gui/screens/screen_back_mixin.py | 50 + .../gui/screens/wabbajack_installer.py | 271 +- .../frontends/gui/services/message_service.py | 56 +- .../gui/widgets/feature_placeholder.py | 22 + .../gui/widgets/file_progress_item.py | 195 + .../gui/widgets/file_progress_list.py | 358 +- .../gui/widgets/progress_indicator.py | 4 +- .../gui/widgets/summary_progress_widget.py | 67 + jackify/shared/progress_models.py | 8 +- jackify/tools/winetricks | 85 +- 169 files changed, 31749 insertions(+), 33649 deletions(-) create mode 100644 jackify/backend/core/modlist_operations_configuration_cli.py create mode 100644 jackify/backend/core/modlist_operations_configuration_gui.py create mode 100644 jackify/backend/core/modlist_operations_discovery.py create mode 100644 jackify/backend/core/modlist_operations_game_detection.py create mode 100644 jackify/backend/core/modlist_operations_nexus.py create mode 100644 jackify/backend/handlers/config_handler_directories.py create mode 100644 jackify/backend/handlers/config_handler_encryption.py create mode 100644 jackify/backend/handlers/config_handler_proton.py create mode 100644 jackify/backend/handlers/filesystem_handler_download.py create mode 100644 jackify/backend/handlers/filesystem_handler_ownership.py create mode 100644 jackify/backend/handlers/filesystem_handler_steam.py delete mode 100644 jackify/backend/handlers/install_wabbajack_handler.py create mode 100644 jackify/backend/handlers/menu_handler_input.py create mode 100644 jackify/backend/handlers/menu_handler_modlist.py create mode 100644 jackify/backend/handlers/modlist_configuration.py create mode 100644 jackify/backend/handlers/modlist_detection.py create mode 100644 jackify/backend/handlers/modlist_install_cli_configuration.py create mode 100644 jackify/backend/handlers/modlist_install_cli_discovery.py create mode 100644 jackify/backend/handlers/modlist_install_cli_nexus.py create mode 100644 jackify/backend/handlers/modlist_install_cli_ttw.py create mode 100644 jackify/backend/handlers/modlist_wine_ops.py create mode 100644 jackify/backend/handlers/path_handler_dxvk.py create mode 100644 jackify/backend/handlers/path_handler_game.py create mode 100644 jackify/backend/handlers/path_handler_mo2.py create mode 100644 jackify/backend/handlers/path_handler_steam.py create mode 100644 jackify/backend/handlers/progress_parser_extraction.py create mode 100644 jackify/backend/handlers/progress_parser_files.py create mode 100644 jackify/backend/handlers/progress_parser_phase.py create mode 100644 jackify/backend/handlers/progress_state_metrics.py create mode 100644 jackify/backend/handlers/progress_state_processing.py create mode 100644 jackify/backend/handlers/protontricks_commands.py create mode 100644 jackify/backend/handlers/protontricks_detection.py create mode 100644 jackify/backend/handlers/protontricks_prefix.py create mode 100644 jackify/backend/handlers/protontricks_steam.py create mode 100644 jackify/backend/handlers/shortcut_creation.py create mode 100644 jackify/backend/handlers/shortcut_discovery.py create mode 100644 jackify/backend/handlers/shortcut_launch_options.py create mode 100644 jackify/backend/handlers/shortcut_steam_restart.py create mode 100644 jackify/backend/handlers/shortcut_vdf_management.py create mode 100644 jackify/backend/handlers/ttw_installer_backend.py create mode 100644 jackify/backend/handlers/wabbajack_directory.py create mode 100644 jackify/backend/handlers/wabbajack_prefix_setup.py create mode 100644 jackify/backend/handlers/wabbajack_steam_integration.py create mode 100644 jackify/backend/handlers/wabbajack_verification.py create mode 100644 jackify/backend/handlers/wabbajack_webview.py create mode 100644 jackify/backend/handlers/wine_utils_config.py create mode 100644 jackify/backend/handlers/wine_utils_proton.py create mode 100644 jackify/backend/handlers/wine_wrapper.py create mode 100644 jackify/backend/handlers/winetricks_discovery.py create mode 100644 jackify/backend/handlers/winetricks_env.py create mode 100644 jackify/backend/handlers/winetricks_installation.py create mode 100644 jackify/backend/handlers/winetricks_verification.py create mode 100644 jackify/backend/services/automated_prefix_creation.py create mode 100644 jackify/backend/services/automated_prefix_game_utils.py create mode 100644 jackify/backend/services/automated_prefix_proton.py create mode 100644 jackify/backend/services/automated_prefix_registry.py create mode 100644 jackify/backend/services/automated_prefix_shortcuts.py create mode 100644 jackify/backend/services/automated_prefix_shortcuts_cleanup.py create mode 100644 jackify/backend/services/automated_prefix_stl.py create mode 100644 jackify/backend/services/automated_prefix_workflow.py create mode 100644 jackify/backend/services/modlist_service_installation.py create mode 100644 jackify/backend/services/nexus_oauth_callback.py create mode 100644 jackify/backend/services/nexus_oauth_protocol.py create mode 100644 jackify/backend/services/wabbajack_installer_service.py create mode 100644 jackify/frontends/cli/commands/install_wabbajack.py create mode 100644 jackify/frontends/gui/dialogs/settings_dialog.py create mode 100644 jackify/frontends/gui/dialogs/settings_dialog_proton.py create mode 100644 jackify/frontends/gui/dialogs/settings_dialog_tabs.py create mode 100644 jackify/frontends/gui/mixins/main_window_backend.py create mode 100644 jackify/frontends/gui/mixins/main_window_dialogs.py create mode 100644 jackify/frontends/gui/mixins/main_window_geometry.py create mode 100644 jackify/frontends/gui/mixins/main_window_startup.py create mode 100644 jackify/frontends/gui/mixins/main_window_ui.py create mode 100644 jackify/frontends/gui/screens/configure_existing_modlist_console.py create mode 100644 jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py create mode 100644 jackify/frontends/gui/screens/configure_existing_modlist_ui.py create mode 100644 jackify/frontends/gui/screens/configure_existing_modlist_workflow.py create mode 100644 jackify/frontends/gui/screens/configure_new_modlist_console.py create mode 100644 jackify/frontends/gui/screens/configure_new_modlist_dialogs.py create mode 100644 jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py create mode 100644 jackify/frontends/gui/screens/configure_new_modlist_workflow.py create mode 100644 jackify/frontends/gui/screens/install_modlist_automated_prefix.py create mode 100644 jackify/frontends/gui/screens/install_modlist_configuration.py create mode 100644 jackify/frontends/gui/screens/install_modlist_console.py create mode 100644 jackify/frontends/gui/screens/install_modlist_dialogs.py create mode 100644 jackify/frontends/gui/screens/install_modlist_installer_thread.py create mode 100644 jackify/frontends/gui/screens/install_modlist_nexus.py create mode 100644 jackify/frontends/gui/screens/install_modlist_output_mixin.py create mode 100644 jackify/frontends/gui/screens/install_modlist_postinstall.py create mode 100644 jackify/frontends/gui/screens/install_modlist_progress.py create mode 100644 jackify/frontends/gui/screens/install_modlist_selection.py create mode 100644 jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py create mode 100644 jackify/frontends/gui/screens/install_modlist_ttw.py create mode 100644 jackify/frontends/gui/screens/install_modlist_ui_setup.py create mode 100644 jackify/frontends/gui/screens/install_modlist_vnv.py create mode 100644 jackify/frontends/gui/screens/install_modlist_workflow.py create mode 100644 jackify/frontends/gui/screens/install_ttw_config.py create mode 100644 jackify/frontends/gui/screens/install_ttw_installer.py create mode 100644 jackify/frontends/gui/screens/install_ttw_integration.py create mode 100644 jackify/frontends/gui/screens/install_ttw_lifecycle.py create mode 100644 jackify/frontends/gui/screens/install_ttw_requirements.py create mode 100644 jackify/frontends/gui/screens/install_ttw_ui.py create mode 100644 jackify/frontends/gui/screens/install_ttw_ui_setup.py create mode 100644 jackify/frontends/gui/screens/install_ttw_workflow.py create mode 100644 jackify/frontends/gui/screens/modlist_gallery_card.py create mode 100644 jackify/frontends/gui/screens/modlist_gallery_detail.py create mode 100644 jackify/frontends/gui/screens/modlist_gallery_filters.py create mode 100644 jackify/frontends/gui/screens/modlist_gallery_image_manager.py create mode 100644 jackify/frontends/gui/screens/modlist_gallery_loading.py create mode 100644 jackify/frontends/gui/screens/screen_back_mixin.py create mode 100644 jackify/frontends/gui/widgets/feature_placeholder.py create mode 100644 jackify/frontends/gui/widgets/file_progress_item.py create mode 100644 jackify/frontends/gui/widgets/summary_progress_widget.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cf611d5..ae5729a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Jackify Changelog +## v0.3.0 - Codebase Refactoring +**Release Date:** 2026-02-06 + +### Technical Improvements +- **Code Architecture**: Refactored 13 large files (1000-5000 lines each) into 50+ focused modules using mixin pattern. All main files now under 600 lines. + +### Bug Fixes +- **Configure New Modlist GUI**: Fixed window not shrinking when Show Details unchecked +- **CLI Wabbajack Installer**: Added missing installation command to CLI menu +- **Wabbajack Installer**: Fixed installation to non-primary disk + +### Improvements +- **Wabbajack Install - Honour Install Proton**: Wabbajack installer now uses the user's selected Install Proton from Settings (same as modlist install/configure). Previously hardcoded to Proton Experimental. Fallback to Proton Experimental when no selection or path invalid. +- **STEAM_COMPAT_MOUNTS (Issue #155)**: Launch options now include mountpoints for both the modlist install path and the download path when known, so MO2 can access game and downloads on different drives. Uses new mountpoint helper and passes install_dir/download_dir through the Install a Modlist workflow. +- **MO2 download_directory (Issue #154)**: When configuring after Install a Modlist, Jackify now sets `download_directory` in ModOrganizer.ini to the correct Wine path (Z: or D: on SD card) so MO2 finds the download folder. Configure New and Configure Existing continue to leave or blank the key as before. +- **Winetricks / Protontricks**: For Flatpak Steam, use protontricks only. Winetricks alone struggles with the flatpak sandbox. +- **Wine Component Animation**: Added pulser animation for individual wine component installation progress in Configure Existing and Install Modlist workflows +- **Wabbajack Installer Log Rotation**: Added log rotation for Wabbajack installer workflow logs + +--- + ## v0.2.2.2 - ModOrganizer.ini Path Fixes for SD Card Installations **Release Date:** 2026-01-28 diff --git a/jackify/__init__.py b/jackify/__init__.py index 548d554..a3874a2 100644 --- a/jackify/__init__.py +++ b/jackify/__init__.py @@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing Wabbajack modlists natively on Linux systems. """ -__version__ = "0.2.2.2" +__version__ = "0.3.0" diff --git a/jackify/backend/core/modlist_operations.py b/jackify/backend/core/modlist_operations.py index bc161c9..3c64a1c 100644 --- a/jackify/backend/core/modlist_operations.py +++ b/jackify/backend/core/modlist_operations.py @@ -5,7 +5,6 @@ from ..handlers.protontricks_handler import ProtontricksHandler from ..handlers.shortcut_handler import ShortcutHandler from ..handlers.menu_handler import MenuHandler, ModlistMenuHandler from ..handlers.ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION -# Standard logging (no file handler) - LoggingHandler import removed import logging from ..handlers.wabbajack_parser import WabbajackParser import re @@ -16,12 +15,16 @@ import json import shlex import time import pty -# from src.core.compressonator import run_compressonatorcli # TODO: Implement compressonator integration from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.configuration import SystemInfo from jackify.backend.handlers.config_handler import ConfigHandler -# UI Colors already imported above +from .modlist_operations_discovery import ModlistOperationsDiscoveryMixin +from .modlist_operations_configuration_cli import ModlistOperationsConfigurationCLIMixin +from .modlist_operations_configuration_gui import ModlistOperationsConfigurationGUIMixin +from .modlist_operations_game_detection import ModlistOperationsGameDetectionMixin +from .modlist_operations_nexus import ModlistOperationsNexusMixin + def _get_user_proton_version(): """Get user's preferred Proton version from config, with fallback to auto-detection""" @@ -133,7 +136,13 @@ def get_jackify_engine_path(): # Return source path as final fallback return engine_path -class ModlistInstallCLI: +class ModlistInstallCLI( + ModlistOperationsDiscoveryMixin, + ModlistOperationsConfigurationCLIMixin, + ModlistOperationsConfigurationGUIMixin, + ModlistOperationsGameDetectionMixin, + ModlistOperationsNexusMixin, +): """CLI interface for modlist installation operations.""" def __init__(self, menu_handler_or_system_info, steamdeck: bool = False): # Support both initialization patterns: @@ -185,444 +194,6 @@ class ModlistInstallCLI: finally: self._current_process = None - def detect_game_type(self, modlist_info: Optional[Dict] = None, wabbajack_file_path: Optional[Path] = None) -> Optional[str]: - """ - Detect the game type for a modlist installation. - - Args: - modlist_info: Dictionary containing modlist information (for online modlists) - wabbajack_file_path: Path to .wabbajack file (for local files) - - Returns: - Jackify game type string or None if detection fails - """ - if wabbajack_file_path: - # Parse .wabbajack file to get game type - self.logger.info(f"Detecting game type from .wabbajack file: {wabbajack_file_path}") - game_type = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_file_path) - if game_type: - self.logger.info(f"Detected game type from .wabbajack file: {game_type}") - return game_type - else: - self.logger.warning(f"Could not detect game type from .wabbajack file: {wabbajack_file_path}") - return None - elif modlist_info and 'game' in modlist_info: - # Use game type from modlist info - game_name = modlist_info['game'].lower() - self.logger.info(f"Detecting game type from modlist info: {game_name}") - - # Map common game names to Jackify game types - game_mapping = { - 'skyrim special edition': 'skyrim', - 'skyrim': 'skyrim', - 'fallout 4': 'fallout4', - 'fallout new vegas': 'falloutnv', - 'oblivion': 'oblivion', - 'starfield': 'starfield', - 'oblivion remastered': 'oblivion_remastered' - } - - game_type = game_mapping.get(game_name) - if game_type: - self.logger.info(f"Mapped game name '{game_name}' to game type: {game_type}") - return game_type - else: - self.logger.warning(f"Unknown game name in modlist info: {game_name}") - return None - else: - self.logger.warning("No modlist info or .wabbajack file path provided for game detection") - return None - - def check_game_support(self, game_type: str) -> bool: - """ - Check if a game type is supported by Jackify's post-install configuration. - - Args: - game_type: Jackify game type string - - Returns: - True if the game is supported, False otherwise - """ - return self.wabbajack_parser.is_supported_game(game_type) - - def run_discovery_phase(self, context_override=None) -> Optional[Dict]: - """ - Run the discovery phase: prompt for all required info, and validate inputs. - Returns a context dict with all collected info, or None if cancelled. - Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow). - """ - self.logger.info("Starting modlist discovery phase (restored logic).") - print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}") - - if context_override: - self.context.update(context_override) - if 'resolution' in context_override: - self.context['resolution'] = context_override['resolution'] - else: - self.context = {} - - is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' - # Only require game_type for non-Tuxborn workflows - if self.context.get('machineid'): - required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key'] - else: - required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type'] - has_modlist = self.context.get('modlist_value') or self.context.get('machineid') - missing = [k for k in required_keys if not self.context.get(k)] - if is_gui_mode: - if missing or not has_modlist: - print(f"ERROR: Missing required arguments for GUI workflow: {', '.join(missing)}") - if not has_modlist: - print("ERROR: Missing modlist_value or machineid for GUI workflow.") - print("This workflow must be fully non-interactive. Please report this as a bug if you see this message.") - return None - self.logger.info("All required context present in GUI mode, skipping prompts.") - return self.context - - # Get engine path using the helper - engine_executable = get_jackify_engine_path() - self.logger.debug(f"Engine executable path: {engine_executable}") - - if not os.path.exists(engine_executable): - self.logger.error(f"jackify-install-engine not found at {engine_executable}") - print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") - print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") - return None - - engine_dir = os.path.dirname(engine_executable) - - # 1. Prompt for modlist source (unless using machineid from context_override) - if 'machineid' not in self.context: - print("\n" + "-" * 28) # Separator - print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}") - print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists") - print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk") - print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu") - source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() - self.logger.debug(f"User selected modlist source option: {source_choice}") - - if source_choice == '1': - self.context['modlist_source_type'] = 'online_list' - print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}") - try: - # Use the same backend service as the GUI - 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 - system_info = SystemInfo(is_steamdeck=is_steamdeck) - modlist_service = ModlistService(system_info) - - # Define categories and their backend keys - categories = [ - ("Skyrim", "skyrim"), - ("Fallout 4", "fallout4"), - ("Fallout New Vegas", "falloutnv"), - ("Oblivion", "oblivion"), - ("Starfield", "starfield"), - ("Oblivion Remastered", "oblivion_remastered"), - ("Other Games", "other") - ] - grouped_modlists = {} - for label, key in categories: - grouped_modlists[label] = modlist_service.list_modlists(game_type=key) - - selected_modlist_info = None - while not selected_modlist_info: - print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}") - category_display_map = {} - display_idx = 1 - for label, _ in categories: - modlists = grouped_modlists[label] - # Always show Oblivion Remastered, even if empty - if label == "Oblivion Remastered" or modlists: - print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {label} ({len(modlists)} modlists)") - category_display_map[str(display_idx)] = label - display_idx += 1 - if display_idx == 1: - print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}") - return None - print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel") - game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip() - if game_cat_choice == '0': - self.logger.info("User cancelled game category selection.") - return None - actual_label = category_display_map.get(game_cat_choice) - if not actual_label: - print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") - continue - modlist_group_for_game = sorted(grouped_modlists[actual_label], key=lambda x: x.id.lower()) - print(f"\n{COLOR_SUCCESS}Available Modlists for {actual_label}:{COLOR_RESET}") - for idx, m_detail in enumerate(modlist_group_for_game, 1): - # Show game name for Other Games - if actual_label == "Other Games": - print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail.id} ({m_detail.game})") - else: - print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail.id}") - print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories") - while True: - mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip() - if mod_choice_idx_str == '0': - break - if mod_choice_idx_str.isdigit(): - mod_idx = int(mod_choice_idx_str) - 1 - if 0 <= mod_idx < len(modlist_group_for_game): - selected_modlist_info = { - 'id': modlist_group_for_game[mod_idx].id, - 'game': modlist_group_for_game[mod_idx].game, - 'machine_url': getattr(modlist_group_for_game[mod_idx], 'machine_url', modlist_group_for_game[mod_idx].id) - } - self.context['modlist_source'] = 'identifier' - # Use machine_url for installation, display name for suggestions - self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id']) - self.context['modlist_game'] = selected_modlist_info['game'] - self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1] - self.logger.info(f"User selected online modlist: {selected_modlist_info}") - break - else: - print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}") - else: - print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") - if selected_modlist_info: - break - except Exception as e: - self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) - print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}") - return None - - elif source_choice == '2': - self.context['modlist_source_type'] = 'local_file' - print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}") - modlist_path = self.menu_handler.get_existing_file_path( - prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", - extension_filter=".wabbajack", # Ensure this is the exact filter used by the method - no_header=True # To avoid re-printing a header if get_existing_file_path has one - ) - if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q' - self.logger.info("User cancelled .wabbajack file selection.") - print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}") - return None - - self.context['modlist_source'] = 'path' # For install command - self.context['modlist_value'] = str(modlist_path) - # Suggest a name based on the file - self.context['modlist_name_suggestion'] = Path(modlist_path).stem - self.logger.info(f"User selected local .wabbajack file: {modlist_path}") - - elif source_choice == '0': - self.logger.info("User cancelled modlist source selection.") - print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}") - return None - else: - self.logger.warning(f"Invalid modlist source choice: {source_choice}") - print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") - return self.run_discovery_phase() # Re-prompt - - # --- Prompts for install_dir, download_dir, modlist_name, api_key --- - # (This part is largely similar to the restored version, adapt as needed) - # It will use self.context['modlist_name_suggestion'] if available. - - # 2. Prompt for modlist name (skip if 'modlist_name' already in context from override) - if 'modlist_name' not in self.context or not self.context['modlist_name']: - default_name = self.context.get('modlist_name_suggestion', 'MyModlist') - print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}") - print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}") - modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() - if not modlist_name_input: # User hit enter for default - modlist_name = default_name - elif modlist_name_input.lower() == 'q': - self.logger.info("User cancelled at modlist name prompt.") - return None - else: - modlist_name = modlist_name_input - self.context['modlist_name'] = modlist_name - self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}") - - # 3. Prompt for install directory - if 'install_dir' not in self.context: - # Use configurable base directory - config_handler = ConfigHandler() - base_install_dir = Path(config_handler.get_modlist_install_base_dir()) - default_install_dir = base_install_dir / self.context['modlist_name'] - print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}") - print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}") - install_dir_path = self.menu_handler.get_directory_path( - prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", - default_path=default_install_dir, - create_if_missing=True, - no_header=True - ) - if install_dir_path is None: - self.logger.info("User cancelled at install directory prompt.") - return None - self.context['install_dir'] = install_dir_path - self.logger.debug(f"Install directory context set to: {self.context['install_dir']}") - - # 4. Prompt for download directory - if 'download_dir' not in self.context: - # Use configurable base directory for downloads - config_handler = ConfigHandler() - base_download_dir = Path(config_handler.get_modlist_downloads_base_dir()) - default_download_dir = base_download_dir / self.context['modlist_name'] - - print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}") - print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}") - download_dir_path = self.menu_handler.get_directory_path( - prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", - default_path=default_download_dir, - create_if_missing=True, - no_header=True - ) - if download_dir_path is None: - self.logger.info("User cancelled at download directory prompt.") - return None - self.context['download_dir'] = download_dir_path - self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") - - # 5. Get Nexus authentication (OAuth or API key) - if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'): - from jackify.backend.services.nexus_auth_service import NexusAuthService - auth_service = NexusAuthService() - - # Get current auth status - authenticated, method, username = auth_service.get_auth_status() - - if authenticated: - # Already authenticated - use existing auth - if method == 'oauth': - print("\n" + "-" * 28) - print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}") - if username: - print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}") - elif method == 'api_key': - print("\n" + "-" * 28) - print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}") - - # Get valid token/key and OAuth state for engine auto-refresh - api_key, oauth_info = auth_service.get_auth_for_engine() - if api_key: - self.context['nexus_api_key'] = api_key - self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh - else: - # Auth expired or invalid - prompt to set up - print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}") - authenticated = False - - if not authenticated: - # Not authenticated - offer to set up OAuth - print("\n" + "-" * 28) - print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}") - print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}") - print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}") - - authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower() - - if authorize in ('', 'y', 'yes'): - # Launch OAuth authorization - print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}") - print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}") - print(f"{COLOR_INFO}Note: You may see a security warning about a self-signed certificate.{COLOR_RESET}") - print(f"{COLOR_INFO}This is normal - click 'Advanced' and 'Proceed' to continue.{COLOR_RESET}") - - def show_message(msg): - print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}") - - success = auth_service.authorize_oauth(show_browser_message_callback=show_message) - - if success: - print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}") - _, _, username = auth_service.get_auth_status() - if username: - print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}") - - api_key, oauth_info = auth_service.get_auth_for_engine() - if api_key: - self.context['nexus_api_key'] = api_key - self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh - else: - print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}") - return None - else: - print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}") - return None - else: - # User declined OAuth - cancelled - print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}") - self.logger.info("User declined Nexus authorization.") - return None - self.logger.debug(f"Nexus authentication configured for engine.") - - # Display summary and confirm - self._display_summary() # Ensure this method exists or implement it - - # --- Unsupported game warning and Enter-to-continue prompt --- - # Determine the game type and name - game_type = None - game_name = None - if self.context.get('modlist_source_type') == 'online_list': - game_name = self.context.get('modlist_game', '') - game_mapping = { - 'skyrim special edition': 'skyrim', - 'skyrim': 'skyrim', - 'fallout 4': 'fallout4', - 'fallout new vegas': 'falloutnv', - 'oblivion': 'oblivion', - 'starfield': 'starfield', - 'oblivion remastered': 'oblivion_remastered' - } - game_type = game_mapping.get(game_name.lower()) - if not game_type: - game_type = 'unknown' - elif self.context.get('modlist_source_type') == 'local_file': - # Use the parser to get the game type from the .wabbajack file - wabbajack_path = self.context.get('modlist_value') - if wabbajack_path: - result = self.wabbajack_parser.parse_wabbajack_game_type(Path(wabbajack_path)) - if result: - if isinstance(result, tuple): - game_type, raw_game_type = result - game_name = raw_game_type if game_type == 'unknown' else game_type - else: - game_type = result - game_name = game_type - # If unsupported, show warning and require Enter - if game_type and not self.wabbajack_parser.is_supported_game(game_type): - print("\n" + "─"*46) - print("\u26A0\uFE0F Game Support Notice\n") - print(f"You are about to install a modlist for: {game_name or 'Unknown'}\n") - print("Jackify does not provide post-install configuration for this game.") - print("You can still install and use the modlist, but you will need to manually set up Steam shortcuts and other steps after installation.\n") - print("Press [Enter] to continue, or [Ctrl+C] to cancel.") - print("─"*46 + "\n") - try: - input() - except KeyboardInterrupt: - print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") - return None - - if self.context.get('skip_confirmation'): - confirm = 'y' - else: - confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower() - if confirm != 'y': - self.logger.info("User cancelled at final confirmation.") - print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") - return None - - self.logger.info("Discovery phase complete.") # Log completion first - - # Create a copy of the context for logging, so we don't alter the original - context_for_logging = self.context.copy() - if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None: - context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging - - self.logger.info(f"Context: {context_for_logging}") # Log the redacted context - return self.context - def _display_summary(self): """ Display a summary of the collected context (excluding API key). @@ -668,963 +239,3 @@ class ModlistInstallCLI: print(auth_display) print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") - def configuration_phase(self): - """ - Run the configuration phase: execute the Linux-native Jackify Install Engine. - """ - import os - import subprocess - import time - import sys - from pathlib import Path - # UI Colors already imported at module level - print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}") - start_time = time.time() - - # --- BEGIN: TEE LOGGING SETUP & LOG ROTATION --- - from jackify.shared.paths import get_jackify_logs_dir - log_dir = get_jackify_logs_dir() - log_dir.mkdir(parents=True, exist_ok=True) - workflow_log_path = log_dir / "Modlist_Install_workflow.log" - # Log rotation: keep last 3 logs, 1MB each (adjust as needed) - max_logs = 3 - max_size = 1024 * 1024 # 1MB - if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size: - for i in range(max_logs, 0, -1): - prev = log_dir / f"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path - dest = log_dir / f"Modlist_Install_workflow.log.{i}" - if prev.exists(): - if dest.exists(): - dest.unlink() - prev.rename(dest) - workflow_log = open(workflow_log_path, 'a') - class TeeStdout: - def __init__(self, *files): - self.files = files - def write(self, data): - for f in self.files: - f.write(data) - f.flush() - def flush(self): - for f in self.files: - f.flush() - orig_stdout, orig_stderr = sys.stdout, sys.stderr - sys.stdout = TeeStdout(sys.stdout, workflow_log) - sys.stderr = TeeStdout(sys.stderr, workflow_log) - # --- END: TEE LOGGING SETUP & LOG ROTATION --- - try: - # --- Process Paths from context --- - install_dir_context = self.context['install_dir'] - if isinstance(install_dir_context, tuple): - actual_install_path = Path(install_dir_context[0]) - if install_dir_context[1]: # Second element is True if creation was intended - self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}") - actual_install_path.mkdir(parents=True, exist_ok=True) - else: # Should be a Path object or string already - actual_install_path = Path(install_dir_context) - install_dir_str = str(actual_install_path) - self.logger.debug(f"Processed install directory for engine: {install_dir_str}") - - download_dir_context = self.context['download_dir'] - if isinstance(download_dir_context, tuple): - actual_download_path = Path(download_dir_context[0]) - if download_dir_context[1]: # Second element is True if creation was intended - self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}") - actual_download_path.mkdir(parents=True, exist_ok=True) - else: # Should be a Path object or string already - actual_download_path = Path(download_dir_context) - download_dir_str = str(actual_download_path) - self.logger.debug(f"Processed download directory for engine: {download_dir_str}") - # --- End Process Paths --- - - modlist_arg = self.context.get('modlist_value') or self.context.get('machineid') - machineid = self.context.get('machineid') - - # CRITICAL: Re-check authentication right before launching engine - # This ensures we use current auth state, not stale cached values from context - # (e.g., if user revoked OAuth after context was created) - from jackify.backend.services.nexus_auth_service import NexusAuthService - auth_service = NexusAuthService() - current_api_key, current_oauth_info = auth_service.get_auth_for_engine() - - # Use current auth state, fallback to context values only if current check failed - api_key = current_api_key or self.context.get('nexus_api_key') - oauth_info = current_oauth_info or self.context.get('nexus_oauth_info') - - # Path to the engine binary - engine_path = get_jackify_engine_path() - engine_dir = os.path.dirname(engine_path) - if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): - print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}") - return - - # --- Patch for GUI/auto: always set modlist_source to 'identifier' if not set, and ensure modlist_value is present --- - if os.environ.get('JACKIFY_GUI_MODE') == '1': - if not self.context.get('modlist_source'): - self.context['modlist_source'] = 'identifier' - if not self.context.get('modlist_value'): - print(f"{COLOR_ERROR}ERROR: modlist_value is missing in context for GUI workflow!{COLOR_RESET}") - self.logger.error("modlist_value is missing in context for GUI workflow!") - return - # --- End Patch --- - - # Build command - cmd = [engine_path, 'install', '--show-file-progress'] - # Determine if this is a local .wabbajack file or an online modlist - modlist_value = self.context.get('modlist_value') - if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): - cmd += ['-w', modlist_value] - elif modlist_value: - cmd += ['-m', modlist_value] - elif self.context.get('machineid'): - cmd += ['-m', self.context['machineid']] - cmd += ['-o', install_dir_str, '-d', download_dir_str] - - # Add debug flag if debug mode is enabled - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - if debug_mode: - cmd.append('--debug') - self.logger.info("Adding --debug flag to jackify-engine") - - # Store original environment values to restore later - original_env_values = { - 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), - 'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), - 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') - } - - try: - # Temporarily modify current process's environment - # Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy) - if oauth_info: - os.environ['NEXUS_OAUTH_INFO'] = oauth_info - # CRITICAL: Set client_id so engine can refresh tokens with correct client_id - # Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack" - from jackify.backend.services.nexus_oauth_service import NexusOAuthService - os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID - self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)") - # Also set NEXUS_API_KEY for backward compatibility - if api_key: - os.environ['NEXUS_API_KEY'] = api_key - elif api_key: - # No OAuth info, use API key only (no auto-refresh support) - os.environ['NEXUS_API_KEY'] = api_key - self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)") - else: - # No auth available, clear any inherited values - if 'NEXUS_API_KEY' in os.environ: - del os.environ['NEXUS_API_KEY'] - if 'NEXUS_OAUTH_INFO' in os.environ: - del os.environ['NEXUS_OAUTH_INFO'] - if 'NEXUS_OAUTH_CLIENT_ID' in os.environ: - del os.environ['NEXUS_OAUTH_CLIENT_ID'] - self.logger.debug(f"No Nexus auth available, cleared inherited env vars") - - os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" - self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.") - - self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.") - self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}") - self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}") - - pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) - print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}") - - # Temporarily increase file descriptor limit for engine process - from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit - success, old_limit, new_limit, message = increase_file_descriptor_limit() - if success: - self.logger.debug(f"File descriptor limit: {message}") - else: - self.logger.warning(f"File descriptor limit: {message}") - - # Use cleaned environment to prevent AppImage variable inheritance - from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env - clean_env = get_clean_subprocess_env() - # Store process reference for cleanup - self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir) - proc = self._current_process - - # Read output in binary mode to properly handle carriage returns - buffer = b'' - while True: - chunk = proc.stdout.read(1) - if not chunk: - break - buffer += chunk - - # Process complete lines or carriage return updates - if chunk == b'\n': - # Complete line - decode and print - line = buffer.decode('utf-8', errors='replace') - # Filter FILE_PROGRESS spam but keep the status line before it - if '[FILE_PROGRESS]' in line: - parts = line.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - line = parts[0].rstrip() - else: - # Skip this line entirely if it's only FILE_PROGRESS - buffer = b'' - continue - print(line, end='') - buffer = b'' - elif chunk == b'\r': - # Carriage return - decode and print without newline - line = buffer.decode('utf-8', errors='replace') - # Filter FILE_PROGRESS spam but keep the status line before it - if '[FILE_PROGRESS]' in line: - parts = line.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - line = parts[0].rstrip() - else: - # Skip this line entirely if it's only FILE_PROGRESS - buffer = b'' - continue - print(line, end='') - sys.stdout.flush() - buffer = b'' - - # Print any remaining buffer content - if buffer: - line = buffer.decode('utf-8', errors='replace') - # Filter FILE_PROGRESS spam but keep the status line before it - if '[FILE_PROGRESS]' in line: - parts = line.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - line = parts[0].rstrip() - else: - # Skip this line entirely if it's only FILE_PROGRESS - line = '' - if line: - print(line, end='') - - proc.wait() - # Clear process reference after completion - self._current_process = None - if proc.returncode != 0: - print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") - self.logger.error(f"Engine exited with code {proc.returncode}.") - return # Configuration phase failed - self.logger.info(f"Engine completed with code {proc.returncode}.") - except Exception as e: - error_message = str(e) - print(f"{COLOR_ERROR}Error running Jackify Install Engine: {error_message}{COLOR_RESET}\n") - self.logger.error(f"Exception running engine: {error_message}", exc_info=True) - - # Check for file descriptor limit issues and attempt to handle them - try: - from jackify.backend.services.resource_manager import handle_file_descriptor_error - if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']): - result = handle_file_descriptor_error(error_message, "Jackify Install Engine execution") - if result['auto_fix_success']: - print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}") - self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") - elif result['error_detected']: - print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}") - self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}") - if result['manual_instructions']: - distro = result['manual_instructions']['distribution'] - print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}") - self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution") - except Exception as resource_error: - self.logger.debug(f"Error checking for resource limit issues: {resource_error}") - - return # Configuration phase failed - finally: - # Restore original environment state - for key, original_value in original_env_values.items(): - current_value_in_os_environ = os.environ.get(key) # Value after Popen and before our restoration for this key - - # Determine display values for logging, redacting NEXUS_API_KEY - display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'" - # display_current_value_before_restore = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{current_value_in_os_environ}'" - - if original_value is not None: - # Original value existed. We must restore it. - if current_value_in_os_environ != original_value: - os.environ[key] = original_value - self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.") - else: - # If current value is already the original, ensure it's correctly set (os.environ[key] = original_value is harmless) - os.environ[key] = original_value # Ensure it is set - self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.") - else: - # Original value was None (key was not in os.environ initially). - if key in os.environ: # If it's in os.environ now, it means we must have set it or it was set by other means. - self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.") - del os.environ[key] - # If original_value was None and key is not in os.environ now, nothing to do. - - except Exception as e: - error_message = str(e) - print(f"{COLOR_ERROR}Error during Tuxborn installation workflow: {error_message}{COLOR_RESET}\n") - self.logger.error(f"Exception in Tuxborn workflow: {error_message}", exc_info=True) - - # Check for file descriptor limit issues and attempt to handle them - try: - from jackify.backend.services.resource_manager import handle_file_descriptor_error - if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']): - result = handle_file_descriptor_error(error_message, "Tuxborn installation workflow") - if result['auto_fix_success']: - print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}") - self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") - elif result['error_detected']: - print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}") - self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}") - if result['manual_instructions']: - distro = result['manual_instructions']['distribution'] - print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}") - self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution") - except Exception as resource_error: - self.logger.debug(f"Error checking for resource limit issues: {resource_error}") - - return - finally: - # --- BEGIN: RESTORE STDOUT/STDERR --- - sys.stdout = orig_stdout - sys.stderr = orig_stderr - workflow_log.close() - # --- END: RESTORE STDOUT/STDERR --- - - elapsed = int(time.time() - start_time) - print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n") - print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n") - if self.context.get('machineid') != 'Tuxborn/Tuxborn': - print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}") - - self.logger.debug("configuration_phase: Starting post-install game detection...") - - # After install, use self.context['modlist_game'] to determine if configuration should be offered - # After install, detect game type from ModOrganizer.ini - modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini") - detected_game = None - self.logger.debug(f"configuration_phase: Looking for ModOrganizer.ini at: {modorganizer_ini}") - if os.path.isfile(modorganizer_ini): - self.logger.debug("configuration_phase: Found ModOrganizer.ini, detecting game...") - from ..handlers.modlist_handler import ModlistHandler - handler = ModlistHandler({}, steamdeck=self.steamdeck) - handler.modlist_ini = modorganizer_ini - handler.modlist_dir = install_dir_str - if handler._detect_game_variables(): - detected_game = handler.game_var_full - self.logger.debug(f"configuration_phase: Detected game: {detected_game}") - else: - self.logger.debug("configuration_phase: Failed to detect game variables") - else: - self.logger.debug("configuration_phase: ModOrganizer.ini not found") - - supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"] - is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn' - self.logger.debug(f"configuration_phase: detected_game='{detected_game}', is_tuxborn={is_tuxborn}") - self.logger.debug(f"configuration_phase: Checking condition: (detected_game in supported_games) or is_tuxborn") - self.logger.debug(f"configuration_phase: Result: {(detected_game in supported_games) or is_tuxborn}") - - if (detected_game in supported_games) or is_tuxborn: - self.logger.debug("configuration_phase: Entering Steam configuration workflow...") - shortcut_name = self.context.get('modlist_name') - self.logger.debug(f"configuration_phase: shortcut_name from context: '{shortcut_name}'") - - if is_tuxborn and not shortcut_name: - self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'") - shortcut_name = "Tuxborn Automatic Installer" # Provide a fallback default - elif not shortcut_name: # For non-Tuxborn, prompt if missing - print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}") - raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip() - if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name: - self.logger.debug("configuration_phase: User cancelled shortcut name input") - return - shortcut_name = raw_shortcut_name - - self.logger.debug(f"configuration_phase: Final shortcut_name: '{shortcut_name}'") - - # Check if GUI mode to skip interactive prompts - is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' - self.logger.debug(f"configuration_phase: is_gui_mode={is_gui_mode}") - - if not is_gui_mode: - self.logger.debug("configuration_phase: Not in GUI mode, prompting user for configuration...") - # 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}") - configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() - self.logger.debug(f"configuration_phase: User choice: '{configure_choice}'") - - if configure_choice == 'n': - print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}") - self.logger.debug("configuration_phase: User chose to skip Steam configuration") - return - else: - self.logger.debug("configuration_phase: In GUI mode, proceeding automatically...") - - self.logger.debug("configuration_phase: Proceeding with Steam configuration...") - - # Add resolution prompting for CLI mode (before Steam operations) - if not is_gui_mode: - from jackify.backend.handlers.resolution_handler import ResolutionHandler - resolution_handler = ResolutionHandler() - - # Check if Steam Deck - is_steamdeck = self.steamdeck if hasattr(self, 'steamdeck') else False - - # Prompt for resolution in CLI mode - selected_resolution = resolution_handler.select_resolution(steamdeck=is_steamdeck) - if selected_resolution: - self.context['resolution'] = selected_resolution - self.logger.info(f"Resolution set to: {selected_resolution}") - - # 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') - - # Check if we should use automated prefix creation - use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1' - - if use_automated_prefix: - # Use automated prefix service for modern workflow - print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}") - - from ..services.automated_prefix_service import AutomatedPrefixService - prefix_service = AutomatedPrefixService() - - # Define progress callback for CLI with jackify-engine style timestamps - import time - start_time = time.time() - - def progress_callback(message): - 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}]" - print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}") - - # Run the automated workflow - # Detect Steam Deck once and pass through to 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( - shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck - ) - - # Handle the result (same logic as GUI) - if isinstance(result, tuple) and len(result) == 4: - if result[0] == "CONFLICT": - # Handle conflict - conflicts = result[1] - print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}") - - for i, conflict in enumerate(conflicts, 1): - print(f" {i}. Name: {conflict['name']}") - print(f" Executable: {conflict['exe']}") - print(f" Start Directory: {conflict['startdir']}") - - print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}") - print(" • Replace - Remove the existing shortcut and create a new one") - print(" • Cancel - Keep the existing shortcut and stop the installation") - print(" • Skip - Continue without creating a Steam shortcut") - - choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower() - - if choice == 'replace': - print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}") - success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str) - if success and app_id: - # Continue the workflow after replacement - result = prefix_service.continue_workflow_after_conflict_resolution( - shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback - ) - if isinstance(result, tuple) and len(result) >= 3: - success, prefix_path, app_id = result[0], result[1], result[2] - else: - success, prefix_path, app_id = False, None, None - else: - success, prefix_path, app_id = False, None, None - elif choice == 'cancel': - print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}") - return - elif choice == 'skip': - print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}") - success, prefix_path, app_id = True, None, None - else: - print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}") - return - else: - # Normal result with timestamp (4-tuple) - success, prefix_path, app_id, last_timestamp = result - elif isinstance(result, tuple) and len(result) == 3: - if result[0] == "CONFLICT": - # Handle conflict (3-tuple format) - conflicts = result[1] - print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}") - - for i, conflict in enumerate(conflicts, 1): - print(f" {i}. Name: {conflict['name']}") - print(f" Executable: {conflict['exe']}") - print(f" Start Directory: {conflict['startdir']}") - - print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}") - print(" • Replace - Remove the existing shortcut and create a new one") - print(" • Cancel - Keep the existing shortcut and stop the installation") - print(" • Skip - Continue without creating a Steam shortcut") - - choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower() - - if choice == 'replace': - print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}") - success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str) - if success and app_id: - # Continue the workflow after replacement - result = prefix_service.continue_workflow_after_conflict_resolution( - shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback - ) - if isinstance(result, tuple) and len(result) >= 3: - success, prefix_path, app_id = result[0], result[1], result[2] - else: - success, prefix_path, app_id = False, None, None - else: - success, prefix_path, app_id = False, None, None - elif choice == 'cancel': - print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}") - return - elif choice == 'skip': - print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}") - success, prefix_path, app_id = True, None, None - else: - print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}") - return - else: - # Normal result (3-tuple format) - success, prefix_path, app_id = result - else: - # Result is not a tuple, check if it's just a boolean success - if result is True: - success, prefix_path, app_id = True, None, None - else: - success, prefix_path, app_id = False, None, None - - if success: - print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}") - if prefix_path: - print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}") - if app_id: - print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}") - # Continue to configuration phase - else: - print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}") - print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}") - return - - # Step 3: Use SAME backend service as GUI - from jackify.backend.services.modlist_service import ModlistService - from jackify.backend.models.modlist import ModlistContext - from pathlib import Path - - # Create ModlistContext with engine_installed=True (same as GUI) - modlist_context = ModlistContext( - name=shortcut_name, - install_dir=Path(install_dir_str), - download_dir=Path(install_dir_str) / "downloads", # Standard location - game_type=self.context.get('detected_game', 'Unknown'), - 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'), - mo2_exe_path=Path(mo2_exe_path), - skip_confirmation=True, # Always skip confirmation in CLI - engine_installed=True # Skip path manipulation for engine workflows - ) - - # Add app_id to context - modlist_context.app_id = app_id - - # Step 4: Configure modlist using SAME service as GUI - modlist_service = ModlistService(self.system_info) - - # Add section header for configuration phase if progress callback is available - if 'progress_callback' in locals() and progress_callback: - progress_callback("") # Blank line for spacing - progress_callback("=== Configuration Phase ===") - - print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}") - self.logger.info("Running post-installation configuration phase using ModlistService") - - # Configure modlist using SAME method as GUI - configuration_success = modlist_service.configure_modlist_post_steam(modlist_context) - - if configuration_success: - print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}") - self.logger.info("Post-installation configuration completed successfully") - else: - print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}") - self.logger.warning("Post-installation configuration had issues") - else: - # Game not supported for automated configuration - print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}") - if detected_game: - print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}") - 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}") - - def configuration_phase_gui_mode(self, context, - progress_callback=None, - manual_steps_callback=None, - completion_callback=None): - """ - GUI-friendly configuration phase that uses callbacks instead of prompts. - - This method provides the same functionality as configuration_phase() but - integrates with GUI frontends using Qt callbacks instead of CLI prompts. - - Args: - context: Configuration context dict with modlist details - progress_callback: Called with progress messages (str) - manual_steps_callback: Called when manual steps needed (modlist_name, retry_count) - completion_callback: Called when configuration completes (success, message, modlist_name) - """ - # Section header now provided by GUI layer to avoid duplication - - try: - # CLI Install: keep original GUI mode (don't force GUI mode) - import os - original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') - - try: - # Build context for configuration - config_context = { - 'name': context.get('modlist_name', ''), - 'path': context.get('install_dir', ''), - 'mo2_exe_path': context.get('mo2_exe_path', ''), - 'modlist_value': context.get('modlist_value'), - 'modlist_source': context.get('modlist_source'), - 'resolution': context.get('resolution'), - 'skip_confirmation': True, # CLI Install is non-interactive - 'manual_steps_completed': False - } - - # Handle existing modlist configuration with app_id - existing_app_id = context.get('app_id') - if existing_app_id: - # This is an existing modlist configuration - config_context['appid'] = existing_app_id - - if progress_callback: - progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...") - - # Get the modlist menu handler - from jackify.backend.handlers.menu_handler import ModlistMenuHandler - from jackify.backend.handlers.config_handler import ConfigHandler - - config_handler = ConfigHandler() - modlist_menu = ModlistMenuHandler(config_handler) - - # Run configuration phase with GUI callbacks for existing modlist - retry_count = 0 - max_retries = 3 - - while retry_count < max_retries: - if progress_callback: - progress_callback("Running modlist configuration...") - - # Run the actual configuration - result = modlist_menu.run_modlist_configuration_phase(config_context) - - if progress_callback: - progress_callback(f"Configuration attempt {retry_count}: {'Success' if result else 'Failed'}") - - if result: - # Configuration successful - if completion_callback: - completion_callback(True, "Configuration completed successfully!", config_context['name']) - return True - else: - # Configuration failed - might need manual steps - retry_count += 1 - - if retry_count < max_retries: - # Show manual steps dialog - if progress_callback: - progress_callback(f"Configuration failed on attempt {retry_count}, showing manual steps dialog...") - if manual_steps_callback: - if progress_callback: - progress_callback(f"Calling manual_steps_callback for {config_context['name']}, retry {retry_count}") - manual_steps_callback(config_context['name'], retry_count) - - # Update context to indicate manual steps were attempted - config_context['manual_steps_completed'] = True - else: - # Max retries reached - if completion_callback: - completion_callback(False, "Manual steps failed after multiple attempts", config_context['name']) - return False - - # Should not reach here - if completion_callback: - completion_callback(False, "Configuration failed", config_context['name']) - return False - - # NEW modlist configuration - create Steam shortcut first - else: - # Get the modlist menu handler - from jackify.backend.handlers.menu_handler import ModlistMenuHandler - from jackify.backend.handlers.config_handler import ConfigHandler - - config_handler = ConfigHandler() - modlist_menu = ModlistMenuHandler(config_handler) - - # Create Steam shortcut first - if progress_callback: - progress_callback("Creating Steam shortcut...") - - # Create shortcut with working NativeSteamService - from jackify.backend.services.native_steam_service import NativeSteamService - steam_service = NativeSteamService() - - # Get user's preferred Proton version - proton_version = _get_user_proton_version() - - success, app_id = steam_service.create_shortcut_with_proton( - app_name=config_context['name'], - exe_path=config_context['mo2_exe_path'], - start_dir=os.path.dirname(config_context['mo2_exe_path']), - launch_options="%command%", - tags=["Jackify"], - proton_version=proton_version - ) - - if not success or not app_id: - if completion_callback: - completion_callback(False, "Failed to create Steam shortcut", config_context['name']) - return False - - # Add the new app_id to context - config_context['appid'] = app_id - - if progress_callback: - # Import here to avoid circular imports - from jackify.shared.timing import get_timestamp - progress_callback(f"{get_timestamp()} Steam shortcut created successfully") - - # For GUI mode, run configuration once and let GUI handle manual steps retry - if progress_callback: - progress_callback("Running modlist configuration...") - - # Run the actual configuration - if progress_callback: - progress_callback(f"About to call run_modlist_configuration_phase with context: {config_context}") - - result = modlist_menu.run_modlist_configuration_phase(config_context) - - if progress_callback: - progress_callback(f"run_modlist_configuration_phase returned: {result}") - - if result: - # Configuration successful - if completion_callback: - completion_callback(True, "Configuration completed successfully!", config_context['name']) - return True - else: - # Configuration failed - need manual steps - if progress_callback: - progress_callback("Configuration failed, manual Steam/Proton setup required") - if manual_steps_callback: - if progress_callback: - progress_callback(f"About to call manual_steps_callback for {config_context['name']}, retry 1") - # Call manual steps callback - GUI will handle validation and retry logic - manual_steps_callback(config_context['name'], 1) - if progress_callback: - progress_callback("manual_steps_callback completed") - - # Don't complete here - let GUI handle retry when user is done - return True - - # Should not reach here - if completion_callback: - completion_callback(False, "Configuration failed", config_context['name']) - return False - - finally: - # Restore original GUI mode - if original_gui_mode is not None: - os.environ['JACKIFY_GUI_MODE'] = original_gui_mode - else: - os.environ.pop('JACKIFY_GUI_MODE', None) - - except Exception as e: - error_msg = f"Configuration failed: {str(e)}" - if completion_callback: - completion_callback(False, error_msg, context.get('modlist_name', 'Unknown')) - return False - - def install_modlist(self, selected_modlist_info: Optional[Dict[str, Any]] = None, wabbajack_file_path: Optional[Union[str, Path]] = None): - # This is where we would get the engine path for the actual installation - engine_path = get_jackify_engine_path() # Use the helper - self.logger.info(f"Using engine path for installation: {engine_path}") - - # --- The rest of your install_modlist logic --- - # ... - # When constructing the subprocess command for install, use `engine_path` - # For example: - # install_command = [engine_path, 'install', '--modlist-url', modlist_url, ...] - # ... - self.logger.info("Placeholder for actual modlist installation logic using the engine.") - print("Modlist installation logic would run here.") - return True # Placeholder - - def _get_nexus_api_key(self) -> Optional[str]: - # This method is not provided in the original file or the code block - # It's assumed to exist as it's called in the _display_summary method - # Implement the logic to retrieve the Nexus API key from the context - return self.context.get('nexus_api_key') - - def get_all_modlists_from_engine(self, game_type=None): - """ - Call the Jackify engine with 'list-modlists' and return a list of modlist dicts. - Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags. - - Args: - game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas") - """ - import subprocess - import re - from pathlib import Path - # COLOR_ERROR already imported at module level - engine_executable = get_jackify_engine_path() - engine_dir = os.path.dirname(engine_executable) - if not os.path.exists(engine_executable): - self.logger.error(f"jackify-install-engine not found at {engine_executable}") - print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_ERROR}") - return [] - env = os.environ.copy() - env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" - command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url'] - - # Add game filter if specified - if game_type: - command.extend(['--game', game_type]) - try: - result = subprocess.run( - command, - capture_output=True, text=True, check=True, - env=env, cwd=engine_dir - ) - lines = result.stdout.splitlines() - modlists = [] - for line in lines: - line = line.strip() - if not line or line.startswith('Loading') or line.startswith('Loaded'): - continue - - # Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL - # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] - - # Extract status indicators - status_down = '[DOWN]' in line - status_nsfw = '[NSFW]' in line - - # Remove status indicators to get clean line - clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() - - # Split from right to handle modlist names with dashes - # Format: "NAME - GAME - SIZES - MACHINE_URL" - parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts - if len(parts) != 4: - continue # Skip malformed lines - - modlist_name = parts[0].strip() - game_name = parts[1].strip() - sizes_str = parts[2].strip() - machine_url = parts[3].strip() - - # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") - size_parts = sizes_str.split('|') - if len(size_parts) != 3: - continue # Skip if sizes don't match expected format - - download_size = size_parts[0].strip() - install_size = size_parts[1].strip() - total_size = size_parts[2].strip() - - # Skip if any required data is missing - if not modlist_name or not game_name or not machine_url: - continue - - modlists.append({ - 'id': modlist_name, # Use modlist name as ID for compatibility - 'name': modlist_name, - 'game': game_name, - 'download_size': download_size, - 'install_size': install_size, - 'total_size': total_size, - 'machine_url': machine_url, # Store machine URL for installation - 'status_down': status_down, - 'status_nsfw': status_nsfw - }) - return modlists - except subprocess.CalledProcessError as e: - self.logger.error(f"list-modlists failed. Code: {e.returncode}") - if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") - if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") - print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}") - return [] - except Exception as e: - self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) - print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}") - return [] - - def _display_summary(self): - # REMOVE pass AND RESTORE THE METHOD BODY - # print(f"{COLOR_WARNING}DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}{COLOR_RESET}") # Keep commented - # self.logger.info(f"DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}") # Keep commented - print(f"\n{COLOR_INFO}--- Summary of Collected Information ---{COLOR_RESET}") - if self.context.get('modlist_source_type') == 'online_list': - print(f"Modlist Source: Selected from online list") - print(f"Modlist Identifier: {self.context.get('modlist_value')}") - print(f"Detected Game: {self.context.get('modlist_game', 'N/A')}") - elif self.context.get('modlist_source_type') == 'local_file': - print(f"Modlist Source: Local .wabbajack file") - print(f"File Path: {self.context.get('modlist_value')}") - elif 'machineid' in self.context: # For Tuxborn/override flow - print(f"Modlist Identifier (Tuxborn/MachineID): {self.context.get('machineid')}") - - print(f"Steam Shortcut Name: {self.context.get('modlist_name', 'N/A')}") - - install_dir_display = self.context.get('install_dir') - if isinstance(install_dir_display, tuple): - install_dir_display = install_dir_display[0] # Get the Path object from (Path, bool) - print(f"Install Directory: {install_dir_display}") - - download_dir_display = self.context.get('download_dir') - if isinstance(download_dir_display, tuple): - download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool) - print(f"Download Directory: {download_dir_display}") - - # Show authentication method - from jackify.backend.services.nexus_auth_service import NexusAuthService - auth_service = NexusAuthService() - authenticated, method, username = auth_service.get_auth_status() - - if method == 'oauth': - auth_display = f"Nexus Authentication: OAuth" - if username: - auth_display += f" ({username})" - elif method == 'api_key': - auth_display = "Nexus Authentication: API Key (Legacy)" - else: - # Should never reach here since we validate auth before getting to summary - auth_display = "Nexus Authentication: Unknown" - - print(auth_display) - print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") \ No newline at end of file diff --git a/jackify/backend/core/modlist_operations_configuration_cli.py b/jackify/backend/core/modlist_operations_configuration_cli.py new file mode 100644 index 0000000..9c2f3ab --- /dev/null +++ b/jackify/backend/core/modlist_operations_configuration_cli.py @@ -0,0 +1,546 @@ +"""CLI configuration phase methods for ModlistInstallCLI (Mixin).""" +import logging +import os +import subprocess +import sys +import time +from pathlib import Path + +from ..handlers.ui_colors import ( + COLOR_PROMPT, + COLOR_RESET, + COLOR_INFO, + COLOR_ERROR, + COLOR_SUCCESS, + COLOR_WARNING, +) + +logger = logging.getLogger(__name__) + + +class ModlistOperationsConfigurationCLIMixin: + """Mixin providing CLI configuration phase methods.""" + + def configuration_phase(self): + """ + Run the configuration phase: execute the Linux-native Jackify Install Engine. + """ + from .modlist_operations import get_jackify_engine_path + + print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}") + start_time = time.time() + + from jackify.shared.paths import get_jackify_logs_dir + log_dir = get_jackify_logs_dir() + log_dir.mkdir(parents=True, exist_ok=True) + workflow_log_path = log_dir / "Modlist_Install_workflow.log" + max_logs = 3 + max_size = 1024 * 1024 + if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size: + for i in range(max_logs, 0, -1): + prev = log_dir / f"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path + dest = log_dir / f"Modlist_Install_workflow.log.{i}" + if prev.exists(): + if dest.exists(): + dest.unlink() + prev.rename(dest) + workflow_log = open(workflow_log_path, 'a') + class TeeStdout: + def __init__(self, *files): + self.files = files + def write(self, data): + for f in self.files: + f.write(data) + f.flush() + def flush(self): + for f in self.files: + f.flush() + orig_stdout, orig_stderr = sys.stdout, sys.stderr + sys.stdout = TeeStdout(sys.stdout, workflow_log) + sys.stderr = TeeStdout(sys.stderr, workflow_log) + try: + install_dir_context = self.context['install_dir'] + if isinstance(install_dir_context, tuple): + actual_install_path = Path(install_dir_context[0]) + if install_dir_context[1]: + self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}") + actual_install_path.mkdir(parents=True, exist_ok=True) + else: + actual_install_path = Path(install_dir_context) + install_dir_str = str(actual_install_path) + self.logger.debug(f"Processed install directory for engine: {install_dir_str}") + + download_dir_context = self.context['download_dir'] + if isinstance(download_dir_context, tuple): + actual_download_path = Path(download_dir_context[0]) + if download_dir_context[1]: + self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}") + actual_download_path.mkdir(parents=True, exist_ok=True) + else: + actual_download_path = Path(download_dir_context) + download_dir_str = str(actual_download_path) + self.logger.debug(f"Processed download directory for engine: {download_dir_str}") + + modlist_arg = self.context.get('modlist_value') or self.context.get('machineid') + machineid = self.context.get('machineid') + + from jackify.backend.services.nexus_auth_service import NexusAuthService + auth_service = NexusAuthService() + current_api_key, current_oauth_info = auth_service.get_auth_for_engine() + + api_key = current_api_key or self.context.get('nexus_api_key') + oauth_info = current_oauth_info or self.context.get('nexus_oauth_info') + + engine_path = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_path) + if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): + print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}") + return + + if os.environ.get('JACKIFY_GUI_MODE') == '1': + if not self.context.get('modlist_source'): + self.context['modlist_source'] = 'identifier' + if not self.context.get('modlist_value'): + self.logger.error("modlist_value is missing in context for GUI workflow!") + return + + cmd = [engine_path, 'install', '--show-file-progress'] + modlist_value = self.context.get('modlist_value') + if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): + cmd += ['-w', modlist_value] + elif modlist_value: + cmd += ['-m', modlist_value] + elif self.context.get('machineid'): + cmd += ['-m', self.context['machineid']] + cmd += ['-o', install_dir_str, '-d', download_dir_str] + + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + if debug_mode: + cmd.append('--debug') + self.logger.info("Adding --debug flag to jackify-engine") + + original_env_values = { + 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), + 'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), + 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') + } + + try: + if oauth_info: + os.environ['NEXUS_OAUTH_INFO'] = oauth_info + from jackify.backend.services.nexus_oauth_service import NexusOAuthService + os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID + self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)") + if api_key: + os.environ['NEXUS_API_KEY'] = api_key + elif api_key: + os.environ['NEXUS_API_KEY'] = api_key + self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)") + else: + if 'NEXUS_API_KEY' in os.environ: + del os.environ['NEXUS_API_KEY'] + if 'NEXUS_OAUTH_INFO' in os.environ: + del os.environ['NEXUS_OAUTH_INFO'] + if 'NEXUS_OAUTH_CLIENT_ID' in os.environ: + del os.environ['NEXUS_OAUTH_CLIENT_ID'] + self.logger.debug(f"No Nexus auth available, cleared inherited env vars") + + os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" + self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.") + + self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.") + self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}") + self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}") + + pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) + print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}") + + from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit + success, old_limit, new_limit, message = increase_file_descriptor_limit() + if success: + self.logger.debug(f"File descriptor limit: {message}") + else: + self.logger.warning(f"File descriptor limit: {message}") + + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + clean_env = get_clean_subprocess_env() + self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir) + proc = self._current_process + + buffer = b'' + while True: + chunk = proc.stdout.read(1) + if not chunk: + break + buffer += chunk + + if chunk == b'\n': + line = buffer.decode('utf-8', errors='replace') + if '[FILE_PROGRESS]' in line: + parts = line.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + line = parts[0].rstrip() + else: + buffer = b'' + continue + print(line, end='') + buffer = b'' + elif chunk == b'\r': + line = buffer.decode('utf-8', errors='replace') + if '[FILE_PROGRESS]' in line: + parts = line.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + line = parts[0].rstrip() + else: + buffer = b'' + continue + print(line, end='') + sys.stdout.flush() + buffer = b'' + + if buffer: + line = buffer.decode('utf-8', errors='replace') + if '[FILE_PROGRESS]' in line: + parts = line.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + line = parts[0].rstrip() + else: + line = '' + if line: + print(line, end='') + + proc.wait() + self._current_process = None + if proc.returncode != 0: + print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") + self.logger.error(f"Engine exited with code {proc.returncode}.") + return + self.logger.info(f"Engine completed with code {proc.returncode}.") + except Exception as e: + error_message = str(e) + print(f"{COLOR_ERROR}Error running Jackify Install Engine: {error_message}{COLOR_RESET}\n") + self.logger.error(f"Exception running engine: {error_message}", exc_info=True) + + try: + from jackify.backend.services.resource_manager import handle_file_descriptor_error + if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']): + result = handle_file_descriptor_error(error_message, "Jackify Install Engine execution") + if result['auto_fix_success']: + print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}") + self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") + elif result['error_detected']: + print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}") + self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}") + if result['manual_instructions']: + distro = result['manual_instructions']['distribution'] + print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}") + self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution") + except Exception as resource_error: + self.logger.debug(f"Error checking for resource limit issues: {resource_error}") + + return + finally: + for key, original_value in original_env_values.items(): + current_value_in_os_environ = os.environ.get(key) + + display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'" + + if original_value is not None: + if current_value_in_os_environ != original_value: + os.environ[key] = original_value + self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.") + else: + os.environ[key] = original_value + self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.") + else: + if key in os.environ: + self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.") + del os.environ[key] + + except Exception as e: + error_message = str(e) + print(f"{COLOR_ERROR}Error during installation workflow: {error_message}{COLOR_RESET}\n") + self.logger.error(f"Exception in installation workflow: {error_message}", exc_info=True) + + try: + from jackify.backend.services.resource_manager import handle_file_descriptor_error + if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']): + result = handle_file_descriptor_error(error_message, "installation workflow") + if result['auto_fix_success']: + print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}") + self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") + elif result['error_detected']: + print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}") + self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}") + if result['manual_instructions']: + distro = result['manual_instructions']['distribution'] + print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}") + self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution") + except Exception as resource_error: + self.logger.debug(f"Error checking for resource limit issues: {resource_error}") + + return + finally: + sys.stdout = orig_stdout + sys.stderr = orig_stderr + workflow_log.close() + + elapsed = int(time.time() - start_time) + print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n") + print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n") + if self.context.get('machineid') != 'Tuxborn/Tuxborn': + print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}") + + self.logger.debug("configuration_phase: Starting post-install game detection...") + + modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini") + detected_game = None + self.logger.debug(f"configuration_phase: Looking for ModOrganizer.ini at: {modorganizer_ini}") + if os.path.isfile(modorganizer_ini): + self.logger.debug("configuration_phase: Found ModOrganizer.ini, detecting game...") + from ..handlers.modlist_handler import ModlistHandler + handler = ModlistHandler({}, steamdeck=self.steamdeck) + handler.modlist_ini = modorganizer_ini + handler.modlist_dir = install_dir_str + if handler._detect_game_variables(): + detected_game = handler.game_var_full + self.logger.debug(f"configuration_phase: Detected game: {detected_game}") + else: + self.logger.debug("configuration_phase: Failed to detect game variables") + else: + self.logger.debug("configuration_phase: ModOrganizer.ini not found") + + supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"] + is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn' + self.logger.debug(f"configuration_phase: detected_game='{detected_game}', is_tuxborn={is_tuxborn}") + self.logger.debug(f"configuration_phase: Checking condition: (detected_game in supported_games) or is_tuxborn") + self.logger.debug(f"configuration_phase: Result: {(detected_game in supported_games) or is_tuxborn}") + + if (detected_game in supported_games) or is_tuxborn: + self.logger.debug("configuration_phase: Entering Steam configuration workflow...") + shortcut_name = self.context.get('modlist_name') + self.logger.debug(f"configuration_phase: shortcut_name from context: '{shortcut_name}'") + + if is_tuxborn and not shortcut_name: + self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'") + shortcut_name = "Tuxborn Automatic Installer" + elif not shortcut_name: + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}") + raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip() + if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name: + self.logger.debug("configuration_phase: User cancelled shortcut name input") + return + shortcut_name = raw_shortcut_name + + self.logger.debug(f"configuration_phase: Final shortcut_name: '{shortcut_name}'") + + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + self.logger.debug(f"configuration_phase: is_gui_mode={is_gui_mode}") + + 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}") + configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() + self.logger.debug(f"configuration_phase: User choice: '{configure_choice}'") + + if configure_choice == 'n': + print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}") + self.logger.debug("configuration_phase: User chose to skip Steam configuration") + return + else: + self.logger.debug("configuration_phase: In GUI mode, proceeding automatically...") + + self.logger.debug("configuration_phase: Proceeding with Steam configuration...") + + if not is_gui_mode: + from jackify.backend.handlers.resolution_handler import ResolutionHandler + resolution_handler = ResolutionHandler() + + is_steamdeck = self.steamdeck if hasattr(self, 'steamdeck') else False + + selected_resolution = resolution_handler.select_resolution(steamdeck=is_steamdeck) + if selected_resolution: + self.context['resolution'] = selected_resolution + self.logger.info(f"Resolution set to: {selected_resolution}") + + self.logger.info(f"Starting Steam configuration for '{shortcut_name}'") + + mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe') + + app_id = None + use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1' + + if use_automated_prefix: + print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}") + + from ..services.automated_prefix_service import AutomatedPrefixService + prefix_service = AutomatedPrefixService() + + start_time = time.time() + + def progress_callback(message): + 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}]" + print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}") + + try: + _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( + shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck + ) + + if isinstance(result, tuple) and len(result) == 4: + if result[0] == "CONFLICT": + conflicts = result[1] + print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}") + + for i, conflict in enumerate(conflicts, 1): + print(f" {i}. Name: {conflict['name']}") + print(f" Executable: {conflict['exe']}") + print(f" Start Directory: {conflict['startdir']}") + + print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}") + print(" * Replace - Remove the existing shortcut and create a new one") + print(" * Cancel - Keep the existing shortcut and stop the installation") + print(" * Skip - Continue without creating a Steam shortcut") + + choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower() + + if choice == 'replace': + print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}") + success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str) + if success and app_id: + result = prefix_service.continue_workflow_after_conflict_resolution( + shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback + ) + if isinstance(result, tuple) and len(result) >= 3: + success, prefix_path, app_id = result[0], result[1], result[2] + else: + success, prefix_path, app_id = False, None, None + else: + success, prefix_path, app_id = False, None, None + elif choice == 'cancel': + print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}") + return + elif choice == 'skip': + print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}") + success, prefix_path, app_id = True, None, None + else: + print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}") + return + else: + success, prefix_path, app_id, last_timestamp = result + elif isinstance(result, tuple) and len(result) == 3: + if result[0] == "CONFLICT": + conflicts = result[1] + print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}") + + for i, conflict in enumerate(conflicts, 1): + print(f" {i}. Name: {conflict['name']}") + print(f" Executable: {conflict['exe']}") + print(f" Start Directory: {conflict['startdir']}") + + print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}") + print(" * Replace - Remove the existing shortcut and create a new one") + print(" * Cancel - Keep the existing shortcut and stop the installation") + print(" * Skip - Continue without creating a Steam shortcut") + + choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower() + + if choice == 'replace': + print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}") + success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str) + if success and app_id: + result = prefix_service.continue_workflow_after_conflict_resolution( + shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback + ) + if isinstance(result, tuple) and len(result) >= 3: + success, prefix_path, app_id = result[0], result[1], result[2] + else: + success, prefix_path, app_id = False, None, None + else: + success, prefix_path, app_id = False, None, None + elif choice == 'cancel': + print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}") + return + elif choice == 'skip': + print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}") + success, prefix_path, app_id = True, None, None + else: + print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}") + return + else: + success, prefix_path, app_id = result + else: + if result is True: + success, prefix_path, app_id = True, None, None + else: + success, prefix_path, app_id = False, None, None + + if success: + print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}") + if prefix_path: + print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}") + if app_id: + print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}") + print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}") + return + + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.modlist import ModlistContext + + modlist_context = ModlistContext( + name=shortcut_name, + install_dir=Path(install_dir_str), + download_dir=Path(install_dir_str) / "downloads", + game_type=self.context.get('detected_game', 'Unknown'), + nexus_api_key='', + modlist_value=self.context.get('modlist_value', ''), + modlist_source=self.context.get('modlist_source', 'identifier'), + resolution=self.context.get('resolution'), + mo2_exe_path=Path(mo2_exe_path), + skip_confirmation=True, + engine_installed=True + ) + + modlist_context.app_id = app_id + + modlist_service = ModlistService(self.system_info) + + if 'progress_callback' in locals() and progress_callback: + progress_callback("") + progress_callback("=== Configuration Phase ===") + + print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}") + self.logger.info("Running post-installation configuration phase using ModlistService") + + configuration_success = modlist_service.configure_modlist_post_steam(modlist_context) + + if configuration_success: + print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}") + self.logger.info("Post-installation configuration completed successfully") + else: + print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}") + self.logger.warning("Post-installation configuration had issues") + else: + print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}") + if detected_game: + print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}") + 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/core/modlist_operations_configuration_gui.py b/jackify/backend/core/modlist_operations_configuration_gui.py new file mode 100644 index 0000000..dd2fb9b --- /dev/null +++ b/jackify/backend/core/modlist_operations_configuration_gui.py @@ -0,0 +1,170 @@ +"""GUI configuration phase methods for ModlistInstallCLI (Mixin).""" +import logging +import os + +logger = logging.getLogger(__name__) + + +class ModlistOperationsConfigurationGUIMixin: + """Mixin providing GUI configuration phase methods.""" + + def configuration_phase_gui_mode(self, context, + progress_callback=None, + manual_steps_callback=None, + completion_callback=None): + """ + GUI-friendly configuration phase that uses callbacks instead of prompts. + + This method provides the same functionality as configuration_phase() but + integrates with GUI frontends using Qt callbacks instead of CLI prompts. + + Args: + context: Configuration context dict with modlist details + progress_callback: Called with progress messages (str) + manual_steps_callback: Called when manual steps needed (modlist_name, retry_count) + completion_callback: Called when configuration completes (success, message, modlist_name) + """ + try: + from .modlist_operations import _get_user_proton_version + + original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') + + try: + config_context = { + 'name': context.get('modlist_name', ''), + 'path': context.get('install_dir', ''), + 'mo2_exe_path': context.get('mo2_exe_path', ''), + 'modlist_value': context.get('modlist_value'), + 'modlist_source': context.get('modlist_source'), + 'resolution': context.get('resolution'), + 'skip_confirmation': True, + 'manual_steps_completed': False + } + + existing_app_id = context.get('app_id') + if existing_app_id: + config_context['appid'] = existing_app_id + + if progress_callback: + progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...") + + from jackify.backend.handlers.menu_handler import ModlistMenuHandler + from jackify.backend.handlers.config_handler import ConfigHandler + + config_handler = ConfigHandler() + modlist_menu = ModlistMenuHandler(config_handler) + + retry_count = 0 + max_retries = 3 + + while retry_count < max_retries: + if progress_callback: + progress_callback("Running modlist configuration...") + + result = modlist_menu.run_modlist_configuration_phase(config_context) + + if progress_callback: + progress_callback(f"Configuration attempt {retry_count}: {'Success' if result else 'Failed'}") + + if result: + if completion_callback: + completion_callback(True, "Configuration completed successfully!", config_context['name']) + return True + else: + retry_count += 1 + + if retry_count < max_retries: + if progress_callback: + progress_callback(f"Configuration failed on attempt {retry_count}, showing manual steps dialog...") + if manual_steps_callback: + if progress_callback: + progress_callback(f"Calling manual_steps_callback for {config_context['name']}, retry {retry_count}") + manual_steps_callback(config_context['name'], retry_count) + + config_context['manual_steps_completed'] = True + else: + if completion_callback: + completion_callback(False, "Manual steps failed after multiple attempts", config_context['name']) + return False + + if completion_callback: + completion_callback(False, "Configuration failed", config_context['name']) + return False + + else: + from jackify.backend.handlers.menu_handler import ModlistMenuHandler + from jackify.backend.handlers.config_handler import ConfigHandler + + config_handler = ConfigHandler() + modlist_menu = ModlistMenuHandler(config_handler) + + if progress_callback: + progress_callback("Creating Steam shortcut...") + + from jackify.backend.services.native_steam_service import NativeSteamService + steam_service = NativeSteamService() + + proton_version = _get_user_proton_version() + + success, app_id = steam_service.create_shortcut_with_proton( + app_name=config_context['name'], + exe_path=config_context['mo2_exe_path'], + start_dir=os.path.dirname(config_context['mo2_exe_path']), + launch_options="%command%", + tags=["Jackify"], + proton_version=proton_version + ) + + if not success or not app_id: + if completion_callback: + completion_callback(False, "Failed to create Steam shortcut", config_context['name']) + return False + + config_context['appid'] = app_id + + if progress_callback: + from jackify.shared.timing import get_timestamp + progress_callback(f"{get_timestamp()} Steam shortcut created successfully") + + if progress_callback: + progress_callback("Running modlist configuration...") + + if progress_callback: + progress_callback(f"About to call run_modlist_configuration_phase with context: {config_context}") + + result = modlist_menu.run_modlist_configuration_phase(config_context) + + if progress_callback: + progress_callback(f"run_modlist_configuration_phase returned: {result}") + + if result: + if completion_callback: + completion_callback(True, "Configuration completed successfully!", config_context['name']) + return True + else: + if progress_callback: + progress_callback("Configuration failed, manual Steam/Proton setup required") + if manual_steps_callback: + if progress_callback: + progress_callback(f"About to call manual_steps_callback for {config_context['name']}, retry 1") + manual_steps_callback(config_context['name'], 1) + if progress_callback: + progress_callback("manual_steps_callback completed") + + return True + + if completion_callback: + completion_callback(False, "Configuration failed", config_context['name']) + return False + + finally: + if original_gui_mode is not None: + os.environ['JACKIFY_GUI_MODE'] = original_gui_mode + else: + os.environ.pop('JACKIFY_GUI_MODE', None) + + except Exception as e: + error_msg = f"Configuration failed: {str(e)}" + if completion_callback: + completion_callback(False, error_msg, context.get('modlist_name', 'Unknown')) + return False diff --git a/jackify/backend/core/modlist_operations_discovery.py b/jackify/backend/core/modlist_operations_discovery.py new file mode 100644 index 0000000..6a11786 --- /dev/null +++ b/jackify/backend/core/modlist_operations_discovery.py @@ -0,0 +1,368 @@ +"""Discovery phase methods for ModlistInstallCLI (Mixin).""" +import logging +import os +from pathlib import Path +from typing import Optional, Dict + +from ..handlers.ui_colors import ( + COLOR_PROMPT, + COLOR_RESET, + COLOR_INFO, + COLOR_ERROR, + COLOR_SUCCESS, + COLOR_WARNING, + COLOR_SELECTION, +) +from ..handlers.config_handler import ConfigHandler +from jackify.backend.models.configuration import SystemInfo +from jackify.backend.services.modlist_service import ModlistService + +logger = logging.getLogger(__name__) + + +class ModlistOperationsDiscoveryMixin: + """Mixin providing modlist discovery phase methods.""" + + def run_discovery_phase(self, context_override=None) -> Optional[Dict]: + """ + Run the discovery phase: prompt for all required info, and validate inputs. + Returns a context dict with all collected info, or None if cancelled. + Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow). + """ + from .modlist_operations import get_jackify_engine_path + + self.logger.info("Starting modlist discovery phase (restored logic).") + print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}") + + if context_override: + self.context.update(context_override) + if 'resolution' in context_override: + self.context['resolution'] = context_override['resolution'] + else: + self.context = {} + + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + if self.context.get('machineid'): + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key'] + else: + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type'] + has_modlist = self.context.get('modlist_value') or self.context.get('machineid') + missing = [k for k in required_keys if not self.context.get(k)] + if is_gui_mode: + if missing or not has_modlist: + self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}") + if not has_modlist: + self.logger.error("Missing modlist_value or machineid for GUI workflow.") + self.logger.error("This workflow must be fully non-interactive. Please report this as a bug if you see this message.") + return None + self.logger.info("All required context present in GUI mode, skipping prompts.") + return self.context + + engine_executable = get_jackify_engine_path() + self.logger.debug(f"Engine executable path: {engine_executable}") + + if not os.path.exists(engine_executable): + print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") + print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") + return None + + engine_dir = os.path.dirname(engine_executable) + + if 'machineid' not in self.context: + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu") + source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() + self.logger.debug(f"User selected modlist source option: {source_choice}") + + if source_choice == '1': + self.context['modlist_source_type'] = 'online_list' + print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}") + try: + 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 + system_info = SystemInfo(is_steamdeck=is_steamdeck) + modlist_service = ModlistService(system_info) + + categories = [ + ("Skyrim", "skyrim"), + ("Fallout 4", "fallout4"), + ("Fallout New Vegas", "falloutnv"), + ("Oblivion", "oblivion"), + ("Starfield", "starfield"), + ("Oblivion Remastered", "oblivion_remastered"), + ("Other Games", "other") + ] + grouped_modlists = {} + for label, key in categories: + grouped_modlists[label] = modlist_service.list_modlists(game_type=key) + + selected_modlist_info = None + while not selected_modlist_info: + print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}") + category_display_map = {} + display_idx = 1 + for label, _ in categories: + modlists = grouped_modlists[label] + if label == "Oblivion Remastered" or modlists: + print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {label} ({len(modlists)} modlists)") + category_display_map[str(display_idx)] = label + display_idx += 1 + if display_idx == 1: + print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}") + return None + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel") + game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip() + if game_cat_choice == '0': + self.logger.info("User cancelled game category selection.") + return None + actual_label = category_display_map.get(game_cat_choice) + if not actual_label: + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + continue + modlist_group_for_game = sorted(grouped_modlists[actual_label], key=lambda x: x.id.lower()) + print(f"\n{COLOR_SUCCESS}Available Modlists for {actual_label}:{COLOR_RESET}") + for idx, m_detail in enumerate(modlist_group_for_game, 1): + if actual_label == "Other Games": + print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail.id} ({m_detail.game})") + else: + print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail.id}") + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories") + while True: + mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip() + if mod_choice_idx_str == '0': + break + if mod_choice_idx_str.isdigit(): + mod_idx = int(mod_choice_idx_str) - 1 + if 0 <= mod_idx < len(modlist_group_for_game): + selected_modlist_info = { + 'id': modlist_group_for_game[mod_idx].id, + 'game': modlist_group_for_game[mod_idx].game, + 'machine_url': getattr(modlist_group_for_game[mod_idx], 'machine_url', modlist_group_for_game[mod_idx].id) + } + self.context['modlist_source'] = 'identifier' + self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id']) + self.context['modlist_game'] = selected_modlist_info['game'] + self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1] + self.logger.info(f"User selected online modlist: {selected_modlist_info}") + break + else: + print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") + if selected_modlist_info: + break + except Exception as e: + self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) + print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}") + return None + + elif source_choice == '2': + self.context['modlist_source_type'] = 'local_file' + print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}") + modlist_path = self.menu_handler.get_existing_file_path( + prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", + extension_filter=".wabbajack", + no_header=True + ) + if modlist_path is None: + self.logger.info("User cancelled .wabbajack file selection.") + print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}") + return None + + self.context['modlist_source'] = 'path' + self.context['modlist_value'] = str(modlist_path) + self.context['modlist_name_suggestion'] = Path(modlist_path).stem + self.logger.info(f"User selected local .wabbajack file: {modlist_path}") + + elif source_choice == '0': + self.logger.info("User cancelled modlist source selection.") + print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}") + return None + else: + self.logger.warning(f"Invalid modlist source choice: {source_choice}") + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + return self.run_discovery_phase() + + if 'modlist_name' not in self.context or not self.context['modlist_name']: + default_name = self.context.get('modlist_name_suggestion', 'MyModlist') + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}") + print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}") + modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() + if not modlist_name_input: + modlist_name = default_name + elif modlist_name_input.lower() == 'q': + self.logger.info("User cancelled at modlist name prompt.") + return None + else: + modlist_name = modlist_name_input + self.context['modlist_name'] = modlist_name + self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}") + + if 'install_dir' not in self.context: + config_handler = ConfigHandler() + base_install_dir = Path(config_handler.get_modlist_install_base_dir()) + default_install_dir = base_install_dir / self.context['modlist_name'] + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}") + print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}") + install_dir_path = self.menu_handler.get_directory_path( + prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", + default_path=default_install_dir, + create_if_missing=True, + no_header=True + ) + if install_dir_path is None: + self.logger.info("User cancelled at install directory prompt.") + return None + self.context['install_dir'] = install_dir_path + self.logger.debug(f"Install directory context set to: {self.context['install_dir']}") + + if 'download_dir' not in self.context: + config_handler = ConfigHandler() + base_download_dir = Path(config_handler.get_modlist_downloads_base_dir()) + default_download_dir = base_download_dir / self.context['modlist_name'] + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}") + print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}") + download_dir_path = self.menu_handler.get_directory_path( + prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", + default_path=default_download_dir, + create_if_missing=True, + no_header=True + ) + if download_dir_path is None: + self.logger.info("User cancelled at download directory prompt.") + return None + self.context['download_dir'] = download_dir_path + self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") + + if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'): + from jackify.backend.services.nexus_auth_service import NexusAuthService + auth_service = NexusAuthService() + authenticated, method, username = auth_service.get_auth_status() + + if authenticated: + if method == 'oauth': + print("\n" + "-" * 28) + print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}") + if username: + print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}") + elif method == 'api_key': + print("\n" + "-" * 28) + print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}") + + api_key, oauth_info = auth_service.get_auth_for_engine() + if api_key: + self.context['nexus_api_key'] = api_key + self.context['nexus_oauth_info'] = oauth_info + else: + print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}") + authenticated = False + + if not authenticated: + print("\n" + "-" * 28) + print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}") + print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}") + print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}") + + authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower() + + if authorize in ('', 'y', 'yes'): + print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}") + print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}") + print(f"{COLOR_INFO}Note: You may see a security warning about a self-signed certificate.{COLOR_RESET}") + print(f"{COLOR_INFO}This is normal - click 'Advanced' and 'Proceed' to continue.{COLOR_RESET}") + + def show_message(msg): + print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}") + + success = auth_service.authorize_oauth(show_browser_message_callback=show_message) + + if success: + print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}") + _, _, username = auth_service.get_auth_status() + if username: + print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}") + + api_key, oauth_info = auth_service.get_auth_for_engine() + if api_key: + self.context['nexus_api_key'] = api_key + self.context['nexus_oauth_info'] = oauth_info + else: + print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}") + return None + else: + print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}") + return None + else: + print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}") + self.logger.info("User declined Nexus authorization.") + return None + self.logger.debug("Nexus authentication configured for engine.") + + self._display_summary() + + game_type = None + game_name = None + if self.context.get('modlist_source_type') == 'online_list': + game_name = self.context.get('modlist_game', '') + game_mapping = { + 'skyrim special edition': 'skyrim', + 'skyrim': 'skyrim', + 'fallout 4': 'fallout4', + 'fallout new vegas': 'falloutnv', + 'oblivion': 'oblivion', + 'starfield': 'starfield', + 'oblivion remastered': 'oblivion_remastered' + } + game_type = game_mapping.get(game_name.lower()) + if not game_type: + game_type = 'unknown' + elif self.context.get('modlist_source_type') == 'local_file': + wabbajack_path = self.context.get('modlist_value') + if wabbajack_path: + result = self.wabbajack_parser.parse_wabbajack_game_type(Path(wabbajack_path)) + if result: + if isinstance(result, tuple): + game_type, raw_game_type = result + game_name = raw_game_type if game_type == 'unknown' else game_type + else: + game_type = result + game_name = game_type + + if game_type and not self.wabbajack_parser.is_supported_game(game_type): + print("\n" + "─" * 46) + print(" Game Support Notice\n") + print(f"You are about to install a modlist for: {game_name or 'Unknown'}\n") + print("Jackify does not provide post-install configuration for this game.") + print("You can still install and use the modlist, but you will need to manually set up Steam shortcuts and other steps after installation.\n") + print("Press [Enter] to continue, or [Ctrl+C] to cancel.") + print("─" * 46 + "\n") + try: + input() + except KeyboardInterrupt: + print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") + return None + + if self.context.get('skip_confirmation'): + confirm = 'y' + else: + confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower() + if confirm != 'y': + self.logger.info("User cancelled at final confirmation.") + print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") + return None + + self.logger.info("Discovery phase complete.") + context_for_logging = self.context.copy() + if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None: + context_for_logging['nexus_api_key'] = "[REDACTED]" + self.logger.info(f"Context: {context_for_logging}") + return self.context diff --git a/jackify/backend/core/modlist_operations_game_detection.py b/jackify/backend/core/modlist_operations_game_detection.py new file mode 100644 index 0000000..a76d814 --- /dev/null +++ b/jackify/backend/core/modlist_operations_game_detection.py @@ -0,0 +1,67 @@ +"""Game detection methods for ModlistInstallCLI (Mixin).""" +import logging +from pathlib import Path +from typing import Optional, Dict + +logger = logging.getLogger(__name__) + + +class ModlistOperationsGameDetectionMixin: + """Mixin providing game type detection methods.""" + + def detect_game_type(self, modlist_info: Optional[Dict] = None, wabbajack_file_path: Optional[Path] = None) -> Optional[str]: + """ + Detect the game type for a modlist installation. + + Args: + modlist_info: Dictionary containing modlist information (for online modlists) + wabbajack_file_path: Path to .wabbajack file (for local files) + + Returns: + Jackify game type string or None if detection fails + """ + if wabbajack_file_path: + self.logger.info(f"Detecting game type from .wabbajack file: {wabbajack_file_path}") + game_type = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_file_path) + if game_type: + self.logger.info(f"Detected game type from .wabbajack file: {game_type}") + return game_type + else: + self.logger.warning(f"Could not detect game type from .wabbajack file: {wabbajack_file_path}") + return None + elif modlist_info and 'game' in modlist_info: + game_name = modlist_info['game'].lower() + self.logger.info(f"Detecting game type from modlist info: {game_name}") + + game_mapping = { + 'skyrim special edition': 'skyrim', + 'skyrim': 'skyrim', + 'fallout 4': 'fallout4', + 'fallout new vegas': 'falloutnv', + 'oblivion': 'oblivion', + 'starfield': 'starfield', + 'oblivion remastered': 'oblivion_remastered' + } + + game_type = game_mapping.get(game_name) + if game_type: + self.logger.info(f"Mapped game name '{game_name}' to game type: {game_type}") + return game_type + else: + self.logger.warning(f"Unknown game name in modlist info: {game_name}") + return None + else: + self.logger.warning("No modlist info or .wabbajack file path provided for game detection") + return None + + def check_game_support(self, game_type: str) -> bool: + """ + Check if a game type is supported by Jackify's post-install configuration. + + Args: + game_type: Jackify game type string + + Returns: + True if the game is supported, False otherwise + """ + return self.wabbajack_parser.is_supported_game(game_type) diff --git a/jackify/backend/core/modlist_operations_nexus.py b/jackify/backend/core/modlist_operations_nexus.py new file mode 100644 index 0000000..8214cab --- /dev/null +++ b/jackify/backend/core/modlist_operations_nexus.py @@ -0,0 +1,99 @@ +"""Nexus and engine methods for ModlistInstallCLI (Mixin).""" +import logging +import os +import re +import subprocess +from pathlib import Path +from typing import Optional + +from ..handlers.ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET + +logger = logging.getLogger(__name__) + + +class ModlistOperationsNexusMixin: + """Mixin providing Nexus API and engine methods.""" + + def _get_nexus_api_key(self) -> Optional[str]: + return self.context.get('nexus_api_key') + + def get_all_modlists_from_engine(self, game_type=None): + """ + Call the Jackify engine with 'list-modlists' and return a list of modlist dicts. + Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags. + + Args: + game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas") + """ + from .modlist_operations import get_jackify_engine_path + + engine_executable = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_executable) + if not os.path.exists(engine_executable): + print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") + print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") + return [] + env = os.environ.copy() + env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" + command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url'] + + if game_type: + command.extend(['--game', game_type]) + try: + result = subprocess.run( + command, + capture_output=True, text=True, check=True, + env=env, cwd=engine_dir + ) + lines = result.stdout.splitlines() + modlists = [] + for line in lines: + line = line.strip() + if not line or line.startswith('Loading') or line.startswith('Loaded'): + continue + + status_down = '[DOWN]' in line + status_nsfw = '[NSFW]' in line + clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() + parts = clean_line.rsplit(' - ', 3) + if len(parts) != 4: + continue + + modlist_name = parts[0].strip() + game_name = parts[1].strip() + sizes_str = parts[2].strip() + machine_url = parts[3].strip() + size_parts = sizes_str.split('|') + if len(size_parts) != 3: + continue + + download_size = size_parts[0].strip() + install_size = size_parts[1].strip() + total_size = size_parts[2].strip() + if not modlist_name or not game_name or not machine_url: + continue + + modlists.append({ + 'id': modlist_name, + 'name': modlist_name, + 'game': game_name, + 'download_size': download_size, + 'install_size': install_size, + 'total_size': total_size, + 'machine_url': machine_url, + 'status_down': status_down, + 'status_nsfw': status_nsfw + }) + return modlists + except subprocess.CalledProcessError as e: + self.logger.error(f"list-modlists failed. Code: {e.returncode}") + if e.stdout: + self.logger.error(f"Engine stdout:\n{e.stdout}") + if e.stderr: + self.logger.error(f"Engine stderr:\n{e.stderr}") + print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}") + return [] + except Exception as e: + self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) + print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}") + return [] diff --git a/jackify/backend/handlers/completers.py b/jackify/backend/handlers/completers.py index a31bd28..f8d110b 100644 --- a/jackify/backend/handlers/completers.py +++ b/jackify/backend/handlers/completers.py @@ -5,17 +5,10 @@ Reusable tab completion functions for Jackify CLI, including bash-like path comp import os import readline -import logging # Added for debugging +import logging -# Get a logger for this module -completer_logger = logging.getLogger(__name__) # Logger will be named src.modules.completers - -# Set level to DEBUG for this logger to ensure all debug messages are generated. -# These messages will be handled by handlers configured in the main application (e.g., via LoggingHandler). +completer_logger = logging.getLogger(__name__) completer_logger.setLevel(logging.INFO) - -# Ensure messages DO NOT propagate to the root logger's console handler by default. -# A dedicated file handler will be added in jackify-cli.py. completer_logger.propagate = False # IMPORTANT: Do NOT include '/' in the completer delimiters! @@ -68,7 +61,6 @@ def path_completer(text, state): final_match_strings_for_readline = [] text_dir_part = os.path.dirname(text) - # If text is a directory with trailing slash, use it as the base for completions if os.path.isdir(text) and text.endswith(os.sep): base_path = text elif os.path.isdir(text): diff --git a/jackify/backend/handlers/config_handler.py b/jackify/backend/handlers/config_handler.py index 0326401..6968625 100644 --- a/jackify/backend/handlers/config_handler.py +++ b/jackify/backend/handlers/config_handler.py @@ -11,16 +11,17 @@ import json import logging import shutil import re -import base64 -import hashlib from pathlib import Path from typing import Optional -# Initialize logger +from .config_handler_encryption import ConfigEncryptionMixin +from .config_handler_directories import ConfigDirectoriesMixin +from .config_handler_proton import ConfigProtonMixin + logger = logging.getLogger(__name__) -class ConfigHandler: +class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonMixin): """ Handles application configuration and settings Singleton pattern ensures all code shares the same instance @@ -60,7 +61,7 @@ class ConfigHandler: "game_proton_path": None, # Proton version for game shortcuts (can be any Proton 9+), separate from install proton "proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect "proton_version": None, # Install Proton version name - None means auto-detect - "steam_restart_strategy": "jackify", # "jackify" (default) or "nak_simple" + "steam_restart_strategy": "jackify", # "jackify" (default) or "simple" "window_width": None, # Saved window width (None = use dynamic sizing) "window_height": None # Saved window height (None = use dynamic sizing) } @@ -214,8 +215,8 @@ class ConfigHandler: config.update(saved_config) return config except Exception as e: - # Don't use logger here - can cause recursion if logger tries to access config - print(f"Warning: Error reading configuration from disk: {e}", file=sys.stderr) + # Use logger.warning instead of print to stderr - logger is initialized before config access + logger.warning(f"Error reading configuration from disk: {e}") return self.settings.copy() def reload_config(self): @@ -305,224 +306,8 @@ class ConfigHandler: def get_protontricks_path(self): """Get the path to protontricks executable""" - return self.settings.get("protontricks_path") - - def _get_encryption_key(self) -> bytes: - """ - Generate encryption key for API key storage using same method as OAuth tokens + return self.settings.get("protontricks_path") - Returns: - Fernet-compatible encryption key - """ - import socket - import getpass - - try: - hostname = socket.gethostname() - username = getpass.getuser() - - # Try to get machine ID - machine_id = None - try: - with open('/etc/machine-id', 'r') as f: - machine_id = f.read().strip() - except: - try: - with open('/var/lib/dbus/machine-id', 'r') as f: - machine_id = f.read().strip() - except: - pass - - if machine_id: - key_material = f"{hostname}:{username}:{machine_id}:jackify" - else: - key_material = f"{hostname}:{username}:jackify" - - except Exception as e: - logger.warning(f"Failed to get machine info for encryption: {e}") - key_material = "jackify:default:key" - - # Generate Fernet-compatible key - key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest() - return base64.urlsafe_b64encode(key_bytes) - - def _encrypt_api_key(self, api_key: str) -> str: - """ - Encrypt API key using AES-GCM - - Args: - api_key: Plain text API key - - Returns: - Encrypted API key string - """ - try: - from Crypto.Cipher import AES - from Crypto.Random import get_random_bytes - - # Derive 32-byte AES key - key = base64.urlsafe_b64decode(self._get_encryption_key()) - - # Generate random nonce - nonce = get_random_bytes(12) - - # Encrypt with AES-GCM - cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) - ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8')) - - # Combine and encode - combined = nonce + ciphertext + tag - return base64.b64encode(combined).decode('utf-8') - - except ImportError: - # Fallback to base64 if pycryptodome not available - logger.warning("pycryptodome not available, using base64 encoding (less secure)") - return base64.b64encode(api_key.encode('utf-8')).decode('utf-8') - except Exception as e: - logger.error(f"Error encrypting API key: {e}") - return "" - - def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]: - """ - Decrypt API key using AES-GCM - - Args: - encrypted_key: Encrypted API key string - - Returns: - Decrypted API key or None on failure - """ - try: - from Crypto.Cipher import AES - - # Check if MODE_GCM is available (pycryptodome has it, old pycrypto doesn't) - if not hasattr(AES, 'MODE_GCM'): - # Fallback to base64 decode if old pycrypto is installed - try: - return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8') - except: - return None - - # Derive 32-byte AES key - key = base64.urlsafe_b64decode(self._get_encryption_key()) - - # Decode and split - combined = base64.b64decode(encrypted_key.encode('utf-8')) - nonce = combined[:12] - tag = combined[-16:] - ciphertext = combined[12:-16] - - # Decrypt with AES-GCM - cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) - plaintext = cipher.decrypt_and_verify(ciphertext, tag) - - return plaintext.decode('utf-8') - - except ImportError: - # Fallback to base64 decode - try: - return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8') - except: - return None - except AttributeError: - # Old pycrypto doesn't have MODE_GCM, fallback to base64 - try: - return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8') - except: - return None - except Exception as e: - # Might be old base64-only format, try decoding - try: - return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8') - except: - logger.error(f"Error decrypting API key: {e}") - return None - - def save_api_key(self, api_key): - """ - Save Nexus API key with Fernet encryption - - Args: - api_key (str): Plain text API key - - Returns: - bool: True if saved successfully, False otherwise - """ - try: - if api_key: - # Encrypt the API key using Fernet - encrypted_key = self._encrypt_api_key(api_key) - if not encrypted_key: - logger.error("Failed to encrypt API key") - return False - - self.settings["nexus_api_key"] = encrypted_key - logger.debug("API key encrypted and saved successfully") - else: - # Clear the API key if empty - self.settings["nexus_api_key"] = None - logger.debug("API key cleared") - - result = self.save_config() - - # Set restrictive permissions on config file - if result: - try: - os.chmod(self.config_file, 0o600) - except Exception as e: - logger.warning(f"Could not set restrictive permissions on config: {e}") - - return result - - except Exception as e: - logger.error(f"Error saving API key: {e}") - return False - - def get_api_key(self): - """ - Retrieve and decrypt the saved Nexus API key. - Always reads fresh from disk. - - Returns: - str: Decrypted API key or None if not saved - """ - try: - config = self._read_config_from_disk() - encrypted_key = config.get("nexus_api_key") - if encrypted_key: - # Decrypt the API key - decrypted_key = self._decrypt_api_key(encrypted_key) - return decrypted_key - return None - except Exception as e: - logger.error(f"Error retrieving API key: {e}") - return None - - def has_saved_api_key(self): - """ - Check if an API key is saved in configuration. - Always reads fresh from disk. - - Returns: - bool: True if API key exists, False otherwise - """ - config = self._read_config_from_disk() - return config.get("nexus_api_key") is not None - - def clear_api_key(self): - """ - Clear the saved API key from configuration - - Returns: - bool: True if cleared successfully, False otherwise - """ - try: - self.settings["nexus_api_key"] = None - logger.debug("API key cleared from configuration") - return self.save_config() - except Exception as e: - logger.error(f"Error clearing API key: {e}") - return False def save_resolution(self, resolution): """ Save resolution setting to configuration @@ -589,262 +374,6 @@ class ConfigHandler: logger.error(f"Error clearing resolution: {e}") return False - def set_default_install_parent_dir(self, path): - """ - Save the parent directory for modlist installations - - Args: - path (str): Parent directory path to save - - Returns: - bool: True if saved successfully, False otherwise - """ - try: - if path and os.path.exists(path): - self.settings["default_install_parent_dir"] = path - logger.debug(f"Default install parent directory saved: {path}") - return self.save_config() - else: - logger.warning(f"Invalid or non-existent path for install parent directory: {path}") - return False - except Exception as e: - logger.error(f"Error saving install parent directory: {e}") - return False - - def get_default_install_parent_dir(self): - """ - Retrieve the saved parent directory for modlist installations - - Returns: - str: Saved parent directory path or None if not saved - """ - try: - path = self.settings.get("default_install_parent_dir") - if path and os.path.exists(path): - logger.debug(f"Retrieved default install parent directory: {path}") - return path - else: - logger.debug("No valid default install parent directory found") - return None - except Exception as e: - logger.error(f"Error retrieving install parent directory: {e}") - return None - - def set_default_download_parent_dir(self, path): - """ - Save the parent directory for downloads - - Args: - path (str): Parent directory path to save - - Returns: - bool: True if saved successfully, False otherwise - """ - try: - if path and os.path.exists(path): - self.settings["default_download_parent_dir"] = path - logger.debug(f"Default download parent directory saved: {path}") - return self.save_config() - else: - logger.warning(f"Invalid or non-existent path for download parent directory: {path}") - return False - except Exception as e: - logger.error(f"Error saving download parent directory: {e}") - return False - - def get_default_download_parent_dir(self): - """ - Retrieve the saved parent directory for downloads - - Returns: - str: Saved parent directory path or None if not saved - """ - try: - path = self.settings.get("default_download_parent_dir") - if path and os.path.exists(path): - logger.debug(f"Retrieved default download parent directory: {path}") - return path - else: - logger.debug("No valid default download parent directory found") - return None - except Exception as e: - logger.error(f"Error retrieving download parent directory: {e}") - return None - - def has_saved_install_parent_dir(self): - """ - Check if a default install parent directory is saved in configuration - - Returns: - bool: True if directory exists and is valid, False otherwise - """ - path = self.settings.get("default_install_parent_dir") - return path is not None and os.path.exists(path) - - def has_saved_download_parent_dir(self): - """ - Check if a default download parent directory is saved in configuration - - Returns: - bool: True if directory exists and is valid, False otherwise - """ - path = self.settings.get("default_download_parent_dir") - return path is not None and os.path.exists(path) - - def get_modlist_install_base_dir(self): - """ - Get the configurable base directory for modlist installations - - Returns: - str: Base directory path for modlist installations - """ - return self.settings.get("modlist_install_base_dir", os.path.expanduser("~/Games")) - - def set_modlist_install_base_dir(self, path): - """ - Set the configurable base directory for modlist installations - - Args: - path (str): Base directory path to save - - Returns: - bool: True if saved successfully, False otherwise - """ - try: - if path: - self.settings["modlist_install_base_dir"] = path - logger.debug(f"Modlist install base directory saved: {path}") - return self.save_config() - else: - logger.warning("Invalid path for modlist install base directory") - return False - except Exception as e: - logger.error(f"Error saving modlist install base directory: {e}") - return False - - def get_modlist_downloads_base_dir(self): - """ - Get the configurable base directory for modlist downloads - - Returns: - str: Base directory path for modlist downloads - """ - return self.settings.get("modlist_downloads_base_dir", os.path.expanduser("~/Games/Modlist_Downloads")) - - def set_modlist_downloads_base_dir(self, path): - """ - Set the configurable base directory for modlist downloads - - Args: - path (str): Base directory path to save - - Returns: - bool: True if saved successfully, False otherwise - """ - try: - if path: - self.settings["modlist_downloads_base_dir"] = path - logger.debug(f"Modlist downloads base directory saved: {path}") - return self.save_config() - else: - logger.warning("Invalid path for modlist downloads base directory") - return False - except Exception as e: - logger.error(f"Error saving modlist downloads base directory: {e}") - return False - def get_proton_path(self): - """ - Retrieve the saved Install Proton path from configuration (for jackify-engine). - Always reads fresh from disk. - - Returns: - str: Saved Install Proton path, or None if not set (indicates auto-detect mode) - """ - try: - config = self._read_config_from_disk() - proton_path = config.get("proton_path") - # Return None if missing/None/empty string - don't default to "auto" - if not proton_path: - logger.debug("proton_path not set in config - will use auto-detection") - return None - logger.debug(f"Retrieved fresh install proton_path from config: {proton_path}") - return proton_path - except Exception as e: - logger.error(f"Error retrieving install proton_path: {e}") - return None - - def get_game_proton_path(self): - """ - Retrieve the saved Game Proton path from configuration (for game shortcuts). - Falls back to install Proton path if game Proton not set. - Always reads fresh from disk. - - Returns: - str: Saved Game Proton path, Install Proton path, or None if not saved (indicates auto-detect mode) - """ - try: - config = self._read_config_from_disk() - game_proton_path = config.get("game_proton_path") - - # If game proton not set or set to same_as_install, use install proton - if not game_proton_path or game_proton_path == "same_as_install": - game_proton_path = config.get("proton_path") # Returns None if not set - - # Return None if missing/None/empty string - if not game_proton_path: - logger.debug("game_proton_path not set in config - will use auto-detection") - return None - logger.debug(f"Retrieved fresh game proton_path from config: {game_proton_path}") - return game_proton_path - except Exception as e: - logger.error(f"Error retrieving game proton_path: {e}") - return "auto" - - def get_proton_version(self): - """ - Retrieve the saved Proton version from configuration. - Always reads fresh from disk. - - Returns: - str: Saved Proton version or 'auto' if not saved - """ - try: - config = self._read_config_from_disk() - proton_version = config.get("proton_version", "auto") - logger.debug(f"Retrieved fresh proton_version from config: {proton_version}") - return proton_version - except Exception as e: - logger.error(f"Error retrieving proton_version: {e}") - return "auto" - - def _auto_detect_proton(self): - """Auto-detect and set best Proton version (includes GE-Proton and Valve Proton)""" - try: - from .wine_utils import WineUtils - best_proton = WineUtils.select_best_proton() - - if best_proton: - self.settings["proton_path"] = str(best_proton['path']) - self.settings["proton_version"] = best_proton['name'] - proton_type = best_proton.get('type', 'Unknown') - logger.info(f"Auto-detected Proton: {best_proton['name']} ({proton_type})") - self.save_config() - else: - # Set proton_path to None (will appear as null in JSON) so jackify-engine doesn't get invalid path - # Code will auto-detect on each run when proton_path is None - self.settings["proton_path"] = None - self.settings["proton_version"] = None - logger.warning("No compatible Proton versions found - proton_path set to null in config.json") - logger.info("Jackify will auto-detect Proton on each run until a valid version is found") - self.save_config() - - except Exception as e: - logger.error(f"Failed to auto-detect Proton: {e}") - # Set proton_path to None (will appear as null in JSON) - self.settings["proton_path"] = None - self.settings["proton_version"] = None - logger.warning("proton_path set to null in config.json due to auto-detection failure") - self.save_config() \ No newline at end of file diff --git a/jackify/backend/handlers/config_handler_directories.py b/jackify/backend/handlers/config_handler_directories.py new file mode 100644 index 0000000..8463db6 --- /dev/null +++ b/jackify/backend/handlers/config_handler_directories.py @@ -0,0 +1,108 @@ +""" +Config handler directory paths: install/download parent and modlist base dirs. +""" + +import os +import logging + +logger = logging.getLogger(__name__) + + +class ConfigDirectoriesMixin: + """Mixin providing directory path getters/setters for ConfigHandler.""" + + def set_default_install_parent_dir(self, path): + """Save the parent directory for modlist installations.""" + try: + if path and os.path.exists(path): + self.settings["default_install_parent_dir"] = path + logger.debug("Default install parent directory saved: %s", path) + return self.save_config() + logger.warning("Invalid or non-existent path for install parent directory: %s", path) + return False + except Exception as e: + logger.error("Error saving install parent directory: %s", e) + return False + + def get_default_install_parent_dir(self): + """Retrieve the saved parent directory for modlist installations.""" + try: + path = self.settings.get("default_install_parent_dir") + if path and os.path.exists(path): + logger.debug("Retrieved default install parent directory: %s", path) + return path + logger.debug("No valid default install parent directory found") + return None + except Exception as e: + logger.error("Error retrieving install parent directory: %s", e) + return None + + def set_default_download_parent_dir(self, path): + """Save the parent directory for downloads.""" + try: + if path and os.path.exists(path): + self.settings["default_download_parent_dir"] = path + logger.debug("Default download parent directory saved: %s", path) + return self.save_config() + logger.warning("Invalid or non-existent path for download parent directory: %s", path) + return False + except Exception as e: + logger.error("Error saving download parent directory: %s", e) + return False + + def get_default_download_parent_dir(self): + """Retrieve the saved parent directory for downloads.""" + try: + path = self.settings.get("default_download_parent_dir") + if path and os.path.exists(path): + logger.debug("Retrieved default download parent directory: %s", path) + return path + logger.debug("No valid default download parent directory found") + return None + except Exception as e: + logger.error("Error retrieving download parent directory: %s", e) + return None + + def has_saved_install_parent_dir(self): + """Check if a default install parent directory is saved and valid.""" + path = self.settings.get("default_install_parent_dir") + return path is not None and os.path.exists(path) + + def has_saved_download_parent_dir(self): + """Check if a default download parent directory is saved and valid.""" + path = self.settings.get("default_download_parent_dir") + return path is not None and os.path.exists(path) + + def get_modlist_install_base_dir(self): + """Get the configurable base directory for modlist installations.""" + return self.settings.get("modlist_install_base_dir", os.path.expanduser("~/Games")) + + def set_modlist_install_base_dir(self, path): + """Set the configurable base directory for modlist installations.""" + try: + if path: + self.settings["modlist_install_base_dir"] = path + logger.debug("Modlist install base directory saved: %s", path) + return self.save_config() + logger.warning("Invalid path for modlist install base directory") + return False + except Exception as e: + logger.error("Error saving modlist install base directory: %s", e) + return False + + def get_modlist_downloads_base_dir(self): + """Get the configurable base directory for modlist downloads.""" + return self.settings.get("modlist_downloads_base_dir", os.path.expanduser("~/Games/Modlist_Downloads")) + + def set_modlist_downloads_base_dir(self, path): + """Set the configurable base directory for modlist downloads.""" + try: + if path: + self.settings["modlist_downloads_base_dir"] = path + logger.debug("Modlist downloads base directory saved: %s", path) + return self.save_config() + logger.warning("Invalid path for modlist downloads base directory") + return False + except Exception as e: + logger.error("Error saving modlist downloads base directory: %s", e) + return False diff --git a/jackify/backend/handlers/config_handler_encryption.py b/jackify/backend/handlers/config_handler_encryption.py new file mode 100644 index 0000000..6f15955 --- /dev/null +++ b/jackify/backend/handlers/config_handler_encryption.py @@ -0,0 +1,137 @@ +""" +Config handler API key encryption and storage. +""" + +import os +import base64 +import hashlib +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class ConfigEncryptionMixin: + """Mixin providing encryption and API key storage for ConfigHandler.""" + + def _get_encryption_key(self) -> bytes: + """Generate Fernet-compatible encryption key for API key storage.""" + import socket + import getpass + try: + hostname = socket.gethostname() + username = getpass.getuser() + machine_id = None + try: + with open('/etc/machine-id', 'r') as f: + machine_id = f.read().strip() + except Exception: + try: + with open('/var/lib/dbus/machine-id', 'r') as f: + machine_id = f.read().strip() + except Exception: + pass + key_material = f"{hostname}:{username}:{machine_id}:jackify" if machine_id else f"{hostname}:{username}:jackify" + except Exception as e: + logger.warning("Failed to get machine info for encryption: %s", e) + key_material = "jackify:default:key" + key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest() + return base64.urlsafe_b64encode(key_bytes) + + def _encrypt_api_key(self, api_key: str) -> str: + """Encrypt API key using AES-GCM.""" + try: + from Crypto.Cipher import AES + from Crypto.Random import get_random_bytes + key = base64.urlsafe_b64decode(self._get_encryption_key()) + nonce = get_random_bytes(12) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8')) + combined = nonce + ciphertext + tag + return base64.b64encode(combined).decode('utf-8') + except ImportError: + logger.warning("pycryptodome not available, using base64 encoding (less secure)") + return base64.b64encode(api_key.encode('utf-8')).decode('utf-8') + except Exception as e: + logger.error("Error encrypting API key: %s", e) + return "" + + def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]: + """Decrypt API key using AES-GCM.""" + try: + from Crypto.Cipher import AES + if not hasattr(AES, 'MODE_GCM'): + try: + return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8') + except Exception: + return None + key = base64.urlsafe_b64decode(self._get_encryption_key()) + combined = base64.b64decode(encrypted_key.encode('utf-8')) + nonce = combined[:12] + tag = combined[-16:] + ciphertext = combined[12:-16] + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + plaintext = cipher.decrypt_and_verify(ciphertext, tag) + return plaintext.decode('utf-8') + except ImportError: + try: + return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8') + except Exception: + return None + except (AttributeError, Exception): + try: + return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8') + except Exception as e: + logger.error("Error decrypting API key: %s", e) + return None + + def save_api_key(self, api_key): + """Save Nexus API key with encryption.""" + try: + if api_key: + encrypted_key = self._encrypt_api_key(api_key) + if not encrypted_key: + logger.error("Failed to encrypt API key") + return False + self.settings["nexus_api_key"] = encrypted_key + logger.debug("API key encrypted and saved successfully") + else: + self.settings["nexus_api_key"] = None + logger.debug("API key cleared") + result = self.save_config() + if result: + try: + os.chmod(self.config_file, 0o600) + except Exception as e: + logger.warning("Could not set restrictive permissions on config: %s", e) + return result + except Exception as e: + logger.error("Error saving API key: %s", e) + return False + + def get_api_key(self): + """Retrieve and decrypt the saved Nexus API key. Always reads fresh from disk.""" + try: + config = self._read_config_from_disk() + encrypted_key = config.get("nexus_api_key") + if encrypted_key: + return self._decrypt_api_key(encrypted_key) + return None + except Exception as e: + logger.error("Error retrieving API key: %s", e) + return None + + def has_saved_api_key(self): + """Check if an API key is saved in configuration. Always reads fresh from disk.""" + config = self._read_config_from_disk() + return config.get("nexus_api_key") is not None + + def clear_api_key(self): + """Clear the saved API key from configuration.""" + try: + self.settings["nexus_api_key"] = None + logger.debug("API key cleared from configuration") + return self.save_config() + except Exception as e: + logger.error("Error clearing API key: %s", e) + return False diff --git a/jackify/backend/handlers/config_handler_proton.py b/jackify/backend/handlers/config_handler_proton.py new file mode 100644 index 0000000..872ce38 --- /dev/null +++ b/jackify/backend/handlers/config_handler_proton.py @@ -0,0 +1,76 @@ +""" +Config handler Proton path and version getters and auto-detect. +""" + +import logging + +logger = logging.getLogger(__name__) + + +class ConfigProtonMixin: + """Mixin providing Proton path/version and auto-detect for ConfigHandler.""" + + def get_proton_path(self): + """Retrieve the saved Install Proton path. Always reads fresh from disk.""" + try: + config = self._read_config_from_disk() + proton_path = config.get("proton_path") + if not proton_path: + logger.debug("proton_path not set in config - will use auto-detection") + return None + logger.debug("Retrieved fresh install proton_path from config: %s", proton_path) + return proton_path + except Exception as e: + logger.error("Error retrieving install proton_path: %s", e) + return None + + def get_game_proton_path(self): + """Retrieve the saved Game Proton path. Falls back to install Proton. Always reads fresh from disk.""" + try: + config = self._read_config_from_disk() + game_proton_path = config.get("game_proton_path") + if not game_proton_path or game_proton_path == "same_as_install": + game_proton_path = config.get("proton_path") + if not game_proton_path: + logger.debug("game_proton_path not set in config - will use auto-detection") + return None + logger.debug("Retrieved fresh game proton_path from config: %s", game_proton_path) + return game_proton_path + except Exception as e: + logger.error("Error retrieving game proton_path: %s", e) + return "auto" + + def get_proton_version(self): + """Retrieve the saved Proton version. Always reads fresh from disk.""" + try: + config = self._read_config_from_disk() + proton_version = config.get("proton_version", "auto") + logger.debug("Retrieved fresh proton_version from config: %s", proton_version) + return proton_version + except Exception as e: + logger.error("Error retrieving proton_version: %s", e) + return "auto" + + def _auto_detect_proton(self): + """Auto-detect and set best Proton version (GE-Proton and Valve Proton).""" + try: + from .wine_utils import WineUtils + best_proton = WineUtils.select_best_proton() + if best_proton: + self.settings["proton_path"] = str(best_proton['path']) + self.settings["proton_version"] = best_proton['name'] + proton_type = best_proton.get('type', 'Unknown') + logger.info("Auto-detected Proton: %s (%s)", best_proton['name'], proton_type) + self.save_config() + else: + self.settings["proton_path"] = None + self.settings["proton_version"] = None + logger.warning("No compatible Proton versions found - proton_path set to null in config.json") + logger.info("Jackify will auto-detect Proton on each run until a valid version is found") + self.save_config() + except Exception as e: + logger.error("Failed to auto-detect Proton: %s", e) + self.settings["proton_path"] = None + self.settings["proton_version"] = None + logger.warning("proton_path set to null in config.json due to auto-detection failure") + self.save_config() diff --git a/jackify/backend/handlers/diagnostic_helper.py b/jackify/backend/handlers/diagnostic_helper.py index d119b81..ec86661 100644 --- a/jackify/backend/handlers/diagnostic_helper.py +++ b/jackify/backend/handlers/diagnostic_helper.py @@ -73,7 +73,7 @@ def diagnose_stalled_engine(pid: int, duration: int = 60) -> Dict[str, Any]: samples.append(sample) # Real-time status - status_icon = "🟢" if sample['cpu_percent'] > 10 else "🟡" if sample['cpu_percent'] > 2 else "🔴" + status_icon = "[OK]" if sample['cpu_percent'] > 10 else "[WARN]" if sample['cpu_percent'] > 2 else "[CRIT]" print(f"{status_icon} CPU: {sample['cpu_percent']:5.1f}% | Memory: {sample['memory_mb']:6.1f}MB | " f"Threads: {sample['thread_count']:2d} | Status: {sample['status']}") diff --git a/jackify/backend/handlers/engine_monitor.py b/jackify/backend/handlers/engine_monitor.py index 2396ade..362d35d 100644 --- a/jackify/backend/handlers/engine_monitor.py +++ b/jackify/backend/handlers/engine_monitor.py @@ -179,7 +179,7 @@ class EnginePerformanceMonitor: if metrics.parent_cpu_percent is not None: parent_info = f", Python wrapper: {metrics.parent_cpu_percent:.1f}% CPU" - self.logger.warning(f"🚨 ENGINE STALL DETECTED: jackify-engine CPU at {metrics.cpu_percent:.1f}% " + self.logger.warning(f"ENGINE STALL DETECTED: jackify-engine CPU at {metrics.cpu_percent:.1f}% " f"for {self.stall_duration}s+ (Memory: {metrics.memory_mb:.1f}MB, " f"Threads: {metrics.thread_count}, FDs: {metrics.fd_count}{parent_info})") diff --git a/jackify/backend/handlers/filesystem_handler.py b/jackify/backend/handlers/filesystem_handler.py index daafc57..594a687 100644 --- a/jackify/backend/handlers/filesystem_handler.py +++ b/jackify/backend/handlers/filesystem_handler.py @@ -11,19 +11,20 @@ from typing import Optional, List, Dict, Tuple from datetime import datetime import re import time -import subprocess # Needed for running sudo commands -import pwd # To get user name -import grp # To get group name -import requests # Import requests -import vdf # Import VDF library at the top level +import subprocess +import pwd +import grp from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET -# Initialize logger for the module +from .filesystem_handler_download import FilesystemDownloadMixin +from .filesystem_handler_ownership import FilesystemOwnershipMixin +from .filesystem_handler_steam import FilesystemSteamMixin + logger = logging.getLogger(__name__) -class FileSystemHandler: + +class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin): def __init__(self): - # Keep instance logger if needed, but static methods use module logger self.logger = logging.getLogger(__name__) @staticmethod @@ -36,7 +37,7 @@ class FileSystemHandler: return Path(path) except Exception as e: logger.error(f"Failed to normalize path {path}: {e}") - return Path(path) # Return original path as Path object on error + return Path(path) @staticmethod def validate_path(path: Path) -> bool: @@ -50,7 +51,6 @@ class FileSystemHandler: logger.warning(f"Validation failed: No read access - {path}") return False # Check write access (important for many operations) - # For directories, check write on parent; for files, check write on file itself if path.is_dir(): if not os.access(path, os.W_OK): logger.warning(f"Validation failed: No write access to directory - {path}") @@ -60,7 +60,7 @@ class FileSystemHandler: if not os.access(path.parent, os.W_OK): logger.warning(f"Validation failed: No write access to parent dir of file - {path.parent}") return False - return True # Passed existence and access checks + return True except Exception as e: logger.error(f"Failed to validate path {path}: {e}") return False @@ -192,16 +192,16 @@ class FileSystemHandler: if recursive and path.is_dir(): for root, dirs, files in os.walk(path): try: - os.chmod(root, 0o755) # Dirs typically 755 + os.chmod(root, 0o755) except Exception as dir_e: logger.warning(f"Failed to chmod dir {root}: {dir_e}") for file in files: try: - os.chmod(os.path.join(root, file), 0o644) # Files typically 644 + os.chmod(os.path.join(root, file), 0o644) except Exception as file_e: logger.warning(f"Failed to chmod file {os.path.join(root, file)}: {file_e}") elif path.is_file(): - os.chmod(path, 0o644 if permissions == 0o755 else permissions) # Default file perms 644 + os.chmod(path, 0o644 if permissions == 0o755 else permissions) elif path.is_dir(): os.chmod(path, permissions) # Set specific perm for top-level dir if not recursive logger.debug(f"Set permissions for {path} (recursive={recursive})") @@ -239,12 +239,6 @@ class FileSystemHandler: logger.debug(f"Path {path} matches SD card pattern: {pattern}") return True - # Less reliable: Check mount point info (can be slow/complex) - # try: - # # ... (logic using /proc/mounts or df command) ... - # except Exception as mount_e: - # logger.warning(f"Could not reliably check mount point for {path}: {mount_e}") - logger.debug(f"Path {path} does not appear to be on a standard SD card mount.") return False @@ -306,7 +300,7 @@ class FileSystemHandler: FileSystemHandler.ensure_directory(destination.parent) - shutil.move(str(source), str(destination)) # shutil.move needs strings + shutil.move(str(source), str(destination)) logger.info(f"Moved directory {source} to {destination}") return True except Exception as e: @@ -321,8 +315,6 @@ class FileSystemHandler: logger.error(f"Copy failed: Source is not a directory - {source}") return False - # shutil.copytree needs destination to NOT exist unless dirs_exist_ok=True (Py 3.8+) - # Ensure parent exists FileSystemHandler.ensure_directory(destination.parent) shutil.copytree(source, destination, dirs_exist_ok=dirs_exist_ok) @@ -392,100 +384,6 @@ class FileSystemHandler: logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}") return False # Backup succeeded, but adding entry failed - @staticmethod - def blank_downloads_dir(modlist_ini: Path) -> bool: - """Blanks the download_directory line in ModOrganizer.ini.""" - logger.info(f"Blanking download_directory in {modlist_ini}...") - try: - content = modlist_ini.read_text().splitlines() - new_content = [] - found = False - for line in content: - if line.strip().startswith("download_directory="): - new_content.append("download_directory=") - found = True - else: - new_content.append(line) - - if found: - modlist_ini.write_text("\n".join(new_content) + "\n") - logger.debug("download_directory line blanked.") - else: - logger.warning("download_directory line not found.") - # Consider if we should add it blank? - - return True - except Exception as e: - logger.error(f"Failed to blank download_directory in {modlist_ini}: {e}") - return False - - @staticmethod - def copy_file(src: Path, dst: Path, overwrite: bool = False) -> bool: - """Copy a single file.""" - try: - if not src.is_file(): - logger.error(f"Copy failed: Source is not a file - {src}") - return False - if dst.exists() and not overwrite: - logger.warning(f"Copy skipped: Destination exists and overwrite=False - {dst}") - return False # Or True, depending on desired behavior for skip - - FileSystemHandler.ensure_directory(dst.parent) - shutil.copy2(src, dst) - logger.debug(f"Copied file {src} to {dst}") - return True - except Exception as e: - logger.error(f"Failed to copy file {src} to {dst}: {e}") - return False - - @staticmethod - def move_file(src: Path, dst: Path, overwrite: bool = False) -> bool: - """Move a single file.""" - try: - if not src.is_file(): - logger.error(f"Move failed: Source is not a file - {src}") - return False - if dst.exists() and not overwrite: - logger.warning(f"Move skipped: Destination exists and overwrite=False - {dst}") - return False - - FileSystemHandler.ensure_directory(dst.parent) - shutil.move(str(src), str(dst)) # shutil.move needs strings - # Create backup with timestamp - timestamp = os.path.getmtime(modlist_ini) - backup_path = modlist_ini.with_suffix(f'.{timestamp:.0f}.bak') - - # Copy file to backup - shutil.copy2(modlist_ini, backup_path) - - # Copy game path to backup path - with open(modlist_ini, 'r') as f: - lines = f.readlines() - - game_path_line = None - for line in lines: - if line.startswith('gamePath'): - game_path_line = line - break - - if game_path_line: - # Create backup path entry - backup_path_line = game_path_line.replace('gamePath', 'backupPath') - - # Append to file if not already present - with open(modlist_ini, 'a') as f: - f.write(backup_path_line) - - self.logger.debug(f"Backed up ModOrganizer.ini and created backupPath entry") - return True - else: - self.logger.error("No gamePath found in ModOrganizer.ini") - return False - - except Exception as e: - self.logger.error(f"Error backing up ModOrganizer.ini: {e}") - return False - def blank_downloads_dir(self, modlist_ini: Path) -> bool: """ Blank or reset the MO2 Downloads Directory @@ -664,7 +562,7 @@ class FileSystemHandler: self.logger.debug(f"Created game-specific directory: {dir_path}") # CRITICAL: Create game-specific Documents directories in Wine prefix - # This is required for USVFS to virtualize profile INI files on first launch + # Required for USVFS to virtualize profile INIs on first launch if game_name in game_docs_dirs: docs_dir_name = game_docs_dirs[game_name] @@ -701,267 +599,3 @@ class FileSystemHandler: except Exception as e: self.logger.error(f"Error creating required directories: {e}") return False - - @staticmethod - def all_owned_by_user(path: Path) -> bool: - """ - Returns True if all files and directories under 'path' are owned by the current user. - """ - uid = os.getuid() - gid = os.getgid() - for root, dirs, files in os.walk(path): - for name in dirs + files: - full_path = os.path.join(root, name) - try: - stat = os.stat(full_path) - if stat.st_uid != uid or stat.st_gid != gid: - return False - except Exception: - return False - return True - - @staticmethod - def verify_ownership_and_permissions(path: Path) -> tuple[bool, str]: - """ - Verify and fix ownership/permissions for modlist directory. - Returns (success, error_message). - - Logic: - - If files NOT owned by user: Can't fix without sudo, return error with instructions - - If files owned by user: Try to fix permissions ourselves with chmod - """ - if not path.exists(): - logger.error(f"Path does not exist: {path}") - return False, f"Path does not exist: {path}" - - # Check if all files/dirs are owned by the user - if not FileSystemHandler.all_owned_by_user(path): - # Files not owned by us - need sudo to fix - try: - user_name = pwd.getpwuid(os.geteuid()).pw_name - group_name = grp.getgrgid(os.geteuid()).gr_name - except KeyError: - logger.error("Could not determine current user or group name.") - return False, "Could not determine current user or group name." - - logger.error(f"Ownership issue detected: Some files in {path} are not owned by {user_name}") - - error_msg = ( - f"\nOwnership Issue Detected\n" - f"Some files in the modlist directory are not owned by your user account.\n" - f"This can happen if the modlist was copied from another location or installed by a different user.\n\n" - f"To fix this, open a terminal and run:\n\n" - f" sudo chown -R {user_name}:{group_name} \"{path}\"\n" - f" sudo chmod -R 755 \"{path}\"\n\n" - f"After running these commands, retry the configuration process." - ) - return False, error_msg - - # Files are owned by us - try to fix permissions ourselves - logger.info(f"Files in {path} are owned by current user, verifying permissions...") - try: - result = subprocess.run( - ['chmod', '-R', '755', str(path)], - capture_output=True, - text=True, - check=False - ) - if result.returncode == 0: - logger.info(f"Permissions set successfully for {path}") - return True, "" - else: - logger.warning(f"chmod returned non-zero but we'll continue: {result.stderr}") - # Non-critical if chmod fails on our own files, might be read-only filesystem or similar - return True, "" - except Exception as e: - logger.warning(f"Error running chmod: {e}, continuing anyway") - # Non-critical error, we own the files so proceed - return True, "" - - @staticmethod - def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool: - """ - DEPRECATED: Use verify_ownership_and_permissions() instead. - This method is kept for backwards compatibility but no longer executes sudo. - """ - logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()") - success, error_msg = FileSystemHandler.verify_ownership_and_permissions(path) - if not success: - logger.error(error_msg) - print(error_msg) - return success - - def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool: - """Downloads a file from a URL to a destination path.""" - self.logger.info(f"Downloading {url} to {destination_path}...") - - if not overwrite and destination_path.exists(): - self.logger.info(f"File already exists, skipping download: {destination_path}") - # Only print if not quiet - if not quiet: - print(f"File {destination_path.name} already exists, skipping download.") - return True # Consider existing file as success - - try: - # Ensure destination directory exists - destination_path.parent.mkdir(parents=True, exist_ok=True) - - # Perform the download with streaming - with requests.get(url, stream=True, timeout=300, verify=True) as r: - r.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) - with open(destination_path, 'wb') as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - - self.logger.info("Download complete.") - # Only print if not quiet - if not quiet: - print("Download complete.") - return True - - except requests.exceptions.RequestException as e: - self.logger.error(f"Download failed: {e}") - print(f"Error: Download failed for {url}. Check network connection and URL.") - # Clean up potentially incomplete file - if destination_path.exists(): - try: destination_path.unlink() - except OSError: pass - return False - except Exception as e: - self.logger.error(f"Error during download or file writing: {e}", exc_info=True) - print("Error: An unexpected error occurred during download.") - # Clean up potentially incomplete file - if destination_path.exists(): - try: destination_path.unlink() - except OSError: pass - return False - - @staticmethod - def find_steam_library() -> Optional[Path]: - """ - Find the Steam library containing game installations, prioritizing vdf. - - Returns: - Optional[Path]: Path object to the Steam library's steamapps/common dir, or None if not found - """ - logger.info("Detecting Steam library location...") - - # Try finding libraryfolders.vdf in common Steam paths - possible_vdf_paths = [ - Path.home() / ".steam/steam/config/libraryfolders.vdf", - Path.home() / ".local/share/Steam/config/libraryfolders.vdf", - Path.home() / ".steam/root/config/libraryfolders.vdf", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf" # Flatpak - ] - - libraryfolders_vdf_path: Optional[Path] = None - for path_obj in possible_vdf_paths: - # Explicitly ensure path_obj is Path before checking is_file - current_path = Path(path_obj) - if current_path.is_file(): - libraryfolders_vdf_path = current_path # Assign the confirmed Path object - logger.debug(f"Found libraryfolders.vdf at: {libraryfolders_vdf_path}") - break - - # Check AFTER loop - libraryfolders_vdf_path is now definitely Path or None - if not libraryfolders_vdf_path: - logger.warning("libraryfolders.vdf not found...") - # Proceed to default check below if vdf not found - else: - # Parse the VDF file to extract library paths - try: - # Try importing vdf here if not done globally - with open(libraryfolders_vdf_path, 'r') as f: - data = vdf.load(f) - - # Look for library folders (indices are strings '0', '1', etc.) - libraries = data.get('libraryfolders', {}) - - for key in libraries: - if isinstance(libraries[key], dict) and 'path' in libraries[key]: - lib_path_str = libraries[key]['path'] - if lib_path_str: - # Check if this library path is valid - potential_lib_path = Path(lib_path_str) / "steamapps/common" - if potential_lib_path.is_dir(): - logger.info(f"Using Steam library path from vdf: {potential_lib_path}") - return potential_lib_path # Return first valid Path object found - - logger.warning("No valid library paths found within libraryfolders.vdf.") - # Proceed to default check below if vdf parsing fails to find a valid path - - except ImportError: - logger.error("Python 'vdf' library not found. Cannot parse libraryfolders.vdf.") - # Proceed to default check below - except Exception as e: - logger.error(f"Error parsing libraryfolders.vdf: {e}") - # Proceed to default check below - - # Fallback: Check default location if VDF parsing didn't yield a result - default_path = Path.home() / ".steam/steam/steamapps/common" - if default_path.is_dir(): - logger.warning(f"Using default Steam library path: {default_path}") - return default_path - - logger.error("No valid Steam library found via vdf or at default location.") - return None - - @staticmethod - def find_compat_data(appid: str) -> Optional[Path]: - """Find the compatdata directory for a given AppID.""" - if not appid or not appid.isdigit(): - logger.error(f"Invalid AppID provided for compatdata search: {appid}") - return None - - logger.debug(f"Searching for compatdata directory for AppID: {appid}") - - # Standard Steam locations - possible_bases = [ - Path.home() / ".steam/steam/steamapps/compatdata", - Path.home() / ".local/share/Steam/steamapps/compatdata", - ] - - # Try to get library path from vdf to check there too - # Use type hint for clarity - steam_lib_common_path: Optional[Path] = FileSystemHandler.find_steam_library() - if steam_lib_common_path: - # find_steam_library returns steamapps/common, go up two levels for library root - library_root = steam_lib_common_path.parent.parent - vdf_compat_path = library_root / "steamapps/compatdata" - if vdf_compat_path.is_dir() and vdf_compat_path not in possible_bases: - possible_bases.insert(0, vdf_compat_path) # Prioritize library path from vdf - - for base_path in possible_bases: - if not base_path.is_dir(): - logger.debug(f"Compatdata base path does not exist or is not a directory: {base_path}") - continue - - potential_path = base_path / appid - if potential_path.is_dir(): - logger.info(f"Found compatdata directory: {potential_path}") - return potential_path # Return Path object - else: - logger.debug(f"Compatdata for {appid} not found in {base_path}") - - logger.warning(f"Compatdata directory for AppID {appid} not found in standard or detected library locations.") - return None - - @staticmethod - def find_steam_config_vdf() -> Optional[Path]: - """Finds the active Steam config.vdf file.""" - logger.debug("Searching for Steam config.vdf...") - possible_steam_paths = [ - Path.home() / ".steam/steam", - Path.home() / ".local/share/Steam", - Path.home() / ".steam/root" - ] - for steam_path in possible_steam_paths: - potential_path = steam_path / "config/config.vdf" - if potential_path.is_file(): - logger.info(f"Found config.vdf at: {potential_path}") - return potential_path # Return Path object - - logger.warning("Could not locate Steam's config.vdf file in standard locations.") - return None - - # ... (rest of the class) ... \ No newline at end of file diff --git a/jackify/backend/handlers/filesystem_handler_download.py b/jackify/backend/handlers/filesystem_handler_download.py new file mode 100644 index 0000000..8e7f100 --- /dev/null +++ b/jackify/backend/handlers/filesystem_handler_download.py @@ -0,0 +1,55 @@ +""" +Filesystem download operations: download_file. +""" + +import logging +from pathlib import Path + +import requests + + +logger = logging.getLogger(__name__) + + +class FilesystemDownloadMixin: + """Mixin providing download_file for FileSystemHandler.""" + + def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool: + """Download a file from a URL to a destination path.""" + self.logger.info("Downloading %s to %s...", url, destination_path) + + if not overwrite and destination_path.exists(): + self.logger.info("File already exists, skipping download: %s", destination_path) + if not quiet: + self.logger.info("File %s already exists, skipping download.", destination_path.name) + return True + + try: + destination_path.parent.mkdir(parents=True, exist_ok=True) + with requests.get(url, stream=True, timeout=300, verify=True) as r: + r.raise_for_status() + with open(destination_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + self.logger.info("Download complete.") + if not quiet: + self.logger.info("Download complete.") + return True + except requests.exceptions.RequestException as e: + self.logger.error("Download failed: %s", e) + self.logger.error("Download failed for %s. Check network connection and URL.", url) + if destination_path.exists(): + try: + destination_path.unlink() + except OSError: + pass + return False + except Exception as e: + self.logger.error("Error during download or file writing: %s", e, exc_info=True) + self.logger.error("An unexpected error occurred during download.") + if destination_path.exists(): + try: + destination_path.unlink() + except OSError: + pass + return False diff --git a/jackify/backend/handlers/filesystem_handler_ownership.py b/jackify/backend/handlers/filesystem_handler_ownership.py new file mode 100644 index 0000000..5c28182 --- /dev/null +++ b/jackify/backend/handlers/filesystem_handler_ownership.py @@ -0,0 +1,89 @@ +""" +Filesystem ownership and permissions: all_owned_by_user, verify_ownership_and_permissions, set_ownership_and_permissions_sudo. +""" + +import os +import logging +import subprocess +import pwd +import grp +from pathlib import Path + + +logger = logging.getLogger(__name__) + + +class FilesystemOwnershipMixin: + """Mixin providing ownership check and sudo-compatible fix for FileSystemHandler.""" + + @staticmethod + def all_owned_by_user(path: Path) -> bool: + """Return True if all files and directories under path are owned by the current user.""" + uid = os.getuid() + gid = os.getgid() + for root, dirs, files in os.walk(path): + for name in dirs + files: + full_path = os.path.join(root, name) + try: + stat = os.stat(full_path) + if stat.st_uid != uid or stat.st_gid != gid: + return False + except Exception: + return False + return True + + @staticmethod + def verify_ownership_and_permissions(path: Path) -> tuple: + """ + Verify and fix ownership/permissions for modlist directory. + Returns (success, error_message). + """ + if not path.exists(): + logger.error("Path does not exist: %s", path) + return False, f"Path does not exist: {path}" + + if not FilesystemOwnershipMixin.all_owned_by_user(path): + try: + user_name = pwd.getpwuid(os.geteuid()).pw_name + group_name = grp.getgrgid(os.geteuid()).gr_name + except KeyError: + logger.error("Could not determine current user or group name.") + return False, "Could not determine current user or group name." + + logger.error("Ownership issue detected: Some files in %s are not owned by %s", path, user_name) + error_msg = ( + f"\nOwnership Issue Detected\n" + f"Some files in the modlist directory are not owned by your user account.\n" + f"This can happen if the modlist was copied from another location or installed by a different user.\n\n" + f"To fix this, open a terminal and run:\n\n" + f" sudo chown -R {user_name}:{group_name} \"{path}\"\n" + f" sudo chmod -R 755 \"{path}\"\n\n" + f"After running these commands, retry the configuration process." + ) + return False, error_msg + + logger.info("Files in %s are owned by current user, verifying permissions...", path) + try: + result = subprocess.run( + ['chmod', '-R', '755', str(path)], + capture_output=True, + text=True, + check=False + ) + if result.returncode == 0: + logger.info("Permissions set successfully for %s", path) + return True, "" + logger.warning("chmod returned non-zero but we'll continue: %s", result.stderr) + return True, "" + except Exception as e: + logger.warning("Error running chmod: %s, continuing anyway", e) + return True, "" + + @staticmethod + def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool: + """Deprecated: use verify_ownership_and_permissions() instead. Kept for backwards compatibility.""" + logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()") + success, error_msg = FilesystemOwnershipMixin.verify_ownership_and_permissions(path) + if not success: + logger.error("%s", error_msg) + return success diff --git a/jackify/backend/handlers/filesystem_handler_steam.py b/jackify/backend/handlers/filesystem_handler_steam.py new file mode 100644 index 0000000..5455736 --- /dev/null +++ b/jackify/backend/handlers/filesystem_handler_steam.py @@ -0,0 +1,124 @@ +""" +Steam path discovery for FileSystemHandler: find_steam_library, find_compat_data, find_steam_config_vdf. +""" + +import logging +from pathlib import Path +from typing import Optional + +import vdf + +logger = logging.getLogger(__name__) + + +class FilesystemSteamMixin: + """Mixin providing Steam library and compatdata path discovery for FileSystemHandler.""" + + @staticmethod + def find_steam_library() -> Optional[Path]: + """ + Find the Steam library containing game installations, prioritizing vdf. + + Returns: + Optional[Path]: Path object to the Steam library's steamapps/common dir, or None if not found + """ + logger.info("Detecting Steam library location...") + + possible_vdf_paths = [ + Path.home() / ".steam/steam/config/libraryfolders.vdf", + Path.home() / ".local/share/Steam/config/libraryfolders.vdf", + Path.home() / ".steam/root/config/libraryfolders.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf" + ] + + libraryfolders_vdf_path: Optional[Path] = None + for path_obj in possible_vdf_paths: + current_path = Path(path_obj) + if current_path.is_file(): + libraryfolders_vdf_path = current_path + logger.debug(f"Found libraryfolders.vdf at: {libraryfolders_vdf_path}") + break + + if not libraryfolders_vdf_path: + logger.warning("libraryfolders.vdf not found...") + else: + try: + with open(libraryfolders_vdf_path, 'r') as f: + data = vdf.load(f) + + libraries = data.get('libraryfolders', {}) + for key in libraries: + if isinstance(libraries[key], dict) and 'path' in libraries[key]: + lib_path_str = libraries[key]['path'] + if lib_path_str: + potential_lib_path = Path(lib_path_str) / "steamapps/common" + if potential_lib_path.is_dir(): + logger.info(f"Using Steam library path from vdf: {potential_lib_path}") + return potential_lib_path + + logger.warning("No valid library paths found within libraryfolders.vdf.") + except ImportError: + logger.error("Python 'vdf' library not found. Cannot parse libraryfolders.vdf.") + except Exception as e: + logger.error(f"Error parsing libraryfolders.vdf: {e}") + + default_path = Path.home() / ".steam/steam/steamapps/common" + if default_path.is_dir(): + logger.warning(f"Using default Steam library path: {default_path}") + return default_path + + logger.error("No valid Steam library found via vdf or at default location.") + return None + + @staticmethod + def find_compat_data(appid: str) -> Optional[Path]: + """Find the compatdata directory for a given AppID.""" + if not appid or not appid.isdigit(): + logger.error(f"Invalid AppID provided for compatdata search: {appid}") + return None + + logger.debug(f"Searching for compatdata directory for AppID: {appid}") + + possible_bases = [ + Path.home() / ".steam/steam/steamapps/compatdata", + Path.home() / ".local/share/Steam/steamapps/compatdata", + ] + + steam_lib_common_path: Optional[Path] = FilesystemSteamMixin.find_steam_library() + if steam_lib_common_path: + library_root = steam_lib_common_path.parent.parent + vdf_compat_path = library_root / "steamapps/compatdata" + if vdf_compat_path.is_dir() and vdf_compat_path not in possible_bases: + possible_bases.insert(0, vdf_compat_path) + + for base_path in possible_bases: + if not base_path.is_dir(): + logger.debug(f"Compatdata base path does not exist or is not a directory: {base_path}") + continue + + potential_path = base_path / appid + if potential_path.is_dir(): + logger.info(f"Found compatdata directory: {potential_path}") + return potential_path + logger.debug(f"Compatdata for {appid} not found in {base_path}") + + logger.warning(f"Compatdata directory for AppID {appid} not found in standard or detected library locations.") + return None + + @staticmethod + def find_steam_config_vdf() -> Optional[Path]: + """Finds the active Steam config.vdf file.""" + logger.debug("Searching for Steam config.vdf...") + possible_steam_paths = [ + Path.home() / ".steam/steam", + Path.home() / ".local/share/Steam", + Path.home() / ".steam/root" + ] + for steam_path in possible_steam_paths: + potential_path = steam_path / "config/config.vdf" + if potential_path.is_file(): + logger.info(f"Found config.vdf at: {potential_path}") + return potential_path + + logger.warning("Could not locate Steam's config.vdf file in standard locations.") + return None diff --git a/jackify/backend/handlers/install_wabbajack_handler.py b/jackify/backend/handlers/install_wabbajack_handler.py deleted file mode 100644 index 721b592..0000000 --- a/jackify/backend/handlers/install_wabbajack_handler.py +++ /dev/null @@ -1,1664 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Install Wabbajack Handler Module -Handles the installation and updating of Wabbajack -""" - -import os -import logging -from pathlib import Path -from typing import Optional, Tuple -import shutil -import subprocess -import pwd -import requests -from tqdm import tqdm -import tempfile -import time -import re - -# Attempt to import readline for tab completion -READLINE_AVAILABLE = False -try: - import readline - READLINE_AVAILABLE = True - # Check if running in a non-interactive environment (e.g., some CI) - if 'libedit' in readline.__doc__: - # libedit doesn't support set_completion_display_matches_hook - pass - # Add other potential checks if needed -except ImportError: - # readline not available on Windows or potentially minimal environments - pass -except Exception as e: - # Catch other potential errors during readline import/setup - logging.warning(f"Readline import failed: {e}") - pass - -# Import UI Colors first - these should always be available -from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR - -# Import necessary components from other modules -try: - from .path_handler import PathHandler - from .protontricks_handler import ProtontricksHandler - from .shortcut_handler import ShortcutHandler - from .vdf_handler import VDFHandler - from .modlist_handler import ModlistHandler - from .filesystem_handler import FileSystemHandler - from .menu_handler import MenuHandler, simple_path_completer - # Standard logging (no file handler) - LoggingHandler import removed - from .status_utils import show_status, clear_status - from jackify.shared.ui_utils import print_section_header -except ImportError as e: - logging.error(f"Import error in InstallWabbajackHandler: {e}") - logging.error("Could not import FileSystemHandler or simple_path_completer. Ensure structure is correct.") - -# Default locations -WABBAJACK_DEFAULT_DIR = os.path.expanduser("~/.config/Jackify/Wabbajack") - -# Initialize logger for the module -logger = logging.getLogger(__name__) - -DEFAULT_WABBAJACK_PATH = "~/Wabbajack" -DEFAULT_WABBAJACK_NAME = "Wabbajack" - -class InstallWabbajackHandler: - """Handles the workflow for installing Wabbajack via Jackify.""" - - def __init__(self, steamdeck: bool, protontricks_handler: ProtontricksHandler, shortcut_handler: ShortcutHandler, path_handler: PathHandler, vdf_handler: VDFHandler, modlist_handler: ModlistHandler, filesystem_handler: FileSystemHandler, menu_handler=None): - """ - Initializes the handler. - - Args: - steamdeck (bool): True if running on a Steam Deck, False otherwise. - protontricks_handler (ProtontricksHandler): An initialized instance. - shortcut_handler (ShortcutHandler): An initialized instance. - path_handler (PathHandler): An initialized instance. - vdf_handler (VDFHandler): An initialized instance. - modlist_handler (ModlistHandler): An initialized instance. - filesystem_handler (FileSystemHandler): An initialized instance. - menu_handler: An optional MenuHandler instance for improved UI interactions. - """ - # Use standard logging (no file handler) - self.logger = logging.getLogger(__name__) - self.logger.propagate = False - self.steamdeck = steamdeck - self.protontricks_handler = protontricks_handler # Store the handler - self.shortcut_handler = shortcut_handler # Store the handler - self.path_handler = path_handler # Store the handler - self.vdf_handler = vdf_handler # Store the handler - self.modlist_handler = modlist_handler # Store the handler - self.filesystem_handler = filesystem_handler # Store the handler - self.menu_handler = menu_handler # Store the menu handler - self.logger.info(f"InstallWabbajackHandler initialized. Steam Deck status: {self.steamdeck}") - self.install_path: Optional[Path] = None - self.shortcut_name: Optional[str] = None - self.initial_appid: Optional[str] = None # To store the AppID from shortcut creation - self.final_appid: Optional[str] = None # To store the AppID after verification - self.compatdata_path: Optional[Path] = None # To store the compatdata path - # Add other state variables as needed - - def _print_default_status(self, message: str): - """Prints overwriting status line, ONLY if not in verbose/debug mode.""" - verbose_console = False - for handler in logging.getLogger().handlers: - if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): - if handler.level <= logging.INFO: - verbose_console = True - break - - if not verbose_console: - # Use \r to return to start, \033[K to clear line, then print message - # Prepend "Current Task: " to the message - status_text = f"Current Task: {message}" - # Use a fixed-width field for consistent display and proper line clearing - status_width = 80 # Ensure sufficient width to cover previous text - # Pad with spaces and use \r to stay on the same line - print(f"\r\033[K{COLOR_INFO}{status_text:<{status_width}}{COLOR_RESET}", end="", flush=True) - - def _clear_default_status(self): - """Clears the status line, ONLY if not in verbose/debug mode.""" - verbose_console = False - for handler in logging.getLogger().handlers: - if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): - if handler.level <= logging.INFO: - verbose_console = True - break - if not verbose_console: - print("\r\033[K", end="", flush=True) - - def _download_file(self, url: str, destination_path: Path) -> bool: - """Downloads a file from a URL to a destination path. - Handles temporary file and overwrites destination if download succeeds. - - Args: - url (str): The URL to download from. - destination_path (Path): The path to save the downloaded file. - - Returns: - bool: True if download succeeds, False otherwise. - """ - self.logger.info(f"Downloading {destination_path.name} from {url}") - - # Ensure parent directory exists - destination_path.parent.mkdir(parents=True, exist_ok=True) - - # --- Download --- - temp_path = destination_path.with_suffix(destination_path.suffix + ".part") - self.logger.debug(f"Downloading to temporary path: {temp_path}") - - try: - with requests.get(url, stream=True, timeout=30, verify=True) as r: - r.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) - # total_size_in_bytes = int(r.headers.get('content-length', 0)) - block_size = 8192 # 8KB chunks - - with open(temp_path, 'wb') as f: - for chunk in r.iter_content(chunk_size=block_size): - if chunk: # filter out keep-alive new chunks - f.write(chunk) - - # --- Post-Download Actions --- - actual_downloaded_size = temp_path.stat().st_size - self.logger.debug(f"Download finished. Actual size: {actual_downloaded_size} bytes.") - - # Overwrite final destination with temp file - # Use shutil.move for better cross-filesystem compatibility if needed - # temp_path.rename(destination_path) # Simple rename - shutil.move(str(temp_path), str(destination_path)) - self.logger.info(f"Successfully downloaded and moved to {destination_path}") - return True - - except requests.exceptions.RequestException as e: - self.logger.error(f"Download failed for {url}: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}Error downloading {destination_path.name}: {e}{COLOR_RESET}") - # Clean up partial file if download fails - if temp_path.exists(): - try: - temp_path.unlink() - except OSError as unlink_err: - self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") - return False - except Exception as e: - self.logger.error(f"An unexpected error occurred during download: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}An unexpected error occurred during download: {e}{COLOR_RESET}") - if temp_path.exists(): - try: - temp_path.unlink() - except OSError as unlink_err: - self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") - return False - - def _prepare_install_directory(self) -> bool: - """ - Ensures the target installation directory exists and is accessible. - Handles directory creation, prompting the user if outside $HOME. - - Returns: - bool: True if the directory exists and is ready, False otherwise. - """ - if not self.install_path: - self.logger.error("Cannot prepare directory: install_path is not set.") - return False - - self.logger.info(f"Preparing installation directory: {self.install_path}") - - if self.install_path.exists(): - if self.install_path.is_dir(): - self.logger.info(f"Directory already exists: {self.install_path}") - # Check write permissions - if not os.access(self.install_path, os.W_OK | os.X_OK): - self.logger.error(f"Directory exists but lacks write/execute permissions: {self.install_path}") - print(f"\n{COLOR_ERROR}Error: Directory exists but lacks necessary write/execute permissions.{COLOR_RESET}") - return False - return True - else: - self.logger.error(f"Path exists but is not a directory: {self.install_path}") - print(f"\n{COLOR_ERROR}Error: The specified path exists but is a file, not a directory.{COLOR_RESET}") - return False - else: - # Directory does not exist, attempt creation - self.logger.info("Directory does not exist. Attempting creation...") - try: - home_dir = Path.home() - is_outside_home = not str(self.install_path.resolve()).startswith(str(home_dir.resolve())) - - if is_outside_home: - self.logger.warning(f"Install path {self.install_path} is outside home directory {home_dir}.") - print(f"\n{COLOR_PROMPT}The chosen path is outside your home directory and may require manual creation.{COLOR_RESET}") - while True: - response = input(f"{COLOR_PROMPT}Please create the directory \"{self.install_path}\" manually,\nensure you have write permissions, and then press Enter to continue (or 'q' to quit): {COLOR_RESET}").lower() - if response == 'q': - self.logger.warning("User aborted manual directory creation.") - return False - # Re-check after user presses Enter - if self.install_path.exists(): - if self.install_path.is_dir(): - self.logger.info("Directory created manually by user.") - if not os.access(self.install_path, os.W_OK | os.X_OK): - self.logger.warning(f"Directory created but may lack write/execute permissions: {self.install_path}") - print(f"\n{COLOR_ERROR}Warning: Directory created, but write/execute permissions might be missing.{COLOR_RESET}") - # Decide whether to proceed or fail here - let's proceed but warn - return True - else: - self.logger.error("User indicated directory created, but path is not a directory.") - print(f"\n{COLOR_ERROR}Error: Path exists now, but it is not a directory. Please fix and try again.{COLOR_RESET}") - else: - print(f"\n{COLOR_ERROR}Directory still not found. Please create it or enter 'q' to quit.{COLOR_RESET}") - else: - # Inside home directory, attempt direct creation - self.logger.info("Path is inside home directory. Creating...") - os.makedirs(self.install_path) - self.logger.info(f"Successfully created directory: {self.install_path}") - # Verify permissions after creation - if not os.access(self.install_path, os.W_OK | os.X_OK): - self.logger.warning(f"Directory created but lacks write/execute permissions: {self.install_path}") - print(f"\n{COLOR_ERROR}Warning: Directory created, but lacks write/execute permissions. Subsequent steps might fail.{COLOR_RESET}") - # Proceed anyway? - return True - - except PermissionError: - self.logger.error(f"Permission denied when trying to create directory: {self.install_path}", exc_info=True) - print(f"\n{COLOR_ERROR}Error: Permission denied creating directory.{COLOR_RESET}") - print(f"{COLOR_INFO}Please check permissions for the parent directory or choose a different location.{COLOR_RESET}") - return False - except OSError as e: - self.logger.error(f"Failed to create directory {self.install_path}: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}Error creating directory: {e}{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"An unexpected error occurred during directory preparation: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - return False - - def _get_wabbajack_install_path(self) -> Optional[Path]: - """ - Prompts the user for the Wabbajack installation path with tab completion. - Uses the FileSystemHandler for path validation and completion. - - Returns: - Optional[Path]: The chosen installation path as a Path object, or None if cancelled. - """ - self.logger.info("Prompting for Wabbajack installation path.") - # Use default path if set, otherwise prompt with suggestion - current_path = self.install_path if self.install_path else Path(DEFAULT_WABBAJACK_PATH).expanduser() - - # Enable tab completion if readline is available - if READLINE_AVAILABLE: - readline.set_completer_delims(' \t\n;') - readline.parse_and_bind("tab: complete") - # Use the simple_path_completer from FileSystemHandler for directory completion - readline.set_completer(simple_path_completer) - - while True: - try: - prompt_text = f"{COLOR_PROMPT}Enter Wabbajack installation path (default: {current_path}): {COLOR_RESET}" - user_input = input(prompt_text).strip() - - if not user_input: # User pressed Enter for default - chosen_path_str = str(current_path) - else: - chosen_path_str = user_input - - # Expand ~ and make absolute - chosen_path = Path(chosen_path_str).expanduser().resolve() - - # Basic validation (is it a plausible path format?) - if not chosen_path.name: # e.g. if user entered just "/" - print(f"{COLOR_ERROR}Invalid path. Please enter a valid directory path.{COLOR_RESET}") - continue - - # Check if path exists and is a directory, or can be created - if chosen_path.exists() and not chosen_path.is_dir(): - print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}") - continue - - # Confirm with user - confirm_prompt = f"{COLOR_PROMPT}Install Wabbajack to {chosen_path}? (Y/n/c to cancel): {COLOR_RESET}" - confirmation = input(confirm_prompt).lower() - - if confirmation == 'c': - self.logger.info("Wabbajack installation path selection cancelled by user.") - return None # User cancelled - elif confirmation != 'n': - self.install_path = chosen_path # Store the confirmed path - self.logger.info(f"Wabbajack installation path set to: {self.install_path}") - return self.install_path - # If 'n', loop again to ask for path - except KeyboardInterrupt: - self.logger.info("Wabbajack installation path selection cancelled by user (Ctrl+C).") - print("\nPath selection cancelled.") - return None - except Exception as e: - self.logger.error(f"Error during path input: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - # Decide if we should return None or retry on general exception - return None - finally: - # Restore default completer if it was changed - if READLINE_AVAILABLE: - readline.set_completer(None) - - def _get_wabbajack_shortcut_name(self) -> Optional[str]: - """ - Prompts the user for the Wabbajack shortcut name. - - Returns: - Optional[str]: The name chosen by the user, or None if cancelled. - """ - self.logger.debug("Getting Wabbajack shortcut name.") - - # Return pre-configured shortcut name if already set - if self.shortcut_name: - self.logger.info(f"Using pre-configured shortcut name: {self.shortcut_name}") - return self.shortcut_name - - chosen_name = DEFAULT_WABBAJACK_NAME - - # Use menu_handler if available for consistent UI - if self.menu_handler: - self.logger.debug("Using menu_handler for shortcut name input") - print(f"\nWabbajack Shortcut Name:") - name_input = self.menu_handler.get_input_with_default( - prompt=f"Enter the desired name for the Wabbajack Steam shortcut (default: {chosen_name})", - default=chosen_name - ) - - if name_input is not None: - self.logger.info(f"User provided shortcut name: {name_input}") - return name_input - else: - self.logger.info("User cancelled shortcut name input") - return None - - # Fallback to direct input if no menu_handler - try: - print(f"\n{COLOR_PROMPT}Enter the desired name for the Wabbajack Steam shortcut.{COLOR_RESET}") - name_input = input(f"{COLOR_PROMPT}Name [{chosen_name}]: {COLOR_RESET}").strip() - - if not name_input: - self.logger.info(f"User did not provide input, using default name: {chosen_name}") - else: - chosen_name = name_input - self.logger.info(f"User provided name: {chosen_name}") - - return chosen_name - - except KeyboardInterrupt: - print(f"\n{COLOR_ERROR}Input cancelled by user.{COLOR_RESET}") - self.logger.warning("User cancelled name input.") - return None - except Exception as e: - self.logger.error(f"An unexpected error occurred while getting name input: {e}", exc_info=True) - return None - - def run_install_workflow(self, context: dict = None) -> bool: - """ - Main entry point for the Wabbajack installation workflow. - """ - os.system('cls' if os.name == 'nt' else 'clear') - # Banner display handled by frontend - print_section_header('Wabbajack Installation') - # Standard logging (no file handler) - LoggingHandler calls removed - - self.logger.info("Starting Wabbajack installation workflow...") - # Remove legacy divider - # print(f"\n{COLOR_INFO}--- Wabbajack Installation ---{COLOR_RESET}") - # 1. Get Installation Path - if self.menu_handler: - print("\nWabbajack Installation Location:") - default_path = Path.home() / 'Wabbajackify' - install_path_result = self.menu_handler.get_directory_path( - prompt_message=f"Enter path (Default: {default_path}):", - default_path=default_path, - create_if_missing=True, - no_header=True - ) - if not install_path_result: - self.logger.info("User cancelled path input via menu_handler") - return True # Return to menu to allow user to retry or exit gracefully - # Handle the result from get_directory_path (could be Path or tuple) - if isinstance(install_path_result, tuple): - self.install_path = install_path_result[0] # Path object - self.logger.info(f"Install path set to {self.install_path}, user confirmed creation if new.") - else: - self.install_path = install_path_result # Already a Path object - self.logger.info(f"Install path set to {self.install_path}.") - else: # Fallback if no menu_handler (should ideally not happen in normal flow) - default_path = Path.home() / 'Wabbajackify' - print(f"\n{COLOR_PROMPT}Enter the full path where Wabbajack should be installed.{COLOR_RESET}") - print(f"Default: {default_path}") - try: - user_input = input(f"{COLOR_PROMPT}Enter path (or press Enter for default: {default_path}): {COLOR_RESET}").strip() - if not user_input: - install_path = default_path - else: - install_path = Path(user_input).expanduser().resolve() - self.install_path = install_path - except KeyboardInterrupt: - print("\nOperation cancelled by user.") - self.logger.info("User cancelled path input.") - return True - - # 2. Get Shortcut Name - self.shortcut_name = self._get_wabbajack_shortcut_name() - if not self.shortcut_name: - self.logger.warning("Workflow aborted: Failed to get shortcut name.") - return True # Return to menu - - # 3. Steam Deck status is already known (self.steamdeck) - self.logger.info(f"Proceeding with Steam Deck status: {self.steamdeck}") - - # 4. Check Prerequisite: Protontricks - self.logger.info("Checking Protontricks prerequisite...") - protontricks_ok = self.protontricks_handler.check_and_setup_protontricks() - if not protontricks_ok: - self.logger.error("Workflow aborted: Protontricks requirement not met or setup failed.") - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True # Return to menu - self.logger.info("Protontricks check successful.") - - # --- Show summary (no input required) --- - self._display_summary() # Show the summary only, no input here - # --- Single confirmation prompt before making changes/restarting Steam --- - print("\n───────────────────────────────────────────────────────────────────") - print(f"{COLOR_PROMPT}Important:{COLOR_RESET} Steam will now restart so Jackify can create the Wabbajack shortcut.\n\nPlease do not manually start or close Steam until Jackify is finished.") - print("───────────────────────────────────────────────────────────────────") - confirm = input(f"{COLOR_PROMPT}Do you wish to continue? (y/N): {COLOR_RESET}").strip().lower() - if confirm not in ('y', ''): - print("Installation cancelled by user.") - return True - - # --- Phase 2: All changes happen after confirmation --- - - # 5. Prepare Install Directory - show_status("Preparing install directory") - if not self._prepare_install_directory(): - self.logger.error("Workflow aborted: Failed to prepare installation directory.") - clear_status() - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True # Return to menu - self.logger.info("Installation directory prepared successfully.") - - # 6. Download Wabbajack.exe - show_status("Downloading Wabbajack.exe") - if not self._download_wabbajack_executable(): - self.logger.error("Workflow aborted: Failed to download Wabbajack.exe.") - clear_status() - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True # Return to menu - clear_status() - - # 7. Create Steam Shortcut - show_status("Creating Steam shortcut") - shortcut_created = self._create_steam_shortcut() - clear_status() - if not shortcut_created: - self.logger.error("Workflow aborted: Failed to create Steam shortcut.") - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True # Return to menu - - # Print the AppID immediately after shortcut creation, before any other output - print("\n==================== Steam Shortcut Created ====================") - if self.initial_appid: - print(f"{COLOR_INFO}Initial Steam AppID (before Steam restart): {self.initial_appid}{COLOR_RESET}") - else: - print(f"{COLOR_ERROR}Warning: Could not determine initial AppID after shortcut creation.{COLOR_RESET}") - print("==============================================================\n") - - # 8. Handle Steam Restart & Manual Steps (Calls _print_default_status internally) - if not self._handle_steam_restart_and_manual_steps(): - # Status already cleared by the function if needed - self.logger.info("Workflow aborted: Steam restart/manual steps issue or user needs to re-run.") - return True # Return to menu, user needs to act - - # 9. Verify Manual Steps - # Move cursor up, return to start, clear line - attempt to overwrite input prompt line - print("\033[A\r\033[K", end="", flush=True) - show_status("Verifying Proton Setup") - while True: - if self._verify_manual_steps(): - show_status("Manual Steps Successful") - # Print the AppID after Steam restart and re-detection - if self.final_appid: - print(f"\n{COLOR_INFO}Final Steam AppID (after Steam restart): {self.final_appid}{COLOR_RESET}") - else: - print(f"\n{COLOR_ERROR}Warning: Could not determine AppID after Steam restart.{COLOR_RESET}") - break # Verification successful - else: - self.logger.warning("Manual steps verification failed.") - clear_status() # Clear status before printing error/prompt - print(f"\n{COLOR_ERROR}Verification failed. Please ensure you have completed all manual steps correctly.{COLOR_RESET}") - self._display_manual_proton_steps() # Re-display steps - try: - # Add a newline before the input prompt for clarity - response = input(f"\n{COLOR_PROMPT}Press Enter to retry verification, or 'q' to quit: {COLOR_RESET}").lower() - if response == 'q': - self.logger.warning("User quit during verification loop.") - return True # Return to menu, aborting config - show_status("Retrying Verification") - except KeyboardInterrupt: - clear_status() - print("\nOperation cancelled by user.") - self.logger.warning("User cancelled during verification loop.") - return True # Return to menu - - # --- Start Actual Configuration --- - self.logger.info(f"Starting final configuration for AppID {self.final_appid}...") - # logger.info("--- Configuration --- Applying final configurations...") # Keep this log for file - - # Check console level for verbose output - verbose_console = False - for handler in logging.getLogger().handlers: - if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): - if handler.level <= logging.INFO: # Check if INFO or DEBUG - verbose_console = True - break - - if verbose_console: - print(f"{COLOR_INFO}Applying final configurations...{COLOR_RESET}") - - # 10. Set Protontricks Permissions (Flatpak) - show_status("Setting Protontricks permissions") - if not self.protontricks_handler.set_protontricks_permissions(str(self.install_path), self.steamdeck): - self.logger.warning("Failed to set Flatpak Protontricks permissions. Continuing, but subsequent steps might fail if Flatpak Protontricks is used.") - clear_status() # Clear status before printing warning - print(f"\n{COLOR_ERROR}Warning: Could not set Flatpak permissions automatically.{COLOR_RESET}") - - # 12. Download WebView Installer (Check happens BEFORE setting prefix) - show_status("Checking WebView Installer") - if not self._download_webview_installer(): - self.logger.error("Workflow aborted: Failed to download WebView installer.") - # Error message printed by the download function - clear_status() - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True # Return to menu - - # 13. Configure Prefix (Set to Win7 for WebView install) - show_status("Applying Initial Win7 Registry Settings (for WebView install)") - try: - import requests - # Download minimal Win7 system.reg (corrected URL) - system_reg_win7_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.wj.win7" - system_reg_dest = self.compatdata_path / 'pfx' / 'system.reg' - system_reg_dest.parent.mkdir(parents=True, exist_ok=True) - self.logger.info(f"Downloading system.reg.wj.win7 from {system_reg_win7_url} to {system_reg_dest}") - response = requests.get(system_reg_win7_url, verify=True) - response.raise_for_status() - with open(system_reg_dest, "wb") as f: - f.write(response.content) - self.logger.info(f"system.reg.wj.win7 downloaded and applied to {system_reg_dest}") - except Exception as e: - self.logger.error(f"Failed to download or apply initial Win7 system.reg: {e}") - print(f"{COLOR_ERROR}Error: Failed to download or apply initial Win7 system.reg. {e}{COLOR_RESET}") - clear_status() - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True - - # 14. Install WebView (using protontricks-launch) - show_status("Installing WebView (Edge)") - webview_installer_path = self.install_path / "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" - webview_result = self.protontricks_handler.run_protontricks_launch( - self.final_appid, webview_installer_path, "/silent", "/install" - ) - self.logger.debug(f"WebView install result: {webview_result}") - if not webview_result or webview_result.returncode != 0: - self.logger.error("WebView installation failed via protontricks-launch.") - print(f"{COLOR_ERROR}Error: WebView installation failed via protontricks-launch.{COLOR_RESET}") - clear_status() - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True - show_status("WebView installation Complete") - - # 15. Configure Prefix (Part 2 - Final Settings) - show_status("Applying Final Registry Settings") - try: - # Download final system.reg - system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.wj" - system_reg_dest = self.compatdata_path / 'pfx' / 'system.reg' - self.logger.info(f"Downloading final system.reg from {system_reg_url} to {system_reg_dest}") - response = requests.get(system_reg_url, verify=True) - response.raise_for_status() - with open(system_reg_dest, "wb") as f: - f.write(response.content) - self.logger.info(f"Final system.reg downloaded and applied to {system_reg_dest}") - # Download final user.reg - user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.wj" - user_reg_dest = self.compatdata_path / 'pfx' / 'user.reg' - self.logger.info(f"Downloading final user.reg from {user_reg_url} to {user_reg_dest}") - response = requests.get(user_reg_url, verify=True) - response.raise_for_status() - with open(user_reg_dest, "wb") as f: - f.write(response.content) - self.logger.info(f"Final user.reg downloaded and applied to {user_reg_dest}") - except Exception as e: - self.logger.error(f"Failed to download or apply final user.reg/system.reg: {e}") - print(f"{COLOR_ERROR}Error: Failed to download or apply final user.reg/system.reg. {e}{COLOR_RESET}") - clear_status() - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True - - # 16. Configure Prefix Steam Library VDF - show_status("Configuring Steam Library in Prefix") - if not self._create_prefix_library_vdf(): return False - - # 17. Create Dotnet Bundle Cache Directory - show_status("Creating .NET Cache Directory") - if not self._create_dotnet_cache_dir(): - self.logger.error("Workflow aborted: Failed to create dotnet cache directory.") - clear_status() - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True # Return to menu - - # --- Final Steps --- - # Check for and optionally apply Flatpak overrides *before* final cleanup/completion - self._check_and_prompt_flatpak_overrides() - - # Attempt to clean up any stray Wine/Protontricks processes as a final measure - self.logger.info("Performing final Wine process cleanup...") - try: - # Ensure the ProtontricksHandler instance exists and has the method - if hasattr(self, 'protontricks_handler') and hasattr(self.protontricks_handler, '_cleanup_wine_processes'): - self.protontricks_handler._cleanup_wine_processes() - self.logger.info("Wine process cleanup command executed.") - else: - self.logger.warning("Protontricks handler or cleanup method not available, skipping cleanup.") - except Exception as cleanup_e: - self.logger.error(f"Error during final Wine process cleanup: {cleanup_e}", exc_info=True) - # Don't abort the whole workflow for a cleanup failure, just log it. - - # 18b. Display Completion Message - clear_status() - self._display_completion_message() - - # End of successful workflow - self.logger.info("Wabbajack installation workflow completed successfully.") - clear_status() # Clear status before final prompt - input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") - return True # Return to menu - - def _display_summary(self): - """Displays a summary of settings (no confirmation prompt).""" - if not self.install_path or not self.shortcut_name: - self.logger.error("Cannot display summary: Install path or shortcut name missing.") - return False # Should not happen if called at the right time - print("\n───────────────────────────────────────────────────────────────────") - print(f"{COLOR_PROMPT}--- Installation Summary ---{COLOR_RESET}") - print(f" Install Path: {self.install_path}") - print(f" Shortcut Name: {self.shortcut_name}") - print(f" Environment: {'Steam Deck' if self.steamdeck else 'Desktop Linux'}") - print(f" Protontricks: {self.protontricks_handler.which_protontricks or 'Unknown'}") - print("───────────────────────────────────────────────────────────────────") - return True - - def _backup_and_replace_final_reg_files(self) -> bool: - """Backs up current reg files and replaces them with the final downloaded versions.""" - if not self.compatdata_path: - self.logger.error("Cannot backup/replace reg files: compatdata_path not set.") - return False - - pfx_path = self.compatdata_path / 'pfx' - system_reg = pfx_path / 'system.reg' - user_reg = pfx_path / 'user.reg' - system_reg_bak = pfx_path / 'system.reg.orig' - user_reg_bak = pfx_path / 'user.reg.orig' - - # Backup existing files - self.logger.info("Backing up existing registry files...") - logger.info("Backing up current registry files...") - try: - if system_reg.exists(): - shutil.copy2(system_reg, system_reg_bak) - self.logger.debug(f"Backed up {system_reg} to {system_reg_bak}") - else: - self.logger.warning(f"Original {system_reg} not found for backup.") - - if user_reg.exists(): - shutil.copy2(user_reg, user_reg_bak) - self.logger.debug(f"Backed up {user_reg} to {user_reg_bak}") - else: - self.logger.warning(f"Original {user_reg} not found for backup.") - - except Exception as e: - self.logger.error(f"Error backing up registry files: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error backing up registry files: {e}{COLOR_RESET}") - return False # Treat backup failure as critical? - - # Define final registry file URLs - final_system_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/system.reg.github" - final_user_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/user.reg.github" - - # Download and replace - logger.info("Downloading and applying final registry settings...") - system_ok = self._download_and_replace_reg_file(final_system_reg_url, system_reg) - user_ok = self._download_and_replace_reg_file(final_user_reg_url, user_reg) - - if system_ok and user_ok: - self.logger.info("Successfully applied final registry files.") - return True - else: - self.logger.error("Failed to download or replace one or both final registry files.") - print(f"{COLOR_ERROR}Error: Failed to apply final registry settings.{COLOR_RESET}") - # Should we attempt to restore backups here? - return False - - def _install_webview(self) -> bool: - """Installs the WebView2 runtime using protontricks-launch.""" - if not self.final_appid or not self.install_path: - self.logger.error("Cannot install WebView: final_appid or install_path not set.") - return False - - installer_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" - installer_path = self.install_path / installer_name - - if not installer_path.is_file(): - self.logger.error(f"WebView installer not found at {installer_path}. Cannot install.") - print(f"{COLOR_ERROR}Error: WebView installer file missing. Please ensure step 12 completed.{COLOR_RESET}") - return False - - self.logger.info(f"Starting WebView installation for AppID {self.final_appid}...") - # Remove print, handled by caller - # print("\nInstalling WebView (this can take a while, please be patient)...") - - cmd_prefix = [] - if self.protontricks_handler.which_protontricks == 'flatpak': - # Using full command path is safer than relying on alias being sourced - cmd_prefix = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks"] - else: - launch_path = shutil.which("protontricks-launch") - if not launch_path: - self.logger.error("protontricks-launch command not found in PATH.") - print(f"{COLOR_ERROR}Error: protontricks-launch command not found.{COLOR_RESET}") - return False - cmd_prefix = [launch_path] - - # Arguments for protontricks-launch - args = ["--appid", self.final_appid, str(installer_path), "/silent", "/install"] - full_cmd = cmd_prefix + args - - self.logger.debug(f"Executing WebView install command: {' '.join(full_cmd)}") - - try: - # Use check=True to raise CalledProcessError on non-zero exit - # Set a longer timeout as this can take time. - result = subprocess.run(full_cmd, check=True, capture_output=True, text=True, timeout=600) # 10 minute timeout - self.logger.info("WebView installation command completed successfully.") - # Do NOT log result.stdout or result.stderr here - return True - except FileNotFoundError: - self.logger.error(f"Command not found: {cmd_prefix[0]}") - print(f"{COLOR_ERROR}Error: Could not execute {cmd_prefix[0]}. Is it installed correctly?{COLOR_RESET}") - return False - except subprocess.TimeoutExpired: - self.logger.error("WebView installation timed out after 10 minutes.") - print(f"{COLOR_ERROR}Error: WebView installation took too long and timed out.{COLOR_RESET}") - return False - except subprocess.CalledProcessError as e: - self.logger.error(f"WebView installation failed with return code {e.returncode}") - # Only log a short snippet of output for debugging - self.logger.error(f"STDERR (truncated):\n{e.stderr[:500] if e.stderr else ''}") - print(f"{COLOR_ERROR}Error: WebView installation failed (Return Code: {e.returncode}). Check logs for details.{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Unexpected error during WebView installation: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred during WebView installation: {e}{COLOR_RESET}") - return False - - def _find_steam_library_and_vdf_path(self) -> Tuple[Optional[Path], Optional[Path]]: - """Finds the Steam library root and the path to the real libraryfolders.vdf.""" - self.logger.info("Attempting to find Steam library and libraryfolders.vdf...") - try: - # Check if PathHandler uses static methods or needs instantiation - if isinstance(self.path_handler, type): - common_path = self.path_handler.find_steam_library() - else: - common_path = self.path_handler.find_steam_library() - - if not common_path or not common_path.is_dir(): - self.logger.error("Could not find Steam library common path.") - return None, None - - # Navigate up to find the library root - library_root = common_path.parent.parent # steamapps/common -> steamapps -> library_root - self.logger.debug(f"Deduced library root: {library_root}") - - # Construct path to the real libraryfolders.vdf - # Common locations relative to library root - vdf_path_candidates = [ - library_root / 'config/libraryfolders.vdf', # For non-Flatpak? ~/.steam/steam/config - library_root / '../config/libraryfolders.vdf' # Flatpak? ~/.var/app/../Steam/config - ] - - real_vdf_path = None - for candidate in vdf_path_candidates: - resolved_candidate = candidate.resolve() # Resolve symlinks/.. parts - if resolved_candidate.is_file(): - real_vdf_path = resolved_candidate - self.logger.info(f"Found real libraryfolders.vdf at: {real_vdf_path}") - break - - if not real_vdf_path: - self.logger.error(f"Could not find libraryfolders.vdf within library root: {library_root}") - return None, None - - return library_root, real_vdf_path - - except Exception as e: - self.logger.error(f"Error finding Steam library/VDF: {e}", exc_info=True) - return None, None - - def _link_steam_library_config(self) -> bool: - """Creates the necessary directory structure and symlinks libraryfolders.vdf.""" - if not self.compatdata_path: - self.logger.error("Cannot link Steam library: compatdata_path not set.") - return False - - self.logger.info("Linking Steam library configuration (libraryfolders.vdf)...") - - library_root, real_vdf_path = self._find_steam_library_and_vdf_path() - if not library_root or not real_vdf_path: - print(f"{COLOR_ERROR}Error: Could not locate Steam library or libraryfolders.vdf.{COLOR_RESET}") - return False - - target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' - link_path = target_dir / 'libraryfolders.vdf' - - try: - # Backup the original libraryfolders.vdf before doing anything else - # Use FileSystemHandler for consistency - NOW USE INSTANCE - self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") - if not self.filesystem_handler.backup_file(real_vdf_path): - self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") - # Optionally, prompt user or fail here? For now, just warn. - print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") - - # Create the target directory - self.logger.debug(f"Creating directory: {target_dir}") - os.makedirs(target_dir, exist_ok=True) - - # Remove existing symlink if it exists - if link_path.is_symlink(): - self.logger.debug(f"Removing existing symlink at {link_path}") - link_path.unlink() - elif link_path.exists(): - # It exists but isn't a symlink - this is unexpected - self.logger.warning(f"Path {link_path} exists but is not a symlink. Removing it.") - if link_path.is_dir(): - shutil.rmtree(link_path) - else: - link_path.unlink() - - # Create the symlink - self.logger.info(f"Creating symlink from {real_vdf_path} to {link_path}") - os.symlink(real_vdf_path, link_path) - - # Verification (optional but good) - if link_path.is_symlink() and link_path.resolve() == real_vdf_path.resolve(): - self.logger.info("Symlink created and verified successfully.") - return True - else: - self.logger.error("Symlink creation failed or verification failed.") - return False - - except OSError as e: - self.logger.error(f"OSError during symlink creation: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error creating Steam library link: {e}{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Unexpected error during symlink creation: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - return False - - def _create_prefix_library_vdf(self) -> bool: - """Creates the necessary directory structure and copies a modified libraryfolders.vdf.""" - if not self.compatdata_path: - self.logger.error("Cannot create prefix VDF: compatdata_path not set.") - return False - - self.logger.info("Creating modified libraryfolders.vdf in prefix...") - - # 1. Find the real host VDF file - library_root, real_vdf_path = self._find_steam_library_and_vdf_path() - if not real_vdf_path: - # Error logged by _find_steam_library_and_vdf_path - print(f"{COLOR_ERROR}Error: Could not locate real libraryfolders.vdf.{COLOR_RESET}") - return False - - # 2. Backup the real VDF file - self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") - if not self.filesystem_handler.backup_file(real_vdf_path): - self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") - print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") - - # 3. Define target location in prefix - target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' - target_vdf_path = target_dir / 'libraryfolders.vdf' - - try: - # 4. Read the content of the real VDF - self.logger.debug(f"Reading content from {real_vdf_path}") - vdf_content = real_vdf_path.read_text(encoding='utf-8') - - # 5. Convert Linux paths to Wine paths within the content string - modified_content = vdf_content - # Regex to find "path" "/linux/path" entries reliably - path_pattern = re.compile(r'("path"\s*")([^"]+)(")') - - # Use a function for replacement logic to handle potential errors - def replace_path(match): - prefix, linux_path_str, suffix = match.groups() - self.logger.debug(f"Found path entry to convert: {linux_path_str}") - try: - linux_path = Path(linux_path_str) - # Check if it's an SD card path - if self.filesystem_handler.is_sd_card(linux_path): - # Assuming SD card maps to D: - # Remove prefix like /run/media/mmcblk0p1/ - relative_sd_path_str = self.filesystem_handler._strip_sdcard_path_prefix(linux_path) - wine_path = "D:\\" + relative_sd_path_str.replace('/', '\\') - self.logger.debug(f" Converted SD card path: {linux_path_str} -> {wine_path}") - else: - # Assume non-SD maps relative to Z: - # Need the full path prefixed with Z: - wine_path = "Z:\\" + linux_path_str.strip('/').replace('/', '\\') - self.logger.debug(f" Converted standard path: {linux_path_str} -> {wine_path}") - - # Ensure backslashes are doubled for VDF format - wine_path_vdf_escaped = wine_path.replace('\\', '\\\\') - return f'{prefix}{wine_path_vdf_escaped}{suffix}' - except Exception as e: - self.logger.error(f"Error converting path '{linux_path_str}': {e}. Keeping original.") - return match.group(0) # Return original match on error - - # Perform the replacement using re.sub with the function - modified_content = path_pattern.sub(replace_path, vdf_content) - - # Log comparison if content changed (optional) - if modified_content != vdf_content: - self.logger.info("Successfully converted Linux paths to Wine paths in VDF content.") - else: - self.logger.warning("VDF content unchanged after conversion attempt. Did it contain Linux paths?") - - # 6. Ensure target directory exists - self.logger.debug(f"Ensuring target directory exists: {target_dir}") - os.makedirs(target_dir, exist_ok=True) - - # 7. Write the modified content to the target file in the prefix - self.logger.info(f"Writing modified VDF content to {target_vdf_path}") - target_vdf_path.write_text(modified_content, encoding='utf-8') - - # 8. Verification (optional: check file exists and content) - if target_vdf_path.is_file(): - self.logger.info("Prefix libraryfolders.vdf created successfully.") - return True - else: - self.logger.error("Failed to create prefix libraryfolders.vdf.") - return False - - except Exception as e: - self.logger.error(f"Error processing or writing prefix libraryfolders.vdf: {e}", exc_info=True) - print(f"{COLOR_ERROR}An error occurred configuring the Steam library in the prefix: {e}{COLOR_RESET}") - return False - - def _create_dotnet_cache_dir(self) -> bool: - """Creates the dotnet_bundle_extract cache directory.""" - if not self.install_path: - self.logger.error("Cannot create dotnet cache dir: install_path not set.") - return False - - try: - # Get username reliably - username = pwd.getpwuid(os.getuid()).pw_name - # Fallback if pwd fails for some reason? - # username = os.getlogin() # Can fail in some environments - except Exception as e: - self.logger.error(f"Could not determine username: {e}") - print(f"{COLOR_ERROR}Error: Could not determine username to create cache directory.{COLOR_RESET}") - return False - - cache_dir = self.install_path / 'home' / username / '.cache' / 'dotnet_bundle_extract' - self.logger.info(f"Creating dotnet bundle cache directory: {cache_dir}") - - try: - os.makedirs(cache_dir, exist_ok=True) - # Optionally set permissions? The bash script didn't explicitly. - self.logger.info("dotnet cache directory created successfully.") - return True - except OSError as e: - self.logger.error(f"Failed to create dotnet cache directory {cache_dir}: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error creating dotnet cache directory: {e}{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Unexpected error creating dotnet cache directory: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - return False - - def _check_and_prompt_flatpak_overrides(self): - """Checks if Flatpak Steam needs filesystem overrides and prompts the user to apply them.""" - self.logger.info("Checking for necessary Flatpak Steam filesystem overrides...") - is_flatpak_steam = False - # Use compatdata_path as indicator - if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): - is_flatpak_steam = True - self.logger.debug("Flatpak Steam detected based on compatdata path.") - # Add other checks if needed (e.g., check if `flatpak info com.valvesoftware.Steam` runs) - - if not is_flatpak_steam: - self.logger.info("Flatpak Steam not detected, skipping override check.") - return - - paths_to_check = [] - if self.install_path: - paths_to_check.append(self.install_path) - - # Get all library paths from libraryfolders.vdf - try: - all_libs = self.path_handler.get_all_steam_libraries() - paths_to_check.extend(all_libs) - except Exception as e: - self.logger.warning(f"Could not get all Steam libraries to check for overrides: {e}") - - needed_overrides = set() # Use a set to store unique parent paths needing override - home_dir = Path.home() - flatpak_steam_data_dir = home_dir / ".var/app/com.valvesoftware.Steam" - - for path in paths_to_check: - if not path: - continue - resolved_path = path.resolve() - # Check if path is outside $HOME AND outside the Flatpak data dir - is_outside_home = not str(resolved_path).startswith(str(home_dir)) - is_outside_flatpak_data = not str(resolved_path).startswith(str(flatpak_steam_data_dir)) - - if is_outside_home and is_outside_flatpak_data: - # Need override for the parent directory containing this path - # Go up levels until we find a reasonable base (e.g., /mnt/Games, /data/Steam) - # Avoid adding /, /home, etc. - parent_to_add = resolved_path.parent - while parent_to_add != parent_to_add.parent and len(str(parent_to_add)) > 1 and parent_to_add.name != 'home': - # Check if adding this parent makes sense (e.g., it exists, not too high up) - if parent_to_add.is_dir(): # Simple check for existence - # Further heuristics could be added here - needed_overrides.add(str(parent_to_add)) - self.logger.debug(f"Path {resolved_path} is outside sandbox. Adding parent {parent_to_add} to needed overrides.") - break # Add the first reasonable parent found - parent_to_add = parent_to_add.parent - - if not needed_overrides: - self.logger.info("No external paths requiring Flatpak overrides detected.") - return - - # Construct the command string(s) - override_commands = [] - for path_str in sorted(list(needed_overrides)): - # Add specific path override - override_commands.append(f"flatpak override --user --filesystem=\"{path_str}\" com.valvesoftware.Steam") - - # Combine into a single string for display, but keep list for execution - command_display = "\n".join([f" {cmd}" for cmd in override_commands]) - - print(f"\n{COLOR_PROMPT}--- Flatpak Steam Permissions ---{COLOR_RESET}") - print("Jackify has detected that you are using Flatpak Steam and have paths") - print("(e.g., Wabbajack install location or other Steam libraries) outside") - print("the standard Flatpak sandbox. For Wabbajack to access these locations,") - print("Steam needs the following filesystem permissions:") - print(f"{COLOR_INFO}{command_display}{COLOR_RESET}") - print("───────────────────────────────────────────────────────────────────") - - try: - confirm = input(f"{COLOR_PROMPT}Do you want Jackify to apply these permissions now? (y/N): {COLOR_RESET}").lower().strip() - if confirm == 'y': - self.logger.info("User confirmed applying Flatpak overrides.") - success_count = 0 - for cmd_str in override_commands: - self.logger.info(f"Executing: {cmd_str}") - try: - # Split command string for subprocess - cmd_list = cmd_str.split() - result = subprocess.run(cmd_list, check=True, capture_output=True, text=True, timeout=30) - self.logger.debug(f"Override command successful: {result.stdout}") - success_count += 1 - except FileNotFoundError: - self.logger.error(f"'flatpak' command not found. Cannot apply override: {cmd_str}") - print(f"{COLOR_ERROR}Error: 'flatpak' command not found.{COLOR_RESET}") - break # Stop trying if flatpak isn't found - except subprocess.TimeoutExpired: - self.logger.error(f"Flatpak override command timed out: {cmd_str}") - print(f"{COLOR_ERROR}Error: Command timed out: {cmd_str}{COLOR_RESET}") - except subprocess.CalledProcessError as e: - self.logger.error(f"Flatpak override failed: {cmd_str}. Error: {e.stderr}") - print(f"{COLOR_ERROR}Error applying override: {cmd_str}\n{e.stderr}{COLOR_RESET}") - except Exception as e: - self.logger.error(f"Unexpected error applying override {cmd_str}: {e}") - print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - - if success_count == len(override_commands): - print(f"{COLOR_INFO}Successfully applied necessary Flatpak permissions.{COLOR_RESET}") - else: - print(f"{COLOR_ERROR}Applied {success_count}/{len(override_commands)} permissions. Some overrides may have failed. Check logs.{COLOR_RESET}") - else: - self.logger.info("User declined applying Flatpak overrides.") - print("Permissions not applied. You may need to run the override command(s) manually") - print("if Wabbajack has issues accessing files or game installations.") - - except KeyboardInterrupt: - print("\nOperation cancelled by user.") - self.logger.warning("User cancelled during Flatpak override prompt.") - except Exception as e: - self.logger.error(f"Error during Flatpak override prompt/execution: {e}") - - def _disable_prefix_decoration(self) -> bool: - """Disables window manager decoration in the Wine prefix using protontricks -c.""" - if not self.final_appid: - self.logger.error("Cannot disable decoration: final_appid not set.") - return False - - self.logger.info(f"Disabling window manager decoration for AppID {self.final_appid} via -c 'wine reg add...'") - # Original command string - command = 'wine reg add "HKCU\\Software\\Wine\\X11 Driver" /v Decorated /t REG_SZ /d N /f' - - try: - # Ensure ProtontricksHandler is available - if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: - self.logger.critical("ProtontricksHandler not initialized!") - print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") - return False - - # Use the original -c method - result = self.protontricks_handler.run_protontricks( - '-c', - command, - self.final_appid # AppID comes last for -c commands - ) - - # Check the return code - if result and result.returncode == 0: - self.logger.info("Successfully disabled window decoration (command returned 0).") - # Add a small delay just in case there's a write lag? - time.sleep(1) - return True - else: - err_msg = result.stderr if result else "Command execution failed or returned non-zero" - # Add stdout to error message if stderr is empty - if result and not result.stderr and result.stdout: - err_msg += f"\nSTDOUT: {result.stdout}" - self.logger.error(f"Failed to disable window decoration via -c. Error: {err_msg}") - print(f"{COLOR_ERROR}Error: Failed to disable window decoration via protontricks -c.{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Exception disabling window decoration: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error disabling window decoration: {e}.{COLOR_RESET}") - return False - - def _display_completion_message(self): - """Displays the final success message and next steps.""" - # Basic log file path (assuming standard location) - # TODO: Get log file path more reliably if needed - from jackify.shared.paths import get_jackify_logs_dir - log_path = get_jackify_logs_dir() / "jackify-cli.log" - - print("\n───────────────────────────────────────────────────────────────────") - print(f"{COLOR_INFO}Wabbajack Installation Completed Successfully!{COLOR_RESET}") - print("───────────────────────────────────────────────────────────────────") - print("Next Steps:") - print(f" • Launch '{COLOR_INFO}{self.shortcut_name or 'Wabbajack'}{COLOR_RESET}' through Steam.") - print(f" • When Wabbajack opens, log in to Nexus using the Settings button (cog icon).") - print(f" • Once logged in, you can browse and install modlists as usual!") - - # Check for Flatpak Steam (Placeholder check) - # A more robust check might involve inspecting self.path_handler findings or config - # For now, check if compatdata path hints at flatpak - is_flatpak_steam = False - if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): - is_flatpak_steam = True - - if is_flatpak_steam: - self.logger.info("Detected Flatpak Steam usage.") - print(f"\n{COLOR_PROMPT}Note: Flatpak Steam Detected:{COLOR_RESET}") - print(f" You may need to grant Wabbajack filesystem access for modlist downloads/installations.") - print(f" Example: If installing to \"/home/{os.getlogin()}/Games/SkyrimSEModlist\", run:") - print(f" {COLOR_INFO}flatpak override --user --filesystem=/home/{os.getlogin()}/Games com.valvesoftware.Steam{COLOR_RESET}") - - print(f"\nDetailed log available at: {log_path}") - print("───────────────────────────────────────────────────────────────────") - - def _download_wabbajack_executable(self) -> bool: - """ - Downloads the latest Wabbajack.exe to the install directory. - Checks existence first. - - Returns: - bool: True on success or if file exists, False otherwise. - """ - if not self.install_path: - self.logger.error("Cannot download Wabbajack.exe: install_path is not set.") - return False - - url = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe" - destination = self.install_path / "Wabbajack.exe" - - # Check if file exists first - if destination.is_file(): - self.logger.info(f"Wabbajack.exe already exists at {destination}. Skipping download.") - # print("Wabbajack.exe already present.") # Replaced by logger - return True - - # print(f"\nDownloading latest Wabbajack.exe...") # Replaced by logger - self.logger.info("Wabbajack.exe not found. Downloading...") - if self._download_file(url, destination): - # print("Wabbajack.exe downloaded successfully.") # Replaced by logger - # Set executable permissions - try: - os.chmod(destination, 0o755) - self.logger.info(f"Set execute permissions on {destination}") - except Exception as e: - self.logger.warning(f"Could not set execute permission on {destination}: {e}") - print(f"{COLOR_ERROR}Warning: Could not set execute permission on Wabbajack.exe.{COLOR_RESET}") - return True - else: - self.logger.error("Failed to download Wabbajack.exe.") - # Error message printed by _download_file - return False - - def _create_steam_shortcut(self) -> bool: - """ - Creates the Steam shortcut for Wabbajack using the ShortcutHandler. - - Returns: - bool: True on success, False otherwise. - """ - if not self.shortcut_name or not self.install_path: - self.logger.error("Cannot create shortcut: Missing shortcut name or install path.") - return False - - self.logger.info(f"Creating Steam shortcut '{self.shortcut_name}'...") - executable_path = str(self.install_path / "Wabbajack.exe") - - # Ensure the ShortcutHandler instance exists - # 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=self.shortcut_name, - exe_path=executable_path, - start_dir=os.path.dirname(executable_path), - launch_options="PROTON_USE_WINED3D=1 %command%", - tags=["Jackify", "Wabbajack"], - proton_version="proton_experimental" - ) - - if success and app_id: - self.initial_appid = app_id # Store the initially generated AppID - self.logger.info(f"Shortcut created successfully with initial AppID: {self.initial_appid}") - # Remove direct print, rely on status indicator from caller - # print(f"Steam shortcut '{self.shortcut_name}' created.") - return True - else: - self.logger.error("Failed to create Steam shortcut via ShortcutHandler.") - print(f"{COLOR_ERROR}Error: Failed to create the Steam shortcut for Wabbajack.{COLOR_RESET}") - # Further error details should be logged by the ShortcutHandler - return False - - # --- Helper Methods for Workflow Steps --- - - def _display_manual_proton_steps(self): - """Displays the detailed manual steps required for Proton setup.""" - if not self.shortcut_name: - self.logger.error("Cannot display manual steps: shortcut_name not set.") - print(f"{COLOR_ERROR}Internal Error: Shortcut name missing.{COLOR_RESET}") - return - - 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}{self.shortcut_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}{self.shortcut_name}{COLOR_RESET}' from your Steam Library") - print(" 8. Wait for Wabbajack to download its files and fully load") - print(" 9. Once Wabbajack has fully loaded, CLOSE IT completely and return here") - print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}") - - def _handle_steam_restart_and_manual_steps(self) -> bool: - """ - Handles Steam restart and manual steps prompt, with GUI mode support. - """ - self.logger.info("Handling Steam restart and manual steps prompt.") - clear_status() - - if os.environ.get('JACKIFY_GUI_MODE'): - # GUI mode: emit prompt markers like ModlistMenuHandler does - print('[PROMPT:RESTART_STEAM]') - input() # Wait for GUI to send confirmation - print('[PROMPT:MANUAL_STEPS]') - input() # Wait for GUI to send confirmation - # Continue with verification as before - return True - else: - # CLI mode: original behavior - # Condensed message: only show essential manual steps guidance - print("\n───────────────────────────────────────────────────────────────────") - print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} After Steam restarts, follow the on-screen instructions to set Proton Experimental.") - print("───────────────────────────────────────────────────────────────────") - self.logger.info("Attempting secure Steam restart...") - show_status("Restarting Steam") - if not hasattr(self, 'shortcut_handler') or not self.shortcut_handler: - self.logger.critical("ShortcutHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Shortcut handler not available for restart.{COLOR_RESET}") - return False - if self.shortcut_handler.secure_steam_restart(): - self.logger.info("Secure Steam restart successful.") - clear_status() - self._display_manual_proton_steps() - print() - input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") - self.logger.info("User confirmed completion of manual steps.") - return True - else: - self.logger.error("Secure Steam restart failed.") - clear_status() - print(f"\n{COLOR_ERROR}Error: Steam restart failed.{COLOR_RESET}") - print("Please try restarting Steam manually:") - print("1. Exit Steam completely (Steam -> Exit or right-click tray icon -> Exit)") - print("2. Wait a few seconds") - print("3. Start Steam again") - print("\nAfter restarting, you MUST perform the manual Proton setup steps:") - self._display_manual_proton_steps() - print(f"\n{COLOR_ERROR}You will need to re-run this Jackify option after completing these steps.{COLOR_RESET}") - print("───────────────────────────────────────────────────────────────────") - return False - - def _redetect_appid(self) -> bool: - """ - Re-detects the AppID for the shortcut after Steam restart. - - Returns: - bool: True if AppID is found, False otherwise. - """ - if not self.shortcut_name: - self.logger.error("Cannot redetect AppID: shortcut_name not set.") - return False - - self.logger.info(f"Re-detecting AppID for shortcut '{self.shortcut_name}'...") - try: - # Ensure the ProtontricksHandler instance exists - if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: - self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") - return False - - all_shortcuts = self.protontricks_handler.list_non_steam_shortcuts() - - if not all_shortcuts: - self.logger.error("Protontricks listed no non-Steam shortcuts.") - return False - - found_appid = None - for name, appid in all_shortcuts.items(): - if name.lower() == self.shortcut_name.lower(): - found_appid = appid - break - - if found_appid: - self.final_appid = found_appid - self.logger.info(f"Successfully re-detected AppID: {self.final_appid}") - if self.initial_appid and self.initial_appid != self.final_appid: - # Change Warning to Info - this is expected behavior - self.logger.info(f"AppID changed after Steam restart: {self.initial_appid} -> {self.final_appid}") - elif not self.initial_appid: - self.logger.warning("Initial AppID was not set, cannot compare.") - return True - else: - self.logger.error(f"Shortcut '{self.shortcut_name}' not found in protontricks list after restart.") - return False - - except Exception as e: - self.logger.error(f"Error re-detecting AppID: {e}", exc_info=True) - return False - - def _find_steam_config_vdf(self) -> Optional[Path]: - """Finds the path to the primary Steam config.vdf file.""" - self.logger.debug("Searching for Steam config.vdf...") - # Use PathHandler if it has this logic? For now, check common paths. - common_paths = [ - Path.home() / ".steam/steam/config/config.vdf", - Path.home() / ".local/share/Steam/config/config.vdf", - Path.home() / ".var/app/com.valvesoftware.Steam/.config/Valve Corporation/Steam/config/config.vdf" # Check Flatpak path - ] - for path in common_paths: - if path.is_file(): - self.logger.info(f"Found config.vdf at: {path}") - return path - self.logger.error("Could not find Steam config.vdf in common locations.") - return None - - def _verify_manual_steps(self) -> bool: - """ - Verifies that the user has performed the manual steps using ModlistHandler. - Checks AppID, Proton version set, and prefix existence. - - Returns: - bool: True if verification passes AND compatdata_path is set, False otherwise. - """ - self.logger.info("Verifying manual Proton setup steps...") - self.compatdata_path = None # Explicitly reset before verification - - # 1. Re-detect AppID - # Clear status BEFORE potentially failing here - clear_status() - if not self._redetect_appid(): - print(f"{COLOR_ERROR}Error: Could not find the Steam shortcut '{self.shortcut_name}' using protontricks.{COLOR_RESET}") - print(f"{COLOR_INFO}Ensure Steam has restarted and the shortcut is visible.{COLOR_RESET}") - return False # Indicate failure - - self.logger.debug(f"Verification using final AppID: {self.final_appid}") - - # Add padding after user confirmation before the next status update - # Removed print() call - padding should come AFTER status clear - - # Print status JUST before calling the verification logic - show_status("Verifying Proton Setup") - - # Ensure ModlistHandler is available - if not hasattr(self, 'modlist_handler') or not self.modlist_handler: - self.logger.critical("ModlistHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Modlist handler not available for verification.{COLOR_RESET}") - return False - - # 2. Call the existing verification logic from ModlistHandler - verified, status_code = self.modlist_handler.verify_proton_setup(self.final_appid) - - if not verified: - # Handle Verification Failure Messages based on status_code - if status_code == 'wrong_proton_version': - proton_ver = getattr(self.modlist_handler, 'proton_ver', 'Unknown') - print(f"{COLOR_ERROR}\nVerification Failed: Incorrect Proton version detected ('{proton_ver}'). Expected 'Proton Experimental' (or similar).{COLOR_RESET}") - print(f"{COLOR_INFO}Please ensure you selected the correct Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") - elif status_code == 'proton_check_failed': - print(f"{COLOR_ERROR}\nVerification Failed: Compatibility tool not detected as set for '{self.shortcut_name}' in Steam config.{COLOR_RESET}") - print(f"{COLOR_INFO}Please ensure you forced a Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") - elif status_code == 'compatdata_missing': - print(f"{COLOR_ERROR}\nVerification Failed: Steam compatdata directory for AppID {self.final_appid} not found.{COLOR_RESET}") - print(f"{COLOR_INFO}Have you launched the shortcut '{self.shortcut_name}' at least once after setting Proton?{COLOR_RESET}") - elif status_code == 'prefix_missing': - print(f"{COLOR_ERROR}\nVerification Failed: Wine prefix directory (pfx) not found inside compatdata.{COLOR_RESET}") - print(f"{COLOR_INFO}This usually means the shortcut hasn't been launched successfully after setting Proton.{COLOR_RESET}") - elif status_code == 'config_vdf_missing' or status_code == 'config_vdf_error': - print(f"{COLOR_ERROR}\nVerification Failed: Could not read or parse Steam's config.vdf file ({status_code}).{COLOR_RESET}") - print(f"{COLOR_INFO}Check file permissions or integrity. Check logs for details.{COLOR_RESET}") - else: # General/unknown error - print(f"{COLOR_ERROR}\nVerification Failed: An unexpected error occurred ({status_code}). Check logs.{COLOR_RESET}") - return False # Indicate verification failure - - # If we reach here, basic verification passed (proton set, prefix exists) - # Now, ensure we have the compatdata path. - self.logger.info("Basic verification checks passed. Confirming compatdata path...") - - modlist_handler_compat_path = getattr(self.modlist_handler, 'compat_data_path', None) - if modlist_handler_compat_path: - self.compatdata_path = modlist_handler_compat_path - self.logger.info(f"Compatdata path obtained from ModlistHandler: {self.compatdata_path}") - else: - # If modlist_handler didn't set it, try path_handler - # Change Warning to Info - Fallback is acceptable - self.logger.info("ModlistHandler did not set compat_data_path. Attempting manual lookup via PathHandler.") - # Ensure path_handler is available - if not hasattr(self, 'path_handler') or not self.path_handler: - self.logger.critical("PathHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Path handler not available for verification.{COLOR_RESET}") - return False - - self.compatdata_path = self.path_handler.find_compat_data(self.final_appid) - if self.compatdata_path: - self.logger.info(f"Manually found compatdata path via PathHandler: {self.compatdata_path}") - else: - self.logger.error("Verification checks passed, but COULD NOT FIND compatdata path via ModlistHandler or PathHandler.") - print(f"{COLOR_ERROR}\nVerification Error: Basic checks passed, but failed to locate the compatdata directory for AppID {self.final_appid}.{COLOR_RESET}") - print(f"{COLOR_INFO}This is unexpected. Check Steam filesystem structure and logs.{COLOR_RESET}") - return False # CRITICAL: Return False if path is unobtainable - - # If we get here, verification passed AND we have the compatdata_path - self.logger.info("Manual steps verification successful (including path confirmation).") - logger.info(f"Verification successful! (AppID: {self.final_appid}, Path: {self.compatdata_path})") - return True - - def _download_webview_installer(self) -> bool: - """ - Downloads the specific WebView2 installer needed by Wabbajack. - Checks existence first. - - Returns: - bool: True on success or if file already exists correctly, False otherwise. - """ - if not self.install_path: - self.logger.error("Cannot download WebView installer: install_path is not set.") - return False - - url = "https://node10.sokloud.com/filebrowser/api/public/dl/yqVTbUT8/rwatch/WebView/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" - file_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" - destination = self.install_path / file_name - - self.logger.info(f"Checking WebView installer: {destination}") - # print(f"\nChecking required WebView installer ({file_name})...") # Replaced by logger - - if destination.is_file(): - self.logger.info(f"WebView installer {destination.name} already exists. Skipping download.") - # Consider adding a message here if verbose/debug? - return True - - # File doesn't exist, attempt download - self.logger.info(f"WebView installer not found locally. Downloading {file_name}...") - # Update status before starting download - Use a more user-friendly message - show_status("Downloading WebView Installer") - - if self._download_file(url, destination): - # Status will be cleared by caller or next step - return True - else: - self.logger.error(f"Failed to download WebView installer from {url}.") - # Error message already printed by _download_file - return False - - def _set_prefix_renderer(self, renderer: str = 'vulkan') -> bool: - """Sets the prefix renderer using protontricks.""" - if not self.final_appid: - self.logger.error("Cannot set renderer: final_appid not set.") - return False - - self.logger.info(f"Setting prefix renderer to {renderer} for AppID {self.final_appid}...") - try: - # Ensure the ProtontricksHandler instance exists - if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: - self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") - return False - - result = self.protontricks_handler.run_protontricks( - self.final_appid, - 'settings', - f'renderer={renderer}' - ) - if result and result.returncode == 0: - self.logger.info(f"Successfully set renderer to {renderer}.") - return True - else: - err_msg = result.stderr if result else "Command execution failed" - self.logger.error(f"Failed to set renderer to {renderer}. Error: {err_msg}") - print(f"{COLOR_ERROR}Error: Failed to set prefix renderer to {renderer}.{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Exception setting renderer: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error setting prefix renderer: {e}.{COLOR_RESET}") - return False - - def _download_and_replace_reg_file(self, url: str, target_reg_path: Path) -> bool: - """Downloads a .reg file and replaces the target file. - Always downloads and overwrites. - """ - self.logger.info(f"Downloading registry file from {url} to replace {target_reg_path}") - - # Always download and replace for registry files - if self._download_file(url, target_reg_path): - self.logger.info(f"Successfully downloaded and replaced {target_reg_path}") - return True - else: - self.logger.error(f"Failed to download/replace {target_reg_path} from {url}") - return False - -# Example usage (for testing - keep this section for easy module testing) -if __name__ == '__main__': - # Configure logging for standalone testing - logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') - - print("Testing Wabbajack Install Handler...") - # Simulate running on or off deck - test_on_deck = False - print(f"Simulating run with steamdeck={test_on_deck}") - - # Need dummy handlers for direct testing - class DummyProton: - which_protontricks = 'native' - def check_and_setup_protontricks(self): return True - def set_protontricks_permissions(self, path, steamdeck): return True - def enable_dotfiles(self, appid): return True - def _cleanup_wine_processes(self): pass - def run_protontricks(self, *args, **kwargs): return subprocess.CompletedProcess(args=[], returncode=0) - def list_non_steam_shortcuts(self): return {"Wabbajack": "12345"} - - class DummyShortcut: - def create_shortcut(self, *args, **kwargs): return True, "12345" - def secure_steam_restart(self): return True - - class DummyPath: - def find_compat_data(self, appid): return Path(f"/tmp/jackify_test/compatdata/{appid}") - def find_steam_library(self): return Path("/tmp/jackify_test/steam/steamapps/common") - - class DummyVDF: - @staticmethod - def load(path): - if "config.vdf" in str(path): - # Simulate structure needed for proton check - return {'UserLocalConfigStore': {'Software': {'Valve': {'Steam': {'apps': {'12345': {'CompatTool': 'proton_experimental'}}}}}}} - return {} - - handler = InstallWabbajackHandler( - steamdeck=test_on_deck, - protontricks_handler=DummyProton(), - shortcut_handler=DummyShortcut(), - path_handler=DummyPath(), - vdf_handler=DummyVDF(), - modlist_handler=ModlistHandler(), - filesystem_handler=FileSystemHandler() - ) - # Pre-create dummy compatdata dir for verification step - if not Path("/tmp/jackify_test/compatdata/12345/pfx").exists(): - os.makedirs("/tmp/jackify_test/compatdata/12345/pfx", exist_ok=True) - - handler.run_install_workflow() - - print("\nTesting completed.") \ No newline at end of file diff --git a/jackify/backend/handlers/menu_handler.py b/jackify/backend/handlers/menu_handler.py index ef8312b..2691139 100644 --- a/jackify/backend/handlers/menu_handler.py +++ b/jackify/backend/handlers/menu_handler.py @@ -10,7 +10,6 @@ import sys import logging import time import subprocess # Add subprocess import -# from datetime import datetime # Not used currently import argparse import re from typing import List, Dict, Optional @@ -37,6 +36,11 @@ from .mo2_handler import MO2Handler from jackify.shared.ui_utils import print_section_header from .completers import path_completer +try: + import readline +except ImportError: + readline = None + # Define exports for this module __all__ = [ 'MenuHandler', @@ -47,700 +51,11 @@ __all__ = [ # Initialize logger logger = logging.getLogger(__name__) -# --- Input Handling with Readline Tab Completion --- -# Simple function for basic input -def basic_input_prompt(message, **kwargs): - return input(message) - -# --- Readline for tab completion --- -READLINE_AVAILABLE = False -READLINE_HAS_PROMPT = False -READLINE_HAS_DISPLAY_HOOK = False -try: - import readline - READLINE_AVAILABLE = True - logging.debug("Readline imported for tab completion") - - # Check for the specific features we want to use - if hasattr(readline, 'set_prompt'): - READLINE_HAS_PROMPT = True - logging.debug("Readline has set_prompt capability") - else: - logging.debug("Readline does not have set_prompt capability, will use fallback") - - # Test readline tab completion functionality - try: - # Try to parse tab configuration to confirm readline is properly configured - readline.parse_and_bind('tab: complete') - logging.debug("Readline tab completion successfully configured") - except Exception as e: - logging.warning(f"Error configuring readline tab completion: {e}. Tab completion may be limited.") - - # Set better readline behavior for displaying completions if available - if hasattr(readline, 'set_completion_display_matches_hook'): - READLINE_HAS_DISPLAY_HOOK = True - logging.debug("Readline has completion display hook capability") - - def custom_display_completions(substitution, matches, longest_match_length): - """Custom function to display completions with better formatting""" - # Print a newline to avoid overwriting the prompt - print() - # Get terminal width - try: - import shutil - term_width = shutil.get_terminal_size().columns - except (ImportError, AttributeError): - term_width = 80 # Default fallback - - # Calculate how many completions to display per line - items_per_line = max(1, term_width // (longest_match_length + 2)) - - # Print completions in columns - for i, match in enumerate(matches): - print(f"{match:<{longest_match_length + 2}}", end='' if (i + 1) % items_per_line else '\n') - - if len(matches) % items_per_line != 0: - print() # Ensure we end with a newline - - # Re-display the prompt with the current input - use the safe approach - current_input = readline.get_line_buffer() - # Use the visual prompt string which may not be exactly what readline knows as the prompt - print(f"{COLOR_PROMPT}> {COLOR_RESET}{current_input}", end='', flush=True) - - try: - # Set the custom display function - readline.set_completion_display_matches_hook(custom_display_completions) - logging.debug("Custom completion display hook successfully set") - except Exception as e: - logging.warning(f"Error setting completion display hook: {e}. Using default display behavior.") - READLINE_HAS_DISPLAY_HOOK = False - else: - logging.debug("Readline doesn't have completion display hook capability, using default") -except ImportError: - READLINE_AVAILABLE = False - READLINE_HAS_PROMPT = False - READLINE_HAS_DISPLAY_HOOK = False - logging.warning("readline not available. Tab completion for paths will be disabled.") -except Exception as e: - READLINE_AVAILABLE = False - READLINE_HAS_PROMPT = False - READLINE_HAS_DISPLAY_HOOK = False - logging.warning(f"Error initializing readline: {e}. Tab completion for paths will be disabled.") - -# --- DEBUG PRINT --- -# --- END DEBUG PRINT --- - -class ModlistMenuHandler: - """ - Handles modlist-specific menu operations - """ - - def __init__(self, config_handler, test_mode=False): - """Initialize the ModlistMenuHandler with configuration""" - - self.config_handler = config_handler - self.test_mode = test_mode - self.exit_flag = False - self.logger = logging.getLogger(__name__) - - # Initialize handlers - try: - # Initialize filesystem handler first, others may depend on it - self.filesystem_handler = FileSystemHandler() - - # Initialize basic handlers - self.path_handler = PathHandler() - self.vdf_handler = VDFHandler() - - # Determine Steam Deck status using centralized detection - from ..services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - self.steamdeck = platform_service.is_steamdeck - - # Create the resolution handler - self.resolution_handler = ResolutionHandler() - - # Initialize menu handler for consistent UI - self.menu_handler = MenuHandler() - - # Initialize modlist handler - self.modlist_handler = ModlistHandler( - self.config_handler.settings, - steamdeck=self.steamdeck, - verbose=False, - filesystem_handler=self.filesystem_handler - ) - - self.shortcut_handler = self.modlist_handler.shortcut_handler - - # Initialize the wabbajack installation handler - self.install_wabbajack_handler = None - - except Exception as e: - self.logger.error(f"Error initializing ModlistMenuHandler: {e}") - # Initialize with defaults/empty to prevent errors - self.filesystem_handler = FileSystemHandler() - # Use centralized detection even in fallback - try: - from ..services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - self.steamdeck = platform_service.is_steamdeck - except: - self.steamdeck = False # Final fallback - self.modlist_handler = None - - def show_modlist_menu(self): - while True: - os.system('cls' if os.name == 'nt' else 'clear') - # Banner display handled by frontend - print_section_header('Modlist Configuration') - print(f"{COLOR_SELECTION}1.{COLOR_RESET} Configure a New modlist not yet in Steam") - print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure a modlist already in Steam") - print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") - choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}") - if choice == "1": - if not self._configure_new_modlist(): - return False - elif choice == "2": - if not self._configure_existing_modlist(): - return False - elif choice == "0": - logger.info("Returning to main menu from Modlist Configuration menu.") - return False - else: - logger.warning(f"Invalid menu selection: {choice}") - 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. - Returns the validated path or None if cancelled/invalid. - """ - self.logger.info("Prompting for ModOrganizer.exe path...") - print("\n" + "-" * 28) # Separator - print(f"{COLOR_PROMPT}Please provide the path to ModOrganizer.exe for your modlist.{COLOR_RESET}") - print(f"{COLOR_INFO}This is typically found in the modlist's installation directory.") - print(f"{COLOR_INFO}Example: ~/Games/MyModlist/ModOrganizer.exe") - print(f"{COLOR_INFO}You can also provide the path to the directory containing ModOrganizer.exe.") - - # Use the menu_handler's get_existing_file_path for consistency if self.menu_handler is available - # Note: self.menu_handler here is an instance of MenuHandler, not ModlistMenuHandler - if hasattr(self, 'menu_handler') and self.menu_handler is not None: - # get_existing_file_path will use its own standard prompting style internally - # We pass no_header=False so it shows its full prompt. - # The prompt_message here becomes the main instruction for get_existing_file_path. - path_result = self.menu_handler.get_existing_file_path( - prompt_message=f"Path to ModOrganizer.exe or its directory", - extension_filter=".exe", - no_header=False # Let get_existing_file_path handle its full prompt including separator - ) - if path_result is None: # User cancelled - self.logger.info("User cancelled ModOrganizer.exe path input via get_existing_file_path.") - return None - - path_str = str(path_result) - if os.path.isdir(path_str): - potential_mo2_path = os.path.join(path_str, "ModOrganizer.exe") - if os.path.isfile(potential_mo2_path): - self.logger.info(f"Found ModOrganizer.exe in directory: {potential_mo2_path}") - return potential_mo2_path - else: - print(f"\n{COLOR_ERROR}Error: ModOrganizer.exe not found in directory: {path_str}{COLOR_RESET}") - # Allow to try again - this might need a loop or rely on get_existing_file_path loop - return self._get_mo2_path() # Recursive call to try again, simple loop better - elif os.path.isfile(path_str) and os.path.basename(path_str).lower() == "modorganizer.exe": - self.logger.info(f"ModOrganizer.exe path validated: {path_str}") - return path_str - else: - print(f"\n{COLOR_ERROR}Error: Path is not ModOrganizer.exe or a directory containing it.{COLOR_RESET}") - return self._get_mo2_path() # Recursive call - - # Fallback to basic input if self.menu_handler is not available (should ideally not happen) - self.logger.warning("_get_mo2_path: self.menu_handler not available, using basic input as fallback.") - while True: - try: - # Basic input prompt if menu_handler isn't used - mo2_path_input = input(f"{COLOR_PROMPT}Enter path to ModOrganizer.exe (or 'q' to cancel): {COLOR_RESET}").strip() - if mo2_path_input.lower() == 'q': - self.logger.info("User cancelled ModOrganizer.exe path input (fallback).") - return None - - expanded_path = os.path.expanduser(mo2_path_input) - normalized_path = os.path.normpath(expanded_path) - - if os.path.isdir(normalized_path): - potential_mo2_path = os.path.join(normalized_path, "ModOrganizer.exe") - if os.path.isfile(potential_mo2_path): - self.logger.info(f"Found ModOrganizer.exe in directory (fallback): {potential_mo2_path}") - return potential_mo2_path - else: - print(f"{COLOR_ERROR}Error: ModOrganizer.exe not found in directory: {normalized_path}{COLOR_RESET}") - continue - - if not normalized_path.lower().endswith('modorganizer.exe'): - print(f"{COLOR_ERROR}Error: Path must be ModOrganizer.exe or a directory containing it.{COLOR_RESET}") - continue - if not os.path.isfile(normalized_path): - print(f"{COLOR_ERROR}Error: File does not exist: {normalized_path}{COLOR_RESET}") - continue - - self.logger.info(f"ModOrganizer.exe path validated (fallback): {normalized_path}") - return normalized_path - except KeyboardInterrupt: - print("\nOperation cancelled.") - self.logger.info("User cancelled ModOrganizer.exe path input via Ctrl+C (fallback).") - return None - except Exception as e: - self.logger.error(f"Error processing ModOrganizer.exe path (fallback): {e}") - print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}") - return None - - def _get_modlist_name(self) -> Optional[str]: - """ - Get the modlist name from user input. - Returns the validated name or None if cancelled. - """ - self.logger.info("Prompting for modlist name...") - - print("\n" + "-" * 28) # Separator - print(f"{COLOR_PROMPT}Please provide a name for your modlist.{COLOR_RESET}") - print(f"{COLOR_INFO}(This will be the name used for the Steam shortcut.){COLOR_RESET}") - - while True: - try: - modlist_name = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() - - if modlist_name.lower() == 'q': - self.logger.info("User cancelled modlist name input.") - return None - - if not modlist_name: - print(f"{COLOR_ERROR}Error: Name cannot be empty.{COLOR_RESET}") - continue - - if len(modlist_name) > 100: - print(f"{COLOR_ERROR}Error: Name is too long (max 100 characters).{COLOR_RESET}") - continue - - invalid_chars = '< > : " / \\ | ? *' # String of invalid chars for message - if any(char in modlist_name for char in invalid_chars.replace(' ','')): - print(f"{COLOR_ERROR}Error: Name contains invalid characters (e.g., {invalid_chars}).{COLOR_RESET}") - continue - - self.logger.info(f"Modlist name validated: {modlist_name}") - return modlist_name - - except KeyboardInterrupt: - print("\nOperation cancelled.") - self.logger.info("User cancelled modlist name input via Ctrl+C.") - return None - except Exception as e: - self.logger.error(f"Error processing modlist name: {e}") - print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}") - return None - - def _configure_new_modlist(self, default_modlist_dir=None, default_modlist_name=None): - """Handle configuration of a new modlist. Returns True to continue menu, False to exit.""" - # --- Get ModOrganizer.exe Path --- - if default_modlist_dir: - # Try to infer ModOrganizer.exe path - mo2_path = os.path.join(default_modlist_dir, "ModOrganizer.exe") - if not os.path.isfile(mo2_path): - print(f"{COLOR_ERROR}Could not find ModOrganizer.exe in {default_modlist_dir}{COLOR_RESET}") - mo2_path = self._get_mo2_path() - else: - mo2_path = self._get_mo2_path() - if not mo2_path: - return True - # --- Get Modlist Name --- - if default_modlist_name: - modlist_name = default_modlist_name - else: - modlist_name = self._get_modlist_name() - if not modlist_name: - return True - # Add a blank line for padding - print("") - try: - # --- Ensure SteamIcons directory is normalized before icon selection --- - mo2_dir = os.path.dirname(mo2_path) - # --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) --- - self.shortcut_handler.write_nxmhandler_ini(mo2_dir, mo2_path) - steam_icons_path = os.path.join(mo2_dir, "Steam Icons") - steamicons_path = os.path.join(mo2_dir, "SteamIcons") - if os.path.isdir(steam_icons_path) and not os.path.isdir(steamicons_path): - try: - os.rename(steam_icons_path, steamicons_path) - self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {mo2_dir}") - except Exception as e: - self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}") - self.logger.debug(f"[DEBUG] After normalization, SteamIcons exists: {os.path.isdir(steamicons_path)}") - # --- Use automated prefix workflow (replaces old manual workflow) --- - try: - mo2_dir = os.path.dirname(mo2_path) - install_dir = mo2_dir - - # Use automated prefix service for modern workflow - print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}") - - from ..services.automated_prefix_service import AutomatedPrefixService - prefix_service = AutomatedPrefixService() - - # Define progress callback for CLI with jackify-engine style timestamps - import time - start_time = time.time() - - def progress_callback(message): - 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}]" - print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}") - - # Run the automated workflow - result = prefix_service.run_working_workflow( - modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck - ) - - # Handle the result - if isinstance(result, tuple) and len(result) == 4: - if result[0] == "CONFLICT": - # Handle conflict - ask user what to do - conflicts = result[1] - print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}") - for i, conflict in enumerate(conflicts, 1): - print(f" {i}. Name: {conflict['name']}") - print(f" Executable: {conflict['exe']}") - print(f" Start Directory: {conflict['startdir']}") - print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}") - print(" 1. Use existing shortcut (recommended)") - print(" 2. Create new shortcut anyway") - choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip() - if choice == "1": - # Use existing shortcut - existing_appid = conflicts[0].get('appid') - if existing_appid: - context = { - "name": modlist_name, - "appid": str(existing_appid), - "path": mo2_dir, - "manual_steps_completed": True, - "resolution": None - } - return self.run_modlist_configuration_phase(context) - elif choice == "2": - # Create new shortcut - would need to handle this, but for now just fail - print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}") - return True - else: - print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}") - return True - else: - # Success - get the results - success, prefix_path, appid_int, last_timestamp = result - if success and appid_int: - context = { - "name": modlist_name, - "appid": str(appid_int), - "path": mo2_dir, - "manual_steps_completed": True, - "resolution": None - } - self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}") - return self.run_modlist_configuration_phase(context) - else: - print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}") - return True - else: - # Unexpected result format - print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}") - self.logger.error(f"Unexpected result format from automated workflow: {result}") - return True - except Exception as e: - self.logger.error(f"Error creating Steam shortcut: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}Failed to create Steam shortcut: {e}{COLOR_RESET}") - return True - except Exception as e: - self.logger.error(f"Error in _configure_new_modlist: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}Unexpected error in new modlist configuration: {e}{COLOR_RESET}") - return True - - def _configure_existing_modlist(self): - """Handle configuration of an existing modlist. Returns True to continue menu, False to exit.""" - logger.info("Detecting installed modlists...") - try: - if not self.modlist_handler: - print("Internal Error: Modlist handler not available.") - input("\nPress Enter to continue...") - return True - configurable_modlists = self.modlist_handler.discover_executable_shortcuts("ModOrganizer.exe") - if not configurable_modlists: - logger.warning("No configurable ModOrganizer modlists found.") - print(f"{COLOR_ERROR}\nCould not find any recognized ModOrganizer modlists.{COLOR_RESET}") - print("Ensure the shortcut exists in Steam, points to ModOrganizer.exe, and has been run once.") - input(f"\n{COLOR_PROMPT}Press Enter to return to menu...{COLOR_RESET}") - return True - selected_modlist_dict = self.select_from_list(configurable_modlists, f"{COLOR_PROMPT}Select Modlist to Configure:{COLOR_RESET}") - if not selected_modlist_dict: - logger.info("Modlist selection cancelled by user.") - return True - logger.info(f"Setting context for selected modlist: {selected_modlist_dict.get('name')}") - context = { - "name": selected_modlist_dict.get("name"), - "appid": selected_modlist_dict.get("appid"), - "path": selected_modlist_dict.get("path"), - "resolution": selected_modlist_dict.get("resolution") if selected_modlist_dict.get("resolution") else None - } - self.logger.debug(f"[DEBUG] Existing Modlist Context: {context}") - return self.run_modlist_configuration_phase(context) - except KeyboardInterrupt: - print("\nConfiguration cancelled by user.") - return True - except Exception as e: - logger.exception(f"Error configuring existing modlist: {e}", exc_info=True) - print(f"{COLOR_ERROR}\nAn unexpected error occurred: {str(e)}{COLOR_RESET}") - input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") - return True - - def select_from_list(self, items: List[Dict], prompt="Select an option") -> Optional[Dict]: - """ - Display a list of items (dictionaries) and let the user select one. - - Args: - items: A list of dictionaries, each expected to have at least 'name' and 'appid'. - prompt: The message to display before the list. - - Returns: - The selected dictionary item or None if cancelled. - """ - if not items: - print(f"{COLOR_WARNING}No items available to select from.{COLOR_RESET}") - return None - - print("\n" + "-" * 28) # Separator - print(f"{COLOR_PROMPT}{prompt}{COLOR_RESET}") # Main prompt message (e.g., "Select Modlist to Configure:") - - for i, item_dict in enumerate(items, 1): - display_name = item_dict.get('name', 'Unknown Item') - # Optionally display other relevant info if available, e.g., AppID or path - # For now, keeping it simple with just the name for selection clarity. - print(f" {COLOR_SELECTION}{i}.{COLOR_RESET} {display_name}") - print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel selection") # Added cancel option - - while True: - try: - choice_input = input(f"{COLOR_PROMPT}Enter your choice (0-{len(items)}): {COLOR_RESET}").strip() - if choice_input.lower() == 'q' or choice_input == '0': # Allow 'q' or '0' for cancel - self.logger.info("User cancelled selection from list.") - print(f"{COLOR_INFO}Selection cancelled.{COLOR_RESET}") - return None - if choice_input.isdigit(): - choice_int = int(choice_input) - if 1 <= choice_int <= len(items): - return items[choice_int - 1] - - print(f"{COLOR_ERROR}Invalid choice. Please enter a number between 0 and {len(items)}.{COLOR_RESET}") - except ValueError: - print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") - except KeyboardInterrupt: - print("\nSelection cancelled (Ctrl+C).") - self.logger.info("User cancelled selection from list via Ctrl+C.") - return None - - def run_modlist_configuration_phase(self, context: dict) -> bool: - """ - Shared configuration phase for both new and existing modlists. - Expects context dict with keys: name, appid, path (at minimum). - """ - self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}") - # 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']: - appid = self.shortcut_handler.get_appid_for_shortcut(context['name'], context['mo2_exe_path']) - if appid: - context['appid'] = appid - else: - self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}") - set_modlist_result = self.modlist_handler.set_modlist(context) - 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: - print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}") - self.logger.error(f"set_modlist failed for {context.get('name')}") - if not gui_mode: - input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") - return False - - # --- Resolution selection logic for GUI mode --- - selected_resolution = context.get('resolution', None) - if gui_mode: - # If resolution is provided, set it and do not prompt - if selected_resolution: - self.modlist_handler.selected_resolution = selected_resolution - self.logger.info(f"[GUI MODE] Resolution set from GUI: {selected_resolution}") - else: - # If on Steam Deck, set to 1280x800; else leave unchanged - if self.steamdeck: - self.modlist_handler.selected_resolution = "1280x800" - self.logger.info("[GUI MODE] Steam Deck detected, setting resolution to 1280x800.") - else: - self.logger.info("[GUI MODE] No resolution set, leaving unchanged.") - else: - # CLI mode: prompt as before - print() # Add padding before resolution prompt - selected_res = self.resolution_handler.select_resolution(steamdeck=self.steamdeck) - if selected_res: - self.modlist_handler.selected_resolution = selected_res - self.logger.info(f"Resolution preference set to: {selected_res}") - elif self.steamdeck: - self.modlist_handler.selected_resolution = "1280x800" - self.logger.info(f"Using default Steam Deck resolution: {self.modlist_handler.selected_resolution}") - else: - self.logger.info("User cancelled resolution selection or not applicable.") - - skip_confirmation = context.get('skip_confirmation', False) - if gui_mode: - skip_confirmation = True - if not self.modlist_handler.display_modlist_summary(skip_confirmation=skip_confirmation): - self.logger.info("User chose not to proceed with configuration after summary.") - return True - - 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 - if status_line: - print("\r" + " " * len(status_line), end="\r") - if gui_mode: - print(msg, flush=True) - else: - status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}" - print(status_line, end="", flush=True) - manual_steps_completed = context.get("manual_steps_completed", False) - if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed): - if status_line: - print() - self.logger.error(f"Core configuration steps failed for {context.get('name')}") - print(f"{COLOR_ERROR}\nModlist configuration failed. Check logs for details.{COLOR_RESET}") - # Only wait for input in CLI mode, not GUI mode - if not gui_mode: - input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") - return False - if status_line: - print() - - # Configure ENB for Linux compatibility (non-blocking, same as GUI) - 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 - - # Run modlist-specific post-install automation (e.g., VNV) before showing completion - # Only in CLI mode - GUI handles this in install_modlist.py - if not gui_mode: - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - from pathlib import Path - - modlist_name = context.get('name', '') - modlist_path = Path(context.get('path', '')) - - try: - print("") - print("Running VNV post-install automation...") - 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 - ) - if error: - print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}") - print("You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html") - except Exception as e: - self.logger.debug(f"VNV automation check skipped: {e}") - # Not an error - just means VNV automation wasn't applicable - - print("") - print("") - print("") # Extra blank line before completion - print("=" * 35) - print("= Configuration phase complete =") - print("=" * 35) - print("") - print("Modlist Install and Configuration complete!") - print(f"• You should now be able to Launch '{context.get('name')}' through Steam") - print("• Congratulations and enjoy the game!") - print("") - - # Show ENB-specific warning if ENB was detected (replaces generic note) - if enb_detected: - print(f"{COLOR_WARNING}⚠️ ENB DETECTED{COLOR_RESET}") - print("") - print("If you plan on using ENB as part of this modlist, you will need to use") - print("one of the following Proton versions, otherwise you will have issues:") - print("") - print(" (in order of recommendation)") - print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}") - print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}") - print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}") - print("") - print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}") - print("") - else: - # 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") - # 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}") - return True +from .menu_handler_input import ( + basic_input_prompt, input_prompt, simple_path_completer, + READLINE_AVAILABLE, READLINE_HAS_PROMPT, READLINE_HAS_DISPLAY_HOOK, +) +from .menu_handler_modlist import ModlistMenuHandler class MenuHandler: """ @@ -1005,8 +320,7 @@ class MenuHandler: self.logger.info(f"Selected directory (exists): {chosen_path}") return chosen_path else: - self.logger.warning(f"Path exists but is not a directory: {chosen_path}") - print(f"{COLOR_ERROR}Error: Path exists but is not a directory: {chosen_path}{COLOR_RESET}") + print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}") if not self._ask_try_again(): return None continue elif create_if_missing: @@ -1072,8 +386,8 @@ class MenuHandler: print("") return file_path else: - print(f"{COLOR_ERROR}Error: Path is not a valid '{extension_filter}' file or a directory: {file_path}{COLOR_RESET}") - print("Please check the path and try again, or press Ctrl+C or 'q' to cancel.") + print(f"{COLOR_ERROR}Path is not a valid '{extension_filter}' file or a directory: {file_path}{COLOR_RESET}") + print(f"{COLOR_INFO}Please check the path and try again, or press Ctrl+C or 'q' to cancel.{COLOR_RESET}") if not self._ask_try_again(): print("") return None @@ -1082,65 +396,5 @@ class MenuHandler: print("") return None finally: - if READLINE_AVAILABLE: - readline.set_completer(None) - -# Basic input prompt function for use throughout the application -input_prompt = basic_input_prompt - -# --- Robust shell-like path completer function --- -def _shell_path_completer(text, state): - """ - Shell-like pathname completer for readline. - Expands ~, handles absolute/relative paths, and completes inside directories. - """ - import os - import glob - # Expand ~ and environment variables - expanded = os.path.expanduser(os.path.expandvars(text)) - # If the expanded path is a directory, list its contents - if os.path.isdir(expanded): - pattern = os.path.join(expanded, '*') - else: - # Complete the last component - pattern = expanded + '*' - matches = glob.glob(pattern) - # Add trailing slash to directories - matches = [m + ('/' if os.path.isdir(m) else '') for m in matches] - # If the user hasn't typed anything, show current dir - if not text: - matches = glob.glob('*') - matches = [m + ('/' if os.path.isdir(m) else '') for m in matches] - # Return the state-th match or None - try: - return matches[state] - except IndexError: - return None - -# Create a public reference to the robust completer -simple_path_completer = _shell_path_completer - -# --- Simple path completer function --- -def _simple_path_completer(text, state): - """ - Simple pathname completer for readline. - Logic: - - If text is empty (at beginning of line), returns options for current dir - - If text has content, does prefix matching on path components - - Tab completion will fill up to next / or complete the filename - - State is an integer index representing which match to return - Args: - text: The text to complete - state: The state index (0 for first match, 1 for second, etc.) - Returns: - The matching completion or None if no more matches - """ - import glob, os - matches = glob.glob(text + '*') - matches = [f + ('/' if os.path.isdir(f) else '') for f in matches] - try: - return matches[state] - except IndexError: - return None - -simple_path_completer = _simple_path_completer \ No newline at end of file + if READLINE_AVAILABLE and readline: + readline.set_completer(None) \ No newline at end of file diff --git a/jackify/backend/handlers/menu_handler_input.py b/jackify/backend/handlers/menu_handler_input.py new file mode 100644 index 0000000..75cc4f3 --- /dev/null +++ b/jackify/backend/handlers/menu_handler_input.py @@ -0,0 +1,98 @@ +""" +Menu handler input and readline tab completion. +Exports: READLINE_* constants, basic_input_prompt, input_prompt, simple_path_completer, _shell_path_completer, _simple_path_completer. +""" + +import logging +import os +import glob + +from .ui_colors import COLOR_PROMPT, COLOR_RESET + +READLINE_AVAILABLE = False +READLINE_HAS_PROMPT = False +READLINE_HAS_DISPLAY_HOOK = False + +try: + import readline + READLINE_AVAILABLE = True + logging.debug("Readline imported for tab completion") + if hasattr(readline, 'set_prompt'): + READLINE_HAS_PROMPT = True + logging.debug("Readline has set_prompt capability") + else: + logging.debug("Readline does not have set_prompt capability, will use fallback") + try: + readline.parse_and_bind('tab: complete') + logging.debug("Readline tab completion successfully configured") + except Exception as e: + logging.warning(f"Error configuring readline tab completion: {e}. Tab completion may be limited.") + if hasattr(readline, 'set_completion_display_matches_hook'): + READLINE_HAS_DISPLAY_HOOK = True + logging.debug("Readline has completion display hook capability") + + def custom_display_completions(substitution, matches, longest_match_length): + print() + try: + import shutil + term_width = shutil.get_terminal_size().columns + except (ImportError, AttributeError): + term_width = 80 + items_per_line = max(1, term_width // (longest_match_length + 2)) + for i, match in enumerate(matches): + print(f"{match:<{longest_match_length + 2}}", end='' if (i + 1) % items_per_line else '\n') + if len(matches) % items_per_line != 0: + print() + current_input = readline.get_line_buffer() + print(f"{COLOR_PROMPT}> {COLOR_RESET}{current_input}", end='', flush=True) + + try: + readline.set_completion_display_matches_hook(custom_display_completions) + logging.debug("Custom completion display hook successfully set") + except Exception as e: + logging.warning(f"Error setting completion display hook: {e}. Using default display behavior.") + READLINE_HAS_DISPLAY_HOOK = False + else: + logging.debug("Readline doesn't have completion display hook capability, using default") +except ImportError: + logging.warning("readline not available. Tab completion for paths will be disabled.") +except Exception as e: + logging.warning(f"Error initializing readline: {e}. Tab completion for paths will be disabled.") + + +def basic_input_prompt(message, **kwargs): + return input(message) + + +input_prompt = basic_input_prompt + + +def _shell_path_completer(text, state): + """Shell-like pathname completer for readline. Expands ~, handles absolute/relative paths.""" + expanded = os.path.expanduser(os.path.expandvars(text)) + if os.path.isdir(expanded): + pattern = os.path.join(expanded, '*') + else: + pattern = expanded + '*' + matches = glob.glob(pattern) + matches = [m + ('/' if os.path.isdir(m) else '') for m in matches] + if not text: + matches = glob.glob('*') + matches = [m + ('/' if os.path.isdir(m) else '') for m in matches] + try: + return matches[state] + except IndexError: + return None + + +def _simple_path_completer(text, state): + """Simple pathname completer for readline. Prefix matching on path components.""" + matches = glob.glob(text + '*') + matches = [f + ('/' if os.path.isdir(f) else '') for f in matches] + try: + return matches[state] + except IndexError: + return None + + +simple_path_completer = _simple_path_completer diff --git a/jackify/backend/handlers/menu_handler_modlist.py b/jackify/backend/handlers/menu_handler_modlist.py new file mode 100644 index 0000000..572fa6d --- /dev/null +++ b/jackify/backend/handlers/menu_handler_modlist.py @@ -0,0 +1,615 @@ +""" +Modlist menu handler: modlist-specific CLI menu operations. +ModlistMenuHandler class. Lazy-imports MenuHandler to avoid circular import. +""" + +import logging +import os +from pathlib import Path +from typing import List, Dict, Optional + +from .ui_colors import ( + COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR, + COLOR_SUCCESS, COLOR_WARNING, COLOR_ACTION, COLOR_INPUT +) +from .modlist_handler import ModlistHandler +from .filesystem_handler import FileSystemHandler +from .path_handler import PathHandler +from .vdf_handler import VDFHandler +from .resolution_handler import ResolutionHandler +from jackify.shared.ui_utils import print_section_header + +logger = logging.getLogger(__name__) + + +class ModlistMenuHandler: + """Handles modlist-specific menu operations.""" + + def __init__(self, config_handler, test_mode=False): + self.config_handler = config_handler + self.test_mode = test_mode + self.exit_flag = False + self.logger = logging.getLogger(__name__) + try: + self.filesystem_handler = FileSystemHandler() + self.path_handler = PathHandler() + self.vdf_handler = VDFHandler() + from ..services.platform_detection_service import PlatformDetectionService + platform_service = PlatformDetectionService.get_instance() + self.steamdeck = platform_service.is_steamdeck + self.resolution_handler = ResolutionHandler() + from .menu_handler import MenuHandler + self.menu_handler = MenuHandler() + self.modlist_handler = ModlistHandler( + self.config_handler.settings, + steamdeck=self.steamdeck, + verbose=False, + filesystem_handler=self.filesystem_handler + ) + self.shortcut_handler = self.modlist_handler.shortcut_handler + self.install_wabbajack_handler = None + except Exception as e: + self.logger.error(f"Error initializing ModlistMenuHandler: {e}") + self.filesystem_handler = FileSystemHandler() + try: + from ..services.platform_detection_service import PlatformDetectionService + platform_service = PlatformDetectionService.get_instance() + self.steamdeck = platform_service.is_steamdeck + except Exception: + self.steamdeck = False + self.modlist_handler = None + + def show_modlist_menu(self): + while True: + os.system('cls' if os.name == 'nt' else 'clear') + # Banner display handled by frontend + print_section_header('Modlist Configuration') + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Configure a New modlist not yet in Steam") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure a modlist already in Steam") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") + choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}") + if choice == "1": + if not self._configure_new_modlist(): + return False + elif choice == "2": + if not self._configure_existing_modlist(): + return False + elif choice == "0": + logger.info("Returning to main menu from Modlist Configuration menu.") + return False + else: + logger.warning(f"Invalid menu selection: {choice}") + 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. + Returns the validated path or None if cancelled/invalid. + """ + self.logger.info("Prompting for ModOrganizer.exe path...") + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}Please provide the path to ModOrganizer.exe for your modlist.{COLOR_RESET}") + print(f"{COLOR_INFO}This is typically found in the modlist's installation directory.") + print(f"{COLOR_INFO}Example: ~/Games/MyModlist/ModOrganizer.exe") + print(f"{COLOR_INFO}You can also provide the path to the directory containing ModOrganizer.exe.") + + # Use the menu_handler's get_existing_file_path for consistency if self.menu_handler is available + # self.menu_handler is MenuHandler, not ModlistMenuHandler + if hasattr(self, 'menu_handler') and self.menu_handler is not None: + # get_existing_file_path will use its own standard prompting style internally + # We pass no_header=False so it shows its full prompt. + # The prompt_message here becomes the main instruction for get_existing_file_path. + path_result = self.menu_handler.get_existing_file_path( + prompt_message=f"Path to ModOrganizer.exe or its directory", + extension_filter=".exe", + no_header=False # Let get_existing_file_path handle its full prompt including separator + ) + if path_result is None: # User cancelled + self.logger.info("User cancelled ModOrganizer.exe path input via get_existing_file_path.") + return None + + path_str = str(path_result) + if os.path.isdir(path_str): + potential_mo2_path = os.path.join(path_str, "ModOrganizer.exe") + if os.path.isfile(potential_mo2_path): + self.logger.info(f"Found ModOrganizer.exe in directory: {potential_mo2_path}") + return potential_mo2_path + else: + print(f"{COLOR_ERROR}ModOrganizer.exe not found in directory: {path_str}{COLOR_RESET}") + # Allow to try again - this might need a loop or rely on get_existing_file_path loop + return self._get_mo2_path() # Recursive call to try again, simple loop better + elif os.path.isfile(path_str) and os.path.basename(path_str).lower() == "modorganizer.exe": + self.logger.info(f"ModOrganizer.exe path validated: {path_str}") + return path_str + else: + print(f"{COLOR_ERROR}Path is not ModOrganizer.exe or a directory containing it.{COLOR_RESET}") + return self._get_mo2_path() # Recursive call + + # Fallback to basic input if self.menu_handler is not available (should ideally not happen) + self.logger.warning("_get_mo2_path: self.menu_handler not available, using basic input as fallback.") + while True: + try: + # Basic input prompt if menu_handler isn't used + mo2_path_input = input(f"{COLOR_PROMPT}Enter path to ModOrganizer.exe (or 'q' to cancel): {COLOR_RESET}").strip() + if mo2_path_input.lower() == 'q': + self.logger.info("User cancelled ModOrganizer.exe path input (fallback).") + return None + + expanded_path = os.path.expanduser(mo2_path_input) + normalized_path = os.path.normpath(expanded_path) + + if os.path.isdir(normalized_path): + potential_mo2_path = os.path.join(normalized_path, "ModOrganizer.exe") + if os.path.isfile(potential_mo2_path): + self.logger.info(f"Found ModOrganizer.exe in directory (fallback): {potential_mo2_path}") + return potential_mo2_path + else: + print(f"{COLOR_ERROR}ModOrganizer.exe not found in directory: {normalized_path}{COLOR_RESET}") + continue + + if not normalized_path.lower().endswith('modorganizer.exe'): + print(f"{COLOR_ERROR}Path must be ModOrganizer.exe or a directory containing it.{COLOR_RESET}") + continue + if not os.path.isfile(normalized_path): + print(f"{COLOR_ERROR}File does not exist: {normalized_path}{COLOR_RESET}") + continue + + self.logger.info(f"ModOrganizer.exe path validated (fallback): {normalized_path}") + return normalized_path + except KeyboardInterrupt: + print("\nOperation cancelled.") + self.logger.info("User cancelled ModOrganizer.exe path input via Ctrl+C (fallback).") + return None + except Exception as e: + self.logger.error(f"Error processing ModOrganizer.exe path (fallback): {e}") + print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}") + return None + + def _get_modlist_name(self) -> Optional[str]: + """ + Get the modlist name from user input. + Returns the validated name or None if cancelled. + """ + self.logger.info("Prompting for modlist name...") + + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}Please provide a name for your modlist.{COLOR_RESET}") + print(f"{COLOR_INFO}(This will be the name used for the Steam shortcut.){COLOR_RESET}") + + while True: + try: + modlist_name = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() + + if modlist_name.lower() == 'q': + self.logger.info("User cancelled modlist name input.") + return None + + if not modlist_name: + print(f"{COLOR_ERROR}Name cannot be empty.{COLOR_RESET}") + continue + + if len(modlist_name) > 100: + print(f"{COLOR_ERROR}Name is too long (max 100 characters).{COLOR_RESET}") + continue + + invalid_chars = '< > : " / \\ | ? *' # String of invalid chars for message + if any(char in modlist_name for char in invalid_chars.replace(' ','')): + print(f"{COLOR_ERROR}Name contains invalid characters (e.g., {invalid_chars}).{COLOR_RESET}") + continue + + self.logger.info(f"Modlist name validated: {modlist_name}") + return modlist_name + + except KeyboardInterrupt: + print("\nOperation cancelled.") + self.logger.info("User cancelled modlist name input via Ctrl+C.") + return None + except Exception as e: + self.logger.error(f"Error processing modlist name: {e}") + print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}") + return None + + def _configure_new_modlist(self, default_modlist_dir=None, default_modlist_name=None): + """Handle configuration of a new modlist. Returns True to continue menu, False to exit.""" + # --- Get ModOrganizer.exe Path --- + if default_modlist_dir: + # Try to infer ModOrganizer.exe path + mo2_path = os.path.join(default_modlist_dir, "ModOrganizer.exe") + if not os.path.isfile(mo2_path): + print(f"{COLOR_ERROR}Could not find ModOrganizer.exe in {default_modlist_dir}{COLOR_RESET}") + mo2_path = self._get_mo2_path() + else: + mo2_path = self._get_mo2_path() + if not mo2_path: + return True + # --- Get Modlist Name --- + if default_modlist_name: + modlist_name = default_modlist_name + else: + modlist_name = self._get_modlist_name() + if not modlist_name: + return True + # Add a blank line for padding + print("") + try: + # --- Ensure SteamIcons directory is normalized before icon selection --- + mo2_dir = os.path.dirname(mo2_path) + # --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) --- + self.shortcut_handler.write_nxmhandler_ini(mo2_dir, mo2_path) + steam_icons_path = os.path.join(mo2_dir, "Steam Icons") + steamicons_path = os.path.join(mo2_dir, "SteamIcons") + if os.path.isdir(steam_icons_path) and not os.path.isdir(steamicons_path): + try: + os.rename(steam_icons_path, steamicons_path) + self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {mo2_dir}") + except Exception as e: + self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}") + self.logger.debug(f"[DEBUG] After normalization, SteamIcons exists: {os.path.isdir(steamicons_path)}") + # --- Use automated prefix workflow (replaces old manual workflow) --- + try: + mo2_dir = os.path.dirname(mo2_path) + install_dir = mo2_dir + + # Use automated prefix service for modern workflow + print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}") + + from ..services.automated_prefix_service import AutomatedPrefixService + prefix_service = AutomatedPrefixService() + + # Define progress callback for CLI with jackify-engine style timestamps + import time + start_time = time.time() + + def progress_callback(message): + 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}]" + print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}") + + # Run the automated workflow + result = prefix_service.run_working_workflow( + modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck + ) + + # Handle the result + if isinstance(result, tuple) and len(result) == 4: + if result[0] == "CONFLICT": + # Handle conflict - ask user what to do + conflicts = result[1] + print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}") + for i, conflict in enumerate(conflicts, 1): + print(f" {i}. Name: {conflict['name']}") + print(f" Executable: {conflict['exe']}") + print(f" Start Directory: {conflict['startdir']}") + print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}") + print(" 1. Use existing shortcut (recommended)") + print(" 2. Create new shortcut anyway") + choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip() + if choice == "1": + # Use existing shortcut + existing_appid = conflicts[0].get('appid') + if existing_appid: + context = { + "name": modlist_name, + "appid": str(existing_appid), + "path": mo2_dir, + "manual_steps_completed": True, + "resolution": None + } + return self.run_modlist_configuration_phase(context) + elif choice == "2": + # Create new shortcut - would need to handle this, but for now just fail + print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}") + return True + else: + print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}") + return True + else: + # Success - get the results + success, prefix_path, appid_int, last_timestamp = result + if success and appid_int: + context = { + "name": modlist_name, + "appid": str(appid_int), + "path": mo2_dir, + "manual_steps_completed": True, + "resolution": None + } + self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}") + return self.run_modlist_configuration_phase(context) + else: + print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}") + return True + else: + # Unexpected result format + print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}") + self.logger.error(f"Unexpected result format from automated workflow: {result}") + return True + except Exception as e: + self.logger.error(f"Error creating Steam shortcut: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Failed to create Steam shortcut: {e}{COLOR_RESET}") + return True + except Exception as e: + self.logger.error(f"Error in _configure_new_modlist: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Unexpected error in new modlist configuration: {e}{COLOR_RESET}") + return True + + def _configure_existing_modlist(self): + """Handle configuration of an existing modlist. Returns True to continue menu, False to exit.""" + logger.info("Detecting installed modlists...") + try: + if not self.modlist_handler: + logger.error("Internal Error: Modlist handler not available.") + input("\nPress Enter to continue...") + return True + configurable_modlists = self.modlist_handler.discover_executable_shortcuts("ModOrganizer.exe") + if not configurable_modlists: + logger.warning("No configurable ModOrganizer modlists found.") + print(f"{COLOR_ERROR}\nCould not find any recognized ModOrganizer modlists.{COLOR_RESET}") + print("Ensure the shortcut exists in Steam, points to ModOrganizer.exe, and has been run once.") + input(f"\n{COLOR_PROMPT}Press Enter to return to menu...{COLOR_RESET}") + return True + selected_modlist_dict = self.select_from_list(configurable_modlists, f"{COLOR_PROMPT}Select Modlist to Configure:{COLOR_RESET}") + if not selected_modlist_dict: + logger.info("Modlist selection cancelled by user.") + return True + logger.info(f"Setting context for selected modlist: {selected_modlist_dict.get('name')}") + context = { + "name": selected_modlist_dict.get("name"), + "appid": selected_modlist_dict.get("appid"), + "path": selected_modlist_dict.get("path"), + "resolution": selected_modlist_dict.get("resolution") if selected_modlist_dict.get("resolution") else None, + "modlist_source": "existing" # Mark as existing modlist to skip manual steps + } + self.logger.debug(f"[DEBUG] Existing Modlist Context: {context}") + return self.run_modlist_configuration_phase(context) + except KeyboardInterrupt: + print("\nConfiguration cancelled by user.") + return True + except Exception as e: + logger.exception(f"Error configuring existing modlist: {e}", exc_info=True) + print(f"{COLOR_ERROR}\nAn unexpected error occurred: {str(e)}{COLOR_RESET}") + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return True + + def select_from_list(self, items: List[Dict], prompt="Select an option") -> Optional[Dict]: + """ + Display a list of items (dictionaries) and let the user select one. + + Args: + items: A list of dictionaries, each expected to have at least 'name' and 'appid'. + prompt: The message to display before the list. + + Returns: + The selected dictionary item or None if cancelled. + """ + if not items: + print(f"{COLOR_WARNING}No items available to select from.{COLOR_RESET}") + return None + + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}{prompt}{COLOR_RESET}") # Main prompt message (e.g., "Select Modlist to Configure:") + + for i, item_dict in enumerate(items, 1): + display_name = item_dict.get('name', 'Unknown Item') + # Optionally display other relevant info if available, e.g., AppID or path + # For now, keeping it simple with just the name for selection clarity. + print(f" {COLOR_SELECTION}{i}.{COLOR_RESET} {display_name}") + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel selection") # Added cancel option + + while True: + try: + choice_input = input(f"{COLOR_PROMPT}Enter your choice (0-{len(items)}): {COLOR_RESET}").strip() + if choice_input.lower() == 'q' or choice_input == '0': # Allow 'q' or '0' for cancel + self.logger.info("User cancelled selection from list.") + print(f"{COLOR_INFO}Selection cancelled.{COLOR_RESET}") + return None + if choice_input.isdigit(): + choice_int = int(choice_input) + if 1 <= choice_int <= len(items): + return items[choice_int - 1] + + print(f"{COLOR_ERROR}Invalid choice. Please enter a number between 0 and {len(items)}.{COLOR_RESET}") + except ValueError: + print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") + except KeyboardInterrupt: + print("\nSelection cancelled (Ctrl+C).") + self.logger.info("User cancelled selection from list via Ctrl+C.") + return None + + def run_modlist_configuration_phase(self, context: dict) -> bool: + """ + Shared configuration phase for both new and existing modlists. + Expects context dict with keys: name, appid, path (at minimum). + """ + self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}") + # 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']: + appid = self.shortcut_handler.get_appid_for_shortcut(context['name'], context['mo2_exe_path']) + if appid: + context['appid'] = appid + else: + self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}") + set_modlist_result = self.modlist_handler.set_modlist(context) + 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: + print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}") + self.logger.error(f"set_modlist failed for {context.get('name')}") + if not gui_mode: + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return False + + # --- Resolution selection logic for GUI mode --- + selected_resolution = context.get('resolution', None) + if gui_mode: + # If resolution is provided, set it and do not prompt + if selected_resolution: + self.modlist_handler.selected_resolution = selected_resolution + self.logger.info(f"[GUI MODE] Resolution set from GUI: {selected_resolution}") + else: + # If on Steam Deck, set to 1280x800; else leave unchanged + if self.steamdeck: + self.modlist_handler.selected_resolution = "1280x800" + self.logger.info("[GUI MODE] Steam Deck detected, setting resolution to 1280x800.") + else: + self.logger.info("[GUI MODE] No resolution set, leaving unchanged.") + else: + # CLI mode: prompt as before + print() # Add padding before resolution prompt + selected_res = self.resolution_handler.select_resolution(steamdeck=self.steamdeck) + if selected_res: + self.modlist_handler.selected_resolution = selected_res + self.logger.info(f"Resolution preference set to: {selected_res}") + elif self.steamdeck: + self.modlist_handler.selected_resolution = "1280x800" + self.logger.info(f"Using default Steam Deck resolution: {self.modlist_handler.selected_resolution}") + else: + self.logger.info("User cancelled resolution selection or not applicable.") + + skip_confirmation = context.get('skip_confirmation', False) + if gui_mode: + skip_confirmation = True + if not self.modlist_handler.display_modlist_summary(skip_confirmation=skip_confirmation): + self.logger.info("User chose not to proceed with configuration after summary.") + return True + + 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 + if status_line: + print("\r" + " " * len(status_line), end="\r") + if gui_mode: + print(msg, flush=True) + else: + status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}" + print(status_line, end="", flush=True) + manual_steps_completed = context.get("manual_steps_completed", False) + skip_manual_for_existing = context.get("modlist_source") == "existing" # Existing modlists skip manual steps + if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed, skip_manual_for_existing=skip_manual_for_existing): + if status_line: + print() + self.logger.error(f"Core configuration steps failed for {context.get('name')}") + print(f"{COLOR_ERROR}\nModlist configuration failed. Check logs for details.{COLOR_RESET}") + # Only wait for input in CLI mode, not GUI mode + if not gui_mode: + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + return False + if status_line: + print() + + # Configure ENB for Linux compatibility (non-blocking, same as GUI) + 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 + + # Run modlist-specific post-install automation (e.g., VNV) before showing completion + # Only in CLI mode - GUI handles this in install_modlist.py + if not gui_mode: + from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + from pathlib import Path + + modlist_name = context.get('name', '') + modlist_path = Path(context.get('path', '')) + + try: + print("") + print("Running VNV post-install automation...") + 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 + ) + 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}") + except Exception as e: + self.logger.debug(f"VNV automation check skipped: {e}") + # Not an error - just means VNV automation wasn't applicable + + print("") + print("") + print("") # Extra blank line before completion + print("=" * 35) + print("= Configuration phase complete =") + print("=" * 35) + print("") + print("Modlist Install and Configuration complete!") + print(f"• You should now be able to Launch '{context.get('name')}' through Steam") + print("• Congratulations and enjoy the game!") + print("") + + # Show ENB-specific warning if ENB was detected (replaces generic note) + if enb_detected: + print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}") + print("") + print("If you plan on using ENB as part of this modlist, you will need to use") + print("one of the following Proton versions, otherwise you will have issues:") + print("") + print(" (in order of recommendation)") + print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}") + print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}") + print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}") + print("") + print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}") + print("") + else: + # 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") + # 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}") + return True diff --git a/jackify/backend/handlers/mo2_handler.py b/jackify/backend/handlers/mo2_handler.py index 679df9e..a702bf9 100644 --- a/jackify/backend/handlers/mo2_handler.py +++ b/jackify/backend/handlers/mo2_handler.py @@ -5,10 +5,13 @@ 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. @@ -17,6 +20,7 @@ class MO2Handler: 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 @@ -30,7 +34,7 @@ class MO2Handler: 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}\n") + 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" @@ -64,12 +68,12 @@ class MO2Handler: 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}\n") + print(f"{COLOR_ERROR}[ERROR] Could not create directory: {e}{COLOR_RESET}") return False else: files = list(install_dir.iterdir()) if files: - print(f"Warning: The directory '{install_dir}' is not empty.") + 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': @@ -92,7 +96,7 @@ class MO2Handler: 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}\n") + 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) @@ -103,7 +107,7 @@ class MO2Handler: asset = a break if not asset: - print(f"{COLOR_ERROR}[ERROR] Could not find main MO2 .7z asset in latest release.{COLOR_RESET}\n") + print(f"{COLOR_ERROR}[ERROR] Could not find main MO2 .7z asset in latest release.{COLOR_RESET}") return False # 7. Download the archive @@ -116,7 +120,7 @@ class MO2Handler: 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}\n") + print(f"{COLOR_ERROR}[ERROR] Failed to download MO2 archive: {e}{COLOR_RESET}") return False # 8. Extract using 7z (suppress noisy output) @@ -124,16 +128,16 @@ class MO2Handler: 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}\n") + 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}\n") + 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}\n") + 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}") @@ -154,7 +158,7 @@ class MO2Handler: proton_version="proton_experimental" ) if not success or not app_id: - print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut.{COLOR_RESET}\n") + 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) @@ -178,7 +182,7 @@ class MO2Handler: 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}\n") + 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_configuration.py b/jackify/backend/handlers/modlist_configuration.py new file mode 100644 index 0000000..d7beba7 --- /dev/null +++ b/jackify/backend/handlers/modlist_configuration.py @@ -0,0 +1,584 @@ +"""Configuration workflow methods for ModlistHandler (Mixin).""" +from pathlib import Path +import os +import logging +import requests +import re +from typing import Optional + +from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR +from .resolution_handler import ResolutionHandler + +logger = logging.getLogger(__name__) + + +class ModlistConfigurationMixin: + """Mixin providing configuration workflow methods for ModlistHandler.""" + + def display_modlist_summary(self, skip_confirmation: bool = False) -> bool: + """Display the detected modlist summary and ask for confirmation.""" + if not self.appid or not self.modlist_dir or not self.modlist_ini: + logger.error("Cannot display summary: Missing essential modlist context.") + return False + + # Detect potentially missing info if not already set + if not self.game_name: + self._detect_game_variables() + if not self.proton_ver or self.proton_ver == "Unknown": + self._detect_proton_version() + + # Don't reset timing - continue from Steam Integration timing + print("=== Configuration Summary ===") + print(f"{self._get_progress_timestamp()} Selected Modlist: {self.game_name}") + print(f"{self._get_progress_timestamp()} Game Type: {self.game_var_full if self.game_var_full else 'Unknown'}") + print(f"{self._get_progress_timestamp()} Steam App ID: {self.appid}") + print(f"{self._get_progress_timestamp()} Modlist Directory: {self.modlist_dir}") + print(f"{self._get_progress_timestamp()} ModOrganizer.ini: {self.modlist_dir}/ModOrganizer.ini") + print(f"{self._get_progress_timestamp()} Proton Version: {self.proton_ver if self.proton_ver else 'Unknown'}") + print(f"{self._get_progress_timestamp()} Resolution: {self.selected_resolution if self.selected_resolution else 'Default'}") + print(f"{self._get_progress_timestamp()} Modlist on SD Card: {self.modlist_sdcard}") + print("") + + if skip_confirmation: + return True + # Ask for confirmation + proceed = input(f"{COLOR_PROMPT}Proceed with configuration? (Y/n): {COLOR_RESET}").lower() + if proceed == 'n': # Now defaults to Yes unless 'n' is entered + logger.info("Configuration cancelled by user after summary.") + return False + else: + return True + + def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False, skip_manual_for_existing=False): + """ + Runs the actual configuration steps for the selected modlist. + Args: + status_callback (callable, optional): A function to call with status updates during configuration. + manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow). + skip_manual_for_existing (bool): If True, always skip manual steps (for existing modlists that are already configured). + """ + try: + # Store status_callback for Configuration Summary + self._current_status_callback = status_callback + + self.logger.info("Executing configuration steps...") + + # Ensure required context is set + if not all([self.modlist_dir, self.appid, self.game_var, self.steamdeck is not None]): + self.logger.error("Cannot execute configuration steps: Missing required context (modlist_dir, appid, game_var, steamdeck status).") + self.logger.error("Missing required information to start configuration.") + return False + except Exception as e: + self.logger.error(f"Exception in _execute_configuration_steps initialization: {e}", exc_info=True) + return False + + # Step 1: Set protontricks permissions + if status_callback: + # Reset timing for Prefix Configuration section + from jackify.shared.timing import start_new_phase + start_new_phase() + + status_callback("") # Blank line after Configuration Summary + status_callback("") # Extra blank line before Prefix Configuration + status_callback("=== Prefix Configuration ===") + status_callback(f"{self._get_progress_timestamp()} Setting Protontricks permissions") + self.logger.info("Step 1: Setting Protontricks permissions...") + if not self.protontricks_handler.set_protontricks_permissions(self.modlist_dir, self.steamdeck): + self.logger.error("Failed to set Protontricks permissions. Configuration aborted.") + self.logger.error("Could not set necessary Protontricks permissions.") + return False # Abort on failure + self.logger.info("Step 1: Setting Protontricks permissions... Done") + + # Step 2: Prompt user for manual steps and wait for compatdata + skip_manual_prompt = skip_manual_for_existing # Existing modlists skip manual steps + if not manual_steps_completed and not skip_manual_for_existing: + # Check if Proton Experimental is already set and compatdata exists + proton_ok = False + compatdata_ok = False + + # Check Proton version + self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}") + if self._detect_proton_version(): + self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}") + if self.proton_ver and 'experimental' in self.proton_ver.lower(): + proton_ok = True + self.logger.debug("[MANUAL STEPS DEBUG] Proton Experimental detected - proton_ok = True") + else: + self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version") + + # Check compatdata/prefix + prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) + self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}") + + if prefix_path_str and os.path.isdir(prefix_path_str): + compatdata_ok = True + self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory exists - compatdata_ok = True") + else: + self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory does not exist") + + self.logger.debug(f"[MANUAL STEPS DEBUG] proton_ok: {proton_ok}, compatdata_ok: {compatdata_ok}") + + if proton_ok and compatdata_ok: + self.logger.info("Proton Experimental and compatdata already set for this AppID; skipping manual steps prompt.") + skip_manual_prompt = True + else: + self.logger.debug("[MANUAL STEPS DEBUG] Manual steps will be required") + + self.logger.debug(f"[MANUAL STEPS DEBUG] manual_steps_completed: {manual_steps_completed}, skip_manual_prompt: {skip_manual_prompt}") + + if not manual_steps_completed and not skip_manual_prompt: + # Check if we're in GUI mode - if so, don't show CLI prompts, just fail and let GUI callbacks handle it + gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + + if gui_mode: + # In GUI mode: don't show CLI prompts, just fail so GUI can show dialog and retry + self.logger.info("GUI mode detected: skipping CLI manual steps prompt, will fail configuration to trigger GUI callback") + if status_callback: + status_callback("Manual Steam/Proton setup required - this will be handled by GUI dialog") + # Return False to trigger manual steps callback in GUI + return False + else: + # CLI mode: show the traditional CLI prompt + if status_callback: + status_callback("Please perform the manual steps in Steam (set Proton, launch shortcut, then close MO2)...") + self.logger.info("Prompting user to perform manual Steam/Proton steps and launch shortcut.") + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} Please follow the on-screen instructions to set Proton Experimental and launch the shortcut from Steam.") + print("───────────────────────────────────────────────────────────────────") + input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") + self.logger.info("User confirmed completion of manual steps.") + # Step 3: Download and apply curated user.reg.modlist and system.reg.modlist + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration") + self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...") + try: + prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) + if not prefix_path_str or not os.path.isdir(prefix_path_str): + raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.") + user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist" + user_reg_dest = Path(prefix_path_str) / "user.reg" + response = requests.get(user_reg_url, verify=True) + response.raise_for_status() + with open(user_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}") + system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist" + system_reg_dest = Path(prefix_path_str) / "system.reg" + response = requests.get(system_reg_url, verify=True) + response.raise_for_status() + with open(system_reg_dest, "wb") as f: + f.write(response.content) + self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}") + except Exception as e: + self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}") + self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}") + return False + self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.") + + # Step 4: Install Wine Components + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)") + self.logger.info("Step 4: Installing Wine components (this may take a while)...") + + # Use canonical logic for all modlists/games + components = self.get_modlist_wine_components(self.game_name, self.game_var_full) + + # All modlists now use their own AppID for wine components + target_appid = self.appid + + # Use user's preferred component installation method (respects settings toggle) + self.logger.debug(f"Getting WINEPREFIX for AppID {target_appid}...") + wineprefix = self.protontricks_handler.get_wine_prefix_path(target_appid) + if not wineprefix: + self.logger.error("Failed to get WINEPREFIX path for component installation.") + self.logger.error("Could not determine wine prefix location.") + return False + self.logger.debug(f"WINEPREFIX obtained: {wineprefix}") + + # Use the winetricks handler which respects the user's toggle setting + try: + self.logger.info("Installing Wine components using user's preferred method...") + self.logger.debug(f"Calling winetricks_handler.install_wine_components with wineprefix={wineprefix}, game_var={self.game_var_full}, components={components}") + success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components, status_callback=status_callback, appid=str(target_appid) if target_appid else None) + if success: + self.logger.info("Wine component installation completed successfully") + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Wine components verified and installed successfully") + else: + self.logger.error("Wine component installation failed") + self.logger.error("Failed to install necessary Wine components.") + return False + except Exception as e: + self.logger.error(f"Wine component installation failed with exception: {e}") + self.logger.error("Failed to install necessary Wine components.") + return False + self.logger.info("Step 4: Installing Wine components... Done") + + # Step 4.5: Apply universal dotnet4.x compatibility registry fixes AFTER wine components + # Apply after components to avoid overwrite + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes") + self.logger.info("Step 4.5: Applying universal dotnet4.x compatibility registry fixes...") + registry_success = False + try: + registry_success = self._apply_universal_dotnet_fixes() + except Exception as e: + error_msg = f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}" + self.logger.error(error_msg) + if status_callback: + status_callback(f"{self._get_progress_timestamp()} ERROR: {error_msg}") + registry_success = False + + if not registry_success: + failure_msg = "WARNING: Universal dotnet4.x registry fixes FAILED! This modlist may experience .NET Framework compatibility issues." + self.logger.error("=" * 80) + self.logger.error(failure_msg) + self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.") + self.logger.error("=" * 80) + if status_callback: + status_callback(f"{self._get_progress_timestamp()} {failure_msg}") + # Continue but user should be aware of potential issues + + # Step 4.6: Enable dotfiles visibility for Wine prefix + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility") + self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...") + try: + if self.protontricks_handler.enable_dotfiles(self.appid): + self.logger.info("Dotfiles visibility enabled successfully") + else: + self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)") + except Exception as e: + self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)") + self.logger.info("Step 4.6: Enabling dotfiles visibility... Done") + + # Step 4.7: Create Wine prefix Documents directories for USVFS + # Critical for USVFS profile INI virtualization on first launch + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS") + self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...") + try: + if self.appid and self.game_var: + # Map game_var to game_name for create_required_dirs + game_name_map = { + "skyrimspecialedition": "skyrimse", + "fallout4": "fallout4", + "falloutnv": "falloutnv", + "oblivion": "oblivion", + "enderalspecialedition": "enderalse" + } + game_name = game_name_map.get(self.game_var.lower(), None) + + if game_name: + appid_str = str(self.appid) + if self.filesystem_handler.create_required_dirs(game_name, appid_str): + self.logger.info("Wine prefix Documents directories created successfully for USVFS") + else: + self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)") + else: + self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping") + else: + self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation") + except Exception as e: + self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)") + self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done") + + # Step 5: Verify ownership of Modlist directory + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership") + self.logger.info("Step 5: Verifying ownership of modlist directory...") + # Convert modlist_dir string to Path object for the method + modlist_path_obj = Path(self.modlist_dir) + success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj) + if not success: + self.logger.error("Ownership verification failed for modlist directory. Configuration aborted.") + print(f"\n{COLOR_ERROR}{error_msg}{COLOR_RESET}") + return False # Abort on failure + self.logger.info("Step 5: Ownership verification... Done") + + # Step 6: Backup ModOrganizer.ini + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Backing up ModOrganizer.ini") + self.logger.info(f"Step 6: Backing up {self.modlist_ini}...") + modlist_ini_path_obj = Path(self.modlist_ini) + backup_path = self.filesystem_handler.backup_file(modlist_ini_path_obj) + if not backup_path: + self.logger.error("Failed to back up ModOrganizer.ini. Configuration aborted.") + self.logger.error("Failed to back up ModOrganizer.ini.") + return False # Abort on failure + self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}") + self.logger.info("Step 6: Backing up ModOrganizer.ini... Done") + + # Step 6.5: Handle symlinked downloads directory + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory") + self.logger.info("Step 6.5: Checking for symlinked downloads directory...") + if not self._handle_symlinked_downloads(): + self.logger.warning("Warning during symlink handling (non-critical)") + self.logger.info("Step 6.5: Checking for symlinked downloads directory... Done") + + # Step 7a: Detect Stock Game/Game Root path + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Detecting stock game path") + # Sets self.stock_game_path if found + if not self._detect_stock_game_path(): + self.logger.error("Failed during stock game path detection.") + self.logger.error("Failed during stock game path detection.") + return False + + # Step 7b: Detect Steam Library Info (Needed for Step 8) + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Detecting Steam Library info") + self.logger.info("Step 7b: Detecting Steam Library info...") + if not self._detect_steam_library_info(): + self.logger.error("Failed to detect necessary Steam Library information.") + self.logger.error("Could not find Steam library information.") + return False + self.logger.info("Step 7b: Detecting Steam Library info... Done") + + # Step 8: Update ModOrganizer.ini Paths (gamePath, Binary, workingDirectory) + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Updating ModOrganizer.ini paths") + self.logger.info("Step 8: Updating gamePath, Binary, and workingDirectory paths in ModOrganizer.ini...") + + # Update gamePath using replace_gamepath method + modlist_dir_path_obj = Path(self.modlist_dir) + modlist_ini_path_obj = Path(self.modlist_ini) + stock_game_path_obj = Path(self.stock_game_path) if self.stock_game_path else None + # Only call replace_gamepath if we have a valid stock game path + if stock_game_path_obj: + if not self.path_handler.replace_gamepath( + modlist_ini_path=modlist_ini_path_obj, + new_game_path=stock_game_path_obj, + modlist_sdcard=self.modlist_sdcard + ): + self.logger.error("Failed to update gamePath in ModOrganizer.ini. Configuration aborted.") + self.logger.error("Failed to update game path in ModOrganizer.ini.") + return False # Abort on failure + else: + self.logger.info("No stock game path found, skipping gamePath update - edit_binary_working_paths will handle all path updates.") + self.logger.info("Using unified path manipulation to avoid duplicate processing.") + + # Conditionally update binary and working directory paths + # Skip for jackify-engine workflows since paths are already correct + # Exception: Always run for SD card installs to fix Z:/run/media/... to D:/... paths + + # DEBUG: Add comprehensive logging to identify Steam Deck SD card path manipulation issues + engine_installed = getattr(self, 'engine_installed', False) + self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler instance: id={id(self)}") + self.logger.debug(f"[SD_CARD_DEBUG] engine_installed: {engine_installed}") + self.logger.debug(f"[SD_CARD_DEBUG] modlist_sdcard: {self.modlist_sdcard}") + self.logger.debug(f"[SD_CARD_DEBUG] steamdeck parameter passed to constructor: {getattr(self, 'steamdeck', 'NOT_SET')}") + self.logger.debug(f"[SD_CARD_DEBUG] Path manipulation condition: not {engine_installed} or {self.modlist_sdcard} = {not engine_installed or self.modlist_sdcard}") + + if not getattr(self, 'engine_installed', False) or self.modlist_sdcard: + # Convert steamapps/common path to library root path + steam_libraries = None + if self.steam_library: + # self.steam_library is steamapps/common, need to go up 2 levels to get library root + steam_library_root = Path(self.steam_library).parent.parent + steam_libraries = [steam_library_root] + self.logger.debug(f"Using Steam library root: {steam_library_root}") + + if not self.path_handler.edit_binary_working_paths( + modlist_ini_path=modlist_ini_path_obj, + modlist_dir_path=modlist_dir_path_obj, + modlist_sdcard=self.modlist_sdcard, + steam_libraries=steam_libraries + ): + self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini. Configuration aborted.") + self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini.") + return False # Abort on failure + else: + self.logger.debug("[SD_CARD_DEBUG] Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini") + self.logger.debug(f"[SD_CARD_DEBUG] SKIPPED because: engine_installed={engine_installed} and modlist_sdcard={self.modlist_sdcard}") + + if getattr(self, 'download_dir', None): + if self.path_handler.set_download_directory( + modlist_ini_path_obj, str(self.download_dir), self.modlist_sdcard + ): + self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)") + else: + self.logger.warning("Could not set download_directory in ModOrganizer.ini") + + self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done") + + # Step 9: Update Resolution Settings (if applicable) + if hasattr(self, 'selected_resolution') and self.selected_resolution: + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Updating resolution settings") + # Ensure resolution_handler call uses correct args if needed + # Assuming it uses modlist_dir (str) and game_var_full (str) + # Construct vanilla game directory path for fallback + vanilla_game_dir = None + if self.steam_library and self.game_var_full: + vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full) + + if not ResolutionHandler.update_ini_resolution( + modlist_dir=self.modlist_dir, + game_var=self.game_var_full, + set_res=self.selected_resolution, + vanilla_game_dir=vanilla_game_dir + ): + self.logger.warning("Failed to update resolution settings in some INI files.") + self.logger.warning("Failed to update resolution settings.") + self.logger.info("Step 9: Updating resolution in INI files... Done") + else: + self.logger.info("Step 9: Skipping resolution update (no resolution selected).") + + # Step 10: Create dxvk.conf (skip for special games using vanilla compatdata) + special_game_type = self.detect_special_game_type(self.modlist_dir) + self.logger.debug(f"DXVK step - modlist_dir='{self.modlist_dir}', special_game_type='{special_game_type}'") + + # Force check specific files for debugging + nvse_path = Path(self.modlist_dir) / "nvse_loader.exe" if self.modlist_dir else None + enderal_path = Path(self.modlist_dir) / "Enderal Launcher.exe" if self.modlist_dir else None + self.logger.debug(f"nvse_loader.exe exists: {nvse_path.exists() if nvse_path else 'N/A'}") + self.logger.debug(f"Enderal Launcher.exe exists: {enderal_path.exists() if enderal_path else 'N/A'}") + + if special_game_type: + self.logger.info(f"Step 10: Skipping dxvk.conf creation for {special_game_type.upper()} (uses vanilla compatdata)") + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Skipping dxvk.conf for {special_game_type.upper()} modlist") + else: + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Creating dxvk.conf file") + self.logger.info("Step 10: Creating dxvk.conf file...") + # Assuming create_dxvk_conf still uses string paths + # Construct vanilla game directory path for fallback + vanilla_game_dir = None + if self.steam_library and self.game_var_full: + vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full) + + dxvk_created = self.path_handler.create_dxvk_conf( + modlist_dir=self.modlist_dir, + modlist_sdcard=self.modlist_sdcard, + steam_library=str(self.steam_library) if self.steam_library else None, # Pass as string or None + basegame_sdcard=self.basegame_sdcard, + game_var_full=self.game_var_full, + vanilla_game_dir=vanilla_game_dir, + stock_game_path=self.stock_game_path + ) + dxvk_verified = self.path_handler.verify_dxvk_conf_exists( + modlist_dir=self.modlist_dir, + steam_library=str(self.steam_library) if self.steam_library else None, + game_var_full=self.game_var_full, + vanilla_game_dir=vanilla_game_dir, + stock_game_path=self.stock_game_path + ) + if not dxvk_created or not dxvk_verified: + self.logger.warning("DXVK configuration file is missing or incomplete after post-install steps.") + self.logger.warning("Failed to verify dxvk.conf file (required for AMD GPUs).") + self.logger.info("Step 10: Creating dxvk.conf... Done") + + # Step 11a: Small Tasks - Delete Incompatible Plugins + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugins") + self.logger.info("Step 11a: Deleting incompatible MO2 plugins...") + + # Delete FixGameRegKey.py plugin + fixgamereg_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py" + if fixgamereg_path.exists(): + try: + fixgamereg_path.unlink() + self.logger.info("FixGameRegKey.py plugin deleted successfully.") + except Exception as e: + self.logger.warning(f"Failed to delete FixGameRegKey.py plugin: {e}") + self.logger.warning("Failed to delete FixGameRegKey.py plugin file.") + else: + self.logger.debug("FixGameRegKey.py plugin not found (this is normal).") + + # Delete PageFileManager plugin directory (Linux has no PageFile) + pagefilemgr_path = Path(self.modlist_dir) / "plugins" / "PageFileManager" + if pagefilemgr_path.exists(): + try: + import shutil + shutil.rmtree(pagefilemgr_path) + self.logger.info("PageFileManager plugin directory deleted successfully.") + except Exception as e: + self.logger.warning(f"Failed to delete PageFileManager plugin directory: {e}") + self.logger.warning("Failed to delete PageFileManager plugin directory.") + else: + self.logger.debug("PageFileManager plugin not found (this is normal).") + + self.logger.info("Step 11a: Incompatible plugin deletion check complete.") + + + # Step 11b: Download Font + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Downloading required font") + prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) + if prefix_path_str: + prefix_path = Path(prefix_path_str) + fonts_dir = prefix_path / "pfx" / "drive_c" / "windows" / "Fonts" + font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf" + font_dest_path = fonts_dir / "seguisym.ttf" + + # Pass quiet=True to suppress print during configuration steps + if not self.filesystem_handler.download_file(font_url, font_dest_path, quiet=True): + self.logger.warning(f"Failed to download {font_url} to {font_dest_path}") + self.logger.warning("Failed to download necessary font file (seguisym.ttf).") + # Continue anyway, not critical for all lists + else: + self.logger.info("Font downloaded successfully.") + else: + self.logger.error("Could not get WINEPREFIX path, skipping font download.") + self.logger.warning("Could not determine Wine prefix path, skipping font download.") + + # Step 12: Modlist-specific steps + if status_callback: + status_callback(f"{self._get_progress_timestamp()} Checking for modlist-specific steps") + status_callback("") # Blank line after final Prefix Configuration step + self.logger.info("Step 12: Checking for modlist-specific steps...") + + # Step 13: Launch options for special games are now set during automated prefix workflow (before Steam restart) + # Avoids a second Steam restart + special_game_type = self.detect_special_game_type(self.modlist_dir) + if special_game_type: + self.logger.info(f"Step 13: Launch options for {special_game_type.upper()} were set during automated workflow") + else: + self.logger.debug("Step 13: No special launch options needed for this modlist type") + + # Do not call status_callback here, the final message is handled in menu_handler + # if status_callback: + # status_callback("Configuration completed successfully!") + + self.logger.info("Configuration steps completed successfully.") + + # Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333) + self._re_enforce_windows_10_mode() + + return True # Return True on success + + def run_modlist_configuration_phase(self, context: dict = None) -> bool: + """ + Main entry point to run the full modlist configuration sequence. + This orchestrates all the individual steps. + """ + self.logger.info(f"Starting configuration phase for modlist: {self.game_name}") + # Call the private method that contains the actual steps + # Pass along the status_callback if it was provided in the context + status_callback = context.get('status_callback') if context else None + return self._execute_configuration_steps(status_callback=status_callback) + + def _prompt_or_set_resolution(self): + # If on Steam Deck, set 1280x800 automatically + if self._is_steam_deck(): + self.selected_resolution = "1280x800" + self.logger.info("Steam Deck detected: setting resolution to 1280x800.") + else: + print("Do you wish to set the display resolution? (This can be changed manually later)") + response = input("Set resolution? (y/N): ").strip().lower() + if response == 'y': + while True: + user_res = input("Enter resolution (e.g., 1920x1080): ").strip() + if re.match(r'^[0-9]+x[0-9]+$', user_res): + self.selected_resolution = user_res + self.logger.info(f"User selected resolution: {user_res}") + break + else: + print("Invalid format. Please use format: 1920x1080") + else: + self.selected_resolution = None + self.logger.info("Resolution setup skipped by user.") + diff --git a/jackify/backend/handlers/modlist_detection.py b/jackify/backend/handlers/modlist_detection.py new file mode 100644 index 0000000..09cbc14 --- /dev/null +++ b/jackify/backend/handlers/modlist_detection.py @@ -0,0 +1,376 @@ +"""Detection and discovery methods for ModlistHandler (Mixin).""" +from pathlib import Path +from typing import Dict, List, Optional +import os +import re +import logging +import subprocess + +logger = logging.getLogger(__name__) + + +class ModlistDetectionMixin: + """Mixin providing detection and discovery methods for ModlistHandler. + + These methods are separated for code organization but require + ModlistHandler's instance attributes (self.logger, self.path_handler, etc.) + """ + + def _detect_modlists_from_shortcuts(self) -> bool: + """ + Detect modlists from Steam shortcuts.vdf entries + """ + self.logger.info("Detecting modlists from Steam shortcuts") + return False + + def discover_executable_shortcuts(self, executable_name: str) -> List[Dict]: + """Discovers non-Steam shortcuts pointing to a specific executable. + + Args: + executable_name: The name of the executable (e.g., "ModOrganizer.exe") + to look for in the shortcut's 'Exe' path. + + Returns: + A list of dictionaries, each containing validated shortcut info: + {'name': AppName, 'appid': AppID, 'path': StartDir} + Returns an empty list if none are found or an error occurs. + """ + self.logger.info(f"Discovering non-Steam shortcuts for executable: {executable_name}") + discovered_modlists_info = [] + + try: + # Get shortcuts pointing to the executable from shortcuts.vdf + matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name) + if not matching_vdf_shortcuts: + self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.") + return [] + self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}") + + # Process each matching shortcut and convert signed AppID to unsigned + for vdf_shortcut in matching_vdf_shortcuts: + app_name = vdf_shortcut.get('AppName') + start_dir = vdf_shortcut.get('StartDir') + signed_appid = vdf_shortcut.get('appid') + + if not app_name or not start_dir: + self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}") + continue + + if signed_appid is None: + self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}") + continue + + # Convert signed AppID to unsigned AppID (the format used by Steam prefixes) + if signed_appid < 0: + unsigned_appid = signed_appid + (2**32) + else: + unsigned_appid = signed_appid + + # Append dictionary with all necessary info using unsigned AppID + modlist_info = { + 'name': app_name, + 'appid': unsigned_appid, + 'path': start_dir + } + discovered_modlists_info.append(modlist_info) + self.logger.info(f"Discovered shortcut: '{app_name}' (Signed: {signed_appid} -> Unsigned: {unsigned_appid}, Path: {start_dir})") + + except Exception as e: + self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True) + return [] + + if not discovered_modlists_info: + self.logger.warning("No validated shortcuts found after correlation.") + + return discovered_modlists_info + + def _detect_game_variables(self): + """Detect game_var and game_var_full based on ModOrganizer.ini content.""" + if not self.modlist_ini or not Path(self.modlist_ini).is_file(): + self.logger.error("Cannot detect game variables: ModOrganizer.ini path not set or file not found.") + self.game_var = "Unknown" + self.game_var_full = "Unknown" + return False + + # Define mapping from loader executable to full game name + loader_to_game = { + "skse64_loader.exe": "Skyrim Special Edition", + "f4se_loader.exe": "Fallout 4", + "nvse_loader.exe": "Fallout New Vegas", + "obse_loader.exe": "Oblivion" + } + + # Short name lookup + short_name_lookup = { + "Skyrim Special Edition": "Skyrim", + "Fallout 4": "Fallout", + "Fallout New Vegas": "FNV", + "Oblivion": "Oblivion" + } + + try: + with open(self.modlist_ini, 'r', encoding='utf-8', errors='ignore') as f: + ini_content = f.read().lower() + except Exception as e: + self.logger.error(f"Error reading ModOrganizer.ini ({self.modlist_ini}): {e}") + self.game_var = "Unknown" + self.game_var_full = "Unknown" + return False + + found_game = None + for loader, game_name in loader_to_game.items(): + if loader in ini_content: + found_game = game_name + self.logger.info(f"Detected game type '{found_game}' based on finding '{loader}' in ModOrganizer.ini") + break + + if found_game: + self.game_var_full = found_game + self.game_var = short_name_lookup.get(found_game, found_game.split()[0]) + return True + else: + self.logger.warning(f"Could not detect game type from ModOrganizer.ini content. Check INI for known loaders (skse64, f4se, nvse, obse).") + self.game_var = "Unknown" + self.game_var_full = "Unknown" + return False + + def _detect_proton_version(self): + """Detect the Proton version used for the modlist prefix.""" + self.logger.info(f"Detecting Proton version for AppID {self.appid}...") + self.proton_ver = "Unknown" + + if not self.appid: + self.logger.error("Cannot detect Proton version without a valid AppID.") + return False + + # Check config.vdf first for user-selected tool name + try: + config_vdf_path = self.path_handler.find_steam_config_vdf() + if config_vdf_path and config_vdf_path.exists(): + import vdf + with open(config_vdf_path, 'r') as f: + data = vdf.load(f) + + mapping = data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}) + app_mapping = mapping.get(str(self.appid), {}) + tool_name = app_mapping.get('name', '') + + if tool_name and 'experimental' in tool_name.lower(): + self.proton_ver = tool_name + self.logger.info(f"Detected Proton tool from config.vdf: {self.proton_ver}") + return True + elif tool_name: + self.logger.debug(f"Proton tool from config.vdf: {tool_name}. Checking registry for runtime version.") + else: + self.logger.debug(f"No specific Proton tool mapping found for AppID {self.appid} in config.vdf.") + else: + self.logger.debug("config.vdf not found, proceeding with registry check.") + + except ImportError: + self.logger.warning("Python 'vdf' library not found. Cannot check config.vdf for Proton version. Skipping.") + except Exception as e: + self.logger.warning(f"Error reading config.vdf: {e}. Proceeding with registry check.") + + # If config.vdf didn't yield 'Experimental', check prefix files + if not self.compat_data_path or not self.compat_data_path.exists(): + self.logger.warning(f"Compatdata path '{self.compat_data_path}' not found or invalid for AppID {self.appid}. Cannot detect Proton version via prefix files.") + return False + + # Method 1: Check system.reg + system_reg_path = self.compat_data_path / "pfx" / "system.reg" + if system_reg_path.exists(): + try: + with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + match = re.search(r'"SteamClientProtonVersion"="([^"]+)"\r?', content) + if match: + version_str = match.group(1).strip() + if version_str: + if "GE" in version_str.upper(): + self.proton_ver = version_str + else: + self.proton_ver = f"Proton {version_str}" + self.logger.info(f"Detected Proton runtime version from system.reg: {self.proton_ver}") + return True + else: + self.logger.debug("'SteamClientProtonVersion' not found in system.reg.") + except Exception as e: + self.logger.warning(f"Error reading system.reg: {e}") + else: + self.logger.debug("system.reg not found.") + + # Method 2: Check config_info + config_info_path = self.compat_data_path / "config_info" + if config_info_path.exists(): + try: + with open(config_info_path, 'r') as f: + version_str = f.readline().strip() + if version_str: + if "GE" in version_str.upper(): + self.proton_ver = version_str + else: + self.proton_ver = f"Proton {version_str}" + self.logger.info(f"Detected Proton runtime version from config_info: {self.proton_ver}") + return True + except Exception as e: + self.logger.warning(f"Error reading config_info: {e}") + else: + self.logger.debug("config_info file not found.") + + self.logger.warning(f"Could not detect Proton version for AppID {self.appid} from prefix files.") + return False + + def _detect_steam_library_info(self) -> bool: + """Detects Steam Library path and whether it's on an SD card.""" + from .path_handler import PathHandler + + self.logger.debug("Detecting Steam Library path...") + steam_lib_path_str = PathHandler.find_steam_library() + + if not steam_lib_path_str: + self.logger.error("PathHandler.find_steam_library() failed to find a Steam library.") + self.steam_library = None + self.basegame_sdcard = False + return False + + self.steam_library = steam_lib_path_str + self.logger.info(f"Detected Steam Library: {self.steam_library}") + + self.logger.debug(f"Checking if Steam Library {self.steam_library} is on SD card...") + steam_lib_path_obj = Path(self.steam_library) + self.basegame_sdcard = self.filesystem_handler.is_sd_card(steam_lib_path_obj) + self.logger.info(f"Base game library on SD card: {self.basegame_sdcard}") + + return True + + def _detect_stock_game_path(self): + """Detects common 'Stock Game' or 'Game Root' directories within the modlist path.""" + self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...") + if not self.modlist_dir: + self.logger.error("Modlist directory not set, cannot detect stock game path.") + return False + + modlist_path = Path(self.modlist_dir) + common_names = [ + "Stock Game", + "Game Root", + "STOCK GAME", + "Stock Game Folder", + "Stock Folder", + "Skyrim Stock", + Path("root/Skyrim Special Edition") + ] + + found_path = None + for name in common_names: + potential_path = modlist_path / name + if potential_path.is_dir(): + found_path = str(potential_path) + self.logger.info(f"Found potential stock game directory: {found_path}") + break + + if found_path: + self.stock_game_path = found_path + return True + else: + self.stock_game_path = None + self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.") + return True + + def _is_steam_deck(self): + """Detect if running on Steam Deck.""" + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + return True + user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True) + if 'app-steam@autostart.service' in user_services.stdout: + return True + except Exception as e: + self.logger.warning(f"Error detecting Steam Deck: {e}") + return False + + def detect_special_game_type(self, modlist_dir: str) -> Optional[str]: + """ + Detect if this modlist requires vanilla compatdata instead of new prefix. + + Detects special game types that need to use existing vanilla game compatdata: + - FNV: Has nvse_loader.exe + - Enderal: Has Enderal Launcher.exe + + Args: + modlist_dir: Path to the modlist installation directory + + Returns: + str: Game type ("fnv", "enderal") or None if not a special game + """ + if not modlist_dir: + return None + + modlist_path = Path(modlist_dir) + if not modlist_path.exists() or not modlist_path.is_dir(): + self.logger.debug(f"Modlist directory does not exist: {modlist_dir}") + return None + + self.logger.debug(f"Checking for special game type in: {modlist_dir}") + + # Check ModOrganizer.ini for indicators + try: + mo2_ini = modlist_path / "ModOrganizer.ini" + if not mo2_ini.exists(): + somnium_mo2_ini = modlist_path / "files" / "ModOrganizer.ini" + if somnium_mo2_ini.exists(): + mo2_ini = somnium_mo2_ini + + if mo2_ini.exists(): + try: + content = mo2_ini.read_text(errors='ignore').lower() + 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 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" + except Exception as e: + self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}") + except Exception: + pass + + # Check for FNV and Enderal launchers in common locations + candidates = [modlist_path] + try: + from .path_handler import STOCK_GAME_FOLDERS + for folder_name in STOCK_GAME_FOLDERS: + sub = modlist_path / folder_name + if sub.exists() and sub.is_dir(): + candidates.append(sub) + except Exception: + pass + + for base in candidates: + nvse_loader = base / "nvse_loader.exe" + if nvse_loader.exists(): + self.logger.info(f"Detected FNV modlist: found nvse_loader.exe in '{base}'") + return "fnv" + enderal_launcher = base / "Enderal Launcher.exe" + if enderal_launcher.exists(): + self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'") + return "enderal" + + # Final heuristic using game_var + try: + game_type = getattr(self, 'game_var', None) + if isinstance(game_type, str): + gt = game_type.strip().lower() + if 'fallout new vegas' in gt or gt == 'fnv': + self.logger.info("Heuristic detection: game_var indicates FNV") + return "fnv" + if 'enderal' in gt: + self.logger.info("Heuristic detection: game_var indicates Enderal") + return "enderal" + except Exception: + pass + + self.logger.debug("No special game type detected - standard workflow will be used") + return None diff --git a/jackify/backend/handlers/modlist_handler.py b/jackify/backend/handlers/modlist_handler.py index e8fcbb4..5e7fb50 100644 --- a/jackify/backend/handlers/modlist_handler.py +++ b/jackify/backend/handlers/modlist_handler.py @@ -15,7 +15,6 @@ import sys # Import our modules from .path_handler import PathHandler -# from .wine_utils import WineUtils # Removed unused import from .filesystem_handler import FileSystemHandler from .protontricks_handler import ProtontricksHandler from .shortcut_handler import ShortcutHandler @@ -23,6 +22,9 @@ from .resolution_handler import ResolutionHandler # Import our safe VDF handler from .vdf_handler import VDFHandler +from .modlist_detection import ModlistDetectionMixin +from .modlist_configuration import ModlistConfigurationMixin +from .modlist_wine_ops import ModlistWineOpsMixin # Import colors from the new central location from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SELECTION, COLOR_ERROR @@ -54,7 +56,7 @@ except Exception: # If signal handling fails, just continue without it pass -class ModlistHandler: +class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWineOpsMixin): """ Handles operations related to modlist detection and configuration """ @@ -71,7 +73,7 @@ class ModlistHandler: } # Canonical mapping of modlist-specific Wine components (from omni-guides.sh) - # NOTE: dotnet4.x components disabled in v0.1.6.2 - replaced with universal registry fixes + # dotnet4.x components disabled in v0.1.6.2 -- replaced with universal registry fixes MODLIST_WINE_COMPONENTS = { # "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation # "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40 @@ -214,74 +216,6 @@ class ModlistHandler: except Exception as e: self.logger.error(f"Error loading modlists: {e}") - def _detect_modlists_from_shortcuts(self) -> bool: - """ - Detect modlists from Steam shortcuts.vdf entries - """ - self.logger.info("Detecting modlists from Steam shortcuts") - return False # Placeholder return - - def discover_executable_shortcuts(self, executable_name: str) -> List[Dict]: - """Discovers non-Steam shortcuts pointing to a specific executable. - - Args: - executable_name: The name of the executable (e.g., "ModOrganizer.exe") - to look for in the shortcut's 'Exe' path. - - Returns: - A list of dictionaries, each containing validated shortcut info: - {'name': AppName, 'appid': AppID, 'path': StartDir} - Returns an empty list if none are found or an error occurs. - """ - self.logger.info(f"Discovering non-Steam shortcuts for executable: {executable_name}") - discovered_modlists_info = [] - - try: - # Get shortcuts pointing to the executable from shortcuts.vdf - matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name) - if not matching_vdf_shortcuts: - self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.") - return [] - self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}") - - # Process each matching shortcut and convert signed AppID to unsigned - for vdf_shortcut in matching_vdf_shortcuts: - app_name = vdf_shortcut.get('AppName') - start_dir = vdf_shortcut.get('StartDir') - signed_appid = vdf_shortcut.get('appid') - - if not app_name or not start_dir: - self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}") - continue - - if signed_appid is None: - self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}") - continue - - # Convert signed AppID to unsigned AppID (the format used by Steam prefixes) - if signed_appid < 0: - unsigned_appid = signed_appid + (2**32) - else: - unsigned_appid = signed_appid - - # Append dictionary with all necessary info using unsigned AppID - modlist_info = { - 'name': app_name, - 'appid': unsigned_appid, - 'path': start_dir - } - discovered_modlists_info.append(modlist_info) - self.logger.info(f"Discovered shortcut: '{app_name}' (Signed: {signed_appid} → Unsigned: {unsigned_appid}, Path: {start_dir})") - - except Exception as e: - self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True) - return [] - - if not discovered_modlists_info: - self.logger.warning("No validated shortcuts found after correlation.") - - return discovered_modlists_info - def set_modlist(self, modlist_info: Dict) -> bool: """Sets the internal context based on the selected modlist dictionary. @@ -365,6 +299,11 @@ class ModlistHandler: self.engine_installed = modlist_info.get('engine_installed', False) self.logger.debug(f" Engine Installed: {self.engine_installed}") + # Store download_dir when known (Install a Modlist flow); Configure New/Existing leave None + self.download_dir = modlist_info.get('download_dir') + if self.download_dir: + self.logger.debug(f" Download dir (for MO2): {self.download_dir}") + # Call internal detection methods to populate more state if not self._detect_game_variables(): @@ -372,1420 +311,6 @@ class ModlistHandler: # Decide if failure to detect game should make set_modlist return False # return False - # TODO: Add calls here or later to detect_steam_library, - # detect_compatdata_path, detect_proton_version based on the now-known AppID/paths - # to fully populate the handler's state before configuration phase. - return True - def _detect_game_variables(self): - """Detect game_var and game_var_full based on ModOrganizer.ini content.""" - if not self.modlist_ini or not Path(self.modlist_ini).is_file(): - self.logger.error("Cannot detect game variables: ModOrganizer.ini path not set or file not found.") - self.game_var = "Unknown" - self.game_var_full = "Unknown" - return False - - # Define mapping from loader executable to full game name - loader_to_game = { - "skse64_loader.exe": "Skyrim Special Edition", - "f4se_loader.exe": "Fallout 4", - "nvse_loader.exe": "Fallout New Vegas", - "obse_loader.exe": "Oblivion" - # Add others if needed - } - - # Short name lookup (can derive from full name later) - short_name_lookup = { - "Skyrim Special Edition": "Skyrim", - "Fallout 4": "Fallout", - "Fallout New Vegas": "FNV", # Or "Fallout" - "Oblivion": "Oblivion" - } - - try: - with open(self.modlist_ini, 'r', encoding='utf-8', errors='ignore') as f: - ini_content = f.read().lower() # Read entire file, lowercase for easier matching - except Exception as e: - self.logger.error(f"Error reading ModOrganizer.ini ({self.modlist_ini}): {e}") - self.game_var = "Unknown" - self.game_var_full = "Unknown" - return False - - found_game = None - for loader, game_name in loader_to_game.items(): - # Look for the loader name within the INI content - # A simple check might be enough, or use regex for more specific context - # (e.g., in a binary= line) - if loader in ini_content: - found_game = game_name - self.logger.info(f"Detected game type '{found_game}' based on finding '{loader}' in ModOrganizer.ini") - break - - if found_game: - self.game_var_full = found_game - self.game_var = short_name_lookup.get(found_game, found_game.split()[0]) # Fallback short name - return True - else: - # Fallback: Could try checking self.game_name keywords as a last resort? - self.logger.warning(f"Could not detect game type from ModOrganizer.ini content. Check INI for known loaders (skse64, f4se, nvse, obse).") - # Optionally, ask the user here? - self.game_var = "Unknown" - self.game_var_full = "Unknown" - return False # Indicate detection failed - - def _detect_proton_version(self): - """Detect the Proton version used for the modlist prefix.""" - self.logger.info(f"Detecting Proton version for AppID {self.appid}...") - self.proton_ver = "Unknown" - - if not self.appid: - self.logger.error("Cannot detect Proton version without a valid AppID.") - return False - - # --- Check config.vdf first for user-selected tool name --- - try: - # Reuse PathHandler's method to find config.vdf - config_vdf_path = self.path_handler.find_steam_config_vdf() - if config_vdf_path and config_vdf_path.exists(): - import vdf # Assuming vdf library is available - with open(config_vdf_path, 'r') as f: - data = vdf.load(f) - - # Navigate the VDF structure (adjust path as needed based on vdf library usage) - mapping = data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}) - app_mapping = mapping.get(str(self.appid), {}) - tool_name = app_mapping.get('name', '') - - if tool_name and 'experimental' in tool_name.lower(): - self.proton_ver = tool_name # Use the name from config.vdf (e.g., proton_experimental) - self.logger.info(f"Detected Proton tool from config.vdf: {self.proton_ver}") - return True - elif tool_name: # If found but not experimental, log it but proceed to reg check - self.logger.debug(f"Proton tool from config.vdf: {tool_name}. Checking registry for runtime version.") - else: - self.logger.debug(f"No specific Proton tool mapping found for AppID {self.appid} in config.vdf.") - else: - self.logger.debug("config.vdf not found, proceeding with registry check.") - - except ImportError: - self.logger.warning("Python 'vdf' library not found. Cannot check config.vdf for Proton version. Skipping.") - except Exception as e: - self.logger.warning(f"Error reading config.vdf: {e}. Proceeding with registry check.") - # --- End config.vdf check --- - - # --- If config.vdf didn't yield 'Experimental', check prefix files --- - if not self.compat_data_path or not self.compat_data_path.exists(): - self.logger.warning(f"Compatdata path '{self.compat_data_path}' not found or invalid for AppID {self.appid}. Cannot detect Proton version via prefix files.") - # Keep self.proton_ver as "Unknown" if config.vdf also failed - return False - - # Method 1: Check system.reg (Primary runtime check) - system_reg_path = self.compat_data_path / "pfx" / "system.reg" - if system_reg_path.exists(): - try: - with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - # Use regex to find the version string - match = re.search(r'"SteamClientProtonVersion"="([^"]+)"\r?', content) - if match: - version_str = match.group(1).strip() - if version_str: - # Check if it's a GE version - if "GE" in version_str.upper(): - self.proton_ver = version_str - else: - self.proton_ver = f"Proton {version_str}" - self.logger.info(f"Detected Proton runtime version from system.reg: {self.proton_ver}") - return True - else: - self.logger.debug("'SteamClientProtonVersion' not found in system.reg.") - - except Exception as e: - self.logger.warning(f"Error reading system.reg: {e}") - else: - self.logger.debug("system.reg not found.") - - # Method 2: Check config_info (Fallback runtime check) - config_info_path = self.compat_data_path / "config_info" - if config_info_path.exists(): - try: - with open(config_info_path, 'r') as f: - version_str = f.readline().strip() - if version_str: - # Check if it's a GE version - if "GE" in version_str.upper(): - self.proton_ver = version_str - else: - self.proton_ver = f"Proton {version_str}" - self.logger.info(f"Detected Proton runtime version from config_info: {self.proton_ver}") - return True - except Exception as e: - self.logger.warning(f"Error reading config_info: {e}") - else: - self.logger.debug("config_info file not found.") - - # If neither method worked - self.logger.warning(f"Could not detect Proton version for AppID {self.appid} from prefix files.") - # self.proton_ver remains "Unknown" from initialization - return False - - def display_modlist_summary(self, skip_confirmation: bool = False) -> bool: - """Display the detected modlist summary and ask for confirmation.""" - if not self.appid or not self.modlist_dir or not self.modlist_ini: - logger.error("Cannot display summary: Missing essential modlist context.") - return False - - # Detect potentially missing info if not already set - if not self.game_name: - self._detect_game_variables() - if not self.proton_ver or self.proton_ver == "Unknown": - self._detect_proton_version() - - # Don't reset timing - continue from Steam Integration timing - print("=== Configuration Summary ===") - print(f"{self._get_progress_timestamp()} Selected Modlist: {self.game_name}") - print(f"{self._get_progress_timestamp()} Game Type: {self.game_var_full if self.game_var_full else 'Unknown'}") - print(f"{self._get_progress_timestamp()} Steam App ID: {self.appid}") - print(f"{self._get_progress_timestamp()} Modlist Directory: {self.modlist_dir}") - print(f"{self._get_progress_timestamp()} ModOrganizer.ini: {self.modlist_dir}/ModOrganizer.ini") - print(f"{self._get_progress_timestamp()} Proton Version: {self.proton_ver if self.proton_ver else 'Unknown'}") - print(f"{self._get_progress_timestamp()} Resolution: {self.selected_resolution if self.selected_resolution else 'Default'}") - print(f"{self._get_progress_timestamp()} Modlist on SD Card: {self.modlist_sdcard}") - print("") - - if skip_confirmation: - return True - # Ask for confirmation - proceed = input(f"{COLOR_PROMPT}Proceed with configuration? (Y/n): {COLOR_RESET}").lower() - if proceed == 'n': # Now defaults to Yes unless 'n' is entered - logger.info("Configuration cancelled by user after summary.") - return False - else: - return True - - def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False): - """ - Runs the actual configuration steps for the selected modlist. - Args: - status_callback (callable, optional): A function to call with status updates during configuration. - manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow). - """ - try: - # Store status_callback for Configuration Summary - self._current_status_callback = status_callback - - self.logger.info("Executing configuration steps...") - - # Ensure required context is set - if not all([self.modlist_dir, self.appid, self.game_var, self.steamdeck is not None]): - self.logger.error("Cannot execute configuration steps: Missing required context (modlist_dir, appid, game_var, steamdeck status).") - print("Error: Missing required information to start configuration.") - return False - except Exception as e: - self.logger.error(f"Exception in _execute_configuration_steps initialization: {e}", exc_info=True) - return False - - # Step 1: Set protontricks permissions - if status_callback: - # Reset timing for Prefix Configuration section - from jackify.shared.timing import start_new_phase - start_new_phase() - - status_callback("") # Blank line after Configuration Summary - status_callback("") # Extra blank line before Prefix Configuration - status_callback("=== Prefix Configuration ===") - status_callback(f"{self._get_progress_timestamp()} Setting Protontricks permissions") - self.logger.info("Step 1: Setting Protontricks permissions...") - if not self.protontricks_handler.set_protontricks_permissions(self.modlist_dir, self.steamdeck): - self.logger.error("Failed to set Protontricks permissions. Configuration aborted.") - print("Error: Could not set necessary Protontricks permissions.") - return False # Abort on failure - self.logger.info("Step 1: Setting Protontricks permissions... Done") - - # Step 2: Prompt user for manual steps and wait for compatdata - skip_manual_prompt = False - if not manual_steps_completed: - # Check if Proton Experimental is already set and compatdata exists - proton_ok = False - compatdata_ok = False - - # Check Proton version - self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}") - if self._detect_proton_version(): - self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}") - if self.proton_ver and 'experimental' in self.proton_ver.lower(): - proton_ok = True - self.logger.debug("[MANUAL STEPS DEBUG] Proton Experimental detected - proton_ok = True") - else: - self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version") - - # Check compatdata/prefix - prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) - self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}") - - if prefix_path_str and os.path.isdir(prefix_path_str): - compatdata_ok = True - self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory exists - compatdata_ok = True") - else: - self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory does not exist") - - self.logger.debug(f"[MANUAL STEPS DEBUG] proton_ok: {proton_ok}, compatdata_ok: {compatdata_ok}") - - if proton_ok and compatdata_ok: - self.logger.info("Proton Experimental and compatdata already set for this AppID; skipping manual steps prompt.") - skip_manual_prompt = True - else: - self.logger.debug("[MANUAL STEPS DEBUG] Manual steps will be required") - - self.logger.debug(f"[MANUAL STEPS DEBUG] manual_steps_completed: {manual_steps_completed}, skip_manual_prompt: {skip_manual_prompt}") - - if not manual_steps_completed and not skip_manual_prompt: - # Check if we're in GUI mode - if so, don't show CLI prompts, just fail and let GUI callbacks handle it - gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' - - if gui_mode: - # In GUI mode: don't show CLI prompts, just fail so GUI can show dialog and retry - self.logger.info("GUI mode detected: skipping CLI manual steps prompt, will fail configuration to trigger GUI callback") - if status_callback: - status_callback("Manual Steam/Proton setup required - this will be handled by GUI dialog") - # Return False to trigger manual steps callback in GUI - return False - else: - # CLI mode: show the traditional CLI prompt - if status_callback: - status_callback("Please perform the manual steps in Steam (set Proton, launch shortcut, then close MO2)...") - self.logger.info("Prompting user to perform manual Steam/Proton steps and launch shortcut.") - print("\n───────────────────────────────────────────────────────────────────") - print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} Please follow the on-screen instructions to set Proton Experimental and launch the shortcut from Steam.") - print("───────────────────────────────────────────────────────────────────") - input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") - self.logger.info("User confirmed completion of manual steps.") - # Step 3: Download and apply curated user.reg.modlist and system.reg.modlist - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration") - self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...") - try: - prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) - if not prefix_path_str or not os.path.isdir(prefix_path_str): - raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.") - user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist" - user_reg_dest = Path(prefix_path_str) / "user.reg" - response = requests.get(user_reg_url, verify=True) - response.raise_for_status() - with open(user_reg_dest, "wb") as f: - f.write(response.content) - self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}") - system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist" - system_reg_dest = Path(prefix_path_str) / "system.reg" - response = requests.get(system_reg_url, verify=True) - response.raise_for_status() - with open(system_reg_dest, "wb") as f: - f.write(response.content) - self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}") - except Exception as e: - self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}") - print(f"{COLOR_ERROR}Error: Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}{COLOR_RESET}") - return False - self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.") - - # Step 4: Install Wine Components - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)") - self.logger.info("Step 4: Installing Wine components (this may take a while)...") - - # Use canonical logic for all modlists/games - components = self.get_modlist_wine_components(self.game_name, self.game_var_full) - - # DISABLED: Special game wine component routing - now using registry injection approach - # special_game_type = self.detect_special_game_type(self.modlist_dir) - # if special_game_type == "fnv": - # target_appid = "22380" # Vanilla Fallout New Vegas AppID - # elif special_game_type == "enderal": - # target_appid = "976620" # Enderal: Forgotten Stories Special Edition AppID - # else: - # target_appid = self.appid # Normal modlist AppID - - # All modlists now use their own AppID for wine components - target_appid = self.appid - - # Use user's preferred component installation method (respects settings toggle) - self.logger.debug(f"Getting WINEPREFIX for AppID {target_appid}...") - wineprefix = self.protontricks_handler.get_wine_prefix_path(target_appid) - if not wineprefix: - self.logger.error("Failed to get WINEPREFIX path for component installation.") - print("Error: Could not determine wine prefix location.") - return False - self.logger.debug(f"WINEPREFIX obtained: {wineprefix}") - - # Use the winetricks handler which respects the user's toggle setting - try: - self.logger.info("Installing Wine components using user's preferred method...") - self.logger.debug(f"Calling winetricks_handler.install_wine_components with wineprefix={wineprefix}, game_var={self.game_var_full}, components={components}") - success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components, status_callback=status_callback) - if success: - self.logger.info("Wine component installation completed successfully") - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Wine components verified and installed successfully") - else: - self.logger.error("Wine component installation failed") - print("Error: Failed to install necessary Wine components.") - return False - except Exception as e: - self.logger.error(f"Wine component installation failed with exception: {e}") - print("Error: Failed to install necessary Wine components.") - return False - self.logger.info("Step 4: Installing Wine components... Done") - - # Step 4.5: Apply universal dotnet4.x compatibility registry fixes AFTER wine components - # This ensures the fixes are not overwritten by component installation processes - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes") - self.logger.info("Step 4.5: Applying universal dotnet4.x compatibility registry fixes...") - registry_success = False - try: - registry_success = self._apply_universal_dotnet_fixes() - except Exception as e: - error_msg = f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}" - self.logger.error(error_msg) - if status_callback: - status_callback(f"{self._get_progress_timestamp()} ERROR: {error_msg}") - registry_success = False - - if not registry_success: - failure_msg = "WARNING: Universal dotnet4.x registry fixes FAILED! This modlist may experience .NET Framework compatibility issues." - self.logger.error("=" * 80) - self.logger.error(failure_msg) - self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.") - self.logger.error("=" * 80) - if status_callback: - status_callback(f"{self._get_progress_timestamp()} {failure_msg}") - # Continue but user should be aware of potential issues - - # Step 4.6: Enable dotfiles visibility for Wine prefix - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility") - self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...") - try: - if self.protontricks_handler.enable_dotfiles(self.appid): - self.logger.info("Dotfiles visibility enabled successfully") - else: - self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)") - except Exception as e: - self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)") - self.logger.info("Step 4.6: Enabling dotfiles visibility... Done") - - # Step 4.7: Create Wine prefix Documents directories for USVFS - # This is critical for USVFS to virtualize profile INI files on first launch - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS") - self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...") - try: - if self.appid and self.game_var: - # Map game_var to game_name for create_required_dirs - game_name_map = { - "skyrimspecialedition": "skyrimse", - "fallout4": "fallout4", - "falloutnv": "falloutnv", - "oblivion": "oblivion", - "enderalspecialedition": "enderalse" - } - game_name = game_name_map.get(self.game_var.lower(), None) - - if game_name: - appid_str = str(self.appid) - if self.filesystem_handler.create_required_dirs(game_name, appid_str): - self.logger.info("Wine prefix Documents directories created successfully for USVFS") - else: - self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)") - else: - self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping") - else: - self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation") - except Exception as e: - self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)") - self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done") - - # Step 5: Verify ownership of Modlist directory - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership") - self.logger.info("Step 5: Verifying ownership of modlist directory...") - # Convert modlist_dir string to Path object for the method - modlist_path_obj = Path(self.modlist_dir) - success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj) - if not success: - self.logger.error("Ownership verification failed for modlist directory. Configuration aborted.") - print(f"\n{COLOR_ERROR}{error_msg}{COLOR_RESET}") - return False # Abort on failure - self.logger.info("Step 5: Ownership verification... Done") - - # Step 6: Backup ModOrganizer.ini - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Backing up ModOrganizer.ini") - self.logger.info(f"Step 6: Backing up {self.modlist_ini}...") - modlist_ini_path_obj = Path(self.modlist_ini) - backup_path = self.filesystem_handler.backup_file(modlist_ini_path_obj) - if not backup_path: - self.logger.error("Failed to back up ModOrganizer.ini. Configuration aborted.") - print("Error: Failed to back up ModOrganizer.ini.") - return False # Abort on failure - self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}") - self.logger.info("Step 6: Backing up ModOrganizer.ini... Done") - - # Step 6.5: Handle symlinked downloads directory - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory") - self.logger.info("Step 6.5: Checking for symlinked downloads directory...") - if not self._handle_symlinked_downloads(): - self.logger.warning("Warning during symlink handling (non-critical)") - self.logger.info("Step 6.5: Checking for symlinked downloads directory... Done") - - # Step 7a: Detect Stock Game/Game Root path - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Detecting stock game path") - # This method sets self.stock_game_path if found - if not self._detect_stock_game_path(): - self.logger.error("Failed during stock game path detection.") - print("Error: Failed during stock game path detection.") - return False - - # Step 7b: Detect Steam Library Info (Needed for Step 8) - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Detecting Steam Library info") - self.logger.info("Step 7b: Detecting Steam Library info...") - if not self._detect_steam_library_info(): - self.logger.error("Failed to detect necessary Steam Library information.") - print("Error: Could not find Steam library information.") - return False - self.logger.info("Step 7b: Detecting Steam Library info... Done") - - # Step 8: Update ModOrganizer.ini Paths (gamePath, Binary, workingDirectory) - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Updating ModOrganizer.ini paths") - self.logger.info("Step 8: Updating gamePath, Binary, and workingDirectory paths in ModOrganizer.ini...") - - # Update gamePath using replace_gamepath method - modlist_dir_path_obj = Path(self.modlist_dir) - modlist_ini_path_obj = Path(self.modlist_ini) - stock_game_path_obj = Path(self.stock_game_path) if self.stock_game_path else None - # Only call replace_gamepath if we have a valid stock game path - if stock_game_path_obj: - if not self.path_handler.replace_gamepath( - modlist_ini_path=modlist_ini_path_obj, - new_game_path=stock_game_path_obj, - modlist_sdcard=self.modlist_sdcard - ): - self.logger.error("Failed to update gamePath in ModOrganizer.ini. Configuration aborted.") - print("Error: Failed to update game path in ModOrganizer.ini.") - return False # Abort on failure - else: - self.logger.info("No stock game path found, skipping gamePath update - edit_binary_working_paths will handle all path updates.") - self.logger.info("Using unified path manipulation to avoid duplicate processing.") - - # Conditionally update binary and working directory paths - # Skip for jackify-engine workflows since paths are already correct - # Exception: Always run for SD card installs to fix Z:/run/media/... to D:/... paths - - # DEBUG: Add comprehensive logging to identify Steam Deck SD card path manipulation issues - engine_installed = getattr(self, 'engine_installed', False) - self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler instance: id={id(self)}") - self.logger.debug(f"[SD_CARD_DEBUG] engine_installed: {engine_installed}") - self.logger.debug(f"[SD_CARD_DEBUG] modlist_sdcard: {self.modlist_sdcard}") - self.logger.debug(f"[SD_CARD_DEBUG] steamdeck parameter passed to constructor: {getattr(self, 'steamdeck', 'NOT_SET')}") - self.logger.debug(f"[SD_CARD_DEBUG] Path manipulation condition: not {engine_installed} or {self.modlist_sdcard} = {not engine_installed or self.modlist_sdcard}") - - if not getattr(self, 'engine_installed', False) or self.modlist_sdcard: - # Convert steamapps/common path to library root path - steam_libraries = None - if self.steam_library: - # self.steam_library is steamapps/common, need to go up 2 levels to get library root - steam_library_root = Path(self.steam_library).parent.parent - steam_libraries = [steam_library_root] - self.logger.debug(f"Using Steam library root: {steam_library_root}") - - if not self.path_handler.edit_binary_working_paths( - modlist_ini_path=modlist_ini_path_obj, - modlist_dir_path=modlist_dir_path_obj, - modlist_sdcard=self.modlist_sdcard, - steam_libraries=steam_libraries - ): - self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini. Configuration aborted.") - print("Error: Failed to update binary and working directory paths in ModOrganizer.ini.") - return False # Abort on failure - else: - self.logger.debug("[SD_CARD_DEBUG] Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini") - self.logger.debug(f"[SD_CARD_DEBUG] SKIPPED because: engine_installed={engine_installed} and modlist_sdcard={self.modlist_sdcard}") - self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done") - - # Step 9: Update Resolution Settings (if applicable) - if hasattr(self, 'selected_resolution') and self.selected_resolution: - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Updating resolution settings") - # Ensure resolution_handler call uses correct args if needed - # Assuming it uses modlist_dir (str) and game_var_full (str) - # Construct vanilla game directory path for fallback - vanilla_game_dir = None - if self.steam_library and self.game_var_full: - vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full) - - if not ResolutionHandler.update_ini_resolution( - modlist_dir=self.modlist_dir, - game_var=self.game_var_full, - set_res=self.selected_resolution, - vanilla_game_dir=vanilla_game_dir - ): - self.logger.warning("Failed to update resolution settings in some INI files.") - print("Warning: Failed to update resolution settings.") - self.logger.info("Step 9: Updating resolution in INI files... Done") - else: - self.logger.info("Step 9: Skipping resolution update (no resolution selected).") - - # Step 10: Create dxvk.conf (skip for special games using vanilla compatdata) - special_game_type = self.detect_special_game_type(self.modlist_dir) - self.logger.debug(f"DXVK step - modlist_dir='{self.modlist_dir}', special_game_type='{special_game_type}'") - - # Force check specific files for debugging - nvse_path = Path(self.modlist_dir) / "nvse_loader.exe" if self.modlist_dir else None - enderal_path = Path(self.modlist_dir) / "Enderal Launcher.exe" if self.modlist_dir else None - self.logger.debug(f"nvse_loader.exe exists: {nvse_path.exists() if nvse_path else 'N/A'}") - self.logger.debug(f"Enderal Launcher.exe exists: {enderal_path.exists() if enderal_path else 'N/A'}") - - if special_game_type: - self.logger.info(f"Step 10: Skipping dxvk.conf creation for {special_game_type.upper()} (uses vanilla compatdata)") - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Skipping dxvk.conf for {special_game_type.upper()} modlist") - else: - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Creating dxvk.conf file") - self.logger.info("Step 10: Creating dxvk.conf file...") - # Assuming create_dxvk_conf still uses string paths - # Construct vanilla game directory path for fallback - vanilla_game_dir = None - if self.steam_library and self.game_var_full: - vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full) - - dxvk_created = self.path_handler.create_dxvk_conf( - modlist_dir=self.modlist_dir, - modlist_sdcard=self.modlist_sdcard, - steam_library=str(self.steam_library) if self.steam_library else None, # Pass as string or None - basegame_sdcard=self.basegame_sdcard, - game_var_full=self.game_var_full, - vanilla_game_dir=vanilla_game_dir, - stock_game_path=self.stock_game_path - ) - dxvk_verified = self.path_handler.verify_dxvk_conf_exists( - modlist_dir=self.modlist_dir, - steam_library=str(self.steam_library) if self.steam_library else None, - game_var_full=self.game_var_full, - vanilla_game_dir=vanilla_game_dir, - stock_game_path=self.stock_game_path - ) - if not dxvk_created or not dxvk_verified: - self.logger.warning("DXVK configuration file is missing or incomplete after post-install steps.") - print("Warning: Failed to verify dxvk.conf file (required for AMD GPUs).") - self.logger.info("Step 10: Creating dxvk.conf... Done") - - # Step 11a: Small Tasks - Delete Incompatible Plugins - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugins") - self.logger.info("Step 11a: Deleting incompatible MO2 plugins...") - - # Delete FixGameRegKey.py plugin - fixgamereg_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py" - if fixgamereg_path.exists(): - try: - fixgamereg_path.unlink() - self.logger.info("FixGameRegKey.py plugin deleted successfully.") - except Exception as e: - self.logger.warning(f"Failed to delete FixGameRegKey.py plugin: {e}") - print("Warning: Failed to delete FixGameRegKey.py plugin file.") - else: - self.logger.debug("FixGameRegKey.py plugin not found (this is normal).") - - # Delete PageFileManager plugin directory (Linux has no PageFile) - pagefilemgr_path = Path(self.modlist_dir) / "plugins" / "PageFileManager" - if pagefilemgr_path.exists(): - try: - import shutil - shutil.rmtree(pagefilemgr_path) - self.logger.info("PageFileManager plugin directory deleted successfully.") - except Exception as e: - self.logger.warning(f"Failed to delete PageFileManager plugin directory: {e}") - print("Warning: Failed to delete PageFileManager plugin directory.") - else: - self.logger.debug("PageFileManager plugin not found (this is normal).") - - self.logger.info("Step 11a: Incompatible plugin deletion check complete.") - - - # Step 11b: Download Font - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Downloading required font") - prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) - if prefix_path_str: - prefix_path = Path(prefix_path_str) - fonts_dir = prefix_path / "pfx" / "drive_c" / "windows" / "Fonts" - font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf" - font_dest_path = fonts_dir / "seguisym.ttf" - - # Pass quiet=True to suppress print during configuration steps - if not self.filesystem_handler.download_file(font_url, font_dest_path, quiet=True): - self.logger.warning(f"Failed to download {font_url} to {font_dest_path}") - print("Warning: Failed to download necessary font file (seguisym.ttf).") - # Continue anyway, not critical for all lists - else: - self.logger.info("Font downloaded successfully.") - else: - self.logger.error("Could not get WINEPREFIX path, skipping font download.") - print("Warning: Could not determine Wine prefix path, skipping font download.") - - # Step 12: Modlist-specific steps - if status_callback: - status_callback(f"{self._get_progress_timestamp()} Checking for modlist-specific steps") - status_callback("") # Blank line after final Prefix Configuration step - self.logger.info("Step 12: Checking for modlist-specific steps...") - # ... (rest of the inline logic for step 12) ... - - # Step 13: Launch options for special games are now set during automated prefix workflow (before Steam restart) - # This ensures proper timing and avoids the need for a second Steam restart - special_game_type = self.detect_special_game_type(self.modlist_dir) - if special_game_type: - self.logger.info(f"Step 13: Launch options for {special_game_type.upper()} were set during automated workflow") - else: - self.logger.debug("Step 13: No special launch options needed for this modlist type") - - # Do not call status_callback here, the final message is handled in menu_handler - # if status_callback: - # status_callback("Configuration completed successfully!") - - self.logger.info("Configuration steps completed successfully.") - - # Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333) - self._re_enforce_windows_10_mode() - - return True # Return True on success - - def _detect_steam_library_info(self) -> bool: - """Detects Steam Library path and whether it's on an SD card.""" - self.logger.debug("Detecting Steam Library path...") - steam_lib_path_str = PathHandler.find_steam_library() - - if not steam_lib_path_str: - self.logger.error("PathHandler.find_steam_library() failed to find a Steam library.") - self.steam_library = None - self.basegame_sdcard = False # Assume not on SD if path not found - return False # Indicate failure - - self.steam_library = steam_lib_path_str - self.logger.info(f"Detected Steam Library: {self.steam_library}") - - # Check if the base game library is on SD card - self.logger.debug(f"Checking if Steam Library {self.steam_library} is on SD card...") - steam_lib_path_obj = Path(self.steam_library) - self.basegame_sdcard = self.filesystem_handler.is_sd_card(steam_lib_path_obj) - self.logger.info(f"Base game library on SD card: {self.basegame_sdcard}") - - return True - - def _detect_stock_game_path(self): - """Detects common 'Stock Game' or 'Game Root' directories within the modlist path.""" - self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...") - if not self.modlist_dir: - self.logger.error("Modlist directory not set, cannot detect stock game path.") - return False - - modlist_path = Path(self.modlist_dir) - common_names = [ - "Stock Game", - "Game Root", - "STOCK GAME", - "Stock Game Folder", - "Stock Folder", - "Skyrim Stock", - Path("root/Skyrim Special Edition") # Special case for some lists - # Add other common names if needed - ] - - found_path = None - for name in common_names: - potential_path = modlist_path / name - if potential_path.is_dir(): - found_path = str(potential_path) - self.logger.info(f"Found potential stock game directory: {found_path}") - break # Found the first match - - if found_path: - self.stock_game_path = found_path - # Suppress print during configuration - # print(f"Step 7a: Found stock game directory: {os.path.basename(found_path)}") - return True - else: - self.stock_game_path = None - self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.") - # Do not print this warning to the user - # print("Step 7a: No common Stock Game/Game Root directory found.") - # Still return True, as the check completed. Lack of this dir isn't always an error. - return True - - def verify_proton_setup(self, appid_to_check: str) -> Tuple[bool, str]: - """Verifies that Proton is correctly set up for a given AppID. - - Checks config.vdf for Proton Experimental and existence of compatdata/pfx dir. - - Args: - appid_to_check: The AppID string to verify. - - Returns: - tuple: (bool success, str status_code) - Status codes: 'ok', 'invalid_appid', 'config_vdf_missing', - 'config_vdf_error', 'proton_check_failed', - 'wrong_proton_version', 'compatdata_missing', - 'prefix_missing' - """ - self.logger.info(f"Verifying Proton setup for AppID: {appid_to_check}") - - if not appid_to_check or not appid_to_check.isdigit(): - self.logger.error("Invalid AppID provided for verification.") - return False, 'invalid_appid' - - proton_tool_name = None - compatdata_path_found = None - prefix_exists = False - - # 1. Find and Parse config.vdf - config_vdf_path = None - possible_steam_paths = [ - Path.home() / ".steam/steam", - Path.home() / ".local/share/Steam", - Path.home() / ".steam/root" - ] - for steam_path in possible_steam_paths: - potential_path = steam_path / "config/config.vdf" - if potential_path.is_file(): - config_vdf_path = potential_path - self.logger.debug(f"Found config.vdf at: {config_vdf_path}") - break - - if not config_vdf_path: - self.logger.error("Could not locate Steam's config.vdf file.") - return False, 'config_vdf_missing' - - # Add a short delay to allow Steam to potentially finish writing changes - self.logger.debug("Waiting 2 seconds before reading config.vdf...") - time.sleep(2) - - try: - self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}") - # CORRECTION: Use the vdf library directly here, not VDFHandler - with open(str(config_vdf_path), 'r') as f: - config_data = vdf.load(f, mapper=vdf.VDFDict) - - # --- Write full config.vdf to a debug file --- - import json - debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt") - with open(debug_dump_path, "w") as dump_f: - json.dump(config_data, dump_f, indent=2) - self.logger.info(f"Full config.vdf dumped to {debug_dump_path}") - - # --- Log only the relevant section for this AppID --- - steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}) - compat_mapping = steam_config_section.get('CompatToolMapping', {}) - app_mapping = compat_mapping.get(appid_to_check, {}) - self.logger.debug("───────────────────────────────────────────────────────────────────") - self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):") - self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2)) - self.logger.debug("───────────────────────────────────────────────────────────────────") - self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}") - # --- End Debugging --- - - # Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name - compat_mapping = steam_config_section.get('CompatToolMapping', {}) - app_mapping = compat_mapping.get(appid_to_check, {}) - proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name' - self.proton_ver = proton_tool_name # Store detected version - - if proton_tool_name: - self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}") - else: - self.logger.warning(f"CompatToolMapping entry not found for AppID {appid_to_check} in config.vdf.") - # Add more debug info here about what *was* found - self.logger.debug(f"CompatToolMapping contents: {json.dumps(compat_mapping.get(appid_to_check, 'Key not found'), indent=2)}") - return False, 'proton_check_failed' # Compatibility not explicitly set - - except FileNotFoundError: - self.logger.error(f"Config.vdf file not found during load attempt: {config_vdf_path}") - return False, 'config_vdf_missing' - except Exception as e: - self.logger.error(f"Error parsing config.vdf: {e}", exc_info=True) - return False, 'config_vdf_error' - - # 2. Check if the correct Proton version is set (allowing variations) - # Target: Proton Experimental - if not proton_tool_name or 'experimental' not in proton_tool_name.lower(): - self.logger.warning(f"Incorrect Proton version detected: '{proton_tool_name}'. Expected 'Proton Experimental'.") - return False, 'wrong_proton_version' - - self.logger.info("Proton version check passed ('Proton Experimental' set).") - - # 3. Check for compatdata / prefix directory existence - possible_compat_bases = [ - Path.home() / ".steam/steam/steamapps/compatdata", - Path.home() / ".local/share/Steam/steamapps/compatdata", - # Add SD card paths if necessary / detectable - # Path("/run/media/mmcblk0p1/steamapps/compatdata") # Example - ] - - compat_dir_found = False - for base_path in possible_compat_bases: - potential_compat_path = base_path / appid_to_check - if potential_compat_path.is_dir(): - self.logger.debug(f"Found compatdata directory: {potential_compat_path}") - compat_dir_found = True - # Check for prefix *within* the found compatdata dir - prefix_path = potential_compat_path / "pfx" - if prefix_path.is_dir(): - self.logger.info(f"Wine prefix directory verified: {prefix_path}") - prefix_exists = True - break # Found both compatdata and prefix, exit loop - else: - self.logger.warning(f"Compatdata directory found, but prefix missing: {prefix_path}") - # Keep searching other base paths in case prefix exists elsewhere - - if not compat_dir_found: - self.logger.error(f"Compatdata directory not found for AppID {appid_to_check} in standard locations.") - return False, 'compatdata_missing' - - if not prefix_exists: - # This means we found compatdata but not pfx inside any of them - self.logger.error(f"Wine prefix directory (pfx) not found within any located compatdata directory for AppID {appid_to_check}.") - return False, 'prefix_missing' - - # All checks passed - self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.") - return True, 'ok' - - def run_modlist_configuration_phase(self, context: dict = None) -> bool: - """ - Main entry point to run the full modlist configuration sequence. - This orchestrates all the individual steps. - """ - self.logger.info(f"Starting configuration phase for modlist: {self.game_name}") - # Call the private method that contains the actual steps - # Pass along the status_callback if it was provided in the context - status_callback = context.get('status_callback') if context else None - return self._execute_configuration_steps(status_callback=status_callback) - - def set_steam_grid_images(self, appid: str, modlist_dir: str): - """ - Copies hero, logo, and poster images from the modlist's SteamIcons directory - to the grid directory of all non-zero Steam user directories, named after the new AppID. - """ - steam_icons_dir = Path(modlist_dir) / "SteamIcons" - if not steam_icons_dir.is_dir(): - self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.") - return - - # Find all non-zero Steam user directories - userdata_base = Path.home() / ".steam/steam/userdata" - if not userdata_base.is_dir(): - self.logger.error(f"Steam userdata directory not found at {userdata_base}") - return - - for user_dir in userdata_base.iterdir(): - if not user_dir.is_dir() or user_dir.name == "0": - continue - grid_dir = user_dir / "config/grid" - grid_dir.mkdir(parents=True, exist_ok=True) - - images = [ - ("grid-hero.png", f"{appid}_hero.png"), - ("grid-logo.png", f"{appid}_logo.png"), - ("grid-tall.png", f"{appid}.png"), - ("grid-tall.png", f"{appid}p.png"), - ] - - for src_name, dest_name in images: - src_path = steam_icons_dir / src_name - dest_path = grid_dir / dest_name - if src_path.exists(): - try: - shutil.copyfile(src_path, dest_path) - self.logger.info(f"Copied {src_path} to {dest_path}") - except Exception as e: - self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}") - else: - self.logger.warning(f"Image {src_path} not found; skipping.") - - def get_modlist_wine_components(self, modlist_name, game_var_full=None): - """ - Returns the full list of Wine components to install for a given modlist/game. - - Always includes the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022) - - Adds game-specific extras (from bash script logic) - - Adds any modlist-specific extras (from MODLIST_WINE_COMPONENTS) - """ - default_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] - extras = [] - # Determine game type - game = (game_var_full or modlist_name or "").lower().replace(" ", "") - # 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: - extras += ["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(): - if key in modlist_lower: - extras += components - # Remove duplicates while preserving order - seen = set() - full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))] - return full_list - - def _is_steam_deck(self): - try: - if os.path.exists('/etc/os-release'): - with open('/etc/os-release') as f: - if 'steamdeck' in f.read().lower(): - return True - user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True) - if 'app-steam@autostart.service' in user_services.stdout: - return True - except Exception as e: - self.logger.warning(f"Error detecting Steam Deck: {e}") - return False - - def _prompt_or_set_resolution(self): - # If on Steam Deck, set 1280x800 automatically - if self._is_steam_deck(): - self.selected_resolution = "1280x800" - self.logger.info("Steam Deck detected: setting resolution to 1280x800.") - else: - print("Do you wish to set the display resolution? (This can be changed manually later)") - response = input("Set resolution? (y/N): ").strip().lower() - if response == 'y': - while True: - user_res = input("Enter resolution (e.g., 1920x1080): ").strip() - if re.match(r'^[0-9]+x[0-9]+$', user_res): - self.selected_resolution = user_res - self.logger.info(f"User selected resolution: {user_res}") - break - else: - print("Invalid format. Please use format: 1920x1080") - else: - self.selected_resolution = None - self.logger.info("Resolution setup skipped by user.") - - def detect_special_game_type(self, modlist_dir: str) -> Optional[str]: - """ - Detect if this modlist requires vanilla compatdata instead of new prefix. - - Detects special game types that need to use existing vanilla game compatdata: - - FNV: Has nvse_loader.exe - - Enderal: Has Enderal Launcher.exe - - Args: - modlist_dir: Path to the modlist installation directory - - Returns: - str: Game type ("fnv", "enderal") or None if not a special game - """ - if not modlist_dir: - return None - - modlist_path = Path(modlist_dir) - if not modlist_path.exists() or not modlist_path.is_dir(): - self.logger.debug(f"Modlist directory does not exist: {modlist_dir}") - return None - - self.logger.debug(f"Checking for special game type in: {modlist_dir}") - - # Check ModOrganizer.ini for indicators (nvse/enderal) as an early, robust signal - try: - mo2_ini = modlist_path / "ModOrganizer.ini" - # Also check Somnium's non-standard location - if not mo2_ini.exists(): - somnium_mo2_ini = modlist_path / "files" / "ModOrganizer.ini" - if somnium_mo2_ini.exists(): - mo2_ini = somnium_mo2_ini - - if mo2_ini.exists(): - try: - content = mo2_ini.read_text(errors='ignore').lower() - 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" - # Look for Enderal-specific patterns, not just the word "enderal" - 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" - except Exception as e: - self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}") - except Exception: - pass - - # Check for FNV (Fallout New Vegas) and Enderal launchers in common locations - candidates = [modlist_path] - try: - # Include common stock game subfolders if present - from .path_handler import STOCK_GAME_FOLDERS - for folder_name in STOCK_GAME_FOLDERS: - sub = modlist_path / folder_name - if sub.exists() and sub.is_dir(): - candidates.append(sub) - except Exception: - # If import fails, continue with root-only - pass - - for base in candidates: - nvse_loader = base / "nvse_loader.exe" - if nvse_loader.exists(): - self.logger.info(f"Detected FNV modlist: found nvse_loader.exe in '{base}'") - return "fnv" - enderal_launcher = base / "Enderal Launcher.exe" - if enderal_launcher.exists(): - self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'") - return "enderal" - - # As a final heuristic, use known game type if available in handler state - try: - game_type = getattr(self, 'game_var', None) - if isinstance(game_type, str): - gt = game_type.strip().lower() - if 'fallout new vegas' in gt or gt == 'fnv': - self.logger.info("Heuristic detection: game_var indicates FNV") - return "fnv" - if 'enderal' in gt: - self.logger.info("Heuristic detection: game_var indicates Enderal") - return "enderal" - except Exception: - pass - - # Not a special game type - self.logger.debug("No special game type detected - standard workflow will be used") - return None - - def _re_enforce_windows_10_mode(self): - """ - Re-enforce Windows 10 mode after modlist-specific configurations. - This matches the legacy script behavior (line 1333) where Windows 10 mode - is re-applied after modlist-specific steps to ensure consistency. - """ - try: - if not hasattr(self, 'appid') or not self.appid: - self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available") - return - - from ..handlers.winetricks_handler import WinetricksHandler - from ..handlers.path_handler import PathHandler - - # Get prefix path for the AppID - prefix_path = PathHandler.find_compat_data(str(self.appid)) - if not prefix_path: - self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found") - return - - # Use winetricks handler to set Windows 10 mode - winetricks_handler = WinetricksHandler() - wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path)) - if not wine_binary: - self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found") - return - - winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary) - - self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations") - - except Exception as e: - self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}") - - def _handle_symlinked_downloads(self) -> bool: - """ - Check if downloads_directory in ModOrganizer.ini points to a symlink. - If it does, comment out the line to force MO2 to use default behavior. - - Returns: - bool: True on success or no action needed, False on error - """ - try: - import configparser - import os - - if not self.modlist_ini or not os.path.exists(self.modlist_ini): - self.logger.warning("ModOrganizer.ini not found for symlink check") - return True # Non-critical - - # Read the INI file - config = configparser.ConfigParser(allow_no_value=True, delimiters=['=']) - config.optionxform = str # Preserve case sensitivity - - try: - # Read file manually to handle BOM - with open(self.modlist_ini, 'r', encoding='utf-8-sig') as f: - config.read_file(f) - except UnicodeDecodeError: - with open(self.modlist_ini, 'r', encoding='latin-1') as f: - config.read_file(f) - - # Check if downloads_directory or download_directory exists and is a symlink - downloads_key = None - downloads_path = None - - if 'General' in config: - # Check for both possible key names - if 'downloads_directory' in config['General']: - downloads_key = 'downloads_directory' - downloads_path = config['General']['downloads_directory'] - elif 'download_directory' in config['General']: - downloads_key = 'download_directory' - downloads_path = config['General']['download_directory'] - - if downloads_path: - - if downloads_path and os.path.exists(downloads_path): - # Check if the path or any parent directory contains symlinks - def has_symlink_in_path(path): - """Check if path or any parent directory is a symlink""" - current_path = Path(path).resolve() - check_path = Path(path) - - # Walk up the path checking each component - for parent in [check_path] + list(check_path.parents): - if parent.is_symlink(): - return True, str(parent) - return False, None - - has_symlink, symlink_path = has_symlink_in_path(downloads_path) - if has_symlink: - self.logger.info(f"Detected symlink in downloads directory path: {symlink_path} -> {downloads_path}") - self.logger.info("Commenting out downloads_directory to avoid Wine symlink issues") - - # Read the file manually to preserve comments and formatting - with open(self.modlist_ini, 'r', encoding='utf-8') as f: - lines = f.readlines() - - # Find and comment out the downloads directory line - modified = False - for i, line in enumerate(lines): - if line.strip().startswith(f'{downloads_key}='): - lines[i] = '#' + line # Comment out the line - modified = True - break - - if modified: - # Write the modified file back - with open(self.modlist_ini, 'w', encoding='utf-8') as f: - f.writelines(lines) - self.logger.info(f"{downloads_key} line commented out successfully") - else: - self.logger.warning("downloads_directory line not found in file") - else: - self.logger.debug(f"downloads_directory is not a symlink: {downloads_path}") - else: - self.logger.debug("downloads_directory path does not exist or is empty") - else: - self.logger.debug("No downloads_directory found in ModOrganizer.ini") - - return True - - except Exception as e: - self.logger.error(f"Error handling symlinked downloads: {e}", exc_info=True) - return False - - def _apply_universal_dotnet_fixes(self): - """ - Apply universal dotnet4.x compatibility registry fixes to ALL modlists. - Now called AFTER wine component installation to prevent overwrites. - Includes wineserver shutdown/flush to ensure persistence. - """ - try: - prefix_path = os.path.join(str(self.compat_data_path), "pfx") - if not os.path.exists(prefix_path): - self.logger.warning(f"Prefix path not found: {prefix_path}") - return False - - self.logger.info("Applying universal dotnet4.x compatibility registry fixes (post-component installation)...") - - # Find the appropriate Wine binary to use for registry operations - wine_binary = self._find_wine_binary_for_registry() - if not wine_binary: - self.logger.error("Could not find Wine binary for registry operations") - return False - - # Find wineserver binary for flushing registry changes - wine_dir = os.path.dirname(wine_binary) - wineserver_binary = os.path.join(wine_dir, 'wineserver') - if not os.path.exists(wineserver_binary): - self.logger.warning(f"wineserver not found at {wineserver_binary}, registry flush may not work") - wineserver_binary = None - - # Set environment for Wine registry operations - env = os.environ.copy() - env['WINEPREFIX'] = prefix_path - env['WINEDEBUG'] = '-all' # Suppress Wine debug output - - # Shutdown any running wineserver processes to ensure clean slate - if wineserver_binary: - self.logger.debug("Shutting down wineserver before applying registry fixes...") - try: - subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True) - self.logger.debug("Wineserver shutdown complete") - except Exception as e: - self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}") - - # Registry fix 1: Set *mscoree=native DLL override (asterisk for full override) - # This tells Wine to use native .NET runtime instead of Wine's implementation - self.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' - ] - - result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30) - self.logger.info(f"*mscoree registry command result: returncode={result1.returncode}, stdout={result1.stdout[:200]}, stderr={result1.stderr[:200]}") - if result1.returncode == 0: - self.logger.info("Successfully applied *mscoree=native DLL override") - else: - self.logger.error(f"Failed to set *mscoree DLL override: returncode={result1.returncode}, stderr={result1.stderr}") - - # Registry fix 2: Set OnlyUseLatestCLR=1 - # This prevents .NET version conflicts by using the latest CLR - self.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', timeout=30) - self.logger.info(f"OnlyUseLatestCLR registry command result: returncode={result2.returncode}, stdout={result2.stdout[:200]}, stderr={result2.stderr[:200]}") - if result2.returncode == 0: - self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry") - else: - self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}") - - # Force wineserver to flush registry changes to disk - if wineserver_binary: - self.logger.debug("Flushing registry changes to disk via wineserver shutdown...") - try: - subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True) - self.logger.debug("Registry changes flushed to disk") - except Exception as e: - self.logger.warning(f"Registry flush failed (non-critical): {e}") - - # VERIFICATION: Confirm the registry entries persisted - self.logger.info("Verifying registry entries were applied and persisted...") - verification_passed = True - - # Verify *mscoree=native - verify_cmd1 = [ - wine_binary, 'reg', 'query', - 'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides', - '/v', '*mscoree' - ] - verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30) - if verify_result1.returncode == 0 and 'native' in verify_result1.stdout: - self.logger.info("VERIFIED: *mscoree=native is set correctly") - else: - self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}") - verification_passed = False - - # Verify OnlyUseLatestCLR=1 - verify_cmd2 = [ - wine_binary, 'reg', 'query', - 'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework', - '/v', 'OnlyUseLatestCLR' - ] - verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30) - if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout): - self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly") - else: - self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}") - verification_passed = False - - # Both fixes applied and verified - if result1.returncode == 0 and result2.returncode == 0 and verification_passed: - self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully") - return True - else: - self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts") - return False - - except Exception as e: - self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}") - return False - - def _find_wine_binary_for_registry(self) -> Optional[str]: - """Find wine binary from Install Proton path""" - try: - # Use Install Proton from config (used by jackify-engine) - from ..handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - proton_path = config_handler.get_proton_path() - - if proton_path: - proton_path = Path(proton_path).expanduser() - - # Check both GE-Proton and Valve Proton structures - wine_candidates = [ - proton_path / "files" / "bin" / "wine", # GE-Proton - proton_path / "dist" / "bin" / "wine" # Valve Proton - ] - - for wine_bin in wine_candidates: - if wine_bin.exists() and wine_bin.is_file(): - return str(wine_bin) - - # Fallback: use best detected Proton - from ..handlers.wine_utils import WineUtils - best_proton = WineUtils.select_best_proton() - if best_proton: - wine_binary = WineUtils.find_proton_binary(best_proton['name']) - if wine_binary: - return wine_binary - - return None - except Exception as e: - self.logger.error(f"Error finding Wine binary: {e}") - return None - - def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]: - """ - Recursively search for wine binary within a Proton directory. - This handles cases where the directory structure might differ between Proton versions. - - Args: - proton_path: Path to the Proton directory to search - - Returns: - Path to wine binary if found, None otherwise - """ - try: - if not proton_path.exists() or not proton_path.is_dir(): - return None - - # Search for 'wine' executable (not 'wine64' or 'wine-preloader') - # Limit search depth to avoid scanning entire filesystem - max_depth = 5 - for root, dirs, files in os.walk(proton_path, followlinks=False): - # Calculate depth relative to proton_path - depth = len(Path(root).relative_to(proton_path).parts) - if depth > max_depth: - dirs.clear() # Don't descend further - continue - - # Check if 'wine' is in this directory - if 'wine' in files: - wine_path = Path(root) / 'wine' - # Verify it's actually an executable file - if wine_path.is_file() and os.access(wine_path, os.X_OK): - self.logger.debug(f"Found wine binary at: {wine_path}") - return str(wine_path) - - return None - except Exception as e: - self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}") - return None - \ No newline at end of file diff --git a/jackify/backend/handlers/modlist_install_cli.py b/jackify/backend/handlers/modlist_install_cli.py index 3b5dd4e..3729648 100644 --- a/jackify/backend/handlers/modlist_install_cli.py +++ b/jackify/backend/handlers/modlist_install_cli.py @@ -18,7 +18,6 @@ import json import shlex import time import pty -# from src.core.compressonator import run_compressonatorcli # TODO: Implement compressonator integration # Import UI Colors first - these should always be available from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SELECTION, COLOR_WARNING @@ -43,10 +42,14 @@ except Exception as e: logging.warning(f"Readline import failed: {e}") # Use standard logging before our handler pass -# Initialize logger for the module -logger = logging.getLogger(__name__) # Standard logger init +logger = logging.getLogger(__name__) + +from .modlist_install_cli_discovery import ModlistInstallCLIDiscoveryMixin +from .modlist_install_cli_configuration import ModlistInstallCLIConfigurationMixin +from .modlist_install_cli_ttw import ModlistInstallCLITTWMixin +from .modlist_install_cli_nexus import ModlistInstallCLINexusMixin + -# Helper function to get path to jackify-install-engine def get_jackify_engine_path(): appdir = os.environ.get('APPDIR') if appdir: @@ -62,7 +65,12 @@ def get_jackify_engine_path(): jackify_dir = os.path.dirname(os.path.dirname(current_file_dir)) return os.path.join(jackify_dir, 'engine', 'jackify-engine') -class ModlistInstallCLI: +class ModlistInstallCLI( + ModlistInstallCLIDiscoveryMixin, + ModlistInstallCLIConfigurationMixin, + ModlistInstallCLITTWMixin, + ModlistInstallCLINexusMixin, +): """ Handles the discovery phase for installing a Wabbajack modlist via CLI. """ @@ -76,433 +84,6 @@ class ModlistInstallCLI: self.logger = logging.getLogger(__name__) self.logger.propagate = False # Prevent duplicate logs if root logger is also configured - def run_discovery_phase(self, context_override=None) -> Optional[Dict]: - """ - Run the discovery phase: prompt for all required info, and validate inputs. - Returns a context dict with all collected info, or None if cancelled. - Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow). - """ - self.logger.info("Starting modlist discovery phase (restored logic).") - print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}") - - if context_override: - self.context.update(context_override) - if 'resolution' in context_override: - self.context['resolution'] = context_override['resolution'] - else: - self.context = {} - - is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' - # Only require game_type for non-Tuxborn workflows - if self.context.get('machineid'): - required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key'] - else: - required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type'] - has_modlist = self.context.get('modlist_value') or self.context.get('machineid') - missing = [k for k in required_keys if not self.context.get(k)] - if is_gui_mode: - if missing or not has_modlist: - print(f"ERROR: Missing required arguments for GUI workflow: {', '.join(missing)}") - if not has_modlist: - print("ERROR: Missing modlist_value or machineid for GUI workflow.") - print("This workflow must be fully non-interactive. Please report this as a bug if you see this message.") - return None - self.logger.info("All required context present in GUI mode, skipping prompts.") - return self.context - - # Get engine path using the helper - engine_executable = get_jackify_engine_path() - self.logger.debug(f"Engine executable path: {engine_executable}") - - if not os.path.exists(engine_executable): - self.logger.error(f"jackify-install-engine not found at {engine_executable}") - print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") - print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") - return None - - engine_dir = os.path.dirname(engine_executable) - - # 1. Prompt for modlist source (unless using machineid from context_override) - if 'machineid' not in self.context: - print("\n" + "-" * 28) # Separator - print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}") - print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists") - print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk") - print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu") - source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() - self.logger.debug(f"User selected modlist source option: {source_choice}") - - if source_choice == '1': - self.context['modlist_source_type'] = 'online_list' - print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}") - try: - env = os.environ.copy() - env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" - self.logger.info("Setting DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 for jackify-engine process.") - - # Use the engine path from the helper function, but the command structure from restored. - engine_executable_path_for_subprocess = get_jackify_engine_path() - command = [engine_executable_path_for_subprocess, 'list-modlists', '--show-all-sizes', '--show-machine-url'] - self.logger.info(f"Executing command: {' '.join(command)} in CWD: {engine_dir}") - - # check=True as in restored logic - result = subprocess.run( - command, - capture_output=True, text=True, check=True, - env=env, cwd=engine_dir - ) - - # self.logger.debug(f"Engine stdout (raw):\n{result.stdout}") # COMMENTED OUT - too verbose - - lines = result.stdout.splitlines() - - # Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL - # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] - raw_modlists_from_engine = [] - for line in lines: - line = line.strip() - if not line or line.startswith('Loading') or line.startswith('Loaded'): - continue - - # Extract status indicators - status_down = '[DOWN]' in line - status_nsfw = '[NSFW]' in line - - # Remove status indicators to get clean line - clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() - - # Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL] - parts = clean_line.split(' - ') - if len(parts) != 4: - continue # Skip malformed lines - - modlist_name = parts[0].strip() - game_name = parts[1].strip() - sizes_str = parts[2].strip() - machine_url = parts[3].strip() - - # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") - size_parts = sizes_str.split('|') - if len(size_parts) != 3: - continue # Skip if sizes don't match expected format - - download_size = size_parts[0].strip() - install_size = size_parts[1].strip() - total_size = size_parts[2].strip() - - # Skip if any required data is missing - if not modlist_name or not game_name or not machine_url: - continue - - raw_modlists_from_engine.append({ - 'id': modlist_name, # Use modlist name as ID for compatibility - 'name': modlist_name, - 'game': game_name, - 'download_size': download_size, - 'install_size': install_size, - 'total_size': total_size, - 'machine_url': machine_url, # Store machine URL for installation - 'status_down': status_down, - 'status_nsfw': status_nsfw - }) - - self.logger.info(f"Scraped {len(raw_modlists_from_engine)} modlists after revised regex and filtering logic.") - - if not raw_modlists_from_engine: - print(f"{COLOR_WARNING}No modlists found after applying revised regex and filtering logic.{COLOR_RESET}") - return None - - # EXACT game_type_map and grouping logic from restored file - game_type_map = { - '1': ('Skyrim', ['Skyrim', 'Skyrim Special Edition']), - '2': ('Fallout 4', ['Fallout 4']), - '3': ('Fallout New Vegas', ['Fallout New Vegas']), - '4': ('Oblivion', ['Oblivion']), - '5': ('Other Games', None) # Using None as in restored for keyword list - } - - grouped_modlists = {k: [] for k in game_type_map} - - for m_info in raw_modlists_from_engine: # m_info is like {'id': ..., 'game': ...} - found_category = False - for cat_key, (cat_label, cat_keywords) in game_type_map.items(): - if cat_key == '5': # Skip 'Other Games' for direct matching initially - continue - if cat_keywords: # Ensure there are keywords to check (handles 'Other Games' with None) - for keyword in cat_keywords: - if keyword.lower() in m_info['game'].lower(): - grouped_modlists[cat_key].append(m_info) - found_category = True - break # Found category for this modlist - if found_category: - break # Move to next modlist - if not found_category: - grouped_modlists['5'].append(m_info) # Add to 'Other Games' - - selected_modlist_info = None # Will store {'id': ..., 'game': ...} - while not selected_modlist_info: - print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}") - - category_display_map = {} # Maps displayed number to actual game_type_map key - display_idx = 1 - # Iterate in a defined order for consistent menu - for cat_key_ordered in ['1','2','3','4','5']: - if cat_key_ordered in grouped_modlists and grouped_modlists[cat_key_ordered]: # Only show if non-empty - cat_label = game_type_map[cat_key_ordered][0] - print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {cat_label} ({len(grouped_modlists[cat_key_ordered])} modlists)") - category_display_map[str(display_idx)] = cat_key_ordered - display_idx += 1 - - if display_idx == 1: # No categories had any modlists - print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}") - return None - - print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel") - - game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip() - if game_cat_choice == '0': - self.logger.info("User cancelled game category selection.") - return None - - actual_cat_key = category_display_map.get(game_cat_choice) - if not actual_cat_key: - print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") - continue - - # modlist_group_for_game is a list of dicts like {'id': ..., 'game': ...} - modlist_group_for_game = sorted(grouped_modlists[actual_cat_key], key=lambda x: x['id'].lower()) - - print(f"\n{COLOR_SUCCESS}Available Modlists for {game_type_map[actual_cat_key][0]}:{COLOR_RESET}") - for idx, m_detail in enumerate(modlist_group_for_game, 1): - if actual_cat_key == '5': # 'Other Games' category - print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']} ({m_detail['game']})") - else: - print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']}") - print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories") - - while True: - mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip() - if mod_choice_idx_str == '0': - break - if mod_choice_idx_str.isdigit(): - mod_idx = int(mod_choice_idx_str) - 1 - if 0 <= mod_idx < len(modlist_group_for_game): - selected_modlist_info = modlist_group_for_game[mod_idx] - self.context['modlist_source'] = 'identifier' - # Use machine_url for installation, display name for suggestions - self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id']) - self.context['modlist_game'] = selected_modlist_info['game'] - self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1] - self.logger.info(f"User selected online modlist: {selected_modlist_info}") - break - else: - print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}") - else: - print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") - if selected_modlist_info: - break - - except subprocess.CalledProcessError as e: - self.logger.error(f"list-modlists failed. Code: {e.returncode}") - if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") - if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") - print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_RESET}") - return None - except FileNotFoundError: - self.logger.error(f"Engine not found: {engine_executable_path_for_subprocess}") - print(f"{COLOR_ERROR}Critical error: jackify-install-engine not found.{COLOR_RESET}") - return None - except Exception as e: - self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) - print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}") - return None - - elif source_choice == '2': - self.context['modlist_source_type'] = 'local_file' - print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}") - modlist_path = self.menu_handler.get_existing_file_path( - prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", - extension_filter=".wabbajack", # Ensure this is the exact filter used by the method - no_header=True # To avoid re-printing a header if get_existing_file_path has one - ) - if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q' - self.logger.info("User cancelled .wabbajack file selection.") - print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}") - return None - - self.context['modlist_source'] = 'path' # For install command - self.context['modlist_value'] = str(modlist_path) - # Suggest a name based on the file - self.context['modlist_name_suggestion'] = Path(modlist_path).stem - self.logger.info(f"User selected local .wabbajack file: {modlist_path}") - - elif source_choice == '0': - self.logger.info("User cancelled modlist source selection.") - print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}") - return None - else: - self.logger.warning(f"Invalid modlist source choice: {source_choice}") - print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") - return self.run_discovery_phase() # Re-prompt - - # --- Prompts for install_dir, download_dir, modlist_name, api_key --- - # (This part is largely similar to the restored version, adapt as needed) - # It will use self.context['modlist_name_suggestion'] if available. - - # 2. Prompt for modlist name (skip if 'modlist_name' already in context from override) - if 'modlist_name' not in self.context or not self.context['modlist_name']: - default_name = self.context.get('modlist_name_suggestion', 'MyModlist') - print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}") - print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}") - modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() - if not modlist_name_input: # User hit enter for default - modlist_name = default_name - elif modlist_name_input.lower() == 'q': - self.logger.info("User cancelled at modlist name prompt.") - return None - else: - modlist_name = modlist_name_input - self.context['modlist_name'] = modlist_name - self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}") - - # 3. Prompt for install directory - if 'install_dir' not in self.context: - # Use configurable base directory - config_handler = ConfigHandler() - base_install_dir = Path(config_handler.get_modlist_install_base_dir()) - default_install_dir = base_install_dir / self.context['modlist_name'] - print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}") - print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}") - install_dir_path = self.menu_handler.get_directory_path( - prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", - default_path=default_install_dir, - create_if_missing=True, - no_header=True - ) - if install_dir_path is None: - self.logger.info("User cancelled at install directory prompt.") - return None - self.context['install_dir'] = install_dir_path - self.logger.debug(f"Install directory context set to: {self.context['install_dir']}") - - # 4. Prompt for download directory - if 'download_dir' not in self.context: - # Use configurable base directory for downloads - config_handler = ConfigHandler() - base_download_dir = Path(config_handler.get_modlist_downloads_base_dir()) - default_download_dir = base_download_dir / self.context['modlist_name'] - - print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}") - print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}") - download_dir_path = self.menu_handler.get_directory_path( - prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", - default_path=default_download_dir, - create_if_missing=True, - no_header=True - ) - if download_dir_path is None: - self.logger.info("User cancelled at download directory prompt.") - return None - self.context['download_dir'] = download_dir_path - self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") - - # 5. Get Nexus authentication (OAuth or API key) - if 'nexus_api_key' not in self.context: - from jackify.backend.services.nexus_auth_service import NexusAuthService - auth_service = NexusAuthService() - - # Get current auth status - authenticated, method, username = auth_service.get_auth_status() - - if authenticated: - # Already authenticated - use existing auth - if method == 'oauth': - print("\n" + "-" * 28) - print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}") - if username: - print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}") - elif method == 'api_key': - print("\n" + "-" * 28) - print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}") - - # Get valid token/key and OAuth state for engine auto-refresh - api_key, oauth_info = auth_service.get_auth_for_engine() - if api_key: - self.context['nexus_api_key'] = api_key - self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh - else: - # Auth expired or invalid - prompt to set up - print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}") - authenticated = False - - if not authenticated: - # Not authenticated - offer to set up OAuth - print("\n" + "-" * 28) - print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}") - print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}") - print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}") - - authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower() - - if authorize in ('', 'y', 'yes'): - # Launch OAuth authorization - print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}") - print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}") - print(f"{COLOR_INFO}Note: Your browser may ask permission to open 'xdg-open' or{COLOR_RESET}") - print(f"{COLOR_INFO}Jackify's protocol handler - please click 'Open' or 'Allow'.{COLOR_RESET}") - - def show_message(msg): - print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}") - - success = auth_service.authorize_oauth(show_browser_message_callback=show_message) - - if success: - print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}") - _, _, username = auth_service.get_auth_status() - if username: - print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}") - - api_key, oauth_info = auth_service.get_auth_for_engine() - if api_key: - self.context['nexus_api_key'] = api_key - self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh - else: - print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}") - return None - else: - print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}") - return None - else: - # User declined OAuth - cancelled - print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}") - self.logger.info("User declined Nexus authorization.") - return None - self.logger.debug(f"Nexus authentication configured for engine.") - - # Display summary and confirm - self._display_summary() # Ensure this method exists or implement it - if self.context.get('skip_confirmation'): - confirm = 'y' - else: - confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower() - if confirm != 'y': - self.logger.info("User cancelled at final confirmation.") - print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") - return None - - self.logger.info("Discovery phase complete.") # Log completion first - - # Create a copy of the context for logging, so we don't alter the original - context_for_logging = self.context.copy() - if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None: - context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging - - self.logger.info(f"Context: {context_for_logging}") # Log the redacted context - return self.context - def _display_summary(self): """ Display a summary of the collected context (excluding API key). @@ -548,629 +129,8 @@ class ModlistInstallCLI: print(auth_display) print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") - def configuration_phase(self): - """ - Run the configuration phase: execute the Linux-native Jackify Install Engine. - """ - import subprocess - import time - import sys - from pathlib import Path - # UI Colors and LoggingHandler already imported at module level - print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}") - start_time = time.time() - - # --- BEGIN: TEE LOGGING SETUP & LOG ROTATION --- - from jackify.shared.paths import get_jackify_logs_dir - log_dir = get_jackify_logs_dir() - log_dir.mkdir(parents=True, exist_ok=True) - workflow_log_path = log_dir / "Modlist_Install_workflow.log" - # Log rotation: keep last 3 logs, 1MB each (adjust as needed) - max_logs = 3 - max_size = 1024 * 1024 # 1MB - if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size: - for i in range(max_logs, 0, -1): - prev = log_dir / f"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path - dest = log_dir / f"Modlist_Install_workflow.log.{i}" - if prev.exists(): - if dest.exists(): - dest.unlink() - prev.rename(dest) - workflow_log = open(workflow_log_path, 'a') - class TeeStdout: - def __init__(self, *files): - self.files = files - def write(self, data): - for f in self.files: - f.write(data) - f.flush() - def flush(self): - for f in self.files: - f.flush() - orig_stdout, orig_stderr = sys.stdout, sys.stderr - sys.stdout = TeeStdout(sys.stdout, workflow_log) - sys.stderr = TeeStdout(sys.stderr, workflow_log) - # --- END: TEE LOGGING SETUP & LOG ROTATION --- - try: - # --- Process Paths from context --- - install_dir_context = self.context['install_dir'] - if isinstance(install_dir_context, tuple): - actual_install_path = Path(install_dir_context[0]) - if install_dir_context[1]: # Second element is True if creation was intended - self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}") - actual_install_path.mkdir(parents=True, exist_ok=True) - else: # Should be a Path object or string already - actual_install_path = Path(install_dir_context) - install_dir_str = str(actual_install_path) - self.logger.debug(f"Processed install directory for engine: {install_dir_str}") - - download_dir_context = self.context['download_dir'] - if isinstance(download_dir_context, tuple): - actual_download_path = Path(download_dir_context[0]) - if download_dir_context[1]: # Second element is True if creation was intended - self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}") - actual_download_path.mkdir(parents=True, exist_ok=True) - else: # Should be a Path object or string already - actual_download_path = Path(download_dir_context) - download_dir_str = str(actual_download_path) - self.logger.debug(f"Processed download directory for engine: {download_dir_str}") - # --- End Process Paths --- - - modlist_arg = self.context.get('modlist_value') or self.context.get('machineid') - machineid = self.context.get('machineid') - - # CRITICAL: Re-check authentication right before launching engine - # This ensures we use current auth state, not stale cached values from context - # (e.g., if user revoked OAuth after context was created) - from jackify.backend.services.nexus_auth_service import NexusAuthService - auth_service = NexusAuthService() - current_api_key, current_oauth_info = auth_service.get_auth_for_engine() - - # Use current auth state, fallback to context values only if current check failed - api_key = current_api_key or self.context.get('nexus_api_key') - oauth_info = current_oauth_info or self.context.get('nexus_oauth_info') - - # Path to the engine binary - engine_path = get_jackify_engine_path() - engine_dir = os.path.dirname(engine_path) - if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): - print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}") - return - - # --- Patch for GUI/auto: always set modlist_source to 'identifier' if not set, and ensure modlist_value is present --- - if os.environ.get('JACKIFY_GUI_MODE') == '1': - if not self.context.get('modlist_source'): - self.context['modlist_source'] = 'identifier' - if not self.context.get('modlist_value'): - print(f"{COLOR_ERROR}ERROR: modlist_value is missing in context for GUI workflow!{COLOR_RESET}") - self.logger.error("modlist_value is missing in context for GUI workflow!") - return - # --- End Patch --- - - # Build command - cmd = [engine_path, 'install', '--show-file-progress'] - - # Check for debug mode and pass --debug to engine if needed - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - if debug_mode: - cmd.append('--debug') - self.logger.info("Debug mode enabled in config - passing --debug flag to jackify-engine") - - # Determine if this is a local .wabbajack file or an online modlist - modlist_value = self.context.get('modlist_value') - machineid = self.context.get('machineid') - - # Check if there's a cached .wabbajack file for this modlist - cached_wabbajack_path = None - if machineid: - # Convert machineid to filename (e.g., "Tuxborn/Tuxborn" -> "Tuxborn.wabbajack") - modlist_name = machineid.split('/')[-1] if '/' in machineid else machineid - from jackify.shared.paths import get_jackify_downloads_dir - cached_wabbajack_path = get_jackify_downloads_dir() / f"{modlist_name}.wabbajack" - self.logger.debug(f"Checking for cached .wabbajack file: {cached_wabbajack_path}") - - if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): - cmd += ['-w', modlist_value] - self.logger.info(f"Using local .wabbajack file: {modlist_value}") - elif cached_wabbajack_path and os.path.isfile(cached_wabbajack_path): - cmd += ['-w', cached_wabbajack_path] - self.logger.info(f"Using cached .wabbajack file: {cached_wabbajack_path}") - elif modlist_value: - cmd += ['-m', modlist_value] - self.logger.info(f"Using modlist identifier: {modlist_value}") - elif machineid: - cmd += ['-m', machineid] - self.logger.info(f"Using machineid: {machineid}") - cmd += ['-o', install_dir_str, '-d', download_dir_str] - - # Store original environment values to restore later - original_env_values = { - 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), - 'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), - 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') - } - - try: - # Temporarily modify current process's environment - # Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy) - if oauth_info: - os.environ['NEXUS_OAUTH_INFO'] = oauth_info - # CRITICAL: Set client_id so engine can refresh tokens with correct client_id - # Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack" - from jackify.backend.services.nexus_oauth_service import NexusOAuthService - os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID - self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)") - # Also set NEXUS_API_KEY for backward compatibility - if api_key: - os.environ['NEXUS_API_KEY'] = api_key - elif api_key: - # No OAuth info, use API key only (no auto-refresh support) - os.environ['NEXUS_API_KEY'] = api_key - self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)") - else: - # No auth available, clear any inherited values - if 'NEXUS_API_KEY' in os.environ: - del os.environ['NEXUS_API_KEY'] - if 'NEXUS_OAUTH_INFO' in os.environ: - del os.environ['NEXUS_OAUTH_INFO'] - if 'NEXUS_OAUTH_CLIENT_ID' in os.environ: - del os.environ['NEXUS_OAUTH_CLIENT_ID'] - self.logger.debug(f"No Nexus auth available, cleared inherited env vars") - - os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" - self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.") - - self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.") - self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}") - self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}") - - pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) - print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}") - - # Temporarily increase file descriptor limit for engine process - from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit - success, old_limit, new_limit, message = increase_file_descriptor_limit() - if success: - self.logger.debug(f"File descriptor limit: {message}") - else: - self.logger.warning(f"File descriptor limit: {message}") - - # Use cleaned environment to prevent AppImage variable inheritance - from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env - clean_env = get_clean_subprocess_env() - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir) - - # Start performance monitoring for the engine process - # Adjust monitoring based on debug mode - if debug_mode: - # More aggressive monitoring in debug mode - performance_monitor = EnginePerformanceMonitor( - logger=self.logger, - stall_threshold=5.0, # CPU below 5% is considered stalled - stall_duration=60.0, # 1 minute of low CPU = stall (faster detection) - sample_interval=5.0 # Check every 5 seconds (more frequent) - ) - # Add debug callback for detailed metrics - from .engine_monitor import create_debug_callback - performance_monitor.add_callback(create_debug_callback(self.logger)) - self.logger.info("Enhanced performance monitoring enabled for debug mode") - else: - # Standard monitoring - performance_monitor = EnginePerformanceMonitor( - logger=self.logger, - stall_threshold=5.0, # CPU below 5% is considered stalled - stall_duration=120.0, # 2 minutes of low CPU = stall - sample_interval=10.0 # Check every 10 seconds - ) - - # Add callback to alert about performance issues - def stall_alert(message: str): - print(f"\nWarning: {message}") - print("If the process appears stuck, you may need to restart it.") - if debug_mode: - print("Debug mode: Use 'python -m jackify.backend.handlers.diagnostic_helper' for detailed analysis") - - performance_monitor.add_callback(create_stall_alert_callback(self.logger, stall_alert)) - - # Start monitoring - monitoring_started = performance_monitor.start_monitoring(proc.pid) - if monitoring_started: - self.logger.info(f"Performance monitoring started for engine PID {proc.pid}") - else: - self.logger.warning("Failed to start performance monitoring") - - try: - # Read output in binary mode to properly handle carriage returns - buffer = b'' - last_progress_time = time.time() - - while True: - chunk = proc.stdout.read(1) - if not chunk: - break - buffer += chunk - - # Process complete lines or carriage return updates - if chunk == b'\n': - # Complete line - decode and print - line = buffer.decode('utf-8', errors='replace') - # Filter FILE_PROGRESS spam but keep the status line before it - if '[FILE_PROGRESS]' in line: - parts = line.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - line = parts[0].rstrip() - else: - # Skip this line entirely if it's only FILE_PROGRESS - buffer = b'' - last_progress_time = time.time() - continue - # Enhance Nexus download errors with modlist context - enhanced_line = self._enhance_nexus_error(line) - print(enhanced_line, end='') - buffer = b'' - last_progress_time = time.time() - elif chunk == b'\r': - # Carriage return - decode and print without newline - line = buffer.decode('utf-8', errors='replace') - # Filter FILE_PROGRESS spam but keep the status line before it - if '[FILE_PROGRESS]' in line: - parts = line.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - line = parts[0].rstrip() - else: - # Skip this line entirely if it's only FILE_PROGRESS - buffer = b'' - last_progress_time = time.time() - continue - # Enhance Nexus download errors with modlist context - enhanced_line = self._enhance_nexus_error(line) - print(enhanced_line, end='') - sys.stdout.flush() - buffer = b'' - last_progress_time = time.time() - - # Check for timeout (no output for too long) - current_time = time.time() - if current_time - last_progress_time > 300: # 5 minutes no output - self.logger.warning("No output from engine for 5 minutes - possible stall") - last_progress_time = current_time # Reset to avoid spam - - # Print any remaining buffer content - if buffer: - line = buffer.decode('utf-8', errors='replace') - print(line, end='') - - proc.wait() - - finally: - # Stop performance monitoring and get summary - if monitoring_started: - performance_monitor.stop_monitoring() - summary = performance_monitor.get_metrics_summary() - - if summary: - self.logger.info(f"Engine Performance Summary: " - f"Duration: {summary.get('monitoring_duration', 0):.1f}s, " - f"Avg CPU: {summary.get('avg_cpu_percent', 0):.1f}%, " - f"Max Memory: {summary.get('max_memory_mb', 0):.1f}MB, " - f"Stalls: {summary.get('stall_percentage', 0):.1f}%") - - # Log detailed summary for debugging - self.logger.debug(f"Detailed performance summary: {summary}") - if proc.returncode != 0: - print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") - self.logger.error(f"Engine exited with code {proc.returncode}.") - return # Configuration phase failed - self.logger.info(f"Engine completed with code {proc.returncode}.") - except Exception as e: - print(f"{COLOR_ERROR}Error running Jackify Install Engine: {e}{COLOR_RESET}\n") - self.logger.error(f"Exception running engine: {e}", exc_info=True) - return # Configuration phase failed - finally: - # Restore original environment state - for key, original_value in original_env_values.items(): - current_value_in_os_environ = os.environ.get(key) # Value after Popen and before our restoration for this key - - # Determine display values for logging, redacting NEXUS_API_KEY - display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'" - # display_current_value_before_restore = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{current_value_in_os_environ}'" - - if original_value is not None: - # Original value existed. We must restore it. - if current_value_in_os_environ != original_value: - os.environ[key] = original_value - self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.") - else: - # If current value is already the original, ensure it's correctly set (os.environ[key] = original_value is harmless) - os.environ[key] = original_value # Ensure it is set - self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.") - else: - # Original value was None (key was not in os.environ initially). - if key in os.environ: # If it's in os.environ now, it means we must have set it or it was set by other means. - self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.") - del os.environ[key] - # If original_value was None and key is not in os.environ now, nothing to do. - - except Exception as e: - print(f"{COLOR_ERROR}Error during Tuxborn installation workflow: {e}{COLOR_RESET}\n") - self.logger.error(f"Exception in Tuxborn workflow: {e}", exc_info=True) - return - finally: - # --- BEGIN: RESTORE STDOUT/STDERR --- - sys.stdout = orig_stdout - sys.stderr = orig_stderr - workflow_log.close() - # --- END: RESTORE STDOUT/STDERR --- - - elapsed = int(time.time() - start_time) - print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n") - print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n") - if self.context.get('machineid') != 'Tuxborn/Tuxborn': - print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}") - # After install, use self.context['modlist_game'] to determine if configuration should be offered - # After install, detect game type from ModOrganizer.ini - modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini") - detected_game = None - if os.path.isfile(modorganizer_ini): - from .modlist_handler import ModlistHandler - handler = ModlistHandler({}, steamdeck=self.steamdeck) - handler.modlist_ini = modorganizer_ini - handler.modlist_dir = install_dir_str - if handler._detect_game_variables(): - detected_game = handler.game_var_full - supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"] - is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn' - if (detected_game in supported_games) or is_tuxborn: - shortcut_name = self.context.get('modlist_name') - if is_tuxborn and not shortcut_name: - self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'") - shortcut_name = "Tuxborn Automatic Installer" # Provide a fallback default - elif not shortcut_name: # For non-Tuxborn, prompt if missing - print("\n" + "-" * 28) - print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}") - raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip() - if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name: - return - shortcut_name = raw_shortcut_name - - # Check if GUI mode to skip interactive prompts - is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' - - 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}") - configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() - - if configure_choice == 'n': - print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}") - return - - # 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'.") - 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}") - 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 - config_context = { - 'name': shortcut_name, - 'appid': app_id, - 'path': install_dir_str, - '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 - } - - # 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: - self.logger.info("Post-installation configuration completed successfully") - - # Check for TTW integration eligibility - self._check_and_prompt_ttw_integration(install_dir_str, detected_game, modlist_name) - else: - self.logger.warning("Post-installation configuration had issues") - else: - # Game not supported for automated configuration - print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}") - if detected_game: - print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}") - 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}") - - def install_modlist(self, selected_modlist_info: Optional[Dict[str, Any]] = None, wabbajack_file_path: Optional[Union[str, Path]] = None): - # This is where we would get the engine path for the actual installation - engine_path = get_jackify_engine_path() # Use the helper - self.logger.info(f"Using engine path for installation: {engine_path}") - - # --- The rest of your install_modlist logic --- - # ... - # When constructing the subprocess command for install, use `engine_path` - # For example: - # install_command = [engine_path, 'install', '--modlist-url', modlist_url, ...] - # ... - self.logger.info("Placeholder for actual modlist installation logic using the engine.") - print("Modlist installation logic would run here.") - return True # Placeholder - - def _get_nexus_api_key(self) -> Optional[str]: - # This method is not provided in the original file or the code block - # It's assumed to exist as it's called in the _display_summary method - # Implement the logic to retrieve the Nexus API key from the context - return self.context.get('nexus_api_key') - - def get_all_modlists_from_engine(self, game_type=None): - """ - Call the Jackify engine with 'list-modlists' and return a list of modlist dicts. - Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags. - - Args: - game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas") - """ - import subprocess - import re - from pathlib import Path - # COLOR_ERROR already imported at module level - engine_executable = get_jackify_engine_path() - engine_dir = os.path.dirname(engine_executable) - if not os.path.exists(engine_executable): - self.logger.error(f"jackify-install-engine not found at {engine_executable}") - print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_ERROR}") - return [] - env = os.environ.copy() - env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" - command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url'] - - # Add game filter if specified - if game_type: - command.extend(['--game', game_type]) - try: - result = subprocess.run( - command, - capture_output=True, text=True, check=True, - env=env, cwd=engine_dir - ) - lines = result.stdout.splitlines() - modlists = [] - for line in lines: - line = line.strip() - if not line or line.startswith('Loading') or line.startswith('Loaded'): - continue - - # Parse the new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL - # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] - - # Extract status indicators - status_down = '[DOWN]' in line - status_nsfw = '[NSFW]' in line - - # Remove status indicators to get clean line - clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() - - # Split from right to handle modlist names with dashes - # Format: "NAME - GAME - SIZES - MACHINE_URL" - parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts - if len(parts) != 4: - continue # Skip malformed lines - - modlist_name = parts[0].strip() - game_name = parts[1].strip() - sizes_str = parts[2].strip() - machine_url = parts[3].strip() - - # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") - size_parts = sizes_str.split('|') - if len(size_parts) != 3: - continue # Skip if sizes don't match expected format - - download_size = size_parts[0].strip() - install_size = size_parts[1].strip() - total_size = size_parts[2].strip() - - # Skip if any required data is missing - if not modlist_name or not game_name or not machine_url: - continue - - modlists.append({ - 'id': modlist_name, # Use modlist name as ID for compatibility - 'name': modlist_name, - 'game': game_name, - 'download_size': download_size, - 'install_size': install_size, - 'total_size': total_size, - 'machine_url': machine_url, # Store machine URL for installation - 'status_down': status_down, - 'status_nsfw': status_nsfw - }) - return modlists - except subprocess.CalledProcessError as e: - self.logger.error(f"list-modlists failed. Code: {e.returncode}") - if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") - if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") - print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}") - return [] - except Exception as e: - self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) - print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}") - return [] - def _display_summary(self): # REMOVE pass AND RESTORE THE METHOD BODY - # print(f"{COLOR_WARNING}DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}{COLOR_RESET}") # Keep commented - # self.logger.info(f"DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}") # Keep commented print(f"\n{COLOR_INFO}--- Summary of Collected Information ---{COLOR_RESET}") if self.context.get('modlist_source_type') == 'online_list': print(f"Modlist Source: Selected from online list") @@ -1212,202 +172,3 @@ class ModlistInstallCLI: print(auth_display) print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") - def _enhance_nexus_error(self, line: str) -> str: - """ - Enhance Nexus download error messages by adding the mod URL for easier troubleshooting. - """ - import re - - # Pattern to match Nexus download errors with ModID and FileID - nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):" - - match = re.search(nexus_error_pattern, line) - if match: - game_name = match.group(1) - mod_id = match.group(2) - - # Map game names to Nexus URL segments - game_url_map = { - 'SkyrimSpecialEdition': 'skyrimspecialedition', - 'Skyrim': 'skyrim', - 'Fallout4': 'fallout4', - 'FalloutNewVegas': 'newvegas', - 'Oblivion': 'oblivion', - 'Starfield': 'starfield' - } - - game_url = game_url_map.get(game_name, game_name.lower()) - mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}" - - # Add URL on next line for easier debugging - return f"{line}\n Nexus URL: {mod_url}" - - return line - - def _check_and_prompt_ttw_integration(self, install_dir: str, game_type: str, modlist_name: str): - """Check if modlist is eligible for TTW integration and prompt user""" - try: - # Check eligibility: FNV game, TTW-compatible modlist, no existing TTW - if not self._is_ttw_eligible(install_dir, game_type, modlist_name): - return - - # Prompt user for TTW installation - print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") - print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}") - print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") - print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).") - 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() - - if user_input in ['yes', 'y']: - self._launch_ttw_installation(modlist_name, install_dir) - else: - print(f"{COLOR_INFO}Skipping TTW installation. You can install it later from the main menu.{COLOR_RESET}") - - except Exception as e: - self.logger.error(f"Error during TTW eligibility check: {e}", exc_info=True) - - def _is_ttw_eligible(self, install_dir: str, game_type: str, modlist_name: str) -> bool: - """Check if modlist is eligible for TTW integration""" - try: - from pathlib import Path - - # Check 1: Must be Fallout New Vegas - if not game_type or game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']: - return False - - # Check 2: Must be on TTW compatibility whitelist - from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible - if not is_ttw_compatible(modlist_name): - return False - - # Check 3: TTW must not already be installed - if self._detect_existing_ttw(install_dir): - self.logger.info(f"TTW already installed in {install_dir}, skipping prompt") - return False - - return True - - except Exception as e: - self.logger.error(f"Error checking TTW eligibility: {e}") - return False - - def _detect_existing_ttw(self, install_dir: str) -> bool: - """Detect if TTW is already installed in the modlist""" - try: - from pathlib import Path - - install_path = Path(install_dir) - - # Search for TTW indicators in common locations - search_paths = [ - install_path, - install_path / "mods", - install_path / "Stock Game", - install_path / "Game Root" - ] - - for search_path in search_paths: - if not search_path.exists(): - continue - - # Look for folders containing "tale" and "two" and "wastelands" - for folder in search_path.iterdir(): - if not folder.is_dir(): - continue - - folder_name_lower = folder.name.lower() - if all(keyword in folder_name_lower for keyword in ['tale', 'two', 'wastelands']): - # Verify it has the TTW ESM file - for file in folder.rglob('*.esm'): - if 'taleoftwowastelands' in file.name.lower(): - self.logger.info(f"Found existing TTW installation: {file}") - return True - - return False - - except Exception as e: - self.logger.error(f"Error detecting existing TTW: {e}") - return False - - def _launch_ttw_installation(self, modlist_name: str, install_dir: str): - """Launch TTW installation workflow""" - try: - print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}") - - # Import TTW installation handler - from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler - from jackify.backend.models.configuration import SystemInfo - from pathlib import Path - - system_info = SystemInfo() - ttw_installer_handler = TTWInstallerHandler( - steamdeck=system_info.is_steamdeck if hasattr(system_info, 'is_steamdeck') else False, - 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 - ) - - # Check if TTW_Linux_Installer is installed - ttw_installer_handler._check_installation() - - 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() - - if user_input not in ['yes', 'y']: - print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}") - return - - # Install TTW_Linux_Installer - print(f"{COLOR_INFO}Installing TTW_Linux_Installer...{COLOR_RESET}") - success, message = ttw_installer_handler.install_ttw_installer() - - if not success: - print(f"{COLOR_ERROR}Failed to install TTW_Linux_Installer: {message}{COLOR_RESET}") - return - - print(f"{COLOR_INFO}TTW_Linux_Installer installed successfully.{COLOR_RESET}") - - # Prompt for TTW .mpi file - print(f"\n{COLOR_PROMPT}TTW Installer File (.mpi){COLOR_RESET}") - mpi_path = input(f"{COLOR_PROMPT}Path to TTW .mpi file: {COLOR_RESET}").strip() - if not mpi_path: - print(f"{COLOR_WARNING}No .mpi file specified. Cancelling.{COLOR_RESET}") - return - - mpi_path = Path(mpi_path).expanduser() - if not mpi_path.exists() or not mpi_path.is_file(): - print(f"{COLOR_ERROR}TTW .mpi file not found: {mpi_path}{COLOR_RESET}") - return - - # Prompt for TTW installation directory - print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}") - default_ttw_dir = os.path.join(install_dir, 'TTW') - print(f"Default: {default_ttw_dir}") - ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip() - - if not ttw_install_dir: - ttw_install_dir = default_ttw_dir - - # 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}") - - success, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir)) - - if success: - 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"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}") - print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}") - - 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 diff --git a/jackify/backend/handlers/modlist_install_cli_configuration.py b/jackify/backend/handlers/modlist_install_cli_configuration.py new file mode 100644 index 0000000..0a59694 --- /dev/null +++ b/jackify/backend/handlers/modlist_install_cli_configuration.py @@ -0,0 +1,527 @@ +"""Configuration phase methods for ModlistInstallCLI (Mixin).""" +import logging +import os +import subprocess +import sys +import time +from pathlib import Path + +from .engine_monitor import EnginePerformanceMonitor, create_stall_alert_callback +from .ui_colors import ( + COLOR_PROMPT, + COLOR_RESET, + COLOR_INFO, + COLOR_ERROR, + COLOR_WARNING, +) + +logger = logging.getLogger(__name__) + + +class ModlistInstallCLIConfigurationMixin: + """Mixin providing configuration phase methods.""" + + def configuration_phase(self): + """ + Run the configuration phase: execute the Linux-native Jackify Install Engine. + """ + import subprocess + import time + import sys + from pathlib import Path + from .modlist_install_cli import get_jackify_engine_path + + # UI Colors and LoggingHandler already imported at module level + print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}") + start_time = time.time() + + # --- BEGIN: TEE LOGGING SETUP & LOG ROTATION --- + from jackify.shared.paths import get_jackify_logs_dir + log_dir = get_jackify_logs_dir() + log_dir.mkdir(parents=True, exist_ok=True) + workflow_log_path = log_dir / "Modlist_Install_workflow.log" + # Log rotation: keep last 3 logs, 1MB each (adjust as needed) + max_logs = 3 + max_size = 1024 * 1024 # 1MB + if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size: + for i in range(max_logs, 0, -1): + prev = log_dir / f"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path + dest = log_dir / f"Modlist_Install_workflow.log.{i}" + if prev.exists(): + if dest.exists(): + dest.unlink() + prev.rename(dest) + workflow_log = open(workflow_log_path, 'a') + class TeeStdout: + def __init__(self, *files): + self.files = files + def write(self, data): + for f in self.files: + f.write(data) + f.flush() + def flush(self): + for f in self.files: + f.flush() + orig_stdout, orig_stderr = sys.stdout, sys.stderr + sys.stdout = TeeStdout(sys.stdout, workflow_log) + sys.stderr = TeeStdout(sys.stderr, workflow_log) + # --- END: TEE LOGGING SETUP & LOG ROTATION --- + try: + # --- Process Paths from context --- + install_dir_context = self.context['install_dir'] + if isinstance(install_dir_context, tuple): + actual_install_path = Path(install_dir_context[0]) + if install_dir_context[1]: # Second element is True if creation was intended + self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}") + actual_install_path.mkdir(parents=True, exist_ok=True) + else: # Should be a Path object or string already + actual_install_path = Path(install_dir_context) + install_dir_str = str(actual_install_path) + self.logger.debug(f"Processed install directory for engine: {install_dir_str}") + + download_dir_context = self.context['download_dir'] + if isinstance(download_dir_context, tuple): + actual_download_path = Path(download_dir_context[0]) + if download_dir_context[1]: # Second element is True if creation was intended + self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}") + actual_download_path.mkdir(parents=True, exist_ok=True) + else: # Should be a Path object or string already + actual_download_path = Path(download_dir_context) + download_dir_str = str(actual_download_path) + self.logger.debug(f"Processed download directory for engine: {download_dir_str}") + # --- End Process Paths --- + + modlist_arg = self.context.get('modlist_value') or self.context.get('machineid') + machineid = self.context.get('machineid') + + # CRITICAL: Re-check authentication right before launching engine + # Use current auth state, not stale cached context + # (e.g., if user revoked OAuth after context was created) + from jackify.backend.services.nexus_auth_service import NexusAuthService + auth_service = NexusAuthService() + current_api_key, current_oauth_info = auth_service.get_auth_for_engine() + + # Use current auth state, fallback to context values only if current check failed + api_key = current_api_key or self.context.get('nexus_api_key') + oauth_info = current_oauth_info or self.context.get('nexus_oauth_info') + + # Path to the engine binary + engine_path = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_path) + if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): + print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}") + return + + # --- Patch for GUI/auto: always set modlist_source to 'identifier' if not set, and ensure modlist_value is present --- + if os.environ.get('JACKIFY_GUI_MODE') == '1': + if not self.context.get('modlist_source'): + self.context['modlist_source'] = 'identifier' + if not self.context.get('modlist_value'): + self.logger.error("modlist_value is missing in context for GUI workflow!") + return + # --- End Patch --- + + # Build command + cmd = [engine_path, 'install', '--show-file-progress'] + + # Check for debug mode and pass --debug to engine if needed + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + if debug_mode: + cmd.append('--debug') + self.logger.info("Debug mode enabled in config - passing --debug flag to jackify-engine") + + # Determine if this is a local .wabbajack file or an online modlist + modlist_value = self.context.get('modlist_value') + machineid = self.context.get('machineid') + + # Check if there's a cached .wabbajack file for this modlist + cached_wabbajack_path = None + if machineid: + # Convert machineid to filename (e.g., "Tuxborn/Tuxborn" -> "Tuxborn.wabbajack") + modlist_name = machineid.split('/')[-1] if '/' in machineid else machineid + from jackify.shared.paths import get_jackify_downloads_dir + cached_wabbajack_path = get_jackify_downloads_dir() / f"{modlist_name}.wabbajack" + self.logger.debug(f"Checking for cached .wabbajack file: {cached_wabbajack_path}") + + if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): + cmd += ['-w', modlist_value] + self.logger.info(f"Using local .wabbajack file: {modlist_value}") + elif cached_wabbajack_path and os.path.isfile(cached_wabbajack_path): + cmd += ['-w', cached_wabbajack_path] + self.logger.info(f"Using cached .wabbajack file: {cached_wabbajack_path}") + elif modlist_value: + cmd += ['-m', modlist_value] + self.logger.info(f"Using modlist identifier: {modlist_value}") + elif machineid: + cmd += ['-m', machineid] + self.logger.info(f"Using machineid: {machineid}") + cmd += ['-o', install_dir_str, '-d', download_dir_str] + + # Store original environment values to restore later + original_env_values = { + 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), + 'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), + 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') + } + + try: + # Temporarily modify current process's environment + # Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy) + if oauth_info: + os.environ['NEXUS_OAUTH_INFO'] = oauth_info + # CRITICAL: Set client_id so engine can refresh tokens with correct client_id + # Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack" + from jackify.backend.services.nexus_oauth_service import NexusOAuthService + os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID + self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)") + # Also set NEXUS_API_KEY for backward compatibility + if api_key: + os.environ['NEXUS_API_KEY'] = api_key + elif api_key: + # No OAuth info, use API key only (no auto-refresh support) + os.environ['NEXUS_API_KEY'] = api_key + self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)") + else: + # No auth available, clear any inherited values + if 'NEXUS_API_KEY' in os.environ: + del os.environ['NEXUS_API_KEY'] + if 'NEXUS_OAUTH_INFO' in os.environ: + del os.environ['NEXUS_OAUTH_INFO'] + if 'NEXUS_OAUTH_CLIENT_ID' in os.environ: + del os.environ['NEXUS_OAUTH_CLIENT_ID'] + self.logger.debug(f"No Nexus auth available, cleared inherited env vars") + + os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" + self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.") + + self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.") + self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}") + self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}") + + pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) + print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}") + + # Temporarily increase file descriptor limit for engine process + from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit + success, old_limit, new_limit, message = increase_file_descriptor_limit() + if success: + self.logger.debug(f"File descriptor limit: {message}") + else: + self.logger.warning(f"File descriptor limit: {message}") + + # Use cleaned environment to prevent AppImage variable inheritance + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + clean_env = get_clean_subprocess_env() + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir) + + # Start performance monitoring for the engine process + # Adjust monitoring based on debug mode + if debug_mode: + # More aggressive monitoring in debug mode + performance_monitor = EnginePerformanceMonitor( + logger=self.logger, + stall_threshold=5.0, # CPU below 5% is considered stalled + stall_duration=60.0, # 1 minute of low CPU = stall (faster detection) + sample_interval=5.0 # Check every 5 seconds (more frequent) + ) + # Add debug callback for detailed metrics + from .engine_monitor import create_debug_callback + performance_monitor.add_callback(create_debug_callback(self.logger)) + self.logger.info("Enhanced performance monitoring enabled for debug mode") + else: + # Standard monitoring + performance_monitor = EnginePerformanceMonitor( + logger=self.logger, + stall_threshold=5.0, # CPU below 5% is considered stalled + stall_duration=120.0, # 2 minutes of low CPU = stall + sample_interval=10.0 # Check every 10 seconds + ) + + # Add callback to alert about performance issues + def stall_alert(message: str): + print(f"\nWarning: {message}") + print("If the process appears stuck, you may need to restart it.") + if debug_mode: + print("Debug mode: Use 'python -m jackify.backend.handlers.diagnostic_helper' for detailed analysis") + + performance_monitor.add_callback(create_stall_alert_callback(self.logger, stall_alert)) + + # Start monitoring + monitoring_started = performance_monitor.start_monitoring(proc.pid) + if monitoring_started: + self.logger.info(f"Performance monitoring started for engine PID {proc.pid}") + else: + self.logger.warning("Failed to start performance monitoring") + + try: + # Read output in binary mode to properly handle carriage returns + buffer = b'' + last_progress_time = time.time() + + while True: + chunk = proc.stdout.read(1) + if not chunk: + break + buffer += chunk + + # Process complete lines or carriage return updates + if chunk == b'\n': + # Complete line - decode and print + line = buffer.decode('utf-8', errors='replace') + # Filter FILE_PROGRESS spam but keep the status line before it + if '[FILE_PROGRESS]' in line: + parts = line.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + line = parts[0].rstrip() + else: + # Skip this line entirely if it's only FILE_PROGRESS + buffer = b'' + last_progress_time = time.time() + continue + # Enhance Nexus download errors with modlist context + enhanced_line = self._enhance_nexus_error(line) + print(enhanced_line, end='') + buffer = b'' + last_progress_time = time.time() + elif chunk == b'\r': + # Carriage return - decode and print without newline + line = buffer.decode('utf-8', errors='replace') + # Filter FILE_PROGRESS spam but keep the status line before it + if '[FILE_PROGRESS]' in line: + parts = line.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + line = parts[0].rstrip() + else: + # Skip this line entirely if it's only FILE_PROGRESS + buffer = b'' + last_progress_time = time.time() + continue + # Enhance Nexus download errors with modlist context + enhanced_line = self._enhance_nexus_error(line) + print(enhanced_line, end='') + sys.stdout.flush() + buffer = b'' + last_progress_time = time.time() + + # Check for timeout (no output for too long) + current_time = time.time() + if current_time - last_progress_time > 300: # 5 minutes no output + self.logger.warning("No output from engine for 5 minutes - possible stall") + last_progress_time = current_time # Reset to avoid spam + + # Print any remaining buffer content + if buffer: + line = buffer.decode('utf-8', errors='replace') + print(line, end='') + + proc.wait() + + finally: + # Stop performance monitoring and get summary + if monitoring_started: + performance_monitor.stop_monitoring() + summary = performance_monitor.get_metrics_summary() + + if summary: + self.logger.info(f"Engine Performance Summary: " + f"Duration: {summary.get('monitoring_duration', 0):.1f}s, " + f"Avg CPU: {summary.get('avg_cpu_percent', 0):.1f}%, " + f"Max Memory: {summary.get('max_memory_mb', 0):.1f}MB, " + f"Stalls: {summary.get('stall_percentage', 0):.1f}%") + + # Log detailed summary for debugging + self.logger.debug(f"Detailed performance summary: {summary}") + if proc.returncode != 0: + print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") + self.logger.error(f"Engine exited with code {proc.returncode}.") + return # Configuration phase failed + self.logger.info(f"Engine completed with code {proc.returncode}.") + except Exception as e: + print(f"{COLOR_ERROR}Error running Jackify Install Engine: {e}{COLOR_RESET}\n") + self.logger.error(f"Exception running engine: {e}", exc_info=True) + return # Configuration phase failed + finally: + # Restore original environment state + for key, original_value in original_env_values.items(): + current_value_in_os_environ = os.environ.get(key) # Value after Popen and before our restoration for this key + + # Determine display values for logging, redacting NEXUS_API_KEY + display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'" + # display_current_value_before_restore = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{current_value_in_os_environ}'" + + if original_value is not None: + # Original value existed. We must restore it. + if current_value_in_os_environ != original_value: + os.environ[key] = original_value + self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.") + else: + # If current value is already the original, ensure it's correctly set (os.environ[key] = original_value is harmless) + os.environ[key] = original_value # Ensure it is set + self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.") + else: + # Original value was None (key was not in os.environ initially). + if key in os.environ: # If it's in os.environ now, it means we must have set it or it was set by other means. + self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.") + del os.environ[key] + # If original_value was None and key is not in os.environ now, nothing to do. + + except Exception as e: + print(f"{COLOR_ERROR}Error during Tuxborn installation workflow: {e}{COLOR_RESET}\n") + self.logger.error(f"Exception in Tuxborn workflow: {e}", exc_info=True) + return + finally: + # --- BEGIN: RESTORE STDOUT/STDERR --- + sys.stdout = orig_stdout + sys.stderr = orig_stderr + workflow_log.close() + # --- END: RESTORE STDOUT/STDERR --- + + elapsed = int(time.time() - start_time) + print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n") + print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n") + if self.context.get('machineid') != 'Tuxborn/Tuxborn': + print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}") + # After install, use self.context['modlist_game'] to determine if configuration should be offered + # After install, detect game type from ModOrganizer.ini + modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini") + detected_game = None + if os.path.isfile(modorganizer_ini): + from .modlist_handler import ModlistHandler + handler = ModlistHandler({}, steamdeck=self.steamdeck) + handler.modlist_ini = modorganizer_ini + handler.modlist_dir = install_dir_str + if handler._detect_game_variables(): + detected_game = handler.game_var_full + supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"] + is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn' + if (detected_game in supported_games) or is_tuxborn: + shortcut_name = self.context.get('modlist_name') + if is_tuxborn and not shortcut_name: + self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'") + shortcut_name = "Tuxborn Automatic Installer" # Provide a fallback default + elif not shortcut_name: # For non-Tuxborn, prompt if missing + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}") + raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip() + if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name: + return + shortcut_name = raw_shortcut_name + + # Check if GUI mode to skip interactive prompts + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + + 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}") + configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() + + if configure_choice == 'n': + print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}") + return + + # 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'.") + 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}") + 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 + config_context = { + 'name': shortcut_name, + 'appid': app_id, + 'path': install_dir_str, + '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 + } + + # 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: + self.logger.info("Post-installation configuration completed successfully") + + # Check for TTW integration eligibility + self._check_and_prompt_ttw_integration(install_dir_str, detected_game, shortcut_name) + else: + self.logger.warning("Post-installation configuration had issues") + else: + # Game not supported for automated configuration + print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}") + if detected_game: + print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}") + 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_discovery.py b/jackify/backend/handlers/modlist_install_cli_discovery.py new file mode 100644 index 0000000..b408f52 --- /dev/null +++ b/jackify/backend/handlers/modlist_install_cli_discovery.py @@ -0,0 +1,451 @@ +"""Discovery phase methods for ModlistInstallCLI (Mixin).""" +import logging +import os +import subprocess +from pathlib import Path +from typing import Optional, Dict + +from .config_handler import ConfigHandler +from .ui_colors import ( + COLOR_PROMPT, + COLOR_RESET, + COLOR_INFO, + COLOR_ERROR, + COLOR_SUCCESS, + COLOR_WARNING, + COLOR_SELECTION, +) + +logger = logging.getLogger(__name__) + + +class ModlistInstallCLIDiscoveryMixin: + """Mixin providing discovery phase methods.""" + + def run_discovery_phase(self, context_override=None) -> Optional[Dict]: + """ + Run the discovery phase: prompt for all required info, and validate inputs. + Returns a context dict with all collected info, or None if cancelled. + Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow). + """ + self.logger.info("Starting modlist discovery phase (restored logic).") + from .modlist_install_cli import get_jackify_engine_path + + print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}") + + if context_override: + self.context.update(context_override) + if 'resolution' in context_override: + self.context['resolution'] = context_override['resolution'] + else: + self.context = {} + + is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' + # Only require game_type for non-Tuxborn workflows + if self.context.get('machineid'): + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key'] + else: + required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type'] + has_modlist = self.context.get('modlist_value') or self.context.get('machineid') + missing = [k for k in required_keys if not self.context.get(k)] + if is_gui_mode: + if missing or not has_modlist: + self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}") + if not has_modlist: + self.logger.error("Missing modlist_value or machineid for GUI workflow.") + self.logger.error("This workflow must be fully non-interactive. Please report this as a bug if you see this message.") + return None + self.logger.info("All required context present in GUI mode, skipping prompts.") + return self.context + + # Get engine path using the helper + engine_executable = get_jackify_engine_path() + self.logger.debug(f"Engine executable path: {engine_executable}") + + if not os.path.exists(engine_executable): + print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") + print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") + return None + + engine_dir = os.path.dirname(engine_executable) + + # 1. Prompt for modlist source (unless using machineid from context_override) + if 'machineid' not in self.context: + print("\n" + "-" * 28) # Separator + print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists") + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk") + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu") + source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() + self.logger.debug(f"User selected modlist source option: {source_choice}") + + if source_choice == '1': + self.context['modlist_source_type'] = 'online_list' + print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}") + try: + env = os.environ.copy() + env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" + self.logger.info("Setting DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 for jackify-engine process.") + + # Use the engine path from the helper function, but the command structure from restored. + engine_executable_path_for_subprocess = get_jackify_engine_path() + command = [engine_executable_path_for_subprocess, 'list-modlists', '--show-all-sizes', '--show-machine-url'] + self.logger.info(f"Executing command: {' '.join(command)} in CWD: {engine_dir}") + + # check=True as in restored logic + result = subprocess.run( + command, + capture_output=True, text=True, check=True, + env=env, cwd=engine_dir + ) + + # self.logger.debug(f"Engine stdout (raw):\n{result.stdout}") # COMMENTED OUT - too verbose + + lines = result.stdout.splitlines() + + # Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL + # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] + raw_modlists_from_engine = [] + for line in lines: + line = line.strip() + if not line or line.startswith('Loading') or line.startswith('Loaded'): + continue + + # Extract status indicators + status_down = '[DOWN]' in line + status_nsfw = '[NSFW]' in line + + # Remove status indicators to get clean line + clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() + + # Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL] + parts = clean_line.split(' - ') + if len(parts) != 4: + continue # Skip malformed lines + + modlist_name = parts[0].strip() + game_name = parts[1].strip() + sizes_str = parts[2].strip() + machine_url = parts[3].strip() + + # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") + size_parts = sizes_str.split('|') + if len(size_parts) != 3: + continue # Skip if sizes don't match expected format + + download_size = size_parts[0].strip() + install_size = size_parts[1].strip() + total_size = size_parts[2].strip() + + # Skip if any required data is missing + if not modlist_name or not game_name or not machine_url: + continue + + raw_modlists_from_engine.append({ + 'id': modlist_name, # Use modlist name as ID for compatibility + 'name': modlist_name, + 'game': game_name, + 'download_size': download_size, + 'install_size': install_size, + 'total_size': total_size, + 'machine_url': machine_url, # Store machine URL for installation + 'status_down': status_down, + 'status_nsfw': status_nsfw + }) + + self.logger.info(f"Scraped {len(raw_modlists_from_engine)} modlists after revised regex and filtering logic.") + + if not raw_modlists_from_engine: + print(f"{COLOR_WARNING}No modlists found after applying revised regex and filtering logic.{COLOR_RESET}") + return None + + # EXACT game_type_map and grouping logic from restored file + game_type_map = { + '1': ('Skyrim', ['Skyrim', 'Skyrim Special Edition']), + '2': ('Fallout 4', ['Fallout 4']), + '3': ('Fallout New Vegas', ['Fallout New Vegas']), + '4': ('Oblivion', ['Oblivion']), + '5': ('Other Games', None) # Using None as in restored for keyword list + } + + grouped_modlists = {k: [] for k in game_type_map} + + for m_info in raw_modlists_from_engine: # m_info is like {'id': ..., 'game': ...} + found_category = False + for cat_key, (cat_label, cat_keywords) in game_type_map.items(): + if cat_key == '5': # Skip 'Other Games' for direct matching initially + continue + if cat_keywords: # Ensure there are keywords to check (handles 'Other Games' with None) + for keyword in cat_keywords: + if keyword.lower() in m_info['game'].lower(): + grouped_modlists[cat_key].append(m_info) + found_category = True + break # Found category for this modlist + if found_category: + break # Move to next modlist + if not found_category: + grouped_modlists['5'].append(m_info) # Add to 'Other Games' + + selected_modlist_info = None # Will store {'id': ..., 'game': ...} + while not selected_modlist_info: + print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}") + + category_display_map = {} # Maps displayed number to actual game_type_map key + display_idx = 1 + # Iterate in a defined order for consistent menu + for cat_key_ordered in ['1','2','3','4','5']: + if cat_key_ordered in grouped_modlists and grouped_modlists[cat_key_ordered]: # Only show if non-empty + cat_label = game_type_map[cat_key_ordered][0] + print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {cat_label} ({len(grouped_modlists[cat_key_ordered])} modlists)") + category_display_map[str(display_idx)] = cat_key_ordered + display_idx += 1 + + if display_idx == 1: # No categories had any modlists + print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}") + return None + + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel") + + game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip() + if game_cat_choice == '0': + self.logger.info("User cancelled game category selection.") + return None + + actual_cat_key = category_display_map.get(game_cat_choice) + if not actual_cat_key: + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + continue + + # modlist_group_for_game is a list of dicts like {'id': ..., 'game': ...} + modlist_group_for_game = sorted(grouped_modlists[actual_cat_key], key=lambda x: x['id'].lower()) + + print(f"\n{COLOR_SUCCESS}Available Modlists for {game_type_map[actual_cat_key][0]}:{COLOR_RESET}") + for idx, m_detail in enumerate(modlist_group_for_game, 1): + if actual_cat_key == '5': # 'Other Games' category + print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']} ({m_detail['game']})") + else: + print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']}") + print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories") + + while True: + mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip() + if mod_choice_idx_str == '0': + break + if mod_choice_idx_str.isdigit(): + mod_idx = int(mod_choice_idx_str) - 1 + if 0 <= mod_idx < len(modlist_group_for_game): + selected_modlist_info = modlist_group_for_game[mod_idx] + self.context['modlist_source'] = 'identifier' + # Use machine_url for installation, display name for suggestions + self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id']) + self.context['modlist_game'] = selected_modlist_info['game'] + self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1] + self.logger.info(f"User selected online modlist: {selected_modlist_info}") + break + else: + print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") + if selected_modlist_info: + break + + except subprocess.CalledProcessError as e: + self.logger.error(f"list-modlists failed. Code: {e.returncode}") + if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") + if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") + print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_RESET}") + return None + except FileNotFoundError: + self.logger.error(f"Engine not found: {engine_executable_path_for_subprocess}") + print(f"{COLOR_ERROR}Critical error: jackify-install-engine not found.{COLOR_RESET}") + return None + except Exception as e: + self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) + print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}") + return None + + elif source_choice == '2': + self.context['modlist_source_type'] = 'local_file' + print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}") + modlist_path = self.menu_handler.get_existing_file_path( + prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", + extension_filter=".wabbajack", # Ensure this is the exact filter used by the method + no_header=True # To avoid re-printing a header if get_existing_file_path has one + ) + if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q' + self.logger.info("User cancelled .wabbajack file selection.") + print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}") + return None + + self.context['modlist_source'] = 'path' # For install command + self.context['modlist_value'] = str(modlist_path) + # Suggest a name based on the file + self.context['modlist_name_suggestion'] = Path(modlist_path).stem + self.logger.info(f"User selected local .wabbajack file: {modlist_path}") + + elif source_choice == '0': + self.logger.info("User cancelled modlist source selection.") + print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}") + return None + else: + self.logger.warning(f"Invalid modlist source choice: {source_choice}") + print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") + return self.run_discovery_phase() # Re-prompt + + # --- Prompts for install_dir, download_dir, modlist_name, api_key --- + # It will use self.context['modlist_name_suggestion'] if available. + + # 2. Prompt for modlist name (skip if 'modlist_name' already in context from override) + if 'modlist_name' not in self.context or not self.context['modlist_name']: + default_name = self.context.get('modlist_name_suggestion', 'MyModlist') + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}") + print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}") + modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() + if not modlist_name_input: # User hit enter for default + modlist_name = default_name + elif modlist_name_input.lower() == 'q': + self.logger.info("User cancelled at modlist name prompt.") + return None + else: + modlist_name = modlist_name_input + self.context['modlist_name'] = modlist_name + self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}") + + # 3. Prompt for install directory + if 'install_dir' not in self.context: + # Use configurable base directory + config_handler = ConfigHandler() + base_install_dir = Path(config_handler.get_modlist_install_base_dir()) + default_install_dir = base_install_dir / self.context['modlist_name'] + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}") + print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}") + install_dir_path = self.menu_handler.get_directory_path( + prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", + default_path=default_install_dir, + create_if_missing=True, + no_header=True + ) + if install_dir_path is None: + self.logger.info("User cancelled at install directory prompt.") + return None + self.context['install_dir'] = install_dir_path + self.logger.debug(f"Install directory context set to: {self.context['install_dir']}") + + # 4. Prompt for download directory + if 'download_dir' not in self.context: + # Use configurable base directory for downloads + config_handler = ConfigHandler() + base_download_dir = Path(config_handler.get_modlist_downloads_base_dir()) + default_download_dir = base_download_dir / self.context['modlist_name'] + + print("\n" + "-" * 28) + print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}") + print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}") + download_dir_path = self.menu_handler.get_directory_path( + prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", + default_path=default_download_dir, + create_if_missing=True, + no_header=True + ) + if download_dir_path is None: + self.logger.info("User cancelled at download directory prompt.") + return None + self.context['download_dir'] = download_dir_path + self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") + + # 5. Get Nexus authentication (OAuth or API key) + if 'nexus_api_key' not in self.context: + from jackify.backend.services.nexus_auth_service import NexusAuthService + auth_service = NexusAuthService() + + # Get current auth status + authenticated, method, username = auth_service.get_auth_status() + + if authenticated: + # Already authenticated - use existing auth + if method == 'oauth': + print("\n" + "-" * 28) + print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}") + if username: + print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}") + elif method == 'api_key': + print("\n" + "-" * 28) + print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}") + + # Get valid token/key and OAuth state for engine auto-refresh + api_key, oauth_info = auth_service.get_auth_for_engine() + if api_key: + self.context['nexus_api_key'] = api_key + self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh + else: + # Auth expired or invalid - prompt to set up + print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}") + authenticated = False + + if not authenticated: + # Not authenticated - offer to set up OAuth + print("\n" + "-" * 28) + print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}") + print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}") + print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}") + + authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower() + + if authorize in ('', 'y', 'yes'): + # Launch OAuth authorization + print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}") + print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}") + print(f"{COLOR_INFO}Note: Your browser may ask permission to open 'xdg-open' or{COLOR_RESET}") + print(f"{COLOR_INFO}Jackify's protocol handler - please click 'Open' or 'Allow'.{COLOR_RESET}") + + def show_message(msg): + print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}") + + success = auth_service.authorize_oauth(show_browser_message_callback=show_message) + + if success: + print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}") + _, _, username = auth_service.get_auth_status() + if username: + print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}") + + api_key, oauth_info = auth_service.get_auth_for_engine() + if api_key: + self.context['nexus_api_key'] = api_key + self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh + else: + print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}") + return None + else: + print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}") + return None + else: + # User declined OAuth - cancelled + print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}") + self.logger.info("User declined Nexus authorization.") + return None + self.logger.debug(f"Nexus authentication configured for engine.") + + # Display summary and confirm + self._display_summary() # Ensure this method exists or implement it + if self.context.get('skip_confirmation'): + confirm = 'y' + else: + confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower() + if confirm != 'y': + self.logger.info("User cancelled at final confirmation.") + print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") + return None + + self.logger.info("Discovery phase complete.") # Log completion first + + # Create a copy of the context for logging, so we don't alter the original + context_for_logging = self.context.copy() + if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None: + context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging + + self.logger.info(f"Context: {context_for_logging}") # Log the redacted context + return self.context + diff --git a/jackify/backend/handlers/modlist_install_cli_nexus.py b/jackify/backend/handlers/modlist_install_cli_nexus.py new file mode 100644 index 0000000..a73d63a --- /dev/null +++ b/jackify/backend/handlers/modlist_install_cli_nexus.py @@ -0,0 +1,144 @@ +"""Nexus and engine methods for ModlistInstallCLI (Mixin).""" +import logging +import os +import re +import subprocess +from pathlib import Path +from typing import Optional + +from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET + +logger = logging.getLogger(__name__) + + +class ModlistInstallCLINexusMixin: + """Mixin providing Nexus API and engine methods.""" + + def _get_nexus_api_key(self) -> Optional[str]: + return self.context.get('nexus_api_key') + + def get_all_modlists_from_engine(self, game_type=None): + """ + Call the Jackify engine with 'list-modlists' and return a list of modlist dicts. + Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags. + + Args: + game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas") + """ + from .modlist_install_cli import get_jackify_engine_path + + engine_executable = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_executable) + if not os.path.exists(engine_executable): + print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") + print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") + return [] + env = os.environ.copy() + env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" + command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url'] + + # Add game filter if specified + if game_type: + command.extend(['--game', game_type]) + try: + result = subprocess.run( + command, + capture_output=True, text=True, check=True, + env=env, cwd=engine_dir + ) + lines = result.stdout.splitlines() + modlists = [] + for line in lines: + line = line.strip() + if not line or line.startswith('Loading') or line.startswith('Loaded'): + continue + + # Parse the new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL + # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] + + # Extract status indicators + status_down = '[DOWN]' in line + status_nsfw = '[NSFW]' in line + + # Remove status indicators to get clean line + clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() + + # Split from right to handle modlist names with dashes + # Format: "NAME - GAME - SIZES - MACHINE_URL" + parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts + if len(parts) != 4: + continue # Skip malformed lines + + modlist_name = parts[0].strip() + game_name = parts[1].strip() + sizes_str = parts[2].strip() + machine_url = parts[3].strip() + + # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") + size_parts = sizes_str.split('|') + if len(size_parts) != 3: + continue # Skip if sizes don't match expected format + + download_size = size_parts[0].strip() + install_size = size_parts[1].strip() + total_size = size_parts[2].strip() + + # Skip if any required data is missing + if not modlist_name or not game_name or not machine_url: + continue + + modlists.append({ + 'id': modlist_name, # Use modlist name as ID for compatibility + 'name': modlist_name, + 'game': game_name, + 'download_size': download_size, + 'install_size': install_size, + 'total_size': total_size, + 'machine_url': machine_url, # Store machine URL for installation + 'status_down': status_down, + 'status_nsfw': status_nsfw + }) + return modlists + except subprocess.CalledProcessError as e: + self.logger.error(f"list-modlists failed. Code: {e.returncode}") + if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") + if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") + print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}") + return [] + except Exception as e: + self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) + print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}") + return [] + + def _enhance_nexus_error(self, line: str) -> str: + """ + Enhance Nexus download error messages by adding the mod URL for easier troubleshooting. + """ + import re + + # Pattern to match Nexus download errors with ModID and FileID + nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):" + + match = re.search(nexus_error_pattern, line) + if match: + game_name = match.group(1) + mod_id = match.group(2) + + # Map game names to Nexus URL segments + game_url_map = { + 'SkyrimSpecialEdition': 'skyrimspecialedition', + 'Skyrim': 'skyrim', + 'Fallout4': 'fallout4', + 'FalloutNewVegas': 'newvegas', + 'Oblivion': 'oblivion', + 'Starfield': 'starfield' + } + + game_url = game_url_map.get(game_name, game_name.lower()) + mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}" + + # Add URL on next line for easier debugging + return f"{line}\n Nexus URL: {mod_url}" + + return line + diff --git a/jackify/backend/handlers/modlist_install_cli_ttw.py b/jackify/backend/handlers/modlist_install_cli_ttw.py new file mode 100644 index 0000000..2db4f5e --- /dev/null +++ b/jackify/backend/handlers/modlist_install_cli_ttw.py @@ -0,0 +1,180 @@ +"""TTW integration methods for ModlistInstallCLI (Mixin).""" +import logging +import os +from pathlib import Path + +from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET + +logger = logging.getLogger(__name__) + + +class ModlistInstallCLITTWMixin: + """Mixin providing TTW integration methods.""" + + def _check_and_prompt_ttw_integration(self, install_dir: str, game_type: str, modlist_name: str): + """Check if modlist is eligible for TTW integration and prompt user""" + try: + # Check eligibility: FNV game, TTW-compatible modlist, no existing TTW + if not self._is_ttw_eligible(install_dir, game_type, modlist_name): + return + + # Prompt user for TTW installation + print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") + print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}") + print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") + print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).") + 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() + + if user_input in ['yes', 'y']: + self._launch_ttw_installation(modlist_name, install_dir) + else: + print(f"{COLOR_INFO}Skipping TTW installation. You can install it later from the main menu.{COLOR_RESET}") + + except Exception as e: + self.logger.error(f"Error during TTW eligibility check: {e}", exc_info=True) + + def _is_ttw_eligible(self, install_dir: str, game_type: str, modlist_name: str) -> bool: + """Check if modlist is eligible for TTW integration""" + try: + from pathlib import Path + + # Check 1: Must be Fallout New Vegas + if not game_type or game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']: + return False + + # Check 2: Must be on TTW compatibility whitelist + from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible + if not is_ttw_compatible(modlist_name): + return False + + # Check 3: TTW must not already be installed + if self._detect_existing_ttw(install_dir): + self.logger.info(f"TTW already installed in {install_dir}, skipping prompt") + return False + + return True + + except Exception as e: + self.logger.error(f"Error checking TTW eligibility: {e}") + return False + + def _detect_existing_ttw(self, install_dir: str) -> bool: + """Detect if TTW is already installed in the modlist""" + try: + from pathlib import Path + + install_path = Path(install_dir) + + # Search for TTW indicators in common locations + search_paths = [ + install_path, + install_path / "mods", + install_path / "Stock Game", + install_path / "Game Root" + ] + + for search_path in search_paths: + if not search_path.exists(): + continue + + # Look for folders containing "tale" and "two" and "wastelands" + for folder in search_path.iterdir(): + if not folder.is_dir(): + continue + + folder_name_lower = folder.name.lower() + if all(keyword in folder_name_lower for keyword in ['tale', 'two', 'wastelands']): + # Verify it has the TTW ESM file + for file in folder.rglob('*.esm'): + if 'taleoftwowastelands' in file.name.lower(): + self.logger.info(f"Found existing TTW installation: {file}") + return True + + return False + + except Exception as e: + self.logger.error(f"Error detecting existing TTW: {e}") + return False + + def _launch_ttw_installation(self, modlist_name: str, install_dir: str): + """Launch TTW installation workflow""" + try: + print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}") + + # Import TTW installation handler + from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler + from jackify.backend.models.configuration import SystemInfo + from pathlib import Path + + system_info = SystemInfo() + ttw_installer_handler = TTWInstallerHandler( + steamdeck=system_info.is_steamdeck if hasattr(system_info, 'is_steamdeck') else False, + 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 + ) + + # Check if TTW_Linux_Installer is installed + ttw_installer_handler._check_installation() + + 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() + + if user_input not in ['yes', 'y']: + print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}") + return + + # Install TTW_Linux_Installer + print(f"{COLOR_INFO}Installing TTW_Linux_Installer...{COLOR_RESET}") + success, message = ttw_installer_handler.install_ttw_installer() + + if not success: + print(f"{COLOR_ERROR}Failed to install TTW_Linux_Installer: {message}{COLOR_RESET}") + return + + print(f"{COLOR_INFO}TTW_Linux_Installer installed successfully.{COLOR_RESET}") + + # Prompt for TTW .mpi file + print(f"\n{COLOR_PROMPT}TTW Installer File (.mpi){COLOR_RESET}") + mpi_path = input(f"{COLOR_PROMPT}Path to TTW .mpi file: {COLOR_RESET}").strip() + if not mpi_path: + print(f"{COLOR_WARNING}No .mpi file specified. Cancelling.{COLOR_RESET}") + return + + mpi_path = Path(mpi_path).expanduser() + if not mpi_path.exists() or not mpi_path.is_file(): + print(f"{COLOR_ERROR}TTW .mpi file not found: {mpi_path}{COLOR_RESET}") + return + + # Prompt for TTW installation directory + print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}") + default_ttw_dir = os.path.join(install_dir, 'TTW') + print(f"Default: {default_ttw_dir}") + ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip() + + if not ttw_install_dir: + ttw_install_dir = default_ttw_dir + + # 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}") + + success, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir)) + + if success: + 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"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}") + print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}") + + 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 diff --git a/jackify/backend/handlers/modlist_wine_ops.py b/jackify/backend/handlers/modlist_wine_ops.py new file mode 100644 index 0000000..907ca25 --- /dev/null +++ b/jackify/backend/handlers/modlist_wine_ops.py @@ -0,0 +1,543 @@ +"""Wine/Proton operation methods for ModlistHandler (Mixin).""" +from pathlib import Path +from typing import Tuple, Optional, List +import os +import logging +import subprocess +import shutil +import time +import vdf +import json +import configparser + +logger = logging.getLogger(__name__) + + +class ModlistWineOpsMixin: + """Mixin providing Wine and Proton operation methods for ModlistHandler.""" + + def verify_proton_setup(self, appid_to_check: str) -> Tuple[bool, str]: + """Verifies that Proton is correctly set up for a given AppID. + + Checks config.vdf for Proton Experimental and existence of compatdata/pfx dir. + + Args: + appid_to_check: The AppID string to verify. + + Returns: + tuple: (bool success, str status_code) + Status codes: 'ok', 'invalid_appid', 'config_vdf_missing', + 'config_vdf_error', 'proton_check_failed', + 'wrong_proton_version', 'compatdata_missing', + 'prefix_missing' + """ + self.logger.info(f"Verifying Proton setup for AppID: {appid_to_check}") + + if not appid_to_check or not appid_to_check.isdigit(): + self.logger.error("Invalid AppID provided for verification.") + return False, 'invalid_appid' + + proton_tool_name = None + compatdata_path_found = None + prefix_exists = False + + # 1. Find and Parse config.vdf + config_vdf_path = None + possible_steam_paths = [ + Path.home() / ".steam/steam", + Path.home() / ".local/share/Steam", + Path.home() / ".steam/root" + ] + for steam_path in possible_steam_paths: + potential_path = steam_path / "config/config.vdf" + if potential_path.is_file(): + config_vdf_path = potential_path + self.logger.debug(f"Found config.vdf at: {config_vdf_path}") + break + + if not config_vdf_path: + self.logger.error("Could not locate Steam's config.vdf file.") + return False, 'config_vdf_missing' + + # Add a short delay to allow Steam to potentially finish writing changes + self.logger.debug("Waiting 2 seconds before reading config.vdf...") + time.sleep(2) + + try: + self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}") + # CORRECTION: Use the vdf library directly here, not VDFHandler + with open(str(config_vdf_path), 'r') as f: + config_data = vdf.load(f, mapper=vdf.VDFDict) + + # --- Write full config.vdf to a debug file --- + debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt") + with open(debug_dump_path, "w") as dump_f: + json.dump(config_data, dump_f, indent=2) + self.logger.info(f"Full config.vdf dumped to {debug_dump_path}") + + # --- Log only the relevant section for this AppID --- + steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}) + compat_mapping = steam_config_section.get('CompatToolMapping', {}) + app_mapping = compat_mapping.get(appid_to_check, {}) + self.logger.debug("───────────────────────────────────────────────────────────────────") + self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):") + self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2)) + self.logger.debug("───────────────────────────────────────────────────────────────────") + self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}") + # --- End Debugging --- + + # Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name + compat_mapping = steam_config_section.get('CompatToolMapping', {}) + app_mapping = compat_mapping.get(appid_to_check, {}) + proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name' + self.proton_ver = proton_tool_name # Store detected version + + if proton_tool_name: + self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}") + else: + self.logger.warning(f"CompatToolMapping entry not found for AppID {appid_to_check} in config.vdf.") + # Add more debug info here about what *was* found + self.logger.debug(f"CompatToolMapping contents: {json.dumps(compat_mapping.get(appid_to_check, 'Key not found'), indent=2)}") + return False, 'proton_check_failed' # Compatibility not explicitly set + + except FileNotFoundError: + self.logger.error(f"Config.vdf file not found during load attempt: {config_vdf_path}") + return False, 'config_vdf_missing' + except Exception as e: + self.logger.error(f"Error parsing config.vdf: {e}", exc_info=True) + return False, 'config_vdf_error' + + # 2. Check if the correct Proton version is set (allowing variations) + # Target: Proton Experimental + if not proton_tool_name or 'experimental' not in proton_tool_name.lower(): + self.logger.warning(f"Incorrect Proton version detected: '{proton_tool_name}'. Expected 'Proton Experimental'.") + return False, 'wrong_proton_version' + + self.logger.info("Proton version check passed ('Proton Experimental' set).") + + # 3. Check for compatdata / prefix directory existence + possible_compat_bases = [ + Path.home() / ".steam/steam/steamapps/compatdata", + Path.home() / ".local/share/Steam/steamapps/compatdata", + # Add SD card paths if necessary / detectable + # Path("/run/media/mmcblk0p1/steamapps/compatdata") # Example + ] + + compat_dir_found = False + for base_path in possible_compat_bases: + potential_compat_path = base_path / appid_to_check + if potential_compat_path.is_dir(): + self.logger.debug(f"Found compatdata directory: {potential_compat_path}") + compat_dir_found = True + # Check for prefix *within* the found compatdata dir + prefix_path = potential_compat_path / "pfx" + if prefix_path.is_dir(): + self.logger.info(f"Wine prefix directory verified: {prefix_path}") + prefix_exists = True + break # Found both compatdata and prefix, exit loop + else: + self.logger.warning(f"Compatdata directory found, but prefix missing: {prefix_path}") + # Keep searching other base paths in case prefix exists elsewhere + + if not compat_dir_found: + self.logger.error(f"Compatdata directory not found for AppID {appid_to_check} in standard locations.") + return False, 'compatdata_missing' + + if not prefix_exists: + # Found compatdata but no pfx inside any of them + self.logger.error(f"Wine prefix directory (pfx) not found within any located compatdata directory for AppID {appid_to_check}.") + return False, 'prefix_missing' + + # All checks passed + self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.") + return True, 'ok' + + def set_steam_grid_images(self, appid: str, modlist_dir: str): + """ + Copies hero, logo, and poster images from the modlist's SteamIcons directory + to the grid directory of all non-zero Steam user directories, named after the new AppID. + """ + steam_icons_dir = Path(modlist_dir) / "SteamIcons" + if not steam_icons_dir.is_dir(): + self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.") + return + + # Find all non-zero Steam user directories + userdata_base = Path.home() / ".steam/steam/userdata" + if not userdata_base.is_dir(): + self.logger.error(f"Steam userdata directory not found at {userdata_base}") + return + + for user_dir in userdata_base.iterdir(): + if not user_dir.is_dir() or user_dir.name == "0": + continue + grid_dir = user_dir / "config/grid" + grid_dir.mkdir(parents=True, exist_ok=True) + + images = [ + ("grid-hero.png", f"{appid}_hero.png"), + ("grid-logo.png", f"{appid}_logo.png"), + ("grid-tall.png", f"{appid}.png"), + ("grid-tall.png", f"{appid}p.png"), + ] + + for src_name, dest_name in images: + src_path = steam_icons_dir / src_name + dest_path = grid_dir / dest_name + if src_path.exists(): + try: + shutil.copyfile(src_path, dest_path) + self.logger.info(f"Copied {src_path} to {dest_path}") + except Exception as e: + self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}") + else: + self.logger.warning(f"Image {src_path} not found; skipping.") + + def get_modlist_wine_components(self, modlist_name, game_var_full=None): + """ + Returns the full list of Wine components to install for a given modlist/game. + - Always includes the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022) + - Adds game-specific extras (from bash script logic) + - Adds any modlist-specific extras (from MODLIST_WINE_COMPONENTS) + """ + default_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] + extras = [] + # Determine game type + game = (game_var_full or modlist_name or "").lower().replace(" ", "") + # 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: + extras += ["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(): + if key in modlist_lower: + extras += components + # Remove duplicates while preserving order + seen = set() + full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))] + return full_list + + def _re_enforce_windows_10_mode(self): + """ + Re-enforce Windows 10 mode after modlist-specific configurations. + This matches the legacy script behavior (line 1333) where Windows 10 mode + is re-applied after modlist-specific steps to ensure consistency. + """ + try: + if not hasattr(self, 'appid') or not self.appid: + self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available") + return + + from ..handlers.winetricks_handler import WinetricksHandler + from ..handlers.path_handler import PathHandler + + # Get prefix path for the AppID + prefix_path = PathHandler.find_compat_data(str(self.appid)) + if not prefix_path: + self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found") + return + + # Use winetricks handler to set Windows 10 mode + winetricks_handler = WinetricksHandler() + wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path)) + if not wine_binary: + self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found") + return + + winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary) + + self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations") + + except Exception as e: + self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}") + + def _handle_symlinked_downloads(self) -> bool: + """ + Check if downloads_directory in ModOrganizer.ini points to a symlink. + If it does, comment out the line to force MO2 to use default behavior. + + Returns: + bool: True on success or no action needed, False on error + """ + try: + if not self.modlist_ini or not os.path.exists(self.modlist_ini): + self.logger.warning("ModOrganizer.ini not found for symlink check") + return True # Non-critical + + # Read the INI file + # Allow duplicate sections/keys since some ModOrganizer.ini variants repeat [General] + # Latest occurrence wins, which matches how we only need the final downloads_directory value. + config = configparser.ConfigParser(allow_no_value=True, delimiters=['='], strict=False) + config.optionxform = str # Preserve case sensitivity + + try: + # Read file manually to handle BOM + with open(self.modlist_ini, 'r', encoding='utf-8-sig') as f: + config.read_file(f) + except UnicodeDecodeError: + with open(self.modlist_ini, 'r', encoding='latin-1') as f: + config.read_file(f) + + # Check if downloads_directory or download_directory exists and is a symlink + downloads_key = None + downloads_path = None + + if 'General' in config: + # Check for both possible key names + if 'downloads_directory' in config['General']: + downloads_key = 'downloads_directory' + downloads_path = config['General']['downloads_directory'] + elif 'download_directory' in config['General']: + downloads_key = 'download_directory' + downloads_path = config['General']['download_directory'] + + if downloads_path: + + if downloads_path and os.path.exists(downloads_path): + # Check if the path or any parent directory contains symlinks + def has_symlink_in_path(path): + """Check if path or any parent directory is a symlink""" + current_path = Path(path).resolve() + check_path = Path(path) + + # Walk up the path checking each component + for parent in [check_path] + list(check_path.parents): + if parent.is_symlink(): + return True, str(parent) + return False, None + + has_symlink, symlink_path = has_symlink_in_path(downloads_path) + if has_symlink: + self.logger.info(f"Detected symlink in downloads directory path: {symlink_path} -> {downloads_path}") + self.logger.info("Commenting out downloads_directory to avoid Wine symlink issues") + + # Read the file manually to preserve comments and formatting + with open(self.modlist_ini, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # Find and comment out the downloads directory line + modified = False + for i, line in enumerate(lines): + if line.strip().startswith(f'{downloads_key}='): + lines[i] = '#' + line # Comment out the line + modified = True + break + + if modified: + # Write the modified file back + with open(self.modlist_ini, 'w', encoding='utf-8') as f: + f.writelines(lines) + self.logger.info(f"{downloads_key} line commented out successfully") + else: + self.logger.warning("downloads_directory line not found in file") + else: + self.logger.debug(f"downloads_directory is not a symlink: {downloads_path}") + else: + self.logger.debug("downloads_directory path does not exist or is empty") + else: + self.logger.debug("No downloads_directory found in ModOrganizer.ini") + + return True + + except Exception as e: + self.logger.error(f"Error handling symlinked downloads: {e}", exc_info=True) + return False + + def _apply_universal_dotnet_fixes(self): + """ + Apply universal dotnet4.x compatibility registry fixes to ALL modlists. + Now called AFTER wine component installation to prevent overwrites. + Includes wineserver shutdown/flush to ensure persistence. + """ + try: + prefix_path = os.path.join(str(self.compat_data_path), "pfx") + if not os.path.exists(prefix_path): + self.logger.warning(f"Prefix path not found: {prefix_path}") + return False + + self.logger.info("Applying universal dotnet4.x compatibility registry fixes (post-component installation)...") + + # Find the appropriate Wine binary to use for registry operations + wine_binary = self._find_wine_binary_for_registry() + if not wine_binary: + self.logger.error("Could not find Wine binary for registry operations") + return False + + # Find wineserver binary for flushing registry changes + wine_dir = os.path.dirname(wine_binary) + wineserver_binary = os.path.join(wine_dir, 'wineserver') + if not os.path.exists(wineserver_binary): + self.logger.warning(f"wineserver not found at {wineserver_binary}, registry flush may not work") + wineserver_binary = None + + # Set environment for Wine registry operations + env = os.environ.copy() + env['WINEPREFIX'] = prefix_path + env['WINEDEBUG'] = '-all' # Suppress Wine debug output + + # Shutdown any running wineserver processes to ensure clean slate + if wineserver_binary: + self.logger.debug("Shutting down wineserver before applying registry fixes...") + try: + subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True) + self.logger.debug("Wineserver shutdown complete") + except Exception as e: + self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}") + + # Registry fix 1: Set *mscoree=native DLL override (asterisk for full override) + # Use native .NET runtime instead of Wine's + self.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' + ] + + result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30) + self.logger.info(f"*mscoree registry command result: returncode={result1.returncode}, stdout={result1.stdout[:200]}, stderr={result1.stderr[:200]}") + if result1.returncode == 0: + self.logger.info("Successfully applied *mscoree=native DLL override") + else: + self.logger.error(f"Failed to set *mscoree DLL override: returncode={result1.returncode}, stderr={result1.stderr}") + + # Registry fix 2: Set OnlyUseLatestCLR=1 + # Use latest CLR to avoid .NET version conflicts + self.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', timeout=30) + self.logger.info(f"OnlyUseLatestCLR registry command result: returncode={result2.returncode}, stdout={result2.stdout[:200]}, stderr={result2.stderr[:200]}") + if result2.returncode == 0: + self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry") + else: + self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}") + + # Force wineserver to flush registry changes to disk + if wineserver_binary: + self.logger.debug("Flushing registry changes to disk via wineserver shutdown...") + try: + subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True) + self.logger.debug("Registry changes flushed to disk") + except Exception as e: + self.logger.warning(f"Registry flush failed (non-critical): {e}") + + # VERIFICATION: Confirm the registry entries persisted + self.logger.info("Verifying registry entries were applied and persisted...") + verification_passed = True + + # Verify *mscoree=native + verify_cmd1 = [ + wine_binary, 'reg', 'query', + 'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides', + '/v', '*mscoree' + ] + verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30) + if verify_result1.returncode == 0 and 'native' in verify_result1.stdout: + self.logger.info("VERIFIED: *mscoree=native is set correctly") + else: + self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}") + verification_passed = False + + # Verify OnlyUseLatestCLR=1 + verify_cmd2 = [ + wine_binary, 'reg', 'query', + 'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework', + '/v', 'OnlyUseLatestCLR' + ] + verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30) + if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout): + self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly") + else: + self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}") + verification_passed = False + + # Both fixes applied and verified + if result1.returncode == 0 and result2.returncode == 0 and verification_passed: + self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully") + return True + else: + self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts") + return False + + except Exception as e: + self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}") + return False + + def _find_wine_binary_for_registry(self) -> Optional[str]: + """Find wine binary from Install Proton path""" + try: + # Use Install Proton from config (used by jackify-engine) + from ..handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + proton_path = config_handler.get_proton_path() + + if proton_path: + proton_path = Path(proton_path).expanduser() + + # Check both GE-Proton and Valve Proton structures + wine_candidates = [ + proton_path / "files" / "bin" / "wine", # GE-Proton + proton_path / "dist" / "bin" / "wine" # Valve Proton + ] + + for wine_bin in wine_candidates: + if wine_bin.exists() and wine_bin.is_file(): + return str(wine_bin) + + # Fallback: use best detected Proton + from ..handlers.wine_utils import WineUtils + best_proton = WineUtils.select_best_proton() + if best_proton: + wine_binary = WineUtils.find_proton_binary(best_proton['name']) + if wine_binary: + return wine_binary + + return None + except Exception as e: + self.logger.error(f"Error finding Wine binary: {e}") + return None + + def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]: + """ + Recursively search for wine binary within a Proton directory. + This handles cases where the directory structure might differ between Proton versions. + + Args: + proton_path: Path to the Proton directory to search + + Returns: + Path to wine binary if found, None otherwise + """ + try: + if not proton_path.exists() or not proton_path.is_dir(): + return None + + # Search for 'wine' executable (not 'wine64' or 'wine-preloader') + # Limit search depth to avoid scanning entire filesystem + max_depth = 5 + for root, dirs, files in os.walk(proton_path, followlinks=False): + # Calculate depth relative to proton_path + depth = len(Path(root).relative_to(proton_path).parts) + if depth > max_depth: + dirs.clear() # Don't descend further + continue + + # Check if 'wine' is in this directory + if 'wine' in files: + wine_path = Path(root) / 'wine' + # Verify it's actually an executable file + if wine_path.is_file() and os.access(wine_path, os.X_OK): + self.logger.debug(f"Found wine binary at: {wine_path}") + return str(wine_path) + + return None + except Exception as e: + self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}") + return None + diff --git a/jackify/backend/handlers/path_handler.py b/jackify/backend/handlers/path_handler.py index 70ec12e..c16e767 100644 --- a/jackify/backend/handlers/path_handler.py +++ b/jackify/backend/handlers/path_handler.py @@ -2,1235 +2,32 @@ # -*- coding: utf-8 -*- """ Path Handler Module -Handles path-related operations for ModOrganizer.ini and other configuration files +Handles path-related operations for ModOrganizer.ini and other configuration files. +Logic split into mixins: MO2, DXVK, Steam, Game. """ -import os -import re -import logging -import shutil -from pathlib import Path -from typing import Optional, Union, Dict, Any, List, Tuple -from datetime import datetime -import vdf +from .path_handler_mo2 import ( + PathHandlerMO2Mixin, + TARGET_EXECUTABLES_LOWER, + STOCK_GAME_FOLDERS, + SDCARD_PREFIX, +) +from .path_handler_dxvk import PathHandlerDXVKMixin +from .path_handler_steam import PathHandlerSteamMixin +from .path_handler_game import PathHandlerGameMixin -# Initialize logger -logger = logging.getLogger(__name__) +__all__ = [ + 'PathHandler', + 'TARGET_EXECUTABLES_LOWER', + 'STOCK_GAME_FOLDERS', + 'SDCARD_PREFIX', +] -# --- Configuration (Adapted from Proposal) --- -# Define known script extender executables (lowercase for comparisons) -TARGET_EXECUTABLES_LOWER = ["skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"] -# Define known stock game folder names (case-sensitive, as they appear on disk) -STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"] -# Define the SD card path prefix on Steam Deck/Linux -SDCARD_PREFIX = '/run/media/mmcblk0p1/' -class PathHandler: - """ - Handles path-related operations for ModOrganizer.ini and other configuration files - """ - - @staticmethod - def _strip_sdcard_path_prefix(path_obj: Path) -> str: - """ - Removes any detected SD card mount prefix dynamically. - Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns. - Returns the path as a POSIX-style string (using /). - """ - from .wine_utils import WineUtils - - path_str = path_obj.as_posix() # Work with consistent forward slashes - - # Use dynamic SD card detection from WineUtils - stripped_path = WineUtils._strip_sdcard_path(path_str) - - if stripped_path != path_str: - # Path was stripped, remove leading slash for relative path - return stripped_path.lstrip('/') if stripped_path != '/' else '.' - - return path_str - - @staticmethod - def update_mo2_ini_paths( - modlist_ini_path: Path, - modlist_dir_path: Path, - modlist_sdcard: bool, - steam_library_common_path: Optional[Path] = None, - basegame_dir_name: Optional[str] = None, - basegame_sdcard: bool = False # Default to False if not provided - ) -> bool: - logger.info(f"[DEBUG] update_mo2_ini_paths called with: modlist_ini_path={modlist_ini_path}, modlist_dir_path={modlist_dir_path}, modlist_sdcard={modlist_sdcard}, steam_library_common_path={steam_library_common_path}, basegame_dir_name={basegame_dir_name}, basegame_sdcard={basegame_sdcard}") - if not modlist_ini_path.is_file(): - logger.error(f"ModOrganizer.ini not found at specified path: {modlist_ini_path}") - # Attempt to create a minimal INI - try: - logger.warning("Creating minimal ModOrganizer.ini with [General] section.") - with open(modlist_ini_path, 'w', encoding='utf-8') as f: - f.write('[General]\n') - # Continue as if file existed - except Exception as e: - logger.critical(f"Failed to create minimal ModOrganizer.ini: {e}") - return False - if not modlist_dir_path.is_dir(): - logger.error(f"Modlist directory not found or not a directory: {modlist_dir_path}") - # Warn but continue - - # --- Bulletproof game directory detection --- - # 1. Get all Steam libraries and log them - all_steam_libraries = PathHandler.get_all_steam_library_paths() - logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") - import sys - if hasattr(sys, 'argv') and any(arg in ('--debug', '-d') for arg in sys.argv): - self.logger.debug(f"Detected Steam libraries: {all_steam_libraries}") - - # 2. For each library, check for the canonical vanilla game directory - GAME_DIR_NAMES = { - "Skyrim Special Edition": "Skyrim Special Edition", - "Fallout 4": "Fallout 4", - "Fallout New Vegas": "Fallout New Vegas", - "Oblivion": "Oblivion" - } - canonical_name = None - if basegame_dir_name and basegame_dir_name in GAME_DIR_NAMES: - canonical_name = GAME_DIR_NAMES[basegame_dir_name] - elif basegame_dir_name: - canonical_name = basegame_dir_name # fallback, but should match above - gamepath_target_dir = None - gamepath_target_is_sdcard = modlist_sdcard - checked_candidates = [] - if canonical_name: - for lib in all_steam_libraries: - candidate = lib / "steamapps" / "common" / canonical_name - checked_candidates.append(str(candidate)) - logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") - if candidate.is_dir(): - gamepath_target_dir = candidate - logger.info(f"Found vanilla game directory: {candidate}") - break - if not gamepath_target_dir: - logger.error(f"Could not find vanilla game directory '{canonical_name}' in any Steam library. Checked: {checked_candidates}") - # 4. Prompt the user for the path - print("\nCould not automatically detect a Stock Game or vanilla game directory.") - print("Please enter the full path to your vanilla game directory (e.g., /path/to/Skyrim Special Edition):") - while True: - user_input = input("Game directory path: ").strip() - user_path = Path(user_input) - logger.info(f"[DEBUG] User entered: {user_input}") - if user_path.is_dir(): - exe_candidates = list(user_path.glob('*.exe')) - logger.info(f"[DEBUG] .exe files in user path: {exe_candidates}") - if exe_candidates: - gamepath_target_dir = user_path - logger.info(f"User provided valid vanilla game directory: {gamepath_target_dir}") - break - else: - print("Directory exists but does not appear to contain the game executable. Please check and try again.") - logger.warning("User path exists but no .exe files found.") - else: - print("Directory not found. Please enter a valid path.") - logger.warning("User path does not exist.") - if not gamepath_target_dir: - logger.critical("[FATAL] Could not determine a valid target directory for gamePath. Check configuration and paths. Aborting update.") - return False - - # 3. Update gamePath, binary, and workingDirectory entries in the INI - logger.debug(f"Determined gamePath target directory: {gamepath_target_dir}") - logger.debug(f"gamePath target is on SD card: {gamepath_target_is_sdcard}") - try: - logger.debug(f"Reading original INI file: {modlist_ini_path}") - with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f: - original_lines = f.readlines() - - # --- Find and robustly update gamePath line --- - gamepath_line_num = -1 - general_section_line = -1 - for i, line in enumerate(original_lines): - if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE): - general_section_line = i - if re.match(r'^\s*gamepath\s*=\s*', line, re.IGNORECASE): - gamepath_line_num = i - break - processed_str = PathHandler._strip_sdcard_path_prefix(gamepath_target_dir) - windows_style_single = processed_str.replace('/', '\\') - gamepath_drive_letter = "D:" if gamepath_target_is_sdcard else "Z:" - # Use robust formatter - formatted_gamepath = PathHandler._format_gamepath_for_mo2(f'{gamepath_drive_letter}{windows_style_single}') - new_gamepath_line = f'gamePath = @ByteArray({formatted_gamepath})\n' - if gamepath_line_num != -1: - logger.info(f"Updating existing gamePath line: {original_lines[gamepath_line_num].strip()} -> {new_gamepath_line.strip()}") - original_lines[gamepath_line_num] = new_gamepath_line - else: - insert_at = general_section_line + 1 if general_section_line != -1 else 0 - logger.info(f"Adding missing gamePath line at line {insert_at+1}: {new_gamepath_line.strip()}") - original_lines.insert(insert_at, new_gamepath_line) - - # --- Update customExecutables binaries and workingDirectories --- - TARGET_EXECUTABLES_LOWER = [ - "skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe" - ] - in_custom_exec = False - for i, line in enumerate(original_lines): - if re.match(r'^\s*\[customExecutables\]\s*$', line, re.IGNORECASE): - in_custom_exec = True - continue - if in_custom_exec and re.match(r'^\s*\[.*\]\s*$', line): - in_custom_exec = False - if in_custom_exec: - m = re.match(r'^(\d+)\\binary\s*=\s*(.*)$', line.strip(), re.IGNORECASE) - if m: - idx, old_path = m.group(1), m.group(2) - exe_name = os.path.basename(old_path).lower() - if exe_name in TARGET_EXECUTABLES_LOWER: - new_path = f'{gamepath_drive_letter}/{PathHandler._strip_sdcard_path_prefix(gamepath_target_dir)}/{exe_name}' - # Use robust formatter - new_path = PathHandler._format_binary_for_mo2(new_path) - logger.info(f"Updating binary for entry {idx}: {old_path} -> {new_path}") - original_lines[i] = f'{idx}\\binary = {new_path}\n' - m_wd = re.match(r'^(\d+)\\workingDirectory\s*=\s*(.*)$', line.strip(), re.IGNORECASE) - if m_wd: - idx, old_wd = m_wd.group(1), m_wd.group(2) - new_wd = f'{gamepath_drive_letter}{windows_style_single}' - # Use robust formatter - new_wd = PathHandler._format_workingdir_for_mo2(new_wd) - logger.info(f"Updating workingDirectory for entry {idx}: {old_wd} -> {new_wd}") - original_lines[i] = f'{idx}\\workingDirectory = {new_wd}\n' - - # --- Backup and Write New INI --- - backup_path = modlist_ini_path.with_suffix(f".{datetime.now().strftime('%Y%m%d_%H%M%S')}.bak") - try: - shutil.copy2(modlist_ini_path, backup_path) - logger.info(f"Backed up original INI to: {backup_path}") - except Exception as bak_err: - logger.error(f"Failed to backup original INI file: {bak_err}") - return False - try: - with open(modlist_ini_path, 'w', encoding='utf-8') as f: - f.writelines(original_lines) - logger.info(f"Successfully wrote updated paths to {modlist_ini_path}") - return True - except Exception as write_err: - logger.error(f"Failed to write updated INI file {modlist_ini_path}: {write_err}", exc_info=True) - logger.error("Attempting to restore from backup...") - try: - shutil.move(backup_path, modlist_ini_path) - logger.info("Successfully restored original INI from backup.") - except Exception as restore_err: - logger.critical(f"CRITICAL FAILURE: Could not write new INI and failed to restore backup {backup_path}. Manual intervention required at {modlist_ini_path}! Error: {restore_err}") - return False - except Exception as e: - logger.error(f"An unexpected error occurred during INI path update: {e}", exc_info=True) - return False - - @staticmethod - def edit_resolution(modlist_ini, resolution): - """ - Edit resolution settings in ModOrganizer.ini - - Args: - modlist_ini (str): Path to ModOrganizer.ini - resolution (str): Resolution in the format "1920x1080" - - Returns: - bool: True on success, False on failure - """ - try: - logger.info(f"Editing resolution settings to {resolution}...") - - # Parse resolution - width, height = resolution.split('x') - - # Read the current ModOrganizer.ini - with open(modlist_ini, 'r') as f: - content = f.read() - - # Replace width and height settings - content = re.sub(r'^width\s*=\s*\d+$', f'width = {width}', content, flags=re.MULTILINE) - content = re.sub(r'^height\s*=\s*\d+$', f'height = {height}', content, flags=re.MULTILINE) - - # Write the updated content back to the file - with open(modlist_ini, 'w') as f: - f.write(content) - - logger.info("Resolution settings edited successfully") - return True - - except Exception as e: - logger.error(f"Error editing resolution settings: {e}") - return False - - @staticmethod - def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full, vanilla_game_dir=None, stock_game_path=None): - """ - Create dxvk.conf file in the appropriate location - - Args: - modlist_dir (str): Path to the modlist directory - modlist_sdcard (bool): Whether the modlist is on an SD card - steam_library (str): Path to the Steam library - basegame_sdcard (bool): Whether the base game is on an SD card - game_var_full (str): Full name of the game (e.g., "Skyrim Special Edition") - vanilla_game_dir (str): Optional path to vanilla game directory for fallback - stock_game_path (str): Direct path to detected stock game directory (if available) - - Returns: - bool: True on success, False on failure - """ - try: - logger.info("Creating dxvk.conf file...") - - candidate_dirs = PathHandler._build_dxvk_candidate_dirs( - modlist_dir=modlist_dir, - stock_game_path=stock_game_path, - steam_library=steam_library, - game_var_full=game_var_full, - vanilla_game_dir=vanilla_game_dir - ) - - if not candidate_dirs: - logger.error("Could not determine location for dxvk.conf (no candidate directories found)") - return False - - target_dir = None - for directory in candidate_dirs: - if directory.is_dir(): - target_dir = directory - break - - if target_dir is None: - fallback_dir = Path(modlist_dir) if modlist_dir and Path(modlist_dir).is_dir() else None - if fallback_dir: - logger.warning(f"No stock/vanilla directories found; falling back to modlist directory: {fallback_dir}") - target_dir = fallback_dir - else: - logger.error("All candidate directories for dxvk.conf are missing.") - return False - - dxvk_conf_path = target_dir / "dxvk.conf" - - # The required line that Jackify needs - required_line = "dxvk.enableGraphicsPipelineLibrary = False" - - # Check if dxvk.conf already exists - if dxvk_conf_path.exists(): - logger.info(f"Found existing dxvk.conf at {dxvk_conf_path}") - - # Read existing content - try: - with open(dxvk_conf_path, 'r', encoding='utf-8') as f: - existing_content = f.read().strip() - - # Check if our required line is already present - existing_lines = existing_content.split('\n') if existing_content else [] - has_required_line = any(line.strip() == required_line for line in existing_lines) - - if has_required_line: - logger.info("Required DXVK setting already present in existing file") - return True - else: - # Append our required line to existing content - if existing_content: - # File has content, append our line - updated_content = existing_content + '\n' + required_line + '\n' - logger.info("Appending required DXVK setting to existing file") - else: - # File is empty, just add our line - updated_content = required_line + '\n' - logger.info("Adding required DXVK setting to empty file") - - with open(dxvk_conf_path, 'w', encoding='utf-8') as f: - f.write(updated_content) - - logger.info(f"dxvk.conf updated successfully at {dxvk_conf_path}") - return True - - except Exception as e: - logger.error(f"Error reading/updating existing dxvk.conf: {e}") - # Fall back to creating new file - logger.info("Falling back to creating new dxvk.conf file") - - # Create new dxvk.conf file (original behavior) - dxvk_conf_content = required_line + '\n' - - dxvk_conf_path.parent.mkdir(parents=True, exist_ok=True) - with open(dxvk_conf_path, 'w', encoding='utf-8') as f: - f.write(dxvk_conf_content) - - logger.info(f"dxvk.conf created successfully at {dxvk_conf_path}") - return True - - except Exception as e: - logger.error(f"Error creating dxvk.conf: {e}") - return False - - @staticmethod - def verify_dxvk_conf_exists(modlist_dir, steam_library, game_var_full, vanilla_game_dir=None, stock_game_path=None) -> bool: - """ - Verify that dxvk.conf exists in at least one of the candidate directories and contains the required setting. - """ - required_line = "dxvk.enableGraphicsPipelineLibrary = False" - candidate_dirs = PathHandler._build_dxvk_candidate_dirs( - modlist_dir=modlist_dir, - stock_game_path=stock_game_path, - steam_library=steam_library, - game_var_full=game_var_full, - vanilla_game_dir=vanilla_game_dir - ) - - for directory in candidate_dirs: - conf_path = directory / "dxvk.conf" - if conf_path.is_file(): - try: - with open(conf_path, 'r', encoding='utf-8') as f: - content = f.read() - if required_line not in content: - logger.warning(f"dxvk.conf found at {conf_path} but missing required setting. Appending now.") - with open(conf_path, 'a', encoding='utf-8') as f: - if not content.endswith('\n'): - f.write('\n') - f.write(required_line + '\n') - logger.info(f"Verified dxvk.conf at {conf_path}") - return True - except Exception as e: - logger.warning(f"Failed to verify dxvk.conf at {conf_path}: {e}") - - logger.warning("dxvk.conf verification failed - file not found in any candidate directory.") - return False - - @staticmethod - def _normalize_common_library_path(steam_library: Optional[str]) -> Optional[Path]: - if not steam_library: - return None - path = Path(steam_library) - parts_lower = [part.lower() for part in path.parts] - if len(parts_lower) >= 2 and parts_lower[-2:] == ['steamapps', 'common']: - return path - if parts_lower and parts_lower[-1] == 'common': - return path - if 'steamapps' in parts_lower: - idx = parts_lower.index('steamapps') - truncated = Path(*path.parts[:idx + 1]) - return truncated / 'common' - return path / 'steamapps' / 'common' - - @staticmethod - def _build_dxvk_candidate_dirs(modlist_dir, stock_game_path, steam_library, game_var_full, vanilla_game_dir) -> List[Path]: - candidates: List[Path] = [] - seen = set() - - def add_candidate(path_obj: Optional[Path]): - if not path_obj: - return - key = path_obj.resolve() if path_obj.exists() else path_obj - if key in seen: - return - seen.add(key) - candidates.append(path_obj) - - if stock_game_path: - add_candidate(Path(stock_game_path)) - - if modlist_dir: - base_path = Path(modlist_dir) - common_names = [ - "Stock Game", - "Game Root", - "STOCK GAME", - "Stock Game Folder", - "Stock Folder", - "Skyrim Stock", - os.path.join("root", "Skyrim Special Edition") - ] - for name in common_names: - add_candidate(base_path / name) - - steam_common = PathHandler._normalize_common_library_path(steam_library) - if steam_common and game_var_full: - add_candidate(steam_common / game_var_full) - - if vanilla_game_dir: - add_candidate(Path(vanilla_game_dir)) - - if modlist_dir: - add_candidate(Path(modlist_dir)) - - return candidates - - @staticmethod - def find_steam_config_vdf() -> Optional[Path]: - """Finds the active Steam config.vdf file.""" - logger.debug("Searching for Steam config.vdf...") - possible_steam_paths = [ - Path.home() / ".steam/steam", - Path.home() / ".local/share/Steam", - Path.home() / ".steam/root" - ] - for steam_path in possible_steam_paths: - potential_path = steam_path / "config/config.vdf" - if potential_path.is_file(): - logger.info(f"Found config.vdf at: {potential_path}") - return potential_path # Return Path object - - logger.warning("Could not locate Steam's config.vdf file in standard locations.") - return None - - @staticmethod - def find_steam_library() -> Optional[Path]: - """Find the primary Steam library common directory containing games.""" - logger.debug("Attempting to find Steam library...") - - # Potential locations for libraryfolders.vdf - libraryfolders_vdf_paths = [ - os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"), - os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"), - os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"), # Flatpak - ] - - # Simple backup mechanism (optional but good practice) - for path in libraryfolders_vdf_paths: - if os.path.exists(path): - backup_dir = os.path.join(os.path.dirname(path), "backups") - if not os.path.exists(backup_dir): - try: - os.makedirs(backup_dir) - except OSError as e: - logger.warning(f"Could not create backup directory {backup_dir}: {e}") - - # Create timestamped backup if it doesn't exist for today - timestamp = datetime.now().strftime("%Y%m%d") - backup_filename = f"libraryfolders_{timestamp}.vdf.bak" - backup_path = os.path.join(backup_dir, backup_filename) - - if not os.path.exists(backup_path): - try: - import shutil - shutil.copy2(path, backup_path) - logger.debug(f"Created backup of libraryfolders.vdf at {backup_path}") - except Exception as e: - logger.error(f"Failed to create backup of libraryfolders.vdf: {e}") - # Continue anyway, as we're only reading the file - pass - - libraryfolders_vdf_path_obj = None # Will hold the Path object - found_path_str = None - for path_str in libraryfolders_vdf_paths: - if os.path.exists(path_str): - found_path_str = path_str # Keep the string path for logging/opening - libraryfolders_vdf_path_obj = Path(path_str) # Convert to Path object here - logger.debug(f"Found libraryfolders.vdf at: {path_str}") - break - - # Check using the Path object's is_file() method - if not libraryfolders_vdf_path_obj or not libraryfolders_vdf_path_obj.is_file(): - logger.warning("libraryfolders.vdf not found or is not a file. Cannot automatically detect Steam Library.") - return None - - # Parse the VDF file to extract library paths - library_paths = [] - try: - # Open using the original string path is fine, or use the Path object - with open(found_path_str, 'r') as f: # Or use libraryfolders_vdf_path_obj - content = f.read() - - # Use regex to find all path entries - path_matches = re.finditer(r'"path"\s*"([^"]+)"', content) - for match in path_matches: - library_path_str = match.group(1).replace('\\\\', '\\') # Fix potential double escapes - common_path = os.path.join(library_path_str, "steamapps", "common") - if os.path.isdir(common_path): # Verify the common path exists - library_paths.append(Path(common_path)) - logger.debug(f"Found potential common path: {common_path}") - else: - logger.debug(f"Skipping non-existent common path derived from VDF: {common_path}") - - logger.debug(f"Found {len(library_paths)} valid library common paths from VDF.") - - # Return the first valid path found - if library_paths: - logger.info(f"Using Steam library common path: {library_paths[0]}") - return library_paths[0] - - # If no valid paths found in VDF, try the default structure - logger.debug("No valid common paths found in VDF, checking default location...") - default_common_path = Path.home() / ".steam/steam/steamapps/common" - if default_common_path.is_dir(): - logger.info(f"Using default Steam library common path: {default_common_path}") - return default_common_path - - default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common" - if default_common_path_local.is_dir(): - logger.info(f"Using default local Steam library common path: {default_common_path_local}") - return default_common_path_local - - logger.error("No valid Steam library common path found in VDF or default locations.") - return None - - except Exception as e: - logger.error(f"Error parsing libraryfolders.vdf or finding Steam library: {e}", exc_info=True) - return None - - @staticmethod - def find_compat_data(appid: str) -> Optional[Path]: - """Find the compatdata directory for a given AppID.""" - if not appid: - logger.error(f"Invalid AppID provided for compatdata search: {appid}") - return None - - # Handle negative AppIDs (remove minus sign for validation) - appid_clean = appid.lstrip('-') - if not appid_clean.isdigit(): - logger.error(f"Invalid AppID provided for compatdata search: {appid}") - return None - - logger.debug(f"Searching for compatdata directory for AppID: {appid}") - - # Use libraryfolders.vdf to find all Steam library paths, when available - library_paths = PathHandler.get_all_steam_library_paths() - if library_paths: - logger.debug(f"Checking compatdata in {len(library_paths)} Steam libraries") - - # Check each Steam library's compatdata directory - for library_path in library_paths: - compatdata_base = library_path / "steamapps" / "compatdata" - if not compatdata_base.is_dir(): - logger.debug(f"Compatdata directory does not exist: {compatdata_base}") - continue - - potential_path = compatdata_base / appid - if potential_path.is_dir(): - logger.info(f"Found compatdata directory: {potential_path}") - return potential_path - else: - logger.debug(f"Compatdata for AppID {appid} not found in {compatdata_base}") - - # Check fallback locations only if we didn't find valid libraries - # If we have valid libraries from libraryfolders.vdf, we should NOT fall back to wrong locations - is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in library_paths) if library_paths else False - - if not library_paths or is_flatpak_steam: - # Only check Flatpak-specific fallbacks if we have Flatpak Steam - logger.debug("Checking fallback compatdata locations...") - if is_flatpak_steam: - # For Flatpak Steam, only check Flatpak-specific locations - fallback_locations = [ - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata", - Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata", - ] - else: - # For native Steam or unknown, check standard locations - fallback_locations = [ - Path.home() / ".local/share/Steam/steamapps/compatdata", - Path.home() / ".steam/steam/steamapps/compatdata", - ] - - for compatdata_base in fallback_locations: - if compatdata_base.is_dir(): - potential_path = compatdata_base / appid - if potential_path.is_dir(): - logger.warning(f"Found compatdata directory in fallback location (may be from old incorrect creation): {potential_path}") - return potential_path - - logger.warning(f"Compatdata directory for AppID {appid} not found in any Steam library or fallback location.") - return None - - @staticmethod - def detect_stock_game_path(game_type: str, steam_library: Path) -> Optional[Path]: - """ - Detect the stock game path for a given game type and Steam library - Returns the path if found, None otherwise - """ - try: - # Map of game types to their Steam App IDs - game_app_ids = { - 'skyrim': '489830', # Skyrim Special Edition - 'fallout4': '377160', # Fallout 4 - 'fnv': '22380', # Fallout: New Vegas - 'oblivion': '22330' # The Elder Scrolls IV: Oblivion - } - - if game_type not in game_app_ids: - return None - - app_id = game_app_ids[game_type] - game_path = steam_library / 'steamapps' / 'common' - - # List of possible game directory names - possible_names = { - 'skyrim': ['Skyrim Special Edition', 'Skyrim'], - 'fallout4': ['Fallout 4'], - 'fnv': ['Fallout New Vegas', 'FalloutNV'], - 'oblivion': ['Oblivion'] - } - - if game_type not in possible_names: - return None - - # Check each possible directory name - for name in possible_names[game_type]: - potential_path = game_path / name - if potential_path.exists(): - return potential_path - - return None - - except Exception as e: - logging.error(f"Error detecting stock game path: {e}") - return None - - @staticmethod - def get_steam_library_path(steam_path: str) -> Optional[str]: - """Get the Steam library path from libraryfolders.vdf.""" - try: - libraryfolders_path = os.path.join(steam_path, 'steamapps', 'libraryfolders.vdf') - if not os.path.exists(libraryfolders_path): - return None - - with open(libraryfolders_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Parse the VDF content - libraries = {} - current_library = None - for line in content.split('\n'): - line = line.strip() - if line.startswith('"path"'): - current_library = line.split('"')[3].replace('\\\\', '\\') - elif line.startswith('"apps"') and current_library: - libraries[current_library] = True - - # Return the first library path that exists - for library_path in libraries: - if os.path.exists(library_path): - return library_path - - return None - except Exception as e: - logger.error(f"Error getting Steam library path: {str(e)}") - return None - - @staticmethod - def get_all_steam_library_paths() -> List[Path]: - """Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak).""" - logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...") - vdf_paths = [ - Path.home() / ".steam/steam/config/libraryfolders.vdf", - Path.home() / ".local/share/Steam/config/libraryfolders.vdf", - Path.home() / ".steam/root/config/libraryfolders.vdf", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", # Flatpak - ] - library_paths = set() - for vdf_path in vdf_paths: - if vdf_path.is_file(): - logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}") - try: - with open(vdf_path, 'r', encoding='utf-8') as f: - data = vdf.load(f) - # libraryfolders.vdf structure: libraryfolders -> "0", "1", etc. -> "path" - libraryfolders = data.get('libraryfolders', {}) - for key, lib_data in libraryfolders.items(): - if isinstance(lib_data, dict) and 'path' in lib_data: - lib_path = Path(lib_data['path']) - # Resolve symlinks for consistency (mmcblk0p1 -> deck/UUID) - try: - resolved_path = lib_path.resolve() - library_paths.add(resolved_path) - logger.debug(f"[DEBUG] Found library path: {resolved_path}") - except (OSError, RuntimeError) as resolve_err: - # If resolve fails, use original path - logger.warning(f"[DEBUG] Could not resolve {lib_path}, using as-is: {resolve_err}") - library_paths.add(lib_path) - except Exception as e: - logger.error(f"[DEBUG] Failed to parse {vdf_path}: {e}") - logger.info(f"[DEBUG] All detected Steam libraries: {library_paths}") - return list(library_paths) - - # Moved _find_shortcuts_vdf here from ShortcutHandler - def _find_shortcuts_vdf(self) -> Optional[str]: - """Helper to find the active shortcuts.vdf file for the current Steam user. - - Uses proper multi-user detection to find the correct Steam user instead - of just taking the first found user directory. - - Returns: - Optional[str]: The full path to the shortcuts.vdf file, or None if not found. - """ - try: - # Use native Steam service for proper multi-user detection - from jackify.backend.services.native_steam_service import NativeSteamService - steam_service = NativeSteamService() - shortcuts_path = steam_service.get_shortcuts_vdf_path() - - if shortcuts_path: - logger.info(f"Found shortcuts.vdf using multi-user detection: {shortcuts_path}") - return str(shortcuts_path) - else: - logger.error("Could not determine shortcuts.vdf path using multi-user detection") - return None - - except Exception as e: - logger.error(f"Error using multi-user detection for shortcuts.vdf: {e}") - return None - - @staticmethod - def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]: - """ - Find installation paths for multiple specified games using Steam app IDs. - - Args: - target_appids: Dictionary mapping game names to app IDs - - Returns: - Dictionary mapping game names to their installation paths - """ - # Get all Steam library paths - library_paths = PathHandler.get_all_steam_library_paths() - if not library_paths: - logger.warning("Failed to find any Steam library paths") - return {} - - results = {} - - # For each library path, look for each target game - for library_path in library_paths: - # Check if the common directory exists (games are in steamapps/common) - common_dir = library_path / "steamapps" / "common" - if not common_dir.is_dir(): - logger.debug(f"No 'steamapps/common' directory in library: {library_path}") - continue - - # Get subdirectories in common dir - try: - game_dirs = [d for d in common_dir.iterdir() if d.is_dir()] - except (PermissionError, OSError) as e: - logger.warning(f"Cannot access directory {common_dir}: {e}") - continue - - # For each app ID, check if we find its directory - for game_name, app_id in target_appids.items(): - if game_name in results: - continue # Already found this game - - # Try to find by appmanifest (manifests are in steamapps subdirectory) - appmanifest_path = library_path / "steamapps" / f"appmanifest_{app_id}.acf" - if appmanifest_path.is_file(): - # Find the installdir value - try: - with open(appmanifest_path, 'r', encoding='utf-8') as f: - content = f.read() - match = re.search(r'"installdir"\s+"([^"]+)"', content) - if match: - install_dir_name = match.group(1) - install_path = common_dir / install_dir_name - if install_path.is_dir(): - results[game_name] = install_path - logger.info(f"Found {game_name} at {install_path}") - continue - except Exception as e: - logger.warning(f"Error reading appmanifest for {game_name}: {e}") - - return results - - def replace_gamepath(self, modlist_ini_path: Path, new_game_path: Path, modlist_sdcard: bool = False) -> bool: - """ - Updates the gamePath value in ModOrganizer.ini to the specified path. - Strictly matches the bash script: only replaces an existing gamePath line. - If the file or line does not exist, logs error and aborts. - """ - logger.info(f"Replacing gamePath in {modlist_ini_path} with {new_game_path}") - if not modlist_ini_path.is_file(): - logger.error(f"ModOrganizer.ini not found at: {modlist_ini_path}") - return False - try: - with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines() - drive_letter = "D:\\\\" if modlist_sdcard else "Z:\\\\" - processed_path = self._strip_sdcard_path_prefix(new_game_path) - windows_style = processed_path.replace('/', '\\') - windows_style_double = windows_style.replace('\\', '\\\\') - new_gamepath_line = f'gamePath=@ByteArray({drive_letter}{windows_style_double})\n' - gamepath_found = False - for i, line in enumerate(lines): - # Make the check case-insensitive and robust to whitespace - if re.match(r'^\s*gamepath\s*=.*$', line, re.IGNORECASE): - lines[i] = new_gamepath_line - gamepath_found = True - break - if not gamepath_found: - logger.error("No gamePath line found in ModOrganizer.ini") - return False - with open(modlist_ini_path, 'w', encoding='utf-8') as f: - f.writelines(lines) - logger.info(f"Successfully updated gamePath to {new_game_path}") - return True - except Exception as e: - logger.error(f"Error replacing gamePath: {e}", exc_info=True) - return False - - # ===================================================================================== - # CRITICAL: DO NOT CHANGE THIS FUNCTION WITHOUT UPDATING TESTS AND CONSULTING PROJECT LEAD - # This function implements the exact path rewriting logic required for ModOrganizer.ini - # to match the original, robust bash script. Any change here risks breaking modlist - # configuration for users. If you must change this, update all relevant tests and - # consult the Project Lead for Jackify. See also omni-guides.sh for reference logic. - # ===================================================================================== - def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool, steam_libraries: Optional[List[Path]] = None) -> bool: - """ - Update all binary paths and working directories in a ModOrganizer.ini file. - Handles various ModOrganizer.ini formats (single or double backslashes in keys). - When updating gamePath, binary, and workingDirectory, retain the original stock folder (Stock Game, Game Root, etc) if present in the current value. - steam_libraries: Optional[List[Path]] - already-discovered Steam library paths to use for vanilla detection. - - # DO NOT CHANGE THIS LOGIC WITHOUT UPDATING TESTS AND CONSULTING THE PROJECT LEAD - # This is a critical, regression-prone area. See omni-guides.sh for reference. - """ - try: - logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}") - if not modlist_ini_path.is_file(): - logger.error(f"INI file {modlist_ini_path} does not exist") - return False - with open(modlist_ini_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - - # Extract existing gamePath to use as source of truth for vanilla game location - existing_game_path = None - gamepath_drive_letter = None - gamepath_line_index = -1 - for i, line in enumerate(lines): - if re.match(r'^\s*gamepath\s*=.*@ByteArray\(([^)]+)\)', line, re.IGNORECASE): - match = re.search(r'@ByteArray\(([^)]+)\)', line) - if match: - raw_path = match.group(1) - gamepath_line_index = i - # Extract drive letter from gamePath (Z: or D:) - if raw_path.startswith('Z:'): - gamepath_drive_letter = 'Z:' - elif raw_path.startswith('D:'): - gamepath_drive_letter = 'D:' - # Convert Windows path back to Linux path - if raw_path.startswith(('Z:', 'D:')): - linux_path = raw_path[2:].replace('\\\\', '/').replace('\\', '/') - existing_game_path = linux_path - logger.debug(f"Extracted existing gamePath: {existing_game_path}, drive letter: {gamepath_drive_letter}") - break - - # Special handling for gamePath in three-true scenario (engine_installed + steamdeck + sdcard) - if modlist_sdcard and existing_game_path and existing_game_path.startswith('/run/media') and gamepath_line_index != -1: - # Simple manual stripping of /run/media/deck/UUID pattern for SD card paths - # Match /run/media/deck/[UUID]/Games/... and extract just /Games/... - sdcard_pattern = r'^/run/media/deck/[^/]+(/Games/.*)$' - match = re.match(sdcard_pattern, existing_game_path) - if match: - stripped_path = match.group(1) # Just the /Games/... part - windows_path = stripped_path.replace('/', '\\\\') - new_gamepath_value = f"D:\\\\{windows_path}" - new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n" - - logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}") - lines[gamepath_line_index] = new_gamepath_line - else: - logger.warning(f"SD card path doesn't match expected pattern: {existing_game_path}") - - game_path_updated = False - binary_paths_updated = 0 - working_dirs_updated = 0 - binary_lines = [] - working_dir_lines = [] - for i, line in enumerate(lines): - stripped = line.strip() - binary_match = re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE) - if binary_match: - index = binary_match.group(1) - backslash_style = binary_match.group(2) - binary_lines.append((i, stripped, index, backslash_style)) - wd_match = re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE) - if wd_match: - index = wd_match.group(1) - backslash_style = wd_match.group(2) - working_dir_lines.append((i, stripped, index, backslash_style)) - binary_paths_by_index = {} - # Use existing gamePath to determine correct Steam library, fallback to detection - if existing_game_path and '/steamapps/common/' in existing_game_path: - # Extract the Steam library root from the existing gamePath - steamapps_index = existing_game_path.find('/steamapps/common/') - steam_lib_root = existing_game_path[:steamapps_index] - steam_libraries = [Path(steam_lib_root)] - logger.info(f"Using Steam library from existing gamePath: {steam_lib_root}") - elif steam_libraries is None or not steam_libraries: - steam_libraries = PathHandler.get_all_steam_library_paths() - logger.debug(f"Fallback to detected Steam libraries: {steam_libraries}") - for i, line, index, backslash_style in binary_lines: - parts = line.split('=', 1) - if len(parts) != 2: - logger.error(f"Malformed binary line: {line}") - continue - key_part, value_part = parts - - # Clean up malformed paths (quotes in wrong places, etc.) - cleaned_value = PathHandler._clean_malformed_binary_path(value_part) - exe_name = os.path.basename(cleaned_value).lower() - - # SELECTIVE FILTERING: Only process target executables (script extenders, etc.) - if exe_name not in TARGET_EXECUTABLES_LOWER: - logger.debug(f"Skipping non-target executable: {exe_name}") - continue - - rel_path = None - # --- BEGIN: FULL PARITY LOGIC --- - if 'steamapps' in cleaned_value: - # Vanilla game path detected - always rebuild to ensure correct format - if not gamepath_drive_letter: - logger.warning(f"Vanilla game path detected but gamePath drive letter not found. Skipping binary path update for: {exe_name}") - logger.warning("This may indicate jackify-engine already configured paths correctly, or gamePath is malformed.") - continue - - # Check if path is malformed (has quotes or wrong structure) - is_malformed = '"' in cleaned_value or cleaned_value != value_part.strip().strip('"') - - # Extract subpath from cleaned value (includes exe name) - idx = cleaned_value.index('steamapps') - subpath = cleaned_value[idx:].lstrip('/') - - # Find correct Steam library - correct_steam_lib = None - for lib in steam_libraries: - # Check if the actual game folder exists in this library - if len(subpath.split('/')) > 3 and (lib / subpath.split('/')[2] / subpath.split('/')[3]).exists(): - correct_steam_lib = lib - break - if not correct_steam_lib and steam_libraries: - correct_steam_lib = steam_libraries[0] - if correct_steam_lib: - # Always rebuild path using gamePath drive letter to ensure correct format - drive_prefix = gamepath_drive_letter - if is_malformed: - logger.info(f"Fixing malformed binary path for {exe_name}: {value_part.strip()}") - logger.debug(f"Vanilla game path detected: Using drive letter from gamePath: {drive_prefix}") - new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/') - else: - logger.error("Could not determine correct Steam library for vanilla game path.") - continue - else: - # For modlist-relative paths (Stock Game, mods, etc.), use modlist location - drive_prefix = "D:" if modlist_sdcard else "Z:" - found_stock = None - for folder in STOCK_GAME_FOLDERS: - folder_pattern = f"/{folder}" - if folder_pattern in cleaned_value: - idx = cleaned_value.index(folder_pattern) - rel_path = cleaned_value[idx:].lstrip('/') - found_stock = folder - break - if not rel_path: - mods_pattern = "/mods/" - if mods_pattern in cleaned_value: - idx = cleaned_value.index(mods_pattern) - rel_path = cleaned_value[idx:].lstrip('/') - else: - rel_path = exe_name - processed_modlist_path = PathHandler._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path) - new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/') - formatted_binary_path = PathHandler._format_binary_for_mo2(new_binary_path) - # Ensure no quotes in formatted path (binary paths should never have quotes) - if '"' in formatted_binary_path: - logger.warning(f"Formatted binary path still contains quotes, removing: {formatted_binary_path}") - formatted_binary_path = formatted_binary_path.replace('"', '') - new_binary_line = f"{index}{backslash_style}binary = {formatted_binary_path}" - logger.info(f"Updating binary path: {line.strip()} -> {new_binary_line}") - # Preserve original line ending - lines from readlines() should have newline, but ensure it - original_line = lines[i] - if original_line.endswith('\n'): - lines[i] = new_binary_line + '\n' - else: - lines[i] = new_binary_line + '\n' - binary_paths_updated += 1 - binary_paths_by_index[index] = formatted_binary_path - for j, wd_line, index, backslash_style in working_dir_lines: - if index in binary_paths_by_index: - binary_path = binary_paths_by_index[index] - wd_path = os.path.dirname(binary_path) - # Derive drive letter from binary path, not modlist location - if binary_path.startswith("D:"): - drive_prefix = "D:" - elif binary_path.startswith("Z:"): - drive_prefix = "Z:" - else: - # Fallback: use modlist location if binary path doesn't have drive letter - drive_prefix = "D:" if modlist_sdcard else "Z:" - if wd_path.startswith("D:") or wd_path.startswith("Z:"): - wd_path = wd_path[2:] - wd_path = drive_prefix + wd_path - formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path) - key_part = f"{index}{backslash_style}workingDirectory" - new_wd_line = f"{key_part} = {formatted_wd_path}" - logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}") - # Preserve original line ending - ensure newline is present - original_wd_line = lines[j] - if original_wd_line.endswith('\n'): - lines[j] = new_wd_line + '\n' - else: - lines[j] = new_wd_line + '\n' - working_dirs_updated += 1 - with open(modlist_ini_path, 'w', encoding='utf-8') as f: - f.writelines(lines) - logger.info(f"edit_binary_working_paths completed: Game path updated: {game_path_updated}, Binary paths updated: {binary_paths_updated}, Working directories updated: {working_dirs_updated}") - return True - except Exception as e: - logger.error(f"Error updating binary paths in {modlist_ini_path}: {str(e)}") - return False - - def _format_path_for_mo2(self, path: str) -> str: - """Format a path for MO2's ModOrganizer.ini file (working directories).""" - # Replace forward slashes with double backslashes - formatted = path.replace('/', '\\') - # Ensure we have a Windows drive letter format - if not re.match(r'^[A-Za-z]:', formatted): - formatted = 'D:' + formatted - # Double the backslashes for the INI file format - formatted = formatted.replace('\\', '\\\\') - return formatted - - def _format_binary_path_for_mo2(self, path_str): - """Format a binary path for MO2 config file. - - Binary paths need forward slashes (/) in the path portion. - """ - # Replace backslashes with forward slashes - return path_str.replace('\\', '/') - - def _format_working_dir_for_mo2(self, path_str): - """ - Format a working directory path for MO2 config file. - Ensures double backslashes throughout, as required by ModOrganizer.ini. - """ - import re - path = path_str.replace('/', '\\') - path = path.replace('\\', '\\\\') # Double all backslashes - # Ensure only one double backslash after drive letter - path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path) - return path - - @staticmethod - def find_vanilla_game_paths(game_names=None) -> Dict[str, Path]: - """ - For each known game, iterate all Steam libraries and look for the canonical game directory name in steamapps/common. - Returns a dict of found games and their paths. - Args: - game_names: Optional list of game names to check. If None, uses default supported games. - Returns: - Dict[str, Path]: Mapping of game name to found install Path. - """ - # Canonical game directory names (allow list for Fallout 3) - GAME_DIR_NAMES = { - "Skyrim Special Edition": ["Skyrim Special Edition"], - "Fallout 4": ["Fallout 4"], - "Fallout New Vegas": ["Fallout New Vegas"], - "Oblivion": ["Oblivion"], - "Fallout 3": ["Fallout 3", "Fallout 3 goty"] - } - if game_names is None: - game_names = list(GAME_DIR_NAMES.keys()) - all_steam_libraries = PathHandler.get_all_steam_library_paths() - logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") - found_games = {} - for game in game_names: - possible_names = GAME_DIR_NAMES.get(game, [game]) - for lib in all_steam_libraries: - for name in possible_names: - candidate = lib / "steamapps" / "common" / name - logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") - if candidate.is_dir(): - found_games[game] = candidate - logger.info(f"Found vanilla game directory for {game}: {candidate}") - break # Stop after first found location - if game in found_games: - break - return found_games - - def _detect_stock_game_path(self): - """Detects common 'Stock Game' or 'Game Root' directories within the modlist path.""" - self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...") - if not self.modlist_dir: - self.logger.error("Modlist directory not set, cannot detect stock game path.") - return False - - modlist_path = Path(self.modlist_dir) - # Always prefer 'Stock Game' if it exists, then fallback to others - preferred_order = [ - "Stock Game", - "STOCK GAME", - "Skyrim Stock", - "Stock Game Folder", - "Stock Folder", - Path("root/Skyrim Special Edition"), - "Game Root" # 'Game Root' is now last - ] - - found_path = None - for name in preferred_order: - potential_path = modlist_path / name - if potential_path.is_dir(): - found_path = str(potential_path) - self.logger.info(f"Found potential stock game directory: {found_path}") - break # Found the first match - if found_path: - self.stock_game_path = found_path - return True - else: - self.stock_game_path = None - self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.") - return True - - # --- Add robust path formatters for INI fields --- - @staticmethod - def _format_gamepath_for_mo2(path: str) -> str: - import re - path = path.replace('/', '\\') - path = re.sub(r'\\+', r'\\', path) # Collapse multiple backslashes - # Ensure only one double backslash after drive letter - path = re.sub(r'^([A-Z]:)\\+', r'\1\\', path) - return path - - @staticmethod - def _clean_malformed_binary_path(value_part: str) -> str: - """ - Clean up malformed binary paths from engine (e.g., quotes in wrong places). - Example: "Z:/path/to/game"/exe.exe -> Z:/path/to/game/exe.exe - """ - cleaned = value_part.strip() - # Remove quotes if they wrap only part of the path (malformed) - if cleaned.startswith('"') and '"' in cleaned[1:]: - # Find the closing quote - quote_end = cleaned.find('"', 1) - if quote_end > 0: - # Check if there's content after the quote (malformed) - after_quote = cleaned[quote_end + 1:].strip() - if after_quote.startswith('/') or after_quote: - # Malformed: quotes wrap only part of path - # Remove quotes and join - path_part = cleaned[1:quote_end] - remaining = after_quote.lstrip('/') - cleaned = f"{path_part}/{remaining}" if remaining else path_part - logger.info(f"Cleaned malformed binary path: {value_part} -> {cleaned}") - # Remove any remaining quotes (handles fully quoted paths too) - cleaned = cleaned.strip('"') - # Normalize slashes - cleaned = cleaned.replace('\\', '/') - return cleaned - - @staticmethod - def _format_binary_for_mo2(path: str) -> str: - import re - path = path.replace('\\', '/') - # Collapse multiple forward slashes after drive letter - path = re.sub(r'^([A-Z]:)//+', r'\1/', path) - return path - - @staticmethod - def _format_workingdir_for_mo2(path: str) -> str: - import re - path = path.replace('/', '\\') - path = path.replace('\\', '\\\\') # Double all backslashes - # Ensure only one double backslash after drive letter - path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path) - return path - -# --- End of PathHandler --- \ No newline at end of file +class PathHandler( + PathHandlerMO2Mixin, + PathHandlerDXVKMixin, + PathHandlerSteamMixin, + PathHandlerGameMixin, +): + """Handles path-related operations. MO2, DXVK, Steam, and Game logic in mixins.""" diff --git a/jackify/backend/handlers/path_handler_dxvk.py b/jackify/backend/handlers/path_handler_dxvk.py new file mode 100644 index 0000000..17601dc --- /dev/null +++ b/jackify/backend/handlers/path_handler_dxvk.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +DXVK config mixin for PathHandler. +Extracted from path_handler for file-size and domain separation. +""" + +import os +import logging +from pathlib import Path +from typing import Optional, List + +logger = logging.getLogger(__name__) + + +class PathHandlerDXVKMixin: + """Mixin providing DXVK config creation and verification.""" + + @staticmethod + def _normalize_common_library_path(steam_library: Optional[str]) -> Optional[Path]: + if not steam_library: + return None + path = Path(steam_library) + parts_lower = [part.lower() for part in path.parts] + if len(parts_lower) >= 2 and parts_lower[-2:] == ['steamapps', 'common']: + return path + if parts_lower and parts_lower[-1] == 'common': + return path + if 'steamapps' in parts_lower: + idx = parts_lower.index('steamapps') + truncated = Path(*path.parts[:idx + 1]) + return truncated / 'common' + return path / 'steamapps' / 'common' + + @staticmethod + def _build_dxvk_candidate_dirs(modlist_dir, stock_game_path, steam_library, game_var_full, vanilla_game_dir) -> List[Path]: + candidates: List[Path] = [] + seen = set() + + def add_candidate(path_obj: Optional[Path]): + if not path_obj: + return + key = path_obj.resolve() if path_obj.exists() else path_obj + if key in seen: + return + seen.add(key) + candidates.append(path_obj) + + if stock_game_path: + add_candidate(Path(stock_game_path)) + if modlist_dir: + base_path = Path(modlist_dir) + common_names = [ + "Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", + "Stock Folder", "Skyrim Stock", os.path.join("root", "Skyrim Special Edition") + ] + for name in common_names: + add_candidate(base_path / name) + steam_common = PathHandlerDXVKMixin._normalize_common_library_path(steam_library) + if steam_common and game_var_full: + add_candidate(steam_common / game_var_full) + if vanilla_game_dir: + add_candidate(Path(vanilla_game_dir)) + if modlist_dir: + add_candidate(Path(modlist_dir)) + return candidates + + @staticmethod + def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full, + vanilla_game_dir=None, stock_game_path=None) -> bool: + """Create dxvk.conf file in the appropriate location.""" + try: + logger.info("Creating dxvk.conf file...") + candidate_dirs = PathHandlerDXVKMixin._build_dxvk_candidate_dirs( + modlist_dir=modlist_dir, stock_game_path=stock_game_path, steam_library=steam_library, + game_var_full=game_var_full, vanilla_game_dir=vanilla_game_dir + ) + if not candidate_dirs: + logger.error("Could not determine location for dxvk.conf (no candidate directories found)") + return False + target_dir = None + for directory in candidate_dirs: + if directory.is_dir(): + target_dir = directory + break + if target_dir is None: + fallback_dir = Path(modlist_dir) if modlist_dir and Path(modlist_dir).is_dir() else None + if fallback_dir: + logger.warning(f"No stock/vanilla directories found; falling back to modlist directory: {fallback_dir}") + target_dir = fallback_dir + else: + logger.error("All candidate directories for dxvk.conf are missing.") + return False + dxvk_conf_path = target_dir / "dxvk.conf" + required_line = "dxvk.enableGraphicsPipelineLibrary = False" + if dxvk_conf_path.exists(): + try: + with open(dxvk_conf_path, 'r', encoding='utf-8') as f: + existing_content = f.read().strip() + existing_lines = existing_content.split('\n') if existing_content else [] + has_required_line = any(line.strip() == required_line for line in existing_lines) + if has_required_line: + logger.info("Required DXVK setting already present in existing file") + return True + updated_content = existing_content + '\n' + required_line + '\n' if existing_content else required_line + '\n' + with open(dxvk_conf_path, 'w', encoding='utf-8') as f: + f.write(updated_content) + logger.info(f"dxvk.conf updated successfully at {dxvk_conf_path}") + return True + except Exception as e: + logger.error(f"Error reading/updating existing dxvk.conf: {e}") + logger.info("Falling back to creating new dxvk.conf file") + dxvk_conf_content = required_line + '\n' + dxvk_conf_path.parent.mkdir(parents=True, exist_ok=True) + with open(dxvk_conf_path, 'w', encoding='utf-8') as f: + f.write(dxvk_conf_content) + logger.info(f"dxvk.conf created successfully at {dxvk_conf_path}") + return True + except Exception as e: + logger.error(f"Error creating dxvk.conf: {e}") + return False + + @staticmethod + def verify_dxvk_conf_exists(modlist_dir, steam_library, game_var_full, vanilla_game_dir=None, + stock_game_path=None) -> bool: + """Verify that dxvk.conf exists in at least one candidate directory and contains the required setting.""" + required_line = "dxvk.enableGraphicsPipelineLibrary = False" + candidate_dirs = PathHandlerDXVKMixin._build_dxvk_candidate_dirs( + modlist_dir=modlist_dir, stock_game_path=stock_game_path, steam_library=steam_library, + game_var_full=game_var_full, vanilla_game_dir=vanilla_game_dir + ) + for directory in candidate_dirs: + conf_path = directory / "dxvk.conf" + if conf_path.is_file(): + try: + with open(conf_path, 'r', encoding='utf-8') as f: + content = f.read() + if required_line not in content: + logger.warning(f"dxvk.conf found at {conf_path} but missing required setting. Appending now.") + with open(conf_path, 'a', encoding='utf-8') as f: + if not content.endswith('\n'): + f.write('\n') + f.write(required_line + '\n') + logger.info(f"Verified dxvk.conf at {conf_path}") + return True + except Exception as e: + logger.warning(f"Failed to verify dxvk.conf at {conf_path}: {e}") + logger.warning("dxvk.conf verification failed - file not found in any candidate directory.") + return False diff --git a/jackify/backend/handlers/path_handler_game.py b/jackify/backend/handlers/path_handler_game.py new file mode 100644 index 0000000..bc99972 --- /dev/null +++ b/jackify/backend/handlers/path_handler_game.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Game path and compatdata mixin for PathHandler. +Extracted from path_handler for file-size and domain separation. +""" + +import os +import re +import logging +from pathlib import Path +from typing import Optional, List, Dict + +logger = logging.getLogger(__name__) + + +class PathHandlerGameMixin: + """Mixin providing game install path and compatdata discovery.""" + + @classmethod + def find_compat_data(cls, appid: str) -> Optional[Path]: + """Find the compatdata directory for a given AppID.""" + if not appid: + logger.error(f"Invalid AppID provided for compatdata search: {appid}") + return None + appid_clean = appid.lstrip('-') + if not appid_clean.isdigit(): + logger.error(f"Invalid AppID provided for compatdata search: {appid}") + return None + logger.debug(f"Searching for compatdata directory for AppID: {appid}") + library_paths = cls.get_all_steam_library_paths() + if library_paths: + logger.debug(f"Checking compatdata in {len(library_paths)} Steam libraries") + for library_path in library_paths: + compatdata_base = library_path / "steamapps" / "compatdata" + if not compatdata_base.is_dir(): + logger.debug(f"Compatdata directory does not exist: {compatdata_base}") + continue + potential_path = compatdata_base / appid + if potential_path.is_dir(): + logger.info(f"Found compatdata directory: {potential_path}") + return potential_path + logger.debug(f"Compatdata for AppID {appid} not found in {compatdata_base}") + is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in library_paths) if library_paths else False + if not library_paths or is_flatpak_steam: + logger.debug("Checking fallback compatdata locations...") + if is_flatpak_steam: + fallback_locations = [ + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata", + Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata", + ] + else: + fallback_locations = [ + Path.home() / ".local/share/Steam/steamapps/compatdata", + Path.home() / ".steam/steam/steamapps/compatdata", + ] + for compatdata_base in fallback_locations: + if compatdata_base.is_dir(): + potential_path = compatdata_base / appid + if potential_path.is_dir(): + logger.warning(f"Found compatdata directory in fallback location: {potential_path}") + return potential_path + logger.warning(f"Compatdata directory for AppID {appid} not found in any Steam library or fallback location.") + return None + + @staticmethod + def detect_stock_game_path(game_type: str, steam_library: Path) -> Optional[Path]: + """Detect the stock game path for a given game type and Steam library.""" + try: + game_app_ids = { + 'skyrim': '489830', 'fallout4': '377160', 'fnv': '22380', 'oblivion': '22330' + } + if game_type not in game_app_ids: + return None + app_id = game_app_ids[game_type] + game_path = steam_library / 'steamapps' / 'common' + possible_names = { + 'skyrim': ['Skyrim Special Edition', 'Skyrim'], + 'fallout4': ['Fallout 4'], + 'fnv': ['Fallout New Vegas', 'FalloutNV'], + 'oblivion': ['Oblivion'] + } + if game_type not in possible_names: + return None + for name in possible_names[game_type]: + potential_path = game_path / name + if potential_path.exists(): + return potential_path + return None + except Exception as e: + logging.error(f"Error detecting stock game path: {e}") + return None + + @classmethod + def find_game_install_paths(cls, target_appids: Dict[str, str]) -> Dict[str, Path]: + """Find installation paths for multiple specified games using Steam app IDs.""" + library_paths = cls.get_all_steam_library_paths() + if not library_paths: + logger.warning("Failed to find any Steam library paths") + return {} + results = {} + for library_path in library_paths: + common_dir = library_path / "steamapps" / "common" + if not common_dir.is_dir(): + logger.debug(f"No 'steamapps/common' directory in library: {library_path}") + continue + try: + game_dirs = [d for d in common_dir.iterdir() if d.is_dir()] + except (PermissionError, OSError) as e: + logger.warning(f"Cannot access directory {common_dir}: {e}") + continue + for game_name, app_id in target_appids.items(): + if game_name in results: + continue + appmanifest_path = library_path / "steamapps" / f"appmanifest_{app_id}.acf" + if appmanifest_path.is_file(): + try: + with open(appmanifest_path, 'r', encoding='utf-8') as f: + content = f.read() + match = re.search(r'"installdir"\s+"([^"]+)"', content) + if match: + install_dir_name = match.group(1) + install_path = common_dir / install_dir_name + if install_path.is_dir(): + results[game_name] = install_path + logger.info(f"Found {game_name} at {install_path}") + continue + except Exception as e: + logger.warning(f"Error reading appmanifest for {game_name}: {e}") + return results + + @classmethod + def find_vanilla_game_paths(cls, game_names=None) -> Dict[str, Path]: + """For each known game, iterate all Steam libraries and look for the canonical game directory in steamapps/common.""" + GAME_DIR_NAMES = { + "Skyrim Special Edition": ["Skyrim Special Edition"], + "Fallout 4": ["Fallout 4"], + "Fallout New Vegas": ["Fallout New Vegas"], + "Oblivion": ["Oblivion"], + "Fallout 3": ["Fallout 3", "Fallout 3 goty"] + } + if game_names is None: + game_names = list(GAME_DIR_NAMES.keys()) + all_steam_libraries = cls.get_all_steam_library_paths() + logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") + found_games = {} + for game in game_names: + possible_names = GAME_DIR_NAMES.get(game, [game]) + for lib in all_steam_libraries: + for name in possible_names: + candidate = lib / "steamapps" / "common" / name + logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") + if candidate.is_dir(): + found_games[game] = candidate + logger.info(f"Found vanilla game directory for {game}: {candidate}") + break + if game in found_games: + break + return found_games + + def _detect_stock_game_path(self) -> bool: + """Detects common Stock Game or Game Root directories within the modlist path. Expects self.logger, self.modlist_dir, self.stock_game_path.""" + self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...") + if not self.modlist_dir: + self.logger.error("Modlist directory not set, cannot detect stock game path.") + return False + modlist_path = Path(self.modlist_dir) + preferred_order = [ + "Stock Game", "STOCK GAME", "Skyrim Stock", "Stock Game Folder", + "Stock Folder", Path("root/Skyrim Special Edition"), "Game Root" + ] + found_path = None + for name in preferred_order: + potential_path = modlist_path / name + if potential_path.is_dir(): + found_path = str(potential_path) + self.logger.info(f"Found potential stock game directory: {found_path}") + break + if found_path: + self.stock_game_path = found_path + return True + self.stock_game_path = None + self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.") + return True diff --git a/jackify/backend/handlers/path_handler_mo2.py b/jackify/backend/handlers/path_handler_mo2.py new file mode 100644 index 0000000..b5ccacd --- /dev/null +++ b/jackify/backend/handlers/path_handler_mo2.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +MO2 INI and path formatting mixin for PathHandler. +Extracted from path_handler for file-size and domain separation. +""" + +import os +import re +import shutil +import logging +from pathlib import Path +from typing import Optional, List +from datetime import datetime + +from .wine_utils import WineUtils + +logger = logging.getLogger(__name__) + +TARGET_EXECUTABLES_LOWER = [ + "skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", + "sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe" +] +STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"] +SDCARD_PREFIX = '/run/media/mmcblk0p1/' + + +class PathHandlerMO2Mixin: + """Mixin providing ModOrganizer.ini path updates and formatting.""" + + @staticmethod + def _strip_sdcard_path_prefix(path_obj: Path) -> str: + """Removes SD card mount prefix. Returns path as POSIX-style string.""" + path_str = path_obj.as_posix() + stripped_path = WineUtils._strip_sdcard_path(path_str) + if stripped_path != path_str: + return stripped_path.lstrip('/') if stripped_path != '/' else '.' + return path_str + + @classmethod + def update_mo2_ini_paths( + cls, + modlist_ini_path: Path, + modlist_dir_path: Path, + modlist_sdcard: bool, + steam_library_common_path: Optional[Path] = None, + basegame_dir_name: Optional[str] = None, + basegame_sdcard: bool = False + ) -> bool: + """Update gamePath, binary, and workingDirectory in ModOrganizer.ini.""" + logger.info(f"[DEBUG] update_mo2_ini_paths called with: modlist_ini_path={modlist_ini_path}, modlist_dir_path={modlist_dir_path}, modlist_sdcard={modlist_sdcard}, steam_library_common_path={steam_library_common_path}, basegame_dir_name={basegame_dir_name}, basegame_sdcard={basegame_sdcard}") + if not modlist_ini_path.is_file(): + logger.error(f"ModOrganizer.ini not found at specified path: {modlist_ini_path}") + try: + logger.warning("Creating minimal ModOrganizer.ini with [General] section.") + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.write('[General]\n') + except Exception as e: + logger.critical(f"Failed to create minimal ModOrganizer.ini: {e}") + return False + if not modlist_dir_path.is_dir(): + logger.error(f"Modlist directory not found or not a directory: {modlist_dir_path}") + all_steam_libraries = cls.get_all_steam_library_paths() + logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}") + import sys + if hasattr(sys, 'argv') and any(arg in ('--debug', '-d') for arg in sys.argv): + logger.debug(f"Detected Steam libraries: {all_steam_libraries}") + GAME_DIR_NAMES = { + "Skyrim Special Edition": "Skyrim Special Edition", + "Fallout 4": "Fallout 4", + "Fallout New Vegas": "Fallout New Vegas", + "Oblivion": "Oblivion" + } + canonical_name = GAME_DIR_NAMES.get(basegame_dir_name, basegame_dir_name) if basegame_dir_name else None + gamepath_target_dir = None + gamepath_target_is_sdcard = modlist_sdcard + checked_candidates = [] + if canonical_name: + for lib in all_steam_libraries: + candidate = lib / "steamapps" / "common" / canonical_name + checked_candidates.append(str(candidate)) + logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}") + if candidate.is_dir(): + gamepath_target_dir = candidate + logger.info(f"Found vanilla game directory: {candidate}") + break + if not gamepath_target_dir: + logger.error(f"Could not find vanilla game directory '{canonical_name}' in any Steam library. Checked: {checked_candidates}") + print("\nCould not automatically detect a Stock Game or vanilla game directory.") + print("Please enter the full path to your vanilla game directory (e.g., /path/to/Skyrim Special Edition):") + while True: + user_input = input("Game directory path: ").strip() + user_path = Path(user_input) + logger.info(f"[DEBUG] User entered: {user_input}") + if user_path.is_dir(): + exe_candidates = list(user_path.glob('*.exe')) + logger.info(f"[DEBUG] .exe files in user path: {exe_candidates}") + if exe_candidates: + gamepath_target_dir = user_path + logger.info(f"User provided valid vanilla game directory: {gamepath_target_dir}") + break + print("Directory exists but does not appear to contain the game executable. Please check and try again.") + logger.warning("User path exists but no .exe files found.") + else: + print("Directory not found. Please enter a valid path.") + logger.warning("User path does not exist.") + if not gamepath_target_dir: + logger.critical("[FATAL] Could not determine a valid target directory for gamePath. Check configuration and paths. Aborting update.") + return False + logger.debug(f"Determined gamePath target directory: {gamepath_target_dir}") + logger.debug(f"gamePath target is on SD card: {gamepath_target_is_sdcard}") + try: + logger.debug(f"Reading original INI file: {modlist_ini_path}") + with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f: + original_lines = f.readlines() + gamepath_line_num = -1 + general_section_line = -1 + for i, line in enumerate(original_lines): + if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE): + general_section_line = i + if re.match(r'^\s*gamepath\s*=\s*', line, re.IGNORECASE): + gamepath_line_num = i + break + processed_str = PathHandlerMO2Mixin._strip_sdcard_path_prefix(gamepath_target_dir) + windows_style_single = processed_str.replace('/', '\\') + gamepath_drive_letter = "D:" if gamepath_target_is_sdcard else "Z:" + formatted_gamepath = PathHandlerMO2Mixin._format_gamepath_for_mo2(f'{gamepath_drive_letter}{windows_style_single}') + new_gamepath_line = f'gamePath = @ByteArray({formatted_gamepath})\n' + if gamepath_line_num != -1: + logger.info(f"Updating existing gamePath line: {original_lines[gamepath_line_num].strip()} -> {new_gamepath_line.strip()}") + original_lines[gamepath_line_num] = new_gamepath_line + else: + insert_at = general_section_line + 1 if general_section_line != -1 else 0 + logger.info(f"Adding missing gamePath line at line {insert_at+1}: {new_gamepath_line.strip()}") + original_lines.insert(insert_at, new_gamepath_line) + TARGET_EXEC_LOWER = [ + "skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe" + ] + in_custom_exec = False + for i, line in enumerate(original_lines): + if re.match(r'^\s*\[customExecutables\]\s*$', line, re.IGNORECASE): + in_custom_exec = True + continue + if in_custom_exec and re.match(r'^\s*\[.*\]\s*$', line): + in_custom_exec = False + if in_custom_exec: + m = re.match(r'^(\d+)\\binary\s*=\s*(.*)$', line.strip(), re.IGNORECASE) + if m: + idx, old_path = m.group(1), m.group(2) + exe_name = os.path.basename(old_path).lower() + if exe_name in TARGET_EXEC_LOWER: + new_path = f'{gamepath_drive_letter}/{PathHandlerMO2Mixin._strip_sdcard_path_prefix(gamepath_target_dir)}/{exe_name}' + new_path = PathHandlerMO2Mixin._format_binary_for_mo2(new_path) + logger.info(f"Updating binary for entry {idx}: {old_path} -> {new_path}") + original_lines[i] = f'{idx}\\binary = {new_path}\n' + m_wd = re.match(r'^(\d+)\\workingDirectory\s*=\s*(.*)$', line.strip(), re.IGNORECASE) + if m_wd: + idx, old_wd = m_wd.group(1), m_wd.group(2) + new_wd = f'{gamepath_drive_letter}{windows_style_single}' + new_wd = PathHandlerMO2Mixin._format_workingdir_for_mo2(new_wd) + logger.info(f"Updating workingDirectory for entry {idx}: {old_wd} -> {new_wd}") + original_lines[i] = f'{idx}\\workingDirectory = {new_wd}\n' + backup_path = modlist_ini_path.with_suffix(f".{datetime.now().strftime('%Y%m%d_%H%M%S')}.bak") + try: + shutil.copy2(modlist_ini_path, backup_path) + logger.info(f"Backed up original INI to: {backup_path}") + except Exception as bak_err: + logger.error(f"Failed to backup original INI file: {bak_err}") + return False + try: + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(original_lines) + logger.info(f"Successfully wrote updated paths to {modlist_ini_path}") + return True + except Exception as write_err: + logger.error(f"Failed to write updated INI file {modlist_ini_path}: {write_err}", exc_info=True) + logger.error("Attempting to restore from backup...") + try: + shutil.move(backup_path, modlist_ini_path) + logger.info("Successfully restored original INI from backup.") + except Exception as restore_err: + logger.critical(f"CRITICAL FAILURE: Could not write new INI and failed to restore backup {backup_path}. Manual intervention required at {modlist_ini_path}! Error: {restore_err}") + return False + except Exception as e: + logger.error(f"An unexpected error occurred during INI path update: {e}", exc_info=True) + return False + + @staticmethod + def edit_resolution(modlist_ini, resolution) -> bool: + """Edit resolution settings in ModOrganizer.ini. resolution format: '1920x1080'.""" + try: + logger.info(f"Editing resolution settings to {resolution}...") + width, height = resolution.split('x') + with open(modlist_ini, 'r') as f: + content = f.read() + content = re.sub(r'^width\s*=\s*\d+$', f'width = {width}', content, flags=re.MULTILINE) + content = re.sub(r'^height\s*=\s*\d+$', f'height = {height}', content, flags=re.MULTILINE) + with open(modlist_ini, 'w') as f: + f.write(content) + logger.info("Resolution settings edited successfully") + return True + except Exception as e: + logger.error(f"Error editing resolution settings: {e}") + return False + + def replace_gamepath(self, modlist_ini_path: Path, new_game_path: Path, modlist_sdcard: bool = False) -> bool: + """Updates the gamePath value in ModOrganizer.ini to the specified path.""" + logger.info(f"Replacing gamePath in {modlist_ini_path} with {new_game_path}") + if not modlist_ini_path.is_file(): + logger.error(f"ModOrganizer.ini not found at: {modlist_ini_path}") + return False + try: + with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + drive_letter = "D:\\\\" if modlist_sdcard else "Z:\\\\" + processed_path = self._strip_sdcard_path_prefix(new_game_path) + windows_style = processed_path.replace('/', '\\') + windows_style_double = windows_style.replace('\\', '\\\\') + new_gamepath_line = f'gamePath=@ByteArray({drive_letter}{windows_style_double})\n' + gamepath_found = False + for i, line in enumerate(lines): + if re.match(r'^\s*gamepath\s*=.*$', line, re.IGNORECASE): + lines[i] = new_gamepath_line + gamepath_found = True + break + if not gamepath_found: + logger.error("gamePath line not found in ModOrganizer.ini. Aborting.") + return False + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info("gamePath updated successfully") + return True + except Exception as e: + logger.error(f"Error replacing gamePath: {e}") + return False + + def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool, + steam_libraries: Optional[List[Path]] = None) -> bool: + """Update all binary paths and working directories in ModOrganizer.ini. Critical, regression-prone.""" + try: + logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}") + if not modlist_ini_path.is_file(): + logger.error(f"INI file {modlist_ini_path} does not exist") + return False + with open(modlist_ini_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + existing_game_path = None + gamepath_drive_letter = None + gamepath_line_index = -1 + for i, line in enumerate(lines): + if re.match(r'^\s*gamepath\s*=.*@ByteArray\(([^)]+)\)', line, re.IGNORECASE): + match = re.search(r'@ByteArray\(([^)]+)\)', line) + if match: + raw_path = match.group(1) + gamepath_line_index = i + if raw_path.startswith('Z:'): + gamepath_drive_letter = 'Z:' + elif raw_path.startswith('D:'): + gamepath_drive_letter = 'D:' + if raw_path.startswith(('Z:', 'D:')): + linux_path = raw_path[2:].replace('\\\\', '/').replace('\\', '/') + existing_game_path = linux_path + logger.debug(f"Extracted existing gamePath: {existing_game_path}, drive letter: {gamepath_drive_letter}") + break + if modlist_sdcard and existing_game_path and existing_game_path.startswith('/run/media') and gamepath_line_index != -1: + sdcard_pattern = r'^/run/media/deck/[^/]+(/Games/.*)$' + match = re.match(sdcard_pattern, existing_game_path) + if match: + stripped_path = match.group(1) + windows_path = stripped_path.replace('/', '\\\\') + new_gamepath_value = f"D:\\\\{windows_path}" + new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n" + logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}") + lines[gamepath_line_index] = new_gamepath_line + else: + logger.warning(f"SD card path doesn't match expected pattern: {existing_game_path}") + game_path_updated = False + binary_paths_updated = 0 + working_dirs_updated = 0 + binary_lines = [] + working_dir_lines = [] + for i, line in enumerate(lines): + stripped = line.strip() + binary_match = re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE) + if binary_match: + binary_lines.append((i, stripped, binary_match.group(1), binary_match.group(2))) + wd_match = re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE) + if wd_match: + working_dir_lines.append((i, stripped, wd_match.group(1), wd_match.group(2))) + binary_paths_by_index = {} + if existing_game_path and '/steamapps/common/' in existing_game_path: + steamapps_index = existing_game_path.find('/steamapps/common/') + steam_lib_root = existing_game_path[:steamapps_index] + steam_libraries = [Path(steam_lib_root)] + logger.info(f"Using Steam library from existing gamePath: {steam_lib_root}") + elif steam_libraries is None or not steam_libraries: + steam_libraries = self.get_all_steam_library_paths() + logger.debug(f"Fallback to detected Steam libraries: {steam_libraries}") + for i, line, index, backslash_style in binary_lines: + parts = line.split('=', 1) + if len(parts) != 2: + logger.error(f"Malformed binary line: {line}") + continue + key_part, value_part = parts + cleaned_value = PathHandlerMO2Mixin._clean_malformed_binary_path(value_part) + exe_name = os.path.basename(cleaned_value).lower() + if exe_name not in TARGET_EXECUTABLES_LOWER: + logger.debug(f"Skipping non-target executable: {exe_name}") + continue + rel_path = None + if 'steamapps' in cleaned_value: + if not gamepath_drive_letter: + logger.warning("Vanilla game path detected but gamePath drive letter not found. Skipping binary path update.") + continue + is_malformed = '"' in cleaned_value or cleaned_value != value_part.strip().strip('"') + idx = cleaned_value.index('steamapps') + subpath = cleaned_value[idx:].lstrip('/') + correct_steam_lib = None + for lib in steam_libraries: + if len(subpath.split('/')) > 3 and (lib / subpath.split('/')[2] / subpath.split('/')[3]).exists(): + correct_steam_lib = lib + break + if not correct_steam_lib and steam_libraries: + correct_steam_lib = steam_libraries[0] + if correct_steam_lib: + drive_prefix = gamepath_drive_letter + if is_malformed: + logger.info(f"Fixing malformed binary path for {exe_name}: {value_part.strip()}") + new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/') + else: + logger.error("Could not determine correct Steam library for vanilla game path.") + continue + else: + drive_prefix = "D:" if modlist_sdcard else "Z:" + found_stock = None + for folder in STOCK_GAME_FOLDERS: + folder_pattern = f"/{folder}" + if folder_pattern in cleaned_value: + idx = cleaned_value.index(folder_pattern) + rel_path = cleaned_value[idx:].lstrip('/') + found_stock = folder + break + if not rel_path: + if "/mods/" in cleaned_value: + idx = cleaned_value.index("/mods/") + rel_path = cleaned_value[idx:].lstrip('/') + else: + rel_path = exe_name + processed_modlist_path = self._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path) + new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/') + formatted_binary_path = PathHandlerMO2Mixin._format_binary_for_mo2(new_binary_path) + if '"' in formatted_binary_path: + formatted_binary_path = formatted_binary_path.replace('"', '') + new_binary_line = f"{index}{backslash_style}binary = {formatted_binary_path}" + logger.info(f"Updating binary path: {line.strip()} -> {new_binary_line}") + original_line = lines[i] + lines[i] = new_binary_line + '\n' + binary_paths_updated += 1 + binary_paths_by_index[index] = formatted_binary_path + for j, wd_line, index, backslash_style in working_dir_lines: + if index in binary_paths_by_index: + binary_path = binary_paths_by_index[index] + wd_path = os.path.dirname(binary_path) + drive_prefix = "D:" if binary_path.startswith("D:") else "Z:" if binary_path.startswith("Z:") else ("D:" if modlist_sdcard else "Z:") + if wd_path.startswith("D:") or wd_path.startswith("Z:"): + wd_path = wd_path[2:] + wd_path = drive_prefix + wd_path + formatted_wd_path = PathHandlerMO2Mixin._format_workingdir_for_mo2(wd_path) + key_part = f"{index}{backslash_style}workingDirectory" + new_wd_line = f"{key_part} = {formatted_wd_path}" + logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}") + original_wd_line = lines[j] + lines[j] = new_wd_line + '\n' + working_dirs_updated += 1 + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info(f"edit_binary_working_paths completed: Game path updated: {game_path_updated}, Binary paths updated: {binary_paths_updated}, Working directories updated: {working_dirs_updated}") + return True + except Exception as e: + logger.error(f"Error updating binary paths in {modlist_ini_path}: {str(e)}") + return False + + def _format_path_for_mo2(self, path: str) -> str: + """Format a path for MO2's ModOrganizer.ini file (working directories).""" + formatted = path.replace('/', '\\') + if not re.match(r'^[A-Za-z]:', formatted): + formatted = 'D:' + formatted + formatted = formatted.replace('\\', '\\\\') + return formatted + + def _format_binary_path_for_mo2(self, path_str) -> str: + """Format a binary path for MO2 config file. Binary paths need forward slashes.""" + return path_str.replace('\\', '/') + + def _format_working_dir_for_mo2(self, path_str) -> str: + """Format a working directory path for MO2 config file. Ensures double backslashes.""" + path = path_str.replace('/', '\\') + path = path.replace('\\', '\\\\') + path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path) + return path + + @staticmethod + def _format_gamepath_for_mo2(path: str) -> str: + path = path.replace('/', '\\') + path = re.sub(r'\\+', r'\\', path) + path = re.sub(r'^([A-Z]:)\\+', r'\1\\', path) + return path + + @staticmethod + def _clean_malformed_binary_path(value_part: str) -> str: + """Clean up malformed binary paths from engine (e.g., quotes in wrong places).""" + cleaned = value_part.strip() + if cleaned.startswith('"') and '"' in cleaned[1:]: + quote_end = cleaned.find('"', 1) + if quote_end > 0: + after_quote = cleaned[quote_end + 1:].strip() + if after_quote.startswith('/') or after_quote: + path_part = cleaned[1:quote_end] + remaining = after_quote.lstrip('/') + cleaned = f"{path_part}/{remaining}" if remaining else path_part + logger.info(f"Cleaned malformed binary path: {value_part} -> {cleaned}") + cleaned = cleaned.strip('"') + cleaned = cleaned.replace('\\', '/') + return cleaned + + @staticmethod + def _format_binary_for_mo2(path: str) -> str: + path = path.replace('\\', '/') + path = re.sub(r'^([A-Z]:)//+', r'\1/', path) + return path + + @staticmethod + def _format_workingdir_for_mo2(path: str) -> str: + path = path.replace('/', '\\') + path = path.replace('\\', '\\\\') + path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path) + return path + + def set_download_directory(self, modlist_ini_path: Path, download_dir_linux_path, modlist_sdcard: bool) -> bool: + """ + Set download_directory in ModOrganizer.ini to the correct Wine path (Z: or D: for SD card). + Use only when download dir is known (e.g. Install a Modlist flow). Configure New/Existing leave as-is. + """ + if not modlist_ini_path.is_file() or not download_dir_linux_path: + return False + try: + path_obj = Path(download_dir_linux_path) + if modlist_sdcard: + drive = "D:" + path_part = self._strip_sdcard_path_prefix(path_obj) + if path_part.startswith('/'): + path_part = path_part[1:] + path_part = path_part.replace('/', '\\') + else: + drive = "Z:" + path_part = str(path_obj).replace('/', '\\').lstrip('\\') + wine_path = drive + "\\" + path_part + formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path) + with open(modlist_ini_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + in_general = False + download_line_idx = -1 + for i, line in enumerate(lines): + if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE): + in_general = True + continue + if in_general and re.match(r'^\s*\[', line): + break + if in_general and re.match(r'^\s*download_directory\s*=', line, re.IGNORECASE): + download_line_idx = i + break + new_line = f"download_directory = {formatted}\n" + if download_line_idx >= 0: + lines[download_line_idx] = new_line + else: + if in_general: + insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1) + if insert_idx >= 0: + insert_idx += 1 + while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]): + insert_idx += 1 + lines.insert(insert_idx, new_line) + else: + lines.append("[General]\n") + lines.append(new_line) + with open(modlist_ini_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info(f"Set download_directory in ModOrganizer.ini to {formatted}") + return True + except Exception as e: + logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}") + return False diff --git a/jackify/backend/handlers/path_handler_steam.py b/jackify/backend/handlers/path_handler_steam.py new file mode 100644 index 0000000..7643296 --- /dev/null +++ b/jackify/backend/handlers/path_handler_steam.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Steam path and library mixin for PathHandler. +Extracted from path_handler for file-size and domain separation. +""" + +import os +import re +import logging +from pathlib import Path +from typing import Optional, List +from datetime import datetime +import vdf + +logger = logging.getLogger(__name__) + + +class PathHandlerSteamMixin: + """Mixin providing Steam config, library, and shortcuts path discovery.""" + + @staticmethod + def find_steam_config_vdf() -> Optional[Path]: + """Finds the active Steam config.vdf file.""" + logger.debug("Searching for Steam config.vdf...") + possible_steam_paths = [ + Path.home() / ".steam/steam", + Path.home() / ".local/share/Steam", + Path.home() / ".steam/root" + ] + for steam_path in possible_steam_paths: + potential_path = steam_path / "config/config.vdf" + if potential_path.is_file(): + logger.info(f"Found config.vdf at: {potential_path}") + return potential_path + logger.warning("Could not locate Steam's config.vdf file in standard locations.") + return None + + @staticmethod + def find_steam_library() -> Optional[Path]: + """Find the primary Steam library common directory containing games.""" + logger.debug("Attempting to find Steam library...") + libraryfolders_vdf_paths = [ + os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"), + os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"), + os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"), + ] + for path in libraryfolders_vdf_paths: + if os.path.exists(path): + backup_dir = os.path.join(os.path.dirname(path), "backups") + if not os.path.exists(backup_dir): + try: + os.makedirs(backup_dir) + except OSError as e: + logger.warning(f"Could not create backup directory {backup_dir}: {e}") + timestamp = datetime.now().strftime("%Y%m%d") + backup_filename = f"libraryfolders_{timestamp}.vdf.bak" + backup_path = os.path.join(backup_dir, backup_filename) + if not os.path.exists(backup_path): + try: + import shutil + shutil.copy2(path, backup_path) + logger.debug(f"Created backup of libraryfolders.vdf at {backup_path}") + except Exception as e: + logger.error(f"Failed to create backup of libraryfolders.vdf: {e}") + libraryfolders_vdf_path_obj = None + found_path_str = None + for path_str in libraryfolders_vdf_paths: + if os.path.exists(path_str): + found_path_str = path_str + libraryfolders_vdf_path_obj = Path(path_str) + logger.debug(f"Found libraryfolders.vdf at: {path_str}") + break + if not libraryfolders_vdf_path_obj or not libraryfolders_vdf_path_obj.is_file(): + logger.warning("libraryfolders.vdf not found or is not a file. Cannot automatically detect Steam Library.") + return None + library_paths = [] + try: + with open(found_path_str, 'r') as f: + content = f.read() + path_matches = re.finditer(r'"path"\s*"([^"]+)"', content) + for match in path_matches: + library_path_str = match.group(1).replace('\\\\', '\\') + common_path = os.path.join(library_path_str, "steamapps", "common") + if os.path.isdir(common_path): + library_paths.append(Path(common_path)) + logger.debug(f"Found potential common path: {common_path}") + else: + logger.debug(f"Skipping non-existent common path derived from VDF: {common_path}") + logger.debug(f"Found {len(library_paths)} valid library common paths from VDF.") + if library_paths: + logger.info(f"Using Steam library common path: {library_paths[0]}") + return library_paths[0] + logger.debug("No valid common paths found in VDF, checking default location...") + default_common_path = Path.home() / ".steam/steam/steamapps/common" + if default_common_path.is_dir(): + logger.info(f"Using default Steam library common path: {default_common_path}") + return default_common_path + default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common" + if default_common_path_local.is_dir(): + logger.info(f"Using default local Steam library common path: {default_common_path_local}") + return default_common_path_local + logger.error("No valid Steam library common path found in VDF or default locations.") + return None + except Exception as e: + logger.error(f"Error parsing libraryfolders.vdf or finding Steam library: {e}", exc_info=True) + return None + + @staticmethod + def get_steam_library_path(steam_path: str) -> Optional[str]: + """Get the Steam library path from libraryfolders.vdf.""" + try: + libraryfolders_path = os.path.join(steam_path, 'steamapps', 'libraryfolders.vdf') + if not os.path.exists(libraryfolders_path): + return None + with open(libraryfolders_path, 'r', encoding='utf-8') as f: + content = f.read() + libraries = {} + current_library = None + for line in content.split('\n'): + line = line.strip() + if line.startswith('"path"'): + current_library = line.split('"')[3].replace('\\\\', '\\') + elif line.startswith('"apps"') and current_library: + libraries[current_library] = True + for library_path in libraries: + if os.path.exists(library_path): + return library_path + return None + except Exception as e: + logger.error(f"Error getting Steam library path: {str(e)}") + return None + + @staticmethod + def get_mountpoint(path) -> Optional[str]: + """Return the mount point for the given path (Linux). Used for STEAM_COMPAT_MOUNTS.""" + if not path: + return None + try: + p = Path(path).resolve() + if not p.exists(): + p = p.parent + while p != p.parent: + if os.path.ismount(p): + return str(p) + p = p.parent + return str(p) + except (OSError, RuntimeError) as e: + logger.debug(f"Could not get mountpoint for {path}: {e}") + return None + + def get_steam_compat_mount_paths(self, install_dir=None, download_dir=None) -> List[str]: + """ + Build list of mount paths for STEAM_COMPAT_MOUNTS: other Steam library roots plus + mountpoints of install_dir and download_dir so MO2 can access game and downloads. + """ + seen = set() + result = [] + main_steam_lib_path_obj = self.find_steam_library() + if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": + main_steam_lib_path = main_steam_lib_path_obj.parent.parent + else: + main_steam_lib_path = main_steam_lib_path_obj + main_resolved = str(main_steam_lib_path.resolve()) if main_steam_lib_path else None + for lib_path in self.get_all_steam_library_paths(): + try: + r = str(lib_path.resolve()) + except (OSError, RuntimeError): + r = str(lib_path) + if r not in seen and r != main_resolved: + seen.add(r) + result.append(r) + for extra in (install_dir, download_dir): + mp = self.get_mountpoint(extra) if extra else None + if mp and mp not in seen: + seen.add(mp) + result.append(mp) + return result + + @staticmethod + def get_all_steam_library_paths() -> List[Path]: + """Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak).""" + logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...") + vdf_paths = [ + Path.home() / ".steam/steam/config/libraryfolders.vdf", + Path.home() / ".local/share/Steam/config/libraryfolders.vdf", + Path.home() / ".steam/root/config/libraryfolders.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", + ] + library_paths = set() + for vdf_path in vdf_paths: + if vdf_path.is_file(): + logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}") + try: + with open(vdf_path, 'r', encoding='utf-8') as f: + data = vdf.load(f) + libraryfolders = data.get('libraryfolders', {}) + for key, lib_data in libraryfolders.items(): + if isinstance(lib_data, dict) and 'path' in lib_data: + lib_path = Path(lib_data['path']) + try: + resolved_path = lib_path.resolve() + library_paths.add(resolved_path) + logger.debug(f"[DEBUG] Found library path: {resolved_path}") + except (OSError, RuntimeError) as resolve_err: + logger.warning(f"[DEBUG] Could not resolve {lib_path}, using as-is: {resolve_err}") + library_paths.add(lib_path) + except Exception as e: + logger.error(f"[DEBUG] Failed to parse {vdf_path}: {e}") + logger.info(f"[DEBUG] All detected Steam libraries: {library_paths}") + return list(library_paths) + + def _find_shortcuts_vdf(self) -> Optional[str]: + """Helper to find the active shortcuts.vdf file for the current Steam user.""" + try: + from jackify.backend.services.native_steam_service import NativeSteamService + steam_service = NativeSteamService() + shortcuts_path = steam_service.get_shortcuts_vdf_path() + if shortcuts_path: + logger.info(f"Found shortcuts.vdf using multi-user detection: {shortcuts_path}") + return str(shortcuts_path) + logger.error("Could not determine shortcuts.vdf path using multi-user detection") + return None + except Exception as e: + logger.error(f"Error using multi-user detection for shortcuts.vdf: {e}") + return None diff --git a/jackify/backend/handlers/progress_parser.py b/jackify/backend/handlers/progress_parser.py index ab781a8..7651623 100644 --- a/jackify/backend/handlers/progress_parser.py +++ b/jackify/backend/handlers/progress_parser.py @@ -7,8 +7,7 @@ This is an R&D implementation - experimental and subject to change. import os import re -import time -from typing import Optional, List, Tuple +from typing import Optional, Tuple from dataclasses import dataclass from jackify.shared.progress_models import ( @@ -18,6 +17,12 @@ from jackify.shared.progress_models import ( OperationType ) +from .progress_parser_phase import ProgressParserPhaseMixin +from .progress_parser_files import ProgressParserFilesMixin +from .progress_parser_extraction import ProgressParserExtractionMixin +from .progress_state_processing import ProgressStateProcessingMixin +from .progress_state_metrics import ProgressStateMetricsMixin + @dataclass class ParsedLine: @@ -35,7 +40,7 @@ class ParsedLine: message: str = "" -class ProgressParser: +class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, ProgressParserExtractionMixin): """ Parses jackify-engine output to extract progress information. @@ -76,7 +81,7 @@ class ProgressParser: ] # Wabbajack status update format: "[12/14] StatusText (current/total)" - # This is the primary format we should match + # Primary format self.wabbajack_status_pattern = re.compile( r'\[(\d+)/(\d+)\]\s+(.+?)\s+\(([^)]+)\)', re.IGNORECASE @@ -335,501 +340,9 @@ class ProgressParser: result.has_progress = True return result - - def _extract_phase(self, line: str) -> Optional[Tuple[InstallationPhase, str]]: - """Extract phase information from line.""" - # Check for section headers like "=== Installing files ===" - section_match = re.search(r'===?\s*(.+?)\s*===?', line) - if section_match: - section_name = section_match.group(1).strip().lower() - phase = self._map_section_to_phase(section_name) - return (phase, section_match.group(1).strip()) - - # Check for action-based phase indicators - action_match = re.search(r'\[.*?\]\s*(Installing|Downloading|Extracting|Validating|Processing|Checking existing)', line, re.IGNORECASE) - if action_match: - action = action_match.group(1).lower() - phase = self._map_action_to_phase(action) - return (phase, action_match.group(1)) - - return None - - def _extract_phase_from_section(self, match: re.Match) -> Optional[Tuple[InstallationPhase, str]]: - """Extract phase from section header match.""" - section_name = match.group(1).strip().lower() - phase = self._map_section_to_phase(section_name) - return (phase, match.group(1).strip()) - - def _extract_phase_from_action(self, match: re.Match) -> Optional[Tuple[InstallationPhase, str]]: - """Extract phase from action match.""" - action = match.group(1).lower() - phase = self._map_action_to_phase(action) - return (phase, match.group(1)) - - def _map_section_to_phase(self, section_name: str) -> InstallationPhase: - """Map section name to InstallationPhase enum.""" - section_lower = section_name.lower() - if 'download' in section_lower: - return InstallationPhase.DOWNLOAD - elif 'extract' in section_lower: - return InstallationPhase.EXTRACT - elif 'validate' in section_lower or 'verif' in section_lower: - return InstallationPhase.VALIDATE - elif 'install' 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: - return InstallationPhase.INITIALIZATION - else: - return InstallationPhase.UNKNOWN - - def _map_action_to_phase(self, action: str) -> InstallationPhase: - """Map action word to InstallationPhase enum.""" - action_lower = action.lower() - if 'download' in action_lower: - return InstallationPhase.DOWNLOAD - elif 'extract' in action_lower: - return InstallationPhase.EXTRACT - elif 'validat' in action_lower or 'checking' in action_lower: - return InstallationPhase.VALIDATE - elif 'install' in action_lower: - return InstallationPhase.INSTALL - else: - return InstallationPhase.UNKNOWN - - def _extract_file_progress(self, line: str) -> Optional[FileProgress]: - """Extract file-level progress information.""" - # CRITICAL: Defensive checks to prevent segfault in regex engine - # Segfaults happen in C code before Python exceptions, so we must validate input first - if not line or not isinstance(line, str): - return None - # Limit line length to prevent stack overflow in regex (10KB should be more than enough) - if len(line) > 10000: - return None - # Check for null bytes or other problematic characters that could corrupt regex - if '\x00' in line: - # Replace null bytes to prevent corruption - line = line.replace('\x00', '') - - # PRIORITY: Check for [FILE_PROGRESS] prefix first (new engine format) - # Format: [FILE_PROGRESS] Downloading: filename.zip (20.0%) [3.7MB/s] - # Updated format: [FILE_PROGRESS] (Downloading|Extracting|Installing|Converting|Completed|etc): filename.zip (20.0%) [3.7MB/s] (current/total) - # Speed bracket is optional to handle cases where speed may not be present - # Counter (current/total) is optional and used for Extracting and Installing phases - file_progress_match = re.search( - r'\[FILE_PROGRESS\]\s+(Downloading|Extracting|Validating|Installing|Converting|Building|Writing|Verifying|Completed|Checking existing):\s+(.+?)\s+\((\d+(?:\.\d+)?)%\)\s*(?:\[(.+?)\])?\s*(?:\((\d+)/(\d+)\))?', - line, - re.IGNORECASE - ) - if file_progress_match: - operation_str = file_progress_match.group(1).strip() - filename = file_progress_match.group(2).strip() - percent = float(file_progress_match.group(3)) - speed_str = file_progress_match.group(4).strip() if file_progress_match.group(4) else None - # Extract counter if present (group 5 and 6) - counter_current = int(file_progress_match.group(5)) if file_progress_match.group(5) else None - counter_total = int(file_progress_match.group(6)) if file_progress_match.group(6) else None - - # Map operation string first (needed for hidden progress items) - operation_map = { - 'downloading': OperationType.DOWNLOAD, - 'extracting': OperationType.EXTRACT, - 'validating': OperationType.VALIDATE, - 'installing': OperationType.INSTALL, - 'building': OperationType.INSTALL, # BSA building - 'writing': OperationType.INSTALL, # BSA writing - 'verifying': OperationType.VALIDATE, # BSA verification - 'checking existing': OperationType.VALIDATE, # Resume verification - 'converting': OperationType.INSTALL, - 'compiling': OperationType.INSTALL, - 'hashing': OperationType.VALIDATE, - 'completed': OperationType.UNKNOWN, - } - operation = operation_map.get(operation_str.lower(), OperationType.UNKNOWN) - - # If we have counter info but file shouldn't be displayed, create a minimal FileProgress - # just to carry the counter information (for extraction/install summary display) - if counter_current and counter_total and not self._should_display_file(filename): - # Create minimal file progress that won't be shown in activity window - # but will carry counter info for summary widget - file_progress = FileProgress( - filename="__phase_progress__", # Dummy name - operation=operation, # Use detected operation - percent=percent, - speed=-1.0 # No speed for summary - ) - file_progress._file_counter = (counter_current, counter_total) - file_progress._hidden = True # Mark as hidden so it doesn't show in activity window - return file_progress - - if not self._should_display_file(filename): - return None - - # Operation already mapped above (line 352) - # If operation is "Completed", ensure percent is 100% - if operation_str.lower() == 'completed': - percent = 100.0 - - # Parse speed if available - # Use -1 as sentinel to indicate "no speed provided by engine" - speed = -1.0 - if speed_str: - speed = self._parse_speed_from_string(speed_str) - file_progress = FileProgress( - filename=filename, - operation=operation, - percent=percent, - speed=speed - ) - size_info = self._extract_data_info(line) - if size_info: - file_progress.current_size, file_progress.total_size = size_info - - # Store counter in a temporary attribute we can access later - # Distinguish between texture conversion, BSA building, and install counters - if counter_current is not None and counter_total is not None: - if operation_str.lower() == 'converting': - # This is a texture conversion counter - file_progress._texture_counter = (counter_current, counter_total) - elif operation_str.lower() == 'building': - # This is a BSA building counter - file_progress._bsa_counter = (counter_current, counter_total) - else: - # This is an install/extract counter - file_progress._file_counter = (counter_current, counter_total) - - return file_progress - - # Skip lines that are clearly status messages, not file progress - if re.search(r'\[.*?\]\s*(?:Downloading|Installing|Extracting)\s+(?:Mod|Files|Archives)', line, re.IGNORECASE): - return None - - # Pattern 1: "Installing: filename.7z (42%)" or "Downloading: filename.7z (42%)" - match = re.search(r'(?:Installing|Downloading|Extracting|Validating):\s*(.+?)\s*\((\d+(?:\.\d+)?)%\)', line, re.IGNORECASE) - if match: - filename = match.group(1).strip() - percent = float(match.group(2)) - operation = self._detect_operation_from_line(line) - file_progress = FileProgress( - filename=filename, - operation=operation, - percent=percent - ) - size_info = self._extract_data_info(line) - if size_info: - file_progress.current_size, file_progress.total_size = size_info - return file_progress - - # Pattern 2: "filename.7z: 42%" or "filename.7z - 42%" or "filename.wabbajack: 42%" - match = re.search(r'(.+?\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[:-]\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE) - if match: - filename = match.group(1).strip() - percent = float(match.group(2)) - operation = self._detect_operation_from_line(line) - file_progress = FileProgress( - filename=filename, - operation=operation, - percent=percent - ) - size_info = self._extract_data_info(line) - if size_info: - file_progress.current_size, file_progress.total_size = size_info - return file_progress - - # Pattern 3: "filename.7z [45.2MB/s]" or "filename.7z @ 45.2MB/s" or "filename.wabbajack [45.2MB/s]" - match = re.search(r'(.+?\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[\[@]\s*([^\]]+)\]?', line, re.IGNORECASE) - if match: - filename = match.group(1).strip() - speed_str = match.group(2).strip().rstrip(']') - speed = self._parse_speed(speed_str) - operation = self._detect_operation_from_line(line) - file_progress = FileProgress( - filename=filename, - operation=operation, - speed=speed - ) - size_info = self._extract_data_info(line) - if size_info: - file_progress.current_size, file_progress.total_size = size_info - return file_progress - - # Pattern 4: Lines that look like filenames with progress info - # Match lines that contain a filename-like pattern followed by percentage - # This catches formats like "Enderal Remastered Armory - Standard-490-1-2-0-1669565635.7z at 42%" - # or "modlist.wabbajack at 42%" - match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s+(?:at|@|:|-)?\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE) - if match: - filename = match.group(1).strip() - percent = float(match.group(2)) - operation = self._detect_operation_from_line(line) - return FileProgress( - filename=filename, - operation=operation, - percent=percent - ) - - # Pattern 5: Filename with size info that might indicate progress - # "filename.7z (1.2MB/5.4MB)" or "filename.7z 1.2MB of 5.4MB" or "filename.wabbajack (1.2MB/5.4MB)" - match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[\(]?\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/?\s*of\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', line, re.IGNORECASE) - if match: - filename = match.group(1).strip() - current_val = float(match.group(2)) - current_unit = match.group(3).upper() - total_val = float(match.group(4)) - total_unit = match.group(5).upper() - current_bytes = self._convert_to_bytes(current_val, current_unit) - total_bytes = self._convert_to_bytes(total_val, total_unit) - percent = (current_bytes / total_bytes * 100.0) if total_bytes > 0 else 0.0 - operation = self._detect_operation_from_line(line) - return FileProgress( - filename=filename, - operation=operation, - percent=percent, - current_size=current_bytes, - total_size=total_bytes - ) - - # Pattern 6: Filename with speed info - # "filename.7z downloading at 45.2MB/s" or "filename.wabbajack downloading at 45.2MB/s" - match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s+(?:downloading|extracting|validating|installing)\s+at\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE) - if match: - filename = match.group(1).strip() - speed_val = float(match.group(2)) - speed_unit = match.group(3).upper() - speed = self._convert_to_bytes(speed_val, speed_unit) - operation = self._detect_operation_from_line(line) - return FileProgress( - filename=filename, - operation=operation, - speed=speed - ) - - return None - - def _parse_file_with_percent(self, match: re.Match) -> Optional[FileProgress]: - """Parse file progress from percentage match.""" - filename = match.group(1).strip() - percent = float(match.group(2)) - operation = OperationType.UNKNOWN - # Try to detect operation from context - return FileProgress( - filename=filename, - operation=operation, - percent=percent - ) - - def _parse_file_with_speed(self, match: re.Match) -> Optional[FileProgress]: - """Parse file progress from speed match.""" - filename = match.group(1).strip() - speed_str = match.group(2).strip() - speed = self._parse_speed(speed_str) - operation = OperationType.UNKNOWN - return FileProgress( - filename=filename, - operation=operation, - speed=speed - ) - - def _detect_operation_from_line(self, line: str) -> OperationType: - """Detect operation type from line content.""" - line_lower = line.lower() - if 'download' in line_lower: - return OperationType.DOWNLOAD - elif 'extract' in line_lower: - return OperationType.EXTRACT - elif 'validat' in line_lower: - return OperationType.VALIDATE - elif 'install' in line_lower or 'build' in line_lower or 'convert' in line_lower: - return OperationType.INSTALL - else: - return OperationType.UNKNOWN - - def _extract_overall_progress(self, line: str) -> Optional[float]: - """Extract overall progress percentage.""" - # Pattern: "Progress: 85%" or "85%" - match = re.search(r'(?:Progress|Overall):\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE) - if match: - return float(match.group(1)) - - # Pattern: "85% complete" - match = re.search(r'^(\d+(?:\.\d+)?)%\s*(?:complete|done|progress)', line, re.IGNORECASE) - if match: - return float(match.group(1)) - - return None - - def _extract_step_info(self, line: str) -> Optional[Tuple[int, int]]: - """Extract step information like [12/14].""" - # Try Wabbajack status format first: "[12/14] StatusText (data)" - match = self.wabbajack_status_pattern.search(line) - if match: - current = int(match.group(1)) - total = int(match.group(2)) - return (current, total) - - # Fallback to simple [12/14] pattern - match = re.search(r'\[(\d+)/(\d+)\]', line) - if match: - current = int(match.group(1)) - total = int(match.group(2)) - return (current, total) - return None - - def _extract_data_info(self, line: str) -> Optional[Tuple[int, int]]: - """Extract data size information like 1.1GB/56.3GB.""" - # Pattern: "1.1GB/56.3GB" or "(1.1GB/56.3GB)" - match = re.search(r'\(?(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\)?', line, re.IGNORECASE) - if match: - current_val = float(match.group(1)) - current_unit = match.group(2).upper() - total_val = float(match.group(3)) - total_unit = match.group(4).upper() - - current_bytes = self._convert_to_bytes(current_val, current_unit) - total_bytes = self._convert_to_bytes(total_val, total_unit) - - return (current_bytes, total_bytes) - - return None - - def _parse_data_string(self, data_str: str) -> Optional[Tuple[int, int]]: - """Parse data string like '1.1GB/56.3GB' or '1234/5678'.""" - # Try size format first: "1.1GB/56.3GB" - match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', data_str, re.IGNORECASE) - if match: - current_val = float(match.group(1)) - current_unit = match.group(2).upper() - total_val = float(match.group(3)) - total_unit = match.group(4).upper() - - current_bytes = self._convert_to_bytes(current_val, current_unit) - total_bytes = self._convert_to_bytes(total_val, total_unit) - - return (current_bytes, total_bytes) - - # Try numeric format: "1234/5678" (might be file counts or bytes) - match = re.search(r'(\d+)\s*/\s*(\d+)', data_str) - if match: - current = int(match.group(1)) - total = int(match.group(2)) - # Assume bytes if values are large, otherwise might be file counts - # For now, return as-is and let caller decide - return (current, total) - - return None - - def _extract_phase_from_text(self, text: str) -> Optional[Tuple[InstallationPhase, str]]: - """Extract phase from status text like 'Installing files'.""" - text_lower = text.lower() - - # Map common Wabbajack status texts to phases - if 'download' in text_lower: - return (InstallationPhase.DOWNLOAD, text) - elif 'extract' in text_lower: - return (InstallationPhase.EXTRACT, text) - elif 'validat' in text_lower or 'hash' in text_lower: - return (InstallationPhase.VALIDATE, text) - elif 'install' in text_lower: - return (InstallationPhase.INSTALL, text) - elif 'prepar' in text_lower or 'configur' in text_lower: - return (InstallationPhase.INITIALIZATION, text) - elif 'finish' in text_lower or 'complet' in text_lower: - return (InstallationPhase.FINALIZE, text) - else: - return (InstallationPhase.UNKNOWN, text) - - def _extract_speed_info(self, line: str) -> Optional[Tuple[str, float]]: - """Extract speed information.""" - # Pattern: "267.3MB/s" or "at 45.2 MB/s" or "- 6.8MB/s" - # Try pattern with dash separator first (common in status lines) - match = re.search(r'-\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE) - if match: - speed_val = float(match.group(1)) - speed_unit = match.group(2).upper() - speed_bytes = self._convert_to_bytes(speed_val, speed_unit) - - # Try to detect operation type from context - operation = "unknown" - line_lower = line.lower() - if 'download' in line_lower: - operation = "download" - elif 'extract' in line_lower: - operation = "extract" - elif 'validat' in line_lower or 'hash' in line_lower: - operation = "validate" - - return (operation, speed_bytes) - - # Pattern: "at 267.3MB/s" or "speed: 45.2 MB/s" - match = re.search(r'(?:at|speed:?)\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE) - if match: - speed_val = float(match.group(1)) - speed_unit = match.group(2).upper() - speed_bytes = self._convert_to_bytes(speed_val, speed_unit) - - # Try to detect operation type from context - operation = "unknown" - line_lower = line.lower() - if 'download' in line_lower: - operation = "download" - elif 'extract' in line_lower: - operation = "extract" - elif 'validat' in line_lower: - operation = "validate" - - return (operation, speed_bytes) - - return None - - def _parse_speed(self, speed_str: str) -> float: - """Parse speed string to bytes per second.""" - match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', speed_str, re.IGNORECASE) - if match: - value = float(match.group(1)) - unit = match.group(2).upper() - return self._convert_to_bytes(value, unit) - return 0.0 - - def _parse_speed_from_string(self, speed_str: str) -> float: - """Parse speed string like '6.8MB/s' to bytes per second.""" - # Handle format: "6.8MB/s" or "6.8 MB/s" or "6.8MB/sec" - match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s(?:ec)?', speed_str, re.IGNORECASE) - if match: - value = float(match.group(1)) - unit = match.group(2).upper() - return self._convert_to_bytes(value, unit) - return 0.0 - - def _extract_completed_file(self, line: str) -> Optional[str]: - """Extract filename from completion messages like 'Finished downloading filename.7z'.""" - # Pattern: "Finished downloading filename.7z. Hash: ..." - # or "Finished downloading filename.7z" - match = re.search( - r'Finished\s+(?:downloading|extracting|validating|installing)\s+(.+?)(?:\.\s|\.$|\s+Hash:)', - line, - re.IGNORECASE - ) - if match: - filename = match.group(1).strip() - # Remove any trailing dots or whitespace - filename = filename.rstrip('. ') - return filename - return None - - def _convert_to_bytes(self, value: float, unit: str) -> int: - """Convert value with unit to bytes.""" - multipliers = { - 'B': 1, - 'KB': 1024, - 'MB': 1024 * 1024, - 'GB': 1024 * 1024 * 1024, - 'TB': 1024 * 1024 * 1024 * 1024 - } - return int(value * multipliers.get(unit, 1)) -class ProgressStateManager: +class ProgressStateManager(ProgressStateProcessingMixin, ProgressStateMetricsMixin): """ Manages installation progress state by accumulating parsed information. @@ -849,302 +362,12 @@ class ProgressStateManager: self._download_files_seen = {} # filename -> (total_size, max_current_size) self._download_total_bytes = 0 # Running total of all file sizes seen self._download_processed_bytes = 0 # Running total of bytes processed - - def process_line(self, line: str) -> bool: - """ - Process a line of output and update state. - - Args: - line: Raw line from jackify-engine output - - Returns: - True if state was updated, False otherwise - """ - parsed = self.parser.parse_line(line) - - if not parsed.has_progress: - return False - - updated = False - - # Update phase - detect phase changes to reset stale data - phase_changed = False - if parsed.phase and parsed.phase != self.state.phase: - # Phase is changing - selectively reset stale data from previous phase - previous_phase = self.state.phase - - # Reset download tracking when leaving download phase - if previous_phase == InstallationPhase.DOWNLOAD: - self._download_files_seen = {} - self._download_total_bytes = 0 - self._download_processed_bytes = 0 - - # Only reset data sizes when transitioning FROM VALIDATE phase - # Validation phase data sizes are from .wabbajack file and shouldn't persist - if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info: - # Clear old validation data sizes (e.g., 339.0MB/339.1MB from .wabbajack) - if self.state.data_total > 0: - self.state.data_processed = 0 - self.state.data_total = 0 - updated = True - - # Clear "Validating" phase name immediately when transitioning away from VALIDATE - # This ensures stale phase name doesn't persist into download phase - if previous_phase == InstallationPhase.VALIDATE: - # Transitioning away from VALIDATE - always clear old phase_name - # The new phase will either provide a new phase_name or get_phase_label() will derive it - if self.state.phase_name and 'validat' in self.state.phase_name.lower(): - self.state.phase_name = "" - updated = True - - phase_changed = True - self._previous_phase = self.state.phase - self.state.phase = parsed.phase - updated = True - elif parsed.phase: - self.state.phase = parsed.phase - updated = True - - # Update phase name - clear old phase name if phase changed but no new phase_name provided - if parsed.phase_name: - self.state.phase_name = parsed.phase_name - updated = True - elif phase_changed: - # Phase changed but no new phase_name - clear old phase_name to prevent stale display - # This ensures "Validating" doesn't stick when we transition to DOWNLOAD - if self.state.phase_name and self.state.phase != InstallationPhase.VALIDATE: - # Only clear if we're not in VALIDATE phase anymore - self.state.phase_name = "" - updated = True - - # CRITICAL: Always clear "Validating" phase_name if we're in DOWNLOAD phase - # This catches cases where phase didn't change but we're downloading, or phase_name got set again - if self.state.phase == InstallationPhase.DOWNLOAD: - if self.state.phase_name and 'validat' in self.state.phase_name.lower(): - self.state.phase_name = "" - updated = True - - # Update overall progress - if parsed.overall_percent is not None: - self.state.overall_percent = parsed.overall_percent - updated = True - - # Update step information - if parsed.step_info: - self.state.phase_step, self.state.phase_max_steps = parsed.step_info - updated = True - - # Update data information - if parsed.data_info: - self.state.data_processed, self.state.data_total = parsed.data_info - # Calculate overall percent from data if not already set - if self.state.data_total > 0 and self.state.overall_percent == 0.0: - self.state.overall_percent = (self.state.data_processed / self.state.data_total) * 100.0 - updated = True + self._has_real_wabbajack = False - # Update file counter (for Extracting phase) - if parsed.file_counter: - self.state.phase_step, self.state.phase_max_steps = parsed.file_counter - updated = True - - # Update file progress - if parsed.file_progress: - # Skip hidden files (used only for carrying counter info) - if hasattr(parsed.file_progress, '_hidden') and parsed.file_progress._hidden: - # Counter already extracted above, don't add to active files - return updated - - # Update texture conversion counter at state level if this is a texture conversion - if hasattr(parsed.file_progress, '_texture_counter'): - tex_current, tex_total = parsed.file_progress._texture_counter - self.state.texture_conversion_current = tex_current - self.state.texture_conversion_total = tex_total - updated = True - - # Update BSA building counter at state level if this is a BSA building operation - if hasattr(parsed.file_progress, '_bsa_counter'): - bsa_current, bsa_total = parsed.file_progress._bsa_counter - self.state.bsa_building_current = bsa_current - self.state.bsa_building_total = bsa_total - updated = True - - if parsed.file_progress.filename.lower().endswith('.wabbajack'): - self._wabbajack_entry_name = parsed.file_progress.filename - self._remove_synthetic_wabbajack() - # Mark that we have a real .wabbajack entry to prevent synthetic ones - self._has_real_wabbajack = True - else: - # CRITICAL: If we get a real archive file (not .wabbajack), remove all .wabbajack entries - # This ensures .wabbajack entries disappear as soon as archive downloads start - from jackify.shared.progress_models import OperationType - if parsed.file_progress.operation == OperationType.DOWNLOAD: - self._remove_all_wabbajack_entries() - self._has_real_wabbajack = True # Prevent re-adding - - # Track download totals from all files seen during download phase - # This allows us to calculate overall remaining/ETA even when engine doesn't report data_total - from jackify.shared.progress_models import OperationType - if self.state.phase == InstallationPhase.DOWNLOAD and parsed.file_progress.operation == OperationType.DOWNLOAD: - filename = parsed.file_progress.filename - total_size = parsed.file_progress.total_size or 0 - current_size = parsed.file_progress.current_size or 0 - - # Track this file's max size and current progress - if filename not in self._download_files_seen: - # New file - add its total size to our running total - if total_size > 0: - self._download_total_bytes += total_size - self._download_files_seen[filename] = (total_size, current_size) - self._download_processed_bytes += current_size - else: - # Existing file - update current size and track max - old_total, old_current = self._download_files_seen[filename] - # If total_size increased (file size discovered), update our total - if total_size > old_total: - self._download_total_bytes += (total_size - old_total) - # Update processed bytes (only count increases) - if current_size > old_current: - self._download_processed_bytes += (current_size - old_current) - self._download_files_seen[filename] = (max(old_total, total_size), current_size) - - # If engine didn't provide data_total, use our aggregated total - if self.state.data_total == 0 and self._download_total_bytes > 0: - self.state.data_total = self._download_total_bytes - self.state.data_processed = self._download_processed_bytes - updated = True - - self._augment_file_metrics(parsed.file_progress) - # Don't add files that are already at 100% unless they're being updated - # This prevents re-adding completed files - existing_file = None - for f in self.state.active_files: - if f.filename == parsed.file_progress.filename: - existing_file = f - break - - # Don't add files that are already at 100% when first detected (downloads that already exist) - # This prevents showing 1600 files instantly at 100% in the activity window - if parsed.file_progress.percent >= 100.0 and not existing_file: - # File completed before we ever saw it (already existed on disk) - # Don't clutter the UI by showing it - # Just update the phase step counts if applicable - updated = True - elif parsed.file_progress.percent >= 100.0: - # File reached 100% that we were already tracking - show completion briefly - parsed.file_progress.percent = 100.0 - parsed.file_progress.last_update = time.time() # Set timestamp to NOW for minimum display - self.state.add_file(parsed.file_progress) - updated = True - else: - # File still in progress, add/update it normally - self.state.add_file(parsed.file_progress) - updated = True - elif parsed.data_info: - # CRITICAL: Remove .wabbajack entries as soon as archive download phase starts - # Check if we're in "Downloading Mod Archives" phase or have real archive files downloading - phase_name_lower = (parsed.phase_name or "").lower() - message_lower = (parsed.message or "").lower() - is_archive_phase = ( - 'mod archives' in phase_name_lower or - 'downloading mod archives' in message_lower or - (parsed.phase == InstallationPhase.DOWNLOAD and self._has_real_download_activity()) - ) - - if is_archive_phase: - # Archive download phase has started - remove all .wabbajack entries immediately - self._remove_all_wabbajack_entries() - self._has_real_wabbajack = True # Prevent re-adding - - # Only create synthetic .wabbajack entry if we don't already have a real one - if not getattr(self, '_has_real_wabbajack', False): - if self._maybe_add_wabbajack_progress(parsed): - updated = True - - # Handle file completion messages - if parsed.completed_filename: - if not self.parser.should_display_file(parsed.completed_filename): - parsed.completed_filename = None - - if parsed.completed_filename: - # Track completed files in download totals - if self.state.phase == InstallationPhase.DOWNLOAD: - filename = parsed.completed_filename - # If we were tracking this file, mark it as complete (100% of total) - if filename in self._download_files_seen: - old_total, old_current = self._download_files_seen[filename] - # Ensure processed bytes equals total for completed file - if old_current < old_total: - self._download_processed_bytes += (old_total - old_current) - self._download_files_seen[filename] = (old_total, old_total) - # Update state if needed - if self.state.data_total == 0 and self._download_total_bytes > 0: - self.state.data_total = self._download_total_bytes - self.state.data_processed = self._download_processed_bytes - updated = True - - # Try to find existing file in the list - found_existing = False - for file_prog in self.state.active_files: - # Match by exact filename or by filename without path - filename_match = ( - file_prog.filename == parsed.completed_filename or - file_prog.filename.endswith(parsed.completed_filename) or - parsed.completed_filename in file_prog.filename - ) - if filename_match: - file_prog.percent = 100.0 - file_prog.last_update = time.time() # Update timestamp for staleness check - updated = True - found_existing = True - break - - # If file wasn't in the list (completed too fast to get a progress line), - # create a FileProgress entry so it appears briefly - if not found_existing: - from jackify.shared.progress_models import FileProgress, OperationType - # Try to infer operation from context or default to DOWNLOAD - operation = OperationType.DOWNLOAD - if parsed.file_progress: - operation = parsed.file_progress.operation - - # Create a completed file entry so it appears for 0.5 seconds - completed_file = FileProgress( - filename=parsed.completed_filename, - operation=operation, - percent=100.0, - current_size=0, - total_size=0 - # speed defaults to -1.0 (not provided) - ) - completed_file.last_update = time.time() - self.state.add_file(completed_file) - updated = True - - # Update speed information - if parsed.speed_info: - operation, speed = parsed.speed_info - self.state.update_speed(operation, speed) - updated = True - - # Update message - if parsed.message: - self.state.message = parsed.message - - # Update timestamp - if updated: - self.state.timestamp = time.time() - - # Always clean up completed files (not just when > 10) - # This ensures completed files are removed promptly - if updated: - self.state.remove_completed_files() - - return updated - def get_state(self) -> InstallationProgress: """Get current progress state.""" return self.state - + def reset(self): """Reset progress state.""" self.state = InstallationProgress() @@ -1152,168 +375,3 @@ class ProgressStateManager: self._wabbajack_entry_name = None self._synthetic_flag = "_synthetic_wabbajack" self._has_real_wabbajack = False - - def _augment_file_metrics(self, file_progress: FileProgress): - """Populate size/speed info to improve UI accuracy.""" - now = time.time() - history = self._file_history.get(file_progress.filename) - - total_size = file_progress.total_size or (history.get('total') if history else None) - if total_size and file_progress.percent and not file_progress.current_size: - file_progress.current_size = int((file_progress.percent / 100.0) * total_size) - elif file_progress.current_size and not total_size and file_progress.total_size: - total_size = file_progress.total_size - - if total_size and not file_progress.total_size: - file_progress.total_size = total_size - - current_size = file_progress.current_size or 0 - - # Only compute speed if engine didn't provide one (sentinel value -1) - # Prefer engine-reported speeds (including 0B/s) as they are more accurate - computed_speed = 0.0 # Initialize default - if file_progress.speed < 0: # -1 means engine didn't provide speed - computed_speed = 0.0 - if history and current_size: - prev_bytes = history.get('bytes', 0) - prev_time = history.get('time', now) - delta_bytes = current_size - prev_bytes - delta_time = now - prev_time - - # Require at least 1 second between updates for speed calculation - # This prevents wildly inaccurate speeds from rapid progress bursts - if delta_bytes >= 0 and delta_time >= 1.0: - computed_speed = delta_bytes / delta_time - elif history.get('computed_speed'): - # Keep previous speed if time delta too small - computed_speed = history.get('computed_speed', 0.0) - - file_progress.speed = computed_speed # Set to 0 or computed value - else: - # Engine provided speed, use it for history - computed_speed = file_progress.speed - - if current_size or total_size: - self._file_history[file_progress.filename] = { - 'bytes': current_size, - 'time': now, - 'total': total_size or (history.get('total') if history else None), - 'computed_speed': computed_speed, - } - elif history: - # Preserve existing history even if new data missing - self._file_history[file_progress.filename] = history - - def _maybe_add_wabbajack_progress(self, parsed: ParsedLine) -> bool: - """Create a synthetic file entry for .wabbajack archive download.""" - if not parsed.data_info: - return False - if not parsed.data_info: - return False - - current_bytes, total_bytes = parsed.data_info - if total_bytes <= 0: - return False - - # Check if we already have ANY .wabbajack entry (real or synthetic) - don't create duplicates - for fp in self.state.active_files: - if fp.filename.lower().endswith('.wabbajack'): - # Update existing entry instead of creating new one - synthetic_entry = fp - if getattr(fp, self._synthetic_flag, False): - # It's synthetic - update it - percent = (current_bytes / total_bytes) * 100.0 - synthetic_entry.percent = percent - synthetic_entry.current_size = current_bytes - synthetic_entry.total_size = total_bytes - synthetic_entry.last_update = time.time() - self._augment_file_metrics(synthetic_entry) - return True - else: - # It's real - don't create synthetic - return False - - synthetic_entry = None - for fp in self.state.active_files: - if getattr(fp, self._synthetic_flag, False): - synthetic_entry = fp - break - - message = (parsed.message or "") - phase_name = (parsed.phase_name or "").lower() - should_force = 'wabbajack' in message.lower() or 'wabbajack' in phase_name - - if not synthetic_entry: - if self._has_real_download_activity() and not should_force: - return False - if self.state.phase not in (InstallationPhase.INITIALIZATION, InstallationPhase.DOWNLOAD) and not should_force: - return False - - percent = (current_bytes / total_bytes) * 100.0 - if not self._wabbajack_entry_name: - filename_match = re.search(r'([A-Za-z0-9_\-\.]+\.wabbajack)', message, re.IGNORECASE) - if filename_match: - self._wabbajack_entry_name = filename_match.group(1) - # Use a consistent name - don't create multiple entries with different names - if not self._wabbajack_entry_name: - # Use display message as filename - self._wabbajack_entry_name = "Downloading .wabbajack file" - entry_name = self._wabbajack_entry_name - - if synthetic_entry: - synthetic_entry.percent = percent - synthetic_entry.current_size = current_bytes - synthetic_entry.total_size = total_bytes - synthetic_entry.last_update = time.time() - self._augment_file_metrics(synthetic_entry) - else: - special_file = FileProgress( - filename=entry_name, - operation=OperationType.DOWNLOAD, - percent=percent, - current_size=current_bytes, - total_size=total_bytes - ) - special_file.last_update = time.time() - setattr(special_file, self._synthetic_flag, True) - self._augment_file_metrics(special_file) - self.state.add_file(special_file) - return True - - def _has_real_download_activity(self) -> bool: - """Check if there are real download entries already visible.""" - for fp in self.state.active_files: - if getattr(fp, self._synthetic_flag, False): - continue - if fp.operation == OperationType.DOWNLOAD: - return True - return False - - def _remove_synthetic_wabbajack(self): - """Remove any synthetic .wabbajack entries once real files appear.""" - remaining = [] - removed = False - for fp in self.state.active_files: - if getattr(fp, self._synthetic_flag, False): - removed = True - self._file_history.pop(fp.filename, None) - continue - remaining.append(fp) - if removed: - self.state.active_files = remaining - - def _remove_all_wabbajack_entries(self): - """Remove ALL .wabbajack entries (synthetic and real) when archive download phase starts.""" - remaining = [] - removed = False - for fp in self.state.active_files: - if fp.filename.lower().endswith('.wabbajack') or 'wabbajack' in fp.filename.lower(): - removed = True - self._file_history.pop(fp.filename, None) - continue - remaining.append(fp) - if removed: - self.state.active_files = remaining - # Also clear the wabbajack entry name to prevent re-adding - self._wabbajack_entry_name = None - diff --git a/jackify/backend/handlers/progress_parser_extraction.py b/jackify/backend/handlers/progress_parser_extraction.py new file mode 100644 index 0000000..0f0f859 --- /dev/null +++ b/jackify/backend/handlers/progress_parser_extraction.py @@ -0,0 +1,143 @@ +"""Progress/speed extraction methods for ProgressParser (Mixin).""" + +import logging +import re +from typing import Optional, Tuple + +logger = logging.getLogger(__name__) + + +class ProgressParserExtractionMixin: + """Mixin providing progress and speed extraction methods.""" + + def _extract_overall_progress(self, line: str) -> Optional[float]: + """Extract overall progress percentage.""" + match = re.search(r'(?:Progress|Overall):\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE) + if match: + return float(match.group(1)) + + match = re.search(r'^(\d+(?:\.\d+)?)%\s*(?:complete|done|progress)', line, re.IGNORECASE) + if match: + return float(match.group(1)) + + return None + + def _extract_step_info(self, line: str) -> Optional[Tuple[int, int]]: + """Extract step information like [12/14].""" + match = self.wabbajack_status_pattern.search(line) + if match: + current = int(match.group(1)) + total = int(match.group(2)) + return (current, total) + + match = re.search(r'\[(\d+)/(\d+)\]', line) + if match: + current = int(match.group(1)) + total = int(match.group(2)) + return (current, total) + return None + + def _extract_data_info(self, line: str) -> Optional[Tuple[int, int]]: + """Extract data size information like 1.1GB/56.3GB.""" + match = re.search(r'\(?(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\)?', line, re.IGNORECASE) + if match: + current_val = float(match.group(1)) + current_unit = match.group(2).upper() + total_val = float(match.group(3)) + total_unit = match.group(4).upper() + + current_bytes = self._convert_to_bytes(current_val, current_unit) + total_bytes = self._convert_to_bytes(total_val, total_unit) + + return (current_bytes, total_bytes) + + return None + + def _parse_data_string(self, data_str: str) -> Optional[Tuple[int, int]]: + """Parse data string like '1.1GB/56.3GB' or '1234/5678'.""" + match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', data_str, re.IGNORECASE) + if match: + current_val = float(match.group(1)) + current_unit = match.group(2).upper() + total_val = float(match.group(3)) + total_unit = match.group(4).upper() + + current_bytes = self._convert_to_bytes(current_val, current_unit) + total_bytes = self._convert_to_bytes(total_val, total_unit) + + return (current_bytes, total_bytes) + + match = re.search(r'(\d+)\s*/\s*(\d+)', data_str) + if match: + current = int(match.group(1)) + total = int(match.group(2)) + return (current, total) + + return None + + def _extract_speed_info(self, line: str) -> Optional[Tuple[str, float]]: + """Extract speed information.""" + match = re.search(r'-\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE) + if match: + speed_val = float(match.group(1)) + speed_unit = match.group(2).upper() + speed_bytes = self._convert_to_bytes(speed_val, speed_unit) + + operation = "unknown" + line_lower = line.lower() + if 'download' in line_lower: + operation = "download" + elif 'extract' in line_lower: + operation = "extract" + elif 'validat' in line_lower or 'hash' in line_lower: + operation = "validate" + + return (operation, speed_bytes) + + match = re.search(r'(?:at|speed:?)\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE) + if match: + speed_val = float(match.group(1)) + speed_unit = match.group(2).upper() + speed_bytes = self._convert_to_bytes(speed_val, speed_unit) + + operation = "unknown" + line_lower = line.lower() + if 'download' in line_lower: + operation = "download" + elif 'extract' in line_lower: + operation = "extract" + elif 'validat' in line_lower: + operation = "validate" + + return (operation, speed_bytes) + + return None + + def _parse_speed(self, speed_str: str) -> float: + """Parse speed string to bytes per second.""" + match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', speed_str, re.IGNORECASE) + if match: + value = float(match.group(1)) + unit = match.group(2).upper() + return self._convert_to_bytes(value, unit) + return 0.0 + + def _parse_speed_from_string(self, speed_str: str) -> float: + """Parse speed string like '6.8MB/s' to bytes per second.""" + match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s(?:ec)?', speed_str, re.IGNORECASE) + if match: + value = float(match.group(1)) + unit = match.group(2).upper() + return self._convert_to_bytes(value, unit) + return 0.0 + + def _convert_to_bytes(self, value: float, unit: str) -> int: + """Convert value with unit to bytes.""" + multipliers = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 * 1024, + 'GB': 1024 * 1024 * 1024, + 'TB': 1024 * 1024 * 1024 * 1024 + } + return int(value * multipliers.get(unit, 1)) diff --git a/jackify/backend/handlers/progress_parser_files.py b/jackify/backend/handlers/progress_parser_files.py new file mode 100644 index 0000000..651f0fe --- /dev/null +++ b/jackify/backend/handlers/progress_parser_files.py @@ -0,0 +1,235 @@ +"""File progress parsing methods for ProgressParser (Mixin).""" + +import logging +import re +from typing import Optional + +from jackify.shared.progress_models import FileProgress, OperationType + +logger = logging.getLogger(__name__) + + +class ProgressParserFilesMixin: + """Mixin providing file progress parsing methods.""" + + def _extract_file_progress(self, line: str) -> Optional[FileProgress]: + """Extract file-level progress information.""" + if not line or not isinstance(line, str): + return None + if len(line) > 10000: + return None + if '\x00' in line: + line = line.replace('\x00', '') + + file_progress_match = re.search( + r'\[FILE_PROGRESS\]\s+(Downloading|Extracting|Validating|Installing|Converting|Building|Writing|Verifying|Completed|Checking existing):\s+(.+?)\s+\((\d+(?:\.\d+)?)%\)\s*(?:\[(.+?)\])?\s*(?:\((\d+)/(\d+)\))?', + line, + re.IGNORECASE + ) + if file_progress_match: + operation_str = file_progress_match.group(1).strip() + filename = file_progress_match.group(2).strip() + percent = float(file_progress_match.group(3)) + speed_str = file_progress_match.group(4).strip() if file_progress_match.group(4) else None + counter_current = int(file_progress_match.group(5)) if file_progress_match.group(5) else None + counter_total = int(file_progress_match.group(6)) if file_progress_match.group(6) else None + + operation_map = { + 'downloading': OperationType.DOWNLOAD, + 'extracting': OperationType.EXTRACT, + 'validating': OperationType.VALIDATE, + 'installing': OperationType.INSTALL, + 'building': OperationType.INSTALL, + 'writing': OperationType.INSTALL, + 'verifying': OperationType.VALIDATE, + 'checking existing': OperationType.VALIDATE, + 'converting': OperationType.INSTALL, + 'compiling': OperationType.INSTALL, + 'hashing': OperationType.VALIDATE, + 'completed': OperationType.UNKNOWN, + } + operation = operation_map.get(operation_str.lower(), OperationType.UNKNOWN) + + if counter_current and counter_total and not self._should_display_file(filename): + file_progress = FileProgress( + filename="__phase_progress__", + operation=operation, + percent=percent, + speed=-1.0 + ) + file_progress._file_counter = (counter_current, counter_total) + file_progress._hidden = True + return file_progress + + if not self._should_display_file(filename): + return None + + if operation_str.lower() == 'completed': + percent = 100.0 + + speed = -1.0 + if speed_str: + speed = self._parse_speed_from_string(speed_str) + file_progress = FileProgress( + filename=filename, + operation=operation, + percent=percent, + speed=speed + ) + size_info = self._extract_data_info(line) + if size_info: + file_progress.current_size, file_progress.total_size = size_info + + if counter_current is not None and counter_total is not None: + if operation_str.lower() == 'converting': + file_progress._texture_counter = (counter_current, counter_total) + elif operation_str.lower() == 'building': + file_progress._bsa_counter = (counter_current, counter_total) + else: + file_progress._file_counter = (counter_current, counter_total) + + return file_progress + + if re.search(r'\[.*?\]\s*(?:Downloading|Installing|Extracting)\s+(?:Mod|Files|Archives)', line, re.IGNORECASE): + return None + + match = re.search(r'(?:Installing|Downloading|Extracting|Validating):\s*(.+?)\s*\((\d+(?:\.\d+)?)%\)', line, re.IGNORECASE) + if match: + filename = match.group(1).strip() + percent = float(match.group(2)) + operation = self._detect_operation_from_line(line) + file_progress = FileProgress( + filename=filename, + operation=operation, + percent=percent + ) + size_info = self._extract_data_info(line) + if size_info: + file_progress.current_size, file_progress.total_size = size_info + return file_progress + + match = re.search(r'(.+?\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[:-]\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE) + if match: + filename = match.group(1).strip() + percent = float(match.group(2)) + operation = self._detect_operation_from_line(line) + file_progress = FileProgress( + filename=filename, + operation=operation, + percent=percent + ) + size_info = self._extract_data_info(line) + if size_info: + file_progress.current_size, file_progress.total_size = size_info + return file_progress + + match = re.search(r'(.+?\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[\[@]\s*([^\]]+)\]?', line, re.IGNORECASE) + if match: + filename = match.group(1).strip() + speed_str = match.group(2).strip().rstrip(']') + speed = self._parse_speed(speed_str) + operation = self._detect_operation_from_line(line) + file_progress = FileProgress( + filename=filename, + operation=operation, + speed=speed + ) + size_info = self._extract_data_info(line) + if size_info: + file_progress.current_size, file_progress.total_size = size_info + return file_progress + + match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s+(?:at|@|:|-)?\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE) + if match: + filename = match.group(1).strip() + percent = float(match.group(2)) + operation = self._detect_operation_from_line(line) + return FileProgress( + filename=filename, + operation=operation, + percent=percent + ) + + match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[\(]?\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/?\s*of\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', line, re.IGNORECASE) + if match: + filename = match.group(1).strip() + current_val = float(match.group(2)) + current_unit = match.group(3).upper() + total_val = float(match.group(4)) + total_unit = match.group(5).upper() + current_bytes = self._convert_to_bytes(current_val, current_unit) + total_bytes = self._convert_to_bytes(total_val, total_unit) + percent = (current_bytes / total_bytes * 100.0) if total_bytes > 0 else 0.0 + operation = self._detect_operation_from_line(line) + return FileProgress( + filename=filename, + operation=operation, + percent=percent, + current_size=current_bytes, + total_size=total_bytes + ) + + match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s+(?:downloading|extracting|validating|installing)\s+at\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE) + if match: + filename = match.group(1).strip() + speed_val = float(match.group(2)) + speed_unit = match.group(3).upper() + speed = self._convert_to_bytes(speed_val, speed_unit) + operation = self._detect_operation_from_line(line) + return FileProgress( + filename=filename, + operation=operation, + speed=speed + ) + + return None + + def _parse_file_with_percent(self, match: re.Match) -> Optional[FileProgress]: + """Parse file progress from percentage match.""" + filename = match.group(1).strip() + percent = float(match.group(2)) + operation = OperationType.UNKNOWN + return FileProgress( + filename=filename, + operation=operation, + percent=percent + ) + + def _parse_file_with_speed(self, match: re.Match) -> Optional[FileProgress]: + """Parse file progress from speed match.""" + filename = match.group(1).strip() + speed_str = match.group(2).strip() + speed = self._parse_speed(speed_str) + operation = OperationType.UNKNOWN + return FileProgress( + filename=filename, + operation=operation, + speed=speed + ) + + def _detect_operation_from_line(self, line: str) -> OperationType: + """Detect operation type from line content.""" + line_lower = line.lower() + if 'download' in line_lower: + return OperationType.DOWNLOAD + elif 'extract' in line_lower: + return OperationType.EXTRACT + elif 'validat' in line_lower: + return OperationType.VALIDATE + elif 'install' in line_lower or 'build' in line_lower or 'convert' in line_lower: + return OperationType.INSTALL + else: + return OperationType.UNKNOWN + + def _extract_completed_file(self, line: str) -> Optional[str]: + """Extract filename from completion messages like 'Finished downloading filename.7z'.""" + match = re.search( + r'Finished\s+(?:downloading|extracting|validating|installing)\s+(.+?)(?:\.\s|\.$|\s+Hash:)', + line, + re.IGNORECASE + ) + if match: + filename = match.group(1).strip() + filename = filename.rstrip('. ') + return filename + return None diff --git a/jackify/backend/handlers/progress_parser_phase.py b/jackify/backend/handlers/progress_parser_phase.py new file mode 100644 index 0000000..8ac6c46 --- /dev/null +++ b/jackify/backend/handlers/progress_parser_phase.py @@ -0,0 +1,96 @@ +"""Phase extraction methods for ProgressParser (Mixin).""" + +import logging +import re +from typing import Optional, Tuple + +from jackify.shared.progress_models import InstallationPhase + +logger = logging.getLogger(__name__) + + +class ProgressParserPhaseMixin: + """Mixin providing phase extraction methods.""" + + def _extract_phase(self, line: str) -> Optional[Tuple[InstallationPhase, str]]: + """Extract phase information from line.""" + section_match = re.search(r'===?\s*(.+?)\s*===?', line) + if section_match: + section_name = section_match.group(1).strip().lower() + phase = self._map_section_to_phase(section_name) + return (phase, section_match.group(1).strip()) + + action_match = re.search( + r'\[.*?\]\s*(Installing|Downloading|Extracting|Validating|Processing|Checking existing)', + line, + re.IGNORECASE + ) + if action_match: + action = action_match.group(1).lower() + phase = self._map_action_to_phase(action) + return (phase, action_match.group(1)) + + return None + + def _extract_phase_from_section(self, match: re.Match) -> Optional[Tuple[InstallationPhase, str]]: + """Extract phase from section header match.""" + section_name = match.group(1).strip().lower() + phase = self._map_section_to_phase(section_name) + return (phase, match.group(1).strip()) + + def _extract_phase_from_action(self, match: re.Match) -> Optional[Tuple[InstallationPhase, str]]: + """Extract phase from action match.""" + action = match.group(1).lower() + phase = self._map_action_to_phase(action) + return (phase, match.group(1)) + + def _map_section_to_phase(self, section_name: str) -> InstallationPhase: + """Map section name to InstallationPhase enum.""" + section_lower = section_name.lower() + if 'download' in section_lower: + return InstallationPhase.DOWNLOAD + elif 'extract' in section_lower: + return InstallationPhase.EXTRACT + elif 'validate' in section_lower or 'verif' in section_lower: + return InstallationPhase.VALIDATE + elif 'install' 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: + return InstallationPhase.INITIALIZATION + else: + return InstallationPhase.UNKNOWN + + def _map_action_to_phase(self, action: str) -> InstallationPhase: + """Map action word to InstallationPhase enum.""" + action_lower = action.lower() + if 'download' in action_lower: + return InstallationPhase.DOWNLOAD + elif 'extract' in action_lower: + return InstallationPhase.EXTRACT + elif 'validat' in action_lower or 'checking' in action_lower: + return InstallationPhase.VALIDATE + elif 'install' in action_lower: + return InstallationPhase.INSTALL + else: + return InstallationPhase.UNKNOWN + + def _extract_phase_from_text(self, text: str) -> Optional[Tuple[InstallationPhase, str]]: + """Extract phase from status text like 'Installing files'.""" + text_lower = text.lower() + + if 'download' in text_lower: + return (InstallationPhase.DOWNLOAD, text) + elif 'extract' in text_lower: + return (InstallationPhase.EXTRACT, text) + elif 'validat' in text_lower or 'hash' in text_lower: + return (InstallationPhase.VALIDATE, text) + elif 'install' in text_lower: + return (InstallationPhase.INSTALL, text) + elif 'prepar' in text_lower or 'configur' in text_lower: + return (InstallationPhase.INITIALIZATION, text) + elif 'finish' in text_lower or 'complet' in text_lower: + return (InstallationPhase.FINALIZE, text) + else: + return (InstallationPhase.UNKNOWN, text) diff --git a/jackify/backend/handlers/progress_state_metrics.py b/jackify/backend/handlers/progress_state_metrics.py new file mode 100644 index 0000000..4cb8b31 --- /dev/null +++ b/jackify/backend/handlers/progress_state_metrics.py @@ -0,0 +1,167 @@ +"""Metrics and synthetic entry methods for ProgressStateManager (Mixin).""" + +import logging +import re +import time +from typing import TYPE_CHECKING + +from jackify.shared.progress_models import FileProgress, OperationType, InstallationPhase + +if TYPE_CHECKING: + from jackify.backend.handlers.progress_parser import ParsedLine + +logger = logging.getLogger(__name__) + + +class ProgressStateMetricsMixin: + """Mixin providing metrics augmentation methods.""" + + def _augment_file_metrics(self, file_progress: FileProgress) -> None: + """Populate size/speed info to improve UI accuracy.""" + now = time.time() + history = self._file_history.get(file_progress.filename) + + total_size = file_progress.total_size or (history.get('total') if history else None) + if total_size and file_progress.percent and not file_progress.current_size: + file_progress.current_size = int((file_progress.percent / 100.0) * total_size) + elif file_progress.current_size and not total_size and file_progress.total_size: + total_size = file_progress.total_size + + if total_size and not file_progress.total_size: + file_progress.total_size = total_size + + current_size = file_progress.current_size or 0 + + computed_speed = 0.0 + if file_progress.speed < 0: + computed_speed = 0.0 + if history and current_size: + prev_bytes = history.get('bytes', 0) + prev_time = history.get('time', now) + delta_bytes = current_size - prev_bytes + delta_time = now - prev_time + + if delta_bytes >= 0 and delta_time >= 1.0: + computed_speed = delta_bytes / delta_time + elif history.get('computed_speed'): + computed_speed = history.get('computed_speed', 0.0) + + file_progress.speed = computed_speed + else: + computed_speed = file_progress.speed + + if current_size or total_size: + self._file_history[file_progress.filename] = { + 'bytes': current_size, + 'time': now, + 'total': total_size or (history.get('total') if history else None), + 'computed_speed': computed_speed, + } + elif history: + self._file_history[file_progress.filename] = history + + def _maybe_add_wabbajack_progress(self, parsed: "ParsedLine") -> bool: + """Create a synthetic file entry for .wabbajack archive download.""" + if not parsed.data_info: + return False + if not parsed.data_info: + return False + + current_bytes, total_bytes = parsed.data_info + if total_bytes <= 0: + return False + + for fp in self.state.active_files: + if fp.filename.lower().endswith('.wabbajack'): + synthetic_entry = fp + if getattr(fp, self._synthetic_flag, False): + percent = (current_bytes / total_bytes) * 100.0 + synthetic_entry.percent = percent + synthetic_entry.current_size = current_bytes + synthetic_entry.total_size = total_bytes + synthetic_entry.last_update = time.time() + self._augment_file_metrics(synthetic_entry) + return True + else: + return False + + synthetic_entry = None + for fp in self.state.active_files: + if getattr(fp, self._synthetic_flag, False): + synthetic_entry = fp + break + + message = (parsed.message or "") + phase_name = (parsed.phase_name or "").lower() + should_force = 'wabbajack' in message.lower() or 'wabbajack' in phase_name + + if not synthetic_entry: + if self._has_real_download_activity() and not should_force: + return False + if self.state.phase not in (InstallationPhase.INITIALIZATION, InstallationPhase.DOWNLOAD) and not should_force: + return False + + percent = (current_bytes / total_bytes) * 100.0 + if not self._wabbajack_entry_name: + filename_match = re.search(r'([A-Za-z0-9_\-\.]+\.wabbajack)', message, re.IGNORECASE) + if filename_match: + self._wabbajack_entry_name = filename_match.group(1) + if not self._wabbajack_entry_name: + self._wabbajack_entry_name = "Downloading .wabbajack file" + entry_name = self._wabbajack_entry_name + + if synthetic_entry: + synthetic_entry.percent = percent + synthetic_entry.current_size = current_bytes + synthetic_entry.total_size = total_bytes + synthetic_entry.last_update = time.time() + self._augment_file_metrics(synthetic_entry) + else: + special_file = FileProgress( + filename=entry_name, + operation=OperationType.DOWNLOAD, + percent=percent, + current_size=current_bytes, + total_size=total_bytes + ) + special_file.last_update = time.time() + setattr(special_file, self._synthetic_flag, True) + self._augment_file_metrics(special_file) + self.state.add_file(special_file) + return True + + def _has_real_download_activity(self) -> bool: + """Check if there are real download entries already visible.""" + for fp in self.state.active_files: + if getattr(fp, self._synthetic_flag, False): + continue + if fp.operation == OperationType.DOWNLOAD: + return True + return False + + def _remove_synthetic_wabbajack(self) -> None: + """Remove any synthetic .wabbajack entries once real files appear.""" + remaining = [] + removed = False + for fp in self.state.active_files: + if getattr(fp, self._synthetic_flag, False): + removed = True + self._file_history.pop(fp.filename, None) + continue + remaining.append(fp) + if removed: + self.state.active_files = remaining + + def _remove_all_wabbajack_entries(self) -> None: + """Remove ALL .wabbajack entries when archive download phase starts.""" + remaining = [] + removed = False + for fp in self.state.active_files: + if fp.filename.lower().endswith('.wabbajack') or 'wabbajack' in fp.filename.lower(): + removed = True + self._file_history.pop(fp.filename, None) + continue + remaining.append(fp) + if removed: + self.state.active_files = remaining + self._wabbajack_entry_name = None diff --git a/jackify/backend/handlers/progress_state_processing.py b/jackify/backend/handlers/progress_state_processing.py new file mode 100644 index 0000000..a2810d1 --- /dev/null +++ b/jackify/backend/handlers/progress_state_processing.py @@ -0,0 +1,239 @@ +"""Line processing methods for ProgressStateManager (Mixin).""" + +import logging +import time +from typing import TYPE_CHECKING + +from jackify.shared.progress_models import ( + InstallationPhase, + InstallationProgress, + FileProgress, + OperationType, +) + +if TYPE_CHECKING: + from jackify.backend.handlers.progress_parser import ParsedLine + +logger = logging.getLogger(__name__) + + +class ProgressStateProcessingMixin: + """Mixin providing line processing methods.""" + + def process_line(self, line: str) -> bool: + """ + Process a line of output and update state. + + Returns: + True if state was updated, False otherwise + """ + parsed = self.parser.parse_line(line) + + if not parsed.has_progress: + return False + + updated = False + + phase_changed = False + if parsed.phase and parsed.phase != self.state.phase: + previous_phase = self.state.phase + + if previous_phase == InstallationPhase.DOWNLOAD: + self._download_files_seen = {} + self._download_total_bytes = 0 + self._download_processed_bytes = 0 + + if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info: + if self.state.data_total > 0: + self.state.data_processed = 0 + self.state.data_total = 0 + updated = True + + if previous_phase == InstallationPhase.VALIDATE: + if self.state.phase_name and 'validat' in self.state.phase_name.lower(): + self.state.phase_name = "" + updated = True + + phase_changed = True + self._previous_phase = self.state.phase + self.state.phase = parsed.phase + updated = True + elif parsed.phase: + self.state.phase = parsed.phase + updated = True + + if parsed.phase_name: + self.state.phase_name = parsed.phase_name + updated = True + elif phase_changed: + if self.state.phase_name and self.state.phase != InstallationPhase.VALIDATE: + self.state.phase_name = "" + updated = True + + if self.state.phase == InstallationPhase.DOWNLOAD: + if self.state.phase_name and 'validat' in self.state.phase_name.lower(): + self.state.phase_name = "" + updated = True + + if parsed.overall_percent is not None: + self.state.overall_percent = parsed.overall_percent + updated = True + + if parsed.step_info: + self.state.phase_step, self.state.phase_max_steps = parsed.step_info + updated = True + + if parsed.data_info: + self.state.data_processed, self.state.data_total = parsed.data_info + if self.state.data_total > 0 and self.state.overall_percent == 0.0: + self.state.overall_percent = (self.state.data_processed / self.state.data_total) * 100.0 + updated = True + + if parsed.file_counter: + self.state.phase_step, self.state.phase_max_steps = parsed.file_counter + 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 + self.state.texture_conversion_total = tex_total + updated = True + + if hasattr(parsed.file_progress, '_bsa_counter'): + bsa_current, bsa_total = parsed.file_progress._bsa_counter + self.state.bsa_building_current = bsa_current + self.state.bsa_building_total = bsa_total + updated = True + + if parsed.file_progress.filename.lower().endswith('.wabbajack'): + self._wabbajack_entry_name = parsed.file_progress.filename + self._remove_synthetic_wabbajack() + self._has_real_wabbajack = True + else: + if parsed.file_progress.operation == OperationType.DOWNLOAD: + self._remove_all_wabbajack_entries() + self._has_real_wabbajack = True + + if self.state.phase == InstallationPhase.DOWNLOAD and parsed.file_progress.operation == OperationType.DOWNLOAD: + filename = parsed.file_progress.filename + total_size = parsed.file_progress.total_size or 0 + current_size = parsed.file_progress.current_size or 0 + + if filename not in self._download_files_seen: + if total_size > 0: + self._download_total_bytes += total_size + self._download_files_seen[filename] = (total_size, current_size) + self._download_processed_bytes += current_size + else: + old_total, old_current = self._download_files_seen[filename] + if total_size > old_total: + self._download_total_bytes += (total_size - old_total) + if current_size > old_current: + self._download_processed_bytes += (current_size - old_current) + self._download_files_seen[filename] = (max(old_total, total_size), current_size) + + if self.state.data_total == 0 and self._download_total_bytes > 0: + self.state.data_total = self._download_total_bytes + self.state.data_processed = self._download_processed_bytes + updated = True + + self._augment_file_metrics(parsed.file_progress) + existing_file = None + for f in self.state.active_files: + if f.filename == parsed.file_progress.filename: + existing_file = f + break + + if parsed.file_progress.percent >= 100.0 and not existing_file: + updated = True + elif parsed.file_progress.percent >= 100.0: + parsed.file_progress.percent = 100.0 + parsed.file_progress.last_update = time.time() + self.state.add_file(parsed.file_progress) + updated = True + else: + self.state.add_file(parsed.file_progress) + updated = True + elif parsed.data_info: + phase_name_lower = (parsed.phase_name or "").lower() + message_lower = (parsed.message or "").lower() + is_archive_phase = ( + 'mod archives' in phase_name_lower or + 'downloading mod archives' in message_lower or + (parsed.phase == InstallationPhase.DOWNLOAD and self._has_real_download_activity()) + ) + + if is_archive_phase: + self._remove_all_wabbajack_entries() + self._has_real_wabbajack = True + + if not getattr(self, '_has_real_wabbajack', False): + if self._maybe_add_wabbajack_progress(parsed): + updated = True + + if parsed.completed_filename: + if not self.parser.should_display_file(parsed.completed_filename): + parsed.completed_filename = None + + if parsed.completed_filename: + if self.state.phase == InstallationPhase.DOWNLOAD: + filename = parsed.completed_filename + if filename in self._download_files_seen: + old_total, old_current = self._download_files_seen[filename] + if old_current < old_total: + self._download_processed_bytes += (old_total - old_current) + self._download_files_seen[filename] = (old_total, old_total) + if self.state.data_total == 0 and self._download_total_bytes > 0: + self.state.data_total = self._download_total_bytes + self.state.data_processed = self._download_processed_bytes + updated = True + + found_existing = False + for file_prog in self.state.active_files: + filename_match = ( + file_prog.filename == parsed.completed_filename or + file_prog.filename.endswith(parsed.completed_filename) or + parsed.completed_filename in file_prog.filename + ) + if filename_match: + file_prog.percent = 100.0 + file_prog.last_update = time.time() + updated = True + found_existing = True + break + + if not found_existing: + operation = OperationType.DOWNLOAD + if parsed.file_progress: + operation = parsed.file_progress.operation + + completed_file = FileProgress( + filename=parsed.completed_filename, + operation=operation, + percent=100.0, + current_size=0, + total_size=0 + ) + completed_file.last_update = time.time() + self.state.add_file(completed_file) + updated = True + + if parsed.speed_info: + operation, speed = parsed.speed_info + self.state.update_speed(operation, speed) + updated = True + + if parsed.message: + self.state.message = parsed.message + + if updated: + self.state.timestamp = time.time() + + if updated: + self.state.remove_completed_files() + + return updated diff --git a/jackify/backend/handlers/protontricks_commands.py b/jackify/backend/handlers/protontricks_commands.py new file mode 100644 index 0000000..67d8ed0 --- /dev/null +++ b/jackify/backend/handlers/protontricks_commands.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Protontricks run/launch commands mixin. +Extracted from protontricks_handler for file-size and domain separation. +""" + +import os +import subprocess +from pathlib import Path +import shutil +from typing import Optional + +import logging + +logger = logging.getLogger(__name__) + + +class ProtontricksCommandsMixin: + """Mixin providing run_protontricks and run_protontricks_launch.""" + + def run_protontricks(self, *args, **kwargs): + """ + Run protontricks with the given arguments and keyword arguments. + kwargs are passed to subprocess.run (e.g., stderr=subprocess.DEVNULL). + Returns subprocess.CompletedProcess or None. + """ + if self.which_protontricks is None: + if not self.detect_protontricks(): + self.logger.error("Could not detect protontricks installation") + return None + + if self.which_protontricks == 'bundled': + from .subprocess_utils import get_safe_python_executable + python_exe = get_safe_python_executable() + wrapper_script = self._get_bundled_protontricks_wrapper_path() + if wrapper_script and Path(wrapper_script).exists(): + cmd = [python_exe, str(wrapper_script)] + cmd.extend([str(a) for a in args]) + else: + cmd = [python_exe, "-m", "protontricks.cli.main"] + cmd.extend([str(a) for a in args]) + elif self.which_protontricks == 'flatpak': + cmd = list(self._get_flatpak_run_args()) + if kwargs.get('env') and kwargs['env'].get('WINETRICKS_CACHE'): + try: + cache_val = str(Path(kwargs['env']['WINETRICKS_CACHE']).resolve()) + cmd.append(f'--env=WINETRICKS_CACHE={cache_val}') + except Exception: + pass + cmd.append("com.github.Matoking.protontricks") + cmd.extend(args) + else: + cmd = ["protontricks"] + cmd.extend(args) + + run_kwargs = { + 'stdout': subprocess.PIPE, + 'stderr': subprocess.PIPE, + 'text': True, + **kwargs + } + + cmd_str = ' '.join(map(str, cmd)) + self.logger.debug("=" * 80) + self.logger.debug("PROTONTRICKS COMMAND (for manual reproduction):") + self.logger.debug(f" {cmd_str}") + self.logger.debug("=" * 80) + + if 'env' in kwargs and kwargs['env']: + env = self._get_clean_subprocess_env() + env.update(kwargs['env']) + else: + env = self._get_clean_subprocess_env() + + env['WINEDEBUG'] = '-all' + steam_dir = self._get_steam_dir_from_libraryfolders() + if steam_dir: + env['STEAM_DIR'] = str(steam_dir) + self.logger.debug(f"Set STEAM_DIR for protontricks: {steam_dir}") + else: + self.logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf - protontricks may prompt user") + + if self.which_protontricks == 'native': + winetricks_path = self._get_bundled_winetricks_path() + if winetricks_path: + env['WINETRICKS'] = str(winetricks_path) + self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") + else: + self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks") + cabextract_path = self._get_bundled_cabextract_path() + if cabextract_path: + cabextract_dir = str(cabextract_path.parent) + current_path = env.get('PATH', '') + env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir + self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}") + else: + self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract") + else: + self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") + + from ..handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + if not debug_mode: + env['WINETRICKS_SUPER_QUIET'] = '1' + self.logger.debug("Set WINETRICKS_SUPER_QUIET=1 to suppress winetricks verbose output") + else: + self.logger.debug("Debug mode enabled - winetricks verbose output will be shown") + + run_kwargs['env'] = env + try: + return subprocess.run(cmd, **run_kwargs) + except Exception as e: + self.logger.error(f"Error running protontricks: {e}") + return None + + def run_protontricks_launch(self, appid, installer_path, *extra_args): + """ + Run protontricks-launch (for WebView or similar installers). + Returns subprocess.CompletedProcess or None. + """ + if self.which_protontricks is None: + if not self.detect_protontricks(): + self.logger.error("Could not detect protontricks installation") + return None + if self.which_protontricks == 'bundled': + from .subprocess_utils import get_safe_python_executable + python_exe = get_safe_python_executable() + cmd = [python_exe, "-m", "protontricks.cli.launch", "--appid", appid, str(installer_path)] + elif self.which_protontricks == 'flatpak': + cmd = self._get_flatpak_run_args() + ["--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)] + else: + launch_path = shutil.which("protontricks-launch") + if not launch_path: + self.logger.error("protontricks-launch command not found in PATH.") + return None + cmd = [launch_path, "--appid", appid, str(installer_path)] + if extra_args: + cmd.extend(extra_args) + self.logger.debug(f"Running protontricks-launch: {' '.join(map(str, cmd))}") + try: + env = self._get_clean_subprocess_env() + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) + except Exception as e: + self.logger.error(f"Error running protontricks-launch: {e}") + return None diff --git a/jackify/backend/handlers/protontricks_detection.py b/jackify/backend/handlers/protontricks_detection.py new file mode 100644 index 0000000..0352958 --- /dev/null +++ b/jackify/backend/handlers/protontricks_detection.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Protontricks detection and version mixin. +Extracted from protontricks_handler for file-size and domain separation. +""" + +import os +import re +import subprocess +from pathlib import Path +import shutil +import logging +from typing import Optional, List +import sys + +from .subprocess_utils import get_clean_subprocess_env + + +class ProtontricksDetectionMixin: + """Mixin providing protontricks detection, Steam dir, bundled paths, and version checks.""" + + def _get_steam_dir_from_libraryfolders(self) -> Optional[Path]: + """Determine Steam installation directory from libraryfolders.vdf.""" + from ..handlers.path_handler import PathHandler + vdf_paths = [ + Path.home() / ".steam/steam/config/libraryfolders.vdf", + Path.home() / ".local/share/Steam/config/libraryfolders.vdf", + Path.home() / ".steam/root/config/libraryfolders.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/config/libraryfolders.vdf", + ] + for vdf_path in vdf_paths: + if vdf_path.is_file(): + steam_dir = vdf_path.parent.parent + if (steam_dir / "steamapps").exists(): + self.logger.debug(f"Determined STEAM_DIR from libraryfolders.vdf: {steam_dir}") + return steam_dir + library_paths = PathHandler.get_all_steam_library_paths() + if library_paths: + first_lib = library_paths[0] + if '.var/app/com.valvesoftware.Steam' in str(first_lib): + data_steam = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam" + if (data_steam / "steamapps").exists(): + self.logger.debug(f"Determined STEAM_DIR from Flatpak data path: {data_steam}") + return data_steam + if (first_lib / "steamapps").exists(): + self.logger.debug(f"Determined STEAM_DIR from Flatpak library path: {first_lib}") + return first_lib + elif (first_lib / "steamapps").exists(): + self.logger.debug(f"Determined STEAM_DIR from native library path: {first_lib}") + return first_lib + self.logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf") + return None + + def _get_bundled_winetricks_path(self) -> Optional[Path]: + """Get path to bundled winetricks (AppImage and dev).""" + possible_paths = [] + if os.environ.get('APPDIR'): + possible_paths.append(Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'winetricks') + module_dir = Path(__file__).parent.parent.parent + possible_paths.append(module_dir / 'tools' / 'winetricks') + for path in possible_paths: + if path.exists() and os.access(path, os.X_OK): + self.logger.debug(f"Found bundled winetricks at: {path}") + return path + self.logger.warning(f"Bundled winetricks not found. Tried paths: {possible_paths}") + return None + + def _get_bundled_cabextract_path(self) -> Optional[Path]: + """Get path to bundled cabextract (AppImage and dev).""" + possible_paths = [] + if os.environ.get('APPDIR'): + possible_paths.append(Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'cabextract') + module_dir = Path(__file__).parent.parent.parent + possible_paths.append(module_dir / 'tools' / 'cabextract') + for path in possible_paths: + if path.exists() and os.access(path, os.X_OK): + self.logger.debug(f"Found bundled cabextract at: {path}") + return path + self.logger.warning(f"Bundled cabextract not found. Tried paths: {possible_paths}") + return None + + def _get_bundled_protontricks_wrapper_path(self) -> Optional[str]: + """Return path to bundled protontricks wrapper script if any. Returns None to use python -m fallback.""" + return None + + def _get_clean_subprocess_env(self): + """Create clean environment for subprocess (remove AppImage/bundle vars).""" + env = get_clean_subprocess_env() + if 'LD_LIBRARY_PATH_ORIG' in env: + env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG'] + else: + env.pop('LD_LIBRARY_PATH', None) + if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'): + dyld_entries = env['DYLD_LIBRARY_PATH'].split(os.pathsep) + cleaned_dyld = [p for p in dyld_entries if not p.startswith(sys._MEIPASS)] + if cleaned_dyld: + env['DYLD_LIBRARY_PATH'] = os.pathsep.join(cleaned_dyld) + else: + env.pop('DYLD_LIBRARY_PATH', None) + return env + + def _get_native_steam_service(self): + """Get native Steam operations service instance.""" + if self._native_steam_service is None: + from ..services.native_steam_operations_service import NativeSteamOperationsService + self._native_steam_service = NativeSteamOperationsService(steamdeck=self.steamdeck) + return self._native_steam_service + + def detect_protontricks(self): + """Detect if protontricks is installed (native or flatpak). Returns True if found.""" + self.logger.debug("Detecting if protontricks is installed...") + protontricks_path_which = shutil.which("protontricks") + self.flatpak_path = shutil.which("flatpak") + if protontricks_path_which: + try: + with open(protontricks_path_which, 'r') as f: + content = f.read() + if "flatpak run" in content: + self.logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}") + self.which_protontricks = 'flatpak' + else: + self.logger.info(f"Native Protontricks found at {protontricks_path_which}") + self.which_protontricks = 'native' + self.protontricks_path = protontricks_path_which + return True + except Exception as e: + self.logger.error(f"Error reading protontricks executable: {e}") + try: + env = self._get_clean_subprocess_env() + result_user = subprocess.run( + ["flatpak", "list", "--user"], + capture_output=True, text=True, env=env + ) + if result_user.returncode == 0 and "com.github.Matoking.protontricks" in result_user.stdout: + self.logger.info("Flatpak Protontricks is installed (user-level)") + self.which_protontricks = 'flatpak' + self.flatpak_install_type = 'user' + return True + result_system = subprocess.run( + ["flatpak", "list", "--system"], + capture_output=True, text=True, env=env + ) + if result_system.returncode == 0 and "com.github.Matoking.protontricks" in result_system.stdout: + self.logger.info("Flatpak Protontricks is installed (system-level)") + self.which_protontricks = 'flatpak' + self.flatpak_install_type = 'system' + return True + except FileNotFoundError: + self.logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.") + except Exception as e: + self.logger.error(f"Unexpected error checking flatpak: {e}") + self.logger.warning("Protontricks not found (native or flatpak).") + return False + + def _get_flatpak_run_args(self) -> List[str]: + """Get flatpak run arguments (--user or --system).""" + base_args = ["flatpak", "run"] + if self.flatpak_install_type == 'user': + base_args.append("--user") + elif self.flatpak_install_type == 'system': + base_args.append("--system") + return base_args + + def _get_flatpak_alias_string(self, command=None) -> str: + """Get flatpak alias string for bashrc.""" + flag = f"--{self.flatpak_install_type}" if self.flatpak_install_type else "" + if command: + return f"flatpak run {flag} --command={command} com.github.Matoking.protontricks" if flag else f"flatpak run --command={command} com.github.Matoking.protontricks" + return f"flatpak run {flag} com.github.Matoking.protontricks" if flag else "flatpak run com.github.Matoking.protontricks" + + def check_protontricks_version(self): + """Check if protontricks version is sufficient (>= 1.12). Returns True if OK.""" + try: + if self.which_protontricks == 'flatpak': + cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-V"] + else: + cmd = ["protontricks", "-V"] + result = subprocess.run(cmd, capture_output=True, text=True) + version_str = result.stdout.split(' ')[1].strip('()') + cleaned_version = re.sub(r'[^0-9.]', '', version_str) + self.protontricks_version = cleaned_version + version_parts = cleaned_version.split('.') + if len(version_parts) >= 2: + major, minor = int(version_parts[0]), int(version_parts[1]) + if major < 1 or (major == 1 and minor < 12): + self.logger.error(f"Protontricks version {cleaned_version} is too old. Version 1.12.0 or newer is required.") + return False + return True + self.logger.error(f"Could not parse protontricks version: {cleaned_version}") + return False + except Exception as e: + self.logger.error(f"Error checking protontricks version: {e}") + return False diff --git a/jackify/backend/handlers/protontricks_handler.py b/jackify/backend/handlers/protontricks_handler.py index 507ae06..e50d0b9 100644 --- a/jackify/backend/handlers/protontricks_handler.py +++ b/jackify/backend/handlers/protontricks_handler.py @@ -2,1187 +2,62 @@ # -*- coding: utf-8 -*- """ Protontricks Handler Module -Handles detection and operation of Protontricks +Handles detection and operation of Protontricks. +Delegates to mixins: detection, commands, steam (permissions/aliases/shortcuts), prefix (dotfiles/win10/components). """ -import os -import re -import subprocess -from pathlib import Path -import shutil import logging -from typing import Dict, Optional, List -import sys -# Initialize logger +from .protontricks_detection import ProtontricksDetectionMixin +from .protontricks_commands import ProtontricksCommandsMixin +from .protontricks_steam import ProtontricksSteamMixin +from .protontricks_prefix import ProtontricksPrefixMixin + logger = logging.getLogger(__name__) -class ProtontricksHandler: +class ProtontricksHandler( + ProtontricksDetectionMixin, + ProtontricksCommandsMixin, + ProtontricksSteamMixin, + ProtontricksPrefixMixin, +): """ - Handles operations related to Protontricks detection and usage - - This handler now supports native Steam operations as a fallback/replacement - for protontricks functionality. + Handles operations related to Protontricks detection and usage. + Supports native Steam operations as fallback/replacement for protontricks. """ def __init__(self, steamdeck: bool, logger=None): self.logger = logger or logging.getLogger(__name__) - self.which_protontricks = None # 'flatpak', 'native', or 'bundled' - self.flatpak_install_type = None # 'user' or 'system' (for flatpak installations) + self.which_protontricks = None + self.flatpak_install_type = None self.protontricks_version = None self.protontricks_path = None - self.steamdeck = steamdeck # Store steamdeck status + self.steamdeck = steamdeck self._native_steam_service = None - self.use_native_operations = True # Enable native Steam operations by default + self.use_native_operations = True - def _get_steam_dir_from_libraryfolders(self) -> Optional[Path]: - """ - Determine the Steam installation directory from libraryfolders.vdf location. - This is the source of truth - we read libraryfolders.vdf to find where Steam is actually installed. - - Returns: - Path to Steam installation directory (the one with config/, steamapps/, etc.) or None - """ - from ..handlers.path_handler import PathHandler - - # Check all possible libraryfolders.vdf locations - vdf_paths = [ - Path.home() / ".steam/steam/config/libraryfolders.vdf", - Path.home() / ".local/share/Steam/config/libraryfolders.vdf", - Path.home() / ".steam/root/config/libraryfolders.vdf", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", # Flatpak - Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/config/libraryfolders.vdf", # Flatpak alternative - ] - - for vdf_path in vdf_paths: - if vdf_path.is_file(): - # The Steam installation directory is the parent of the config directory - steam_dir = vdf_path.parent.parent - # Verify it has steamapps directory (required by protontricks) - if (steam_dir / "steamapps").exists(): - logger.debug(f"Determined STEAM_DIR from libraryfolders.vdf: {steam_dir}") - return steam_dir - - # Fallback: try to get from library paths - library_paths = PathHandler.get_all_steam_library_paths() - if library_paths: - # For Flatpak Steam, library path is .local/share/Steam, but Steam installation might be data/Steam - first_lib = library_paths[0] - if '.var/app/com.valvesoftware.Steam' in str(first_lib): - # Check if data/Steam exists (main Flatpak Steam installation) - data_steam = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam" - if (data_steam / "steamapps").exists(): - logger.debug(f"Determined STEAM_DIR from Flatpak data path: {data_steam}") - return data_steam - # Otherwise use the library path itself - if (first_lib / "steamapps").exists(): - logger.debug(f"Determined STEAM_DIR from Flatpak library path: {first_lib}") - return first_lib - else: - # Native Steam - library path should be the Steam installation - if (first_lib / "steamapps").exists(): - logger.debug(f"Determined STEAM_DIR from native library path: {first_lib}") - return first_lib - - logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf") - return None - - def _get_bundled_winetricks_path(self) -> Optional[Path]: - """ - Get the path to the bundled winetricks script following AppImage best practices. - Same logic as WinetricksHandler._get_bundled_winetricks_path() - """ - possible_paths = [] - - # AppImage environment - use APPDIR (standard AppImage best practice) - if os.environ.get('APPDIR'): - appdir_path = Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'winetricks' - possible_paths.append(appdir_path) - - # Development environment - relative to module location - module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/ - dev_path = module_dir / 'tools' / 'winetricks' - possible_paths.append(dev_path) - - # Try each path until we find one that works - for path in possible_paths: - if path.exists() and os.access(path, os.X_OK): - logger.debug(f"Found bundled winetricks at: {path}") - return path - - logger.warning(f"Bundled winetricks not found. Tried paths: {possible_paths}") - return None - - def _get_bundled_cabextract_path(self) -> Optional[Path]: - """ - Get the path to the bundled cabextract binary following AppImage best practices. - Same logic as WinetricksHandler._get_bundled_cabextract() - """ - possible_paths = [] - - # AppImage environment - use APPDIR (standard AppImage best practice) - if os.environ.get('APPDIR'): - appdir_path = Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'cabextract' - possible_paths.append(appdir_path) - - # Development environment - relative to module location - module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/ - dev_path = module_dir / 'tools' / 'cabextract' - possible_paths.append(dev_path) - - # Try each path until we find one that works - for path in possible_paths: - if path.exists() and os.access(path, os.X_OK): - logger.debug(f"Found bundled cabextract at: {path}") - return path - - logger.warning(f"Bundled cabextract not found. Tried paths: {possible_paths}") - return None - - def _get_clean_subprocess_env(self): - """ - Create a clean environment for subprocess calls by removing bundle-specific - environment variables that can interfere with external program execution. - - Uses the centralized get_clean_subprocess_env() to ensure AppImage variables - are removed to prevent subprocess spawning issues. - - Returns: - dict: Cleaned environment dictionary - """ - # Use centralized function that removes AppImage variables - from .subprocess_utils import get_clean_subprocess_env - env = get_clean_subprocess_env() - - # Clean library path variables that frozen bundles modify (Linux/Unix) - if 'LD_LIBRARY_PATH_ORIG' in env: - # Restore original LD_LIBRARY_PATH if it was backed up by the bundler - env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG'] - else: - # Remove bundle-modified LD_LIBRARY_PATH - env.pop('LD_LIBRARY_PATH', None) - - # Clean macOS library path (if present) - if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'): - dyld_entries = env['DYLD_LIBRARY_PATH'].split(os.pathsep) - cleaned_dyld = [p for p in dyld_entries if not p.startswith(sys._MEIPASS)] - if cleaned_dyld: - env['DYLD_LIBRARY_PATH'] = os.pathsep.join(cleaned_dyld) - else: - env.pop('DYLD_LIBRARY_PATH', None) - - return env - - def _get_native_steam_service(self): - """Get native Steam operations service instance""" - if self._native_steam_service is None: - from ..services.native_steam_operations_service import NativeSteamOperationsService - self._native_steam_service = NativeSteamOperationsService(steamdeck=self.steamdeck) - return self._native_steam_service - - def detect_protontricks(self): - """ - Detect if protontricks is installed (silent detection for GUI/automated use). - - Returns True if protontricks is found, False otherwise. - Does NOT prompt user or attempt installation - that's handled by the GUI. - """ - logger.debug("Detecting if protontricks is installed...") - - # Check if protontricks exists as a command - protontricks_path_which = shutil.which("protontricks") - self.flatpak_path = shutil.which("flatpak") - - if protontricks_path_which: - # Check if it's a flatpak wrapper - try: - with open(protontricks_path_which, 'r') as f: - content = f.read() - if "flatpak run" in content: - logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}") - self.which_protontricks = 'flatpak' - else: - logger.info(f"Native Protontricks found at {protontricks_path_which}") - self.which_protontricks = 'native' - self.protontricks_path = protontricks_path_which - return True - except Exception as e: - logger.error(f"Error reading protontricks executable: {e}") - - # Check if flatpak protontricks is installed (check both user and system) - try: - env = self._get_clean_subprocess_env() - - # Check user installation first - result_user = subprocess.run( - ["flatpak", "list", "--user"], - capture_output=True, - text=True, - env=env - ) - if result_user.returncode == 0 and "com.github.Matoking.protontricks" in result_user.stdout: - logger.info("Flatpak Protontricks is installed (user-level)") - self.which_protontricks = 'flatpak' - self.flatpak_install_type = 'user' - return True - - # Check system installation - result_system = subprocess.run( - ["flatpak", "list", "--system"], - capture_output=True, - text=True, - env=env - ) - if result_system.returncode == 0 and "com.github.Matoking.protontricks" in result_system.stdout: - logger.info("Flatpak Protontricks is installed (system-level)") - self.which_protontricks = 'flatpak' - self.flatpak_install_type = 'system' - return True - - except FileNotFoundError: - logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.") - except Exception as e: - logger.error(f"Unexpected error checking flatpak: {e}") - - # Not found - logger.warning("Protontricks not found (native or flatpak).") - return False - - def _get_flatpak_run_args(self) -> List[str]: - """ - Get the correct flatpak run arguments based on installation type. - Returns list starting with ['flatpak', 'run', '--user'|'--system', ...] - """ - base_args = ["flatpak", "run"] - - if self.flatpak_install_type == 'user': - base_args.append("--user") - elif self.flatpak_install_type == 'system': - base_args.append("--system") - # If flatpak_install_type is None, don't add flag (shouldn't happen in normal flow) - - return base_args - - def _get_flatpak_alias_string(self, command=None) -> str: - """ - Get the correct flatpak alias string based on installation type. - Args: - command: Optional command override (e.g., 'protontricks-launch'). - If None, returns base protontricks alias. - Returns: - String like 'flatpak run --user com.github.Matoking.protontricks' - """ - flag = f"--{self.flatpak_install_type}" if self.flatpak_install_type else "" - - if command: - # For commands like protontricks-launch - if flag: - return f"flatpak run {flag} --command={command} com.github.Matoking.protontricks" - else: - return f"flatpak run --command={command} com.github.Matoking.protontricks" - else: - # Base protontricks command - if flag: - return f"flatpak run {flag} com.github.Matoking.protontricks" - else: - return f"flatpak run com.github.Matoking.protontricks" - - def check_protontricks_version(self): - """ - Check if the protontricks version is sufficient - Returns True if version is sufficient, False otherwise - """ - try: - if self.which_protontricks == 'flatpak': - cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-V"] - else: - cmd = ["protontricks", "-V"] - - result = subprocess.run(cmd, capture_output=True, text=True) - version_str = result.stdout.split(' ')[1].strip('()') - - # Clean version string - cleaned_version = re.sub(r'[^0-9.]', '', version_str) - self.protontricks_version = cleaned_version - - # Parse version components - version_parts = cleaned_version.split('.') - if len(version_parts) >= 2: - major, minor = int(version_parts[0]), int(version_parts[1]) - if major < 1 or (major == 1 and minor < 12): - logger.error(f"Protontricks version {cleaned_version} is too old. Version 1.12.0 or newer is required.") - return False - return True - else: - logger.error(f"Could not parse protontricks version: {cleaned_version}") - return False - - except Exception as e: - logger.error(f"Error checking protontricks version: {e}") - return False - - def run_protontricks(self, *args, **kwargs): - """ - Run protontricks with the given arguments and keyword arguments. - kwargs are passed directly to subprocess.run (e.g., stderr=subprocess.DEVNULL). - Use stdout=subprocess.PIPE, stderr=subprocess.PIPE/DEVNULL instead of capture_output=True. - Returns subprocess.CompletedProcess object - """ - # Ensure protontricks is detected first - if self.which_protontricks is None: - if not self.detect_protontricks(): - logger.error("Could not detect protontricks installation") - return None - - # Build command based on detected protontricks type - if self.which_protontricks == 'bundled': - # CRITICAL: Use safe Python executable to prevent AppImage recursive spawning - from .subprocess_utils import get_safe_python_executable - python_exe = get_safe_python_executable() - - # Use bundled wrapper script for reliable invocation - # The wrapper script imports cli and calls it with sys.argv - wrapper_script = self._get_bundled_protontricks_wrapper_path() - if wrapper_script and Path(wrapper_script).exists(): - cmd = [python_exe, str(wrapper_script)] - cmd.extend([str(a) for a in args]) - else: - # Fallback: use python -m to run protontricks CLI directly - # This avoids importing protontricks.__init__ which imports gui.py which needs Pillow - cmd = [python_exe, "-m", "protontricks.cli.main"] - cmd.extend([str(a) for a in args]) - elif self.which_protontricks == 'flatpak': - cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks"] - cmd.extend(args) - else: # native - cmd = ["protontricks"] - cmd.extend(args) - - # Default to capturing stdout/stderr unless specified otherwise in kwargs - run_kwargs = { - 'stdout': subprocess.PIPE, - 'stderr': subprocess.PIPE, - 'text': True, - **kwargs # Allow overriding defaults (like stderr=DEVNULL) - } - - # Log full command for advanced users to reproduce manually (debug mode only) - cmd_str = ' '.join(map(str, cmd)) - logger.debug("=" * 80) - logger.debug("PROTONTRICKS COMMAND (for manual reproduction):") - logger.debug(f" {cmd_str}") - logger.debug("=" * 80) - - # Handle environment: if env was passed in kwargs, merge it with our clean env - # Otherwise create a clean env from scratch - if 'env' in kwargs and kwargs['env']: - # Merge passed env with our clean env (our values take precedence) - env = self._get_clean_subprocess_env() - env.update(kwargs['env']) # Merge passed env, but our clean env is base - # Re-apply our critical settings after merge to ensure they're set - else: - # Bundled-runtime fix: Use cleaned environment for all protontricks calls - env = self._get_clean_subprocess_env() - - # Suppress Wine debug output - env['WINEDEBUG'] = '-all' - - # CRITICAL: Set STEAM_DIR based on libraryfolders.vdf to prevent user prompts - steam_dir = self._get_steam_dir_from_libraryfolders() - if steam_dir: - env['STEAM_DIR'] = str(steam_dir) - logger.debug(f"Set STEAM_DIR for protontricks: {steam_dir}") - else: - logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf - protontricks may prompt user") - - # CRITICAL: Only set bundled winetricks for NATIVE protontricks - # Flatpak protontricks runs in a sandbox and CANNOT access AppImage FUSE mounts (/tmp/.mount_*) - # Flatpak protontricks has its own winetricks bundled inside the flatpak - if self.which_protontricks == 'native': - winetricks_path = self._get_bundled_winetricks_path() - if winetricks_path: - env['WINETRICKS'] = str(winetricks_path) - logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") - else: - logger.warning("Bundled winetricks not found - native protontricks will use system winetricks") - - cabextract_path = self._get_bundled_cabextract_path() - if cabextract_path: - cabextract_dir = str(cabextract_path.parent) - current_path = env.get('PATH', '') - env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir - logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}") - else: - logger.warning("Bundled cabextract not found - native protontricks will use system cabextract") - else: - # Flatpak protontricks - DO NOT set bundled paths - logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") - - # CRITICAL: Suppress winetricks verbose output when not in debug mode - # WINETRICKS_SUPER_QUIET suppresses "Executing..." messages from winetricks - from ..handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - if not debug_mode: - env['WINETRICKS_SUPER_QUIET'] = '1' - logger.debug("Set WINETRICKS_SUPER_QUIET=1 to suppress winetricks verbose output") - else: - logger.debug("Debug mode enabled - winetricks verbose output will be shown") - - # Note: No need to modify LD_LIBRARY_PATH for Wine/Proton as it's a system dependency - # Wine/Proton finds its own libraries through the system's library search paths - - run_kwargs['env'] = env - try: - return subprocess.run(cmd, **run_kwargs) - except Exception as e: - logger.error(f"Error running protontricks: {e}") - return None - - def set_protontricks_permissions(self, modlist_dir, steamdeck=False): - """ - Set permissions for Steam operations to access the modlist directory. - - Uses native operations when enabled, falls back to protontricks permissions. - Returns True on success, False on failure - """ - # Use native operations if enabled - if self.use_native_operations: - logger.debug("Using native Steam operations, permissions handled natively") - try: - return self._get_native_steam_service().set_steam_permissions(modlist_dir, steamdeck) - except Exception as e: - logger.warning(f"Native permissions failed, falling back to protontricks: {e}") - - if self.which_protontricks != 'flatpak': - logger.debug("Using Native protontricks, skip setting permissions") - return True - - logger.info("Setting Protontricks permissions...") - # Bundled-runtime fix: Use cleaned environment - env = self._get_clean_subprocess_env() - - permissions_set = [] - permissions_failed = [] - - try: - # 1. Set permission for modlist directory (required for wine component installation) - logger.debug(f"Setting permission for modlist directory: {modlist_dir}") - try: - subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", - f"--filesystem={modlist_dir}"], check=True, env=env, capture_output=True) - permissions_set.append(f"modlist directory: {modlist_dir}") - except subprocess.CalledProcessError as e: - permissions_failed.append(f"modlist directory: {modlist_dir} ({e})") - logger.warning(f"Failed to set permission for modlist directory: {e}") - - # 2. Set permission for main Steam directory (required for accessing compatdata, config, etc.) - steam_dir = self._get_steam_dir_from_libraryfolders() - if steam_dir and steam_dir.exists(): - logger.info(f"Setting permission for Steam directory: {steam_dir}") - logger.debug("This allows protontricks to access Steam compatdata, config, and steamapps directories") - try: - subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", - f"--filesystem={steam_dir}"], check=True, env=env, capture_output=True) - permissions_set.append(f"Steam directory: {steam_dir}") - except subprocess.CalledProcessError as e: - permissions_failed.append(f"Steam directory: {steam_dir} ({e})") - logger.warning(f"Failed to set permission for Steam directory: {e}") - else: - logger.warning("Could not determine Steam directory - protontricks may not have access to Steam directories") - - # 3. Set permissions for all additional Steam library folders (compatdata can be in any library) - from ..handlers.path_handler import PathHandler - all_library_paths = PathHandler.get_all_steam_library_paths() - for lib_path in all_library_paths: - # Skip if this is the main Steam directory (already set above) - if steam_dir and lib_path.resolve() == steam_dir.resolve(): - continue - if lib_path.exists(): - logger.debug(f"Setting permission for Steam library folder: {lib_path}") - try: - subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", - f"--filesystem={lib_path}"], check=True, env=env, capture_output=True) - permissions_set.append(f"Steam library: {lib_path}") - except subprocess.CalledProcessError as e: - permissions_failed.append(f"Steam library: {lib_path} ({e})") - logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}") - - # 4. Set SD card permissions (Steam Deck only) - if steamdeck: - logger.warn("Checking for SDCard and setting permissions appropriately...") - # Find sdcard path - result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env) - for line in result.stdout.splitlines(): - if "/run/media" in line: - sdcard_path = line.split()[-1] - logger.debug(f"SDCard path: {sdcard_path}") - try: - subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}", - "com.github.Matoking.protontricks"], check=True, env=env, capture_output=True) - permissions_set.append(f"SD card: {sdcard_path}") - except subprocess.CalledProcessError as e: - permissions_failed.append(f"SD card: {sdcard_path} ({e})") - logger.warning(f"Failed to set permission for SD card {sdcard_path}: {e}") - # Add standard Steam Deck SD card path as fallback - try: - subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1", - "com.github.Matoking.protontricks"], check=True, env=env, capture_output=True) - permissions_set.append("SD card: /run/media/mmcblk0p1") - except subprocess.CalledProcessError as e: - # This is expected to fail if the path doesn't exist, so only log at debug level - logger.debug(f"Could not set permission for fallback SD card path (may not exist): {e}") - - # Report results - if permissions_set: - logger.info(f"Successfully set {len(permissions_set)} permission(s) for protontricks") - logger.debug(f"Permissions set: {', '.join(permissions_set)}") - if permissions_failed: - logger.warning(f"Failed to set {len(permissions_failed)} permission(s)") - logger.debug(f"Failed permissions: {', '.join(permissions_failed)}") - - # Return True if at least modlist directory permission was set (critical) - if any("modlist directory" in p for p in permissions_set): - logger.info("Protontricks permissions configured (at least modlist directory access granted)") - return True - else: - logger.error("Failed to set critical modlist directory permission") - return False - - except Exception as e: - logger.error(f"Unexpected error while setting Protontricks permissions: {e}") - return False - - def create_protontricks_alias(self): - """ - Create aliases for protontricks in ~/.bashrc if using flatpak - Returns True if created or already exists, False on failure - """ - if self.which_protontricks != 'flatpak': - logger.debug("Not using flatpak, skipping alias creation") - return True - - try: - bashrc_path = os.path.expanduser("~/.bashrc") - - # Check if file exists and read content - if os.path.exists(bashrc_path): - with open(bashrc_path, 'r') as f: - content = f.read() - - # Check if aliases already exist - protontricks_alias_exists = "alias protontricks=" in content - launch_alias_exists = "alias protontricks-launch" in content - - # Add missing aliases with correct flag based on installation type - with open(bashrc_path, 'a') as f: - if not protontricks_alias_exists: - logger.info("Adding protontricks alias to ~/.bashrc") - alias_cmd = self._get_flatpak_alias_string() - f.write(f"\nalias protontricks='{alias_cmd}'\n") - - if not launch_alias_exists: - logger.info("Adding protontricks-launch alias to ~/.bashrc") - launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch') - f.write(f"\nalias protontricks-launch='{launch_alias_cmd}'\n") - - return True - else: - logger.error("~/.bashrc not found, skipping alias creation") - return False - - except Exception as e: - logger.error(f"Failed to create protontricks aliases: {e}") - return False - - # def get_modlists(self): # Keep commented out or remove old method - # """ - # Get a list of Skyrim, Fallout, Oblivion modlists from Steam via protontricks - # Returns a list of modlist names - # """ - # ... (old implementation with filtering) ... - - # Renamed from list_non_steam_games for clarity and purpose - def list_non_steam_shortcuts(self) -> Dict[str, str]: - """List ALL non-Steam shortcuts. - - Uses native VDF parsing when enabled, falls back to protontricks -l parsing. - - Returns: - A dictionary mapping the shortcut name (AppName) to its AppID. - Returns an empty dictionary if none are found or an error occurs. - """ - # Use native operations if enabled - if self.use_native_operations: - logger.info("Listing non-Steam shortcuts via native VDF parsing...") - try: - return self._get_native_steam_service().list_non_steam_shortcuts() - except Exception as e: - logger.warning(f"Native shortcut listing failed, falling back to protontricks: {e}") - - logger.info("Listing ALL non-Steam shortcuts via protontricks...") - non_steam_shortcuts = {} - # --- Ensure protontricks is detected before proceeding --- - if not self.which_protontricks: - self.logger.info("Protontricks type/path not yet determined. Running detection...") - if not self.detect_protontricks(): - self.logger.error("Protontricks detection failed. Cannot list shortcuts.") - return {} - self.logger.info(f"Protontricks detection successful: {self.which_protontricks}") - # --- End detection check --- - try: - cmd = [] # Initialize cmd list - if self.which_protontricks == 'flatpak': - cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-l"] - elif self.protontricks_path: - cmd = [self.protontricks_path, "-l"] - else: - logger.error("Protontricks path not determined, cannot list shortcuts.") - return {} - self.logger.debug(f"Running command: {' '.join(cmd)}") - # Bundled-runtime fix: Use cleaned environment - env = self._get_clean_subprocess_env() - result = subprocess.run(cmd, capture_output=True, text=True, check=True, encoding='utf-8', errors='ignore', env=env) - # Regex to capture name and AppID - pattern = re.compile(r"Non-Steam shortcut:\s+(.+)\s+\((\d+)\)") - for line in result.stdout.splitlines(): - line = line.strip() - match = pattern.match(line) - if match: - app_name = match.group(1).strip() # Get the name - app_id = match.group(2).strip() # Get the AppID - non_steam_shortcuts[app_name] = app_id - logger.debug(f"Found non-Steam shortcut: '{app_name}' with AppID {app_id}") - if not non_steam_shortcuts: - logger.warning("No non-Steam shortcuts found in protontricks output.") - except FileNotFoundError: - logger.error(f"Protontricks command not found. Path: {cmd[0] if cmd else 'N/A'}") - return {} - except subprocess.CalledProcessError as e: - # Log error but don't necessarily stop; might have partial output - logger.error(f"Error running protontricks -l (Exit code: {e.returncode}): {e}") - logger.error(f"Stderr (truncated): {e.stderr[:500] if e.stderr else ''}") - # Return what we have, might be useful - except Exception as e: - logger.error(f"Unexpected error listing non-Steam shortcuts: {e}", exc_info=True) - return {} - return non_steam_shortcuts - - def enable_dotfiles(self, appid): - """ - Enable visibility of (.)dot files in the Wine prefix - Returns True on success, False on failure - - Args: - appid (str): The app ID to use - - Returns: - bool: True on success, False on failure - """ - logger.debug(f"APPID={appid}") - logger.info("Enabling visibility of (.)dot files...") - - try: - # Check current setting - result = self.run_protontricks( - "-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles", - appid, - stderr=subprocess.DEVNULL # Suppress stderr for this query - ) - - # Check if the initial query command ran successfully and contained expected output - if result and result.returncode == 0 and "ShowDotFiles" in result.stdout and "Y" in result.stdout: - logger.info("DotFiles already enabled via registry... skipping") - return True - elif result and result.returncode != 0: - # Log as info/debug since non-zero exit is expected if key doesn't exist - logger.info(f"Initial query for ShowDotFiles likely failed because the key doesn't exist yet (Exit Code: {result.returncode}). Proceeding to set it. Stderr: {result.stderr}") - elif not result: - logger.error("Failed to execute initial dotfile query command.") - # Proceed cautiously - - # --- Try to set the value --- - dotfiles_set_success = False - - # Method 1: Set registry key (Primary Method) - logger.debug("Attempting to set ShowDotFiles registry key...") - result_add = self.run_protontricks( - "-c", "WINEDEBUG=-all wine reg add \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles /d Y /f", - appid, - # Keep stderr for this one to log potential errors from reg add - # stderr=subprocess.DEVNULL - ) - if result_add and result_add.returncode == 0: - logger.info("'wine reg add' command executed successfully.") - dotfiles_set_success = True # Tentative success - elif result_add: - logger.warning(f"'wine reg add' command failed (Exit Code: {result_add.returncode}). Stderr: {result_add.stderr}") - else: - logger.error("Failed to execute 'wine reg add' command.") - - # Method 2: Create user.reg entry (Backup Method) - # This is useful if registry commands fail but direct file access works - logger.debug("Ensuring user.reg has correct entry...") - prefix_path = self.get_wine_prefix_path(appid) - if prefix_path: - user_reg_path = Path(prefix_path) / "user.reg" - try: - if user_reg_path.exists(): - content = user_reg_path.read_text(encoding='utf-8', errors='ignore') - # Check for CORRECT format with proper backslash escaping - has_correct_format = '[Software\\\\Wine]' in content and '"ShowDotFiles"="Y"' in content - has_broken_format = '[SoftwareWine]' in content and '"ShowDotFiles"="Y"' in content - - if has_broken_format and not has_correct_format: - # Fix the broken format by replacing the section header - logger.debug(f"Found broken ShowDotFiles format in {user_reg_path}, fixing...") - content = content.replace('[SoftwareWine]', '[Software\\\\Wine]') - user_reg_path.write_text(content, encoding='utf-8') - dotfiles_set_success = True - elif not has_correct_format: - logger.debug(f"Adding ShowDotFiles entry to {user_reg_path}") - with open(user_reg_path, 'a', encoding='utf-8') as f: - f.write('\n[Software\\\\Wine] 1603891765\n') - f.write('"ShowDotFiles"="Y"\n') - dotfiles_set_success = True # Count file write as success too - else: - logger.debug("ShowDotFiles already present in correct format in user.reg") - dotfiles_set_success = True # Already there counts as success - else: - logger.warning(f"user.reg not found at {user_reg_path}, creating it.") - with open(user_reg_path, 'w', encoding='utf-8') as f: - f.write('[Software\\\\Wine] 1603891765\n') - f.write('"ShowDotFiles"="Y"\n') - dotfiles_set_success = True # Creating file counts as success - except Exception as e: - logger.warning(f"Error reading/writing user.reg: {e}") - else: - logger.warning("Could not get WINEPREFIX path, skipping user.reg modification.") - - # --- Verification Step --- - logger.debug("Verifying dotfile setting after attempts...") - verify_result = self.run_protontricks( - "-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles", - appid, - stderr=subprocess.DEVNULL # Suppress stderr for verification query - ) - - query_verified = False - if verify_result and verify_result.returncode == 0 and "ShowDotFiles" in verify_result.stdout and "Y" in verify_result.stdout: - logger.debug("Verification query successful and key is set.") - query_verified = True - elif verify_result: - # Change Warning to Info - verification failing right after setting is common - logger.info(f"Verification query failed or key not found (Exit Code: {verify_result.returncode}). Stderr: {verify_result.stderr}") - else: - logger.error("Failed to execute verification query command.") - - # --- Final Decision --- - if dotfiles_set_success: - # If the add command or file write succeeded, we report overall success, - # even if the verification query failed, but log the query status. - if query_verified: - logger.info("Dotfiles enabled and verified successfully!") - else: - # Change Warning to Info - verification failing right after setting is common - logger.info("Dotfiles potentially enabled (reg add/user.reg succeeded), but verification query failed.") - return True # Report success based on the setting action - else: - # If both the reg add and user.reg steps failed - logger.error("Failed to enable dotfiles using registry and user.reg methods.") - return False - - except Exception as e: - logger.error(f"Unexpected error enabling dotfiles: {e}", exc_info=True) - return False - - def set_win10_prefix(self, appid): - """ - Set Windows 10 version in the proton prefix - Returns True on success, False on failure - """ - try: - # Bundled-runtime fix: Use cleaned environment - env = self._get_clean_subprocess_env() - env["WINEDEBUG"] = "-all" - - if self.which_protontricks == 'flatpak': - cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "--no-bwrap", appid, "win10"] - else: - cmd = ["protontricks", "--no-bwrap", appid, "win10"] - - subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - return True - except Exception as e: - logger.error(f"Error setting Windows 10 prefix: {e}") - return False - - def protontricks_alias(self): - """ - Create protontricks alias in ~/.bashrc - """ - logger.info("Creating protontricks alias in ~/.bashrc...") - - try: - if self.which_protontricks == 'flatpak': - # Check if aliases already exist - bashrc_path = os.path.expanduser("~/.bashrc") - protontricks_alias_exists = False - launch_alias_exists = False - - if os.path.exists(bashrc_path): - with open(bashrc_path, 'r') as f: - content = f.read() - protontricks_alias_exists = "alias protontricks=" in content - launch_alias_exists = "alias protontricks-launch=" in content - - # Add aliases if they don't exist with correct flag based on installation type - with open(bashrc_path, 'a') as f: - if not protontricks_alias_exists: - f.write("\n# Jackify: Protontricks alias\n") - alias_cmd = self._get_flatpak_alias_string() - f.write(f"alias protontricks='{alias_cmd}'\n") - logger.debug("Added protontricks alias to ~/.bashrc") - - if not launch_alias_exists: - f.write("\n# Jackify: Protontricks-launch alias\n") - launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch') - f.write(f"alias protontricks-launch='{launch_alias_cmd}'\n") - logger.debug("Added protontricks-launch alias to ~/.bashrc") - - logger.info("Protontricks aliases created successfully") - return True - else: - logger.info("Protontricks is not installed via flatpak, skipping alias creation") - return True - except Exception as e: - logger.error(f"Error creating protontricks alias: {e}") - return False - - def get_wine_prefix_path(self, appid) -> Optional[str]: - """Gets the WINEPREFIX path for a given AppID. - - Uses native path discovery when enabled, falls back to protontricks detection. - - Args: - appid (str): The Steam AppID. - - Returns: - The WINEPREFIX path as a string, or None if detection fails. - """ - # Use native operations if enabled - if self.use_native_operations: - logger.debug(f"Getting WINEPREFIX for AppID {appid} via native path discovery") - try: - return self._get_native_steam_service().get_wine_prefix_path(appid) - except Exception as e: - logger.warning(f"Native WINEPREFIX detection failed, falling back to protontricks: {e}") - - logger.debug(f"Getting WINEPREFIX for AppID {appid}") - result = self.run_protontricks("-c", "echo $WINEPREFIX", appid) - if result and result.returncode == 0 and result.stdout.strip(): - prefix_path = result.stdout.strip() - logger.debug(f"Detected WINEPREFIX: {prefix_path}") - return prefix_path - else: - logger.error(f"Failed to get WINEPREFIX for AppID {appid}. Stderr: {result.stderr if result else 'N/A'}") - return None - - def run_protontricks_launch(self, appid, installer_path, *extra_args): - """ - Run protontricks-launch (for WebView or similar installers) using the correct method for bundled, flatpak, or native. - Returns subprocess.CompletedProcess object. - """ - if self.which_protontricks is None: - if not self.detect_protontricks(): - self.logger.error("Could not detect protontricks installation") - return None - if self.which_protontricks == 'bundled': - # CRITICAL: Use safe Python executable to prevent AppImage recursive spawning - from .subprocess_utils import get_safe_python_executable - python_exe = get_safe_python_executable() - # Use bundled Python module - cmd = [python_exe, "-m", "protontricks.cli.launch", "--appid", appid, str(installer_path)] - elif self.which_protontricks == 'flatpak': - cmd = self._get_flatpak_run_args() + ["--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)] - else: # native - launch_path = shutil.which("protontricks-launch") - if not launch_path: - self.logger.error("protontricks-launch command not found in PATH.") - return None - cmd = [launch_path, "--appid", appid, str(installer_path)] - if extra_args: - cmd.extend(extra_args) - self.logger.debug(f"Running protontricks-launch: {' '.join(map(str, cmd))}") - try: - # Bundled-runtime fix: Use cleaned environment - env = self._get_clean_subprocess_env() - return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) - except Exception as e: - self.logger.error(f"Error running protontricks-launch: {e}") - return None - - def _ensure_flatpak_cache_access(self, cache_path: Path) -> bool: - """ - Ensure flatpak protontricks has filesystem access to the winetricks cache. - - Args: - cache_path: Path to winetricks cache directory - - Returns: - True if access granted or already exists, False on failure - """ - if self.which_protontricks != 'flatpak': - return True # Not flatpak, no action needed - - try: - # Check if flatpak already has access to this path - result = subprocess.run( - ['flatpak', 'override', '--user', '--show', 'com.github.Matoking.protontricks'], - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode == 0: - # Check if cache path is already in filesystem overrides - cache_str = str(cache_path.resolve()) - if f'filesystems=' in result.stdout and cache_str in result.stdout: - self.logger.debug(f"Flatpak protontricks already has access to cache: {cache_str}") - return True - - # Grant access to cache directory - self.logger.info(f"Granting flatpak protontricks access to winetricks cache: {cache_path}") - result = subprocess.run( - ['flatpak', 'override', '--user', 'com.github.Matoking.protontricks', - f'--filesystem={cache_path.resolve()}'], - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode == 0: - self.logger.info("Successfully granted flatpak protontricks cache access") - return True - else: - self.logger.warning(f"Failed to grant flatpak cache access: {result.stderr}") - return False - - except Exception as e: - self.logger.warning(f"Could not configure flatpak cache access: {e}") - return False - - def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None): - """ - Install the specified Wine components into the given prefix using protontricks. - If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022). - """ - self.logger.info("=" * 80) - self.logger.info("USING PROTONTRICKS") - self.logger.info("=" * 80) - env = self._get_clean_subprocess_env() - env["WINEDEBUG"] = "-all" - - # CRITICAL: Only set bundled winetricks for NATIVE protontricks - # Flatpak protontricks runs in a sandbox and CANNOT access AppImage FUSE mounts (/tmp/.mount_*) - # Flatpak protontricks has its own winetricks bundled inside the flatpak - if self.which_protontricks == 'native': - winetricks_path = self._get_bundled_winetricks_path() - if winetricks_path: - env['WINETRICKS'] = str(winetricks_path) - self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") - else: - self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks") - - cabextract_path = self._get_bundled_cabextract_path() - if cabextract_path: - cabextract_dir = str(cabextract_path.parent) - current_path = env.get('PATH', '') - env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir - self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}") - else: - self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract") - else: - # Flatpak protontricks - DO NOT set bundled paths - self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") - - # CRITICAL: Suppress winetricks verbose output when not in debug mode - from ..handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - if not debug_mode: - env['WINETRICKS_SUPER_QUIET'] = '1' - self.logger.debug("Set WINETRICKS_SUPER_QUIET=1 in install_wine_components to suppress winetricks verbose output") - - # Set up winetricks cache (shared with winetricks_handler for efficiency) - from jackify.shared.paths import get_jackify_data_dir - jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' - jackify_cache_dir.mkdir(parents=True, exist_ok=True) - - # Ensure flatpak protontricks has access to cache (no-op for native) - self._ensure_flatpak_cache_access(jackify_cache_dir) - - env['WINETRICKS_CACHE'] = str(jackify_cache_dir) - self.logger.info(f"Using winetricks cache: {jackify_cache_dir}") - if specific_components is not None: - components_to_install = specific_components - self.logger.info(f"Installing specific components: {components_to_install}") - else: - components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] - self.logger.info(f"Installing default components: {components_to_install}") - if not components_to_install: - self.logger.info("No Wine components to install.") - return True - self.logger.info(f"AppID: {appid}, Game: {game_var}, Components: {components_to_install}") - # print(f"\n[Jackify] Installing Wine components for AppID {appid} ({game_var}):\n {', '.join(components_to_install)}\n") # Suppressed per user request - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - if attempt > 1: - self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...") - self._cleanup_wine_processes() - try: - result = self.run_protontricks("--no-bwrap", appid, "-q", *components_to_install, env=env, timeout=600) - self.logger.debug(f"Protontricks output: {result.stdout if result else ''}") - if result and result.returncode == 0: - self.logger.info("Wine Component installation command completed.") - - # Verify components were actually installed - if self._verify_components_installed(appid, components_to_install): - self.logger.info("Component verification successful - all components installed correctly.") - return True - else: - self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})") - # Continue to retry - else: - self.logger.error(f"Protontricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode if result else 'N/A'}") - # Only show stdout/stderr in debug mode to avoid verbose output - from ..handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - if debug_mode: - self.logger.error(f"Stdout: {result.stdout.strip() if result else ''}") - self.logger.error(f"Stderr: {result.stderr.strip() if result else ''}") - else: - # In non-debug mode, only show stderr if it contains actual errors (not verbose winetricks output) - if result and result.stderr: - stderr_lower = result.stderr.lower() - # Filter out verbose winetricks messages - if any(keyword in stderr_lower for keyword in ['error', 'failed', 'cannot', 'warning: cannot find']): - # Only show actual errors, not "Executing..." messages - error_lines = [line for line in result.stderr.strip().split('\n') - if any(keyword in line.lower() for keyword in ['error', 'failed', 'cannot', 'warning: cannot find']) - and 'executing' not in line.lower()] - if error_lines: - self.logger.error(f"Stderr (errors only): {' '.join(error_lines)}") - except Exception as e: - self.logger.error(f"Error during protontricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True) - self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.") - return False - - def _verify_components_installed(self, appid: str, components: List[str]) -> bool: - """ - Verify that Wine components were actually installed by querying protontricks. - - Args: - appid: Steam AppID - components: List of components that should be installed - - Returns: - bool: True if all critical components are verified, False otherwise - """ - try: - self.logger.info("Verifying installed components...") - - # Run protontricks list-installed to get actual installed components - result = self.run_protontricks("--no-bwrap", appid, "list-installed", timeout=30) - - if not result or result.returncode != 0: - self.logger.error("Failed to query installed components") - self.logger.debug(f"list-installed stderr: {result.stderr if result else 'N/A'}") - return False - - installed_output = result.stdout.lower() - self.logger.debug(f"Installed components output: {installed_output}") - - # Define critical components that MUST be installed - # These are the core components that determine success - critical_components = ["vcrun2022", "xact"] - - # Check for critical components - missing_critical = [] - for component in critical_components: - if component.lower() not in installed_output: - missing_critical.append(component) - - if missing_critical: - self.logger.error(f"CRITICAL: Missing essential components: {missing_critical}") - self.logger.error("Installation reported success but components are NOT installed") - return False - - # Check for requested components (warn but don't fail) - missing_requested = [] - for component in components: - # Handle settings like fontsmooth=rgb (just check the base component name) - base_component = component.split('=')[0].lower() - if base_component not in installed_output and component.lower() not in installed_output: - missing_requested.append(component) - - if missing_requested: - self.logger.warning(f"Some requested components may not be installed: {missing_requested}") - self.logger.warning("This may cause issues, but critical components are present") - - self.logger.info(f"Verification passed - critical components confirmed: {critical_components}") - return True - - except Exception as e: - self.logger.error(f"Error verifying components: {e}", exc_info=True) - return False - - def _cleanup_wine_processes(self): - """ - Internal method to clean up wine processes during component installation - """ - try: - subprocess.run("pgrep -f 'win7|win10|ShowDotFiles|protontricks' | xargs -r kill -9", - shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - subprocess.run("pkill -9 winetricks", - shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except Exception as e: - logger.error(f"Error cleaning up wine processes: {e}") - def check_and_setup_protontricks(self) -> bool: """ - Runs all necessary checks and setup steps for Protontricks. - - Detects (and prompts for install if missing) - - Checks version - - Creates aliases if using Flatpak - - Returns: - bool: True if Protontricks is ready to use, False otherwise. + Run detection, version check, and alias setup for Protontricks. + Returns True if Protontricks is ready to use, False otherwise. """ - logger.info("Checking and setting up Protontricks...") - - logger.info("Checking Protontricks installation...") + self.logger.info("Checking and setting up Protontricks...") + self.logger.info("Checking Protontricks installation...") if not self.detect_protontricks(): - # Error message already printed by detect_protontricks if install fails/skipped return False - logger.info(f"Protontricks detected: {self.which_protontricks}") + self.logger.info(f"Protontricks detected: {self.which_protontricks}") - logger.info("Checking Protontricks version...") + self.logger.info("Checking Protontricks version...") if not self.check_protontricks_version(): - # Error message already printed by check_protontricks_version - print(f"Error: Protontricks version {self.protontricks_version} is too old or could not be checked.") + self.logger.error(f"Protontricks version {self.protontricks_version} is too old or could not be checked.") return False - logger.info(f"Protontricks version {self.protontricks_version} is sufficient.") + self.logger.info(f"Protontricks version {self.protontricks_version} is sufficient.") - # Aliases are non-critical, log warning if creation fails if self.which_protontricks == 'flatpak': - logger.info("Ensuring Flatpak aliases exist in ~/.bashrc...") + self.logger.info("Ensuring Flatpak aliases exist in ~/.bashrc...") if not self.protontricks_alias(): - # Logged by protontricks_alias, maybe add print? - print("Warning: Failed to create/verify protontricks aliases in ~/.bashrc") - # Don't necessarily fail the whole setup for this + self.logger.warning("Failed to create/verify protontricks aliases in ~/.bashrc") - logger.info("Protontricks check and setup completed successfully.") - return True \ No newline at end of file + self.logger.info("Protontricks check and setup completed successfully.") + return True diff --git a/jackify/backend/handlers/protontricks_prefix.py b/jackify/backend/handlers/protontricks_prefix.py new file mode 100644 index 0000000..6fe9dd3 --- /dev/null +++ b/jackify/backend/handlers/protontricks_prefix.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Protontricks prefix/Wine component mixin. +Extracted from protontricks_handler for file-size and domain separation. +""" + +import os +import subprocess +from pathlib import Path +from typing import Optional, List + +import logging + +logger = logging.getLogger(__name__) + + +class ProtontricksPrefixMixin: + """Mixin for Wine prefix operations: dotfiles, win10, prefix path, component install/verify.""" + + def enable_dotfiles(self, appid): + """Enable visibility of (.)dot files in the Wine prefix. Returns True on success.""" + self.logger.debug(f"APPID={appid}") + self.logger.info("Enabling visibility of (.)dot files...") + try: + result = self.run_protontricks( + "-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles", + appid, + stderr=subprocess.DEVNULL + ) + if result and result.returncode == 0 and "ShowDotFiles" in result.stdout and "Y" in result.stdout: + self.logger.info("DotFiles already enabled via registry... skipping") + return True + elif result and result.returncode != 0: + self.logger.info(f"Initial query for ShowDotFiles likely failed (Exit Code: {result.returncode}). Proceeding to set it. Stderr: {result.stderr}") + elif not result: + self.logger.error("Failed to execute initial dotfile query command.") + + dotfiles_set_success = False + self.logger.debug("Attempting to set ShowDotFiles registry key...") + result_add = self.run_protontricks( + "-c", "WINEDEBUG=-all wine reg add \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles /d Y /f", + appid, + ) + if result_add and result_add.returncode == 0: + self.logger.info("'wine reg add' command executed successfully.") + dotfiles_set_success = True + elif result_add: + self.logger.warning(f"'wine reg add' command failed (Exit Code: {result_add.returncode}). Stderr: {result_add.stderr}") + else: + self.logger.error("Failed to execute 'wine reg add' command.") + + self.logger.debug("Ensuring user.reg has correct entry...") + prefix_path = self.get_wine_prefix_path(appid) + if prefix_path: + user_reg_path = Path(prefix_path) / "user.reg" + try: + if user_reg_path.exists(): + content = user_reg_path.read_text(encoding='utf-8', errors='ignore') + has_correct_format = '[Software\\\\Wine]' in content and '"ShowDotFiles"="Y"' in content + has_broken_format = '[SoftwareWine]' in content and '"ShowDotFiles"="Y"' in content + if has_broken_format and not has_correct_format: + self.logger.debug(f"Found broken ShowDotFiles format in {user_reg_path}, fixing...") + content = content.replace('[SoftwareWine]', '[Software\\\\Wine]') + user_reg_path.write_text(content, encoding='utf-8') + dotfiles_set_success = True + elif not has_correct_format: + self.logger.debug(f"Adding ShowDotFiles entry to {user_reg_path}") + with open(user_reg_path, 'a', encoding='utf-8') as f: + f.write('\n[Software\\\\Wine] 1603891765\n') + f.write('"ShowDotFiles"="Y"\n') + dotfiles_set_success = True + else: + self.logger.debug("ShowDotFiles already present in correct format in user.reg") + dotfiles_set_success = True + else: + self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.") + with open(user_reg_path, 'w', encoding='utf-8') as f: + f.write('[Software\\\\Wine] 1603891765\n') + f.write('"ShowDotFiles"="Y"\n') + dotfiles_set_success = True + except Exception as e: + self.logger.warning(f"Error reading/writing user.reg: {e}") + else: + self.logger.warning("Could not get WINEPREFIX path, skipping user.reg modification.") + + self.logger.debug("Verifying dotfile setting after attempts...") + verify_result = self.run_protontricks( + "-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles", + appid, + stderr=subprocess.DEVNULL + ) + query_verified = False + if verify_result and verify_result.returncode == 0 and "ShowDotFiles" in verify_result.stdout and "Y" in verify_result.stdout: + self.logger.debug("Verification query successful and key is set.") + query_verified = True + elif verify_result: + self.logger.info(f"Verification query failed or key not found (Exit Code: {verify_result.returncode}). Stderr: {verify_result.stderr}") + else: + self.logger.error("Failed to execute verification query command.") + + if dotfiles_set_success: + if query_verified: + self.logger.info("Dotfiles enabled and verified successfully!") + else: + self.logger.info("Dotfiles potentially enabled (reg add/user.reg succeeded), but verification query failed.") + return True + self.logger.error("Failed to enable dotfiles using registry and user.reg methods.") + return False + except Exception as e: + self.logger.error(f"Unexpected error enabling dotfiles: {e}", exc_info=True) + return False + + def set_win10_prefix(self, appid): + """Set Windows 10 version in the proton prefix. Returns True on success.""" + try: + env = self._get_clean_subprocess_env() + env["WINEDEBUG"] = "-all" + if self.which_protontricks == 'flatpak': + cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "--no-bwrap", appid, "win10"] + else: + cmd = ["protontricks", "--no-bwrap", appid, "win10"] + subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except Exception as e: + self.logger.error(f"Error setting Windows 10 prefix: {e}") + return False + + def get_wine_prefix_path(self, appid) -> Optional[str]: + """ + Get the WINEPREFIX path for a given AppID. + Uses native path discovery when enabled, else protontricks -c echo $WINEPREFIX. + """ + if self.use_native_operations: + self.logger.debug(f"Getting WINEPREFIX for AppID {appid} via native path discovery") + try: + return self._get_native_steam_service().get_wine_prefix_path(appid) + except Exception as e: + self.logger.warning(f"Native WINEPREFIX detection failed, falling back to protontricks: {e}") + + self.logger.debug(f"Getting WINEPREFIX for AppID {appid}") + result = self.run_protontricks("-c", "echo $WINEPREFIX", appid) + if result and result.returncode == 0 and result.stdout.strip(): + prefix_path = result.stdout.strip() + self.logger.debug(f"Detected WINEPREFIX: {prefix_path}") + return prefix_path + self.logger.error(f"Failed to get WINEPREFIX for AppID {appid}. Stderr: {result.stderr if result else 'N/A'}") + return None + + def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None): + """ + Install Wine components into the prefix using protontricks. + If specific_components is None, use default set (fontsmooth=rgb, xact, xact_x64, vcrun2022). + """ + self.logger.info("=" * 80) + self.logger.info("USING PROTONTRICKS") + self.logger.info("=" * 80) + env = self._get_clean_subprocess_env() + env["WINEDEBUG"] = "-all" + + if self.which_protontricks == 'native': + winetricks_path = self._get_bundled_winetricks_path() + if winetricks_path: + env['WINETRICKS'] = str(winetricks_path) + self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") + else: + self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks") + cabextract_path = self._get_bundled_cabextract_path() + if cabextract_path: + cabextract_dir = str(cabextract_path.parent) + current_path = env.get('PATH', '') + env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir + self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}") + else: + self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract") + else: + self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") + + from ..handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + if not debug_mode: + env['WINETRICKS_SUPER_QUIET'] = '1' + self.logger.debug("Set WINETRICKS_SUPER_QUIET=1 in install_wine_components to suppress winetricks verbose output") + + from jackify.shared.paths import get_jackify_data_dir + jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' + jackify_cache_dir.mkdir(parents=True, exist_ok=True) + self._ensure_flatpak_cache_access(jackify_cache_dir) + env['WINETRICKS_CACHE'] = str(jackify_cache_dir) + self.logger.info(f"Using winetricks cache: {jackify_cache_dir}") + + if specific_components is not None: + components_to_install = specific_components + self.logger.info(f"Installing specific components: {components_to_install}") + else: + components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] + self.logger.info(f"Installing default components: {components_to_install}") + if not components_to_install: + self.logger.info("No Wine components to install.") + return True + self.logger.info(f"AppID: {appid}, Game: {game_var}, Components: {components_to_install}") + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + if attempt > 1: + self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...") + self._cleanup_wine_processes() + try: + result = self.run_protontricks("--no-bwrap", appid, "-q", *components_to_install, env=env, timeout=600) + self.logger.debug(f"Protontricks output: {result.stdout if result else ''}") + if result and result.returncode == 0: + self.logger.info("Wine Component installation command completed.") + if self._verify_components_installed(appid, components_to_install): + self.logger.info("Component verification successful - all components installed correctly.") + return True + self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})") + else: + self.logger.error(f"Protontricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode if result else 'N/A'}") + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + if debug_mode: + self.logger.error(f"Stdout: {result.stdout.strip() if result else ''}") + self.logger.error(f"Stderr: {result.stderr.strip() if result else ''}") + elif result and result.stderr: + stderr_lower = result.stderr.lower() + if any(k in stderr_lower for k in ['error', 'failed', 'cannot', 'warning: cannot find']): + error_lines = [line for line in result.stderr.strip().split('\n') + if any(k in line.lower() for k in ['error', 'failed', 'cannot', 'warning: cannot find']) + and 'executing' not in line.lower()] + if error_lines: + self.logger.error(f"Stderr (errors only): {' '.join(error_lines)}") + except Exception as e: + self.logger.error(f"Error during protontricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True) + self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.") + return False + + def _verify_components_installed(self, appid: str, components: List[str]) -> bool: + """Verify every requested component is present in protontricks list-installed.""" + try: + self.logger.info("Verifying installed components...") + result = self.run_protontricks("--no-bwrap", appid, "list-installed", timeout=30) + if not result or result.returncode != 0: + self.logger.error("Failed to query installed components") + self.logger.debug(f"list-installed stderr: {result.stderr if result else 'N/A'}") + return False + installed_output = result.stdout.lower() + self.logger.debug(f"Installed components output: {installed_output}") + missing = [] + for component in components: + base_component = component.split('=')[0].lower() + if base_component in installed_output or component.lower() in installed_output: + continue + missing.append(component) + if missing: + self.logger.error(f"Components not in list-installed: {missing}") + return False + self.logger.info("Verification passed - all components in list-installed") + return True + except Exception as e: + self.logger.error(f"Error verifying components: {e}", exc_info=True) + return False + + def _cleanup_wine_processes(self): + """Clean up wine-related processes during component installation.""" + try: + subprocess.run("pgrep -f 'win7|win10|ShowDotFiles|protontricks' | xargs -r kill -9", + shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run("pkill -9 winetricks", + shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + self.logger.error(f"Error cleaning up wine processes: {e}") diff --git a/jackify/backend/handlers/protontricks_steam.py b/jackify/backend/handlers/protontricks_steam.py new file mode 100644 index 0000000..3ebef90 --- /dev/null +++ b/jackify/backend/handlers/protontricks_steam.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Protontricks Steam/permissions/shortcuts/alias mixin. +Extracted from protontricks_handler for file-size and domain separation. +""" + +import os +import re +import subprocess +from pathlib import Path +from typing import Dict + +import logging + +logger = logging.getLogger(__name__) + + +class ProtontricksSteamMixin: + """Mixin for Steam permissions, aliases, and non-Steam shortcut listing.""" + + def set_protontricks_permissions(self, modlist_dir, steamdeck=False): + """ + Set permissions for Steam operations to access the modlist directory. + Uses native operations when enabled, else protontricks flatpak overrides. + Returns True on success, False on failure. + """ + if self.use_native_operations: + self.logger.debug("Using native Steam operations, permissions handled natively") + try: + return self._get_native_steam_service().set_steam_permissions(modlist_dir, steamdeck) + except Exception as e: + self.logger.warning(f"Native permissions failed, falling back to protontricks: {e}") + + if self.which_protontricks != 'flatpak': + self.logger.debug("Using Native protontricks, skip setting permissions") + return True + + self.logger.info("Setting Protontricks permissions...") + env = self._get_clean_subprocess_env() + permissions_set = [] + permissions_failed = [] + + try: + self.logger.debug(f"Setting permission for modlist directory: {modlist_dir}") + try: + subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", + f"--filesystem={modlist_dir}"], check=True, env=env, capture_output=True) + permissions_set.append(f"modlist directory: {modlist_dir}") + except subprocess.CalledProcessError as e: + permissions_failed.append(f"modlist directory: {modlist_dir} ({e})") + self.logger.warning(f"Failed to set permission for modlist directory: {e}") + + steam_dir = self._get_steam_dir_from_libraryfolders() + if steam_dir and steam_dir.exists(): + self.logger.info(f"Setting permission for Steam directory: {steam_dir}") + self.logger.debug("Allows protontricks to access Steam compatdata, config, steamapps") + try: + subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", + f"--filesystem={steam_dir}"], check=True, env=env, capture_output=True) + permissions_set.append(f"Steam directory: {steam_dir}") + except subprocess.CalledProcessError as e: + permissions_failed.append(f"Steam directory: {steam_dir} ({e})") + self.logger.warning(f"Failed to set permission for Steam directory: {e}") + else: + self.logger.warning("Could not determine Steam directory - protontricks may not have access to Steam directories") + + from ..handlers.path_handler import PathHandler + all_library_paths = PathHandler.get_all_steam_library_paths() + for lib_path in all_library_paths: + if steam_dir and lib_path.resolve() == steam_dir.resolve(): + continue + if lib_path.exists(): + self.logger.debug(f"Setting permission for Steam library folder: {lib_path}") + try: + subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", + f"--filesystem={lib_path}"], check=True, env=env, capture_output=True) + permissions_set.append(f"Steam library: {lib_path}") + except subprocess.CalledProcessError as e: + permissions_failed.append(f"Steam library: {lib_path} ({e})") + self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}") + + if steamdeck: + self.logger.warning("Checking for SDCard and setting permissions appropriately...") + result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env) + for line in result.stdout.splitlines(): + if "/run/media" in line: + sdcard_path = line.split()[-1] + self.logger.debug(f"SDCard path: {sdcard_path}") + try: + subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}", + "com.github.Matoking.protontricks"], check=True, env=env, capture_output=True) + permissions_set.append(f"SD card: {sdcard_path}") + except subprocess.CalledProcessError as e: + permissions_failed.append(f"SD card: {sdcard_path} ({e})") + self.logger.warning(f"Failed to set permission for SD card {sdcard_path}: {e}") + try: + subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1", + "com.github.Matoking.protontricks"], check=True, env=env, capture_output=True) + permissions_set.append("SD card: /run/media/mmcblk0p1") + except subprocess.CalledProcessError as e: + self.logger.debug(f"Could not set permission for fallback SD card path (may not exist): {e}") + + if permissions_set: + self.logger.info(f"Successfully set {len(permissions_set)} permission(s) for protontricks") + self.logger.debug(f"Permissions set: {', '.join(permissions_set)}") + if permissions_failed: + self.logger.warning(f"Failed to set {len(permissions_failed)} permission(s)") + self.logger.debug(f"Failed permissions: {', '.join(permissions_failed)}") + + if any("modlist directory" in p for p in permissions_set): + self.logger.info("Protontricks permissions configured (at least modlist directory access granted)") + return True + self.logger.error("Failed to set critical modlist directory permission") + return False + + except Exception as e: + self.logger.error(f"Unexpected error while setting Protontricks permissions: {e}") + return False + + def create_protontricks_alias(self): + """Create aliases for protontricks in ~/.bashrc if using flatpak. Returns True if created or already exists.""" + if self.which_protontricks != 'flatpak': + self.logger.debug("Not using flatpak, skipping alias creation") + return True + try: + bashrc_path = os.path.expanduser("~/.bashrc") + if os.path.exists(bashrc_path): + with open(bashrc_path, 'r') as f: + content = f.read() + protontricks_alias_exists = "alias protontricks=" in content + launch_alias_exists = "alias protontricks-launch" in content + with open(bashrc_path, 'a') as f: + if not protontricks_alias_exists: + self.logger.info("Adding protontricks alias to ~/.bashrc") + alias_cmd = self._get_flatpak_alias_string() + f.write(f"\nalias protontricks='{alias_cmd}'\n") + if not launch_alias_exists: + self.logger.info("Adding protontricks-launch alias to ~/.bashrc") + launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch') + f.write(f"\nalias protontricks-launch='{launch_alias_cmd}'\n") + return True + self.logger.error("~/.bashrc not found, skipping alias creation") + return False + except Exception as e: + self.logger.error(f"Failed to create protontricks aliases: {e}") + return False + + def list_non_steam_shortcuts(self) -> Dict[str, str]: + """ + List ALL non-Steam shortcuts. + Uses native VDF parsing when enabled, else protontricks -l. + Returns dict mapping shortcut name to AppID. + """ + if self.use_native_operations: + self.logger.info("Listing non-Steam shortcuts via native VDF parsing...") + try: + return self._get_native_steam_service().list_non_steam_shortcuts() + except Exception as e: + self.logger.warning(f"Native shortcut listing failed, falling back to protontricks: {e}") + + self.logger.info("Listing ALL non-Steam shortcuts via protontricks...") + non_steam_shortcuts = {} + if not self.which_protontricks: + self.logger.info("Protontricks type/path not yet determined. Running detection...") + if not self.detect_protontricks(): + self.logger.error("Protontricks detection failed. Cannot list shortcuts.") + return {} + self.logger.info(f"Protontricks detection successful: {self.which_protontricks}") + try: + cmd = [] + if self.which_protontricks == 'flatpak': + cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-l"] + elif self.protontricks_path: + cmd = [self.protontricks_path, "-l"] + else: + self.logger.error("Protontricks path not determined, cannot list shortcuts.") + return {} + self.logger.debug(f"Running command: {' '.join(cmd)}") + env = self._get_clean_subprocess_env() + result = subprocess.run(cmd, capture_output=True, text=True, check=True, encoding='utf-8', errors='ignore', env=env) + pattern = re.compile(r"Non-Steam shortcut:\s+(.+)\s+\((\d+)\)") + for line in result.stdout.splitlines(): + line = line.strip() + match = pattern.match(line) + if match: + app_name = match.group(1).strip() + app_id = match.group(2).strip() + non_steam_shortcuts[app_name] = app_id + self.logger.debug(f"Found non-Steam shortcut: '{app_name}' with AppID {app_id}") + if not non_steam_shortcuts: + self.logger.warning("No non-Steam shortcuts found in protontricks output.") + except FileNotFoundError: + self.logger.error(f"Protontricks command not found. Path: {cmd[0] if cmd else 'N/A'}") + return {} + except subprocess.CalledProcessError as e: + self.logger.error(f"Error running protontricks -l (Exit code: {e.returncode}): {e}") + self.logger.error(f"Stderr (truncated): {e.stderr[:500] if e.stderr else ''}") + except Exception as e: + self.logger.error(f"Unexpected error listing non-Steam shortcuts: {e}", exc_info=True) + return {} + return non_steam_shortcuts + + def protontricks_alias(self): + """Create protontricks alias in ~/.bashrc (flatpak only). Returns True on success.""" + self.logger.info("Creating protontricks alias in ~/.bashrc...") + try: + if self.which_protontricks == 'flatpak': + bashrc_path = os.path.expanduser("~/.bashrc") + protontricks_alias_exists = False + launch_alias_exists = False + if os.path.exists(bashrc_path): + with open(bashrc_path, 'r') as f: + content = f.read() + protontricks_alias_exists = "alias protontricks=" in content + launch_alias_exists = "alias protontricks-launch=" in content + with open(bashrc_path, 'a') as f: + if not protontricks_alias_exists: + f.write("\n# Jackify: Protontricks alias\n") + alias_cmd = self._get_flatpak_alias_string() + f.write(f"alias protontricks='{alias_cmd}'\n") + self.logger.debug("Added protontricks alias to ~/.bashrc") + if not launch_alias_exists: + f.write("\n# Jackify: Protontricks-launch alias\n") + launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch') + f.write(f"alias protontricks-launch='{launch_alias_cmd}'\n") + self.logger.debug("Added protontricks-launch alias to ~/.bashrc") + self.logger.info("Protontricks aliases created successfully") + return True + self.logger.info("Protontricks is not installed via flatpak, skipping alias creation") + return True + except Exception as e: + self.logger.error(f"Error creating protontricks alias: {e}") + return False + + def _ensure_flatpak_cache_access(self, cache_path: Path) -> bool: + """Ensure flatpak protontricks has filesystem access to the winetricks cache dir. + WINETRICKS_CACHE is passed at run time via flatpak run --env= (see run_protontricks).""" + if self.which_protontricks != 'flatpak': + return True + try: + cache_str = str(cache_path.resolve()) + result = subprocess.run( + ['flatpak', 'override', '--user', '--show', 'com.github.Matoking.protontricks'], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0 and f'filesystems=' in result.stdout and cache_str in result.stdout: + self.logger.debug(f"Flatpak protontricks already has cache filesystem access: {cache_str}") + return True + self.logger.info(f"Granting flatpak protontricks filesystem access to winetricks cache: {cache_path}") + result = subprocess.run( + ['flatpak', 'override', '--user', 'com.github.Matoking.protontricks', + f'--filesystem={cache_str}'], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + self.logger.info("Successfully granted flatpak protontricks cache filesystem access") + return True + self.logger.warning(f"Failed to grant flatpak cache access: {result.stderr}") + return False + except Exception as e: + self.logger.warning(f"Could not configure flatpak cache access: {e}") + return False diff --git a/jackify/backend/handlers/resolution_handler.py b/jackify/backend/handlers/resolution_handler.py index 799e6c0..99681b9 100644 --- a/jackify/backend/handlers/resolution_handler.py +++ b/jackify/backend/handlers/resolution_handler.py @@ -100,11 +100,7 @@ class ResolutionHandler: while True: user_res = input(f"{COLOR_PROMPT}Enter desired resolution (e.g., 1920x1080): {COLOR_RESET}").strip() if self._validate_resolution_format(user_res): - # Optional: Add confirmation step here if desired - # confirm = input(f"{COLOR_PROMPT}Use resolution {user_res}? (Y/n): {COLOR_RESET}").lower() - # if confirm != 'n': - # return user_res - return user_res # Return validated resolution + return user_res else: print(f"{COLOR_ERROR}Invalid format. Please use format WxH (e.g., 1920x1080){COLOR_RESET}") else: diff --git a/jackify/backend/handlers/shortcut_creation.py b/jackify/backend/handlers/shortcut_creation.py new file mode 100644 index 0000000..f0ab5f1 --- /dev/null +++ b/jackify/backend/handlers/shortcut_creation.py @@ -0,0 +1,156 @@ +"""Shortcut creation methods for ShortcutHandler (Mixin).""" +import logging +import os +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class ShortcutCreationMixin: + """Mixin providing shortcut creation methods.""" + + def create_shortcut(self, executable_path=None, shortcut_name=None, launch_options="", icon_path="", + install_dir=None, download_dir=None): + """ + Create a new Steam shortcut entry. + + Args: + executable_path (str): Path to the main executable (e.g., Hoolamike.exe) + shortcut_name (str): Name for the Steam shortcut + launch_options (str): Launch options string (optional) + icon_path (str): Path to the icon for the shortcut (optional) + install_dir: Optional modlist install path; its mountpoint is added to STEAM_COMPAT_MOUNTS + download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS + + Returns: + tuple: (bool success, Optional[str] app_id) - Success status and the generated AppID, or None if failed. + """ + self.logger.info(f"Attempting to create shortcut for: {shortcut_name}") + self.logger.debug(f"[DEBUG] create_shortcut called with executable_path={executable_path}, shortcut_name={shortcut_name}, icon_path={icon_path}") + self._last_shortcuts_backup = None + self._safe_shortcuts_backup = None + self._shortcuts_file = None + + if executable_path: + exe_dir = os.path.dirname(executable_path) + steam_icons_path = Path(exe_dir) / "Steam Icons" + steamicons_path = Path(exe_dir) / "SteamIcons" + if steam_icons_path.is_dir() and not steamicons_path.is_dir(): + try: + steam_icons_path.rename(steamicons_path) + self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {exe_dir}") + except Exception as e: + self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}") + + if not executable_path or not os.path.exists(executable_path): + self.logger.error(f"Invalid or non-existent executable path provided: {executable_path}") + return False, None + else: + start_dir = os.path.dirname(executable_path) + + if not shortcut_name: + self.logger.error("Shortcut name not provided.") + return False, None + + try: + shortcuts_file = self.shortcuts_path + self._shortcuts_file = shortcuts_file + + if not shortcuts_file or not os.path.isfile(shortcuts_file): + self.logger.error("shortcuts.vdf path not found or is invalid.") + self.logger.error("Could not find the Steam shortcuts file (shortcuts.vdf).") + config_dir = os.path.dirname(shortcuts_file) if shortcuts_file else None + if config_dir and os.path.isdir(config_dir): + self.logger.warning(f"Attempting to create blank shortcuts.vdf at {shortcuts_file}") + with open(shortcuts_file, 'wb') as f: + f.write(b'\x00shortcuts\x00\x08\x08') + self.logger.info("Created blank shortcuts.vdf.") + else: + self.logger.error("Cannot create shortcuts.vdf as parent directory doesn't exist.") + return False, None + else: + config_dir = os.path.dirname(shortcuts_file) + if not os.path.isdir(config_dir): + self.logger.error(f"Config directory not found: {config_dir}") + self.logger.error(f"Steam config directory not found: {config_dir}") + return False, None + + backup_dir = os.path.join(config_dir, "backups") + os.makedirs(backup_dir, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") + backup_path = os.path.join(backup_dir, f"shortcuts_{timestamp}.bak") + + if os.path.exists(shortcuts_file): + import shutil + shutil.copy2(shortcuts_file, backup_path) + self._last_shortcuts_backup = backup_path + self.logger.info(f"Created backup at {backup_path}") + else: + self.logger.warning(f"shortcuts.vdf does not exist at {shortcuts_file}, cannot create backup. Proceeding with potentially new file.") + + compat_mounts_str = "" + try: + self.logger.info("Determining necessary STEAM_COMPAT_MOUNTS...") + mount_paths = self.path_handler.get_steam_compat_mount_paths( + install_dir=install_dir, download_dir=download_dir + ) + if mount_paths: + compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}"' + self.logger.info(f"Generated STEAM_COMPAT_MOUNTS string: {compat_mounts_str}") + else: + self.logger.info("No additional libraries or mountpoints needed for STEAM_COMPAT_MOUNTS.") + + except Exception as e: + self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True) + + final_launch_options = launch_options + if compat_mounts_str: + if final_launch_options: + final_launch_options = f"{compat_mounts_str} {final_launch_options}" + else: + final_launch_options = compat_mounts_str + + if not final_launch_options.strip().endswith("%command%"): + if final_launch_options: + final_launch_options = f"{final_launch_options} %command%" + else: + final_launch_options = "%command%" + + self.logger.debug(f"Final launch options string: {final_launch_options}") + + success, app_id = self._add_steam_shortcut_safely( + shortcuts_file, + shortcut_name, + executable_path, + start_dir, + icon_path=icon_path, + launch_options=final_launch_options, + tags=["Jackify", "Tool"] + ) + + if not success: + self.logger.error("Failed to add shortcut entry safely.") + return False, None + + self.logger.info(f"Shortcut created successfully for {shortcut_name} with AppID {app_id}") + return True, app_id + + except Exception as e: + self.logger.error(f"Error creating shortcut: {e}", exc_info=True) + print(f"An error occurred while creating the shortcut: {e}") + return False, None + + def _is_steam_deck(self): + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + return True + import subprocess + user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True) + if 'app-steam@autostart.service' in user_services.stdout: + return True + except Exception as e: + self.logger.warning(f"Error detecting Steam Deck: {e}") + return False diff --git a/jackify/backend/handlers/shortcut_discovery.py b/jackify/backend/handlers/shortcut_discovery.py new file mode 100644 index 0000000..32d5b16 --- /dev/null +++ b/jackify/backend/handlers/shortcut_discovery.py @@ -0,0 +1,340 @@ +"""Shortcut discovery and AppID methods for ShortcutHandler (Mixin).""" +import logging +import os +import re +from pathlib import Path +from typing import List, Dict, Optional + +from .vdf_handler import VDFHandler + +logger = logging.getLogger(__name__) + + +class ShortcutDiscoveryMixin: + """Mixin providing shortcut discovery and AppID resolution methods.""" + + # DEAD CODE - Commented out 2026-01-29 + # These methods were never completed. create_shortcut() requires arguments + # and returns tuple(bool, str), not dict. Kept for reference if CLI shortcut + # creation feature is implemented later. + # + # def create_shortcut_workflow(self): + # """Run the complete shortcut creation workflow""" + # shortcut_data = self.create_shortcut() + # if not shortcut_data: + # return False + # return True + # + # def create_new_modlist_shortcut(self): + # """Create a new modlist shortcut in Steam""" + # print("\nShortcut Creation") + # ... + # modlist_data = self.create_shortcut() # BUG: needs args, returns tuple not dict + # ... + + def get_selected_modlist(self): + """ + Get the selected modlist string in the format expected by ModlistHandler.configure_modlist + + Returns: + str: Selected modlist string in the format "Non-Steam shortcut: Name (AppID)" + or None if no modlist was selected + """ + return getattr(self, 'selected_modlist', None) + + def get_appid_for_shortcut(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]: + """ + Find the current AppID for a given shortcut name and (optionally) executable path. + + Primary method: Read directly from shortcuts.vdf (reliable, no external dependencies) + Fallback method: Use protontricks (if available) + + Args: + shortcut_name (str): The name of the Steam shortcut. + exe_path (Optional[str]): The path to the executable (for robust matching after Steam restart). + + Returns: + Optional[str]: The found AppID string, or None if not found or error occurs. + """ + self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')") + + try: + appid = self.get_appid_from_vdf(shortcut_name, exe_path) + if appid: + self.logger.info(f"Successfully found AppID {appid} from shortcuts.vdf") + return appid + + self.logger.info("AppID not found in shortcuts.vdf, trying protontricks as fallback...") + from .protontricks_handler import ProtontricksHandler + pt_handler = ProtontricksHandler(self.steamdeck) + if not pt_handler.detect_protontricks(): + self.logger.warning("Protontricks not detected - cannot use as fallback") + return None + result = pt_handler.run_protontricks("-l") + if not result or result.returncode != 0: + self.logger.warning(f"Protontricks fallback failed: {result.stderr if result else 'No result'}") + return None + found_shortcuts = [] + for line in result.stdout.splitlines(): + m = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line) + if m: + pt_name = m.group(1).strip() + pt_appid = m.group(2) + found_shortcuts.append((pt_name, pt_appid)) + vdf_shortcuts = [] + shortcuts_vdf_path = self.shortcuts_path + if shortcuts_vdf_path and os.path.isfile(shortcuts_vdf_path): + try: + shortcuts_data = VDFHandler.load(shortcuts_vdf_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() + exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip() + vdf_shortcuts.append((app_name, exe, idx)) + except Exception as e: + self.logger.error(f"Error parsing shortcuts.vdf for exe path matching: {e}") + if exe_path: + exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower() + shortcut_name_clean = shortcut_name.strip().lower() + for pt_name, pt_appid in found_shortcuts: + for vdf_name, vdf_exe, vdf_idx in vdf_shortcuts: + if vdf_name.strip().lower() == pt_name.strip().lower() == shortcut_name_clean: + vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower() + if vdf_exe_norm == exe_path_norm: + self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' with exe '{vdf_exe}' (input: '{exe_path}')") + return pt_appid + self.logger.error(f"No shortcut found matching both name '{shortcut_name}' and exe_path '{exe_path}'.") + return None + shortcut_name_clean = shortcut_name.strip().lower() + for pt_name, pt_appid in found_shortcuts: + if pt_name.strip().lower() == shortcut_name_clean: + self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' (input: '{shortcut_name}')") + return pt_appid + self.logger.error(f"Could not find an AppID for shortcut named '{shortcut_name}' via protontricks.") + return None + except Exception as e: + self.logger.error(f"Error getting AppID for shortcut '{shortcut_name}': {e}") + self.logger.exception("Traceback:") + return None + + def get_appid_from_vdf(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]: + """ + Get AppID directly from shortcuts.vdf by reading the file and matching shortcut name/exe. + This is more reliable than using protontricks since it doesn't depend on external tools. + + Args: + shortcut_name (str): The name of the Steam shortcut. + exe_path (Optional[str]): The path to the executable for additional validation. + + Returns: + Optional[str]: The AppID as a string, or None if not found. + """ + self.logger.info(f"Looking up AppID from shortcuts.vdf for shortcut: '{shortcut_name}' (exe: '{exe_path}')") + + if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): + self.logger.warning(f"Shortcuts.vdf not found at {self.shortcuts_path}") + return None + + try: + shortcuts_data = VDFHandler.load(self.shortcuts_path, binary=True) + if not shortcuts_data or 'shortcuts' not in shortcuts_data: + self.logger.warning("No shortcuts found in shortcuts.vdf") + return None + + shortcut_name_clean = shortcut_name.strip().lower() + + for idx, shortcut in shortcuts_data['shortcuts'].items(): + name = shortcut.get('AppName', shortcut.get('appname', '')).strip() + + if name.lower() == shortcut_name_clean: + appid = shortcut.get('appid') + + if appid: + if exe_path: + vdf_exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip() + exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower() + vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower() + + if vdf_exe_norm == exe_path_norm: + self.logger.info(f"Found AppID {appid} for shortcut '{name}' with matching exe '{vdf_exe}'") + return str(int(appid) & 0xFFFFFFFF) + else: + self.logger.debug(f"Found shortcut '{name}' but exe doesn't match: '{vdf_exe}' vs '{exe_path}'") + continue + else: + self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)") + return str(int(appid) & 0xFFFFFFFF) + + self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'") + return None + + except Exception as e: + self.logger.error(f"Error reading shortcuts.vdf: {e}") + self.logger.exception("Traceback:") + return None + + def _scan_shortcuts_for_executable(self, executable_name: str) -> List[Dict[str, str]]: + """ + Scans the user's shortcuts.vdf file for entries pointing to a specific executable. + + Args: + executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe") + + Returns: + List[Dict[str, str]]: A list of dictionaries, each containing {'name': AppName, 'path': StartDir} + for shortcuts matching the executable name. + """ + self.logger.info(f"Scanning {self.shortcuts_path} for executable '{executable_name}'...") + matched_shortcuts = [] + + if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): + self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations") + return [] + + shortcuts_file = self.shortcuts_path + try: + shortcuts_data = VDFHandler.load(shortcuts_file, binary=True) + if shortcuts_data is None or 'shortcuts' not in shortcuts_data: + self.logger.warning(f"Could not load or parse data from {shortcuts_file}") + return [] + + for shortcut_id, shortcut in shortcuts_data['shortcuts'].items(): + if not isinstance(shortcut, dict): + self.logger.warning(f"Skipping invalid shortcut entry (not a dict) at index {shortcut_id} in {shortcuts_file}") + continue + + app_name = shortcut.get('AppName', shortcut.get('appname')) + exe_path = shortcut.get('Exe', shortcut.get('exe', '')).strip('"') + start_dir = shortcut.get('StartDir', shortcut.get('startdir', '')).strip('"') + + if app_name and start_dir and os.path.basename(exe_path) == executable_name: + is_valid = True + if executable_name == "ModOrganizer.exe": + if not (Path(start_dir) / 'ModOrganizer.ini').exists(): + self.logger.warning(f"Found MO2 shortcut '{app_name}' but ModOrganizer.ini missing in '{start_dir}'") + is_valid = False + + if is_valid: + matched_shortcuts.append({'name': app_name, 'path': start_dir}) + self.logger.debug(f"Found '{executable_name}' shortcut in VDF: {app_name} -> {start_dir}") + + except Exception as e: + self.logger.error(f"Error processing {shortcuts_file}: {e}") + return [] + + self.logger.info(f"Scan complete. Found {len(matched_shortcuts)} potential '{executable_name}' shortcuts in VDF file.") + return matched_shortcuts + + def discover_executable_shortcuts(self, executable_name: str) -> List[str]: + """ + Discovers non-Steam shortcuts for a specific executable, cross-referencing + VDF files with the Protontricks runtime list. + + Args: + executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe") + + Returns: + List[str]: A list of strings in the format "Non-Steam shortcut: Name (AppID)" + for valid, matched shortcuts. + """ + self.logger.info(f"Discovering configured shortcuts for '{executable_name}'...") + + vdf_shortcuts = self._scan_shortcuts_for_executable(executable_name) + if not vdf_shortcuts: + self.logger.warning(f"No '{executable_name}' shortcuts found in VDF files.") + + pt_result = self.protontricks_handler.run_protontricks("-l") + if not pt_result or pt_result.returncode != 0: + self.logger.error(f"Protontricks failed to list applications: {pt_result.stderr if pt_result else 'No result'}") + return [] + + pt_shortcuts = {} + for line in pt_result.stdout.splitlines(): + line = line.strip() + if "Non-Steam shortcut:" in line: + match = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line) + if match: + pt_name = match.group(1).strip() + pt_appid = match.group(2) + pt_shortcuts[pt_name] = pt_appid + + if not pt_shortcuts: + self.logger.warning("No Non-Steam shortcuts listed by Protontricks.") + return [] + + final_list = [] + for vdf_shortcut in vdf_shortcuts: + vdf_name = vdf_shortcut['name'] + if vdf_name in pt_shortcuts: + runtime_appid = pt_shortcuts[vdf_name] + modlist_string = f"Non-Steam shortcut: {vdf_name} ({runtime_appid})" + final_list.append(modlist_string) + self.logger.debug(f"Validated shortcut: {modlist_string}") + + if not final_list: + self.logger.warning(f"No shortcuts for '{executable_name}' found in VDF matched the Protontricks list.") + + self.logger.info(f"Discovery complete. Found {len(final_list)} validated shortcuts for '{executable_name}'.") + return final_list + + def find_shortcuts_by_exe(self, executable_name: str) -> List[Dict]: + """Finds shortcuts in shortcuts.vdf that point to a specific executable. + + Args: + executable_name: The name of the executable (e.g., "ModOrganizer.exe") + to search for within the 'Exe' path. + + Returns: + A list of dictionaries, each representing a matching shortcut + and containing keys like 'AppName', 'Exe', 'StartDir'. + Returns an empty list if no matches are found or an error occurs. + """ + self.logger.info(f"Scanning {self.shortcuts_path} for executable: {executable_name}") + matching_shortcuts = [] + + if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): + self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations") + return [] + + vdf_path = self.shortcuts_path + try: + self.logger.debug(f"Parsing shortcuts file: {vdf_path}") + shortcuts_data = VDFHandler.load(vdf_path, binary=True) + + if not shortcuts_data or 'shortcuts' not in shortcuts_data: + self.logger.warning(f"Shortcuts data is empty or invalid in {vdf_path}") + return [] + + shortcuts_dict = shortcuts_data.get('shortcuts', {}) + + for index, shortcut_details in shortcuts_dict.items(): + if not isinstance(shortcut_details, dict): + self.logger.warning(f"Skipping invalid shortcut entry at index {index} in {vdf_path}") + continue + + exe_path = shortcut_details.get('Exe', shortcut_details.get('exe', '')).strip('"') + app_name = shortcut_details.get('AppName', shortcut_details.get('appname', 'Unknown Shortcut')) + + if executable_name in os.path.basename(exe_path): + self.logger.info(f"Found matching shortcut '{app_name}' in {vdf_path}") + app_id = shortcut_details.get('appid', shortcut_details.get('AppID', shortcut_details.get('appId', None))) + start_dir = shortcut_details.get('StartDir', shortcut_details.get('startdir', '')).strip('"') + + match = { + 'AppName': app_name, + 'Exe': exe_path, + 'StartDir': start_dir, + 'appid': app_id + } + matching_shortcuts.append(match) + else: + self.logger.debug(f"Skipping shortcut '{app_name}': Exe path '{exe_path}' does not contain '{executable_name}'") + + except Exception as e: + self.logger.error(f"Error processing shortcuts file {vdf_path}: {e}", exc_info=True) + return [] + + if not matching_shortcuts: + self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in {vdf_path}.") + + return matching_shortcuts diff --git a/jackify/backend/handlers/shortcut_handler.py b/jackify/backend/handlers/shortcut_handler.py index 0a23543..89fb6d0 100644 --- a/jackify/backend/handlers/shortcut_handler.py +++ b/jackify/backend/handlers/shortcut_handler.py @@ -3,8 +3,12 @@ import os import random import subprocess import logging -import readline # For tab completion import time + +try: + import readline # For tab completion +except ModuleNotFoundError: + readline = None import glob from pathlib import Path import vdf @@ -14,14 +18,26 @@ import shutil # Import other necessary modules from .protontricks_handler import ProtontricksHandler -from .vdf_handler import VDFHandler # Changed to relative import -from .path_handler import PathHandler # Added PathHandler import +from .vdf_handler import VDFHandler +from .path_handler import PathHandler from .completers import path_completer -# Get logger for the module +from .shortcut_vdf_management import ShortcutVDFManagementMixin +from .shortcut_creation import ShortcutCreationMixin +from .shortcut_steam_restart import ShortcutSteamRestartMixin +from .shortcut_discovery import ShortcutDiscoveryMixin +from .shortcut_launch_options import ShortcutLaunchOptionsMixin + logger = logging.getLogger(__name__) -class ShortcutHandler: + +class ShortcutHandler( + ShortcutVDFManagementMixin, + ShortcutCreationMixin, + ShortcutSteamRestartMixin, + ShortcutDiscoveryMixin, + ShortcutLaunchOptionsMixin, +): """Handles creation and management of Steam shortcuts""" def __init__(self, steamdeck: bool, verbose: bool = False): @@ -45,1397 +61,22 @@ class ShortcutHandler: def _enable_tab_completion(self): """Enable tab completion for file paths using the shared completer""" + if readline is None: + self.logger.debug("readline module not available; disabling CLI tab completion") + return readline.set_completer(path_completer) readline.set_completer_delims(' \t\n;') readline.parse_and_bind("tab: complete") - - def _get_mo2_path(self): - """ - Get the path to ModOrganizer.exe from user with tab completion - Returns: - tuple: (mo2_dir, mo2_path) or (None, None) if cancelled - """ - self._enable_tab_completion() - while True: - try: - path = input("\nEnter the path to ModOrganizer.exe or its containing directory: ").strip() - if not path: - return None, None - - # Convert to absolute path - path = os.path.expanduser(path) - path = os.path.abspath(path) - - # If directory provided, look for ModOrganizer.exe - if os.path.isdir(path): - mo2_path = os.path.join(path, "ModOrganizer.exe") - else: - mo2_path = path - path = os.path.dirname(path) - - # Verify ModOrganizer.exe exists - if os.path.isfile(mo2_path): - self.logger.debug(f"Found ModOrganizer.exe at: {mo2_path}") - return path, mo2_path - else: - print("ModOrganizer.exe not found at specified location. Please try again.") - except KeyboardInterrupt: - return None, None + + # DEAD CODE - Commented out 2026-01-29 + # These helper methods were meant for create_new_modlist_shortcut() in + # shortcut_discovery.py which was never completed. Kept for reference. + # + # def _get_mo2_path(self): + # """Get path to ModOrganizer.exe from user with tab completion""" + # ... + # + # def _get_modlist_name(self): + # """Get the modlist name from user""" + # ... - def _get_modlist_name(self): - """ - Get the modlist name from user - Returns: - str: Modlist name or None if cancelled - """ - try: - name = input("\nEnter a name for the modlist: ").strip() - if not name: - return None - return name - except KeyboardInterrupt: - return None - - def _check_and_restore_shortcuts_vdf(self): - """ - Check if shortcuts.vdf exists and restore from backup if missing. - Returns: - bool: True if file exists or was restored, False if unable to restore - """ - # Find all shortcuts.vdf paths - shortcuts_files = [] - for user_dir in os.listdir(self.shortcuts_path): - shortcuts_file = os.path.join(self.shortcuts_path, user_dir, "config", "shortcuts.vdf") - if os.path.dirname(shortcuts_file): - shortcuts_files.append(shortcuts_file) - - # Check if any are missing and need restoration - missing_files = [] - for file_path in shortcuts_files: - if not os.path.exists(file_path): - self.logger.warning(f"shortcuts.vdf is missing at: {file_path}") - missing_files.append(file_path) - - if not missing_files: - self.logger.debug("All shortcuts.vdf files are present") - return True - - # Try to restore from backups - restored = 0 - for file_path in missing_files: - # Try timestamped backup first - backup_files = sorted(glob.glob(f"{file_path}.*.bak"), reverse=True) - if backup_files: - try: - import shutil - shutil.copy2(backup_files[0], file_path) - self.logger.info(f"Restored {file_path} from {backup_files[0]}") - restored += 1 - continue - except Exception as e: - self.logger.error(f"Failed to restore from timestamped backup: {e}") - - # Try simple backup - simple_backup = f"{file_path}.bak" - if os.path.exists(simple_backup): - try: - import shutil - shutil.copy2(simple_backup, file_path) - self.logger.info(f"Restored {file_path} from simple backup") - restored += 1 - continue - except Exception as e: - self.logger.error(f"Failed to restore from simple backup: {e}") - - if restored == len(missing_files): - self.logger.info("Successfully restored all missing shortcuts.vdf files") - return True - elif restored > 0: - self.logger.warning(f"Partially restored {restored}/{len(missing_files)} shortcuts.vdf files") - return True - else: - self.logger.error("Failed to restore any shortcuts.vdf files") - return False - - def _modify_shortcuts_directly(self, shortcuts_file, modlist_name, mo2_path, mo2_dir): - """ - Directly modify shortcuts.vdf in a way that preserves Steam's exact binary format. - This is a fallback method when regular VDF handling might cause issues. - - Args: - shortcuts_file (str): Path to shortcuts.vdf - modlist_name (str): Name for the modlist - mo2_path (str): Path to ModOrganizer.exe - mo2_dir (str): Directory containing ModOrganizer.exe - - Returns: - bool: True if successful, False otherwise - """ - try: - # Make a secure backup first - import shutil - backup_path = f"{shortcuts_file}.{int(time.time())}.bak" - shutil.copy2(shortcuts_file, backup_path) - self.logger.info(f"Created backup before direct modification: {backup_path}") - - # Create a new shortcut entry using Steam's expected format - - # Pre-populate shortcuts.vdf if it doesn't exist or is empty - if not os.path.exists(shortcuts_file) or os.path.getsize(shortcuts_file) == 0: - with open(shortcuts_file, 'wb') as f: - f.write(b'\x00shortcuts\x00\x08\x08') - self.logger.info(f"Created new shortcuts.vdf file at {shortcuts_file}") - - # Use direct steam-vdf library for reliable binary operations - try: - # Try to import the steam-vdf library - import sys - import importlib.util - - # Check if steam_vdf is installed - steam_vdf_spec = importlib.util.find_spec("steam_vdf") - - if steam_vdf_spec is None: - # Try to install steam-vdf using pip - print("Installing required dependency (steam-vdf)...") - # CRITICAL: Use safe Python executable to prevent AppImage recursive spawning - from jackify.backend.handlers.subprocess_utils import get_safe_python_executable - python_exe = get_safe_python_executable() - subprocess.check_call([python_exe, "-m", "pip", "install", "steam-vdf", "--user"]) - time.sleep(1) # Give some time for the install to complete - - # Now import it - import vdf as steam_vdf - - with open(shortcuts_file, 'rb') as f: - shortcuts_data = steam_vdf.load(f) - - # Find the highest shortcut ID to use for the new entry - max_id = -1 - if 'shortcuts' in shortcuts_data: - for id_str in shortcuts_data['shortcuts']: - try: - id_num = int(id_str) - if id_num > max_id: - max_id = id_num - except ValueError: - pass - - # Create a new shortcut entry - new_id = max_id + 1 - - # Ensure 'shortcuts' key exists - if 'shortcuts' not in shortcuts_data: - shortcuts_data['shortcuts'] = {} - - # Add the new shortcut - shortcuts_data['shortcuts'][str(new_id)] = { - 'AppName': modlist_name, - 'Exe': f'"{mo2_path}"', - 'StartDir': mo2_dir, - 'icon': '', - 'ShortcutPath': '', - 'LaunchOptions': '', - 'IsHidden': 0, - 'AllowDesktopConfig': 1, - 'AllowOverlay': 1, - 'OpenVR': 0, - 'Devkit': 0, - 'DevkitGameID': '', - 'LastPlayTime': 0 - } - - # Write back to file - with open(shortcuts_file, 'wb') as f: - steam_vdf.dump(shortcuts_data, f) - - self.logger.info(f"Added shortcut for {modlist_name} using steam-vdf library") - return True - - except Exception as e: - self.logger.warning(f"Failed to use steam-vdf library: {e}") - - # Fall back to our safe VDFHandler - self.logger.info("Falling back to VDFHandler for shortcuts.vdf modification") - shortcuts_data = VDFHandler.load(shortcuts_file, binary=True) - - # If the data is empty, initialize it - if not shortcuts_data: - shortcuts_data = {'shortcuts': {}} - - # Create new shortcut entry - new_id = len(shortcuts_data.get('shortcuts', {})) - new_entry = { - 'AppName': modlist_name, - 'Exe': f'"{mo2_path}"', - 'StartDir': mo2_dir, - 'icon': '', - 'ShortcutPath': '', - 'LaunchOptions': '', - 'IsHidden': 0, - 'AllowDesktopConfig': 1, - 'AllowOverlay': 1, - 'OpenVR': 0, - 'Devkit': 0, - 'DevkitGameID': '', - 'LastPlayTime': 0 - } - - # Add to shortcuts - if 'shortcuts' not in shortcuts_data: - shortcuts_data['shortcuts'] = {} - shortcuts_data['shortcuts'][str(new_id)] = new_entry - - # Write back to file using our safe VDFHandler - result = VDFHandler.save(shortcuts_file, shortcuts_data, binary=True) - - self.logger.info(f"Added shortcut for {modlist_name} using VDFHandler") - return result - - except Exception as e: - self.logger.error(f"Error in direct shortcut modification: {e}") - return False - - def _add_steam_shortcut_safely(self, shortcuts_file, app_name, exe_path, start_dir, icon_path="", launch_options="", tags=None): - """ - Adds a new shortcut entry to the shortcuts.vdf file using the correct binary format. - This method is carefully designed to maintain file integrity. - - Args: - shortcuts_file (str): Path to shortcuts.vdf - app_name (str): Name for the shortcut - exe_path (str): Path to the executable - start_dir (str): Start directory for the executable - icon_path (str): Path to icon file (optional) - launch_options (str): Command line options (optional) - tags (list): List of tags (optional) - - Returns: - tuple: (bool success, str app_id) - Success status and calculated AppID - """ - if tags is None: - tags = [] # Ensure tags is a list - - # Initialize data structure - data = {'shortcuts': {}} # Default structure if file doesn't exist or is empty - - try: - # CRITICAL: Open in BINARY READ mode ('rb') - if os.path.exists(shortcuts_file): - with open(shortcuts_file, 'rb') as f: - file_data = f.read() - if file_data: # Only try to parse if the file has content - try: - data = vdf.binary_loads(file_data) - # Ensure the top-level 'shortcuts' key exists - if 'shortcuts' not in data: - data['shortcuts'] = {} - except Exception as e: - self.logger.warning(f"Could not parse existing shortcuts.vdf: {e}") - # Reset to default structure if loading fails - data = {'shortcuts': {}} - else: - self.logger.info(f"shortcuts.vdf not found at {shortcuts_file}. A new file will be created.") - except Exception as e: - self.logger.warning(f"Error accessing shortcuts.vdf: {e}") - # Reset to default structure if loading fails - data = {'shortcuts': {}} - - # Ensure the shortcuts key exists - if 'shortcuts' not in data: - data['shortcuts'] = {} - - # Find the next available index key (0, 1, 2, ...) - next_index = 0 - if data.get('shortcuts'): # Check if shortcuts dictionary exists and is not empty - shortcut_indices = [int(k) for k in data['shortcuts'].keys() if k.isdigit()] - if shortcut_indices: - next_index = max(shortcut_indices) + 1 - - # Steam expects specific fields for each shortcut. - # Even empty ones are often necessary. - new_shortcut = { - 'AppName': app_name, - 'Exe': f'"{exe_path}"', # Enclose executable path in quotes - 'StartDir': f'"{start_dir}"', # Enclose start directory in quotes - 'icon': icon_path, - 'ShortcutPath': "", # Usually empty for non-Steam games - 'LaunchOptions': launch_options, - 'IsHidden': 0, # 0 for visible, 1 for hidden - 'AllowDesktopConfig': 1, # Allow Steam Input configuration - 'AllowOverlay': 1, # Allow Steam Overlay - 'OpenVR': 0, # Set to 1 for VR games - 'Devkit': 0, - 'DevkitGameID': '', - 'DevkitOverrideAppID': 0, - 'LastPlayTime': 0, # Timestamp, 0 for never played - 'FlatpakAppID': '', # For Flatpak apps on Linux - 'IsInstalled': 1, # Make it appear in "Locally Installed" filter - } - - # Add tags in the correct format if any - if tags: - new_shortcut['tags'] = {str(i): tag for i, tag in enumerate(tags)} - - # Calculate the AppID - this is how Steam does it - app_id = (0x80000000 + int(next_index)) % (2**32) - - # Ensure the AppID is within the valid 32-bit signed integer range - if app_id > 0x7FFFFFFF: - app_id = app_id - 0x100000000 - - # Add the appid to the shortcut entry (like STL does) - new_shortcut['appid'] = app_id - - # Add the new shortcut entry using the string representation of the index - data['shortcuts'][str(next_index)] = new_shortcut - self.logger.info(f"Adding shortcut '{app_name}' at index {next_index}") - - try: - # CRITICAL: Open in BINARY WRITE mode ('wb') - # First create a temp file to ensure we don't corrupt the original if something goes wrong - temp_file = f"{shortcuts_file}.temp" - with open(temp_file, 'wb') as f: - vdf_data = vdf.binary_dumps(data) - f.write(vdf_data) - - # Now rename the temp file to the actual file - import shutil - shutil.move(temp_file, shortcuts_file) - - self.logger.info(f"Successfully updated shortcuts.vdf! AppID: {app_id}") - return True, app_id - except Exception as e: - self.logger.error(f"Error: Failed to write updated shortcuts.vdf: {e}") - return False, None - - def create_shortcut(self, executable_path=None, shortcut_name=None, launch_options="", icon_path=""): - """ - Create a new Steam shortcut entry. - - Args: - executable_path (str): Path to the main executable (e.g., Hoolamike.exe) - shortcut_name (str): Name for the Steam shortcut - launch_options (str): Launch options string (optional) - icon_path (str): Path to the icon for the shortcut (optional) - - Returns: - tuple: (bool success, Optional[str] app_id) - Success status and the generated AppID, or None if failed. - """ - self.logger.info(f"Attempting to create shortcut for: {shortcut_name}") - self.logger.debug(f"[DEBUG] create_shortcut called with executable_path={executable_path}, shortcut_name={shortcut_name}, icon_path={icon_path}") - self._last_shortcuts_backup = None - self._safe_shortcuts_backup = None - self._shortcuts_file = None # Ensure this is reset/set correctly - - # --- Steam Icons normalization (move here for all flows) --- - if executable_path: - exe_dir = os.path.dirname(executable_path) - steam_icons_path = Path(exe_dir) / "Steam Icons" - steamicons_path = Path(exe_dir) / "SteamIcons" - if steam_icons_path.is_dir() and not steamicons_path.is_dir(): - try: - steam_icons_path.rename(steamicons_path) - self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {exe_dir}") - except Exception as e: - self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}") - # ---------------------------------------------------------- - - # Validate inputs - if not executable_path or not os.path.exists(executable_path): - self.logger.error(f"Invalid or non-existent executable path provided: {executable_path}") - return False, None - else: - start_dir = os.path.dirname(executable_path) - - if not shortcut_name: - self.logger.error("Shortcut name not provided.") - return False, None - - try: - # Use the shortcuts.vdf path found during initialization - shortcuts_file = self.shortcuts_path - self._shortcuts_file = shortcuts_file # Store for potential use - - if not shortcuts_file or not os.path.isfile(shortcuts_file): - self.logger.error("shortcuts.vdf path not found or is invalid.") - print("Error: Could not find the Steam shortcuts file (shortcuts.vdf).") - # Attempt to create a blank one? Might be risky. - # Let's try creating it if the directory exists. - config_dir = os.path.dirname(shortcuts_file) if shortcuts_file else None - if config_dir and os.path.isdir(config_dir): - self.logger.warning(f"Attempting to create blank shortcuts.vdf at {shortcuts_file}") - with open(shortcuts_file, 'wb') as f: - f.write(b'\x00shortcuts\x00\x08\x08') # Minimal valid binary VDF structure - self.logger.info("Created blank shortcuts.vdf.") - else: - self.logger.error("Cannot create shortcuts.vdf as parent directory doesn't exist.") - return False, None - else: - # Ensure the parent directory exists for backups if shortcuts_file was valid - config_dir = os.path.dirname(shortcuts_file) - if not os.path.isdir(config_dir): - self.logger.error(f"Config directory not found: {config_dir}") - print(f"Error: Steam config directory not found: {config_dir}") - return False, None - - # Create a direct backup before making any changes - backup_dir = os.path.join(config_dir, "backups") - os.makedirs(backup_dir, exist_ok=True) - timestamp = time.strftime("%Y%m%d_%H%M%S") - backup_path = os.path.join(backup_dir, f"shortcuts_{timestamp}.bak") - - # Check if the shortcuts file exists before backing up - if os.path.exists(shortcuts_file): - import shutil - shutil.copy2(shortcuts_file, backup_path) - self._last_shortcuts_backup = backup_path # Store for potential restoration - self.logger.info(f"Created backup at {backup_path}") - else: - self.logger.warning(f"shortcuts.vdf does not exist at {shortcuts_file}, cannot create backup. Proceeding with potentially new file.") - - # --- Add STEAM_COMPAT_MOUNTS --- (Keep this logic) - compat_mounts_str = "" - try: - self.logger.info("Determining necessary STEAM_COMPAT_MOUNTS...") - all_libs = self.path_handler.get_all_steam_library_paths() - main_steam_lib_path_obj = self.path_handler.find_steam_library() - if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": - main_steam_lib_path = main_steam_lib_path_obj.parent.parent - else: - main_steam_lib_path = main_steam_lib_path_obj - - mount_paths = [] - if main_steam_lib_path: - self.logger.debug(f"Identified main Steam library: {main_steam_lib_path}") - main_resolved = main_steam_lib_path.resolve() - for lib_path in all_libs: - if lib_path.resolve() != main_resolved: - mount_paths.append(str(lib_path.resolve())) - else: - self.logger.debug(f"Excluding main library {lib_path} from mounts.") - else: - self.logger.warning("Could not reliably determine the main Steam library. STEAM_COMPAT_MOUNTS may include it or be empty.") - mount_paths = [] - - if mount_paths: - compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}"' - self.logger.info(f"Generated STEAM_COMPAT_MOUNTS string: {compat_mounts_str}") - else: - self.logger.info("No additional libraries identified or needed for STEAM_COMPAT_MOUNTS.") - - except Exception as e: - self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True) - - # Prepend STEAM_COMPAT_MOUNTS to existing launch options - final_launch_options = launch_options - if compat_mounts_str: - if final_launch_options: - final_launch_options = f"{compat_mounts_str} {final_launch_options}" - else: - final_launch_options = compat_mounts_str - - # Ensure %command% is at the end if not already present - if not final_launch_options.strip().endswith("%command%"): - if final_launch_options: - final_launch_options = f"{final_launch_options} %command%" - else: - final_launch_options = "%command%" - - self.logger.debug(f"Final launch options string: {final_launch_options}") - # --- End STEAM_COMPAT_MOUNTS --- - - # Add the shortcut using our safe method - success, app_id = self._add_steam_shortcut_safely( - shortcuts_file, - shortcut_name, - executable_path, # Use the validated path - start_dir, # Use the derived start_dir - icon_path=icon_path, # Pass the icon path - launch_options=final_launch_options, # Pass the combined options - tags=["Jackify", "Tool"] # Add relevant tags - ) - - if not success: - self.logger.error("Failed to add shortcut entry safely.") - return False, None - - self.logger.info(f"Shortcut created successfully for {shortcut_name} with AppID {app_id}") - return True, app_id - - except Exception as e: - self.logger.error(f"Error creating shortcut: {e}", exc_info=True) - print(f"An error occurred while creating the shortcut: {e}") - return False, None - - def _is_steam_deck(self): - # Check /etc/os-release for 'steamdeck' or if the systemd service exists - try: - if os.path.exists('/etc/os-release'): - with open('/etc/os-release') as f: - if 'steamdeck' in f.read().lower(): - return True - # Check for the systemd user service - user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True) - if 'app-steam@autostart.service' in user_services.stdout: - return True - except Exception as e: - self.logger.warning(f"Error detecting Steam Deck: {e}") - return False - - def secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool: - """ - Secure Steam restart with comprehensive error handling to prevent segfaults. - Now delegates to the robust steam restart service for cross-distro compatibility. - """ - try: - from ..services.steam_restart_service import robust_steam_restart - return robust_steam_restart(progress_callback=status_callback, timeout=60) - except ImportError as e: - self.logger.error(f"Failed to import steam restart service: {e}") - # Fallback to original implementation if service is not available - return self._legacy_secure_steam_restart(status_callback) - except Exception as e: - self.logger.error(f"Error in robust steam restart: {e}") - # Fallback to original implementation on any error - return self._legacy_secure_steam_restart(status_callback) - - def _legacy_secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool: - """ - Legacy secure Steam restart implementation (fallback). - """ - import subprocess - import time - import os - - self.logger.info("Attempting secure Steam restart sequence...") - - # Wrap all subprocess calls in try-catch to prevent segfaults - def safe_subprocess_run(cmd, **kwargs): - """Safely run subprocess with error handling""" - try: - return subprocess.run(cmd, **kwargs) - except Exception as e: - self.logger.error(f"Subprocess error with cmd {cmd}: {e}") - return subprocess.CompletedProcess(cmd, 1, "", str(e)) - - def safe_subprocess_popen(cmd, **kwargs): - """Safely start subprocess with error handling""" - try: - return subprocess.Popen(cmd, **kwargs) - except Exception as e: - self.logger.error(f"Popen error with cmd {cmd}: {e}") - return None - - if self._is_steam_deck(): - self.logger.info("Detected Steam Deck. Using systemd to restart Steam.") - if status_callback: - try: - status_callback("Restarting Steam via systemd...") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - - try: - result = safe_subprocess_run(['systemctl', '--user', 'restart', 'app-steam@autostart.service'], capture_output=True, text=True, timeout=30) - self.logger.info(f"systemctl restart output: {result.stdout.strip()} {result.stderr.strip()}") - # Wait a bit for Steam to come up - time.sleep(10) - # Optionally, check if Steam is running - check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) - if check.returncode == 0: - self.logger.info("Steam restarted successfully via systemd.") - if status_callback: - try: - status_callback("Steam Started") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - return True - else: - self.logger.error("Steam did not start after systemd restart.") - if status_callback: - try: - status_callback("Start Failed") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - return False - except Exception as e: - self.logger.error(f"Error restarting Steam via systemd: {e}") - if status_callback: - try: - status_callback("Restart Failed") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - return False - - # --- Non-Steam Deck (generic Linux) implementation --- - try: - if status_callback: - try: - status_callback("Stopping Steam...") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - - self.logger.info("Attempting clean Steam shutdown via 'steam -shutdown'...") - shutdown_timeout = 30 - result = safe_subprocess_run(['steam', '-shutdown'], timeout=shutdown_timeout, check=False, capture_output=True, text=True) - if result.returncode != 1: # subprocess.run returns CompletedProcess even on error - self.logger.debug("'steam -shutdown' command executed (exit code ignored, verification follows).") - else: - self.logger.warning(f"'steam -shutdown' had issues: {result.stderr}") - except Exception as e: - self.logger.warning(f"Error executing 'steam -shutdown': {e}. Will proceed to check processes.") - - if status_callback: - try: - status_callback("Waiting for Steam to close...") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - - self.logger.info("Verifying Steam processes are terminated...") - max_attempts = 6 - steam_closed_successfully = False - - for attempt in range(max_attempts): - try: - check_cmd = ['pgrep', '-f', 'steamwebhelper'] - self.logger.debug(f"Executing check: {' '.join(check_cmd)}") - result = safe_subprocess_run(check_cmd, capture_output=True, timeout=10) - if result.returncode != 0: - self.logger.info("No Steam web helper processes found via pgrep.") - steam_closed_successfully = True - break - else: - try: - steam_pids = result.stdout.decode().strip().split('\n') if result.stdout else [] - self.logger.debug(f"Steam web helper processes still detected (PIDs: {steam_pids}). Waiting... (Attempt {attempt + 1}/{max_attempts} after shutdown cmd)") - except Exception as e: - self.logger.warning(f"Error parsing pgrep output: {e}") - time.sleep(5) - except Exception as e: - self.logger.warning(f"Error checking Steam processes (attempt {attempt + 1}): {e}") - time.sleep(5) - - if not steam_closed_successfully: - self.logger.debug("Steam processes still running after 'steam -shutdown'. Attempting fallback with 'pkill steam'...") - if status_callback: - try: - status_callback("Force stopping Steam...") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - - # Fallback: Use pkill to force terminate Steam processes - try: - self.logger.info("Attempting force shutdown via 'pkill steam'...") - pkill_result = safe_subprocess_run(['pkill', '-f', 'steam'], timeout=15, check=False, capture_output=True, text=True) - self.logger.info(f"pkill steam result: {pkill_result.returncode} - {pkill_result.stdout.strip()} {pkill_result.stderr.strip()}") - - # Wait a bit for processes to terminate - time.sleep(3) - - # Check again if Steam processes are terminated - final_check = safe_subprocess_run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10) - if final_check.returncode != 0: - self.logger.info("Steam processes successfully terminated via pkill fallback.") - steam_closed_successfully = True - else: - self.logger.debug("Steam processes still running after pkill fallback.") - if status_callback: - try: - status_callback("Shutdown Failed") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - return False - - except Exception as e: - self.logger.error(f"Error during pkill fallback: {e}") - if status_callback: - try: - status_callback("Shutdown Failed") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - return False - - if not steam_closed_successfully: - self.logger.error("Failed to terminate Steam processes via all methods.") - if status_callback: - try: - status_callback("Shutdown Failed") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - return False - - self.logger.info("Steam confirmed closed.") - - start_methods = [ - {"name": "Popen", "cmd": ["steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}}, - {"name": "setsid", "cmd": ["setsid", "steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL}}, - {"name": "nohup", "cmd": ["nohup", "steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "preexec_fn": os.setpgrp}} - ] - steam_start_initiated = False - - for i, method in enumerate(start_methods): - method_name = method["name"] - status_msg = f"Starting Steam ({method_name})" - if status_callback: - try: - status_callback(status_msg) - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - - self.logger.info(f"Attempting to start Steam using method: {method_name}") - try: - process = safe_subprocess_popen(method["cmd"], **method["kwargs"]) - if process is not None: - self.logger.info(f"Initiated Steam start with {method_name}.") - time.sleep(5) - check_result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) - if check_result.returncode == 0: - self.logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.") - steam_start_initiated = True - break - else: - self.logger.warning(f"Steam process not detected after initiating with {method_name}. Trying next method.") - else: - self.logger.warning(f"Failed to start process with {method_name}. Trying next method.") - except FileNotFoundError: - self.logger.error(f"Command not found for method {method_name} (e.g., setsid, nohup). Trying next method.") - except Exception as e: - self.logger.error(f"Error starting Steam with {method_name}: {e}. Trying next method.") - - if not steam_start_initiated: - self.logger.error("All methods to initiate Steam start failed.") - if status_callback: - try: - status_callback("Start Failed") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - return False - - status_msg = "Waiting for Steam to fully start" - if status_callback: - try: - status_callback(status_msg) - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - - self.logger.info("Waiting up to 2 minutes for Steam to fully initialize...") - max_startup_wait = 120 - elapsed_wait = 0 - initial_wait_done = False - - while elapsed_wait < max_startup_wait: - try: - result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) - if result.returncode == 0: - if not initial_wait_done: - self.logger.info("Steam process detected. Waiting additional time for full initialization...") - initial_wait_done = True - time.sleep(5) - elapsed_wait += 5 - if initial_wait_done and elapsed_wait >= 15: - final_check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) - if final_check.returncode == 0: - if status_callback: - try: - status_callback("Steam Started") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - self.logger.info("Steam confirmed running after wait.") - return True - else: - self.logger.warning("Steam process disappeared during final initialization wait.") - break - else: - self.logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)") - time.sleep(5) - elapsed_wait += 5 - except Exception as e: - self.logger.warning(f"Error during Steam startup wait: {e}") - time.sleep(5) - elapsed_wait += 5 - - self.logger.error("Steam failed to start/initialize within the allowed time.") - if status_callback: - try: - status_callback("Start Timed Out") - except Exception as e: - self.logger.warning(f"Status callback error: {e}") - return False - - def _verify_and_restore_shortcuts(self): - """ - Verify shortcuts.vdf exists after Steam restart and restore it if needed. - """ - shortcuts_file = getattr(self, '_shortcuts_file', None) - if not shortcuts_file: - self.logger.warning("No shortcuts file to verify") - return - - if not os.path.exists(shortcuts_file) or os.path.getsize(shortcuts_file) == 0: - self.logger.warning(f"shortcuts.vdf missing or empty after restart: {shortcuts_file}") - - # Try to restore from pre-restart backup - safe_backup = getattr(self, '_safe_shortcuts_backup', None) - if safe_backup and os.path.exists(safe_backup): - try: - import shutil - shutil.copy2(safe_backup, shortcuts_file) - self.logger.info(f"Restored shortcuts.vdf from pre-restart backup") - print("Restored shortcuts file after Steam restart") - return - except Exception as e: - self.logger.error(f"Failed to restore from pre-restart backup: {e}") - - # Try regular backup if pre-restart failed - backup = getattr(self, '_last_shortcuts_backup', None) - if backup and os.path.exists(backup): - try: - import shutil - shutil.copy2(backup, shortcuts_file) - self.logger.info(f"Restored shortcuts.vdf from regular backup") - print("Restored shortcuts file after Steam restart") - except Exception as e: - self.logger.error(f"Failed to restore from backup: {e}") - print("Failed to restore shortcuts file. You may need to recreate your shortcut.") - else: - self.logger.info(f"shortcuts.vdf verified intact after restart") - - def create_shortcut_workflow(self): - """ - Run the complete shortcut creation workflow - Returns: - bool: True if successful, False otherwise - """ - # Create the shortcut - shortcut_data = self.create_shortcut() - if not shortcut_data: - return False - - # Note: Steam restart is now handled within create_shortcut() - return True - - def create_new_modlist_shortcut(self): - """ - Create a new modlist shortcut in Steam - This follows the procedure described in the documentation - - Returns: - bool: True if successful, False otherwise - """ - print("\nShortcut Creation") - print("───────────────────────────────────────────────────────────────────") - print("This will create a new Steam shortcut for your modlist.") - print("You will need to provide the path to ModOrganizer.exe and a name for your modlist.") - - # Create the shortcut - modlist_data = self.create_shortcut() - if not modlist_data: - print("Shortcut creation cancelled or failed.") - return False - - # Present the user with a summary of what was created - print("\nShortcut created successfully!") - print("───────────────────────────────────────────────────────────────────") - print(f"Modlist Name: {modlist_data['name']}") - print(f"Directory: {modlist_data['directory']}") - print(f"Steam AppID: {modlist_data['app_id']}") - print("───────────────────────────────────────────────────────────────────") - - return True - - def get_selected_modlist(self): - """ - Get the selected modlist string in the format expected by ModlistHandler.configure_modlist - - Returns: - str: Selected modlist string in the format "Non-Steam shortcut: Name (AppID)" - or None if no modlist was selected - """ - return getattr(self, 'selected_modlist', None) - - def get_appid_for_shortcut(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]: - """ - Find the current AppID for a given shortcut name and (optionally) executable path. - - Primary method: Read directly from shortcuts.vdf (reliable, no external dependencies) - Fallback method: Use protontricks (if available) - - Args: - shortcut_name (str): The name of the Steam shortcut. - exe_path (Optional[str]): The path to the executable (for robust matching after Steam restart). - - Returns: - Optional[str]: The found AppID string, or None if not found or error occurs. - """ - self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')") - - try: - appid = self.get_appid_from_vdf(shortcut_name, exe_path) - if appid: - self.logger.info(f"Successfully found AppID {appid} from shortcuts.vdf") - return appid - - self.logger.info("AppID not found in shortcuts.vdf, trying protontricks as fallback...") - from .protontricks_handler import ProtontricksHandler - pt_handler = ProtontricksHandler(self.steamdeck) - if not pt_handler.detect_protontricks(): - self.logger.warning("Protontricks not detected - cannot use as fallback") - return None - result = pt_handler.run_protontricks("-l") - if not result or result.returncode != 0: - self.logger.warning(f"Protontricks fallback failed: {result.stderr if result else 'No result'}") - return None - # Build a list of all shortcuts - found_shortcuts = [] - for line in result.stdout.splitlines(): - m = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line) - if m: - pt_name = m.group(1).strip() - pt_appid = m.group(2) - found_shortcuts.append((pt_name, pt_appid)) - # For robust matching, also parse shortcuts.vdf for exe paths - vdf_shortcuts = [] - shortcuts_vdf_path = self.shortcuts_path - if shortcuts_vdf_path and os.path.isfile(shortcuts_vdf_path): - try: - shortcuts_data = VDFHandler.load(shortcuts_vdf_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() - exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip() - vdf_shortcuts.append((app_name, exe, idx)) - except Exception as e: - self.logger.error(f"Error parsing shortcuts.vdf for exe path matching: {e}") - # Try to match by both name and exe_path if exe_path is provided - if exe_path: - exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower() - shortcut_name_clean = shortcut_name.strip().lower() - for pt_name, pt_appid in found_shortcuts: - for vdf_name, vdf_exe, vdf_idx in vdf_shortcuts: - if vdf_name.strip().lower() == pt_name.strip().lower() == shortcut_name_clean: - vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower() - if vdf_exe_norm == exe_path_norm: - self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' with exe '{vdf_exe}' (input: '{exe_path}')") - return pt_appid - self.logger.error(f"No shortcut found matching both name '{shortcut_name}' and exe_path '{exe_path}'.") - return None - # Fallback: match by name only (for existing modlist config) - shortcut_name_clean = shortcut_name.strip().lower() - for pt_name, pt_appid in found_shortcuts: - if pt_name.strip().lower() == shortcut_name_clean: - self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' (input: '{shortcut_name}')") - return pt_appid - self.logger.error(f"Could not find an AppID for shortcut named '{shortcut_name}' via protontricks.") - return None - except Exception as e: - self.logger.error(f"Error getting AppID for shortcut '{shortcut_name}': {e}") - self.logger.exception("Traceback:") - return None - - def get_appid_from_vdf(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]: - """ - Get AppID directly from shortcuts.vdf by reading the file and matching shortcut name/exe. - This is more reliable than using protontricks since it doesn't depend on external tools. - - Args: - shortcut_name (str): The name of the Steam shortcut. - exe_path (Optional[str]): The path to the executable for additional validation. - - Returns: - Optional[str]: The AppID as a string, or None if not found. - """ - self.logger.info(f"Looking up AppID from shortcuts.vdf for shortcut: '{shortcut_name}' (exe: '{exe_path}')") - - if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): - self.logger.warning(f"Shortcuts.vdf not found at {self.shortcuts_path}") - return None - - try: - shortcuts_data = VDFHandler.load(self.shortcuts_path, binary=True) - if not shortcuts_data or 'shortcuts' not in shortcuts_data: - self.logger.warning("No shortcuts found in shortcuts.vdf") - return None - - shortcut_name_clean = shortcut_name.strip().lower() - - for idx, shortcut in shortcuts_data['shortcuts'].items(): - name = shortcut.get('AppName', shortcut.get('appname', '')).strip() - - if name.lower() == shortcut_name_clean: - appid = shortcut.get('appid') - - if appid: - if exe_path: - vdf_exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip() - exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower() - vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower() - - if vdf_exe_norm == exe_path_norm: - self.logger.info(f"Found AppID {appid} for shortcut '{name}' with matching exe '{vdf_exe}'") - return str(int(appid) & 0xFFFFFFFF) - else: - self.logger.debug(f"Found shortcut '{name}' but exe doesn't match: '{vdf_exe}' vs '{exe_path}'") - continue - else: - self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)") - return str(int(appid) & 0xFFFFFFFF) - - self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'") - return None - - except Exception as e: - self.logger.error(f"Error reading shortcuts.vdf: {e}") - self.logger.exception("Traceback:") - return None - - # --- Discovery Methods Moved from ModlistHandler --- - - def _scan_shortcuts_for_executable(self, executable_name: str) -> List[Dict[str, str]]: - """ - Scans the user's shortcuts.vdf file for entries pointing to a specific executable. - - Args: - executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe") - - Returns: - List[Dict[str, str]]: A list of dictionaries, each containing {'name': AppName, 'path': StartDir} - for shortcuts matching the executable name. - """ - self.logger.info(f"Scanning {self.shortcuts_path} for executable '{executable_name}'...") - matched_shortcuts = [] - - if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): - self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations") - return [] - - # Directly process the single shortcuts.vdf file found during init - shortcuts_file = self.shortcuts_path - try: - # Use VDFHandler static method for loading - shortcuts_data = VDFHandler.load(shortcuts_file, binary=True) - if shortcuts_data is None or 'shortcuts' not in shortcuts_data: - self.logger.warning(f"Could not load or parse data from {shortcuts_file}") - return [] # Cannot proceed if file is empty/invalid - - for shortcut_id, shortcut in shortcuts_data['shortcuts'].items(): - # Ensure shortcut entry is a dictionary - if not isinstance(shortcut, dict): - self.logger.warning(f"Skipping invalid shortcut entry (not a dict) at index {shortcut_id} in {shortcuts_file}") - continue - - app_name = shortcut.get('AppName', shortcut.get('appname')) - exe_path = shortcut.get('Exe', shortcut.get('exe', '')).strip('"') - start_dir = shortcut.get('StartDir', shortcut.get('startdir', '')).strip('"') - - # Check if the base name of the exe_path matches the target - if app_name and start_dir and os.path.basename(exe_path) == executable_name: - # Perform a basic check for MO2 ini if looking for MO2 - is_valid = True - if executable_name == "ModOrganizer.exe": - # Use Path object for exists check - if not (Path(start_dir) / 'ModOrganizer.ini').exists(): - self.logger.warning(f"Found MO2 shortcut '{app_name}' but ModOrganizer.ini missing in '{start_dir}'") - is_valid = False - - if is_valid: - matched_shortcuts.append({'name': app_name, 'path': start_dir}) - self.logger.debug(f"Found '{executable_name}' shortcut in VDF: {app_name} -> {start_dir}") - - except Exception as e: - self.logger.error(f"Error processing {shortcuts_file}: {e}") - # Return empty list on error processing the file - return [] - - self.logger.info(f"Scan complete. Found {len(matched_shortcuts)} potential '{executable_name}' shortcuts in VDF file.") - return matched_shortcuts - - def discover_executable_shortcuts(self, executable_name: str) -> List[str]: - """ - Discovers non-Steam shortcuts for a specific executable, cross-referencing - VDF files with the Protontricks runtime list. - - Args: - executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe") - - Returns: - List[str]: A list of strings in the format "Non-Steam shortcut: Name (AppID)" - for valid, matched shortcuts. - """ - self.logger.info(f"Discovering configured shortcuts for '{executable_name}'...") - - # 1. Get potential shortcuts from VDF files - vdf_shortcuts = self._scan_shortcuts_for_executable(executable_name) - if not vdf_shortcuts: - self.logger.warning(f"No '{executable_name}' shortcuts found in VDF files.") - # Don't exit yet, maybe protontricks lists something VDF missed? - - # 2. Get the list of shortcuts known to Protontricks - # Use the handler initialized in __init__ - pt_result = self.protontricks_handler.run_protontricks("-l") - if not pt_result or pt_result.returncode != 0: - self.logger.error(f"Protontricks failed to list applications: {pt_result.stderr if pt_result else 'No result'}") - return [] # Cannot proceed without protontricks list - - # Extract names and AppIDs from protontricks output - pt_shortcuts = {} - for line in pt_result.stdout.splitlines(): - line = line.strip() - if "Non-Steam shortcut:" in line: - match = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line) - if match: - pt_name = match.group(1).strip() - pt_appid = match.group(2) - pt_shortcuts[pt_name] = pt_appid # Store AppName -> AppID - - if not pt_shortcuts: - self.logger.warning("No Non-Steam shortcuts listed by Protontricks.") - return [] - - # 3. Cross-reference VDF shortcuts with Protontricks list - final_list = [] - vdf_names_found = {item['name'] for item in vdf_shortcuts} - # pt_names_found = set(pt_shortcuts.keys()) # Not needed directly - - for vdf_shortcut in vdf_shortcuts: - vdf_name = vdf_shortcut['name'] - if vdf_name in pt_shortcuts: - # Match found! - runtime_appid = pt_shortcuts[vdf_name] - modlist_string = f"Non-Steam shortcut: {vdf_name} ({runtime_appid})" - final_list.append(modlist_string) - self.logger.debug(f"Validated shortcut: {modlist_string}") - - if not final_list: - self.logger.warning(f"No shortcuts for '{executable_name}' found in VDF matched the Protontricks list.") - - self.logger.info(f"Discovery complete. Found {len(final_list)} validated shortcuts for '{executable_name}'.") - return final_list - - def find_shortcuts_by_exe(self, executable_name: str) -> List[Dict]: - """Finds shortcuts in shortcuts.vdf that point to a specific executable. - - Args: - executable_name: The name of the executable (e.g., "ModOrganizer.exe") - to search for within the 'Exe' path. - - Returns: - A list of dictionaries, each representing a matching shortcut - and containing keys like 'AppName', 'Exe', 'StartDir'. - Returns an empty list if no matches are found or an error occurs. - """ - self.logger.info(f"Scanning {self.shortcuts_path} for executable: {executable_name}") - matching_shortcuts = [] - - # --- Use the single shortcuts.vdf path found during init --- - if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): - self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations") - return [] - - vdf_path = self.shortcuts_path - try: - self.logger.debug(f"Parsing shortcuts file: {vdf_path}") - shortcuts_data = VDFHandler.load(vdf_path, binary=True) - - if not shortcuts_data or 'shortcuts' not in shortcuts_data: - self.logger.warning(f"Shortcuts data is empty or invalid in {vdf_path}") - return [] # Return empty if no data - - # The shortcuts are under a top-level 'shortcuts' key - shortcuts_dict = shortcuts_data.get('shortcuts', {}) - - for index, shortcut_details in shortcuts_dict.items(): - # Ensure shortcut_details is a dictionary - if not isinstance(shortcut_details, dict): - self.logger.warning(f"Skipping invalid shortcut entry at index {index} in {vdf_path}") - continue - - exe_path = shortcut_details.get('Exe', shortcut_details.get('exe', '')).strip('"') # Get Exe path, remove quotes - app_name = shortcut_details.get('AppName', shortcut_details.get('appname', 'Unknown Shortcut')) - - # Check if the executable_name is present in the Exe path - if executable_name in os.path.basename(exe_path): - self.logger.info(f"Found matching shortcut '{app_name}' in {vdf_path}") - # Extract relevant details with case-insensitive fallbacks - app_id = shortcut_details.get('appid', shortcut_details.get('AppID', shortcut_details.get('appId', None))) - start_dir = shortcut_details.get('StartDir', shortcut_details.get('startdir', '')).strip('"') - - match = { - 'AppName': app_name, - 'Exe': exe_path, # Store unquoted path - 'StartDir': start_dir, - 'appid': app_id # Include the AppID for conversion to unsigned - } - matching_shortcuts.append(match) - else: - self.logger.debug(f"Skipping shortcut '{app_name}': Exe path '{exe_path}' does not contain '{executable_name}'") - - except Exception as e: - self.logger.error(f"Error processing shortcuts file {vdf_path}: {e}", exc_info=True) - # Return empty list on error - return [] - - if not matching_shortcuts: - # Changed log level to debug as this is an expected outcome sometimes - self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in {vdf_path}.") - - return matching_shortcuts - - def update_shortcut_launch_options(self, app_name, exe_path, new_launch_options): - """ - Updates the LaunchOptions for a specific existing shortcut in shortcuts.vdf by matching AppName and Exe. - - Args: - app_name (str): The AppName of the shortcut to update (from config summary). - exe_path (str): The Exe path of the shortcut to update (from config summary, including quotes if present in VDF). - new_launch_options (str): The new string to set for LaunchOptions. - - Returns: - bool: True if the update was successful, False otherwise. - """ - self.logger.info(f"Attempting to update launch options for shortcut with AppName '{app_name}' and Exe '{exe_path}' (no AppID matching)...") - - # Find the user's shortcuts.vdf - shortcuts_file = self.path_handler._find_shortcuts_vdf() - if not shortcuts_file: - self.logger.error("Could not find shortcuts.vdf to update.") - return False - - data = {'shortcuts': {}} - # Load existing shortcuts safely (binary read) - try: - if os.path.exists(shortcuts_file): - with open(shortcuts_file, 'rb') as f: - file_data = f.read() - if file_data: - data = vdf.binary_loads(file_data) - if 'shortcuts' not in data: - data['shortcuts'] = {} - else: - self.logger.error(f"shortcuts.vdf does not exist at {shortcuts_file}. Cannot update.") - return False - except Exception as e: - self.logger.error(f"Error reading or parsing shortcuts.vdf: {e}") - return False - - # Normalize paths for robust matching (handle quotes, absolute paths, case) - def _normalize_path(p: str) -> str: - try: - # Strip surrounding quotes, expanduser, abspath, collapse duplicate slashes - p_clean = os.path.abspath(os.path.expanduser(p.strip().strip('"'))) - return os.path.normpath(p_clean).lower() - except Exception: - return p.strip().strip('"').lower() - - exe_norm = _normalize_path(exe_path) - target_index = None - for index, shortcut_data in data.get('shortcuts', {}).items(): - shortcut_name = (shortcut_data.get('AppName', '') or '').strip() - shortcut_exe_raw = shortcut_data.get('Exe', '') - shortcut_exe_norm = _normalize_path(shortcut_exe_raw) - if shortcut_name == app_name and shortcut_exe_norm == exe_norm: - target_index = index - break - - if target_index is None: - self.logger.error(f"Could not find shortcut with AppName '{app_name}' and Exe '{exe_path}' in shortcuts.vdf.") - # Log all AppNames and Exe values for debugging - for index, shortcut_data in data.get('shortcuts', {}).items(): - shortcut_name = shortcut_data.get('AppName', '') - shortcut_exe = shortcut_data.get('Exe', '') - self.logger.error(f"Found shortcut: AppName='{shortcut_name}', Exe='{shortcut_exe}' -> norm='{_normalize_path(shortcut_exe)}'") - return False - - # Update the LaunchOptions for the found shortcut - if target_index in data['shortcuts']: - self.logger.info(f"Found shortcut at index {target_index}. Updating LaunchOptions...") - data['shortcuts'][target_index]['LaunchOptions'] = new_launch_options - else: - self.logger.error(f"Target index {target_index} not found in shortcuts dictionary after identification.") - return False - - # Write the updated data back safely (binary write to temp file first) - try: - temp_file = f"{shortcuts_file}.temp" - with open(temp_file, 'wb') as f: - vdf_data = vdf.binary_dumps(data) - f.write(vdf_data) - - # Create backup before overwriting - backup_dir = os.path.join(os.path.dirname(shortcuts_file), "backups") - os.makedirs(backup_dir, exist_ok=True) - timestamp = time.strftime("%Y%m%d_%H%M%S") - backup_path = os.path.join(backup_dir, f"shortcuts_update_{app_name}_{timestamp}.bak") - if os.path.exists(shortcuts_file): - shutil.copy2(shortcuts_file, backup_path) - self.logger.info(f"Created backup before update at {backup_path}") - - shutil.move(temp_file, shortcuts_file) - self.logger.info(f"Successfully updated LaunchOptions for shortcut '{app_name}' in {shortcuts_file}.") - return True - except Exception as e: - self.logger.error(f"Error writing updated shortcuts.vdf: {e}") - # Attempt to restore backup if update failed - if 'backup_path' in locals() and os.path.exists(backup_path): - try: - shutil.copy2(backup_path, shortcuts_file) - self.logger.warning(f"Restored shortcuts.vdf from backup {backup_path} after update failure.") - except Exception as restore_e: - self.logger.critical(f"CRITICAL: Failed to write updated shortcuts.vdf AND failed to restore backup! Error: {restore_e}") - return False - - @staticmethod - def get_steam_shortcut_icon_path(exe_path, steamicons_dir=None, logger=None): - """ - Select the best icon for a Steam shortcut given an executable path and optional SteamIcons directory. - Prefers grid-tall.png, else any .png, else returns ''. - Logs selection steps if logger is provided. - """ - exe_dir = os.path.dirname(exe_path) - if not steamicons_dir: - steamicons_dir = os.path.join(exe_dir, "SteamIcons") - if logger: - logger.debug(f"[DEBUG] Looking for Steam shortcut icon in: {steamicons_dir}") - if os.path.isdir(steamicons_dir): - preferred_icon = os.path.join(steamicons_dir, "grid-tall.png") - if os.path.isfile(preferred_icon): - if logger: - logger.debug(f"[DEBUG] Using grid-tall.png as shortcut icon: {preferred_icon}") - return preferred_icon - pngs = [f for f in os.listdir(steamicons_dir) if f.lower().endswith('.png')] - if pngs: - icon_path = os.path.join(steamicons_dir, pngs[0]) - if logger: - logger.debug(f"[DEBUG] Using fallback icon for shortcut: {icon_path}") - return icon_path - if logger: - logger.debug("[DEBUG] No .png icon found in SteamIcons directory.") - return "" - if logger: - logger.debug("[DEBUG] No SteamIcons directory found; shortcut will have no icon.") - return "" - - def write_nxmhandler_ini(self, modlist_dir, mo2_exe_path): - """ - Create nxmhandler.ini in the modlist directory to suppress the NXM Handling popup on first MO2 launch. - If the file already exists, do nothing. - The executable path will be written as Z:\\, matching MO2's format. - """ - ini_path = os.path.join(modlist_dir, "nxmhandler.ini") - if os.path.exists(ini_path): - self.logger.info(f"nxmhandler.ini already exists at {ini_path}") - return - # Build the correct executable path: Z:\\ - abs_path = os.path.abspath(mo2_exe_path) - z_path = f"Z:{abs_path}" - win_path = z_path.replace('/', '\\') # single backslash first - win_path = win_path.replace('\\', '\\\\') # double all backslashes - content = ( - "[handlers]\n" - "size=1\n" - "1\\games=\"skyrimse,skyrim\"\n" - f"1\\executable={win_path}\n" - "1\\arguments=\n" - ) - with open(ini_path, "w") as f: - f.write(content) - self.logger.info(f"[SUCCESS] nxmhandler.ini written to {ini_path}") \ No newline at end of file diff --git a/jackify/backend/handlers/shortcut_launch_options.py b/jackify/backend/handlers/shortcut_launch_options.py new file mode 100644 index 0000000..d775d87 --- /dev/null +++ b/jackify/backend/handlers/shortcut_launch_options.py @@ -0,0 +1,162 @@ +"""Launch options and icon methods for ShortcutHandler (Mixin).""" +import logging +import os +import shutil +import time +import vdf + +logger = logging.getLogger(__name__) + + +class ShortcutLaunchOptionsMixin: + """Mixin providing launch options and icon methods.""" + + def update_shortcut_launch_options(self, app_name, exe_path, new_launch_options): + """ + Updates the LaunchOptions for a specific existing shortcut in shortcuts.vdf by matching AppName and Exe. + + Args: + app_name (str): The AppName of the shortcut to update (from config summary). + exe_path (str): The Exe path of the shortcut to update (from config summary, including quotes if present in VDF). + new_launch_options (str): The new string to set for LaunchOptions. + + Returns: + bool: True if the update was successful, False otherwise. + """ + self.logger.info(f"Attempting to update launch options for shortcut with AppName '{app_name}' and Exe '{exe_path}' (no AppID matching)...") + + shortcuts_file = self.path_handler._find_shortcuts_vdf() + if not shortcuts_file: + self.logger.error("Could not find shortcuts.vdf to update.") + return False + + data = {'shortcuts': {}} + try: + if os.path.exists(shortcuts_file): + with open(shortcuts_file, 'rb') as f: + file_data = f.read() + if file_data: + data = vdf.binary_loads(file_data) + if 'shortcuts' not in data: + data['shortcuts'] = {} + else: + self.logger.error(f"shortcuts.vdf does not exist at {shortcuts_file}. Cannot update.") + return False + except Exception as e: + self.logger.error(f"Error reading or parsing shortcuts.vdf: {e}") + return False + + def _normalize_path(p: str) -> str: + try: + p_clean = os.path.abspath(os.path.expanduser(p.strip().strip('"'))) + return os.path.normpath(p_clean).lower() + except Exception: + return p.strip().strip('"').lower() + + exe_norm = _normalize_path(exe_path) + target_index = None + for index, shortcut_data in data.get('shortcuts', {}).items(): + shortcut_name = (shortcut_data.get('AppName', '') or '').strip() + shortcut_exe_raw = shortcut_data.get('Exe', '') + shortcut_exe_norm = _normalize_path(shortcut_exe_raw) + if shortcut_name == app_name and shortcut_exe_norm == exe_norm: + target_index = index + break + + if target_index is None: + self.logger.error(f"Could not find shortcut with AppName '{app_name}' and Exe '{exe_path}' in shortcuts.vdf.") + for index, shortcut_data in data.get('shortcuts', {}).items(): + shortcut_name = shortcut_data.get('AppName', '') + shortcut_exe = shortcut_data.get('Exe', '') + self.logger.error(f"Found shortcut: AppName='{shortcut_name}', Exe='{shortcut_exe}' -> norm='{_normalize_path(shortcut_exe)}'") + return False + + if target_index in data['shortcuts']: + self.logger.info(f"Found shortcut at index {target_index}. Updating LaunchOptions...") + data['shortcuts'][target_index]['LaunchOptions'] = new_launch_options + else: + self.logger.error(f"Target index {target_index} not found in shortcuts dictionary after identification.") + return False + + try: + temp_file = f"{shortcuts_file}.temp" + with open(temp_file, 'wb') as f: + vdf_data = vdf.binary_dumps(data) + f.write(vdf_data) + + backup_dir = os.path.join(os.path.dirname(shortcuts_file), "backups") + os.makedirs(backup_dir, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") + backup_path = os.path.join(backup_dir, f"shortcuts_update_{app_name}_{timestamp}.bak") + if os.path.exists(shortcuts_file): + shutil.copy2(shortcuts_file, backup_path) + self.logger.info(f"Created backup before update at {backup_path}") + + shutil.move(temp_file, shortcuts_file) + self.logger.info(f"Successfully updated LaunchOptions for shortcut '{app_name}' in {shortcuts_file}.") + return True + except Exception as e: + self.logger.error(f"Error writing updated shortcuts.vdf: {e}") + if 'backup_path' in locals() and os.path.exists(backup_path): + try: + shutil.copy2(backup_path, shortcuts_file) + self.logger.warning(f"Restored shortcuts.vdf from backup {backup_path} after update failure.") + except Exception as restore_e: + self.logger.critical(f"CRITICAL: Failed to write updated shortcuts.vdf AND failed to restore backup! Error: {restore_e}") + return False + + @staticmethod + def get_steam_shortcut_icon_path(exe_path, steamicons_dir=None, logger=None): + """ + Select the best icon for a Steam shortcut given an executable path and optional SteamIcons directory. + Prefers grid-tall.png, else any .png, else returns ''. + Logs selection steps if logger is provided. + """ + exe_dir = os.path.dirname(exe_path) + if not steamicons_dir: + steamicons_dir = os.path.join(exe_dir, "SteamIcons") + if logger: + logger.debug(f"[DEBUG] Looking for Steam shortcut icon in: {steamicons_dir}") + if os.path.isdir(steamicons_dir): + preferred_icon = os.path.join(steamicons_dir, "grid-tall.png") + if os.path.isfile(preferred_icon): + if logger: + logger.debug(f"[DEBUG] Using grid-tall.png as shortcut icon: {preferred_icon}") + return preferred_icon + pngs = [f for f in os.listdir(steamicons_dir) if f.lower().endswith('.png')] + if pngs: + icon_path = os.path.join(steamicons_dir, pngs[0]) + if logger: + logger.debug(f"[DEBUG] Using fallback icon for shortcut: {icon_path}") + return icon_path + if logger: + logger.debug("[DEBUG] No .png icon found in SteamIcons directory.") + return "" + if logger: + logger.debug("[DEBUG] No SteamIcons directory found; shortcut will have no icon.") + return "" + + def write_nxmhandler_ini(self, modlist_dir, mo2_exe_path): + """ + Create nxmhandler.ini in the modlist directory to suppress the NXM Handling popup on first MO2 launch. + If the file already exists, do nothing. + The executable path will be written as Z:\\, matching MO2's format. + """ + ini_path = os.path.join(modlist_dir, "nxmhandler.ini") + if os.path.exists(ini_path): + self.logger.info(f"nxmhandler.ini already exists at {ini_path}") + return + abs_path = os.path.abspath(mo2_exe_path) + z_path = f"Z:{abs_path}" + win_path = z_path.replace('/', '\\') + win_path = win_path.replace('\\', '\\\\') + content = ( + "[handlers]\n" + "size=1\n" + "1\\games=\"skyrimse,skyrim\"\n" + f"1\\executable={win_path}\n" + "1\\arguments=\n" + ) + with open(ini_path, "w") as f: + f.write(content) + self.logger.info(f"[SUCCESS] nxmhandler.ini written to {ini_path}") diff --git a/jackify/backend/handlers/shortcut_steam_restart.py b/jackify/backend/handlers/shortcut_steam_restart.py new file mode 100644 index 0000000..2f87f16 --- /dev/null +++ b/jackify/backend/handlers/shortcut_steam_restart.py @@ -0,0 +1,293 @@ +"""Steam restart methods for ShortcutHandler (Mixin).""" +import logging +import os +import subprocess +import time +from typing import Optional, Callable + +logger = logging.getLogger(__name__) + + +def _resolve_steam_exe(): + """Resolve steam executable for legacy restart path (same logic as steam_restart_service).""" + try: + from jackify.backend.services.steam_restart_service import _get_steam_executable + return _get_steam_executable(os.environ) + except Exception: + import shutil + exe = shutil.which("steam") + if exe: + return exe + for p in ("/usr/games/steam", "/usr/bin/steam"): + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + return "steam" + + +class ShortcutSteamRestartMixin: + """Mixin providing Steam restart methods.""" + + def secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool: + """ + Secure Steam restart with comprehensive error handling to prevent segfaults. + Now delegates to the robust steam restart service for cross-distro compatibility. + """ + try: + from ..services.steam_restart_service import robust_steam_restart + return robust_steam_restart(progress_callback=status_callback, timeout=60) + except ImportError as e: + self.logger.error(f"Failed to import steam restart service: {e}") + return self._legacy_secure_steam_restart(status_callback) + except Exception as e: + self.logger.error(f"Error in robust steam restart: {e}") + return self._legacy_secure_steam_restart(status_callback) + + def _legacy_secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool: + """ + Legacy secure Steam restart implementation (fallback). + """ + self.logger.info("Attempting secure Steam restart sequence...") + + def safe_subprocess_run(cmd, **kwargs): + try: + return subprocess.run(cmd, **kwargs) + except Exception as e: + self.logger.error(f"Subprocess error with cmd {cmd}: {e}") + return subprocess.CompletedProcess(cmd, 1, "", str(e)) + + def safe_subprocess_popen(cmd, **kwargs): + try: + return subprocess.Popen(cmd, **kwargs) + except Exception as e: + self.logger.error(f"Popen error with cmd {cmd}: {e}") + return None + + if self._is_steam_deck(): + self.logger.info("Detected Steam Deck. Using systemd to restart Steam.") + if status_callback: + try: + status_callback("Restarting Steam via systemd...") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + try: + result = safe_subprocess_run(['systemctl', '--user', 'restart', 'app-steam@autostart.service'], capture_output=True, text=True, timeout=30) + self.logger.info(f"systemctl restart output: {result.stdout.strip()} {result.stderr.strip()}") + time.sleep(10) + check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) + if check.returncode == 0: + self.logger.info("Steam restarted successfully via systemd.") + if status_callback: + try: + status_callback("Steam Started") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return True + else: + self.logger.error("Steam did not start after systemd restart.") + if status_callback: + try: + status_callback("Start Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + except Exception as e: + self.logger.error(f"Error restarting Steam via systemd: {e}") + if status_callback: + try: + status_callback("Restart Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + try: + if status_callback: + try: + status_callback("Stopping Steam...") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + self.logger.info("Attempting clean Steam shutdown via 'steam -shutdown'...") + shutdown_timeout = 30 + result = safe_subprocess_run(['steam', '-shutdown'], timeout=shutdown_timeout, check=False, capture_output=True, text=True) + if result.returncode != 1: + self.logger.debug("'steam -shutdown' command executed (exit code ignored, verification follows).") + else: + self.logger.warning(f"'steam -shutdown' had issues: {result.stderr}") + except Exception as e: + self.logger.warning(f"Error executing 'steam -shutdown': {e}. Will proceed to check processes.") + + if status_callback: + try: + status_callback("Waiting for Steam to close...") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + self.logger.info("Verifying Steam processes are terminated...") + max_attempts = 6 + steam_closed_successfully = False + + for attempt in range(max_attempts): + try: + check_cmd = ['pgrep', '-f', 'steamwebhelper'] + self.logger.debug(f"Executing check: {' '.join(check_cmd)}") + result = safe_subprocess_run(check_cmd, capture_output=True, timeout=10) + if result.returncode != 0: + self.logger.info("No Steam web helper processes found via pgrep.") + steam_closed_successfully = True + break + else: + try: + steam_pids = result.stdout.decode().strip().split('\n') if result.stdout else [] + self.logger.debug(f"Steam web helper processes still detected (PIDs: {steam_pids}). Waiting... (Attempt {attempt + 1}/{max_attempts} after shutdown cmd)") + except Exception as e: + self.logger.warning(f"Error parsing pgrep output: {e}") + time.sleep(5) + except Exception as e: + self.logger.warning(f"Error checking Steam processes (attempt {attempt + 1}): {e}") + time.sleep(5) + + if not steam_closed_successfully: + self.logger.debug("Steam processes still running after 'steam -shutdown'. Attempting fallback with 'pkill steam'...") + if status_callback: + try: + status_callback("Force stopping Steam...") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + try: + self.logger.info("Attempting force shutdown via 'pkill steam'...") + pkill_result = safe_subprocess_run(['pkill', '-f', 'steam'], timeout=15, check=False, capture_output=True, text=True) + self.logger.info(f"pkill steam result: {pkill_result.returncode} - {pkill_result.stdout.strip()} {pkill_result.stderr.strip()}") + + time.sleep(3) + + final_check = safe_subprocess_run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10) + if final_check.returncode != 0: + self.logger.info("Steam processes successfully terminated via pkill fallback.") + steam_closed_successfully = True + else: + self.logger.debug("Steam processes still running after pkill fallback.") + if status_callback: + try: + status_callback("Shutdown Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + except Exception as e: + self.logger.error(f"Error during pkill fallback: {e}") + if status_callback: + try: + status_callback("Shutdown Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + if not steam_closed_successfully: + self.logger.error("Failed to terminate Steam processes via all methods.") + if status_callback: + try: + status_callback("Shutdown Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + self.logger.info("Steam confirmed closed.") + + steam_exe = _resolve_steam_exe() + start_methods = [ + {"name": "Popen", "cmd": [steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}}, + {"name": "setsid", "cmd": ["setsid", steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL}}, + {"name": "nohup", "cmd": ["nohup", steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}} + ] + steam_start_initiated = False + + for i, method in enumerate(start_methods): + method_name = method["name"] + status_msg = f"Starting Steam ({method_name})" + if status_callback: + try: + status_callback(status_msg) + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + self.logger.info(f"Attempting to start Steam using method: {method_name}") + try: + process = safe_subprocess_popen(method["cmd"], **method["kwargs"]) + if process is not None: + self.logger.info(f"Initiated Steam start with {method_name}.") + time.sleep(5) + check_result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) + if check_result.returncode == 0: + self.logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.") + steam_start_initiated = True + break + else: + self.logger.warning(f"Steam process not detected after initiating with {method_name}. Trying next method.") + else: + self.logger.warning(f"Failed to start process with {method_name}. Trying next method.") + except FileNotFoundError: + self.logger.error(f"Command not found for method {method_name} (e.g., setsid, nohup). Trying next method.") + except Exception as e: + self.logger.error(f"Error starting Steam with {method_name}: {e}. Trying next method.") + + if not steam_start_initiated: + self.logger.error("All methods to initiate Steam start failed.") + if status_callback: + try: + status_callback("Start Failed") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False + + status_msg = "Waiting for Steam to fully start" + if status_callback: + try: + status_callback(status_msg) + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + + self.logger.info("Waiting up to 2 minutes for Steam to fully initialize...") + max_startup_wait = 120 + elapsed_wait = 0 + initial_wait_done = False + + while elapsed_wait < max_startup_wait: + try: + result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) + if result.returncode == 0: + if not initial_wait_done: + self.logger.info("Steam process detected. Waiting additional time for full initialization...") + initial_wait_done = True + time.sleep(5) + elapsed_wait += 5 + if initial_wait_done and elapsed_wait >= 15: + final_check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10) + if final_check.returncode == 0: + if status_callback: + try: + status_callback("Steam Started") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + self.logger.info("Steam confirmed running after wait.") + return True + else: + self.logger.warning("Steam process disappeared during final initialization wait.") + break + else: + self.logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)") + time.sleep(5) + elapsed_wait += 5 + except Exception as e: + self.logger.warning(f"Error during Steam startup wait: {e}") + time.sleep(5) + elapsed_wait += 5 + + self.logger.error("Steam failed to start/initialize within the allowed time.") + if status_callback: + try: + status_callback("Start Timed Out") + except Exception as e: + self.logger.warning(f"Status callback error: {e}") + return False diff --git a/jackify/backend/handlers/shortcut_vdf_management.py b/jackify/backend/handlers/shortcut_vdf_management.py new file mode 100644 index 0000000..a742267 --- /dev/null +++ b/jackify/backend/handlers/shortcut_vdf_management.py @@ -0,0 +1,318 @@ +"""VDF backup/restore and modification methods for ShortcutHandler (Mixin).""" +import logging +import os +import shutil +import subprocess +import time +from pathlib import Path + +import glob +import vdf + +from .vdf_handler import VDFHandler + +logger = logging.getLogger(__name__) + + +class ShortcutVDFManagementMixin: + """Mixin providing VDF file management methods.""" + + def _check_and_restore_shortcuts_vdf(self): + """ + Check if shortcuts.vdf exists and restore from backup if missing. + Returns: + bool: True if file exists or was restored, False if unable to restore + """ + shortcuts_files = [] + for user_dir in os.listdir(self.shortcuts_path): + shortcuts_file = os.path.join(self.shortcuts_path, user_dir, "config", "shortcuts.vdf") + if os.path.dirname(shortcuts_file): + shortcuts_files.append(shortcuts_file) + + missing_files = [] + for file_path in shortcuts_files: + if not os.path.exists(file_path): + self.logger.warning(f"shortcuts.vdf is missing at: {file_path}") + missing_files.append(file_path) + + if not missing_files: + self.logger.debug("All shortcuts.vdf files are present") + return True + + restored = 0 + for file_path in missing_files: + backup_files = sorted(glob.glob(f"{file_path}.*.bak"), reverse=True) + if backup_files: + try: + shutil.copy2(backup_files[0], file_path) + self.logger.info(f"Restored {file_path} from {backup_files[0]}") + restored += 1 + continue + except Exception as e: + self.logger.error(f"Failed to restore from timestamped backup: {e}") + + simple_backup = f"{file_path}.bak" + if os.path.exists(simple_backup): + try: + shutil.copy2(simple_backup, file_path) + self.logger.info(f"Restored {file_path} from simple backup") + restored += 1 + continue + except Exception as e: + self.logger.error(f"Failed to restore from simple backup: {e}") + + if restored == len(missing_files): + self.logger.info("Successfully restored all missing shortcuts.vdf files") + return True + elif restored > 0: + self.logger.warning(f"Partially restored {restored}/{len(missing_files)} shortcuts.vdf files") + return True + else: + self.logger.error("Failed to restore any shortcuts.vdf files") + return False + + def _modify_shortcuts_directly(self, shortcuts_file, modlist_name, mo2_path, mo2_dir): + """ + Directly modify shortcuts.vdf in a way that preserves Steam's exact binary format. + This is a fallback method when regular VDF handling might cause issues. + + Args: + shortcuts_file (str): Path to shortcuts.vdf + modlist_name (str): Name for the modlist + mo2_path (str): Path to ModOrganizer.exe + mo2_dir (str): Directory containing ModOrganizer.exe + + Returns: + bool: True if successful, False otherwise + """ + try: + backup_path = f"{shortcuts_file}.{int(time.time())}.bak" + shutil.copy2(shortcuts_file, backup_path) + self.logger.info(f"Created backup before direct modification: {backup_path}") + + if not os.path.exists(shortcuts_file) or os.path.getsize(shortcuts_file) == 0: + with open(shortcuts_file, 'wb') as f: + f.write(b'\x00shortcuts\x00\x08\x08') + self.logger.info(f"Created new shortcuts.vdf file at {shortcuts_file}") + + try: + import sys + import importlib.util + + steam_vdf_spec = importlib.util.find_spec("steam_vdf") + + if steam_vdf_spec is None: + from jackify.backend.handlers.subprocess_utils import get_safe_python_executable + python_exe = get_safe_python_executable() + subprocess.check_call([python_exe, "-m", "pip", "install", "steam-vdf", "--user"]) + time.sleep(1) + + import vdf as steam_vdf + + with open(shortcuts_file, 'rb') as f: + shortcuts_data = steam_vdf.load(f) + + max_id = -1 + if 'shortcuts' in shortcuts_data: + for id_str in shortcuts_data['shortcuts']: + try: + id_num = int(id_str) + if id_num > max_id: + max_id = id_num + except ValueError: + pass + + new_id = max_id + 1 + + if 'shortcuts' not in shortcuts_data: + shortcuts_data['shortcuts'] = {} + + shortcuts_data['shortcuts'][str(new_id)] = { + 'AppName': modlist_name, + 'Exe': f'"{mo2_path}"', + 'StartDir': mo2_dir, + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'OpenVR': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0 + } + + with open(shortcuts_file, 'wb') as f: + steam_vdf.dump(shortcuts_data, f) + + self.logger.info(f"Added shortcut for {modlist_name} using steam-vdf library") + return True + + except Exception as e: + self.logger.warning(f"Failed to use steam-vdf library: {e}") + + self.logger.info("Falling back to VDFHandler for shortcuts.vdf modification") + shortcuts_data = VDFHandler.load(shortcuts_file, binary=True) + + if not shortcuts_data: + shortcuts_data = {'shortcuts': {}} + + new_id = len(shortcuts_data.get('shortcuts', {})) + new_entry = { + 'AppName': modlist_name, + 'Exe': f'"{mo2_path}"', + 'StartDir': mo2_dir, + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'OpenVR': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0 + } + + if 'shortcuts' not in shortcuts_data: + shortcuts_data['shortcuts'] = {} + shortcuts_data['shortcuts'][str(new_id)] = new_entry + + result = VDFHandler.save(shortcuts_file, shortcuts_data, binary=True) + + self.logger.info(f"Added shortcut for {modlist_name} using VDFHandler") + return result + + except Exception as e: + self.logger.error(f"Error in direct shortcut modification: {e}") + return False + + def _add_steam_shortcut_safely(self, shortcuts_file, app_name, exe_path, start_dir, icon_path="", launch_options="", tags=None): + """ + Adds a new shortcut entry to the shortcuts.vdf file using the correct binary format. + This method is carefully designed to maintain file integrity. + + Args: + shortcuts_file (str): Path to shortcuts.vdf + app_name (str): Name for the shortcut + exe_path (str): Path to the executable + start_dir (str): Start directory for the executable + icon_path (str): Path to icon file (optional) + launch_options (str): Command line options (optional) + tags (list): List of tags (optional) + + Returns: + tuple: (bool success, str app_id) - Success status and calculated AppID + """ + if tags is None: + tags = [] + + data = {'shortcuts': {}} + + try: + if os.path.exists(shortcuts_file): + with open(shortcuts_file, 'rb') as f: + file_data = f.read() + if file_data: + try: + data = vdf.binary_loads(file_data) + if 'shortcuts' not in data: + data['shortcuts'] = {} + except Exception as e: + self.logger.warning(f"Could not parse existing shortcuts.vdf: {e}") + data = {'shortcuts': {}} + else: + self.logger.info(f"shortcuts.vdf not found at {shortcuts_file}. A new file will be created.") + except Exception as e: + self.logger.warning(f"Error accessing shortcuts.vdf: {e}") + data = {'shortcuts': {}} + + if 'shortcuts' not in data: + data['shortcuts'] = {} + + next_index = 0 + if data.get('shortcuts'): + shortcut_indices = [int(k) for k in data['shortcuts'].keys() if k.isdigit()] + if shortcut_indices: + next_index = max(shortcut_indices) + 1 + + new_shortcut = { + 'AppName': app_name, + 'Exe': f'"{exe_path}"', + 'StartDir': f'"{start_dir}"', + 'icon': icon_path, + 'ShortcutPath': "", + 'LaunchOptions': launch_options, + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'OpenVR': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'DevkitOverrideAppID': 0, + 'LastPlayTime': 0, + 'FlatpakAppID': '', + 'IsInstalled': 1, + } + + if tags: + new_shortcut['tags'] = {str(i): tag for i, tag in enumerate(tags)} + + app_id = (0x80000000 + int(next_index)) % (2**32) + + if app_id > 0x7FFFFFFF: + app_id = app_id - 0x100000000 + + new_shortcut['appid'] = app_id + + data['shortcuts'][str(next_index)] = new_shortcut + self.logger.info(f"Adding shortcut '{app_name}' at index {next_index}") + + try: + temp_file = f"{shortcuts_file}.temp" + with open(temp_file, 'wb') as f: + vdf_data = vdf.binary_dumps(data) + f.write(vdf_data) + + shutil.move(temp_file, shortcuts_file) + + self.logger.info(f"Successfully updated shortcuts.vdf! AppID: {app_id}") + return True, app_id + except Exception as e: + self.logger.error(f"Error: Failed to write updated shortcuts.vdf: {e}") + return False, None + + def _verify_and_restore_shortcuts(self): + """ + Verify shortcuts.vdf exists after Steam restart and restore it if needed. + """ + shortcuts_file = getattr(self, '_shortcuts_file', None) + if not shortcuts_file: + self.logger.warning("No shortcuts file to verify") + return + + if not os.path.exists(shortcuts_file) or os.path.getsize(shortcuts_file) == 0: + self.logger.warning(f"shortcuts.vdf missing or empty after restart: {shortcuts_file}") + + safe_backup = getattr(self, '_safe_shortcuts_backup', None) + if safe_backup and os.path.exists(safe_backup): + try: + shutil.copy2(safe_backup, shortcuts_file) + self.logger.info(f"Restored shortcuts.vdf from pre-restart backup") + print("Restored shortcuts file after Steam restart") + return + except Exception as e: + self.logger.error(f"Failed to restore from pre-restart backup: {e}") + + backup = getattr(self, '_last_shortcuts_backup', None) + if backup and os.path.exists(backup): + try: + shutil.copy2(backup, shortcuts_file) + self.logger.info(f"Restored shortcuts.vdf from regular backup") + print("Restored shortcuts file after Steam restart") + except Exception as e: + self.logger.error(f"Failed to restore from backup: {e}") + print("Failed to restore shortcuts file. You may need to recreate your shortcut.") + else: + self.logger.info(f"shortcuts.vdf verified intact after restart") diff --git a/jackify/backend/handlers/subprocess_utils.py b/jackify/backend/handlers/subprocess_utils.py index 2c2a50e..22f92de 100644 --- a/jackify/backend/handlers/subprocess_utils.py +++ b/jackify/backend/handlers/subprocess_utils.py @@ -65,7 +65,7 @@ def get_clean_subprocess_env(extra_env=None): current_path = env.get('PATH', '') # Ensure common system directories are in PATH if not already present - # This is critical for tools like lz4 that might be in /usr/bin, /usr/local/bin, etc. + # Critical for tools in /usr/bin, /usr/local/bin, etc. system_paths = ['/usr/bin', '/usr/local/bin', '/bin', '/sbin', '/usr/sbin'] path_parts = current_path.split(':') if current_path else [] for sys_path in system_paths: @@ -73,10 +73,10 @@ def get_clean_subprocess_env(extra_env=None): path_parts.append(sys_path) # Add bundled tools directory to PATH if running as AppImage - # This ensures cabextract and winetricks are available to subprocesses + # cabextract and winetricks must be available to subprocesses # System utilities (wget, curl, unzip, xz, gzip, sha256sum) come from system PATH - # Note: appdir was saved before env cleanup above - # Note: lz4 was only needed for TTW installer and is no longer bundled + # appdir saved before env cleanup above + # lz4 was only needed for TTW installer, no longer bundled tools_dir = None if appdir: diff --git a/jackify/backend/handlers/ttw_installer_backend.py b/jackify/backend/handlers/ttw_installer_backend.py new file mode 100644 index 0000000..8bccca1 --- /dev/null +++ b/jackify/backend/handlers/ttw_installer_backend.py @@ -0,0 +1,318 @@ +""" +TTW installer backend: install_ttw_backend, start_ttw_installation, cleanup, stream output, integrate. +""" + +import subprocess +from pathlib import Path +from typing import Optional, Tuple + +from .logging_handler import LoggingHandler +from .subprocess_utils import get_clean_subprocess_env + + +class TTWInstallerBackendMixin: + """Mixin providing TTW installation process and integration for TTWInstallerHandler.""" + + def install_ttw_backend(self, ttw_mpi_path: Path, ttw_output_path: Path) -> Tuple[bool, str]: + """Install TTW using TTW_Linux_Installer.""" + self.logger.info("Starting Tale of Two Wastelands installation via TTW_Linux_Installer") + if not ttw_mpi_path or not ttw_output_path: + return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required" + ttw_mpi_path = Path(ttw_mpi_path) + ttw_output_path = Path(ttw_output_path) + if not ttw_mpi_path.exists(): + return False, f"TTW .mpi file not found: {ttw_mpi_path}" + if not ttw_mpi_path.is_file(): + return False, f"TTW .mpi path is not a file: {ttw_mpi_path}" + if ttw_mpi_path.suffix.lower() != '.mpi': + return False, f"TTW path does not have .mpi extension: {ttw_mpi_path}" + if not ttw_output_path.exists(): + try: + ttw_output_path.mkdir(parents=True, exist_ok=True) + except Exception as e: + return False, f"Failed to create output directory: {e}" + if not self.ttw_installer_installed: + self.logger.info("TTW_Linux_Installer not found, attempting to install...") + success, message = self.install_ttw_installer() + if not success: + return False, f"TTW_Linux_Installer not installed and auto-install failed: {message}" + if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file(): + return False, "TTW_Linux_Installer executable not found" + required_games = ['Fallout 3', 'Fallout New Vegas'] + detected_games = self.path_handler.find_vanilla_game_paths() + missing_games = [game for game in required_games if game not in detected_games] + if missing_games: + return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas." + fallout3_path = detected_games.get('Fallout 3') + falloutnv_path = detected_games.get('Fallout New Vegas') + if not fallout3_path or not falloutnv_path: + return False, "Could not detect Fallout 3 or Fallout New Vegas installation paths" + cmd = [ + str(self.ttw_installer_executable_path), + "--fo3", str(fallout3_path), + "--fnv", str(falloutnv_path), + "--mpi", str(ttw_mpi_path), + "--output", str(ttw_output_path), + "--start" + ] + self.logger.info("Executing TTW_Linux_Installer: %s", ' '.join(cmd)) + try: + env = get_clean_subprocess_env() + exe_dir = str(self.ttw_installer_executable_path.parent) + process = subprocess.Popen( + cmd, cwd=exe_dir, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, universal_newlines=True + ) + if process.stdout: + for line in process.stdout: + line = line.rstrip() + if line: + self.logger.info("TTW_Linux_Installer: %s", line) + process.wait() + ret = process.returncode + if ret == 0: + self.logger.info("TTW installation completed successfully.") + return True, "TTW installation completed successfully!" + self.logger.error("TTW installation process returned non-zero exit code: %s", ret) + return False, f"TTW installation failed with exit code {ret}" + except Exception as e: + self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True) + return False, f"Error executing TTW_Linux_Installer: {e}" + + def start_ttw_installation(self, ttw_mpi_path: Path, ttw_output_path: Path, output_file: Path): + """Start TTW installation process (non-blocking). Returns (process, error_message).""" + self.logger.info("Starting TTW installation (non-blocking mode)") + if not ttw_mpi_path or not ttw_output_path: + return None, "Missing required parameters: ttw_mpi_path and ttw_output_path are required" + ttw_mpi_path = Path(ttw_mpi_path) + ttw_output_path = Path(ttw_output_path) + if not ttw_mpi_path.exists(): + return None, f"TTW .mpi file not found: {ttw_mpi_path}" + if not ttw_mpi_path.is_file(): + return None, f"TTW .mpi path is not a file: {ttw_mpi_path}" + if ttw_mpi_path.suffix.lower() != '.mpi': + return None, f"TTW path does not have .mpi extension: {ttw_mpi_path}" + if not ttw_output_path.exists(): + try: + ttw_output_path.mkdir(parents=True, exist_ok=True) + except Exception as e: + return None, f"Failed to create output directory: {e}" + if not self.ttw_installer_installed: + self.logger.info("TTW_Linux_Installer not found, attempting to install...") + success, message = self.install_ttw_installer() + if not success: + return None, f"TTW_Linux_Installer not installed and auto-install failed: {message}" + if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file(): + return None, "TTW_Linux_Installer executable not found" + required_games = ['Fallout 3', 'Fallout New Vegas'] + detected_games = self.path_handler.find_vanilla_game_paths() + missing_games = [game for game in required_games if game not in detected_games] + if missing_games: + return None, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas." + fallout3_path = detected_games.get('Fallout 3') + falloutnv_path = detected_games.get('Fallout New Vegas') + if not fallout3_path or not falloutnv_path: + return None, "Could not detect Fallout 3 or Fallout New Vegas installation paths" + cmd = [ + str(self.ttw_installer_executable_path), + "--fo3", str(fallout3_path), + "--fnv", str(falloutnv_path), + "--mpi", str(ttw_mpi_path), + "--output", str(ttw_output_path), + "--start" + ] + self.logger.info("Executing TTW_Linux_Installer: %s", ' '.join(cmd)) + try: + env = get_clean_subprocess_env() + output_fh = open(output_file, 'w', encoding='utf-8', buffering=1) + exe_dir = str(self.ttw_installer_executable_path.parent) + process = subprocess.Popen( + cmd, cwd=exe_dir, env=env, + stdout=output_fh, stderr=subprocess.STDOUT, bufsize=1 + ) + self.logger.info("TTW_Linux_Installer process started (PID: %s), output to %s", process.pid, output_file) + process._output_fh = output_fh + return process, None + except Exception as e: + self.logger.error("Error starting TTW_Linux_Installer: %s", e, exc_info=True) + return None, f"Error starting TTW_Linux_Installer: {e}" + + @staticmethod + def cleanup_ttw_process(process): + """Clean up after TTW installation process.""" + if process: + if hasattr(process, '_output_fh'): + try: + process._output_fh.close() + except Exception: + pass + if process.poll() is None: + try: + process.terminate() + process.wait(timeout=5) + except Exception: + try: + process.kill() + except Exception: + pass + + def install_ttw_backend_with_output_stream(self, ttw_mpi_path: Path, ttw_output_path: Path, output_callback=None): + """Install TTW with streaming output (DEPRECATED - use start_ttw_installation instead).""" + self.logger.info("Starting Tale of Two Wastelands installation via TTW_Linux_Installer (with output stream)") + if not ttw_mpi_path or not ttw_output_path: + return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required" + ttw_mpi_path = Path(ttw_mpi_path) + ttw_output_path = Path(ttw_output_path) + if not ttw_mpi_path.exists(): + return False, f"TTW .mpi file not found: {ttw_mpi_path}" + if not ttw_mpi_path.is_file(): + return False, f"TTW .mpi path is not a file: {ttw_mpi_path}" + if ttw_mpi_path.suffix.lower() != '.mpi': + return False, f"TTW path does not have .mpi extension: {ttw_mpi_path}" + if not ttw_output_path.exists(): + try: + ttw_output_path.mkdir(parents=True, exist_ok=True) + except Exception as e: + return False, f"Failed to create output directory: {e}" + if not self.ttw_installer_installed: + if output_callback: + output_callback("TTW_Linux_Installer not found, installing...") + self.logger.info("TTW_Linux_Installer not found, attempting to install...") + success, message = self.install_ttw_installer() + if not success: + return False, f"TTW_Linux_Installer not installed and auto-install failed: {message}" + if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file(): + return False, "TTW_Linux_Installer executable not found" + required_games = ['Fallout 3', 'Fallout New Vegas'] + detected_games = self.path_handler.find_vanilla_game_paths() + missing_games = [game for game in required_games if game not in detected_games] + if missing_games: + return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas." + fallout3_path = detected_games.get('Fallout 3') + falloutnv_path = detected_games.get('Fallout New Vegas') + if not fallout3_path or not falloutnv_path: + return False, "Could not detect Fallout 3 or Fallout New Vegas installation paths" + cmd = [ + str(self.ttw_installer_executable_path), + "--fo3", str(fallout3_path), + "--fnv", str(falloutnv_path), + "--mpi", str(ttw_mpi_path), + "--output", str(ttw_output_path), + "--start" + ] + self.logger.info("Executing TTW_Linux_Installer: %s", ' '.join(cmd)) + try: + env = get_clean_subprocess_env() + exe_dir = str(self.ttw_installer_executable_path.parent) + process = subprocess.Popen( + cmd, cwd=exe_dir, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, universal_newlines=True + ) + if process.stdout: + for line in process.stdout: + line = line.rstrip() + if line: + self.logger.info("TTW_Linux_Installer: %s", line) + if output_callback: + output_callback(line) + process.wait() + ret = process.returncode + if ret == 0: + self.logger.info("TTW installation completed successfully.") + return True, "TTW installation completed successfully!" + self.logger.error("TTW installation process returned non-zero exit code: %s", ret) + return False, f"TTW installation failed with exit code {ret}" + except Exception as e: + self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True) + 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: + """Integrate TTW output into a modlist's MO2 structure.""" + import shutil + logging_handler = LoggingHandler() + logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log') + logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log') + try: + if not ttw_output_path.exists(): + logger.error("TTW output path does not exist: %s", ttw_output_path) + return False + mods_dir = modlist_install_dir / "mods" + profiles_dir = modlist_install_dir / "profiles" + if not mods_dir.exists() or not profiles_dir.exists(): + logger.error("Invalid modlist directory structure: %s", modlist_install_dir) + 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") + ttw_esms = [ + "Fallout3.esm", "Anchorage.esm", "ThePitt.esm", "BrokenSteel.esm", + "PointLookout.esm", "Zeta.esm", "TaleOfTwoWastelands.esm", "YUPTTW.esm" + ] + for profile_dir in profiles_dir.iterdir(): + if not profile_dir.is_dir(): + continue + profile_name = profile_dir.name + logger.info("Processing profile: %s", profile_name) + modlist_file = profile_dir / "modlist.txt" + if modlist_file.exists(): + with open(modlist_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + separator_found = False + ttw_mod_line = f"+{mod_folder_name}\n" + new_lines = [] + for line in lines: + stripped = line.strip() + if stripped.startswith('+') and '[nodelete]' in stripped.lower(): + if ('tale of two wastelands' in stripped.lower() and 'quick start' not in stripped.lower() and + 'loading wheel' not in stripped.lower()) or stripped.lower().startswith('+[nodelete] ttw '): + logger.info("Removing existing TTW mod entry: %s", stripped) + continue + if "put tale of two wastelands mod here" in line.lower() and "_separator" in line.lower(): + new_lines.append(ttw_mod_line) + separator_found = True + logger.info("Inserted TTW mod before separator: %s", line.strip()) + new_lines.append(line) + if not separator_found: + new_lines.append(ttw_mod_line) + logger.warning("No TTW separator found in %s, appended to end", profile_name) + with open(modlist_file, 'w', encoding='utf-8') as f: + f.writelines(new_lines) + logger.info("Updated modlist.txt for %s", profile_name) + else: + logger.warning("modlist.txt not found for profile %s", profile_name) + plugins_file = profile_dir / "plugins.txt" + if plugins_file.exists(): + with open(plugins_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + ttw_esm_set = set(esm.lower() for esm in ttw_esms) + lines = [line for line in lines if line.strip().lower() not in ttw_esm_set] + insert_index = None + for i, line in enumerate(lines): + if line.strip().lower() == "caravanpack.esm": + insert_index = i + 1 + break + if insert_index is not None: + for esm in reversed(ttw_esms): + lines.insert(insert_index, f"{esm}\n") + else: + logger.warning("CaravanPack.esm not found in %s, appending TTW ESMs to end", profile_name) + for esm in ttw_esms: + lines.append(f"{esm}\n") + with open(plugins_file, 'w', encoding='utf-8') as f: + f.writelines(lines) + logger.info("Updated plugins.txt for %s", profile_name) + else: + logger.warning("plugins.txt not found for profile %s", profile_name) + logger.info("TTW integration completed successfully") + return True + except Exception as e: + logger.error("Error integrating TTW into modlist: %s", e, exc_info=True) + return False diff --git a/jackify/backend/handlers/ttw_installer_handler.py b/jackify/backend/handlers/ttw_installer_handler.py index ad9c717..50a7034 100644 --- a/jackify/backend/handlers/ttw_installer_handler.py +++ b/jackify/backend/handlers/ttw_installer_handler.py @@ -18,7 +18,7 @@ from .path_handler import PathHandler from .filesystem_handler import FileSystemHandler from .config_handler import ConfigHandler from .logging_handler import LoggingHandler -from .subprocess_utils import get_clean_subprocess_env +from .ttw_installer_backend import TTWInstallerBackendMixin logger = logging.getLogger(__name__) @@ -36,7 +36,7 @@ TTW_INSTALLER_RELEASE_URL = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/ TTW_INSTALLER_PINNED_VERSION = "0.0.7" -class TTWInstallerHandler: +class TTWInstallerHandler(TTWInstallerBackendMixin): """Handles TTW installation using TTW_Linux_Installer (replaces hoolamike for TTW).""" def __init__(self, steamdeck: bool, verbose: bool, filesystem_handler: FileSystemHandler, @@ -108,18 +108,37 @@ class TTWInstallerHandler: target_dir = Path(install_dir) if install_dir else self.ttw_installer_dir target_dir.mkdir(parents=True, exist_ok=True) - # Fetch release info (pinned version or latest) + # Fetch release info - always use pinned version when set; never use latest if TTW_INSTALLER_PINNED_VERSION: - release_url = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/releases/tags/{TTW_INSTALLER_PINNED_VERSION}" - self.logger.info(f"Fetching pinned TTW_Linux_Installer version {TTW_INSTALLER_PINNED_VERSION} from {release_url}") + tag_candidates = [ + TTW_INSTALLER_PINNED_VERSION, + f"v{TTW_INSTALLER_PINNED_VERSION}" if not TTW_INSTALLER_PINNED_VERSION.startswith("v") else None, + ] + tag_candidates = [t for t in tag_candidates if t] + data = None + release_tag = None + for tag in tag_candidates: + release_url = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/releases/tags/{tag}" + self.logger.info(f"Fetching pinned TTW_Linux_Installer version {tag} from {release_url}") + resp = requests.get(release_url, timeout=15, verify=True) + if resp.status_code == 200: + data = resp.json() + release_tag = data.get("tag_name") or data.get("name") + break + if resp.status_code != 404: + resp.raise_for_status() + if not data: + return False, ( + f"Pinned release {TTW_INSTALLER_PINNED_VERSION} not found on GitHub " + f"(tried tags: {', '.join(tag_candidates)}). Check repo and tag names." + ) else: release_url = TTW_INSTALLER_RELEASE_URL self.logger.info(f"Fetching latest TTW_Linux_Installer release from {release_url}") - - resp = requests.get(release_url, timeout=15, verify=True) - resp.raise_for_status() - data = resp.json() - release_tag = data.get("tag_name") or data.get("name") + resp = requests.get(release_url, timeout=15, verify=True) + resp.raise_for_status() + data = resp.json() + release_tag = data.get("tag_name") or data.get("name") # Find Linux asset - universal-mpi-installer pattern (can be .zip or .tar.gz) linux_asset = None @@ -135,15 +154,15 @@ class TTWInstallerHandler: break if not linux_asset: - # Log all available assets for debugging all_assets = [asset.get("name", "") for asset in data.get("assets", [])] self.logger.error(f"No suitable Linux asset found. Available assets: {all_assets}") - return False, f"No suitable Linux TTW_Linux_Installer asset found in latest release. Available assets: {', '.join(all_assets)}" + release_desc = f"release {release_tag}" if release_tag else "release" + return False, f"No suitable Linux TTW_Linux_Installer asset found in {release_desc}. Available assets: {', '.join(all_assets)}" download_url = linux_asset.get("browser_download_url") asset_name = linux_asset.get("name") if not download_url or not asset_name: - return False, "Latest release is missing required asset metadata" + return False, f"Release {release_tag or 'unknown'} is missing required asset metadata" # Download to target directory temp_path = target_dir / asset_name @@ -282,515 +301,3 @@ class TTWInstallerHandler: self.logger.warning(f"Error checking for TTW_Linux_Installer updates: {e}") return (False, installed, None) - def install_ttw_backend(self, ttw_mpi_path: Path, ttw_output_path: Path) -> Tuple[bool, str]: - """Install TTW using TTW_Linux_Installer. - - Args: - ttw_mpi_path: Path to TTW .mpi file - ttw_output_path: Target installation directory - - Returns: - (success: bool, message: str) - """ - self.logger.info("Starting Tale of Two Wastelands installation via TTW_Linux_Installer") - - # Validate parameters - if not ttw_mpi_path or not ttw_output_path: - return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required" - - ttw_mpi_path = Path(ttw_mpi_path) - ttw_output_path = Path(ttw_output_path) - - # Validate paths - if not ttw_mpi_path.exists(): - return False, f"TTW .mpi file not found: {ttw_mpi_path}" - - if not ttw_mpi_path.is_file(): - return False, f"TTW .mpi path is not a file: {ttw_mpi_path}" - - if ttw_mpi_path.suffix.lower() != '.mpi': - return False, f"TTW path does not have .mpi extension: {ttw_mpi_path}" - - if not ttw_output_path.exists(): - try: - ttw_output_path.mkdir(parents=True, exist_ok=True) - except Exception as e: - return False, f"Failed to create output directory: {e}" - - # Check installation - if not self.ttw_installer_installed: - # Try to install automatically - self.logger.info("TTW_Linux_Installer not found, attempting to install...") - success, message = self.install_ttw_installer() - if not success: - return False, f"TTW_Linux_Installer not installed and auto-install failed: {message}" - - if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file(): - return False, "TTW_Linux_Installer executable not found" - - # Detect game paths - required_games = ['Fallout 3', 'Fallout New Vegas'] - detected_games = self.path_handler.find_vanilla_game_paths() - missing_games = [game for game in required_games if game not in detected_games] - if missing_games: - return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas." - - fallout3_path = detected_games.get('Fallout 3') - falloutnv_path = detected_games.get('Fallout New Vegas') - - if not fallout3_path or not falloutnv_path: - return False, "Could not detect Fallout 3 or Fallout New Vegas installation paths" - - # Construct command - run in CLI mode with arguments - cmd = [ - str(self.ttw_installer_executable_path), - "--fo3", str(fallout3_path), - "--fnv", str(falloutnv_path), - "--mpi", str(ttw_mpi_path), - "--output", str(ttw_output_path), - "--start" - ] - - self.logger.info(f"Executing TTW_Linux_Installer: {' '.join(cmd)}") - - try: - env = get_clean_subprocess_env() - # CRITICAL: cwd must be the directory containing the executable, not the extraction root - # This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries) - # is the directory containing the executable, not the working directory - exe_dir = str(self.ttw_installer_executable_path.parent) - process = subprocess.Popen( - cmd, - cwd=exe_dir, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - universal_newlines=True - ) - - # Stream output to logger - if process.stdout: - for line in process.stdout: - line = line.rstrip() - if line: - self.logger.info(f"TTW_Linux_Installer: {line}") - - process.wait() - ret = process.returncode - - if ret == 0: - self.logger.info("TTW installation completed successfully.") - return True, "TTW installation completed successfully!" - else: - self.logger.error(f"TTW installation process returned non-zero exit code: {ret}") - return False, f"TTW installation failed with exit code {ret}" - - except Exception as e: - self.logger.error(f"Error executing TTW_Linux_Installer: {e}", exc_info=True) - return False, f"Error executing TTW_Linux_Installer: {e}" - - def start_ttw_installation(self, ttw_mpi_path: Path, ttw_output_path: Path, output_file: Path): - """Start TTW installation process (non-blocking). - - Starts the TTW_Linux_Installer subprocess with output redirected to a file. - Returns immediately with process handle. Caller should poll process and read output file. - - Args: - ttw_mpi_path: Path to TTW .mpi file - ttw_output_path: Target installation directory - output_file: Path to file where stdout/stderr will be written - - Returns: - (process: subprocess.Popen, error_message: str) - process is None if failed - """ - self.logger.info("Starting TTW installation (non-blocking mode)") - - # Validate parameters - if not ttw_mpi_path or not ttw_output_path: - return None, "Missing required parameters: ttw_mpi_path and ttw_output_path are required" - - ttw_mpi_path = Path(ttw_mpi_path) - ttw_output_path = Path(ttw_output_path) - - # Validate paths - if not ttw_mpi_path.exists(): - return None, f"TTW .mpi file not found: {ttw_mpi_path}" - - if not ttw_mpi_path.is_file(): - return None, f"TTW .mpi path is not a file: {ttw_mpi_path}" - - if ttw_mpi_path.suffix.lower() != '.mpi': - return None, f"TTW path does not have .mpi extension: {ttw_mpi_path}" - - if not ttw_output_path.exists(): - try: - ttw_output_path.mkdir(parents=True, exist_ok=True) - except Exception as e: - return None, f"Failed to create output directory: {e}" - - # Check installation - if not self.ttw_installer_installed: - self.logger.info("TTW_Linux_Installer not found, attempting to install...") - success, message = self.install_ttw_installer() - if not success: - return None, f"TTW_Linux_Installer not installed and auto-install failed: {message}" - - if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file(): - return None, "TTW_Linux_Installer executable not found" - - # Detect game paths - required_games = ['Fallout 3', 'Fallout New Vegas'] - detected_games = self.path_handler.find_vanilla_game_paths() - missing_games = [game for game in required_games if game not in detected_games] - if missing_games: - return None, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas." - - fallout3_path = detected_games.get('Fallout 3') - falloutnv_path = detected_games.get('Fallout New Vegas') - - if not fallout3_path or not falloutnv_path: - return None, "Could not detect Fallout 3 or Fallout New Vegas installation paths" - - # Construct command - cmd = [ - str(self.ttw_installer_executable_path), - "--fo3", str(fallout3_path), - "--fnv", str(falloutnv_path), - "--mpi", str(ttw_mpi_path), - "--output", str(ttw_output_path), - "--start" - ] - - self.logger.info(f"Executing TTW_Linux_Installer: {' '.join(cmd)}") - - try: - env = get_clean_subprocess_env() - # Note: TTW_Linux_Installer bundles its own lz4 and will find it via AppContext.BaseDirectory - # We set cwd to the executable's directory so AppContext.BaseDirectory matches the working directory - - # Open output file for writing - output_fh = open(output_file, 'w', encoding='utf-8', buffering=1) - - # Start process with output redirected to file - # CRITICAL: cwd must be the directory containing the executable, not the extraction root - # This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries) - # is the directory containing the executable, not the working directory - exe_dir = str(self.ttw_installer_executable_path.parent) - process = subprocess.Popen( - cmd, - cwd=exe_dir, - env=env, - stdout=output_fh, - stderr=subprocess.STDOUT, - bufsize=1 - ) - - self.logger.info(f"TTW_Linux_Installer process started (PID: {process.pid}), output to {output_file}") - - # Store file handle so it can be closed later - process._output_fh = output_fh - - return process, None - - except Exception as e: - self.logger.error(f"Error starting TTW_Linux_Installer: {e}", exc_info=True) - return None, f"Error starting TTW_Linux_Installer: {e}" - - @staticmethod - def cleanup_ttw_process(process): - """Clean up after TTW installation process. - - Closes file handles and ensures process is terminated properly. - - Args: - process: subprocess.Popen object from start_ttw_installation() - """ - if process: - # Close output file handle if attached - if hasattr(process, '_output_fh'): - try: - process._output_fh.close() - except Exception: - pass - - # Terminate if still running - if process.poll() is None: - try: - process.terminate() - process.wait(timeout=5) - except Exception: - try: - process.kill() - except Exception: - pass - - def install_ttw_backend_with_output_stream(self, ttw_mpi_path: Path, ttw_output_path: Path, output_callback=None): - """Install TTW with streaming output for GUI (DEPRECATED - use start_ttw_installation instead). - - Args: - ttw_mpi_path: Path to TTW .mpi file - ttw_output_path: Target installation directory - output_callback: Optional callback function(line: str) for real-time output - - Returns: - (success: bool, message: str) - """ - self.logger.info("Starting Tale of Two Wastelands installation via TTW_Linux_Installer (with output stream)") - - # Validate parameters (same as install_ttw_backend) - if not ttw_mpi_path or not ttw_output_path: - return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required" - - ttw_mpi_path = Path(ttw_mpi_path) - ttw_output_path = Path(ttw_output_path) - - # Validate paths - if not ttw_mpi_path.exists(): - return False, f"TTW .mpi file not found: {ttw_mpi_path}" - - if not ttw_mpi_path.is_file(): - return False, f"TTW .mpi path is not a file: {ttw_mpi_path}" - - if ttw_mpi_path.suffix.lower() != '.mpi': - return False, f"TTW path does not have .mpi extension: {ttw_mpi_path}" - - if not ttw_output_path.exists(): - try: - ttw_output_path.mkdir(parents=True, exist_ok=True) - except Exception as e: - return False, f"Failed to create output directory: {e}" - - # Check installation - if not self.ttw_installer_installed: - if output_callback: - output_callback("TTW_Linux_Installer not found, installing...") - self.logger.info("TTW_Linux_Installer not found, attempting to install...") - success, message = self.install_ttw_installer() - if not success: - return False, f"TTW_Linux_Installer not installed and auto-install failed: {message}" - - if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file(): - return False, "TTW_Linux_Installer executable not found" - - # Detect game paths - required_games = ['Fallout 3', 'Fallout New Vegas'] - detected_games = self.path_handler.find_vanilla_game_paths() - missing_games = [game for game in required_games if game not in detected_games] - if missing_games: - return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas." - - fallout3_path = detected_games.get('Fallout 3') - falloutnv_path = detected_games.get('Fallout New Vegas') - - if not fallout3_path or not falloutnv_path: - return False, "Could not detect Fallout 3 or Fallout New Vegas installation paths" - - # Construct command - cmd = [ - str(self.ttw_installer_executable_path), - "--fo3", str(fallout3_path), - "--fnv", str(falloutnv_path), - "--mpi", str(ttw_mpi_path), - "--output", str(ttw_output_path), - "--start" - ] - - self.logger.info(f"Executing TTW_Linux_Installer: {' '.join(cmd)}") - - try: - env = get_clean_subprocess_env() - # CRITICAL: cwd must be the directory containing the executable, not the extraction root - # This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries) - # is the directory containing the executable, not the working directory - exe_dir = str(self.ttw_installer_executable_path.parent) - process = subprocess.Popen( - cmd, - cwd=exe_dir, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - universal_newlines=True - ) - - # Stream output to both logger and callback - if process.stdout: - for line in process.stdout: - line = line.rstrip() - if line: - self.logger.info(f"TTW_Linux_Installer: {line}") - if output_callback: - output_callback(line) - - process.wait() - ret = process.returncode - - if ret == 0: - self.logger.info("TTW installation completed successfully.") - return True, "TTW installation completed successfully!" - else: - self.logger.error(f"TTW installation process returned non-zero exit code: {ret}") - return False, f"TTW installation failed with exit code {ret}" - - except Exception as e: - self.logger.error(f"Error executing TTW_Linux_Installer: {e}", exc_info=True) - 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: - """Integrate TTW output into a modlist's MO2 structure - - This method: - 1. Copies TTW output to the modlist's mods folder - 2. Updates modlist.txt for all profiles - 3. Updates plugins.txt with TTW ESMs in correct order - - Args: - ttw_output_path: Path to TTW output directory - modlist_install_dir: Path to modlist installation directory - ttw_version: TTW version string (e.g., "3.4") - - Returns: - bool: True if integration successful, False otherwise - """ - logging_handler = LoggingHandler() - logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log') - logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log') - - try: - import shutil - - # Validate paths - if not ttw_output_path.exists(): - logger.error(f"TTW output path does not exist: {ttw_output_path}") - return False - - mods_dir = modlist_install_dir / "mods" - profiles_dir = modlist_install_dir / "profiles" - - if not mods_dir.exists() or not profiles_dir.exists(): - logger.error(f"Invalid modlist directory structure: {modlist_install_dir}") - return False - - # Create mod folder name with version - 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 - - # Copy TTW output to mods directory - logger.info(f"Copying TTW output to {target_mod_dir}") - if target_mod_dir.exists(): - logger.info(f"Removing existing TTW mod at {target_mod_dir}") - shutil.rmtree(target_mod_dir) - - shutil.copytree(ttw_output_path, target_mod_dir) - logger.info("TTW output copied successfully") - - # TTW ESMs in correct load order - ttw_esms = [ - "Fallout3.esm", - "Anchorage.esm", - "ThePitt.esm", - "BrokenSteel.esm", - "PointLookout.esm", - "Zeta.esm", - "TaleOfTwoWastelands.esm", - "YUPTTW.esm" - ] - - # Process each profile - for profile_dir in profiles_dir.iterdir(): - if not profile_dir.is_dir(): - continue - - profile_name = profile_dir.name - logger.info(f"Processing profile: {profile_name}") - - # Update modlist.txt - modlist_file = profile_dir / "modlist.txt" - if modlist_file.exists(): - # Read existing modlist - with open(modlist_file, 'r', encoding='utf-8') as f: - lines = f.readlines() - - # Find the TTW placeholder separator and insert BEFORE it - separator_found = False - ttw_mod_line = f"+{mod_folder_name}\n" - new_lines = [] - - for line in lines: - # Skip existing TTW mod entries (but keep separators and other TTW-related mods) - # Match patterns: "+[NoDelete] Tale of Two Wastelands", "+[NoDelete] TTW", etc. - stripped = line.strip() - if stripped.startswith('+') and '[nodelete]' in stripped.lower(): - # Check if it's the main TTW mod (not other TTW-related mods like "TTW Quick Start") - if ('tale of two wastelands' in stripped.lower() and 'quick start' not in stripped.lower() and - 'loading wheel' not in stripped.lower()) or stripped.lower().startswith('+[nodelete] ttw '): - logger.info(f"Removing existing TTW mod entry: {stripped}") - continue - - # Insert TTW mod BEFORE the placeholder separator (MO2 order is bottom-up) - # Check BEFORE appending so TTW mod appears before separator in file - if "put tale of two wastelands mod here" in line.lower() and "_separator" in line.lower(): - new_lines.append(ttw_mod_line) - separator_found = True - logger.info(f"Inserted TTW mod before separator: {line.strip()}") - - new_lines.append(line) - - # If no separator found, append at the end - if not separator_found: - new_lines.append(ttw_mod_line) - logger.warning(f"No TTW separator found in {profile_name}, appended to end") - - # Write back - with open(modlist_file, 'w', encoding='utf-8') as f: - f.writelines(new_lines) - - logger.info(f"Updated modlist.txt for {profile_name}") - else: - logger.warning(f"modlist.txt not found for profile {profile_name}") - - # Update plugins.txt - plugins_file = profile_dir / "plugins.txt" - if plugins_file.exists(): - # Read existing plugins - with open(plugins_file, 'r', encoding='utf-8') as f: - lines = f.readlines() - - # Remove any existing TTW ESMs - ttw_esm_set = set(esm.lower() for esm in ttw_esms) - lines = [line for line in lines if line.strip().lower() not in ttw_esm_set] - - # Find CaravanPack.esm and insert TTW ESMs after it - insert_index = None - for i, line in enumerate(lines): - if line.strip().lower() == "caravanpack.esm": - insert_index = i + 1 - break - - if insert_index is not None: - # Insert TTW ESMs in correct order - for esm in reversed(ttw_esms): - lines.insert(insert_index, f"{esm}\n") - else: - logger.warning(f"CaravanPack.esm not found in {profile_name}, appending TTW ESMs to end") - for esm in ttw_esms: - lines.append(f"{esm}\n") - - # Write back - with open(plugins_file, 'w', encoding='utf-8') as f: - f.writelines(lines) - - logger.info(f"Updated plugins.txt for {profile_name}") - else: - logger.warning(f"plugins.txt not found for profile {profile_name}") - - logger.info("TTW integration completed successfully") - return True - - except Exception as e: - logger.error(f"Error integrating TTW into modlist: {e}", exc_info=True) - return False diff --git a/jackify/backend/handlers/ui_handler.py b/jackify/backend/handlers/ui_handler.py index 21bb042..5efb1ad 100644 --- a/jackify/backend/handlers/ui_handler.py +++ b/jackify/backend/handlers/ui_handler.py @@ -165,7 +165,6 @@ class UIHandler: def show_help(self, topic: str) -> None: """Display help information for a topic.""" try: - # This would typically load help content from a file or database print(f"\nHelp: {topic}") print("=" * (len(topic) + 6)) print("Help content would be displayed here.") diff --git a/jackify/backend/handlers/vdf_handler.py b/jackify/backend/handlers/vdf_handler.py index 709e369..be211d6 100644 --- a/jackify/backend/handlers/vdf_handler.py +++ b/jackify/backend/handlers/vdf_handler.py @@ -63,11 +63,8 @@ class VDFHandler: if file_name == "shortcuts.vdf": return False - # Check exact filename match if file_name in PROTECTED_VDF_FILES: return True - - # Check pattern match (for appmanifest_*.acf) for pattern in PROTECTED_VDF_FILES: if '*' in pattern and pattern.replace('*', '') in file_name: return True @@ -125,7 +122,7 @@ class VDFHandler: return vdf.load(f_text) except FileNotFoundError: - # This might be redundant due to os.path.exists checks, but keep for safety + # Possibly redundant with os.path.exists checks -- kept for safety logger.error(f"VDF file not found during load operation: {file_path}") return None except PermissionError: diff --git a/jackify/backend/handlers/wabbajack_directory.py b/jackify/backend/handlers/wabbajack_directory.py new file mode 100644 index 0000000..ae363db --- /dev/null +++ b/jackify/backend/handlers/wabbajack_directory.py @@ -0,0 +1,296 @@ +"""Directory and download methods for InstallWabbajackHandler (Mixin).""" +import logging +import os +import shutil +from pathlib import Path +from typing import Optional + +import requests + +from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET, COLOR_WARNING + +logger = logging.getLogger(__name__) + +DEFAULT_WABBAJACK_PATH = "~/Wabbajack" +DEFAULT_WABBAJACK_NAME = "Wabbajack" + +READLINE_AVAILABLE = False +try: + import readline + READLINE_AVAILABLE = True +except ImportError: + pass +except Exception as e: + logging.warning(f"Readline import failed: {e}") + +try: + from .menu_handler import simple_path_completer +except ImportError: + simple_path_completer = None + + +class WabbajackDirectoryMixin: + """Mixin providing directory setup and download methods.""" + + def _download_file(self, url: str, destination_path: Path) -> bool: + """Downloads a file from a URL to a destination path. + Handles temporary file and overwrites destination if download succeeds. + + Args: + url (str): The URL to download from. + destination_path (Path): The path to save the downloaded file. + + Returns: + bool: True if download succeeds, False otherwise. + """ + self.logger.info(f"Downloading {destination_path.name} from {url}") + + destination_path.parent.mkdir(parents=True, exist_ok=True) + + temp_path = destination_path.with_suffix(destination_path.suffix + ".part") + self.logger.debug(f"Downloading to temporary path: {temp_path}") + + try: + with requests.get(url, stream=True, timeout=30, verify=True) as r: + r.raise_for_status() + block_size = 8192 + with open(temp_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=block_size): + if chunk: + f.write(chunk) + + actual_downloaded_size = temp_path.stat().st_size + self.logger.debug(f"Download finished. Actual size: {actual_downloaded_size} bytes.") + + shutil.move(str(temp_path), str(destination_path)) + self.logger.info(f"Successfully downloaded and moved to {destination_path}") + return True + + except requests.exceptions.RequestException as e: + self.logger.error(f"Download failed for {url}: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Error downloading {destination_path.name}: {e}{COLOR_RESET}") + if temp_path.exists(): + try: + temp_path.unlink() + except OSError as unlink_err: + self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") + return False + except Exception as e: + self.logger.error(f"An unexpected error occurred during download: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}An unexpected error occurred during download: {e}{COLOR_RESET}") + if temp_path.exists(): + try: + temp_path.unlink() + except OSError as unlink_err: + self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") + return False + + def _prepare_install_directory(self) -> bool: + """ + Ensures the target installation directory exists and is accessible. + Handles directory creation, prompting the user if outside $HOME. + + Returns: + bool: True if the directory exists and is ready, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot prepare directory: install_path is not set.") + return False + + self.logger.info(f"Preparing installation directory: {self.install_path}") + + if self.install_path.exists(): + if self.install_path.is_dir(): + self.logger.info(f"Directory already exists: {self.install_path}") + if not os.access(self.install_path, os.W_OK | os.X_OK): + print(f"{COLOR_ERROR}Error: Directory exists but lacks necessary write/execute permissions.{COLOR_RESET}") + return False + return True + else: + print(f"{COLOR_ERROR}Error: The specified path exists but is a file, not a directory.{COLOR_RESET}") + return False + else: + self.logger.info("Directory does not exist. Attempting creation...") + try: + home_dir = Path.home() + is_outside_home = not str(self.install_path.resolve()).startswith(str(home_dir.resolve())) + + if is_outside_home: + self.logger.warning(f"Install path {self.install_path} is outside home directory {home_dir}.") + print(f"\n{COLOR_PROMPT}The chosen path is outside your home directory and may require manual creation.{COLOR_RESET}") + while True: + response = input(f"{COLOR_PROMPT}Please create the directory \"{self.install_path}\" manually,\nensure you have write permissions, and then press Enter to continue (or 'q' to quit): {COLOR_RESET}").lower() + if response == 'q': + self.logger.warning("User aborted manual directory creation.") + return False + if self.install_path.exists(): + if self.install_path.is_dir(): + self.logger.info("Directory created manually by user.") + if not os.access(self.install_path, os.W_OK | os.X_OK): + print(f"{COLOR_WARNING}Warning: Directory created, but write/execute permissions might be missing.{COLOR_RESET}") + return True + else: + print(f"{COLOR_ERROR}Error: Path exists now, but it is not a directory. Please fix and try again.{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}Directory still not found. Please create it or enter 'q' to quit.{COLOR_RESET}") + else: + self.logger.info("Path is inside home directory. Creating...") + os.makedirs(self.install_path) + self.logger.info(f"Successfully created directory: {self.install_path}") + if not os.access(self.install_path, os.W_OK | os.X_OK): + print(f"{COLOR_WARNING}Warning: Directory created, but lacks write/execute permissions. Subsequent steps might fail.{COLOR_RESET}") + return True + + except PermissionError: + self.logger.error(f"Permission denied when trying to create directory: {self.install_path}", exc_info=True) + print(f"\n{COLOR_ERROR}Error: Permission denied creating directory.{COLOR_RESET}") + print(f"{COLOR_INFO}Please check permissions for the parent directory or choose a different location.{COLOR_RESET}") + return False + except OSError as e: + self.logger.error(f"Failed to create directory {self.install_path}: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}Error creating directory: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"An unexpected error occurred during directory preparation: {e}", exc_info=True) + print(f"\n{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _get_wabbajack_install_path(self) -> Optional[Path]: + """ + Prompts the user for the Wabbajack installation path with tab completion. + Uses the FileSystemHandler for path validation and completion. + + Returns: + Optional[Path]: The chosen installation path as a Path object, or None if cancelled. + """ + self.logger.info("Prompting for Wabbajack installation path.") + current_path = self.install_path if self.install_path else Path(DEFAULT_WABBAJACK_PATH).expanduser() + + if READLINE_AVAILABLE and simple_path_completer: + readline.set_completer_delims(' \t\n;') + readline.parse_and_bind("tab: complete") + readline.set_completer(simple_path_completer) + + try: + while True: + try: + prompt_text = f"{COLOR_PROMPT}Enter Wabbajack installation path (default: {current_path}): {COLOR_RESET}" + user_input = input(prompt_text).strip() + + if not user_input: + chosen_path_str = str(current_path) + else: + chosen_path_str = user_input + + chosen_path = Path(chosen_path_str).expanduser().resolve() + + if not chosen_path.name: + print(f"{COLOR_ERROR}Invalid path. Please enter a valid directory path.{COLOR_RESET}") + continue + + if chosen_path.exists() and not chosen_path.is_dir(): + print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}") + continue + + confirm_prompt = f"{COLOR_PROMPT}Install Wabbajack to {chosen_path}? (Y/n/c to cancel): {COLOR_RESET}" + confirmation = input(confirm_prompt).lower() + + if confirmation == 'c': + self.logger.info("Wabbajack installation path selection cancelled by user.") + return None + elif confirmation != 'n': + self.install_path = chosen_path + self.logger.info(f"Wabbajack installation path set to: {self.install_path}") + return self.install_path + except KeyboardInterrupt: + self.logger.info("Wabbajack installation path selection cancelled by user (Ctrl+C).") + print("\nPath selection cancelled.") + return None + except Exception as e: + self.logger.error(f"Error during path input: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return None + finally: + if READLINE_AVAILABLE: + readline.set_completer(None) + + def _get_wabbajack_shortcut_name(self) -> Optional[str]: + """ + Prompts the user for the Wabbajack shortcut name. + + Returns: + Optional[str]: The name chosen by the user, or None if cancelled. + """ + self.logger.debug("Getting Wabbajack shortcut name.") + + if self.shortcut_name: + self.logger.info(f"Using pre-configured shortcut name: {self.shortcut_name}") + return self.shortcut_name + + chosen_name = DEFAULT_WABBAJACK_NAME + + if self.menu_handler: + self.logger.debug("Using menu_handler for shortcut name input") + print(f"\nWabbajack Shortcut Name:") + name_input = self.menu_handler.get_input_with_default( + prompt=f"Enter the desired name for the Wabbajack Steam shortcut (default: {chosen_name})", + default=chosen_name + ) + if name_input is not None: + self.logger.info(f"User provided shortcut name: {name_input}") + return name_input + else: + self.logger.info("User cancelled shortcut name input") + return None + + try: + print(f"\n{COLOR_PROMPT}Enter the desired name for the Wabbajack Steam shortcut.{COLOR_RESET}") + name_input = input(f"{COLOR_PROMPT}Name [{chosen_name}]: {COLOR_RESET}").strip() + + if not name_input: + self.logger.info(f"User did not provide input, using default name: {chosen_name}") + else: + chosen_name = name_input + self.logger.info(f"User provided name: {chosen_name}") + + return chosen_name + + except KeyboardInterrupt: + print(f"\n{COLOR_ERROR}Input cancelled by user.{COLOR_RESET}") + self.logger.warning("User cancelled name input.") + return None + except Exception as e: + self.logger.error(f"An unexpected error occurred while getting name input: {e}", exc_info=True) + return None + + def _download_wabbajack_executable(self) -> bool: + """ + Downloads the latest Wabbajack.exe to the install directory. + Checks existence first. + + Returns: + bool: True on success or if file exists, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot download Wabbajack.exe: install_path is not set.") + return False + + url = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe" + destination = self.install_path / "Wabbajack.exe" + + if destination.is_file(): + self.logger.info(f"Wabbajack.exe already exists at {destination}. Skipping download.") + return True + + self.logger.info("Wabbajack.exe not found. Downloading...") + if self._download_file(url, destination): + try: + os.chmod(destination, 0o755) + self.logger.info(f"Set execute permissions on {destination}") + except Exception as e: + self.logger.warning(f"Could not set execute permission on {destination}: {e}") + self.logger.warning("Could not set execute permission on Wabbajack.exe.") + return True + else: + self.logger.error("Failed to download Wabbajack.exe.") + return False diff --git a/jackify/backend/handlers/wabbajack_handler.py b/jackify/backend/handlers/wabbajack_handler.py index a7ac8db..f097ac0 100644 --- a/jackify/backend/handlers/wabbajack_handler.py +++ b/jackify/backend/handlers/wabbajack_handler.py @@ -18,26 +18,8 @@ import tempfile import time import re -# Attempt to import readline for tab completion -READLINE_AVAILABLE = False -try: - import readline - READLINE_AVAILABLE = True - # Check if running in a non-interactive environment (e.g., some CI) - if 'libedit' in readline.__doc__: - # libedit doesn't support set_completion_display_matches_hook - pass - # Add other potential checks if needed -except ImportError: - # readline not available on Windows or potentially minimal environments - pass -except Exception as e: - # Catch other potential errors during readline import/setup - logging.warning(f"Readline import failed: {e}") - pass - # Import UI Colors first - these should always be available -from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR +from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_WARNING # Import necessary components from other modules try: @@ -47,13 +29,12 @@ try: from .vdf_handler import VDFHandler from .modlist_handler import ModlistHandler from .filesystem_handler import FileSystemHandler - from .menu_handler import MenuHandler, simple_path_completer - # Standard logging (no file handler) - LoggingHandler import removed + from .menu_handler import MenuHandler from .status_utils import show_status, clear_status from jackify.shared.ui_utils import print_section_header except ImportError as e: logging.error(f"Import error in InstallWabbajackHandler: {e}") - logging.error("Could not import FileSystemHandler or simple_path_completer. Ensure structure is correct.") + logging.error("Could not import required handlers. Ensure structure is correct.") # Default locations WABBAJACK_DEFAULT_DIR = os.path.expanduser("~/.config/Jackify/Wabbajack") @@ -61,10 +42,20 @@ WABBAJACK_DEFAULT_DIR = os.path.expanduser("~/.config/Jackify/Wabbajack") # Initialize logger for the module logger = logging.getLogger(__name__) -DEFAULT_WABBAJACK_PATH = "~/Wabbajack" -DEFAULT_WABBAJACK_NAME = "Wabbajack" +from .wabbajack_webview import WabbajackWebViewMixin +from .wabbajack_steam_integration import WabbajackSteamIntegrationMixin +from .wabbajack_prefix_setup import WabbajackPrefixSetupMixin +from .wabbajack_verification import WabbajackVerificationMixin +from .wabbajack_directory import WabbajackDirectoryMixin -class InstallWabbajackHandler: + +class InstallWabbajackHandler( + WabbajackWebViewMixin, + WabbajackSteamIntegrationMixin, + WabbajackPrefixSetupMixin, + WabbajackVerificationMixin, + WabbajackDirectoryMixin, +): """Handles the workflow for installing Wabbajack via Jackify.""" def __init__(self, steamdeck: bool, protontricks_handler: ProtontricksHandler, shortcut_handler: ShortcutHandler, path_handler: PathHandler, vdf_handler: VDFHandler, modlist_handler: ModlistHandler, filesystem_handler: FileSystemHandler, menu_handler=None): @@ -129,271 +120,6 @@ class InstallWabbajackHandler: if not verbose_console: print("\r\033[K", end="", flush=True) - def _download_file(self, url: str, destination_path: Path) -> bool: - """Downloads a file from a URL to a destination path. - Handles temporary file and overwrites destination if download succeeds. - - Args: - url (str): The URL to download from. - destination_path (Path): The path to save the downloaded file. - - Returns: - bool: True if download succeeds, False otherwise. - """ - self.logger.info(f"Downloading {destination_path.name} from {url}") - - # Ensure parent directory exists - destination_path.parent.mkdir(parents=True, exist_ok=True) - - # --- Download --- - temp_path = destination_path.with_suffix(destination_path.suffix + ".part") - self.logger.debug(f"Downloading to temporary path: {temp_path}") - - try: - with requests.get(url, stream=True, timeout=30, verify=True) as r: - r.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) - # total_size_in_bytes = int(r.headers.get('content-length', 0)) - block_size = 8192 # 8KB chunks - - with open(temp_path, 'wb') as f: - for chunk in r.iter_content(chunk_size=block_size): - if chunk: # filter out keep-alive new chunks - f.write(chunk) - - # --- Post-Download Actions --- - actual_downloaded_size = temp_path.stat().st_size - self.logger.debug(f"Download finished. Actual size: {actual_downloaded_size} bytes.") - - # Overwrite final destination with temp file - # Use shutil.move for better cross-filesystem compatibility if needed - # temp_path.rename(destination_path) # Simple rename - shutil.move(str(temp_path), str(destination_path)) - self.logger.info(f"Successfully downloaded and moved to {destination_path}") - return True - - except requests.exceptions.RequestException as e: - self.logger.error(f"Download failed for {url}: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}Error downloading {destination_path.name}: {e}{COLOR_RESET}") - # Clean up partial file if download fails - if temp_path.exists(): - try: - temp_path.unlink() - except OSError as unlink_err: - self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") - return False - except Exception as e: - self.logger.error(f"An unexpected error occurred during download: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}An unexpected error occurred during download: {e}{COLOR_RESET}") - if temp_path.exists(): - try: - temp_path.unlink() - except OSError as unlink_err: - self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") - return False - - def _prepare_install_directory(self) -> bool: - """ - Ensures the target installation directory exists and is accessible. - Handles directory creation, prompting the user if outside $HOME. - - Returns: - bool: True if the directory exists and is ready, False otherwise. - """ - if not self.install_path: - self.logger.error("Cannot prepare directory: install_path is not set.") - return False - - self.logger.info(f"Preparing installation directory: {self.install_path}") - - if self.install_path.exists(): - if self.install_path.is_dir(): - self.logger.info(f"Directory already exists: {self.install_path}") - # Check write permissions - if not os.access(self.install_path, os.W_OK | os.X_OK): - self.logger.error(f"Directory exists but lacks write/execute permissions: {self.install_path}") - print(f"\n{COLOR_ERROR}Error: Directory exists but lacks necessary write/execute permissions.{COLOR_RESET}") - return False - return True - else: - self.logger.error(f"Path exists but is not a directory: {self.install_path}") - print(f"\n{COLOR_ERROR}Error: The specified path exists but is a file, not a directory.{COLOR_RESET}") - return False - else: - # Directory does not exist, attempt creation - self.logger.info("Directory does not exist. Attempting creation...") - try: - home_dir = Path.home() - is_outside_home = not str(self.install_path.resolve()).startswith(str(home_dir.resolve())) - - if is_outside_home: - self.logger.warning(f"Install path {self.install_path} is outside home directory {home_dir}.") - print(f"\n{COLOR_PROMPT}The chosen path is outside your home directory and may require manual creation.{COLOR_RESET}") - while True: - response = input(f"{COLOR_PROMPT}Please create the directory \"{self.install_path}\" manually,\nensure you have write permissions, and then press Enter to continue (or 'q' to quit): {COLOR_RESET}").lower() - if response == 'q': - self.logger.warning("User aborted manual directory creation.") - return False - # Re-check after user presses Enter - if self.install_path.exists(): - if self.install_path.is_dir(): - self.logger.info("Directory created manually by user.") - if not os.access(self.install_path, os.W_OK | os.X_OK): - self.logger.warning(f"Directory created but may lack write/execute permissions: {self.install_path}") - print(f"\n{COLOR_ERROR}Warning: Directory created, but write/execute permissions might be missing.{COLOR_RESET}") - # Decide whether to proceed or fail here - let's proceed but warn - return True - else: - self.logger.error("User indicated directory created, but path is not a directory.") - print(f"\n{COLOR_ERROR}Error: Path exists now, but it is not a directory. Please fix and try again.{COLOR_RESET}") - else: - print(f"\n{COLOR_ERROR}Directory still not found. Please create it or enter 'q' to quit.{COLOR_RESET}") - else: - # Inside home directory, attempt direct creation - self.logger.info("Path is inside home directory. Creating...") - os.makedirs(self.install_path) - self.logger.info(f"Successfully created directory: {self.install_path}") - # Verify permissions after creation - if not os.access(self.install_path, os.W_OK | os.X_OK): - self.logger.warning(f"Directory created but lacks write/execute permissions: {self.install_path}") - print(f"\n{COLOR_ERROR}Warning: Directory created, but lacks write/execute permissions. Subsequent steps might fail.{COLOR_RESET}") - # Proceed anyway? - return True - - except PermissionError: - self.logger.error(f"Permission denied when trying to create directory: {self.install_path}", exc_info=True) - print(f"\n{COLOR_ERROR}Error: Permission denied creating directory.{COLOR_RESET}") - print(f"{COLOR_INFO}Please check permissions for the parent directory or choose a different location.{COLOR_RESET}") - return False - except OSError as e: - self.logger.error(f"Failed to create directory {self.install_path}: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}Error creating directory: {e}{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"An unexpected error occurred during directory preparation: {e}", exc_info=True) - print(f"\n{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - return False - - def _get_wabbajack_install_path(self) -> Optional[Path]: - """ - Prompts the user for the Wabbajack installation path with tab completion. - Uses the FileSystemHandler for path validation and completion. - - Returns: - Optional[Path]: The chosen installation path as a Path object, or None if cancelled. - """ - self.logger.info("Prompting for Wabbajack installation path.") - # Use default path if set, otherwise prompt with suggestion - current_path = self.install_path if self.install_path else Path(DEFAULT_WABBAJACK_PATH).expanduser() - - # Enable tab completion if readline is available - if READLINE_AVAILABLE: - readline.set_completer_delims(' \t\n;') - readline.parse_and_bind("tab: complete") - # Use the simple_path_completer from FileSystemHandler for directory completion - readline.set_completer(simple_path_completer) - - while True: - try: - prompt_text = f"{COLOR_PROMPT}Enter Wabbajack installation path (default: {current_path}): {COLOR_RESET}" - user_input = input(prompt_text).strip() - - if not user_input: # User pressed Enter for default - chosen_path_str = str(current_path) - else: - chosen_path_str = user_input - - # Expand ~ and make absolute - chosen_path = Path(chosen_path_str).expanduser().resolve() - - # Basic validation (is it a plausible path format?) - if not chosen_path.name: # e.g. if user entered just "/" - print(f"{COLOR_ERROR}Invalid path. Please enter a valid directory path.{COLOR_RESET}") - continue - - # Check if path exists and is a directory, or can be created - if chosen_path.exists() and not chosen_path.is_dir(): - print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}") - continue - - # Confirm with user - confirm_prompt = f"{COLOR_PROMPT}Install Wabbajack to {chosen_path}? (Y/n/c to cancel): {COLOR_RESET}" - confirmation = input(confirm_prompt).lower() - - if confirmation == 'c': - self.logger.info("Wabbajack installation path selection cancelled by user.") - return None # User cancelled - elif confirmation != 'n': - self.install_path = chosen_path # Store the confirmed path - self.logger.info(f"Wabbajack installation path set to: {self.install_path}") - return self.install_path - # If 'n', loop again to ask for path - except KeyboardInterrupt: - self.logger.info("Wabbajack installation path selection cancelled by user (Ctrl+C).") - print("\nPath selection cancelled.") - return None - except Exception as e: - self.logger.error(f"Error during path input: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - # Decide if we should return None or retry on general exception - return None - finally: - # Restore default completer if it was changed - if READLINE_AVAILABLE: - readline.set_completer(None) - - def _get_wabbajack_shortcut_name(self) -> Optional[str]: - """ - Prompts the user for the Wabbajack shortcut name. - - Returns: - Optional[str]: The name chosen by the user, or None if cancelled. - """ - self.logger.debug("Getting Wabbajack shortcut name.") - - # Return pre-configured shortcut name if already set - if self.shortcut_name: - self.logger.info(f"Using pre-configured shortcut name: {self.shortcut_name}") - return self.shortcut_name - - chosen_name = DEFAULT_WABBAJACK_NAME - - # Use menu_handler if available for consistent UI - if self.menu_handler: - self.logger.debug("Using menu_handler for shortcut name input") - print(f"\nWabbajack Shortcut Name:") - name_input = self.menu_handler.get_input_with_default( - prompt=f"Enter the desired name for the Wabbajack Steam shortcut (default: {chosen_name})", - default=chosen_name - ) - - if name_input is not None: - self.logger.info(f"User provided shortcut name: {name_input}") - return name_input - else: - self.logger.info("User cancelled shortcut name input") - return None - - # Fallback to direct input if no menu_handler - try: - print(f"\n{COLOR_PROMPT}Enter the desired name for the Wabbajack Steam shortcut.{COLOR_RESET}") - name_input = input(f"{COLOR_PROMPT}Name [{chosen_name}]: {COLOR_RESET}").strip() - - if not name_input: - self.logger.info(f"User did not provide input, using default name: {chosen_name}") - else: - chosen_name = name_input - self.logger.info(f"User provided name: {chosen_name}") - - return chosen_name - - except KeyboardInterrupt: - print(f"\n{COLOR_ERROR}Input cancelled by user.{COLOR_RESET}") - self.logger.warning("User cancelled name input.") - return None - except Exception as e: - self.logger.error(f"An unexpected error occurred while getting name input: {e}", exc_info=True) - return None - def run_install_workflow(self, context: dict = None) -> bool: """ Main entry point for the Wabbajack installation workflow. @@ -404,8 +130,6 @@ class InstallWabbajackHandler: # Standard logging (no file handler) - LoggingHandler calls removed self.logger.info("Starting Wabbajack installation workflow...") - # Remove legacy divider - # print(f"\n{COLOR_INFO}--- Wabbajack Installation ---{COLOR_RESET}") # 1. Get Installation Path if self.menu_handler: print("\nWabbajack Installation Location:") @@ -505,7 +229,7 @@ class InstallWabbajackHandler: if self.initial_appid: print(f"{COLOR_INFO}Initial Steam AppID (before Steam restart): {self.initial_appid}{COLOR_RESET}") else: - print(f"{COLOR_ERROR}Warning: Could not determine initial AppID after shortcut creation.{COLOR_RESET}") + self.logger.warning("Could not determine initial AppID after shortcut creation.") print("==============================================================\n") # 8. Handle Steam Restart & Manual Steps (Calls _print_default_status internally) @@ -525,7 +249,7 @@ class InstallWabbajackHandler: if self.final_appid: print(f"\n{COLOR_INFO}Final Steam AppID (after Steam restart): {self.final_appid}{COLOR_RESET}") else: - print(f"\n{COLOR_ERROR}Warning: Could not determine AppID after Steam restart.{COLOR_RESET}") + self.logger.warning("Could not determine AppID after Steam restart.") break # Verification successful else: self.logger.warning("Manual steps verification failed.") @@ -565,7 +289,7 @@ class InstallWabbajackHandler: if not self.protontricks_handler.set_protontricks_permissions(str(self.install_path), self.steamdeck): self.logger.warning("Failed to set Flatpak Protontricks permissions. Continuing, but subsequent steps might fail if Flatpak Protontricks is used.") clear_status() # Clear status before printing warning - print(f"\n{COLOR_ERROR}Warning: Could not set Flatpak permissions automatically.{COLOR_RESET}") + print(f"\n{COLOR_WARNING}Warning: Could not set Flatpak permissions automatically.{COLOR_RESET}") # 12. Download WebView Installer (Check happens BEFORE setting prefix) show_status("Checking WebView Installer") @@ -592,7 +316,7 @@ class InstallWabbajackHandler: self.logger.info(f"system.reg.wj.win7 downloaded and applied to {system_reg_dest}") except Exception as e: self.logger.error(f"Failed to download or apply initial Win7 system.reg: {e}") - print(f"{COLOR_ERROR}Error: Failed to download or apply initial Win7 system.reg. {e}{COLOR_RESET}") + self.logger.error(f"Failed to download or apply initial Win7 system.reg. {e}") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True @@ -606,7 +330,7 @@ class InstallWabbajackHandler: self.logger.debug(f"WebView install result: {webview_result}") if not webview_result or webview_result.returncode != 0: self.logger.error("WebView installation failed via protontricks-launch.") - print(f"{COLOR_ERROR}Error: WebView installation failed via protontricks-launch.{COLOR_RESET}") + self.logger.error("WebView installation failed via protontricks-launch.") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True @@ -635,7 +359,7 @@ class InstallWabbajackHandler: self.logger.info(f"Final user.reg downloaded and applied to {user_reg_dest}") except Exception as e: self.logger.error(f"Failed to download or apply final user.reg/system.reg: {e}") - print(f"{COLOR_ERROR}Error: Failed to download or apply final user.reg/system.reg. {e}{COLOR_RESET}") + self.logger.error(f"Failed to download or apply final user.reg/system.reg. {e}") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True @@ -693,509 +417,8 @@ class InstallWabbajackHandler: print("───────────────────────────────────────────────────────────────────") return True - def _backup_and_replace_final_reg_files(self) -> bool: - """Backs up current reg files and replaces them with the final downloaded versions.""" - if not self.compatdata_path: - self.logger.error("Cannot backup/replace reg files: compatdata_path not set.") - return False - - pfx_path = self.compatdata_path / 'pfx' - system_reg = pfx_path / 'system.reg' - user_reg = pfx_path / 'user.reg' - system_reg_bak = pfx_path / 'system.reg.orig' - user_reg_bak = pfx_path / 'user.reg.orig' - - # Backup existing files - self.logger.info("Backing up existing registry files...") - logger.info("Backing up current registry files...") - try: - if system_reg.exists(): - shutil.copy2(system_reg, system_reg_bak) - self.logger.debug(f"Backed up {system_reg} to {system_reg_bak}") - else: - self.logger.warning(f"Original {system_reg} not found for backup.") - - if user_reg.exists(): - shutil.copy2(user_reg, user_reg_bak) - self.logger.debug(f"Backed up {user_reg} to {user_reg_bak}") - else: - self.logger.warning(f"Original {user_reg} not found for backup.") - - except Exception as e: - self.logger.error(f"Error backing up registry files: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error backing up registry files: {e}{COLOR_RESET}") - return False # Treat backup failure as critical? - - # Define final registry file URLs - final_system_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/system.reg.github" - final_user_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/user.reg.github" - - # Download and replace - logger.info("Downloading and applying final registry settings...") - system_ok = self._download_and_replace_reg_file(final_system_reg_url, system_reg) - user_ok = self._download_and_replace_reg_file(final_user_reg_url, user_reg) - - if system_ok and user_ok: - self.logger.info("Successfully applied final registry files.") - return True - else: - self.logger.error("Failed to download or replace one or both final registry files.") - print(f"{COLOR_ERROR}Error: Failed to apply final registry settings.{COLOR_RESET}") - # Should we attempt to restore backups here? - return False - - def _install_webview(self) -> bool: - """Installs the WebView2 runtime using protontricks-launch.""" - if not self.final_appid or not self.install_path: - self.logger.error("Cannot install WebView: final_appid or install_path not set.") - return False - - installer_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" - installer_path = self.install_path / installer_name - - if not installer_path.is_file(): - self.logger.error(f"WebView installer not found at {installer_path}. Cannot install.") - print(f"{COLOR_ERROR}Error: WebView installer file missing. Please ensure step 12 completed.{COLOR_RESET}") - return False - - self.logger.info(f"Starting WebView installation for AppID {self.final_appid}...") - # Remove print, handled by caller - # print("\nInstalling WebView (this can take a while, please be patient)...") - - cmd_prefix = [] - if self.protontricks_handler.which_protontricks == 'flatpak': - # Using full command path is safer than relying on alias being sourced - cmd_prefix = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks"] - else: - launch_path = shutil.which("protontricks-launch") - if not launch_path: - self.logger.error("protontricks-launch command not found in PATH.") - print(f"{COLOR_ERROR}Error: protontricks-launch command not found.{COLOR_RESET}") - return False - cmd_prefix = [launch_path] - - # Arguments for protontricks-launch - args = ["--appid", self.final_appid, str(installer_path), "/silent", "/install"] - full_cmd = cmd_prefix + args - - self.logger.debug(f"Executing WebView install command: {' '.join(full_cmd)}") - - try: - # Use check=True to raise CalledProcessError on non-zero exit - # Set a longer timeout as this can take time. - result = subprocess.run(full_cmd, check=True, capture_output=True, text=True, timeout=600) # 10 minute timeout - self.logger.info("WebView installation command completed successfully.") - # Do NOT log result.stdout or result.stderr here - return True - except FileNotFoundError: - self.logger.error(f"Command not found: {cmd_prefix[0]}") - print(f"{COLOR_ERROR}Error: Could not execute {cmd_prefix[0]}. Is it installed correctly?{COLOR_RESET}") - return False - except subprocess.TimeoutExpired: - self.logger.error("WebView installation timed out after 10 minutes.") - print(f"{COLOR_ERROR}Error: WebView installation took too long and timed out.{COLOR_RESET}") - return False - except subprocess.CalledProcessError as e: - self.logger.error(f"WebView installation failed with return code {e.returncode}") - # Only log a short snippet of output for debugging - self.logger.error(f"STDERR (truncated):\n{e.stderr[:500] if e.stderr else ''}") - print(f"{COLOR_ERROR}Error: WebView installation failed (Return Code: {e.returncode}). Check logs for details.{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Unexpected error during WebView installation: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred during WebView installation: {e}{COLOR_RESET}") - return False - - def _find_steam_library_and_vdf_path(self) -> Tuple[Optional[Path], Optional[Path]]: - """Finds the Steam library root and the path to the real libraryfolders.vdf.""" - self.logger.info("Attempting to find Steam library and libraryfolders.vdf...") - try: - # Check if PathHandler uses static methods or needs instantiation - if isinstance(self.path_handler, type): - common_path = self.path_handler.find_steam_library() - else: - common_path = self.path_handler.find_steam_library() - - if not common_path or not common_path.is_dir(): - self.logger.error("Could not find Steam library common path.") - return None, None - - # Navigate up to find the library root - library_root = common_path.parent.parent # steamapps/common -> steamapps -> library_root - self.logger.debug(f"Deduced library root: {library_root}") - - # Construct path to the real libraryfolders.vdf - # Common locations relative to library root - vdf_path_candidates = [ - library_root / 'config/libraryfolders.vdf', # For non-Flatpak? ~/.steam/steam/config - library_root / '../config/libraryfolders.vdf' # Flatpak? ~/.var/app/../Steam/config - ] - - real_vdf_path = None - for candidate in vdf_path_candidates: - resolved_candidate = candidate.resolve() # Resolve symlinks/.. parts - if resolved_candidate.is_file(): - real_vdf_path = resolved_candidate - self.logger.info(f"Found real libraryfolders.vdf at: {real_vdf_path}") - break - - if not real_vdf_path: - self.logger.error(f"Could not find libraryfolders.vdf within library root: {library_root}") - return None, None - - return library_root, real_vdf_path - - except Exception as e: - self.logger.error(f"Error finding Steam library/VDF: {e}", exc_info=True) - return None, None - - def _link_steam_library_config(self) -> bool: - """Creates the necessary directory structure and symlinks libraryfolders.vdf.""" - if not self.compatdata_path: - self.logger.error("Cannot link Steam library: compatdata_path not set.") - return False - - self.logger.info("Linking Steam library configuration (libraryfolders.vdf)...") - - library_root, real_vdf_path = self._find_steam_library_and_vdf_path() - if not library_root or not real_vdf_path: - print(f"{COLOR_ERROR}Error: Could not locate Steam library or libraryfolders.vdf.{COLOR_RESET}") - return False - - target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' - link_path = target_dir / 'libraryfolders.vdf' - - try: - # Backup the original libraryfolders.vdf before doing anything else - # Use FileSystemHandler for consistency - NOW USE INSTANCE - self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") - if not self.filesystem_handler.backup_file(real_vdf_path): - self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") - # Optionally, prompt user or fail here? For now, just warn. - print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") - - # Create the target directory - self.logger.debug(f"Creating directory: {target_dir}") - os.makedirs(target_dir, exist_ok=True) - - # Remove existing symlink if it exists - if link_path.is_symlink(): - self.logger.debug(f"Removing existing symlink at {link_path}") - link_path.unlink() - elif link_path.exists(): - # It exists but isn't a symlink - this is unexpected - self.logger.warning(f"Path {link_path} exists but is not a symlink. Removing it.") - if link_path.is_dir(): - shutil.rmtree(link_path) - else: - link_path.unlink() - - # Create the symlink - self.logger.info(f"Creating symlink from {real_vdf_path} to {link_path}") - os.symlink(real_vdf_path, link_path) - - # Verification (optional but good) - if link_path.is_symlink() and link_path.resolve() == real_vdf_path.resolve(): - self.logger.info("Symlink created and verified successfully.") - return True - else: - self.logger.error("Symlink creation failed or verification failed.") - return False - - except OSError as e: - self.logger.error(f"OSError during symlink creation: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error creating Steam library link: {e}{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Unexpected error during symlink creation: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - return False - - def _create_prefix_library_vdf(self) -> bool: - """Creates the necessary directory structure and copies a modified libraryfolders.vdf.""" - if not self.compatdata_path: - self.logger.error("Cannot create prefix VDF: compatdata_path not set.") - return False - - self.logger.info("Creating modified libraryfolders.vdf in prefix...") - - # 1. Find the real host VDF file - library_root, real_vdf_path = self._find_steam_library_and_vdf_path() - if not real_vdf_path: - # Error logged by _find_steam_library_and_vdf_path - print(f"{COLOR_ERROR}Error: Could not locate real libraryfolders.vdf.{COLOR_RESET}") - return False - - # 2. Backup the real VDF file - self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") - if not self.filesystem_handler.backup_file(real_vdf_path): - self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") - print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") - - # 3. Define target location in prefix - target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' - target_vdf_path = target_dir / 'libraryfolders.vdf' - - try: - # 4. Read the content of the real VDF - self.logger.debug(f"Reading content from {real_vdf_path}") - vdf_content = real_vdf_path.read_text(encoding='utf-8') - - # 5. Convert Linux paths to Wine paths within the content string - modified_content = vdf_content - # Regex to find "path" "/linux/path" entries reliably - path_pattern = re.compile(r'("path"\s*")([^"]+)(")') - - # Use a function for replacement logic to handle potential errors - def replace_path(match): - prefix, linux_path_str, suffix = match.groups() - self.logger.debug(f"Found path entry to convert: {linux_path_str}") - try: - linux_path = Path(linux_path_str) - # Check if it's an SD card path - if self.filesystem_handler.is_sd_card(linux_path): - # Assuming SD card maps to D: - # Remove prefix like /run/media/mmcblk0p1/ - relative_sd_path_str = self.filesystem_handler._strip_sdcard_path_prefix(linux_path) - wine_path = "D:\\" + relative_sd_path_str.replace('/', '\\') - self.logger.debug(f" Converted SD card path: {linux_path_str} -> {wine_path}") - else: - # Assume non-SD maps relative to Z: - # Need the full path prefixed with Z: - wine_path = "Z:\\" + linux_path_str.strip('/').replace('/', '\\') - self.logger.debug(f" Converted standard path: {linux_path_str} -> {wine_path}") - - # Ensure backslashes are doubled for VDF format - wine_path_vdf_escaped = wine_path.replace('\\', '\\\\') - return f'{prefix}{wine_path_vdf_escaped}{suffix}' - except Exception as e: - self.logger.error(f"Error converting path '{linux_path_str}': {e}. Keeping original.") - return match.group(0) # Return original match on error - - # Perform the replacement using re.sub with the function - modified_content = path_pattern.sub(replace_path, vdf_content) - - # Log comparison if content changed (optional) - if modified_content != vdf_content: - self.logger.info("Successfully converted Linux paths to Wine paths in VDF content.") - else: - self.logger.warning("VDF content unchanged after conversion attempt. Did it contain Linux paths?") - - # 6. Ensure target directory exists - self.logger.debug(f"Ensuring target directory exists: {target_dir}") - os.makedirs(target_dir, exist_ok=True) - - # 7. Write the modified content to the target file in the prefix - self.logger.info(f"Writing modified VDF content to {target_vdf_path}") - target_vdf_path.write_text(modified_content, encoding='utf-8') - - # 8. Verification (optional: check file exists and content) - if target_vdf_path.is_file(): - self.logger.info("Prefix libraryfolders.vdf created successfully.") - return True - else: - self.logger.error("Failed to create prefix libraryfolders.vdf.") - return False - - except Exception as e: - self.logger.error(f"Error processing or writing prefix libraryfolders.vdf: {e}", exc_info=True) - print(f"{COLOR_ERROR}An error occurred configuring the Steam library in the prefix: {e}{COLOR_RESET}") - return False - - def _create_dotnet_cache_dir(self) -> bool: - """Creates the dotnet_bundle_extract cache directory.""" - if not self.install_path: - self.logger.error("Cannot create dotnet cache dir: install_path not set.") - return False - - try: - # Get username reliably - username = pwd.getpwuid(os.getuid()).pw_name - # Fallback if pwd fails for some reason? - # username = os.getlogin() # Can fail in some environments - except Exception as e: - self.logger.error(f"Could not determine username: {e}") - print(f"{COLOR_ERROR}Error: Could not determine username to create cache directory.{COLOR_RESET}") - return False - - cache_dir = self.install_path / 'home' / username / '.cache' / 'dotnet_bundle_extract' - self.logger.info(f"Creating dotnet bundle cache directory: {cache_dir}") - - try: - os.makedirs(cache_dir, exist_ok=True) - # Optionally set permissions? The bash script didn't explicitly. - self.logger.info("dotnet cache directory created successfully.") - return True - except OSError as e: - self.logger.error(f"Failed to create dotnet cache directory {cache_dir}: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error creating dotnet cache directory: {e}{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Unexpected error creating dotnet cache directory: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - return False - - def _check_and_prompt_flatpak_overrides(self): - """Checks if Flatpak Steam needs filesystem overrides and prompts the user to apply them.""" - self.logger.info("Checking for necessary Flatpak Steam filesystem overrides...") - is_flatpak_steam = False - # Use compatdata_path as indicator - if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): - is_flatpak_steam = True - self.logger.debug("Flatpak Steam detected based on compatdata path.") - # Add other checks if needed (e.g., check if `flatpak info com.valvesoftware.Steam` runs) - - if not is_flatpak_steam: - self.logger.info("Flatpak Steam not detected, skipping override check.") - return - - paths_to_check = [] - if self.install_path: - paths_to_check.append(self.install_path) - - # Get all library paths from libraryfolders.vdf - try: - all_libs = self.path_handler.get_all_steam_libraries() - paths_to_check.extend(all_libs) - except Exception as e: - self.logger.warning(f"Could not get all Steam libraries to check for overrides: {e}") - - needed_overrides = set() # Use a set to store unique parent paths needing override - home_dir = Path.home() - flatpak_steam_data_dir = home_dir / ".var/app/com.valvesoftware.Steam" - - for path in paths_to_check: - if not path: - continue - resolved_path = path.resolve() - # Check if path is outside $HOME AND outside the Flatpak data dir - is_outside_home = not str(resolved_path).startswith(str(home_dir)) - is_outside_flatpak_data = not str(resolved_path).startswith(str(flatpak_steam_data_dir)) - - if is_outside_home and is_outside_flatpak_data: - # Need override for the parent directory containing this path - # Go up levels until we find a reasonable base (e.g., /mnt/Games, /data/Steam) - # Avoid adding /, /home, etc. - parent_to_add = resolved_path.parent - while parent_to_add != parent_to_add.parent and len(str(parent_to_add)) > 1 and parent_to_add.name != 'home': - # Check if adding this parent makes sense (e.g., it exists, not too high up) - if parent_to_add.is_dir(): # Simple check for existence - # Further heuristics could be added here - needed_overrides.add(str(parent_to_add)) - self.logger.debug(f"Path {resolved_path} is outside sandbox. Adding parent {parent_to_add} to needed overrides.") - break # Add the first reasonable parent found - parent_to_add = parent_to_add.parent - - if not needed_overrides: - self.logger.info("No external paths requiring Flatpak overrides detected.") - return - - # Construct the command string(s) - override_commands = [] - for path_str in sorted(list(needed_overrides)): - # Add specific path override - override_commands.append(f"flatpak override --user --filesystem=\"{path_str}\" com.valvesoftware.Steam") - - # Combine into a single string for display, but keep list for execution - command_display = "\n".join([f" {cmd}" for cmd in override_commands]) - - print(f"\n{COLOR_PROMPT}--- Flatpak Steam Permissions ---{COLOR_RESET}") - print("Jackify has detected that you are using Flatpak Steam and have paths") - print("(e.g., Wabbajack install location or other Steam libraries) outside") - print("the standard Flatpak sandbox. For Wabbajack to access these locations,") - print("Steam needs the following filesystem permissions:") - print(f"{COLOR_INFO}{command_display}{COLOR_RESET}") - print("───────────────────────────────────────────────────────────────────") - - try: - confirm = input(f"{COLOR_PROMPT}Do you want Jackify to apply these permissions now? (y/N): {COLOR_RESET}").lower().strip() - if confirm == 'y': - self.logger.info("User confirmed applying Flatpak overrides.") - success_count = 0 - for cmd_str in override_commands: - self.logger.info(f"Executing: {cmd_str}") - try: - # Split command string for subprocess - cmd_list = cmd_str.split() - result = subprocess.run(cmd_list, check=True, capture_output=True, text=True, timeout=30) - self.logger.debug(f"Override command successful: {result.stdout}") - success_count += 1 - except FileNotFoundError: - self.logger.error(f"'flatpak' command not found. Cannot apply override: {cmd_str}") - print(f"{COLOR_ERROR}Error: 'flatpak' command not found.{COLOR_RESET}") - break # Stop trying if flatpak isn't found - except subprocess.TimeoutExpired: - self.logger.error(f"Flatpak override command timed out: {cmd_str}") - print(f"{COLOR_ERROR}Error: Command timed out: {cmd_str}{COLOR_RESET}") - except subprocess.CalledProcessError as e: - self.logger.error(f"Flatpak override failed: {cmd_str}. Error: {e.stderr}") - print(f"{COLOR_ERROR}Error applying override: {cmd_str}\n{e.stderr}{COLOR_RESET}") - except Exception as e: - self.logger.error(f"Unexpected error applying override {cmd_str}: {e}") - print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") - - if success_count == len(override_commands): - print(f"{COLOR_INFO}Successfully applied necessary Flatpak permissions.{COLOR_RESET}") - else: - print(f"{COLOR_ERROR}Applied {success_count}/{len(override_commands)} permissions. Some overrides may have failed. Check logs.{COLOR_RESET}") - else: - self.logger.info("User declined applying Flatpak overrides.") - print("Permissions not applied. You may need to run the override command(s) manually") - print("if Wabbajack has issues accessing files or game installations.") - - except KeyboardInterrupt: - print("\nOperation cancelled by user.") - self.logger.warning("User cancelled during Flatpak override prompt.") - except Exception as e: - self.logger.error(f"Error during Flatpak override prompt/execution: {e}") - - def _disable_prefix_decoration(self) -> bool: - """Disables window manager decoration in the Wine prefix using protontricks -c.""" - if not self.final_appid: - self.logger.error("Cannot disable decoration: final_appid not set.") - return False - - self.logger.info(f"Disabling window manager decoration for AppID {self.final_appid} via -c 'wine reg add...'") - # Original command string - command = 'wine reg add "HKCU\\Software\\Wine\\X11 Driver" /v Decorated /t REG_SZ /d N /f' - - try: - # Ensure ProtontricksHandler is available - if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: - self.logger.critical("ProtontricksHandler not initialized!") - print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") - return False - - # Use the original -c method - result = self.protontricks_handler.run_protontricks( - '-c', - command, - self.final_appid # AppID comes last for -c commands - ) - - # Check the return code - if result and result.returncode == 0: - self.logger.info("Successfully disabled window decoration (command returned 0).") - # Add a small delay just in case there's a write lag? - time.sleep(1) - return True - else: - err_msg = result.stderr if result else "Command execution failed or returned non-zero" - # Add stdout to error message if stderr is empty - if result and not result.stderr and result.stdout: - err_msg += f"\nSTDOUT: {result.stdout}" - self.logger.error(f"Failed to disable window decoration via -c. Error: {err_msg}") - print(f"{COLOR_ERROR}Error: Failed to disable window decoration via protontricks -c.{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Exception disabling window decoration: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error disabling window decoration: {e}.{COLOR_RESET}") - return False - def _display_completion_message(self): """Displays the final success message and next steps.""" - # Basic log file path (assuming standard location) - # TODO: Get log file path more reliably if needed from jackify.shared.paths import get_jackify_logs_dir log_path = get_jackify_logs_dir() / "jackify-cli.log" @@ -1206,398 +429,21 @@ class InstallWabbajackHandler: print(f" • Launch '{COLOR_INFO}{self.shortcut_name or 'Wabbajack'}{COLOR_RESET}' through Steam.") print(f" • When Wabbajack opens, log in to Nexus using the Settings button (cog icon).") print(f" • Once logged in, you can browse and install modlists as usual!") - - # Check for Flatpak Steam (Placeholder check) - # A more robust check might involve inspecting self.path_handler findings or config - # For now, check if compatdata path hints at flatpak + is_flatpak_steam = False if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): is_flatpak_steam = True - + if is_flatpak_steam: - self.logger.info("Detected Flatpak Steam usage.") - print(f"\n{COLOR_PROMPT}Note: Flatpak Steam Detected:{COLOR_RESET}") - print(f" You may need to grant Wabbajack filesystem access for modlist downloads/installations.") - print(f" Example: If installing to \"/home/{os.getlogin()}/Games/SkyrimSEModlist\", run:") - print(f" {COLOR_INFO}flatpak override --user --filesystem=/home/{os.getlogin()}/Games com.valvesoftware.Steam{COLOR_RESET}") + self.logger.info("Detected Flatpak Steam usage.") + print(f"\n{COLOR_PROMPT}Note: Flatpak Steam Detected:{COLOR_RESET}") + print(f" You may need to grant Wabbajack filesystem access for modlist downloads/installations.") + print(f" Example: If installing to \"/home/{os.getlogin()}/Games/SkyrimSEModlist\", run:") + print(f" {COLOR_INFO}flatpak override --user --filesystem=/home/{os.getlogin()}/Games com.valvesoftware.Steam{COLOR_RESET}") print(f"\nDetailed log available at: {log_path}") print("───────────────────────────────────────────────────────────────────") - def _download_wabbajack_executable(self) -> bool: - """ - Downloads the latest Wabbajack.exe to the install directory. - Checks existence first. - - Returns: - bool: True on success or if file exists, False otherwise. - """ - if not self.install_path: - self.logger.error("Cannot download Wabbajack.exe: install_path is not set.") - return False - - url = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe" - destination = self.install_path / "Wabbajack.exe" - - # Check if file exists first - if destination.is_file(): - self.logger.info(f"Wabbajack.exe already exists at {destination}. Skipping download.") - # print("Wabbajack.exe already present.") # Replaced by logger - return True - - # print(f"\nDownloading latest Wabbajack.exe...") # Replaced by logger - self.logger.info("Wabbajack.exe not found. Downloading...") - if self._download_file(url, destination): - # print("Wabbajack.exe downloaded successfully.") # Replaced by logger - # Set executable permissions - try: - os.chmod(destination, 0o755) - self.logger.info(f"Set execute permissions on {destination}") - except Exception as e: - self.logger.warning(f"Could not set execute permission on {destination}: {e}") - print(f"{COLOR_ERROR}Warning: Could not set execute permission on Wabbajack.exe.{COLOR_RESET}") - return True - else: - self.logger.error("Failed to download Wabbajack.exe.") - # Error message printed by _download_file - return False - - def _create_steam_shortcut(self) -> bool: - """ - Creates the Steam shortcut for Wabbajack using the ShortcutHandler. - - Returns: - bool: True on success, False otherwise. - """ - if not self.shortcut_name or not self.install_path: - self.logger.error("Cannot create shortcut: Missing shortcut name or install path.") - return False - - self.logger.info(f"Creating Steam shortcut '{self.shortcut_name}'...") - executable_path = str(self.install_path / "Wabbajack.exe") - - # Ensure the ShortcutHandler instance exists - # 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=self.shortcut_name, - exe_path=executable_path, - start_dir=os.path.dirname(executable_path), - launch_options="PROTON_USE_WINED3D=1 %command%", - tags=["Jackify", "Wabbajack"], - proton_version="proton_experimental" - ) - - if success and app_id: - self.initial_appid = app_id # Store the initially generated AppID - self.logger.info(f"Shortcut created successfully with initial AppID: {self.initial_appid}") - # Remove direct print, rely on status indicator from caller - # print(f"Steam shortcut '{self.shortcut_name}' created.") - return True - else: - self.logger.error("Failed to create Steam shortcut via ShortcutHandler.") - print(f"{COLOR_ERROR}Error: Failed to create the Steam shortcut for Wabbajack.{COLOR_RESET}") - # Further error details should be logged by the ShortcutHandler - return False - - # --- Helper Methods for Workflow Steps --- - - def _display_manual_proton_steps(self): - """Displays the detailed manual steps required for Proton setup.""" - if not self.shortcut_name: - self.logger.error("Cannot display manual steps: shortcut_name not set.") - print(f"{COLOR_ERROR}Internal Error: Shortcut name missing.{COLOR_RESET}") - return - - 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}{self.shortcut_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}{self.shortcut_name}{COLOR_RESET}' from your Steam Library") - print(" 8. Wait for Wabbajack to download its files and fully load") - print(" 9. Once Wabbajack has fully loaded, CLOSE IT completely and return here") - print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}") - - def _handle_steam_restart_and_manual_steps(self) -> bool: - """ - Handles Steam restart and manual steps prompt, but no extra confirmation. - """ - self.logger.info("Handling Steam restart and manual steps prompt.") - clear_status() - # Condensed message: only show essential manual steps guidance - print("\n───────────────────────────────────────────────────────────────────") - print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} After Steam restarts, follow the on-screen instructions to set Proton Experimental.") - print("───────────────────────────────────────────────────────────────────") - self.logger.info("Attempting secure Steam restart...") - show_status("Restarting Steam") - if not hasattr(self, 'shortcut_handler') or not self.shortcut_handler: - self.logger.critical("ShortcutHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Shortcut handler not available for restart.{COLOR_RESET}") - return False - if self.shortcut_handler.secure_steam_restart(): - self.logger.info("Secure Steam restart successful.") - clear_status() - self._display_manual_proton_steps() - print() - input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") - self.logger.info("User confirmed completion of manual steps.") - return True - else: - self.logger.error("Secure Steam restart failed.") - clear_status() - print(f"\n{COLOR_ERROR}Error: Steam restart failed.{COLOR_RESET}") - print("Please try restarting Steam manually:") - print("1. Exit Steam completely (Steam -> Exit or right-click tray icon -> Exit)") - print("2. Wait a few seconds") - print("3. Start Steam again") - print("\nAfter restarting, you MUST perform the manual Proton setup steps:") - self._display_manual_proton_steps() - print(f"\n{COLOR_ERROR}You will need to re-run this Jackify option after completing these steps.{COLOR_RESET}") - print("───────────────────────────────────────────────────────────────────") - return False - - def _redetect_appid(self) -> bool: - """ - Re-detects the AppID for the shortcut after Steam restart. - - Returns: - bool: True if AppID is found, False otherwise. - """ - if not self.shortcut_name: - self.logger.error("Cannot redetect AppID: shortcut_name not set.") - return False - - self.logger.info(f"Re-detecting AppID for shortcut '{self.shortcut_name}'...") - try: - # Ensure the ProtontricksHandler instance exists - if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: - self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") - return False - - all_shortcuts = self.protontricks_handler.list_non_steam_shortcuts() - - if not all_shortcuts: - self.logger.error("Protontricks listed no non-Steam shortcuts.") - return False - - found_appid = None - for name, appid in all_shortcuts.items(): - if name.lower() == self.shortcut_name.lower(): - found_appid = appid - break - - if found_appid: - self.final_appid = found_appid - self.logger.info(f"Successfully re-detected AppID: {self.final_appid}") - if self.initial_appid and self.initial_appid != self.final_appid: - # Change Warning to Info - this is expected behavior - self.logger.info(f"AppID changed after Steam restart: {self.initial_appid} -> {self.final_appid}") - elif not self.initial_appid: - self.logger.warning("Initial AppID was not set, cannot compare.") - return True - else: - self.logger.error(f"Shortcut '{self.shortcut_name}' not found in protontricks list after restart.") - return False - - except Exception as e: - self.logger.error(f"Error re-detecting AppID: {e}", exc_info=True) - return False - - def _find_steam_config_vdf(self) -> Optional[Path]: - """Finds the path to the primary Steam config.vdf file.""" - self.logger.debug("Searching for Steam config.vdf...") - # Use PathHandler if it has this logic? For now, check common paths. - common_paths = [ - Path.home() / ".steam/steam/config/config.vdf", - Path.home() / ".local/share/Steam/config/config.vdf", - Path.home() / ".var/app/com.valvesoftware.Steam/.config/Valve Corporation/Steam/config/config.vdf" # Check Flatpak path - ] - for path in common_paths: - if path.is_file(): - self.logger.info(f"Found config.vdf at: {path}") - return path - self.logger.error("Could not find Steam config.vdf in common locations.") - return None - - def _verify_manual_steps(self) -> bool: - """ - Verifies that the user has performed the manual steps using ModlistHandler. - Checks AppID, Proton version set, and prefix existence. - - Returns: - bool: True if verification passes AND compatdata_path is set, False otherwise. - """ - self.logger.info("Verifying manual Proton setup steps...") - self.compatdata_path = None # Explicitly reset before verification - - # 1. Re-detect AppID - # Clear status BEFORE potentially failing here - clear_status() - if not self._redetect_appid(): - print(f"{COLOR_ERROR}Error: Could not find the Steam shortcut '{self.shortcut_name}' using protontricks.{COLOR_RESET}") - print(f"{COLOR_INFO}Ensure Steam has restarted and the shortcut is visible.{COLOR_RESET}") - return False # Indicate failure - - self.logger.debug(f"Verification using final AppID: {self.final_appid}") - - # Add padding after user confirmation before the next status update - # Removed print() call - padding should come AFTER status clear - - # Print status JUST before calling the verification logic - show_status("Verifying Proton Setup") - - # Ensure ModlistHandler is available - if not hasattr(self, 'modlist_handler') or not self.modlist_handler: - self.logger.critical("ModlistHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Modlist handler not available for verification.{COLOR_RESET}") - return False - - # 2. Call the existing verification logic from ModlistHandler - verified, status_code = self.modlist_handler.verify_proton_setup(self.final_appid) - - if not verified: - # Handle Verification Failure Messages based on status_code - if status_code == 'wrong_proton_version': - proton_ver = getattr(self.modlist_handler, 'proton_ver', 'Unknown') - print(f"{COLOR_ERROR}\nVerification Failed: Incorrect Proton version detected ('{proton_ver}'). Expected 'Proton Experimental' (or similar).{COLOR_RESET}") - print(f"{COLOR_INFO}Please ensure you selected the correct Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") - elif status_code == 'proton_check_failed': - print(f"{COLOR_ERROR}\nVerification Failed: Compatibility tool not detected as set for '{self.shortcut_name}' in Steam config.{COLOR_RESET}") - print(f"{COLOR_INFO}Please ensure you forced a Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") - elif status_code == 'compatdata_missing': - print(f"{COLOR_ERROR}\nVerification Failed: Steam compatdata directory for AppID {self.final_appid} not found.{COLOR_RESET}") - print(f"{COLOR_INFO}Have you launched the shortcut '{self.shortcut_name}' at least once after setting Proton?{COLOR_RESET}") - elif status_code == 'prefix_missing': - print(f"{COLOR_ERROR}\nVerification Failed: Wine prefix directory (pfx) not found inside compatdata.{COLOR_RESET}") - print(f"{COLOR_INFO}This usually means the shortcut hasn't been launched successfully after setting Proton.{COLOR_RESET}") - elif status_code == 'config_vdf_missing' or status_code == 'config_vdf_error': - print(f"{COLOR_ERROR}\nVerification Failed: Could not read or parse Steam's config.vdf file ({status_code}).{COLOR_RESET}") - print(f"{COLOR_INFO}Check file permissions or integrity. Check logs for details.{COLOR_RESET}") - else: # General/unknown error - print(f"{COLOR_ERROR}\nVerification Failed: An unexpected error occurred ({status_code}). Check logs.{COLOR_RESET}") - return False # Indicate verification failure - - # If we reach here, basic verification passed (proton set, prefix exists) - # Now, ensure we have the compatdata path. - self.logger.info("Basic verification checks passed. Confirming compatdata path...") - - modlist_handler_compat_path = getattr(self.modlist_handler, 'compat_data_path', None) - if modlist_handler_compat_path: - self.compatdata_path = modlist_handler_compat_path - self.logger.info(f"Compatdata path obtained from ModlistHandler: {self.compatdata_path}") - else: - # If modlist_handler didn't set it, try path_handler - # Change Warning to Info - Fallback is acceptable - self.logger.info("ModlistHandler did not set compat_data_path. Attempting manual lookup via PathHandler.") - # Ensure path_handler is available - if not hasattr(self, 'path_handler') or not self.path_handler: - self.logger.critical("PathHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Path handler not available for verification.{COLOR_RESET}") - return False - - self.compatdata_path = self.path_handler.find_compat_data(self.final_appid) - if self.compatdata_path: - self.logger.info(f"Manually found compatdata path via PathHandler: {self.compatdata_path}") - else: - self.logger.error("Verification checks passed, but COULD NOT FIND compatdata path via ModlistHandler or PathHandler.") - print(f"{COLOR_ERROR}\nVerification Error: Basic checks passed, but failed to locate the compatdata directory for AppID {self.final_appid}.{COLOR_RESET}") - print(f"{COLOR_INFO}This is unexpected. Check Steam filesystem structure and logs.{COLOR_RESET}") - return False # CRITICAL: Return False if path is unobtainable - - # If we get here, verification passed AND we have the compatdata_path - self.logger.info("Manual steps verification successful (including path confirmation).") - logger.info(f"Verification successful! (AppID: {self.final_appid}, Path: {self.compatdata_path})") - return True - - def _download_webview_installer(self) -> bool: - """ - Downloads the specific WebView2 installer needed by Wabbajack. - Checks existence first. - - Returns: - bool: True on success or if file already exists correctly, False otherwise. - """ - if not self.install_path: - self.logger.error("Cannot download WebView installer: install_path is not set.") - return False - - url = "https://node10.sokloud.com/filebrowser/api/public/dl/yqVTbUT8/rwatch/WebView/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" - file_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" - destination = self.install_path / file_name - - self.logger.info(f"Checking WebView installer: {destination}") - # print(f"\nChecking required WebView installer ({file_name})...") # Replaced by logger - - if destination.is_file(): - self.logger.info(f"WebView installer {destination.name} already exists. Skipping download.") - # Consider adding a message here if verbose/debug? - return True - - # File doesn't exist, attempt download - self.logger.info(f"WebView installer not found locally. Downloading {file_name}...") - # Update status before starting download - Use a more user-friendly message - show_status("Downloading WebView Installer") - - if self._download_file(url, destination): - # Status will be cleared by caller or next step - return True - else: - self.logger.error(f"Failed to download WebView installer from {url}.") - # Error message already printed by _download_file - return False - - def _set_prefix_renderer(self, renderer: str = 'vulkan') -> bool: - """Sets the prefix renderer using protontricks.""" - if not self.final_appid: - self.logger.error("Cannot set renderer: final_appid not set.") - return False - - self.logger.info(f"Setting prefix renderer to {renderer} for AppID {self.final_appid}...") - try: - # Ensure the ProtontricksHandler instance exists - if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: - self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") - print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") - return False - - result = self.protontricks_handler.run_protontricks( - self.final_appid, - 'settings', - f'renderer={renderer}' - ) - if result and result.returncode == 0: - self.logger.info(f"Successfully set renderer to {renderer}.") - return True - else: - err_msg = result.stderr if result else "Command execution failed" - self.logger.error(f"Failed to set renderer to {renderer}. Error: {err_msg}") - print(f"{COLOR_ERROR}Error: Failed to set prefix renderer to {renderer}.{COLOR_RESET}") - return False - except Exception as e: - self.logger.error(f"Exception setting renderer: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error setting prefix renderer: {e}.{COLOR_RESET}") - return False - - def _download_and_replace_reg_file(self, url: str, target_reg_path: Path) -> bool: - """Downloads a .reg file and replaces the target file. - Always downloads and overwrites. - """ - self.logger.info(f"Downloading registry file from {url} to replace {target_reg_path}") - - # Always download and replace for registry files - if self._download_file(url, target_reg_path): - self.logger.info(f"Successfully downloaded and replaced {target_reg_path}") - return True - else: - self.logger.error(f"Failed to download/replace {target_reg_path} from {url}") - return False # Example usage (for testing - keep this section for easy module testing) if __name__ == '__main__': diff --git a/jackify/backend/handlers/wabbajack_installer_handler.py b/jackify/backend/handlers/wabbajack_installer_handler.py index ef424f3..eeb680e 100644 --- a/jackify/backend/handlers/wabbajack_installer_handler.py +++ b/jackify/backend/handlers/wabbajack_installer_handler.py @@ -2,14 +2,9 @@ Wabbajack Installer Handler Automated Wabbajack.exe installation and configuration via Proton. -Self-contained implementation inspired by Wabbajack-Proton-AuCu (MIT). -This handler provides: -- Automatic Wabbajack.exe download -- Steam shortcuts.vdf manipulation -- WebView2 installation -- Win7 registry configuration -- Optional Heroic GOG game detection +Provides: Wabbajack.exe download, Steam shortcuts.vdf handling, +WebView2 install, Win7 registry for compatibility, optional Heroic GOG detection. """ import json @@ -271,28 +266,54 @@ class WabbajackInstallerHandler: return None def get_compat_data_path(self, app_id: int) -> Optional[Path]: - """Get compatdata path for AppID""" - home = Path.home() - steam_paths = [ - home / ".steam/steam", - home / ".local/share/Steam", - home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", - ] + """ + Get compatdata path for AppID. Uses same detection logic as create_prefix_with_proton_wrapper. - for steam_path in steam_paths: - compat_path = steam_path / f"steamapps/compatdata/{app_id}" - if compat_path.parent.exists(): - # Parent exists, so this is valid location even if prefix doesn't exist yet - return compat_path + Priority: + 1. Check if prefix already exists at any known location + 2. Use PathHandler library detection (Flatpak-aware via libraryfolders.vdf) + 3. Fallback to native ~/.steam/steam + """ + from .path_handler import PathHandler + path_handler = PathHandler() + all_libraries = path_handler.get_all_steam_library_paths() + # Check if Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths + is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries) + + # Determine compatdata root using same logic as create_prefix_with_proton_wrapper + if is_flatpak_steam and all_libraries: + # Flatpak Steam: use first library root (from libraryfolders.vdf) + library_root = all_libraries[0] + compatdata_dir = library_root / "steamapps/compatdata" + self.logger.debug(f"Flatpak Steam detected, using library root: {library_root}") + else: + # Native Steam + compatdata_dir = Path.home() / ".steam/steam/steamapps/compatdata" + self.logger.debug("Native Steam detected") + + compat_path = compatdata_dir / str(app_id) + + # Check if prefix already exists there + if compat_path.exists(): + self.logger.debug(f"Found existing compatdata at: {compat_path}") + return compat_path + + # Prefix doesn't exist yet - return expected path if compatdata root exists + if compatdata_dir.is_dir(): + self.logger.debug(f"Using compatdata location: {compat_path}") + return compat_path + + self.logger.warning(f"Compatdata root does not exist: {compatdata_dir}") return None - def init_wine_prefix(self, app_id: int) -> Path: + def init_wine_prefix(self, app_id: int, proton_path: Optional[Path] = None) -> Path: """ Initialize Wine prefix using Proton. Args: app_id: Steam AppID + proton_path: Optional path to Proton directory; if None, uses Proton Experimental Returns: Path to created prefix @@ -300,9 +321,9 @@ class WabbajackInstallerHandler: Raises: RuntimeError: If prefix creation fails """ - proton_path = self.find_proton_experimental() + proton_path = proton_path or self.find_proton_experimental() if not proton_path: - raise RuntimeError("Proton Experimental not found. Please install it from Steam.") + raise RuntimeError("Proton not found. Install a Proton version in Steam or set Install Proton in Settings.") compat_data = self.get_compat_data_path(app_id) if not compat_data: @@ -318,10 +339,15 @@ class WabbajackInstallerHandler: env = os.environ.copy() env['STEAM_COMPAT_DATA_PATH'] = str(compat_data) env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(compat_data.parent.parent.parent) + # Suppress GUI windows + env['DISPLAY'] = '' + env['WAYLAND_DISPLAY'] = '' + env['WINEDEBUG'] = '-all' + env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d' self.logger.info(f"Initializing Wine prefix for AppID {app_id}...") result = subprocess.run( - [str(proton_bin), 'run', 'wineboot'], + [str(proton_bin), 'run', 'wineboot', '-u'], env=env, capture_output=True, text=True, @@ -334,7 +360,7 @@ class WabbajackInstallerHandler: self.logger.info(f"Prefix created: {prefix_path}") return prefix_path - def run_in_prefix(self, app_id: int, exe_path: Path, args: List[str] = None) -> None: + def run_in_prefix(self, app_id: int, exe_path: Path, args: List[str] = None, proton_path: Optional[Path] = None) -> None: """ Run executable in Wine prefix using Proton. @@ -342,13 +368,14 @@ class WabbajackInstallerHandler: app_id: Steam AppID exe_path: Path to executable args: Optional command line arguments + proton_path: Optional path to Proton directory; if None, uses Proton Experimental Raises: RuntimeError: If execution fails """ - proton_path = self.find_proton_experimental() + proton_path = proton_path or self.find_proton_experimental() if not proton_path: - raise RuntimeError("Proton Experimental not found") + raise RuntimeError("Proton not found") compat_data = self.get_compat_data_path(app_id) if not compat_data: @@ -362,8 +389,14 @@ class WabbajackInstallerHandler: env = os.environ.copy() env['STEAM_COMPAT_DATA_PATH'] = str(compat_data) env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(compat_data.parent.parent.parent) + # Suppress Wine debug output + env['WINEDEBUG'] = '-all' + # Suppress cmd.exe and conhost.exe windows (the flickers you see) + # Keep DISPLAY so installers can run, but prevent console windows + env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d' self.logger.info(f"Running {exe_path.name} in prefix...") + self.logger.debug(f"Command: {' '.join(cmd)}") result = subprocess.run( cmd, env=env, @@ -379,22 +412,24 @@ class WabbajackInstallerHandler: if result.stdout: error_msg += f"\nStdout: {result.stdout}" self.logger.error(error_msg) + self.logger.debug(f"Full command output - returncode: {result.returncode}, stdout length: {len(result.stdout) if result.stdout else 0}, stderr length: {len(result.stderr) if result.stderr else 0}") raise RuntimeError(error_msg) - def apply_registry(self, app_id: int, reg_content: str) -> None: + def apply_registry(self, app_id: int, reg_content: str, proton_path: Optional[Path] = None) -> None: """ Apply registry content to Wine prefix. Args: app_id: Steam AppID reg_content: Registry file content + proton_path: Optional path to Proton directory; if None, uses Proton Experimental Raises: RuntimeError: If registry application fails """ - proton_path = self.find_proton_experimental() + proton_path = proton_path or self.find_proton_experimental() if not proton_path: - raise RuntimeError("Proton Experimental not found") + raise RuntimeError("Proton not found") compat_data = self.get_compat_data_path(app_id) if not compat_data: @@ -434,13 +469,14 @@ class WabbajackInstallerHandler: if temp_reg.exists(): temp_reg.unlink() - def install_webview2(self, app_id: int, install_folder: Path) -> None: + def install_webview2(self, app_id: int, install_folder: Path, proton_path: Optional[Path] = None) -> None: """ Download and install WebView2 runtime. Args: app_id: Steam AppID install_folder: Directory to download installer to + proton_path: Optional path to Proton directory; if None, uses Proton Experimental Raises: RuntimeError: If installation fails @@ -456,11 +492,18 @@ class WabbajackInstallerHandler: self.logger.info(f"WebView2 installer path: {webview_installer}") self.logger.info(f"AppID: {app_id}") try: - self.run_in_prefix(app_id, webview_installer, ["/silent", "/install"]) + self.run_in_prefix(app_id, webview_installer, ["/silent", "/install"], proton_path=proton_path) self.logger.info("WebView2 installed successfully") except RuntimeError as e: + error_str = str(e) + # Exit code 8 might mean "already installed" - log but don't fail + if "exit code 8" in error_str: + self.logger.warning(f"WebView2 installer returned exit code 8: {error_str}") + self.logger.warning("This may indicate WebView2 is already installed. Continuing...") + # Don't raise - treat as non-fatal + return self.logger.error(f"WebView2 installation failed: {e}") - # Re-raise to let caller handle it + # Re-raise for other errors raise finally: @@ -472,17 +515,18 @@ class WabbajackInstallerHandler: except Exception as e: self.logger.warning(f"Failed to cleanup WebView2 installer: {e}") - def apply_win7_registry(self, app_id: int) -> None: + def apply_win7_registry(self, app_id: int, proton_path: Optional[Path] = None) -> None: """ Apply Windows 7 registry settings. Args: app_id: Steam AppID + proton_path: Optional path to Proton directory; if None, uses Proton Experimental Raises: RuntimeError: If registry application fails """ - self.apply_registry(app_id, self.WIN7_REGISTRY) + self.apply_registry(app_id, self.WIN7_REGISTRY, proton_path=proton_path) def detect_heroic_gog_games(self) -> List[Dict]: """ diff --git a/jackify/backend/handlers/wabbajack_prefix_setup.py b/jackify/backend/handlers/wabbajack_prefix_setup.py new file mode 100644 index 0000000..c5de7db --- /dev/null +++ b/jackify/backend/handlers/wabbajack_prefix_setup.py @@ -0,0 +1,347 @@ +"""Prefix setup methods for InstallWabbajackHandler (Mixin).""" +import logging +import os +import pwd +import re +import shutil +import subprocess +import time +from pathlib import Path +from typing import Optional, Tuple + +from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET + +logger = logging.getLogger(__name__) + + +class WabbajackPrefixSetupMixin: + """Mixin providing Wine prefix setup methods.""" + + def _find_steam_library_and_vdf_path(self) -> Tuple[Optional[Path], Optional[Path]]: + """Finds the Steam library root and the path to the real libraryfolders.vdf.""" + self.logger.info("Attempting to find Steam library and libraryfolders.vdf...") + try: + if isinstance(self.path_handler, type): + common_path = self.path_handler.find_steam_library() + else: + common_path = self.path_handler.find_steam_library() + + if not common_path or not common_path.is_dir(): + self.logger.error("Could not find Steam library common path.") + return None, None + + library_root = common_path.parent.parent + self.logger.debug(f"Deduced library root: {library_root}") + + vdf_path_candidates = [ + library_root / 'config/libraryfolders.vdf', + library_root / '../config/libraryfolders.vdf' + ] + + real_vdf_path = None + for candidate in vdf_path_candidates: + resolved_candidate = candidate.resolve() + if resolved_candidate.is_file(): + real_vdf_path = resolved_candidate + self.logger.info(f"Found real libraryfolders.vdf at: {real_vdf_path}") + break + + if not real_vdf_path: + self.logger.error(f"Could not find libraryfolders.vdf within library root: {library_root}") + return None, None + + return library_root, real_vdf_path + + except Exception as e: + self.logger.error(f"Error finding Steam library/VDF: {e}", exc_info=True) + return None, None + + def _link_steam_library_config(self) -> bool: + """Creates the necessary directory structure and symlinks libraryfolders.vdf.""" + if not self.compatdata_path: + self.logger.error("Cannot link Steam library: compatdata_path not set.") + return False + + self.logger.info("Linking Steam library configuration (libraryfolders.vdf)...") + + library_root, real_vdf_path = self._find_steam_library_and_vdf_path() + if not library_root or not real_vdf_path: + self.logger.error("Could not locate Steam library or libraryfolders.vdf.") + return False + + target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' + link_path = target_dir / 'libraryfolders.vdf' + + try: + self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") + if not self.filesystem_handler.backup_file(real_vdf_path): + self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") + self.logger.warning("Failed to create backup of libraryfolders.vdf.") + + self.logger.debug(f"Creating directory: {target_dir}") + os.makedirs(target_dir, exist_ok=True) + + if link_path.is_symlink(): + self.logger.debug(f"Removing existing symlink at {link_path}") + link_path.unlink() + elif link_path.exists(): + self.logger.warning(f"Path {link_path} exists but is not a symlink. Removing it.") + if link_path.is_dir(): + shutil.rmtree(link_path) + else: + link_path.unlink() + + self.logger.info(f"Creating symlink from {real_vdf_path} to {link_path}") + os.symlink(real_vdf_path, link_path) + + if link_path.is_symlink() and link_path.resolve() == real_vdf_path.resolve(): + self.logger.info("Symlink created and verified successfully.") + return True + else: + self.logger.error("Symlink creation failed or verification failed.") + return False + + except OSError as e: + self.logger.error(f"OSError during symlink creation: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error creating Steam library link: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Unexpected error during symlink creation: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _create_prefix_library_vdf(self) -> bool: + """Creates the necessary directory structure and copies a modified libraryfolders.vdf.""" + if not self.compatdata_path: + self.logger.error("Cannot create prefix VDF: compatdata_path not set.") + return False + + self.logger.info("Creating modified libraryfolders.vdf in prefix...") + + library_root, real_vdf_path = self._find_steam_library_and_vdf_path() + if not real_vdf_path: + self.logger.error("Could not locate real libraryfolders.vdf.") + return False + + self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") + if not self.filesystem_handler.backup_file(real_vdf_path): + self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") + self.logger.warning("Failed to create backup of libraryfolders.vdf.") + + target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' + target_vdf_path = target_dir / 'libraryfolders.vdf' + + try: + self.logger.debug(f"Reading content from {real_vdf_path}") + vdf_content = real_vdf_path.read_text(encoding='utf-8') + + path_pattern = re.compile(r'("path"\s*")([^"]+)(")') + + def replace_path(match): + prefix, linux_path_str, suffix = match.groups() + self.logger.debug(f"Found path entry to convert: {linux_path_str}") + try: + linux_path = Path(linux_path_str) + if self.filesystem_handler.is_sd_card(linux_path): + relative_sd_path_str = self.filesystem_handler._strip_sdcard_path_prefix(linux_path) + wine_path = "D:\\" + relative_sd_path_str.replace('/', '\\') + self.logger.debug(f" Converted SD card path: {linux_path_str} -> {wine_path}") + else: + wine_path = "Z:\\" + linux_path_str.strip('/').replace('/', '\\') + self.logger.debug(f" Converted standard path: {linux_path_str} -> {wine_path}") + + wine_path_vdf_escaped = wine_path.replace('\\', '\\\\') + return f'{prefix}{wine_path_vdf_escaped}{suffix}' + except Exception as e: + self.logger.error(f"Error converting path '{linux_path_str}': {e}. Keeping original.") + return match.group(0) + + modified_content = path_pattern.sub(replace_path, vdf_content) + + if modified_content != vdf_content: + self.logger.info("Successfully converted Linux paths to Wine paths in VDF content.") + else: + self.logger.warning("VDF content unchanged after conversion attempt. Did it contain Linux paths?") + + self.logger.debug(f"Ensuring target directory exists: {target_dir}") + os.makedirs(target_dir, exist_ok=True) + + self.logger.info(f"Writing modified VDF content to {target_vdf_path}") + target_vdf_path.write_text(modified_content, encoding='utf-8') + + if target_vdf_path.is_file(): + self.logger.info("Prefix libraryfolders.vdf created successfully.") + return True + else: + self.logger.error("Failed to create prefix libraryfolders.vdf.") + return False + + except Exception as e: + self.logger.error(f"Error processing or writing prefix libraryfolders.vdf: {e}", exc_info=True) + print(f"{COLOR_ERROR}An error occurred configuring the Steam library in the prefix: {e}{COLOR_RESET}") + return False + + def _create_dotnet_cache_dir(self) -> bool: + """Creates the dotnet_bundle_extract cache directory.""" + if not self.install_path: + self.logger.error("Cannot create dotnet cache dir: install_path not set.") + return False + + try: + username = pwd.getpwuid(os.getuid()).pw_name + except Exception as e: + self.logger.error(f"Could not determine username: {e}") + self.logger.error("Could not determine username to create cache directory.") + return False + + cache_dir = self.install_path / 'home' / username / '.cache' / 'dotnet_bundle_extract' + self.logger.info(f"Creating dotnet bundle cache directory: {cache_dir}") + + try: + os.makedirs(cache_dir, exist_ok=True) + self.logger.info("dotnet cache directory created successfully.") + return True + except OSError as e: + self.logger.error(f"Failed to create dotnet cache directory {cache_dir}: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error creating dotnet cache directory: {e}{COLOR_RESET}") + return False + except Exception as e: + self.logger.error(f"Unexpected error creating dotnet cache directory: {e}", exc_info=True) + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + return False + + def _check_and_prompt_flatpak_overrides(self): + """Checks if Flatpak Steam needs filesystem overrides and prompts the user to apply them.""" + self.logger.info("Checking for necessary Flatpak Steam filesystem overrides...") + is_flatpak_steam = False + if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): + is_flatpak_steam = True + self.logger.debug("Flatpak Steam detected based on compatdata path.") + + if not is_flatpak_steam: + self.logger.info("Flatpak Steam not detected, skipping override check.") + return + + paths_to_check = [] + if self.install_path: + paths_to_check.append(self.install_path) + + try: + all_libs = self.path_handler.get_all_steam_libraries() + paths_to_check.extend(all_libs) + except Exception as e: + self.logger.warning(f"Could not get all Steam libraries to check for overrides: {e}") + + needed_overrides = set() + home_dir = Path.home() + flatpak_steam_data_dir = home_dir / ".var/app/com.valvesoftware.Steam" + + for path in paths_to_check: + if not path: + continue + resolved_path = path.resolve() + is_outside_home = not str(resolved_path).startswith(str(home_dir)) + is_outside_flatpak_data = not str(resolved_path).startswith(str(flatpak_steam_data_dir)) + + if is_outside_home and is_outside_flatpak_data: + parent_to_add = resolved_path.parent + while parent_to_add != parent_to_add.parent and len(str(parent_to_add)) > 1 and parent_to_add.name != 'home': + if parent_to_add.is_dir(): + needed_overrides.add(str(parent_to_add)) + self.logger.debug(f"Path {resolved_path} is outside sandbox. Adding parent {parent_to_add} to needed overrides.") + break + parent_to_add = parent_to_add.parent + + if not needed_overrides: + self.logger.info("No external paths requiring Flatpak overrides detected.") + return + + override_commands = [] + for path_str in sorted(list(needed_overrides)): + override_commands.append(f"flatpak override --user --filesystem=\"{path_str}\" com.valvesoftware.Steam") + + command_display = "\n".join([f" {cmd}" for cmd in override_commands]) + + print(f"\n{COLOR_PROMPT}--- Flatpak Steam Permissions ---{COLOR_RESET}") + print("Jackify has detected that you are using Flatpak Steam and have paths") + print("(e.g., Wabbajack install location or other Steam libraries) outside") + print("the standard Flatpak sandbox. For Wabbajack to access these locations,") + print("Steam needs the following filesystem permissions:") + print(f"{COLOR_INFO}{command_display}{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + + try: + confirm = input(f"{COLOR_PROMPT}Do you want Jackify to apply these permissions now? (y/N): {COLOR_RESET}").lower().strip() + if confirm == 'y': + self.logger.info("User confirmed applying Flatpak overrides.") + success_count = 0 + for cmd_str in override_commands: + self.logger.info(f"Executing: {cmd_str}") + try: + cmd_list = cmd_str.split() + result = subprocess.run(cmd_list, check=True, capture_output=True, text=True, timeout=30) + self.logger.debug(f"Override command successful: {result.stdout}") + success_count += 1 + except FileNotFoundError: + print(f"{COLOR_ERROR}Error: 'flatpak' command not found. Cannot apply override.{COLOR_RESET}") + break + except subprocess.TimeoutExpired: + print(f"{COLOR_ERROR}Error: Flatpak override command timed out.{COLOR_RESET}") + except subprocess.CalledProcessError as e: + self.logger.error(f"Flatpak override failed: {cmd_str}. Error: {e.stderr}") + print(f"{COLOR_ERROR}Error applying override: {cmd_str}\n{e.stderr}{COLOR_RESET}") + except Exception as e: + self.logger.error(f"Unexpected error applying override {cmd_str}: {e}") + print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") + + if success_count == len(override_commands): + print(f"{COLOR_INFO}Successfully applied necessary Flatpak permissions.{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}Applied {success_count}/{len(override_commands)} permissions. Some overrides may have failed. Check logs.{COLOR_RESET}") + else: + self.logger.info("User declined applying Flatpak overrides.") + print("Permissions not applied. You may need to run the override command(s) manually") + print("if Wabbajack has issues accessing files or game installations.") + + except KeyboardInterrupt: + print("\nOperation cancelled by user.") + self.logger.warning("User cancelled during Flatpak override prompt.") + except Exception as e: + self.logger.error(f"Error during Flatpak override prompt/execution: {e}") + + def _disable_prefix_decoration(self) -> bool: + """Disables window manager decoration in the Wine prefix using protontricks -c.""" + if not self.final_appid: + self.logger.error("Cannot disable decoration: final_appid not set.") + return False + + self.logger.info(f"Disabling window manager decoration for AppID {self.final_appid} via -c 'wine reg add...'") + command = 'wine reg add "HKCU\\Software\\Wine\\X11 Driver" /v Decorated /t REG_SZ /d N /f' + + try: + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized!") + self.logger.error("Internal Error: Protontricks handler not available.") + return False + + result = self.protontricks_handler.run_protontricks( + '-c', + command, + self.final_appid + ) + + if result and result.returncode == 0: + self.logger.info("Successfully disabled window decoration (command returned 0).") + time.sleep(1) + return True + else: + err_msg = result.stderr if result else "Command execution failed or returned non-zero" + if result and not result.stderr and result.stdout: + err_msg += f"\nSTDOUT: {result.stdout}" + self.logger.error(f"Failed to disable window decoration via -c. Error: {err_msg}") + self.logger.error("Failed to disable window decoration via protontricks -c.") + return False + except Exception as e: + self.logger.error(f"Exception disabling window decoration: {e}", exc_info=True) + self.logger.error(f"Error disabling window decoration: {e}.") + return False diff --git a/jackify/backend/handlers/wabbajack_steam_integration.py b/jackify/backend/handlers/wabbajack_steam_integration.py new file mode 100644 index 0000000..06c7976 --- /dev/null +++ b/jackify/backend/handlers/wabbajack_steam_integration.py @@ -0,0 +1,148 @@ +"""Steam integration methods for InstallWabbajackHandler (Mixin).""" +import logging +import os + +from .status_utils import clear_status, show_status +from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET + +logger = logging.getLogger(__name__) + + +class WabbajackSteamIntegrationMixin: + """Mixin providing Steam shortcut and restart methods.""" + + def _create_steam_shortcut(self) -> bool: + """ + Creates the Steam shortcut for Wabbajack using the ShortcutHandler. + + Returns: + bool: True on success, False otherwise. + """ + if not self.shortcut_name or not self.install_path: + self.logger.error("Cannot create shortcut: Missing shortcut name or install path.") + return False + + self.logger.info(f"Creating Steam shortcut '{self.shortcut_name}'...") + executable_path = str(self.install_path / "Wabbajack.exe") + + from ..services.native_steam_service import NativeSteamService + steam_service = NativeSteamService() + + success, app_id = steam_service.create_shortcut_with_proton( + app_name=self.shortcut_name, + exe_path=executable_path, + start_dir=os.path.dirname(executable_path), + launch_options="PROTON_USE_WINED3D=1 %command%", + tags=["Jackify", "Wabbajack"], + proton_version="proton_experimental" + ) + + if success and app_id: + self.initial_appid = app_id + self.logger.info(f"Shortcut created successfully with initial AppID: {self.initial_appid}") + return True + else: + self.logger.error("Failed to create Steam shortcut via ShortcutHandler.") + print(f"{COLOR_ERROR}Error: Failed to create the Steam shortcut for Wabbajack.{COLOR_RESET}") + return False + + def _display_manual_proton_steps(self): + """Displays the detailed manual steps required for Proton setup.""" + if not self.shortcut_name: + self.logger.error("Cannot display manual steps: shortcut_name not set.") + self.logger.error("Internal Error: Shortcut name missing.") + return + + 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}{self.shortcut_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}{self.shortcut_name}{COLOR_RESET}' from your Steam Library") + print(" 8. Wait for Wabbajack to download its files and fully load") + print(" 9. Once Wabbajack has fully loaded, CLOSE IT completely and return here") + print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}") + + def _handle_steam_restart_and_manual_steps(self) -> bool: + """Handles Steam restart and manual steps prompt, but no extra confirmation.""" + self.logger.info("Handling Steam restart and manual steps prompt.") + clear_status() + print("\n───────────────────────────────────────────────────────────────────") + print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} After Steam restarts, follow the on-screen instructions to set Proton Experimental.") + print("───────────────────────────────────────────────────────────────────") + self.logger.info("Attempting secure Steam restart...") + show_status("Restarting Steam") + if not hasattr(self, 'shortcut_handler') or not self.shortcut_handler: + self.logger.critical("ShortcutHandler not initialized in InstallWabbajackHandler!") + self.logger.error("Internal Error: Shortcut handler not available for restart.") + return False + if self.shortcut_handler.secure_steam_restart(): + self.logger.info("Secure Steam restart successful.") + clear_status() + self._display_manual_proton_steps() + print() + input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") + self.logger.info("User confirmed completion of manual steps.") + return True + else: + self.logger.error("Secure Steam restart failed.") + clear_status() + print(f"\n{COLOR_ERROR}Error: Steam restart failed.{COLOR_RESET}") + print("Please try restarting Steam manually:") + print("1. Exit Steam completely (Steam -> Exit or right-click tray icon -> Exit)") + print("2. Wait a few seconds") + print("3. Start Steam again") + print("\nAfter restarting, you MUST perform the manual Proton setup steps:") + self._display_manual_proton_steps() + print(f"\n{COLOR_ERROR}You will need to re-run this Jackify option after completing these steps.{COLOR_RESET}") + print("───────────────────────────────────────────────────────────────────") + return False + + def _redetect_appid(self) -> bool: + """ + Re-detects the AppID for the shortcut after Steam restart. + + Returns: + bool: True if AppID is found, False otherwise. + """ + if not self.shortcut_name: + self.logger.error("Cannot redetect AppID: shortcut_name not set.") + return False + + self.logger.info(f"Re-detecting AppID for shortcut '{self.shortcut_name}'...") + try: + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") + self.logger.error("Internal Error: Protontricks handler not available.") + return False + + all_shortcuts = self.protontricks_handler.list_non_steam_shortcuts() + + if not all_shortcuts: + self.logger.error("Protontricks listed no non-Steam shortcuts.") + return False + + found_appid = None + for name, appid in all_shortcuts.items(): + if name.lower() == self.shortcut_name.lower(): + found_appid = appid + break + + if found_appid: + self.final_appid = found_appid + self.logger.info(f"Successfully re-detected AppID: {self.final_appid}") + if self.initial_appid and self.initial_appid != self.final_appid: + self.logger.info(f"AppID changed after Steam restart: {self.initial_appid} -> {self.final_appid}") + elif not self.initial_appid: + self.logger.warning("Initial AppID was not set, cannot compare.") + return True + else: + self.logger.error(f"Shortcut '{self.shortcut_name}' not found in protontricks list after restart.") + return False + + except Exception as e: + self.logger.error(f"Error re-detecting AppID: {e}", exc_info=True) + return False diff --git a/jackify/backend/handlers/wabbajack_verification.py b/jackify/backend/handlers/wabbajack_verification.py new file mode 100644 index 0000000..84a846c --- /dev/null +++ b/jackify/backend/handlers/wabbajack_verification.py @@ -0,0 +1,151 @@ +"""Verification methods for InstallWabbajackHandler (Mixin).""" +import logging +import shutil +from pathlib import Path +from typing import Optional + +from .status_utils import clear_status, show_status +from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET + +logger = logging.getLogger(__name__) + + +class WabbajackVerificationMixin: + """Mixin providing verification and validation methods.""" + + def _find_steam_config_vdf(self) -> Optional[Path]: + """Finds the path to the primary Steam config.vdf file.""" + self.logger.debug("Searching for Steam config.vdf...") + common_paths = [ + Path.home() / ".steam/steam/config/config.vdf", + Path.home() / ".local/share/Steam/config/config.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.config/Valve Corporation/Steam/config/config.vdf" + ] + for path in common_paths: + if path.is_file(): + self.logger.info(f"Found config.vdf at: {path}") + return path + self.logger.error("Could not find Steam config.vdf in common locations.") + return None + + def _verify_manual_steps(self) -> bool: + """ + Verifies that the user has performed the manual steps using ModlistHandler. + Checks AppID, Proton version set, and prefix existence. + + Returns: + bool: True if verification passes AND compatdata_path is set, False otherwise. + """ + self.logger.info("Verifying manual Proton setup steps...") + self.compatdata_path = None + + clear_status() + if not self._redetect_appid(): + print(f"{COLOR_ERROR}Error: Could not find the Steam shortcut '{self.shortcut_name}' using protontricks.{COLOR_RESET}") + print(f"{COLOR_INFO}Ensure Steam has restarted and the shortcut is visible.{COLOR_RESET}") + return False + + self.logger.debug(f"Verification using final AppID: {self.final_appid}") + + show_status("Verifying Proton Setup") + + if not hasattr(self, 'modlist_handler') or not self.modlist_handler: + self.logger.critical("ModlistHandler not initialized in InstallWabbajackHandler!") + self.logger.error("Internal Error: Modlist handler not available for verification.") + return False + + verified, status_code = self.modlist_handler.verify_proton_setup(self.final_appid) + + if not verified: + if status_code == 'wrong_proton_version': + proton_ver = getattr(self.modlist_handler, 'proton_ver', 'Unknown') + print(f"{COLOR_ERROR}\nVerification Failed: Incorrect Proton version detected ('{proton_ver}'). Expected 'Proton Experimental' (or similar).{COLOR_RESET}") + print(f"{COLOR_INFO}Please ensure you selected the correct Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") + elif status_code == 'proton_check_failed': + print(f"{COLOR_ERROR}\nVerification Failed: Compatibility tool not detected as set for '{self.shortcut_name}' in Steam config.{COLOR_RESET}") + print(f"{COLOR_INFO}Please ensure you forced a Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") + elif status_code == 'compatdata_missing': + print(f"{COLOR_ERROR}\nVerification Failed: Steam compatdata directory for AppID {self.final_appid} not found.{COLOR_RESET}") + print(f"{COLOR_INFO}Have you launched the shortcut '{self.shortcut_name}' at least once after setting Proton?{COLOR_RESET}") + elif status_code == 'prefix_missing': + print(f"{COLOR_ERROR}\nVerification Failed: Wine prefix directory (pfx) not found inside compatdata.{COLOR_RESET}") + print(f"{COLOR_INFO}This usually means the shortcut hasn't been launched successfully after setting Proton.{COLOR_RESET}") + elif status_code == 'config_vdf_missing' or status_code == 'config_vdf_error': + print(f"{COLOR_ERROR}\nVerification Failed: Could not read or parse Steam's config.vdf file ({status_code}).{COLOR_RESET}") + print(f"{COLOR_INFO}Check file permissions or integrity. Check logs for details.{COLOR_RESET}") + else: + print(f"{COLOR_ERROR}\nVerification Failed: An unexpected error occurred ({status_code}). Check logs.{COLOR_RESET}") + return False + + self.logger.info("Basic verification checks passed. Confirming compatdata path...") + + modlist_handler_compat_path = getattr(self.modlist_handler, 'compat_data_path', None) + if modlist_handler_compat_path: + self.compatdata_path = modlist_handler_compat_path + self.logger.info(f"Compatdata path obtained from ModlistHandler: {self.compatdata_path}") + else: + self.logger.info("ModlistHandler did not set compat_data_path. Attempting manual lookup via PathHandler.") + if not hasattr(self, 'path_handler') or not self.path_handler: + self.logger.critical("PathHandler not initialized in InstallWabbajackHandler!") + self.logger.error("Internal Error: Path handler not available for verification.") + return False + + self.compatdata_path = self.path_handler.find_compat_data(self.final_appid) + if self.compatdata_path: + self.logger.info(f"Manually found compatdata path via PathHandler: {self.compatdata_path}") + else: + self.logger.error("Verification checks passed, but COULD NOT FIND compatdata path via ModlistHandler or PathHandler.") + print(f"\n{COLOR_ERROR}Verification Error: Basic checks passed, but failed to locate the compatdata directory for AppID {self.final_appid}.{COLOR_RESET}") + print(f"{COLOR_INFO}This is unexpected. Check Steam filesystem structure and logs.{COLOR_RESET}") + return False + + self.logger.info("Manual steps verification successful (including path confirmation).") + logger.info(f"Verification successful! (AppID: {self.final_appid}, Path: {self.compatdata_path})") + return True + + def _backup_and_replace_final_reg_files(self) -> bool: + """Backs up current reg files and replaces them with the final downloaded versions.""" + if not self.compatdata_path: + self.logger.error("Cannot backup/replace reg files: compatdata_path not set.") + return False + + pfx_path = self.compatdata_path / 'pfx' + system_reg = pfx_path / 'system.reg' + user_reg = pfx_path / 'user.reg' + system_reg_bak = pfx_path / 'system.reg.orig' + user_reg_bak = pfx_path / 'user.reg.orig' + + self.logger.info("Backing up existing registry files...") + logger.info("Backing up current registry files...") + try: + if system_reg.exists(): + shutil.copy2(system_reg, system_reg_bak) + self.logger.debug(f"Backed up {system_reg} to {system_reg_bak}") + else: + self.logger.warning(f"Original {system_reg} not found for backup.") + + if user_reg.exists(): + shutil.copy2(user_reg, user_reg_bak) + self.logger.debug(f"Backed up {user_reg} to {user_reg_bak}") + else: + self.logger.warning(f"Original {user_reg} not found for backup.") + + except Exception as e: + self.logger.error(f"Error backing up registry files: {e}", exc_info=True) + print(f"{COLOR_ERROR}Error backing up registry files: {e}{COLOR_RESET}") + return False + + final_system_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/system.reg.github" + final_user_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/user.reg.github" + + logger.info("Downloading and applying final registry settings...") + system_ok = self._download_and_replace_reg_file(final_system_reg_url, system_reg) + user_ok = self._download_and_replace_reg_file(final_user_reg_url, user_reg) + + if system_ok and user_ok: + self.logger.info("Successfully applied final registry files.") + return True + else: + self.logger.error("Failed to download or replace one or both final registry files.") + self.logger.error("Failed to apply final registry settings.") + return False diff --git a/jackify/backend/handlers/wabbajack_webview.py b/jackify/backend/handlers/wabbajack_webview.py new file mode 100644 index 0000000..28109f0 --- /dev/null +++ b/jackify/backend/handlers/wabbajack_webview.py @@ -0,0 +1,140 @@ +"""WebView installation methods for InstallWabbajackHandler (Mixin).""" +import logging +import shutil +import subprocess +from pathlib import Path + +from .status_utils import show_status + +logger = logging.getLogger(__name__) + + +class WabbajackWebViewMixin: + """Mixin providing WebView installation methods.""" + + def _install_webview(self) -> bool: + """Installs the WebView2 runtime using protontricks-launch.""" + if not self.final_appid or not self.install_path: + self.logger.error("Cannot install WebView: final_appid or install_path not set.") + return False + + installer_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + installer_path = self.install_path / installer_name + + if not installer_path.is_file(): + self.logger.error(f"WebView installer not found at {installer_path}. Cannot install.") + self.logger.error("WebView installer file missing. Please ensure step 12 completed.") + return False + + self.logger.info(f"Starting WebView installation for AppID {self.final_appid}...") + + cmd_prefix = [] + if self.protontricks_handler.which_protontricks == 'flatpak': + cmd_prefix = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks"] + else: + launch_path = shutil.which("protontricks-launch") + if not launch_path: + self.logger.error("protontricks-launch command not found in PATH.") + self.logger.error("protontricks-launch command not found.") + return False + cmd_prefix = [launch_path] + + args = ["--appid", self.final_appid, str(installer_path), "/silent", "/install"] + full_cmd = cmd_prefix + args + + self.logger.debug(f"Executing WebView install command: {' '.join(full_cmd)}") + + try: + result = subprocess.run(full_cmd, check=True, capture_output=True, text=True, timeout=600) + self.logger.info("WebView installation command completed successfully.") + return True + except FileNotFoundError: + self.logger.error(f"Command not found: {cmd_prefix[0]}") + self.logger.error(f"Could not execute {cmd_prefix[0]}. Is it installed correctly?") + return False + except subprocess.TimeoutExpired: + self.logger.error("WebView installation timed out after 10 minutes.") + self.logger.error("WebView installation took too long and timed out.") + return False + except subprocess.CalledProcessError as e: + self.logger.error(f"WebView installation failed with return code {e.returncode}") + self.logger.error(f"STDERR (truncated):\n{e.stderr[:500] if e.stderr else ''}") + self.logger.error(f"WebView installation failed (Return Code: {e.returncode}). Check logs for details.") + return False + except Exception as e: + self.logger.error(f"Unexpected error during WebView installation: {e}", exc_info=True) + self.logger.error(f"An unexpected error occurred during WebView installation: {e}") + return False + + def _download_webview_installer(self) -> bool: + """ + Downloads the specific WebView2 installer needed by Wabbajack. + Checks existence first. + + Returns: + bool: True on success or if file already exists correctly, False otherwise. + """ + if not self.install_path: + self.logger.error("Cannot download WebView installer: install_path is not set.") + return False + + url = "https://node10.sokloud.com/filebrowser/api/public/dl/yqVTbUT8/rwatch/WebView/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + file_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" + destination = self.install_path / file_name + + self.logger.info(f"Checking WebView installer: {destination}") + + if destination.is_file(): + self.logger.info(f"WebView installer {destination.name} already exists. Skipping download.") + return True + + self.logger.info(f"WebView installer not found locally. Downloading {file_name}...") + show_status("Downloading WebView Installer") + + if self._download_file(url, destination): + return True + else: + self.logger.error(f"Failed to download WebView installer from {url}.") + return False + + def _set_prefix_renderer(self, renderer: str = 'vulkan') -> bool: + """Sets the prefix renderer using protontricks.""" + if not self.final_appid: + self.logger.error("Cannot set renderer: final_appid not set.") + return False + + self.logger.info(f"Setting prefix renderer to {renderer} for AppID {self.final_appid}...") + try: + if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: + self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") + self.logger.error("Internal Error: Protontricks handler not available.") + return False + + result = self.protontricks_handler.run_protontricks( + self.final_appid, + 'settings', + f'renderer={renderer}' + ) + if result and result.returncode == 0: + self.logger.info(f"Successfully set renderer to {renderer}.") + return True + else: + err_msg = result.stderr if result else "Command execution failed" + self.logger.error(f"Failed to set renderer to {renderer}. Error: {err_msg}") + self.logger.error(f"Failed to set prefix renderer to {renderer}.") + return False + except Exception as e: + self.logger.error(f"Exception setting renderer: {e}", exc_info=True) + self.logger.error(f"Error setting prefix renderer: {e}.") + return False + + def _download_and_replace_reg_file(self, url: str, target_reg_path: Path) -> bool: + """Downloads a .reg file and replaces the target file. Always downloads and overwrites.""" + self.logger.info(f"Downloading registry file from {url} to replace {target_reg_path}") + + if self._download_file(url, target_reg_path): + self.logger.info(f"Successfully downloaded and replaced {target_reg_path}") + return True + else: + self.logger.error(f"Failed to download/replace {target_reg_path} from {url}") + return False diff --git a/jackify/backend/handlers/wine_utils.py b/jackify/backend/handlers/wine_utils.py index 11ce136..0a1d894 100644 --- a/jackify/backend/handlers/wine_utils.py +++ b/jackify/backend/handlers/wine_utils.py @@ -2,269 +2,178 @@ # -*- coding: utf-8 -*- """ Wine Utilities Module -Handles wine-related operations and utilities +Handles wine-related operations and utilities. +Proton and config logic live in mixins (wine_utils_proton, wine_utils_config). """ import os import re import subprocess import logging -import shutil -import time from pathlib import Path -import glob -from typing import Optional, Tuple, List, Dict -from .subprocess_utils import get_clean_subprocess_env +from typing import Optional + +from .subprocess_utils import get_clean_subprocess_env +from .wine_utils_proton import WineUtilsProtonMixin, VALVE_PROTON_APPID_MAP +from .wine_utils_config import WineUtilsConfigMixin -# Initialize logger logger = logging.getLogger(__name__) -# Known Valve Proton App ID -> config.vdf internal name mapping -VALVE_PROTON_APPID_MAP = { - '2805730': 'proton_9', - '3658110': 'proton_10', - '1493710': 'proton_experimental', - '2180100': 'proton_hotfix', - '1887720': 'proton_8', -} +# Re-export for any code that imports from wine_utils +__all__ = ['WineUtils', 'VALVE_PROTON_APPID_MAP'] -class WineUtils: - """ - Utilities for wine-related operations - """ - +class WineUtils(WineUtilsProtonMixin, WineUtilsConfigMixin): + """Utilities for wine-related operations. Proton and config logic in mixins.""" + @staticmethod - def cleanup_wine_processes(): - """ - Clean up wine processes - Returns True on success, False on failure - """ + def cleanup_wine_processes() -> bool: + """Clean up wine processes. Returns True on success, False on failure.""" try: - # Find and kill processes containing various process names processes = subprocess.run( - "pgrep -f 'win7|win10|ShowDotFiles|protontricks'", - shell=True, - capture_output=True, + "pgrep -f 'win7|win10|ShowDotFiles|protontricks'", + shell=True, + capture_output=True, text=True, env=get_clean_subprocess_env() ).stdout.strip() - if processes: for pid in processes.split("\n"): try: - subprocess.run(f"kill -9 {pid}", shell=True, check=True, env=get_clean_subprocess_env()) + subprocess.run( + f"kill -9 {pid}", shell=True, check=True, + env=get_clean_subprocess_env() + ) except subprocess.CalledProcessError: logger.warning(f"Failed to kill process {pid}") logger.debug("Processes killed successfully") else: logger.debug("No matching processes found") - - # Kill winetricks processes subprocess.run("pkill -9 winetricks", shell=True, env=get_clean_subprocess_env()) return True except Exception as e: logger.error(f"Failed to cleanup wine processes: {e}") return False - - @staticmethod - def edit_binary_working_paths(modlist_ini, modlist_dir, modlist_sdcard, steam_library, basegame_sdcard): - """ - Edit binary and working directory paths in ModOrganizer.ini - Returns True on success, False on failure - """ - if not os.path.isfile(modlist_ini): - logger.error(f"ModOrganizer.ini not found at {modlist_ini}") - return False - - try: - # Read the file - with open(modlist_ini, 'r', encoding='utf-8', errors='ignore') as f: - content = f.readlines() - - modified_content = [] - found_skse = False - - # First pass to identify SKSE/F4SE launcher entries - skse_lines = [] - for i, line in enumerate(content): - if re.search(r'skse64_loader\.exe|f4se_loader\.exe', line): - skse_lines.append((i, line)) - found_skse = True - - if not found_skse: - logger.debug("No SKSE/F4SE launcher entries found") - return False - - # Process each SKSE/F4SE entry - for line_num, orig_line in skse_lines: - # Split the line into key and value - if '=' not in orig_line: - continue - - binary_num, skse_loc = orig_line.split('=', 1) - - # Set drive letter based on whether using SD card - if modlist_sdcard: - drive_letter = " = D:" - else: - drive_letter = " = Z:" - - # Determine the working directory key - just_num = binary_num.split('\\')[0] - bin_path_start = binary_num.strip().replace('\\', '\\\\') - path_start = f"{just_num}\\\\workingDirectory".replace('\\', '\\\\') - - # Process the path based on its type - if "mods" in orig_line: - # mods path type - if modlist_sdcard: - path_middle = WineUtils._strip_sdcard_path(modlist_dir) - else: - path_middle = modlist_dir - - path_end = re.sub(r'.*/mods', '/mods', skse_loc.split('/')[0]) - bin_path_end = re.sub(r'.*/mods', '/mods', skse_loc) - - elif any(term in orig_line for term in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]): - # Stock Game or Game Root type - if modlist_sdcard: - path_middle = WineUtils._strip_sdcard_path(modlist_dir) - else: - path_middle = modlist_dir - - # Determine the specific stock folder type - if "Stock Game" in orig_line: - dir_type = "stockgame" - path_end = re.sub(r'.*/Stock Game', '/Stock Game', os.path.dirname(skse_loc)) - bin_path_end = re.sub(r'.*/Stock Game', '/Stock Game', skse_loc) - elif "Game Root" in orig_line: - dir_type = "gameroot" - path_end = re.sub(r'.*/Game Root', '/Game Root', os.path.dirname(skse_loc)) - bin_path_end = re.sub(r'.*/Game Root', '/Game Root', skse_loc) - elif "STOCK GAME" in orig_line: - dir_type = "STOCKGAME" - path_end = re.sub(r'.*/STOCK GAME', '/STOCK GAME', os.path.dirname(skse_loc)) - bin_path_end = re.sub(r'.*/STOCK GAME', '/STOCK GAME', skse_loc) - elif "Stock Folder" in orig_line: - dir_type = "stockfolder" - path_end = re.sub(r'.*/Stock Folder', '/Stock Folder', os.path.dirname(skse_loc)) - bin_path_end = re.sub(r'.*/Stock Folder', '/Stock Folder', skse_loc) - elif "Skyrim Stock" in orig_line: - dir_type = "skyrimstock" - path_end = re.sub(r'.*/Skyrim Stock', '/Skyrim Stock', os.path.dirname(skse_loc)) - bin_path_end = re.sub(r'.*/Skyrim Stock', '/Skyrim Stock', skse_loc) - elif "Stock Game Folder" in orig_line: - dir_type = "stockgamefolder" - path_end = re.sub(r'.*/Stock Game Folder', '/Stock Game Folder', skse_loc) - bin_path_end = path_end - elif "root/Skyrim Special Edition" in orig_line: - dir_type = "rootskyrimse" - path_end = '/' + skse_loc.lstrip() - bin_path_end = path_end - else: - logger.error(f"Unknown stock game type in line: {orig_line}") - continue - - elif "steamapps" in orig_line: - # Steam apps path type - if basegame_sdcard: - path_middle = WineUtils._strip_sdcard_path(steam_library) - drive_letter = " = D:" - else: - path_middle = steam_library.split('steamapps')[0] - - path_end = re.sub(r'.*/steamapps', '/steamapps', os.path.dirname(skse_loc)) - bin_path_end = re.sub(r'.*/steamapps', '/steamapps', skse_loc) - - else: - logger.warning(f"No matching pattern found in the path: {orig_line}") - continue - - # Combine paths - full_bin_path = f"{bin_path_start}{drive_letter}{path_middle}{bin_path_end}" - full_path = f"{path_start}{drive_letter}{path_middle}{path_end}" - - # Replace forward slashes with double backslashes for Windows paths - new_path = full_path.replace('/', '\\\\') - - # Update the content with new paths - for i, line in enumerate(content): - if line.startswith(bin_path_start): - content[i] = f"{full_bin_path}\n" - elif line.startswith(path_start): - content[i] = f"{new_path}\n" - - # Write back the modified content - with open(modlist_ini, 'w', encoding='utf-8') as f: - f.writelines(content) - - logger.debug("Updated binary and working directory paths successfully") - return True - - except Exception as e: - logger.error(f"Error editing binary working paths: {e}") - return False - - @staticmethod - def _get_sd_card_mounts(): - """ - Detect SD card mount points using df. - Returns list of actual mount paths from /run/media (e.g., /run/media/deck/MicroSD). - """ - import subprocess - import re + @staticmethod + def _get_sd_card_mounts() -> list: + """Detect SD card mount points using df. Returns list of mount paths from /run/media.""" result = subprocess.run(['df', '-h'], capture_output=True, text=True, timeout=5) sd_mounts = [] - for line in result.stdout.split('\n'): if '/run/media' in line: parts = line.split() if len(parts) >= 6: - mount_point = parts[-1] # Last column is the mount point + mount_point = parts[-1] if mount_point.startswith('/run/media/'): sd_mounts.append(mount_point) - - # Sort by length (longest first) to match most specific paths first sd_mounts.sort(key=len, reverse=True) logger.debug(f"Detected SD card mounts from df: {sd_mounts}") return sd_mounts @staticmethod - def _strip_sdcard_path(path): - """ - Strip SD card mount prefix from path. - Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns. - Pattern: /run/media/deck/UUID/Games/... becomes /Games/... - Pattern: /run/media/mmcblk0p1/Games/... becomes /Games/... - """ - import re - - # Pattern 1: /run/media/deck/UUID/... strip everything up to and including UUID - # This matches the bash: "${path#*/run/media/deck/*/*}" + def _strip_sdcard_path(path: str) -> str: + """Strip SD card mount prefix from path. Handles /run/media/deck/UUID and mmcblk0p1 patterns.""" deck_pattern = r'^/run/media/deck/[^/]+(/.*)?$' match = re.match(deck_pattern, path) if match: stripped = match.group(1) if match.group(1) else "/" logger.debug(f"Stripped SD card path (deck pattern): {path} -> {stripped}") return stripped - - # Pattern 2: /run/media/mmcblk0p1/... strip /run/media/mmcblk0p1 - # This matches the bash: "${path#*mmcblk0p1}" if path.startswith('/run/media/mmcblk0p1/'): stripped = path.replace('/run/media/mmcblk0p1', '', 1) logger.debug(f"Stripped SD card path (mmcblk pattern): {path} -> {stripped}") return stripped - - # No SD card pattern matched return path - + @staticmethod - def all_owned_by_user(path): - """ - Returns True if all files and directories under 'path' are owned by the current user. - """ + def edit_binary_working_paths(modlist_ini: str, modlist_dir: str, modlist_sdcard: bool, + steam_library: str, basegame_sdcard: bool) -> bool: + """Edit binary and working directory paths in ModOrganizer.ini. Returns True on success.""" + if not os.path.isfile(modlist_ini): + logger.error(f"ModOrganizer.ini not found at {modlist_ini}") + return False + try: + with open(modlist_ini, 'r', encoding='utf-8', errors='ignore') as f: + content = f.readlines() + modified_content = [] + found_skse = False + skse_lines = [] + for i, line in enumerate(content): + if re.search(r'skse64_loader\.exe|f4se_loader\.exe', line): + skse_lines.append((i, line)) + found_skse = True + if not found_skse: + logger.debug("No SKSE/F4SE launcher entries found") + return False + for line_num, orig_line in skse_lines: + if '=' not in orig_line: + continue + binary_num, skse_loc = orig_line.split('=', 1) + drive_letter = " = D:" if modlist_sdcard else " = Z:" + just_num = binary_num.split('\\')[0] + bin_path_start = binary_num.strip().replace('\\', '\\\\') + path_start = f"{just_num}\\\\workingDirectory".replace('\\', '\\\\') + if "mods" in orig_line: + path_middle = WineUtils._strip_sdcard_path(modlist_dir) if modlist_sdcard else modlist_dir + path_end = re.sub(r'.*/mods', '/mods', skse_loc.split('/')[0]) + bin_path_end = re.sub(r'.*/mods', '/mods', skse_loc) + elif any(term in orig_line for term in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]): + path_middle = WineUtils._strip_sdcard_path(modlist_dir) if modlist_sdcard else modlist_dir + if "Stock Game" in orig_line: + path_end = re.sub(r'.*/Stock Game', '/Stock Game', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/Stock Game', '/Stock Game', skse_loc) + elif "Game Root" in orig_line: + path_end = re.sub(r'.*/Game Root', '/Game Root', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/Game Root', '/Game Root', skse_loc) + elif "STOCK GAME" in orig_line: + path_end = re.sub(r'.*/STOCK GAME', '/STOCK GAME', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/STOCK GAME', '/STOCK GAME', skse_loc) + elif "Stock Folder" in orig_line: + path_end = re.sub(r'.*/Stock Folder', '/Stock Folder', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/Stock Folder', '/Stock Folder', skse_loc) + elif "Skyrim Stock" in orig_line: + path_end = re.sub(r'.*/Skyrim Stock', '/Skyrim Stock', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/Skyrim Stock', '/Skyrim Stock', skse_loc) + elif "Stock Game Folder" in orig_line: + path_end = re.sub(r'.*/Stock Game Folder', '/Stock Game Folder', skse_loc) + bin_path_end = path_end + elif "root/Skyrim Special Edition" in orig_line: + path_end = '/' + skse_loc.lstrip() + bin_path_end = path_end + else: + logger.error(f"Unknown stock game type in line: {orig_line}") + continue + elif "steamapps" in orig_line: + if basegame_sdcard: + path_middle = WineUtils._strip_sdcard_path(steam_library) + drive_letter = " = D:" + else: + path_middle = steam_library.split('steamapps')[0] + path_end = re.sub(r'.*/steamapps', '/steamapps', os.path.dirname(skse_loc)) + bin_path_end = re.sub(r'.*/steamapps', '/steamapps', skse_loc) + else: + logger.warning(f"No matching pattern found in the path: {orig_line}") + continue + full_bin_path = f"{bin_path_start}{drive_letter}{path_middle}{bin_path_end}" + full_path = f"{path_start}{drive_letter}{path_middle}{path_end}" + new_path = full_path.replace('/', '\\\\') + for i, line in enumerate(content): + if line.startswith(bin_path_start): + content[i] = f"{full_bin_path}\n" + elif line.startswith(path_start): + content[i] = f"{new_path}\n" + with open(modlist_ini, 'w', encoding='utf-8') as f: + f.writelines(content) + logger.debug("Updated binary and working directory paths successfully") + return True + except Exception as e: + logger.error(f"Error editing binary working paths: {e}") + return False + + @staticmethod + def all_owned_by_user(path: str) -> bool: + """Return True if all files and directories under path are owned by the current user.""" uid = os.getuid() gid = os.getgid() for root, dirs, files in os.walk(path): @@ -279,31 +188,24 @@ class WineUtils: return True @staticmethod - def chown_chmod_modlist_dir(modlist_dir): + def chown_chmod_modlist_dir(modlist_dir: str) -> bool: """ DEPRECATED: Use FileSystemHandler.verify_ownership_and_permissions() instead. Verify and fix ownership/permissions for modlist directory. - Returns True if successful, False if sudo required. """ if not WineUtils.all_owned_by_user(modlist_dir): - # Files not owned by us - need sudo to fix logger.error(f"Ownership issue detected: Some files in {modlist_dir} are not owned by the current user") - try: user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip() group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip() - logger.error("To fix ownership issues, open a terminal and run:") logger.error(f" sudo chown -R {user}:{group} \"{modlist_dir}\"") logger.error(f" sudo chmod -R 755 \"{modlist_dir}\"") logger.error("After running these commands, retry the operation.") return False - except Exception as e: logger.error(f"Error checking ownership: {e}") return False - - # Files are owned by us - try to fix permissions ourselves logger.info(f"Files in {modlist_dir} are owned by current user, verifying permissions...") try: result = subprocess.run( @@ -320,945 +222,75 @@ class WineUtils: except Exception as e: logger.warning(f"Error running chmod: {e}, continuing anyway") return True - + @staticmethod - def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full): - """ - Create DXVK file in the modlist directory - """ - try: - # Construct the path to the game directory - game_dir = os.path.join(steam_library, game_var_full) - - # Create the DXVK file - dxvk_file = os.path.join(modlist_dir, "DXVK") - with open(dxvk_file, 'w') as f: - f.write(game_dir) - - logger.debug(f"Created DXVK file at {dxvk_file} pointing to {game_dir}") - return True - except Exception as e: - logger.error(f"Error creating DXVK file: {e}") - return False - - @staticmethod - def small_additional_tasks(modlist_dir, compat_data_path): - """ - Perform small additional tasks like deleting unsupported plugins - Returns True on success, False on failure - """ - try: - # Delete MO2 plugins that don't work via Proton - file_to_delete = os.path.join(modlist_dir, "plugins/FixGameRegKey.py") - if os.path.exists(file_to_delete): - os.remove(file_to_delete) - logger.debug(f"File deleted: {file_to_delete}") - - # Download Font to support Bethini - if compat_data_path and os.path.isdir(compat_data_path): - font_path = os.path.join(compat_data_path, "pfx/drive_c/windows/Fonts/seguisym.ttf") - font_dir = os.path.dirname(font_path) - - # Ensure the directory exists - os.makedirs(font_dir, exist_ok=True) - - # Download the font - font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf" - subprocess.run( - f"wget {font_url} -q -nc -O \"{font_path}\"", - shell=True, - check=True - ) - logger.debug(f"Downloaded font to: {font_path}") - - return True - - except Exception as e: - logger.error(f"Error performing additional tasks: {e}") - return False - - @staticmethod - def modlist_specific_steps(modlist, appid): - """ - Perform modlist-specific steps - Returns True on success, False on failure - """ - try: - # Define modlist-specific configurations - modlist_configs = { - "wildlander": ["dotnet48", "dotnet472", "vcrun2019"], - "septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"], - "masterstroke": ["dotnet48", "dotnet472"], - "diablo": ["dotnet48", "dotnet472"], - "living_skyrim": ["dotnet48", "dotnet472", "dotnet462"], - "nolvus": ["dotnet8"] - } - - modlist_lower = modlist.lower().replace(" ", "") - - # Check for wildlander special case - if "wildlander" in modlist_lower: - logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!") - # Implementation for wildlander-specific steps - return True - - # Check for other modlists - for pattern, components in modlist_configs.items(): - if re.search(pattern.replace("|", "|.*"), modlist_lower): - logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!") - - # Install components - for component in components: - if component == "dotnet8": - # Special handling for .NET 8 - logger.info("Downloading .NET 8 Runtime") - # Implementation for .NET 8 installation - pass - else: - # Standard component installation - logger.info(f"Installing {component}...") - # Implementation for standard component installation - pass - - # Set Windows 10 prefix - # Implementation for setting Windows 10 prefix - - return True - - # No specific steps for this modlist - logger.debug(f"No specific steps needed for {modlist}") - return True - - except Exception as e: - logger.error(f"Error performing modlist-specific steps: {e}") - return False - - @staticmethod - def fnv_launch_options(game_var, compat_data_path, modlist): - """ - Set up Fallout New Vegas launch options - Returns True on success, False on failure - """ - if game_var != "Fallout New Vegas": - return True - - try: - appid_to_check = "22380" # Fallout New Vegas AppID - - for path in [ - os.path.expanduser("~/.local/share/Steam/steamapps/compatdata"), - os.path.expanduser("~/.steam/steam/steamapps/compatdata"), - os.path.expanduser("~/.steam/root/steamapps/compatdata") - ]: - compat_path = os.path.join(path, appid_to_check) - if os.path.exists(compat_path): - logger.warning(f"\nFor {modlist}, please add the following line to the Launch Options in Steam for your '{modlist}' entry:") - logger.info(f"\nSTEAM_COMPAT_DATA_PATH=\"{compat_path}\" %command%") - logger.warning("\nThis is essential for the modlist to load correctly.") - return True - - logger.error("Could not determine the compatdata path for Fallout New Vegas") - return False - - except Exception as e: - logger.error(f"Error setting FNV launch options: {e}") - return False - - @staticmethod - def get_proton_version(compat_data_path): - """ - Detect the Proton version used by a Steam game/shortcut - - Args: - compat_data_path (str): Path to the compatibility data directory - - Returns: - str: Detected Proton version or 'Unknown' if not found - """ - logger.info("Detecting Proton version...") - - # Validate the compatdata path exists - if not os.path.isdir(compat_data_path): - logger.warning(f"Compatdata directory not found at '{compat_data_path}'") - return "Unknown" - - # First try to get Proton version from the registry - system_reg_path = os.path.join(compat_data_path, "pfx", "system.reg") - if os.path.isfile(system_reg_path): - try: - with open(system_reg_path, "r", encoding="utf-8", errors="ignore") as f: - content = f.read() - - # Use regex to find SteamClientProtonVersion entry - match = re.search(r'"SteamClientProtonVersion"="([^"]+)"', content) - if match: - version = match.group(1).strip() - # Keep GE versions as is, otherwise prefix with "Proton" - if "GE" in version: - proton_ver = version - else: - proton_ver = f"Proton {version}" - - logger.debug(f"Detected Proton version from registry: {proton_ver}") - return proton_ver - except Exception as e: - logger.debug(f"Error reading system.reg: {e}") - - # Fallback to config_info if registry method fails - config_info_path = os.path.join(compat_data_path, "config_info") - if os.path.isfile(config_info_path): - try: - with open(config_info_path, "r") as f: - config_ver = f.readline().strip() - - if config_ver: - # Keep GE versions as is, otherwise prefix with "Proton" - if "GE" in config_ver: - proton_ver = config_ver - else: - proton_ver = f"Proton {config_ver}" - - logger.debug(f"Detected Proton version from config_info: {proton_ver}") - return proton_ver - except Exception as e: - logger.debug(f"Error reading config_info: {e}") - - logger.warning("Could not detect Proton version") - return "Unknown" - - @staticmethod - def update_executables(modlist_ini, modlist_dir, modlist_sdcard, steam_library, basegame_sdcard): - """ - Update executable paths in ModOrganizer.ini - """ + def update_executables(modlist_ini: str, modlist_dir: str, modlist_sdcard: bool, + steam_library: str, basegame_sdcard: bool) -> bool: + """Update executable paths in ModOrganizer.ini.""" logger.info("Updating executable paths in ModOrganizer.ini...") - try: - # Find SKSE or F4SE loader entries with open(modlist_ini, 'r') as f: lines = f.readlines() - - # Process each line for i, line in enumerate(lines): if "skse64_loader.exe" in line or "f4se_loader.exe" in line: - # Extract the binary path binary_path = line.strip().split('=', 1)[1] if '=' in line else "" - - # Determine drive letter drive_letter = "D:" if modlist_sdcard else "Z:" - - # Extract binary number binary_num = line.strip().split('=', 1)[0] if '=' in line else "" - - # Find the equivalent workingDirectory justnum = binary_num.split('\\')[0] if '\\' in binary_num else binary_num bin_path_start = binary_num.replace('\\', '\\\\') path_start = f"{justnum}\\workingDirectory".replace('\\', '\\\\') - - # Determine path type and construct new paths if "mods" in binary_path: - # mods path type found - if modlist_sdcard: - path_middle = WineUtils._strip_sdcard_path(modlist_dir) - else: - path_middle = modlist_dir - + path_middle = WineUtils._strip_sdcard_path(modlist_dir) if modlist_sdcard else modlist_dir path_end = '/' + '/'.join(binary_path.split('/mods/', 1)[1].split('/')[:-1]) if '/mods/' in binary_path else "" bin_path_end = '/' + '/'.join(binary_path.split('/mods/', 1)[1].split('/')) if '/mods/' in binary_path else "" - elif any(x in binary_path for x in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]): - # Stock/Game Root found - if modlist_sdcard: - path_middle = WineUtils._strip_sdcard_path(modlist_dir) - else: - path_middle = modlist_dir - - # Determine directory type + path_middle = WineUtils._strip_sdcard_path(modlist_dir) if modlist_sdcard else modlist_dir if "Stock Game" in binary_path: - dir_type = "stockgame" path_end = '/' + '/'.join(binary_path.split('/Stock Game/', 1)[1].split('/')[:-1]) if '/Stock Game/' in binary_path else "" bin_path_end = '/' + '/'.join(binary_path.split('/Stock Game/', 1)[1].split('/')) if '/Stock Game/' in binary_path else "" elif "Game Root" in binary_path: - dir_type = "gameroot" path_end = '/' + '/'.join(binary_path.split('/Game Root/', 1)[1].split('/')[:-1]) if '/Game Root/' in binary_path else "" bin_path_end = '/' + '/'.join(binary_path.split('/Game Root/', 1)[1].split('/')) if '/Game Root/' in binary_path else "" elif "STOCK GAME" in binary_path: - dir_type = "STOCKGAME" path_end = '/' + '/'.join(binary_path.split('/STOCK GAME/', 1)[1].split('/')[:-1]) if '/STOCK GAME/' in binary_path else "" bin_path_end = '/' + '/'.join(binary_path.split('/STOCK GAME/', 1)[1].split('/')) if '/STOCK GAME/' in binary_path else "" elif "Stock Folder" in binary_path: - dir_type = "stockfolder" path_end = '/' + '/'.join(binary_path.split('/Stock Folder/', 1)[1].split('/')[:-1]) if '/Stock Folder/' in binary_path else "" bin_path_end = '/' + '/'.join(binary_path.split('/Stock Folder/', 1)[1].split('/')) if '/Stock Folder/' in binary_path else "" elif "Skyrim Stock" in binary_path: - dir_type = "skyrimstock" path_end = '/' + '/'.join(binary_path.split('/Skyrim Stock/', 1)[1].split('/')[:-1]) if '/Skyrim Stock/' in binary_path else "" bin_path_end = '/' + '/'.join(binary_path.split('/Skyrim Stock/', 1)[1].split('/')) if '/Skyrim Stock/' in binary_path else "" elif "Stock Game Folder" in binary_path: - dir_type = "stockgamefolder" path_end = '/' + '/'.join(binary_path.split('/Stock Game Folder/', 1)[1].split('/')) if '/Stock Game Folder/' in binary_path else "" + bin_path_end = path_end elif "root/Skyrim Special Edition" in binary_path: - dir_type = "rootskyrimse" path_end = '/' + binary_path.split('root/Skyrim Special Edition', 1)[1] if 'root/Skyrim Special Edition' in binary_path else "" bin_path_end = '/' + binary_path.split('root/Skyrim Special Edition', 1)[1] if 'root/Skyrim Special Edition' in binary_path else "" - + else: + continue elif "steamapps" in binary_path: - # Steamapps found if basegame_sdcard: path_middle = WineUtils._strip_sdcard_path(steam_library) drive_letter = "D:" else: path_middle = steam_library.split('steamapps', 1)[0] if 'steamapps' in steam_library else steam_library - path_end = '/' + '/'.join(binary_path.split('/steamapps/', 1)[1].split('/')[:-1]) if '/steamapps/' in binary_path else "" bin_path_end = '/' + '/'.join(binary_path.split('/steamapps/', 1)[1].split('/')) if '/steamapps/' in binary_path else "" - else: logger.warning(f"No matching pattern found in the path: {binary_path}") continue - - # Combine paths full_bin_path = f"{bin_path_start}={drive_letter}{path_middle}{bin_path_end}" full_path = f"{path_start}={drive_letter}{path_middle}{path_end}" - - # Replace forward slashes with double backslashes new_path = full_path.replace('/', '\\\\') - - # Update the lines lines[i] = f"{full_bin_path}\n" - - # Find and update the workingDirectory line for j, working_line in enumerate(lines): if working_line.startswith(path_start): lines[j] = f"{new_path}\n" break - - # Write the updated content back to the file with open(modlist_ini, 'w') as f: f.writelines(lines) - logger.info("Executable paths updated successfully") return True except Exception as e: logger.error(f"Error updating executable paths: {e}") return False - - @staticmethod - def find_proton_binary(proton_version: str): - """ - Find the full path to the Proton binary given a version string (e.g., 'Proton 8.0', 'GE-Proton8-15'). - Searches standard Steam library locations. - Returns the path to the 'files/bin/wine' executable, or None if not found. - """ - # Clean up the version string for directory matching - version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')] - - # Get actual Steam library paths from libraryfolders.vdf (smart detection) - steam_common_paths = [] - compatibility_paths = [] - - try: - from .path_handler import PathHandler - # Get root Steam library paths (without /steamapps/common suffix) - root_steam_libs = PathHandler.get_all_steam_library_paths() - for lib_path in root_steam_libs: - lib = Path(lib_path) - if lib.exists(): - # Valve Proton: {library}/steamapps/common - common_path = lib / "steamapps/common" - if common_path.exists(): - steam_common_paths.append(common_path) - # GE-Proton: same Steam installation root + compatibilitytools.d - compatibility_paths.append(lib / "compatibilitytools.d") - except Exception as e: - logger.warning(f"Could not detect Steam libraries from libraryfolders.vdf: {e}") - - # Fallback locations if dynamic detection fails - if not steam_common_paths: - steam_common_paths = [ - Path.home() / ".steam/steam/steamapps/common", - Path.home() / ".local/share/Steam/steamapps/common", - Path.home() / ".steam/root/steamapps/common" - ] - - if not compatibility_paths: - compatibility_paths = [ - Path.home() / ".steam/steam/compatibilitytools.d", - Path.home() / ".local/share/Steam/compatibilitytools.d" - ] - - # Add standard compatibility tool locations (covers edge cases like Flatpak) - compatibility_paths.extend([ - Path.home() / ".steam/root/compatibilitytools.d", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d", - # Flatpak GE-Proton extension paths - 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" - ]) - # Special handling for Proton 9: try all possible directory names - if proton_version.strip().startswith("Proton 9"): - proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"] - for base_path in steam_common_paths: - for name in proton9_candidates: - candidate = base_path / name / "files/bin/wine" - if candidate.is_file(): - return str(candidate) - # Fallback: any Proton 9* directory - for subdir in base_path.glob("Proton 9*"): - wine_bin = subdir / "files/bin/wine" - if wine_bin.is_file(): - return str(wine_bin) - # General case: try version patterns in both steamapps and compatibilitytools.d - all_paths = steam_common_paths + compatibility_paths - for base_path in all_paths: - if not base_path.is_dir(): - continue - for pattern in version_patterns: - # Try direct match for Proton directory - proton_dir = base_path / pattern - wine_bin = proton_dir / "files/bin/wine" - if wine_bin.is_file(): - return str(wine_bin) - # Try glob for GE/other variants - for subdir in base_path.glob(f"*{pattern}*"): - wine_bin = subdir / "files/bin/wine" - if wine_bin.is_file(): - return str(wine_bin) - # Fallback: Try user's configured Proton version - try: - from .config_handler import ConfigHandler - config = ConfigHandler() - fallback_path = config.get_proton_path() - if fallback_path != 'auto': - fallback_wine_bin = Path(fallback_path) / "files/bin/wine" - if fallback_wine_bin.is_file(): - logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.") - return str(fallback_wine_bin) - except Exception: - pass - - # Final fallback: Try 'Proton - Experimental' if present - for base_path in steam_common_paths: - wine_bin = base_path / "Proton - Experimental" / "files/bin/wine" - if wine_bin.is_file(): - logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to 'Proton - Experimental'.") - return str(wine_bin) - return None - - @staticmethod - def get_proton_paths(appid: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: - """ - Get the Proton paths for a given AppID. - - Args: - appid (str): The Steam AppID to get paths for - - Returns: - tuple: (compatdata_path, proton_path, wine_bin) or (None, None, None) if not found - """ - logger.info(f"Getting Proton paths for AppID {appid}") - - # Find compatdata path - possible_compat_bases = [ - Path.home() / ".steam/steam/steamapps/compatdata", - Path.home() / ".local/share/Steam/steamapps/compatdata" - ] - - compatdata_path = None - for base_path in possible_compat_bases: - potential_compat_path = base_path / appid - if potential_compat_path.is_dir(): - compatdata_path = str(potential_compat_path) - logger.debug(f"Found compatdata directory: {compatdata_path}") - break - - if not compatdata_path: - logger.error(f"Could not find compatdata directory for AppID {appid}") - return None, None, None - - # Get Proton version - proton_version = WineUtils.get_proton_version(compatdata_path) - if proton_version == "Unknown": - logger.error(f"Could not determine Proton version for AppID {appid}") - return None, None, None - - # Find Proton binary - wine_bin = WineUtils.find_proton_binary(proton_version) - if not wine_bin: - logger.error(f"Could not find Proton binary for version {proton_version}") - return None, None, None - - # Get Proton path (parent of wine binary) - proton_path = str(Path(wine_bin).parent.parent) - logger.debug(f"Found Proton path: {proton_path}") - - return compatdata_path, proton_path, wine_bin - - @staticmethod - def get_steam_library_paths() -> List[Path]: - """ - Get all Steam library paths from libraryfolders.vdf (handles Flatpak, custom locations, etc.). - - Returns: - List of Path objects for Steam library directories - """ - steam_common_paths = [] - - try: - from .path_handler import PathHandler - # Use existing PathHandler that reads libraryfolders.vdf - library_paths = PathHandler.get_all_steam_library_paths() - logger.info(f"PathHandler found Steam libraries: {library_paths}") - - # Convert to steamapps/common paths for Proton scanning - for lib_path in library_paths: - common_path = lib_path / "steamapps" / "common" - if common_path.exists(): - steam_common_paths.append(common_path) - logger.debug(f"Added Steam library: {common_path}") - else: - logger.debug(f"Steam library path doesn't exist: {common_path}") - - except Exception as e: - logger.error(f"PathHandler failed to read libraryfolders.vdf: {e}") - - # Always add fallback paths in case PathHandler missed something - fallback_paths = [ - Path.home() / ".steam/steam/steamapps/common", - Path.home() / ".local/share/Steam/steamapps/common", - Path.home() / ".steam/root/steamapps/common" - ] - - for fallback_path in fallback_paths: - if fallback_path.exists() and fallback_path not in steam_common_paths: - steam_common_paths.append(fallback_path) - logger.debug(f"Added fallback Steam library: {fallback_path}") - - logger.info(f"Final Steam library paths for Proton scanning: {steam_common_paths}") - return steam_common_paths - - @staticmethod - def get_compatibility_tool_paths() -> List[Path]: - """ - Get all compatibility tool paths for GE-Proton and other custom Proton versions. - - Returns: - List of Path objects for compatibility tool directories - """ - compat_paths = [ - Path.home() / ".steam/steam/compatibilitytools.d", - Path.home() / ".local/share/Steam/compatibilitytools.d", - Path.home() / ".steam/root/compatibilitytools.d", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d", - # Flatpak GE-Proton extension paths - 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" - ] - - # Return only existing paths - return [path for path in compat_paths if path.exists()] - - @staticmethod - def _parse_compat_tool_name(proton_dir: Path) -> Optional[str]: - """Parse the Steam internal name from a compatibilitytool.vdf file. - The key under compat_tools is what Steam uses in config.vdf CompatToolMapping.""" - vdf_path = proton_dir / "compatibilitytool.vdf" - if not vdf_path.exists(): - return None - try: - with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - match = re.search(r'"compat_tools"\s*\{[^{]*"([^"]+)"\s*(?://[^\n]*)?\s*\{', content, re.DOTALL) - if match: - return match.group(1) - except Exception as e: - logger.warning(f"Failed to parse {vdf_path}: {e}") - return None - - @staticmethod - def _find_valve_proton_appid(proton_dir_name: str) -> Optional[str]: - """Find the Steam App ID for a Valve Proton by matching appmanifest installdir.""" - steam_libs = WineUtils.get_steam_library_paths() - for lib_path in steam_libs: - steamapps_dir = lib_path.parent - for manifest in steamapps_dir.glob("appmanifest_*.acf"): - try: - with open(manifest, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - installdir_match = re.search(r'"installdir"\s+"([^"]+)"', content) - appid_match = re.search(r'"appid"\s+"(\d+)"', content) - if installdir_match and appid_match: - if installdir_match.group(1) == proton_dir_name: - return appid_match.group(1) - except Exception: - continue - return None - - @staticmethod - def resolve_steam_compat_name(proton_path) -> Optional[str]: - """Resolve the correct Steam config.vdf internal name for a Proton installation. - - For third-party Protons (GE, CachyOS, etc.): parses compatibilitytool.vdf - For Valve Protons: maps via App ID from appmanifest files. - - Args: - proton_path: Path to the Proton directory (str or Path) - - Returns: - Internal name for config.vdf CompatToolMapping, or None if unresolvable - """ - proton_path = Path(proton_path) - - if not proton_path.is_dir(): - logger.warning(f"Proton path not found: {proton_path}") - return None - - # Third-party Proton: check for compatibilitytool.vdf - compat_name = WineUtils._parse_compat_tool_name(proton_path) - if compat_name: - logger.debug(f"Resolved compat name from vdf: {proton_path.name} -> {compat_name}") - return compat_name - - # Valve Proton: look up App ID from appmanifest, then map - dir_name = proton_path.name - appid = WineUtils._find_valve_proton_appid(dir_name) - if appid and appid in VALVE_PROTON_APPID_MAP: - name = VALVE_PROTON_APPID_MAP[appid] - logger.debug(f"Resolved Valve Proton: {dir_name} (AppID {appid}) -> {name}") - return name - - # Fallback for GE-Proton dirs without a vdf (shouldn't happen, but safe) - if dir_name.startswith('GE-Proton'): - return dir_name - - logger.warning(f"Could not resolve Steam compat name for: {proton_path}") - return None - - @staticmethod - def scan_thirdparty_proton_versions() -> List[Dict[str, any]]: - """Scan for non-GE third-party Proton versions in compatibilitytools.d directories. - Discovers CachyOS, TKG, and other community builds by parsing compatibilitytool.vdf. - - Returns: - List of dicts with version info, sorted by name - """ - logger.info("Scanning for third-party Proton versions...") - - found_versions = [] - seen_names = set() - compat_paths = WineUtils.get_compatibility_tool_paths() - - if not compat_paths: - return [] - - for compat_path in compat_paths: - try: - for proton_dir in compat_path.iterdir(): - if not proton_dir.is_dir(): - continue - - dir_name = proton_dir.name - - # Skip GE-Proton (handled by scan_ge_proton_versions) - if dir_name.startswith("GE-Proton"): - continue - - # Must have a wine binary to be a usable Proton - wine_bin = proton_dir / "files" / "bin" / "wine" - if not wine_bin.exists(): - continue - - # Must have a compatibilitytool.vdf (proves it's a Proton compat tool) - compat_name = WineUtils._parse_compat_tool_name(proton_dir) - if not compat_name: - continue - - # Skip non-Proton tools (e.g., LegacyRuntime) - vdf_path = proton_dir / "compatibilitytool.vdf" - try: - with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f: - vdf_content = f.read() - if '"from_oslist" "linux"' in vdf_content: - continue - except Exception: - pass - - # Skip Proton Hotfix - if 'hotfix' in compat_name.lower(): - continue - - if compat_name in seen_names: - continue - seen_names.add(compat_name) - - found_versions.append({ - 'name': dir_name, - 'path': proton_dir, - 'wine_bin': wine_bin, - 'priority': 175, - 'type': 'ThirdParty-Proton', - 'steam_compat_name': compat_name, - }) - logger.debug(f"Found third-party Proton: {dir_name} (compat name: {compat_name})") - - except Exception as e: - logger.warning(f"Error scanning {compat_path}: {e}") - - logger.info(f"Found {len(found_versions)} third-party Proton version(s)") - return found_versions - - @staticmethod - def scan_ge_proton_versions() -> List[Dict[str, any]]: - """ - Scan for available GE-Proton versions in compatibilitytools.d directories. - - Returns: - List of dicts with version info, sorted by priority (newest first) - """ - logger.info("Scanning for available GE-Proton versions...") - - found_versions = [] - compat_paths = WineUtils.get_compatibility_tool_paths() - - if not compat_paths: - logger.warning("No compatibility tool paths found") - return [] - - for compat_path in compat_paths: - logger.debug(f"Scanning compatibility tools: {compat_path}") - - try: - # Look for GE-Proton directories - for proton_dir in compat_path.iterdir(): - if not proton_dir.is_dir(): - continue - - dir_name = proton_dir.name - if not dir_name.startswith("GE-Proton"): - continue - - # Check for wine binary - wine_bin = proton_dir / "files" / "bin" / "wine" - if not wine_bin.exists() or not wine_bin.is_file(): - logger.debug(f"Skipping {dir_name} - no wine binary found") - continue - - # Parse version from directory name (e.g., "GE-Proton10-16") - version_match = re.match(r'GE-Proton(\d+)-(\d+)', dir_name) - if version_match: - major_ver = int(version_match.group(1)) - minor_ver = int(version_match.group(2)) - - # Calculate priority: GE-Proton gets highest priority - # Priority format: 200 (base) + major*10 + minor (e.g., 200 + 100 + 16 = 316) - priority = 200 + (major_ver * 10) + minor_ver - - compat_name = WineUtils._parse_compat_tool_name(proton_dir) or dir_name - found_versions.append({ - 'name': dir_name, - 'path': proton_dir, - 'wine_bin': wine_bin, - 'priority': priority, - 'major_version': major_ver, - 'minor_version': minor_ver, - 'type': 'GE-Proton', - 'steam_compat_name': compat_name, - }) - logger.debug(f"Found {dir_name} at {proton_dir} (priority: {priority})") - else: - logger.debug(f"Skipping {dir_name} - unknown GE-Proton version format") - - except Exception as e: - logger.warning(f"Error scanning {compat_path}: {e}") - - # Sort by priority (highest first, so newest GE-Proton versions come first) - found_versions.sort(key=lambda x: x['priority'], reverse=True) - - logger.info(f"Found {len(found_versions)} GE-Proton version(s)") - return found_versions - - @staticmethod - def scan_valve_proton_versions() -> List[Dict[str, any]]: - """ - Scan for available Valve Proton versions with fallback priority. - - Returns: - List of dicts with version info, sorted by priority (best first) - """ - logger.info("Scanning for available Valve Proton versions...") - - found_versions = [] - steam_libs = WineUtils.get_steam_library_paths() - - if not steam_libs: - logger.warning("No Steam library paths found") - return [] - - # Priority order for Valve Proton versions - # Note: GE-Proton uses 200+ range, so Valve Proton gets 100+ range - preferred_versions = [ - ("Proton - Experimental", 150), # Higher priority than regular Valve Proton - ("Proton 10.0", 140), - ("Proton 9.0", 130), - ("Proton 9.0 (Beta)", 125) - ] - - for steam_path in steam_libs: - logger.debug(f"Scanning Steam library: {steam_path}") - - for version_name, priority in preferred_versions: - proton_path = steam_path / version_name - wine_bin = proton_path / "files" / "bin" / "wine" - - if wine_bin.exists() and wine_bin.is_file(): - compat_name = WineUtils.resolve_steam_compat_name(proton_path) - found_versions.append({ - 'name': version_name, - 'path': proton_path, - 'wine_bin': wine_bin, - 'priority': priority, - 'type': 'Valve-Proton', - 'steam_compat_name': compat_name, - }) - logger.debug(f"Found {version_name} at {proton_path}") - - # Sort by priority (highest first) - found_versions.sort(key=lambda x: x['priority'], reverse=True) - - # Remove duplicates while preserving order - unique_versions = [] - seen_names = set() - for version in found_versions: - if version['name'] not in seen_names: - unique_versions.append(version) - seen_names.add(version['name']) - - logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)") - return unique_versions - - @staticmethod - def scan_all_proton_versions() -> List[Dict[str, any]]: - """ - Scan for all available Proton versions (GE-Proton + Valve Proton) with unified priority. - - Priority Chain (highest to lowest): - 1. GE-Proton10-16+ (priority 316+) - 2. GE-Proton10-* (priority 200+) - 3. Proton - Experimental (priority 150) - 4. Proton 10.0 (priority 140) - 5. Proton 9.0 (priority 130) - 6. Proton 9.0 (Beta) (priority 125) - - Returns: - List of dicts with version info, sorted by priority (best first) - """ - logger.info("Scanning for all available Proton versions...") - - all_versions = [] - - # Scan GE-Proton versions (highest priority) - ge_versions = WineUtils.scan_ge_proton_versions() - all_versions.extend(ge_versions) - - # Scan third-party Proton versions (CachyOS, TKG, etc.) - thirdparty_versions = WineUtils.scan_thirdparty_proton_versions() - all_versions.extend(thirdparty_versions) - - # Scan Valve Proton versions - valve_versions = WineUtils.scan_valve_proton_versions() - all_versions.extend(valve_versions) - - # Sort by priority (highest first) - all_versions.sort(key=lambda x: x['priority'], reverse=True) - - # Remove duplicates while preserving order - unique_versions = [] - seen_names = set() - for version in all_versions: - if version['name'] not in seen_names: - unique_versions.append(version) - seen_names.add(version['name']) - - if unique_versions: - logger.debug(f"Found {len(unique_versions)} total Proton version(s)") - logger.debug(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})") - else: - logger.warning("No Proton versions found") - - return unique_versions - - @staticmethod - def select_best_proton() -> Optional[Dict[str, any]]: - """ - Select the best available Proton version (GE-Proton or Valve Proton) using unified precedence. - Excludes third-party builds (CachyOS, etc.) which may have compatibility issues. - - Returns: - Dict with version info for the best Proton, or None if none found - """ - available_versions = WineUtils.scan_all_proton_versions() - - if not available_versions: - logger.warning("No compatible Proton versions found") - return None - - # Filter out third-party Protons - they may have compatibility issues with component installation - # Only include GE-Proton and Valve-Proton types - 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 - - # Return the highest priority version (first in sorted list) - best_version = compatible_versions[0] - logger.info(f"Selected best Proton version: {best_version['name']} ({best_version['type']})") - return best_version - - @staticmethod - def select_best_valve_proton() -> Optional[Dict[str, any]]: - """ - Select the best available Valve Proton version using fallback precedence. - Note: This method is kept for backward compatibility. Consider using select_best_proton() instead. - - Returns: - Dict with version info for the best Proton, or None if none found - """ - available_versions = WineUtils.scan_valve_proton_versions() - - if not available_versions: - logger.warning("No compatible Valve Proton versions found") - return None - - # Return the highest priority version (first in sorted list) - best_version = available_versions[0] - logger.info(f"Selected Valve Proton version: {best_version['name']}") - return best_version - - @staticmethod - def check_proton_requirements() -> Tuple[bool, str, Optional[Dict[str, any]]]: - """ - Check if compatible Proton version is available for workflows. - - Returns: - tuple: (requirements_met, status_message, proton_info) - - requirements_met: True if compatible Proton found - - status_message: Human-readable status for display to user - - proton_info: Dict with Proton details if found, None otherwise - """ - logger.info("Checking Proton requirements for workflow...") - - # Scan for available Proton versions (includes GE-Proton + Valve Proton) - best_proton = WineUtils.select_best_proton() - - if best_proton: - # Compatible Proton found - proton_type = best_proton.get('type', 'Unknown') - status_msg = f"[OK] Using {best_proton['name']} ({proton_type}) for this workflow" - logger.info(f"Proton requirements satisfied: {best_proton['name']} ({proton_type})") - return True, status_msg, best_proton - else: - # No compatible Proton found - status_msg = "[FAIL] No compatible Proton version found (GE-Proton 10+, Proton 9+, 10, or Experimental required)" - logger.warning("Proton requirements not met - no compatible version found") - return False, status_msg, None \ No newline at end of file diff --git a/jackify/backend/handlers/wine_utils_config.py b/jackify/backend/handlers/wine_utils_config.py new file mode 100644 index 0000000..d839fc2 --- /dev/null +++ b/jackify/backend/handlers/wine_utils_config.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Post-install and modlist config mixin for WineUtils. +Extracted from wine_utils for file-size and domain separation. +""" + +import os +import re +import subprocess +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class WineUtilsConfigMixin: + """Mixin providing post-install tasks and modlist-specific configuration.""" + + @staticmethod + def create_dxvk_file(modlist_dir: str, modlist_sdcard: bool, steam_library: str, + basegame_sdcard: bool, game_var_full: str) -> bool: + """Create DXVK file in the modlist directory pointing to the game directory.""" + try: + game_dir = os.path.join(steam_library, game_var_full) + dxvk_file = os.path.join(modlist_dir, "DXVK") + with open(dxvk_file, 'w') as f: + f.write(game_dir) + logger.debug(f"Created DXVK file at {dxvk_file} pointing to {game_dir}") + return True + except Exception as e: + logger.error(f"Error creating DXVK file: {e}") + return False + + @staticmethod + def small_additional_tasks(modlist_dir: str, compat_data_path: Optional[str]) -> bool: + """Perform small additional tasks (delete unsupported plugins, download Bethini font).""" + try: + file_to_delete = os.path.join(modlist_dir, "plugins/FixGameRegKey.py") + if os.path.exists(file_to_delete): + os.remove(file_to_delete) + logger.debug(f"File deleted: {file_to_delete}") + if compat_data_path and os.path.isdir(compat_data_path): + font_path = os.path.join(compat_data_path, "pfx/drive_c/windows/Fonts/seguisym.ttf") + font_dir = os.path.dirname(font_path) + os.makedirs(font_dir, exist_ok=True) + font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf" + subprocess.run( + f"wget {font_url} -q -nc -O \"{font_path}\"", + shell=True, + check=True + ) + logger.debug(f"Downloaded font to: {font_path}") + return True + except Exception as e: + logger.error(f"Error performing additional tasks: {e}") + return False + + @staticmethod + def modlist_specific_steps(modlist: str, appid: str) -> bool: + """Perform modlist-specific configuration steps. Returns True on success.""" + try: + modlist_configs = { + "wildlander": ["dotnet48", "dotnet472", "vcrun2019"], + "septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"], + "masterstroke": ["dotnet48", "dotnet472"], + "diablo": ["dotnet48", "dotnet472"], + "living_skyrim": ["dotnet48", "dotnet472", "dotnet462"], + "nolvus": ["dotnet8"] + } + modlist_lower = modlist.lower().replace(" ", "") + if "wildlander" in modlist_lower: + logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!") + return True + for pattern, components in modlist_configs.items(): + if re.search(pattern.replace("|", "|.*"), modlist_lower): + logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!") + for component in components: + if component == "dotnet8": + logger.info("Downloading .NET 8 Runtime") + pass + else: + logger.info(f"Installing {component}...") + pass + return True + logger.debug(f"No specific steps needed for {modlist}") + return True + except Exception as e: + logger.error(f"Error performing modlist-specific steps: {e}") + return False + + @staticmethod + def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool: + """Set up Fallout New Vegas launch options. Returns True on success.""" + if game_var != "Fallout New Vegas": + return True + try: + appid_to_check = "22380" + for path in [ + os.path.expanduser("~/.local/share/Steam/steamapps/compatdata"), + os.path.expanduser("~/.steam/steam/steamapps/compatdata"), + os.path.expanduser("~/.steam/root/steamapps/compatdata") + ]: + compat_path = os.path.join(path, appid_to_check) + if os.path.exists(compat_path): + logger.warning( + f"\nFor {modlist}, please add the following line to the Launch Options " + f"in Steam for your '{modlist}' entry:" + ) + logger.info(f"\nSTEAM_COMPAT_DATA_PATH=\"{compat_path}\" %command%") + logger.warning("\nThis is essential for the modlist to load correctly.") + return True + logger.error("Could not determine the compatdata path for Fallout New Vegas") + return False + except Exception as e: + logger.error(f"Error setting FNV launch options: {e}") + return False diff --git a/jackify/backend/handlers/wine_utils_proton.py b/jackify/backend/handlers/wine_utils_proton.py new file mode 100644 index 0000000..e177f84 --- /dev/null +++ b/jackify/backend/handlers/wine_utils_proton.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Proton scanning and selection mixin for WineUtils. +Extracted from wine_utils for file-size and domain separation. +""" + +import os +import re +import logging +from pathlib import Path +from typing import Optional, Tuple, List, Dict, Any + +logger = logging.getLogger(__name__) + +VALVE_PROTON_APPID_MAP = { + '2805730': 'proton_9', + '3658110': 'proton_10', + '1493710': 'proton_experimental', + '2180100': 'proton_hotfix', + '1887720': 'proton_8', +} + + +class WineUtilsProtonMixin: + """Mixin providing Proton scanning, selection, and path resolution.""" + + @staticmethod + def get_proton_version(compat_data_path: str) -> str: + """ + Detect the Proton version used by a Steam game/shortcut. + + Args: + compat_data_path: Path to the compatibility data directory. + + Returns: + Detected Proton version or 'Unknown' if not found. + """ + logger.info("Detecting Proton version...") + if not os.path.isdir(compat_data_path): + logger.warning(f"Compatdata directory not found at '{compat_data_path}'") + return "Unknown" + system_reg_path = os.path.join(compat_data_path, "pfx", "system.reg") + if os.path.isfile(system_reg_path): + try: + with open(system_reg_path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + match = re.search(r'"SteamClientProtonVersion"="([^"]+)"', content) + if match: + version = match.group(1).strip() + proton_ver = version if "GE" in version else f"Proton {version}" + logger.debug(f"Detected Proton version from registry: {proton_ver}") + return proton_ver + except Exception as e: + logger.debug(f"Error reading system.reg: {e}") + config_info_path = os.path.join(compat_data_path, "config_info") + if os.path.isfile(config_info_path): + try: + with open(config_info_path, "r") as f: + config_ver = f.readline().strip() + if config_ver: + proton_ver = config_ver if "GE" in config_ver else f"Proton {config_ver}" + logger.debug(f"Detected Proton version from config_info: {proton_ver}") + return proton_ver + except Exception as e: + logger.debug(f"Error reading config_info: {e}") + logger.warning("Could not detect Proton version") + return "Unknown" + + @staticmethod + def find_proton_binary(proton_version: str) -> Optional[str]: + """ + Find the full path to the Proton binary given a version string. + Returns the path to 'files/bin/wine', or None if not found. + """ + version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')] + steam_common_paths = [] + compatibility_paths = [] + try: + from .path_handler import PathHandler + root_steam_libs = PathHandler.get_all_steam_library_paths() + for lib_path in root_steam_libs: + lib = Path(lib_path) + if lib.exists(): + common_path = lib / "steamapps/common" + if common_path.exists(): + steam_common_paths.append(common_path) + compatibility_paths.append(lib / "compatibilitytools.d") + except Exception as e: + logger.warning(f"Could not detect Steam libraries from libraryfolders.vdf: {e}") + if not steam_common_paths: + steam_common_paths = [ + Path.home() / ".steam/steam/steamapps/common", + Path.home() / ".local/share/Steam/steamapps/common", + Path.home() / ".steam/root/steamapps/common" + ] + if not compatibility_paths: + compatibility_paths = [ + Path.home() / ".steam/steam/compatibilitytools.d", + Path.home() / ".local/share/Steam/compatibilitytools.d" + ] + compatibility_paths.extend([ + 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" + ]) + if proton_version.strip().startswith("Proton 9"): + proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"] + for base_path in steam_common_paths: + for name in proton9_candidates: + candidate = base_path / name / "files/bin/wine" + if candidate.is_file(): + return str(candidate) + for subdir in base_path.glob("Proton 9*"): + wine_bin = subdir / "files/bin/wine" + if wine_bin.is_file(): + return str(wine_bin) + all_paths = steam_common_paths + compatibility_paths + for base_path in all_paths: + if not base_path.is_dir(): + continue + for pattern in version_patterns: + proton_dir = base_path / pattern + wine_bin = proton_dir / "files/bin/wine" + if wine_bin.is_file(): + return str(wine_bin) + for subdir in base_path.glob(f"*{pattern}*"): + wine_bin = subdir / "files/bin/wine" + if wine_bin.is_file(): + return str(wine_bin) + try: + from .config_handler import ConfigHandler + config = ConfigHandler() + fallback_path = config.get_proton_path() + if fallback_path != 'auto': + fallback_wine_bin = Path(fallback_path) / "files/bin/wine" + if fallback_wine_bin.is_file(): + logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.") + return str(fallback_wine_bin) + except Exception: + pass + for base_path in steam_common_paths: + wine_bin = base_path / "Proton - Experimental" / "files/bin/wine" + if wine_bin.is_file(): + logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to 'Proton - Experimental'.") + return str(wine_bin) + return None + + @staticmethod + def get_proton_paths(appid: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Get the Proton paths for a given AppID. + Returns (compatdata_path, proton_path, wine_bin) or (None, None, None) if not found. + """ + logger.info(f"Getting Proton paths for AppID {appid}") + possible_compat_bases = [ + Path.home() / ".steam/steam/steamapps/compatdata", + Path.home() / ".local/share/Steam/steamapps/compatdata" + ] + compatdata_path = None + for base_path in possible_compat_bases: + potential_compat_path = base_path / appid + if potential_compat_path.is_dir(): + compatdata_path = str(potential_compat_path) + logger.debug(f"Found compatdata directory: {compatdata_path}") + break + if not compatdata_path: + logger.error(f"Could not find compatdata directory for AppID {appid}") + return None, None, None + proton_version = WineUtilsProtonMixin.get_proton_version(compatdata_path) + if proton_version == "Unknown": + logger.error(f"Could not determine Proton version for AppID {appid}") + return None, None, None + wine_bin = WineUtilsProtonMixin.find_proton_binary(proton_version) + if not wine_bin: + logger.error(f"Could not find Proton binary for version {proton_version}") + return None, None, None + proton_path = str(Path(wine_bin).parent.parent) + logger.debug(f"Found Proton path: {proton_path}") + return compatdata_path, proton_path, wine_bin + + @staticmethod + def get_steam_library_paths() -> List[Path]: + """Get all Steam library paths from libraryfolders.vdf.""" + steam_common_paths = [] + try: + from .path_handler import PathHandler + library_paths = PathHandler.get_all_steam_library_paths() + logger.info(f"PathHandler found Steam libraries: {library_paths}") + for lib_path in library_paths: + common_path = lib_path / "steamapps" / "common" + if common_path.exists(): + steam_common_paths.append(common_path) + logger.debug(f"Added Steam library: {common_path}") + else: + logger.debug(f"Steam library path doesn't exist: {common_path}") + except Exception as e: + logger.error(f"PathHandler failed to read libraryfolders.vdf: {e}") + fallback_paths = [ + Path.home() / ".steam/steam/steamapps/common", + Path.home() / ".local/share/Steam/steamapps/common", + Path.home() / ".steam/root/steamapps/common" + ] + for fallback_path in fallback_paths: + if fallback_path.exists() and fallback_path not in steam_common_paths: + steam_common_paths.append(fallback_path) + logger.debug(f"Added fallback Steam library: {fallback_path}") + logger.info(f"Final Steam library paths for Proton scanning: {steam_common_paths}") + return steam_common_paths + + @staticmethod + def get_compatibility_tool_paths() -> List[Path]: + """Get all compatibility tool paths for GE-Proton and other custom Proton versions.""" + compat_paths = [ + Path.home() / ".steam/steam/compatibilitytools.d", + Path.home() / ".local/share/Steam/compatibilitytools.d", + 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" + ] + return [path for path in compat_paths if path.exists()] + + @staticmethod + def _parse_compat_tool_name(proton_dir: Path) -> Optional[str]: + """Parse the Steam internal name from a compatibilitytool.vdf file.""" + vdf_path = proton_dir / "compatibilitytool.vdf" + if not vdf_path.exists(): + return None + try: + with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + match = re.search(r'"compat_tools"\s*\{[^{]*"([^"]+)"\s*(?://[^\n]*)?\s*\{', content, re.DOTALL) + if match: + return match.group(1) + except Exception as e: + logger.warning(f"Failed to parse {vdf_path}: {e}") + return None + + @staticmethod + def _find_valve_proton_appid(proton_dir_name: str) -> Optional[str]: + """Find the Steam App ID for a Valve Proton by matching appmanifest installdir.""" + steam_libs = WineUtilsProtonMixin.get_steam_library_paths() + for lib_path in steam_libs: + steamapps_dir = lib_path.parent + for manifest in steamapps_dir.glob("appmanifest_*.acf"): + try: + with open(manifest, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + installdir_match = re.search(r'"installdir"\s+"([^"]+)"', content) + appid_match = re.search(r'"appid"\s+"(\d+)"', content) + if installdir_match and appid_match and installdir_match.group(1) == proton_dir_name: + return appid_match.group(1) + except Exception: + continue + return None + + @staticmethod + def resolve_steam_compat_name(proton_path: Any) -> Optional[str]: + """ + Resolve the correct Steam config.vdf internal name for a Proton installation. + Returns internal name for CompatToolMapping, or None if unresolvable. + """ + proton_path = Path(proton_path) + if not proton_path.is_dir(): + logger.warning(f"Proton path not found: {proton_path}") + return None + compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_path) + if compat_name: + logger.debug(f"Resolved compat name from vdf: {proton_path.name} -> {compat_name}") + return compat_name + dir_name = proton_path.name + appid = WineUtilsProtonMixin._find_valve_proton_appid(dir_name) + if appid and appid in VALVE_PROTON_APPID_MAP: + name = VALVE_PROTON_APPID_MAP[appid] + logger.debug(f"Resolved Valve Proton: {dir_name} (AppID {appid}) -> {name}") + return name + if dir_name.startswith('GE-Proton'): + return dir_name + logger.warning(f"Could not resolve Steam compat name for: {proton_path}") + return None + + @staticmethod + def scan_thirdparty_proton_versions() -> List[Dict[str, Any]]: + """Scan for non-GE third-party Proton versions in compatibilitytools.d directories.""" + logger.info("Scanning for third-party Proton versions...") + found_versions = [] + seen_names = set() + compat_paths = WineUtilsProtonMixin.get_compatibility_tool_paths() + if not compat_paths: + return [] + for compat_path in compat_paths: + try: + for proton_dir in compat_path.iterdir(): + if not proton_dir.is_dir(): + continue + dir_name = proton_dir.name + if dir_name.startswith("GE-Proton"): + continue + wine_bin = proton_dir / "files" / "bin" / "wine" + if not wine_bin.exists(): + continue + compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir) + if not compat_name: + continue + vdf_path = proton_dir / "compatibilitytool.vdf" + try: + with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f: + vdf_content = f.read() + if '"from_oslist" "linux"' in vdf_content: + continue + except Exception: + pass + if 'hotfix' in compat_name.lower(): + continue + if compat_name in seen_names: + continue + seen_names.add(compat_name) + found_versions.append({ + 'name': dir_name, + 'path': proton_dir, + 'wine_bin': wine_bin, + 'priority': 175, + 'type': 'ThirdParty-Proton', + 'steam_compat_name': compat_name, + }) + logger.debug(f"Found third-party Proton: {dir_name} (compat name: {compat_name})") + except Exception as e: + logger.warning(f"Error scanning {compat_path}: {e}") + logger.info(f"Found {len(found_versions)} third-party Proton version(s)") + return found_versions + + @staticmethod + def scan_ge_proton_versions() -> List[Dict[str, Any]]: + """Scan for available GE-Proton versions in compatibilitytools.d directories.""" + logger.info("Scanning for available GE-Proton versions...") + found_versions = [] + compat_paths = WineUtilsProtonMixin.get_compatibility_tool_paths() + if not compat_paths: + logger.warning("No compatibility tool paths found") + return [] + for compat_path in compat_paths: + logger.debug(f"Scanning compatibility tools: {compat_path}") + try: + for proton_dir in compat_path.iterdir(): + if not proton_dir.is_dir(): + continue + dir_name = proton_dir.name + if not dir_name.startswith("GE-Proton"): + continue + wine_bin = proton_dir / "files" / "bin" / "wine" + if not wine_bin.exists() or not wine_bin.is_file(): + logger.debug(f"Skipping {dir_name} - no wine binary found") + continue + version_match = re.match(r'GE-Proton(\d+)-(\d+)', dir_name) + if version_match: + major_ver = int(version_match.group(1)) + minor_ver = int(version_match.group(2)) + priority = 200 + (major_ver * 10) + minor_ver + compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir) or dir_name + found_versions.append({ + 'name': dir_name, + 'path': proton_dir, + 'wine_bin': wine_bin, + 'priority': priority, + 'major_version': major_ver, + 'minor_version': minor_ver, + 'type': 'GE-Proton', + 'steam_compat_name': compat_name, + }) + logger.debug(f"Found {dir_name} at {proton_dir} (priority: {priority})") + else: + 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) + logger.info(f"Found {len(found_versions)} GE-Proton version(s)") + return found_versions + + @staticmethod + def scan_valve_proton_versions() -> List[Dict[str, Any]]: + """Scan for available Valve Proton versions with fallback priority.""" + logger.info("Scanning for available Valve Proton versions...") + found_versions = [] + steam_libs = WineUtilsProtonMixin.get_steam_library_paths() + if not steam_libs: + logger.warning("No Steam library paths found") + return [] + preferred_versions = [ + ("Proton - Experimental", 150), + ("Proton 10.0", 140), + ("Proton 9.0", 130), + ("Proton 9.0 (Beta)", 125) + ] + for steam_path in steam_libs: + logger.debug(f"Scanning Steam library: {steam_path}") + for version_name, priority in preferred_versions: + proton_path = steam_path / version_name + wine_bin = proton_path / "files" / "bin" / "wine" + if wine_bin.exists() and wine_bin.is_file(): + compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(proton_path) + found_versions.append({ + 'name': version_name, + 'path': proton_path, + 'wine_bin': wine_bin, + 'priority': priority, + 'type': 'Valve-Proton', + 'steam_compat_name': compat_name, + }) + logger.debug(f"Found {version_name} at {proton_path}") + found_versions.sort(key=lambda x: x['priority'], reverse=True) + unique_versions = [] + seen_names = set() + for version in found_versions: + if version['name'] not in seen_names: + unique_versions.append(version) + seen_names.add(version['name']) + logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)") + return unique_versions + + @staticmethod + def scan_all_proton_versions() -> List[Dict[str, Any]]: + """Scan for all available Proton versions (GE + third-party + Valve) with unified priority.""" + logger.info("Scanning for all available Proton versions...") + all_versions = [] + 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) + unique_versions = [] + seen_names = set() + for version in all_versions: + if version['name'] not in seen_names: + unique_versions.append(version) + seen_names.add(version['name']) + if unique_versions: + logger.debug(f"Found {len(unique_versions)} total Proton version(s)") + logger.debug(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})") + else: + logger.warning("No Proton versions found") + return unique_versions + + @staticmethod + def select_best_proton() -> Optional[Dict[str, Any]]: + """Select the best available Proton (GE or Valve). Excludes third-party builds.""" + available_versions = WineUtilsProtonMixin.scan_all_proton_versions() + if not available_versions: + logger.warning("No compatible 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']})") + return best_version + + @staticmethod + def select_best_valve_proton() -> Optional[Dict[str, Any]]: + """Select the best available Valve Proton. Kept for backward compatibility.""" + available_versions = WineUtilsProtonMixin.scan_valve_proton_versions() + if not available_versions: + logger.warning("No compatible Valve Proton versions found") + return None + best_version = available_versions[0] + logger.info(f"Selected Valve Proton version: {best_version['name']}") + return best_version + + @staticmethod + def check_proton_requirements() -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Check if a compatible Proton version is available for workflows.""" + logger.info("Checking Proton requirements for workflow...") + best_proton = WineUtilsProtonMixin.select_best_proton() + if best_proton: + proton_type = best_proton.get('type', 'Unknown') + status_msg = f"[OK] Using {best_proton['name']} ({proton_type}) for this workflow" + logger.info(f"Proton requirements satisfied: {best_proton['name']} ({proton_type})") + return True, status_msg, best_proton + status_msg = "[FAIL] No compatible Proton version found (GE-Proton 10+, Proton 9+, 10, or Experimental required)" + logger.warning("Proton requirements not met - no compatible version found") + return False, status_msg, None diff --git a/jackify/backend/handlers/wine_wrapper.py b/jackify/backend/handlers/wine_wrapper.py new file mode 100644 index 0000000..90987ce --- /dev/null +++ b/jackify/backend/handlers/wine_wrapper.py @@ -0,0 +1,140 @@ +""" +Wine wrapper script generation for winetricks. +Creates wrapper scripts similar to protontricks to properly set up +LD_LIBRARY_PATH and other environment variables before invoking wine/wineserver. +""" + +import os +import stat +import shutil +import logging +from pathlib import Path +from typing import Optional + +from jackify.shared.paths import get_jackify_data_dir + +logger = logging.getLogger(__name__) + +WINE_WRAPPER_TEMPLATE = '''#!/bin/bash +# Wine wrapper script generated by Jackify +# Ensures proper LD_LIBRARY_PATH setup when calling Proton wine binaries + +PROTON_DIST_PATH="@@PROTON_DIST_PATH@@" +BINARY_NAME="@@BINARY_NAME@@" + +# Set up LD_LIBRARY_PATH with Proton libraries first +PROTON_LIB_PATH="${PROTON_DIST_PATH}/lib64:${PROTON_DIST_PATH}/lib" +if [[ -n "$LD_LIBRARY_PATH" ]]; then + export LD_LIBRARY_PATH="${PROTON_LIB_PATH}:${LD_LIBRARY_PATH}" +else + export LD_LIBRARY_PATH="${PROTON_LIB_PATH}" +fi + +# Enable fsync/esync by default if not already set +if [[ -z "$WINEFSYNC" && -z "$PROTON_NO_FSYNC" ]]; then + export WINEFSYNC=1 +fi +if [[ -z "$WINEESYNC" && -z "$PROTON_NO_ESYNC" ]]; then + export WINEESYNC=1 +fi + +# Execute the actual Proton binary +exec "${PROTON_DIST_PATH}/bin/${BINARY_NAME}" "$@" +''' + + +class WineWrapperManager: + """Manages creation of wine/wineserver wrapper scripts for winetricks.""" + + def __init__(self): + self.logger = logging.getLogger(__name__) + self._wrapper_dir: Optional[Path] = None + + def get_wrapper_dir(self, proton_path: str) -> Path: + """Get or create the wrapper directory for a specific Proton version.""" + proton_name = Path(proton_path).name.replace(" ", "_") + cache_dir = get_jackify_data_dir() / "wine_wrappers" / proton_name + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + def create_wrappers(self, proton_dist_path: str) -> Optional[Path]: + """ + Create wine and wineserver wrapper scripts for the given Proton dist path. + + Args: + proton_dist_path: Path to Proton's dist directory (containing bin/, lib/, lib64/) + + Returns: + Path to the wrapper directory, or None if creation failed + """ + try: + proton_dist = Path(proton_dist_path) + if not proton_dist.exists(): + self.logger.error(f"Proton dist path does not exist: {proton_dist_path}") + return None + + # Verify required binaries exist + wine_bin = proton_dist / "bin" / "wine" + wineserver_bin = proton_dist / "bin" / "wineserver" + + if not wine_bin.exists(): + self.logger.error(f"Wine binary not found: {wine_bin}") + return None + if not wineserver_bin.exists(): + self.logger.error(f"Wineserver binary not found: {wineserver_bin}") + return None + + # Get wrapper directory based on Proton install path (parent of dist) + proton_install_path = proton_dist.parent + wrapper_dir = self.get_wrapper_dir(str(proton_install_path)) + + # Clean and recreate to ensure fresh scripts + if wrapper_dir.exists(): + shutil.rmtree(str(wrapper_dir)) + wrapper_dir.mkdir(parents=True, exist_ok=True) + + # Create wrapper for each binary in Proton's bin directory + binaries_to_wrap = ["wine", "wine64", "wineserver", "wineboot", "winecfg"] + created_wrappers = [] + + for binary_name in binaries_to_wrap: + binary_path = proton_dist / "bin" / binary_name + if not binary_path.exists(): + continue + + wrapper_path = wrapper_dir / binary_name + wrapper_content = WINE_WRAPPER_TEMPLATE.replace( + "@@PROTON_DIST_PATH@@", str(proton_dist) + ).replace( + "@@BINARY_NAME@@", binary_name + ) + + wrapper_path.write_text(wrapper_content) + wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC) + created_wrappers.append(binary_name) + + self.logger.info(f"Created wine wrappers in {wrapper_dir}: {', '.join(created_wrappers)}") + self._wrapper_dir = wrapper_dir + return wrapper_dir + + except Exception as e: + self.logger.error(f"Failed to create wine wrappers: {e}", exc_info=True) + return None + + def get_wine_wrapper_path(self, proton_dist_path: str) -> Optional[str]: + """Get path to the wine wrapper script.""" + wrapper_dir = self.create_wrappers(proton_dist_path) + if wrapper_dir: + wine_wrapper = wrapper_dir / "wine" + if wine_wrapper.exists(): + return str(wine_wrapper) + return None + + def get_wineserver_wrapper_path(self, proton_dist_path: str) -> Optional[str]: + """Get path to the wineserver wrapper script.""" + wrapper_dir = self.create_wrappers(proton_dist_path) + if wrapper_dir: + wineserver_wrapper = wrapper_dir / "wineserver" + if wineserver_wrapper.exists(): + return str(wineserver_wrapper) + return None diff --git a/jackify/backend/handlers/winetricks_discovery.py b/jackify/backend/handlers/winetricks_discovery.py new file mode 100644 index 0000000..680dbb2 --- /dev/null +++ b/jackify/backend/handlers/winetricks_discovery.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Winetricks discovery mixin: bundled path and tool availability. +Extracted from winetricks_handler for file-size and domain separation. +""" + +import os +import subprocess +import logging +from pathlib import Path +from typing import Optional + + +class WinetricksDiscoveryMixin: + """Mixin providing winetricks path discovery and availability checks.""" + + def _get_bundled_winetricks_path(self) -> Optional[str]: + """Get the path to the bundled winetricks script (AppImage and dev).""" + possible_paths = [] + if os.environ.get('APPDIR'): + appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'winetricks') + possible_paths.append(appdir_path) + module_dir = Path(__file__).parent.parent.parent + dev_path = module_dir / 'tools' / 'winetricks' + possible_paths.append(str(dev_path)) + for path in possible_paths: + if os.path.exists(path) and os.access(path, os.X_OK): + self.logger.debug(f"Found bundled winetricks at: {path}") + return str(path) + self.logger.error(f"Bundled winetricks not found. Tried paths: {possible_paths}") + return None + + def _get_bundled_tool(self, tool_name: str, fallback_to_system: bool = True) -> Optional[str]: + """Get path to a bundled tool (e.g. cabextract, wget). Fall back to system PATH if requested.""" + possible_paths = [] + if os.environ.get('APPDIR'): + appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', tool_name) + possible_paths.append(appdir_path) + module_dir = Path(__file__).parent.parent.parent + dev_path = module_dir / 'tools' / tool_name + possible_paths.append(str(dev_path)) + for path in possible_paths: + if os.path.exists(path) and os.access(path, os.X_OK): + self.logger.debug(f"Found bundled {tool_name} at: {path}") + return str(path) + if fallback_to_system: + try: + import shutil + system_tool = shutil.which(tool_name) + if system_tool: + self.logger.debug(f"Using system {tool_name}: {system_tool}") + return system_tool + except Exception: + pass + self.logger.debug(f"Bundled {tool_name} not found in tools directory") + return None + + def _get_bundled_cabextract(self) -> Optional[str]: + """Get the path to the bundled cabextract binary. Backward compatibility.""" + return self._get_bundled_tool('cabextract', fallback_to_system=True) + + def is_available(self) -> bool: + """Check if winetricks is available and ready to use.""" + if not self.winetricks_path: + self.logger.error("Bundled winetricks not found") + return False + try: + env = os.environ.copy() + result = subprocess.run( + [self.winetricks_path, '--version'], + capture_output=True, + text=True, + env=env, + timeout=10 + ) + if result.returncode == 0: + self.logger.debug(f"Winetricks version: {result.stdout.strip()}") + return True + self.logger.error(f"Winetricks --version failed: {result.stderr}") + return False + except Exception as e: + self.logger.error(f"Error testing winetricks: {e}") + return False diff --git a/jackify/backend/handlers/winetricks_env.py b/jackify/backend/handlers/winetricks_env.py new file mode 100644 index 0000000..0b4c7db --- /dev/null +++ b/jackify/backend/handlers/winetricks_env.py @@ -0,0 +1,301 @@ +""" +Winetricks environment and dependency setup for install_wine_components. +Builds env dict, checks downloaders/deps, resolves components list. +""" + +import os +import sys +import subprocess +import logging +from typing import Optional, List, Callable, Tuple + +logger = logging.getLogger(__name__) + + +def _get_clean_winetricks_base_env() -> dict: + """ + Base environment for winetricks subprocess with no AppImage/bundle vars. + Wine and wineserver must not see _MEIPASS, bundle PATH/LD_LIBRARY_PATH or + connection reset / regsvr32 failures can occur when running from AppImage. + """ + preserve = [ + "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LANGUAGE", + "DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "XAUTHORITY", + "XDG_SESSION_TYPE", "DBUS_SESSION_BUS_ADDRESS", "XDG_DATA_DIRS", "XDG_CONFIG_DIRS", + "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "QT_QPA_PLATFORM", "GDK_BACKEND", + ] + env = {} + for var in preserve: + if var in os.environ: + env[var] = os.environ[var] + if "HOME" not in env and "HOME" in os.environ: + env["HOME"] = os.environ["HOME"] + path = os.environ.get("PATH", "") + if getattr(sys, "_MEIPASS", None): + path = os.pathsep.join(p for p in path.split(os.pathsep) if not p.startswith(sys._MEIPASS)) + env["PATH"] = path or "/usr/bin:/bin" + return env + + +class WinetricksEnvMixin: + """Mixin providing env build and dependency check for WinetricksHandler.install_wine_components.""" + + def _build_winetricks_env( + self, + wineprefix: str, + status_callback: Optional[Callable[[str], None]], + specific_components: Optional[List[str]], + ) -> Tuple[Optional[dict], Optional[List[str]]]: + """ + Build environment and resolve components for winetricks. Returns (env, components_to_install) or (None, None). + Uses a clean base env (no AppImage/bundle vars) so wine/wineserver see only Proton and system. + """ + env = _get_clean_winetricks_base_env() + env['WINEDEBUG'] = '-all' + env['WINEPREFIX'] = wineprefix + env['WINETRICKS_GUI'] = 'none' + if 'DISPLAY' in env: + env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d' + else: + env['DISPLAY'] = env.get('DISPLAY', '') + + try: + from ..handlers.config_handler import ConfigHandler + from ..handlers.wine_utils import WineUtils + + config = ConfigHandler() + user_proton_path = config.get_proton_path() + wine_binary = None + if user_proton_path and user_proton_path != 'auto': + if os.path.exists(user_proton_path): + resolved_proton_path = os.path.realpath(user_proton_path) + valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine') + ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine') + if os.path.exists(valve_proton_wine): + wine_binary = valve_proton_wine + self.logger.info(f"Using user-selected Proton: {user_proton_path}") + elif os.path.exists(ge_proton_wine): + wine_binary = ge_proton_wine + self.logger.info(f"Using user-selected GE-Proton: {user_proton_path}") + else: + self.logger.warning(f"User-selected Proton path invalid: {user_proton_path}") + else: + self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}") + + if not wine_binary: + if user_proton_path == 'auto': + self.logger.info("Auto-detecting Proton (user selected 'auto')") + best_proton = WineUtils.select_best_proton() + if best_proton: + wine_binary = WineUtils.find_proton_binary(best_proton['name']) + self.logger.info(f"Auto-selected Proton: {best_proton['name']} at {best_proton['path']}") + else: + self.logger.error("Auto-detection failed - no Proton versions found") + available_versions = WineUtils.scan_all_proton_versions() + if available_versions: + self.logger.error(f"Available Proton versions: {[v['name'] for v in available_versions]}") + else: + self.logger.error("No Proton versions detected in standard Steam locations") + else: + self.logger.error(f"Cannot use configured Proton: {user_proton_path}") + self.logger.error("Please check Settings and ensure the Proton version still exists") + return (None, None) + + if not wine_binary: + self.logger.error("Cannot run winetricks: No compatible Proton version found") + self.logger.error("Please ensure you have Proton 9+ or GE-Proton installed through Steam") + return (None, None) + + if not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)): + self.logger.error(f"Cannot run winetricks: Wine binary not found or not executable: {wine_binary}") + return (None, None) + + proton_dist_path = os.path.dirname(os.path.dirname(wine_binary)) + self.logger.debug(f"Proton dist path: {proton_dist_path}") + + # Create wine wrapper scripts (like protontricks does) to ensure proper + # LD_LIBRARY_PATH setup when winetricks spawns wine subprocesses + from .wine_wrapper import WineWrapperManager + wrapper_manager = WineWrapperManager() + wrapper_dir = wrapper_manager.create_wrappers(proton_dist_path) + + if wrapper_dir: + wine_wrapper = wrapper_dir / "wine" + wineserver_wrapper = wrapper_dir / "wineserver" + env['WINE'] = str(wine_wrapper) + env['WINELOADER'] = str(wine_wrapper) + env['WINESERVER'] = str(wineserver_wrapper) + # Put wrapper dir first in PATH so winetricks finds our wrappers + env['PATH'] = f"{wrapper_dir}:{proton_dist_path}/bin:{env.get('PATH', '')}" + self.logger.info(f"Using wine wrappers for winetricks: {wrapper_dir}") + self.logger.debug(f"WINE={wine_wrapper}, WINESERVER={wineserver_wrapper}") + else: + # Fallback to direct binary paths if wrapper creation fails + self.logger.warning("Wine wrapper creation failed, using direct binary paths") + env['WINE'] = str(wine_binary) + env['WINELOADER'] = str(wine_binary) + wineserver_bin = os.path.join(proton_dist_path, 'bin', 'wineserver') + if os.path.exists(wineserver_bin) and os.access(wineserver_bin, os.X_OK): + env['WINESERVER'] = wineserver_bin + env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}" + + env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine" + # LD_LIBRARY_PATH is now set by wrapper scripts, but set it here too for completeness + ld_prepend = f"{proton_dist_path}/lib64:{proton_dist_path}/lib" + env['LD_LIBRARY_PATH'] = f"{ld_prepend}:{env.get('LD_LIBRARY_PATH', '')}" if env.get('LD_LIBRARY_PATH') else ld_prepend + self.logger.debug(f"Set LD_LIBRARY_PATH for Proton (prepend): {ld_prepend[:80]}...") + + dll_overrides = { + "beclient": "b,n", "beclient_x64": "b,n", "dxgi": "n", "d3d9": "n", + "d3d10core": "n", "d3d11": "n", "d3d12": "n", "d3d12core": "n", + "nvapi": "n", "nvapi64": "n", "nvofapi64": "n", "nvcuda": "b" + } + existing_overrides = env.get('WINEDLLOVERRIDES', '') + if existing_overrides: + for override in existing_overrides.split(';'): + if '=' in override: + name, value = override.split('=', 1) + dll_overrides[name] = value + env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items()) + env['WINE_LARGE_ADDRESS_AWARE'] = '1' + env['DXVK_ENABLE_NVAPI'] = '1' + self.logger.debug(f"Set protontricks environment: WINEDLLPATH={env['WINEDLLPATH']}") + + except Exception as e: + self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}") + return (None, None) + + has_downloader = False + for tool in ['aria2c', 'curl', 'wget']: + try: + result = subprocess.run(['which', tool], capture_output=True, timeout=2, env=os.environ.copy()) + if result.returncode == 0: + has_downloader = True + self.logger.info(f"System has {tool} available - winetricks will auto-select best option") + break + except Exception: + pass + + if not has_downloader: + self._handle_missing_downloader_error() + return (None, None) + + tools_dir = None + bundled_tools = [] + for tool_name in ['cabextract', 'unzip', '7z', 'xz', 'sha256sum']: + bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False) + if bundled_tool: + bundled_tools.append(tool_name) + if tools_dir is None: + tools_dir = os.path.dirname(bundled_tool) + if tools_dir: + env['PATH'] = f"{env.get('PATH', '')}:{tools_dir}" + bundling_msg = f"Using bundled tools directory (after system PATH): {tools_dir}" + self.logger.info(bundling_msg) + if status_callback: + status_callback(bundling_msg) + tools_msg = f"Bundled tools available: {', '.join(bundled_tools)}" + self.logger.info(tools_msg) + if status_callback: + status_callback(tools_msg) + else: + self.logger.debug("No bundled tools found, relying on system PATH") + + deps_check_msg = "=== Checking winetricks dependencies ===" + self.logger.info(deps_check_msg) + if status_callback: + status_callback(deps_check_msg) + missing_deps = [] + bundled_tools_list = ['aria2c', 'wget', 'unzip', '7z', 'xz', 'sha256sum', 'cabextract'] + dependency_checks = { + 'wget': 'wget', 'curl': 'curl', 'aria2c': 'aria2c', 'unzip': 'unzip', + '7z': ['7z', '7za', '7zr'], 'xz': 'xz', + 'sha256sum': ['sha256sum', 'sha256', 'shasum'], 'perl': 'perl' + } + for dep_name, commands in dependency_checks.items(): + found = False + if isinstance(commands, str): + commands = [commands] + if dep_name in bundled_tools_list: + for cmd in commands: + bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False) + if bundled_tool: + dep_msg = f" {dep_name}: {bundled_tool} (bundled)" + self.logger.info(dep_msg) + if status_callback: + status_callback(dep_msg) + found = True + break + if not found: + for cmd in commands: + try: + result = subprocess.run(['which', cmd], capture_output=True, timeout=2) + if result.returncode == 0: + cmd_path = result.stdout.decode().strip() + dep_msg = f" {dep_name}: {cmd_path} (system)" + self.logger.info(dep_msg) + if status_callback: + status_callback(dep_msg) + found = True + break + except Exception: + pass + if not found: + missing_deps.append(dep_name) + if dep_name in bundled_tools_list: + self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)") + else: + self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)") + + if missing_deps: + download_deps = [d for d in missing_deps if d in ['wget', 'curl', 'aria2c']] + verbose = getattr(self, 'verbose', False) + if verbose: + critical_deps = [d for d in missing_deps if d not in ['aria2c']] + if critical_deps: + self.logger.warning(f"Missing critical winetricks dependencies: {', '.join(critical_deps)}") + self.logger.warning("Winetricks may fail if these are required for component installation") + optional_deps = [d for d in missing_deps if d in ['aria2c']] + if optional_deps: + self.logger.info(f"Optional dependencies not found (will use alternatives): {', '.join(optional_deps)}") + all_downloaders = {'wget', 'curl', 'aria2c'} + if set(download_deps) == all_downloaders: + self.logger.error("=" * 80) + self.logger.error("CRITICAL: No download tools found (wget, curl, or aria2c)") + self.logger.error("Winetricks requires at least ONE download tool to install components") + self.logger.error("") + self.logger.error("SOLUTION: Install one of the following:") + self.logger.error(" - aria2c (preferred): sudo apt install aria2 # or equivalent for your distro") + self.logger.error(" - curl: sudo apt install curl # or equivalent for your distro") + self.logger.error(" - wget: sudo apt install wget # or equivalent for your distro") + self.logger.error("=" * 80) + elif getattr(self, 'verbose', False): + self.logger.warning("Critical dependencies: wget/curl (download), unzip/7z (extract)") + elif getattr(self, 'verbose', False): + self.logger.info("All winetricks dependencies found") + if getattr(self, 'verbose', False): + self.logger.info("========================================") + + from jackify.shared.paths import get_jackify_data_dir + jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' + jackify_cache_dir.mkdir(parents=True, exist_ok=True) + env['WINETRICKS_CACHE'] = str(jackify_cache_dir) + + if specific_components is not None: + all_components = specific_components + self.logger.info(f"Installing specific components: {all_components}") + else: + all_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] + self.logger.info(f"Installing default components: {all_components}") + + if not all_components: + self.logger.info("No Wine components to install.") + if status_callback: + status_callback("No Wine components to install") + return (env, []) + + components_to_install = self._reorder_components_for_installation(all_components) + self.logger.info(f"WINEPREFIX: {wineprefix}, Ordered Components: {components_to_install}") + if status_callback: + status_callback(f"Installing Wine components: {', '.join(components_to_install)}") + return (env, components_to_install) diff --git a/jackify/backend/handlers/winetricks_handler.py b/jackify/backend/handlers/winetricks_handler.py index 12da521..e5f1f3a 100644 --- a/jackify/backend/handlers/winetricks_handler.py +++ b/jackify/backend/handlers/winetricks_handler.py @@ -2,7 +2,8 @@ # -*- coding: utf-8 -*- """ Winetricks Handler Module -Handles wine component installation using bundled winetricks +Handles wine component installation using bundled winetricks. +Discovery, installation strategy, and verification live in mixins. """ import os @@ -11,439 +12,63 @@ import logging from pathlib import Path from typing import Optional, List, Callable +from .winetricks_discovery import WinetricksDiscoveryMixin +from .winetricks_env import WinetricksEnvMixin +from .winetricks_installation import WinetricksInstallationMixin +from .winetricks_verification import WinetricksVerificationMixin + logger = logging.getLogger(__name__) -class WinetricksHandler: - """ - Handles wine component installation using bundled winetricks - """ +class WinetricksHandler( + WinetricksDiscoveryMixin, + WinetricksEnvMixin, + WinetricksInstallationMixin, + WinetricksVerificationMixin, +): + """Handles wine component installation. Discovery, installation, verification in mixins.""" def __init__(self, logger=None): self.logger = logger or logging.getLogger(__name__) self.winetricks_path = self._get_bundled_winetricks_path() - def _get_bundled_winetricks_path(self) -> Optional[str]: - """ - Get the path to the bundled winetricks script following AppImage best practices - """ - possible_paths = [] - - # AppImage environment - use APPDIR (standard AppImage best practice) - if os.environ.get('APPDIR'): - appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'winetricks') - possible_paths.append(appdir_path) - - # Development environment - relative to module location - module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/ - dev_path = module_dir / 'tools' / 'winetricks' - possible_paths.append(str(dev_path)) - - # Try each path until we find one that works - for path in possible_paths: - if os.path.exists(path) and os.access(path, os.X_OK): - self.logger.debug(f"Found bundled winetricks at: {path}") - return str(path) - - self.logger.error(f"Bundled winetricks not found. Tried paths: {possible_paths}") - return None - - def _get_bundled_tool(self, tool_name: str, fallback_to_system: bool = True) -> Optional[str]: - """ - Get the path to a bundled tool binary, checking same locations as winetricks. - - Args: - tool_name: Name of the tool (e.g., 'cabextract', 'wget', 'unzip') - fallback_to_system: If True, fall back to system PATH if bundled version not found - - Returns: - Path to the tool, or None if not found - """ - possible_paths = [] - - # AppImage environment - same pattern as winetricks detection - if os.environ.get('APPDIR'): - appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', tool_name) - possible_paths.append(appdir_path) - - # Development environment - relative to module location, same as winetricks - module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/ - dev_path = module_dir / 'tools' / tool_name - possible_paths.append(str(dev_path)) - - # Try each path until we find one that works - for path in possible_paths: - if os.path.exists(path) and os.access(path, os.X_OK): - self.logger.debug(f"Found bundled {tool_name} at: {path}") - return str(path) - - # Fallback to system PATH if requested - if fallback_to_system: - try: - import shutil - system_tool = shutil.which(tool_name) - if system_tool: - self.logger.debug(f"Using system {tool_name}: {system_tool}") - return system_tool - except Exception: - pass - - self.logger.debug(f"Bundled {tool_name} not found in tools directory") - return None - - def _get_bundled_cabextract(self) -> Optional[str]: - """ - Get the path to the bundled cabextract binary. - Maintains backward compatibility with existing code. - """ - return self._get_bundled_tool('cabextract', fallback_to_system=True) - - def is_available(self) -> bool: - """ - Check if winetricks is available and ready to use - """ - if not self.winetricks_path: - self.logger.error("Bundled winetricks not found") - return False - - try: - env = os.environ.copy() - result = subprocess.run( - [self.winetricks_path, '--version'], - capture_output=True, - text=True, - env=env, - timeout=10 - ) - if result.returncode == 0: - self.logger.debug(f"Winetricks version: {result.stdout.strip()}") - return True - else: - self.logger.error(f"Winetricks --version failed: {result.stderr}") - return False - except Exception as e: - self.logger.error(f"Error testing winetricks: {e}") - return False - - def install_wine_components(self, wineprefix: str, game_var: str, specific_components: Optional[List[str]] = None, status_callback: Optional[Callable[[str], None]] = None) -> bool: + def install_wine_components(self, wineprefix: str, game_var: str, specific_components: Optional[List[str]] = None, status_callback: Optional[Callable[[str], None]] = None, appid: Optional[str] = None) -> bool: """ Install the specified Wine components into the given prefix using winetricks. If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022). - + Args: wineprefix: Path to Wine prefix game_var: Game name for logging specific_components: Optional list of specific components to install status_callback: Optional callback function(status_message: str) for progress updates + appid: Optional Steam App ID (for fallback or logging) """ if not self.is_available(): self.logger.error("Winetricks is not available") return False - env = os.environ.copy() - env['WINEDEBUG'] = '-all' # Suppress Wine debug output - env['WINEPREFIX'] = wineprefix - env['WINETRICKS_GUI'] = 'none' # Suppress GUI popups - # Less aggressive popup suppression - don't completely disable display - if 'DISPLAY' in env: - # Keep DISPLAY but add window manager hints to prevent focus stealing - env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d' # Disable Wine menu integration - else: - # No display available anyway - env['DISPLAY'] = '' - - # Force winetricks to use Proton wine binary - NEVER fall back to system wine - try: - from ..handlers.config_handler import ConfigHandler - from ..handlers.wine_utils import WineUtils - - config = ConfigHandler() - # Use Install Proton for component installation/texture processing - # get_proton_path() returns the Install Proton path - user_proton_path = config.get_proton_path() - - # If user selected a specific Proton, try that first - wine_binary = None - if user_proton_path and user_proton_path != 'auto': - # Check if user-selected Proton still exists - if os.path.exists(user_proton_path): - # Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam - resolved_proton_path = os.path.realpath(user_proton_path) - - # Check for wine binary in different Proton structures - valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine') - ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine') - - if os.path.exists(valve_proton_wine): - wine_binary = valve_proton_wine - self.logger.info(f"Using user-selected Proton: {user_proton_path}") - elif os.path.exists(ge_proton_wine): - wine_binary = ge_proton_wine - self.logger.info(f"Using user-selected GE-Proton: {user_proton_path}") - else: - self.logger.warning(f"User-selected Proton path invalid: {user_proton_path}") - else: - self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}") - - # Only auto-detect if user explicitly chose 'auto' - if not wine_binary: - if user_proton_path == 'auto': - self.logger.info("Auto-detecting Proton (user selected 'auto')") - best_proton = WineUtils.select_best_proton() - if best_proton: - wine_binary = WineUtils.find_proton_binary(best_proton['name']) - self.logger.info(f"Auto-selected Proton: {best_proton['name']} at {best_proton['path']}") - else: - # Enhanced debugging for Proton detection failure - self.logger.error("Auto-detection failed - no Proton versions found") - available_versions = WineUtils.scan_all_proton_versions() - if available_versions: - self.logger.error(f"Available Proton versions: {[v['name'] for v in available_versions]}") - else: - self.logger.error("No Proton versions detected in standard Steam locations") - else: - # User selected a specific Proton but validation failed - this is an ERROR - self.logger.error(f"Cannot use configured Proton: {user_proton_path}") - self.logger.error("Please check Settings and ensure the Proton version still exists") - return False - - if not wine_binary: - self.logger.error("Cannot run winetricks: No compatible Proton version found") - self.logger.error("Please ensure you have Proton 9+ or GE-Proton installed through Steam") - return False - - if not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)): - self.logger.error(f"Cannot run winetricks: Wine binary not found or not executable: {wine_binary}") - return False - - env['WINE'] = str(wine_binary) - self.logger.info(f"Using Proton wine binary for winetricks: {wine_binary}") - - # CRITICAL: Set up protontricks-compatible environment - proton_dist_path = os.path.dirname(os.path.dirname(wine_binary)) # e.g., /path/to/proton/dist/bin/wine -> /path/to/proton/dist - self.logger.debug(f"Proton dist path: {proton_dist_path}") - - # Set WINEDLLPATH like protontricks does - env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine" - - # Ensure Proton bin directory is first in PATH - env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}" - - # Set DLL overrides exactly like protontricks - dll_overrides = { - "beclient": "b,n", - "beclient_x64": "b,n", - "dxgi": "n", - "d3d9": "n", - "d3d10core": "n", - "d3d11": "n", - "d3d12": "n", - "d3d12core": "n", - "nvapi": "n", - "nvapi64": "n", - "nvofapi64": "n", - "nvcuda": "b" - } - - # Merge with existing overrides - existing_overrides = env.get('WINEDLLOVERRIDES', '') - if existing_overrides: - # Parse existing overrides - for override in existing_overrides.split(';'): - if '=' in override: - name, value = override.split('=', 1) - dll_overrides[name] = value - - env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items()) - - # Set Wine defaults from protontricks - env['WINE_LARGE_ADDRESS_AWARE'] = '1' - env['DXVK_ENABLE_NVAPI'] = '1' - - self.logger.debug(f"Set protontricks environment: WINEDLLPATH={env['WINEDLLPATH']}") - - except Exception as e: - self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}") + env, components_to_install = self._build_winetricks_env(wineprefix, status_callback, specific_components) + if env is None: return False - - # CRITICAL: NEVER add bundled downloaders to PATH - they segfault on some systems - # Let winetricks auto-detect system downloaders (aria2c > wget > curl > fetch) - # Winetricks will automatically fall back if preferred tool isn't available - # We verify at least one exists before proceeding - - # Quick check: does system have at least one downloader? - has_downloader = False - for tool in ['aria2c', 'curl', 'wget']: - try: - result = subprocess.run(['which', tool], capture_output=True, timeout=2, env=os.environ.copy()) - if result.returncode == 0: - has_downloader = True - self.logger.info(f"System has {tool} available - winetricks will auto-select best option") - break - except Exception: - pass - - if not has_downloader: - self._handle_missing_downloader_error() - return False - - # Don't set WINETRICKS_DOWNLOADER - let winetricks auto-detect and fall back - # This ensures it uses the best available tool and handles fallbacks automatically - - # Set up bundled tools directory for winetricks - # NEVER add bundled downloaders to PATH - they segfault on some systems - # Only bundle non-downloader tools: cabextract, unzip, 7z, xz, sha256sum - tools_dir = None - bundled_tools = [] - - # Check for bundled tools and collect their directory - # Downloaders (aria2c, wget, curl) are NEVER bundled - always use system tools - tool_names = ['cabextract', 'unzip', '7z', 'xz', 'sha256sum'] - - for tool_name in tool_names: - bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False) - if bundled_tool: - bundled_tools.append(tool_name) - if tools_dir is None: - tools_dir = os.path.dirname(bundled_tool) - - # Add bundled tools to PATH (system PATH first, so system downloaders are found first) - # NEVER add bundled downloaders - only archive/utility tools - if tools_dir: - # System PATH first, then bundled tools (so system downloaders are always found first) - env['PATH'] = f"{env.get('PATH', '')}:{tools_dir}" - bundling_msg = f"Using bundled tools directory (after system PATH): {tools_dir}" - self.logger.info(bundling_msg) - if status_callback: - status_callback(bundling_msg) - tools_msg = f"Bundled tools available: {', '.join(bundled_tools)}" - self.logger.info(tools_msg) - if status_callback: - status_callback(tools_msg) - else: - self.logger.debug("No bundled tools found, relying on system PATH") - - # CRITICAL: Check for winetricks dependencies BEFORE attempting installation - # This helps diagnose failures on systems where dependencies are missing - deps_check_msg = "=== Checking winetricks dependencies ===" - self.logger.info(deps_check_msg) - if status_callback: - status_callback(deps_check_msg) - missing_deps = [] - bundled_tools_list = ['aria2c', 'wget', 'unzip', '7z', 'xz', 'sha256sum', 'cabextract'] - dependency_checks = { - 'wget': 'wget', - 'curl': 'curl', - 'aria2c': 'aria2c', - 'unzip': 'unzip', - '7z': ['7z', '7za', '7zr'], - 'xz': 'xz', - 'sha256sum': ['sha256sum', 'sha256', 'shasum'], - 'perl': 'perl' - } - - for dep_name, commands in dependency_checks.items(): - found = False - if isinstance(commands, str): - commands = [commands] - - # Check for bundled version only for tools we bundle - if dep_name in bundled_tools_list: - bundled_tool = None - for cmd in commands: - bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False) - if bundled_tool: - dep_msg = f" {dep_name}: {bundled_tool} (bundled)" - self.logger.info(dep_msg) - if status_callback: - status_callback(dep_msg) - found = True - break - - # Check system PATH if not found bundled - if not found: - for cmd in commands: - try: - result = subprocess.run(['which', cmd], capture_output=True, timeout=2) - if result.returncode == 0: - cmd_path = result.stdout.decode().strip() - dep_msg = f" {dep_name}: {cmd_path} (system)" - self.logger.info(dep_msg) - if status_callback: - status_callback(dep_msg) - found = True - break - except Exception: - pass - - if not found: - missing_deps.append(dep_name) - if dep_name in bundled_tools_list: - self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)") - else: - self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)") - - if missing_deps: - # Separate critical vs optional dependencies - download_deps = [d for d in missing_deps if d in ['wget', 'curl', 'aria2c']] - critical_deps = [d for d in missing_deps if d not in ['aria2c']] - optional_deps = [d for d in missing_deps if d in ['aria2c']] - - if critical_deps: - self.logger.warning(f"Missing critical winetricks dependencies: {', '.join(critical_deps)}") - self.logger.warning("Winetricks may fail if these are required for component installation") - - if optional_deps: - self.logger.info(f"Optional dependencies not found (will use alternatives): {', '.join(optional_deps)}") - self.logger.info("aria2c is optional - winetricks will use wget/curl if available") - - # Special warning if ALL downloaders are missing - all_downloaders = {'wget', 'curl', 'aria2c'} - missing_downloaders = set(download_deps) - if missing_downloaders == all_downloaders: - self.logger.error("=" * 80) - self.logger.error("CRITICAL: No download tools found (wget, curl, or aria2c)") - self.logger.error("Winetricks requires at least ONE download tool to install components") - self.logger.error("") - self.logger.error("SOLUTION: Install one of the following:") - self.logger.error(" - aria2c (preferred): sudo apt install aria2 # or equivalent for your distro") - self.logger.error(" - curl: sudo apt install curl # or equivalent for your distro") - self.logger.error(" - wget: sudo apt install wget # or equivalent for your distro") - self.logger.error("=" * 80) - else: - self.logger.warning("Critical dependencies: wget/curl (download), unzip/7z (extract)") - self.logger.info("Optional dependencies: aria2c (preferred but not required)") - else: - self.logger.info("All winetricks dependencies found") - self.logger.info("========================================") - - # Set winetricks cache to jackify_data_dir for self-containment - from jackify.shared.paths import get_jackify_data_dir - jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' - jackify_cache_dir.mkdir(parents=True, exist_ok=True) - env['WINETRICKS_CACHE'] = str(jackify_cache_dir) - - if specific_components is not None: - all_components = specific_components - self.logger.info(f"Installing specific components: {all_components}") - else: - all_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] - self.logger.info(f"Installing default components: {all_components}") - - if not all_components: - self.logger.info("No Wine components to install.") - if status_callback: - status_callback("No Wine components to install") + if not components_to_install: return True - # Reorder components for proper installation sequence - components_to_install = self._reorder_components_for_installation(all_components) - self.logger.info(f"WINEPREFIX: {wineprefix}, Game: {game_var}, Ordered Components: {components_to_install}") - - # Show status with component list - if status_callback: - components_list = ', '.join(components_to_install) - status_callback(f"Installing Wine components: {components_list}") + # Flatpak Steam: use protontricks only; bundled winetricks is unreliable (e.g. from AppImage) + flatpak_steam = False + try: + from ..services.steam_restart_service import is_flatpak_steam + flatpak_steam = is_flatpak_steam() + except Exception as e: + self.logger.debug("Could not check Flatpak Steam via CLI: %s", e) + if not flatpak_steam and self._is_flatpak_steam_prefix(wineprefix): + flatpak_steam = True + self.logger.info("Flatpak Steam prefix detected (path): using protontricks only") + if flatpak_steam: + self.logger.info("Flatpak Steam detected: using protontricks only for component installation") + return self._install_components_protontricks_only( + components_to_install, wineprefix, game_var, status_callback, appid=appid + ) # Check user preference for component installation method from ..handlers.config_handler import ConfigHandler @@ -465,7 +90,6 @@ class WinetricksHandler: self.logger.info("=" * 80) self.logger.info("Using system protontricks for all components") return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback) - # else: method == 'winetricks' (default behavior continues below) # Install all components together with winetricks (faster) self.logger.info("=" * 80) @@ -479,10 +103,12 @@ class WinetricksHandler: if attempt > 1: self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...") self._cleanup_wine_processes() + elif attempt == 1: + self._kill_wineserver_for_prefix(env) try: - # Build winetricks command - using --unattended for silent installation cmd = [self.winetricks_path, '--unattended'] + components_to_install + run_env = env # Log full command for advanced users to reproduce manually (debug mode only) cmd_str = ' '.join(cmd) @@ -493,6 +119,7 @@ class WinetricksHandler: self.logger.debug("Environment variables required:") self.logger.debug(f" WINEPREFIX={env.get('WINEPREFIX', 'NOT SET')}") self.logger.debug(f" WINE={env.get('WINE', 'NOT SET')}") + self.logger.debug(f" WINESERVER={env.get('WINESERVER', 'NOT SET')}") self.logger.debug("=" * 80) # Enhanced diagnostics for bundled winetricks @@ -530,7 +157,7 @@ class WinetricksHandler: result = subprocess.run( cmd, - env=env, + env=run_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -547,12 +174,22 @@ class WinetricksHandler: components_list = ', '.join(components_to_install) if status_callback: status_callback(f"Wine components installed and verified: {components_list}") - # Set Windows 10 mode after component installation (matches legacy script timing) - self._set_windows_10_mode(wineprefix, env.get('WINE', '')) + self._set_windows_10_mode_after_install(wineprefix, env) return True else: self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})") - # Continue to retry + winetricks_failed = True + elif result.returncode == 1 and "returned status 120" in result.stderr and "aborting" in result.stderr.lower(): + # VC redist / some installers return 120 under Wine (ERROR_CALL_NOT_IMPLEMENTED); install may still have succeeded + self.logger.info("Winetricks returned 1 with status 120 (installer quirk under Wine); verifying components...") + if self._verify_components_installed(wineprefix, components_to_install, env): + self.logger.info("Component verification passed after status 120 - accepting as success.") + if status_callback: + status_callback(f"Wine components installed and verified: {', '.join(components_to_install)}") + self._set_windows_10_mode_after_install(wineprefix, env) + return True + last_error_details = {'returncode': result.returncode, 'stdout': result.stdout.strip(), 'stderr': result.stderr.strip(), 'attempt': attempt} + winetricks_failed = True else: # Store detailed error information for fallback diagnostics last_error_details = { @@ -618,6 +255,16 @@ class WinetricksHandler: self.logger.error(" - Check dependency check output above for missing tools") self.logger.error(" - Will attempt protontricks fallback if all attempts fail") diagnostic_found = True + elif ("returned status" in stderr_lower and "aborting" in stderr_lower) or "connection reset by peer" in stderr_lower: + self.logger.error("DIAGNOSTIC: Wine/Proton command failed (regedit, VC redist, etc.)") + self.logger.error(" - Wine subprocess returned non-zero or wineserver connection reset") + self.logger.error(" - Common when running winetricks from AppImage against Proton prefix; protontricks fallback uses same prefix from inside Steam env") + diagnostic_found = True + elif "w_metadata" in stderr_lower and ("unix path" in stderr_lower or "windows path" in stderr_lower): + self.logger.error("DIAGNOSTIC: Winetricks metadata bug (e.g. jet40 installed_file path)") + self.logger.error(" - Known winetricks bug: component metadata uses Unix path instead of Windows path") + self.logger.error(" - Upstream fix in newer winetricks; protontricks fallback will be used") + diagnostic_found = True elif "permission denied" in stderr_lower: self.logger.error("DIAGNOSTIC: Permission issue detected") self.logger.error(f" - Check permissions on: {self.winetricks_path}") @@ -628,7 +275,7 @@ class WinetricksHandler: self.logger.error(" - Network may be slow or unstable") self.logger.error(" - Component download may be taking too long") diagnostic_found = True - elif "sha256sum mismatch" in stderr_lower or "sha256sum" in stdout_lower: + elif "sha256sum mismatch" in stderr_lower or ("checksum" in stderr_lower and ("fail" in stderr_lower or "mismatch" in stderr_lower)): self.logger.error("DIAGNOSTIC: Checksum verification failed") self.logger.error(" - Component download may be corrupted") self.logger.error(" - Network issue or upstream file change") @@ -642,12 +289,12 @@ class WinetricksHandler: self.logger.error(" - Network connectivity problem or missing download tool") self.logger.error(" - Check dependency check output above") diagnostic_found = True - elif "cabextract" in stderr_lower: + elif "cabextract" in stderr_lower and ("not found" in stderr_lower or "failed" in stderr_lower or "command not found" in stderr_lower or "no such file" in stderr_lower): self.logger.error("DIAGNOSTIC: cabextract missing or failed") self.logger.error(" - Required for extracting Windows cabinet files") self.logger.error(" - Bundled cabextract should be available, check PATH") diagnostic_found = True - elif "unzip" in stderr_lower or "7z" in stderr_lower: + elif ("unzip" in stderr_lower or "7z" in stderr_lower) and ("not found" in stderr_lower or "failed" in stderr_lower or "error" in stderr_lower): self.logger.error("DIAGNOSTIC: Archive extraction tool (unzip/7z) missing or failed") self.logger.error(" - Required for extracting zip/7z archives") self.logger.error(" - Check dependency check output above") @@ -792,421 +439,25 @@ class WinetricksHandler: self.logger.error("=" * 80) - def _reorder_components_for_installation(self, components: list) -> list: - """ - Reorder components for proper installation sequence if needed. - Currently returns components in original order. - """ - return components - - def _install_components_separately(self, components: list, wineprefix: str, wine_binary: str, base_env: dict) -> bool: - """ - Install components separately for maximum compatibility. - """ - self.logger.info(f"Installing {len(components)} components separately") - - for i, component in enumerate(components, 1): - self.logger.info(f"Installing component {i}/{len(components)}: {component}") - - # Prepare environment for this component - env = base_env.copy() - env['WINEPREFIX'] = wineprefix - env['WINE'] = wine_binary - - # Install this component - max_attempts = 3 - component_success = False - - for attempt in range(1, max_attempts + 1): - if attempt > 1: - self.logger.warning(f"Retrying {component} installation (attempt {attempt}/{max_attempts})") - self._cleanup_wine_processes() - - try: - cmd = [self.winetricks_path, '--unattended', component] - self.logger.debug(f"Running: {' '.join(cmd)}") - - result = subprocess.run( - cmd, - env=env, - capture_output=True, - text=True, - timeout=600 - ) - - if result.returncode == 0: - self.logger.info(f"{component} installed successfully") - component_success = True - break - else: - self.logger.error(f"{component} failed (attempt {attempt}): {result.stderr.strip()}") - self.logger.debug(f"Full stdout for {component}: {result.stdout.strip()}") - - except Exception as e: - self.logger.error(f"Error installing {component} (attempt {attempt}): {e}") - - if not component_success: - self.logger.error(f"Failed to install {component} after {max_attempts} attempts") - return False - - self.logger.info("All components installed successfully using separate sessions") - # Set Windows 10 mode after all component installation - self._set_windows_10_mode(wineprefix, env.get('WINE', '')) - return True - - def _prepare_winetricks_environment(self, wineprefix: str) -> Optional[dict]: - """ - Prepare the environment for winetricks installation. - This reuses the existing environment setup logic. - - Args: - wineprefix: Wine prefix path - - Returns: - dict: Environment variables for winetricks, or None if failed - """ + def _kill_wineserver_for_prefix(self, env: dict) -> None: + """Kill wineserver for the current WINEPREFIX so the next wine invocation starts a fresh one (avoids connection reset by peer).""" + wineserver = env.get('WINESERVER') + if not wineserver or not os.path.exists(wineserver): + return try: - env = os.environ.copy() - env['WINEDEBUG'] = '-all' - env['WINEPREFIX'] = wineprefix - env['WINETRICKS_GUI'] = 'none' - - # Existing Proton detection logic - from ..handlers.config_handler import ConfigHandler - from ..handlers.wine_utils import WineUtils - - config = ConfigHandler() - user_proton_path = config.get_proton_path() - - wine_binary = None - if user_proton_path and user_proton_path != 'auto': - if os.path.exists(user_proton_path): - resolved_proton_path = os.path.realpath(user_proton_path) - valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine') - ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine') - - if os.path.exists(valve_proton_wine): - wine_binary = valve_proton_wine - elif os.path.exists(ge_proton_wine): - wine_binary = ge_proton_wine - - if not wine_binary: - if not user_proton_path or user_proton_path == 'auto': - self.logger.info("Auto-detecting Proton (user selected 'auto' or path not set)") - best_proton = WineUtils.select_best_proton() - if best_proton: - wine_binary = WineUtils.find_proton_binary(best_proton['name']) - else: - # User selected a specific Proton but validation failed - self.logger.error(f"Cannot prepare winetricks environment: configured Proton not found: {user_proton_path}") - return None - - if not wine_binary or not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)): - self.logger.error(f"Cannot prepare winetricks environment: No compatible Proton found") - return None - - env['WINE'] = str(wine_binary) - - # Set up protontricks-compatible environment (existing logic) - proton_dist_path = os.path.dirname(os.path.dirname(wine_binary)) - env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine" - env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}" - - # Existing DLL overrides - dll_overrides = { - "beclient": "b,n", "beclient_x64": "b,n", "dxgi": "n", "d3d9": "n", - "d3d10core": "n", "d3d11": "n", "d3d12": "n", "d3d12core": "n", - "nvapi": "n", "nvapi64": "n", "nvofapi64": "n", "nvcuda": "b" - } - - env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items()) - env['WINE_LARGE_ADDRESS_AWARE'] = '1' - env['DXVK_ENABLE_NVAPI'] = '1' - - # Set up winetricks cache - from jackify.shared.paths import get_jackify_data_dir - jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' - jackify_cache_dir.mkdir(parents=True, exist_ok=True) - env['WINETRICKS_CACHE'] = str(jackify_cache_dir) - - return env - + subprocess.run( + [wineserver, '-k'], + env=env, + timeout=10, + capture_output=True, + ) + self.logger.debug("Killed wineserver for prefix so winetricks can start a fresh one") except Exception as e: - self.logger.error(f"Failed to prepare winetricks environment: {e}") - return None - - def _install_components_with_winetricks(self, components: list, wineprefix: str, env: dict) -> bool: - """ - Install components using winetricks with the prepared environment. - - Args: - components: List of components to install - wineprefix: Wine prefix path - env: Prepared environment variables - - Returns: - bool: True if installation succeeded, False otherwise - """ - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - if attempt > 1: - self.logger.warning(f"Retrying winetricks installation (attempt {attempt}/{max_attempts})") - self._cleanup_wine_processes() - - try: - cmd = [self.winetricks_path, '--unattended'] + components - self.logger.debug(f"Running winetricks: {' '.join(cmd)}") - - result = subprocess.run( - cmd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=600 - ) - - if result.returncode == 0: - self.logger.info(f"Winetricks components installation command completed.") - - # Verify components were actually installed - if self._verify_components_installed(wineprefix, components, env): - self.logger.info("Component verification successful - all components installed correctly.") - # Set Windows 10 mode after component installation (matches legacy script timing) - wine_binary = env.get('WINE', '') - self._set_windows_10_mode(env.get('WINEPREFIX', ''), wine_binary) - return True - else: - self.logger.error(f"Component verification failed (attempt {attempt})") - # Continue to retry - else: - self.logger.error(f"Winetricks failed (attempt {attempt}): {result.stderr.strip()}") - - except Exception as e: - self.logger.error(f"Error during winetricks run (attempt {attempt}): {e}") - - self.logger.error(f"Failed to install components with winetricks after {max_attempts} attempts") - return False - - def _set_windows_10_mode(self, wineprefix: str, wine_binary: str): - """ - Set Windows 10 mode for the prefix after component installation (matches legacy script timing). - This should be called AFTER all Wine components are installed, not before. - """ - try: - env = os.environ.copy() - env['WINEPREFIX'] = wineprefix - env['WINE'] = wine_binary - - self.logger.info("Setting Windows 10 mode after component installation (matching legacy script)") - result = subprocess.run([ - self.winetricks_path, '-q', 'win10' - ], env=env, capture_output=True, text=True, timeout=300) - - if result.returncode == 0: - self.logger.info("Windows 10 mode set successfully") - else: - self.logger.warning(f"Could not set Windows 10 mode: {result.stderr}") - - except Exception as e: - self.logger.warning(f"Error setting Windows 10 mode: {e}") - - def _install_components_protontricks_only(self, components: list, wineprefix: str, game_var: str, status_callback: Optional[Callable[[str], None]] = None) -> bool: - """ - Install all components using protontricks only. - This matches the behavior of the original bash script. - - Args: - components: List of components to install - wineprefix: Path to wine prefix - game_var: Game variable name - """ - try: - self.logger.info(f"Installing all components with system protontricks: {components}") - - # Import protontricks handler - from ..handlers.protontricks_handler import ProtontricksHandler - - # Determine if we're on Steam Deck (for protontricks handler) - steamdeck = os.path.exists('/home/deck') - protontricks_handler = ProtontricksHandler(steamdeck, logger=self.logger) - - # Get AppID from wineprefix - appid = self._extract_appid_from_wineprefix(wineprefix) - if not appid: - self.logger.error("Could not extract AppID from wineprefix for protontricks installation") - return False - - self.logger.info(f"Using AppID {appid} for protontricks installation") - - # Detect protontricks availability - if not protontricks_handler.detect_protontricks(): - self.logger.error("Protontricks not available for component installation") - return False - - # Install all components using protontricks - components_list = ', '.join(components) - if status_callback: - status_callback(f"Installing Wine components via protontricks: {components_list}") - success = protontricks_handler.install_wine_components(appid, game_var, components) - - if success: - self.logger.info("All components installed successfully with protontricks") - # Set Windows 10 mode after component installation - wine_binary = self._get_wine_binary_for_prefix(wineprefix) - self._set_windows_10_mode(wineprefix, wine_binary) - return True - else: - self.logger.error("Component installation failed with protontricks") - return False - - except Exception as e: - self.logger.error(f"Error installing components with protontricks: {e}", exc_info=True) - return False - - def _extract_appid_from_wineprefix(self, wineprefix: str) -> Optional[str]: - """ - Extract AppID from wineprefix path. - - Args: - wineprefix: Wine prefix path - - Returns: - AppID as string, or None if extraction fails - """ - try: - if 'compatdata' in wineprefix: - # Standard Steam compatdata structure - path_parts = Path(wineprefix).parts - for i, part in enumerate(path_parts): - if part == 'compatdata' and i + 1 < len(path_parts): - potential_appid = path_parts[i + 1] - if potential_appid.isdigit(): - return potential_appid - self.logger.error(f"Could not extract AppID from wineprefix path: {wineprefix}") - return None - except Exception as e: - self.logger.error(f"Error extracting AppID from wineprefix: {e}") - return None - - def _get_wine_binary_for_prefix(self, wineprefix: str) -> str: - """ - Get the wine binary path for a given prefix. - - Args: - wineprefix: Wine prefix path - - Returns: - Wine binary path as string - """ - try: - from ..handlers.config_handler import ConfigHandler - from ..handlers.wine_utils import WineUtils - - config = ConfigHandler() - user_proton_path = config.get_proton_path() - - # If user selected a specific Proton, try that first - wine_binary = None - if user_proton_path and user_proton_path != 'auto': - if os.path.exists(user_proton_path): - resolved_proton_path = os.path.realpath(user_proton_path) - valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine') - ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine') - - if os.path.exists(valve_proton_wine): - wine_binary = valve_proton_wine - elif os.path.exists(ge_proton_wine): - wine_binary = ge_proton_wine - - # Only auto-detect if user explicitly chose 'auto' or path is not set - if not wine_binary: - if not user_proton_path or user_proton_path == 'auto': - self.logger.info("Auto-detecting Proton (user selected 'auto' or path not set)") - best_proton = WineUtils.select_best_proton() - if best_proton: - wine_binary = WineUtils.find_proton_binary(best_proton['name']) - else: - # User selected a specific Proton but validation failed - self.logger.error(f"Configured Proton not found: {user_proton_path}") - return "" - - return wine_binary if wine_binary else "" - except Exception as e: - self.logger.error(f"Error getting wine binary for prefix: {e}") - return "" - - def _verify_components_installed(self, wineprefix: str, components: List[str], env: dict) -> bool: - """ - Verify that Wine components were actually installed by checking winetricks.log. - - Args: - wineprefix: Wine prefix path - components: List of components that should be installed - env: Environment variables (includes WINE path) - - Returns: - bool: True if all critical components are verified, False otherwise - """ - try: - self.logger.info("Verifying installed components...") - - # Check winetricks.log file for installed components - winetricks_log = os.path.join(wineprefix, 'winetricks.log') - - if not os.path.exists(winetricks_log): - self.logger.error(f"winetricks.log not found at {winetricks_log}") - return False - - try: - with open(winetricks_log, 'r', encoding='utf-8', errors='ignore') as f: - log_content = f.read().lower() - except Exception as e: - self.logger.error(f"Failed to read winetricks.log: {e}") - return False - - self.logger.debug(f"winetricks.log length: {len(log_content)} bytes") - - # Define critical components that MUST be installed - critical_components = ["vcrun2022", "xact"] - - # Check for critical components - missing_critical = [] - for component in critical_components: - if component.lower() not in log_content: - missing_critical.append(component) - - if missing_critical: - self.logger.error(f"CRITICAL: Missing essential components: {missing_critical}") - self.logger.error("Installation reported success but components are NOT in winetricks.log") - return False - - # Check for requested components (warn but don't fail) - missing_requested = [] - for component in components: - # Handle settings like fontsmooth=rgb (just check the base component name) - base_component = component.split('=')[0].lower() - if base_component not in log_content and component.lower() not in log_content: - missing_requested.append(component) - - if missing_requested: - self.logger.warning(f"Some requested components may not be installed: {missing_requested}") - self.logger.warning("This may cause issues, but critical components are present") - - self.logger.info(f"Verification passed - critical components confirmed: {critical_components}") - return True - - except Exception as e: - self.logger.error(f"Error verifying components: {e}", exc_info=True) - return False + self.logger.debug("Wineserver -k failed (non-fatal): %s", e) def _cleanup_wine_processes(self): - """ - Internal method to clean up wine processes during component installation - Only cleanup winetricks processes - NEVER kill all wine processes - """ + """Clean up winetricks processes only during component installation.""" try: - # Only cleanup winetricks processes - do NOT kill other wine apps subprocess.run("pkill -f winetricks", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self.logger.debug("Cleaned up winetricks processes only") except Exception as e: diff --git a/jackify/backend/handlers/winetricks_installation.py b/jackify/backend/handlers/winetricks_installation.py new file mode 100644 index 0000000..58e6f7b --- /dev/null +++ b/jackify/backend/handlers/winetricks_installation.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Winetricks installation mixin: environment, run winetricks, protontricks fallback. +Extracted from winetricks_handler for file-size and domain separation. +""" + +import os +import subprocess +import logging +from pathlib import Path +from typing import Optional, List, Callable + +logger = logging.getLogger(__name__) + + +class WinetricksInstallationMixin: + """Mixin providing winetricks environment setup and component installation strategies.""" + + def _reorder_components_for_installation(self, components: list) -> list: + """Reorder components for proper installation sequence. Currently returns original order.""" + return components + + def _prepare_winetricks_environment(self, wineprefix: str) -> Optional[dict]: + """Prepare environment for winetricks (Proton detection, DLL overrides, cache). Returns env dict or None.""" + try: + env = os.environ.copy() + env['WINEDEBUG'] = '-all' + env['WINEPREFIX'] = wineprefix + env['WINETRICKS_GUI'] = 'none' + from ..handlers.config_handler import ConfigHandler + from ..handlers.wine_utils import WineUtils + config = ConfigHandler() + user_proton_path = config.get_proton_path() + wine_binary = None + if user_proton_path and user_proton_path != 'auto': + if os.path.exists(user_proton_path): + resolved_proton_path = os.path.realpath(user_proton_path) + valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine') + ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine') + if os.path.exists(valve_proton_wine): + wine_binary = valve_proton_wine + elif os.path.exists(ge_proton_wine): + wine_binary = ge_proton_wine + if not wine_binary: + if not user_proton_path or user_proton_path == 'auto': + self.logger.info("Auto-detecting Proton (user selected 'auto' or path not set)") + best_proton = WineUtils.select_best_proton() + if best_proton: + wine_binary = WineUtils.find_proton_binary(best_proton['name']) + else: + self.logger.error(f"Cannot prepare winetricks environment: configured Proton not found: {user_proton_path}") + return None + if not wine_binary or not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)): + self.logger.error("Cannot prepare winetricks environment: No compatible Proton found") + return None + env['WINE'] = str(wine_binary) + proton_dist_path = os.path.dirname(os.path.dirname(wine_binary)) + env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine" + env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}" + dll_overrides = { + "beclient": "b,n", "beclient_x64": "b,n", "dxgi": "n", "d3d9": "n", + "d3d10core": "n", "d3d11": "n", "d3d12": "n", "d3d12core": "n", + "nvapi": "n", "nvapi64": "n", "nvofapi64": "n", "nvcuda": "b" + } + env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items()) + env['WINE_LARGE_ADDRESS_AWARE'] = '1' + env['DXVK_ENABLE_NVAPI'] = '1' + from jackify.shared.paths import get_jackify_data_dir + jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' + jackify_cache_dir.mkdir(parents=True, exist_ok=True) + env['WINETRICKS_CACHE'] = str(jackify_cache_dir) + return env + except Exception as e: + self.logger.error(f"Failed to prepare winetricks environment: {e}") + return None + + def _install_components_with_winetricks(self, components: list, wineprefix: str, env: dict) -> bool: + """Install components using winetricks with the prepared environment.""" + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + if attempt > 1: + self.logger.warning(f"Retrying winetricks installation (attempt {attempt}/{max_attempts})") + self._cleanup_wine_processes() + try: + cmd = [self.winetricks_path, '--unattended'] + components + self.logger.debug(f"Running winetricks: {' '.join(cmd)}") + result = subprocess.run( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=600 + ) + if result.returncode == 0: + self.logger.info("Winetricks components installation command completed.") + if self._verify_components_installed(wineprefix, components, env): + self.logger.info("Component verification successful - all components installed correctly.") + wine_binary = env.get('WINE', '') + self._set_windows_10_mode(env.get('WINEPREFIX', ''), wine_binary) + return True + self.logger.error(f"Component verification failed (attempt {attempt})") + else: + self.logger.error(f"Winetricks failed (attempt {attempt}): {result.stderr.strip()}") + except Exception as e: + self.logger.error(f"Error during winetricks run (attempt {attempt}): {e}") + self.logger.error(f"Failed to install components with winetricks after {max_attempts} attempts") + return False + + def _set_windows_10_mode(self, wineprefix: str, wine_binary: str) -> None: + """Set Windows 10 mode for the prefix after component installation.""" + try: + env = os.environ.copy() + env['WINEPREFIX'] = wineprefix + env['WINE'] = wine_binary + self.logger.info("Setting Windows 10 mode after component installation (matching legacy script)") + result = subprocess.run( + [self.winetricks_path, '-q', 'win10'], + env=env, + capture_output=True, + text=True, + timeout=300 + ) + if result.returncode == 0: + self.logger.info("Windows 10 mode set successfully") + else: + self.logger.warning(f"Could not set Windows 10 mode: {result.stderr}") + except Exception as e: + self.logger.warning(f"Error setting Windows 10 mode: {e}") + + def _set_windows_10_mode_after_install(self, wineprefix: str, install_env: dict) -> None: + """Set Windows 10 mode for the prefix after component installation.""" + try: + self._set_windows_10_mode(wineprefix, install_env.get('WINE', '')) + except Exception as e: + self.logger.warning(f"Error setting Windows 10 mode: {e}") + + def _install_components_separately(self, components: list, wineprefix: str, wine_binary: str, base_env: dict) -> bool: + """Install components one at a time for maximum compatibility.""" + self.logger.info(f"Installing {len(components)} components separately") + for i, component in enumerate(components, 1): + self.logger.info(f"Installing component {i}/{len(components)}: {component}") + env = base_env.copy() + env['WINEPREFIX'] = wineprefix + env['WINE'] = wine_binary + max_attempts = 3 + component_success = False + for attempt in range(1, max_attempts + 1): + if attempt > 1: + self.logger.warning(f"Retrying {component} installation (attempt {attempt}/{max_attempts})") + self._cleanup_wine_processes() + try: + cmd = [self.winetricks_path, '--unattended', component] + self.logger.debug(f"Running: {' '.join(cmd)}") + result = subprocess.run( + cmd, + env=env, + capture_output=True, + text=True, + timeout=600 + ) + if result.returncode == 0: + self.logger.info(f"{component} installed successfully") + component_success = True + break + self.logger.error(f"{component} failed (attempt {attempt}): {result.stderr.strip()}") + self.logger.debug(f"Full stdout for {component}: {result.stdout.strip()}") + except Exception as e: + self.logger.error(f"Error installing {component} (attempt {attempt}): {e}") + if not component_success: + self.logger.error(f"Failed to install {component} after {max_attempts} attempts") + return False + self.logger.info("All components installed successfully using separate sessions") + self._set_windows_10_mode(wineprefix, env.get('WINE', '')) + return True + + def _is_flatpak_steam_prefix(self, wineprefix: str) -> bool: + """True if wineprefix is under Flatpak Steam (.var/app/com.valvesoftware.Steam).""" + if not wineprefix: + return False + path_str = os.fspath(wineprefix) + return ".var" in path_str and "app" in path_str and "com.valvesoftware.Steam" in path_str + + def _extract_appid_from_wineprefix(self, wineprefix: str) -> Optional[str]: + """Extract AppID from wineprefix path (compatdata/AppID).""" + try: + if 'compatdata' in wineprefix: + path_parts = Path(wineprefix).parts + for i, part in enumerate(path_parts): + if part == 'compatdata' and i + 1 < len(path_parts): + potential_appid = path_parts[i + 1] + if potential_appid.isdigit(): + return potential_appid + self.logger.error(f"Could not extract AppID from wineprefix path: {wineprefix}") + return None + except Exception as e: + self.logger.error(f"Error extracting AppID from wineprefix: {e}") + return None + + def _get_wine_binary_for_prefix(self, wineprefix: str) -> str: + """Get the wine binary path for a given prefix (user Proton or auto-detect).""" + try: + from ..handlers.config_handler import ConfigHandler + from ..handlers.wine_utils import WineUtils + config = ConfigHandler() + user_proton_path = config.get_proton_path() + wine_binary = None + if user_proton_path and user_proton_path != 'auto': + if os.path.exists(user_proton_path): + resolved_proton_path = os.path.realpath(user_proton_path) + valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine') + ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine') + if os.path.exists(valve_proton_wine): + wine_binary = valve_proton_wine + elif os.path.exists(ge_proton_wine): + wine_binary = ge_proton_wine + if not wine_binary: + if not user_proton_path or user_proton_path == 'auto': + self.logger.info("Auto-detecting Proton (user selected 'auto' or path not set)") + best_proton = WineUtils.select_best_proton() + if best_proton: + wine_binary = WineUtils.find_proton_binary(best_proton['name']) + else: + self.logger.error(f"Configured Proton not found: {user_proton_path}") + return "" + return wine_binary if wine_binary else "" + except Exception as e: + self.logger.error(f"Error getting wine binary for prefix: {e}") + return "" + + def _install_components_protontricks_only(self, components: list, wineprefix: str, game_var: str, + status_callback: Optional[Callable[[str], None]] = None, + appid: Optional[str] = None) -> bool: + """Install all components using system protontricks only. appid can be passed in or extracted from wineprefix.""" + try: + self.logger.info(f"Installing all components with system protontricks: {components}") + from ..handlers.protontricks_handler import ProtontricksHandler + steamdeck = os.path.exists('/home/deck') + protontricks_handler = ProtontricksHandler(steamdeck, logger=self.logger) + resolved_appid = appid or self._extract_appid_from_wineprefix(wineprefix) + if not resolved_appid: + self.logger.error("Could not extract AppID from wineprefix for protontricks installation") + return False + self.logger.info(f"Using AppID {resolved_appid} for protontricks installation") + if not protontricks_handler.detect_protontricks(): + self.logger.error("Protontricks not available for component installation") + return False + components_list = ', '.join(components) + if status_callback: + status_callback(f"Installing Wine components via protontricks: {components_list}") + success = protontricks_handler.install_wine_components(resolved_appid, game_var, components) + if success: + self.logger.info("All components installed successfully with protontricks") + wine_binary = self._get_wine_binary_for_prefix(wineprefix) + self._set_windows_10_mode(wineprefix, wine_binary) + return True + self.logger.error("Component installation failed with protontricks") + return False + except Exception as e: + self.logger.error(f"Error installing components with protontricks: {e}", exc_info=True) + return False diff --git a/jackify/backend/handlers/winetricks_verification.py b/jackify/backend/handlers/winetricks_verification.py new file mode 100644 index 0000000..858d94d --- /dev/null +++ b/jackify/backend/handlers/winetricks_verification.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Winetricks verification mixin: component install verification. +Extracted from winetricks_handler for file-size and domain separation. +""" + +import os +import logging +from typing import List + + +class WinetricksVerificationMixin: + """Mixin providing verification of installed Wine components.""" + + def _verify_components_installed(self, wineprefix: str, components: List[str], env: dict) -> bool: + """Verify that every requested component was installed (winetricks.log).""" + try: + self.logger.info("Verifying installed components...") + winetricks_log = os.path.join(wineprefix, 'winetricks.log') + log_content = "" + if os.path.exists(winetricks_log): + try: + with open(winetricks_log, 'r', encoding='utf-8', errors='ignore') as f: + log_content = f.read().lower() + except Exception as e: + self.logger.error(f"Failed to read winetricks.log: {e}") + return False + self.logger.debug(f"winetricks.log length: {len(log_content)} bytes") + missing = [] + for component in components: + base_component = component.split('=')[0].lower() + if base_component in log_content or component.lower() in log_content: + continue + missing.append(component) + if missing: + self.logger.error(f"Components not verified installed: {missing}") + return False + self.logger.info("Verification passed - all components confirmed") + return True + except Exception as e: + self.logger.error(f"Error verifying components: {e}", exc_info=True) + return False diff --git a/jackify/backend/services/automated_prefix_creation.py b/jackify/backend/services/automated_prefix_creation.py new file mode 100644 index 0000000..8e05f0c --- /dev/null +++ b/jackify/backend/services/automated_prefix_creation.py @@ -0,0 +1,500 @@ +"""Prefix creation methods for AutomatedPrefixService (Mixin).""" +from pathlib import Path +from typing import Optional +import logging +import os +import time +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. + + First tries to find existing compatdata, then constructs path from libraryfolders.vdf + for creating new prefixes. + + Args: + appid: The AppID to get the path for + + Returns: + Path to the compatdata directory, or None if not found + """ + from ..handlers.path_handler import PathHandler + + # First, try to find existing compatdata + compatdata_path = PathHandler.find_compat_data(str(appid)) + if compatdata_path: + return compatdata_path + + # Prefix doesn't exist yet - determine where to create it from libraryfolders.vdf + library_paths = PathHandler.get_all_steam_library_paths() + if library_paths: + # Use the first library (typically the default library) + # Construct compatdata path: library_path/steamapps/compatdata/appid + first_library = library_paths[0] + compatdata_base = first_library / "steamapps" / "compatdata" + return compatdata_base / str(appid) + + # Only fallback if VDF parsing completely fails + logger.warning("Could not get library paths from libraryfolders.vdf, using fallback locations") + fallback_bases = [ + Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata", + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata", + Path.home() / ".steam/steam/steamapps/compatdata", + Path.home() / ".local/share/Steam/steamapps/compatdata", + ] + + for base_path in fallback_bases: + if base_path.is_dir(): + return base_path / str(appid) + + return None + + def verify_prefix_creation(self, prefix_path: Path) -> bool: + """ + Verify that the prefix was created successfully. + + Args: + prefix_path: Path to the prefix directory + + Returns: + True if prefix is valid, False otherwise + """ + try: + logger.info(f"Verifying prefix: {prefix_path}") + + # Check if prefix exists and has proper structure + if not prefix_path.exists(): + logger.error("Prefix directory does not exist") + return False + + pfx_dir = prefix_path / "pfx" + if not pfx_dir.exists(): + logger.error("Prefix exists but no pfx subdirectory") + return False + + # Check for key Wine files + system_reg = pfx_dir / "system.reg" + user_reg = pfx_dir / "user.reg" + drive_c = pfx_dir / "drive_c" + + if not system_reg.exists(): + logger.error("No system.reg found in prefix") + return False + + if not user_reg.exists(): + logger.error("No user.reg found in prefix") + return False + + if not drive_c.exists(): + logger.error("No drive_c directory found in prefix") + return False + + logger.info("Prefix structure verified successfully") + return True + + except Exception as e: + logger.error(f"Error verifying prefix: {e}") + return False + + def wait_for_prefix_completion(self, prefix_id: str, timeout: int = 60) -> bool: + """ + Wait for system.reg to stop growing (indicates prefix creation is complete). + + Args: + prefix_id: The Steam prefix ID to monitor + timeout: Maximum seconds to wait + + Returns: + True if prefix creation completed, False if timeout + """ + try: + prefix_path = Path.home() / f".local/share/Steam/steamapps/compatdata/{prefix_id}" + system_reg = prefix_path / "pfx/system.reg" + + logger.info(f"Monitoring prefix completion: {system_reg}") + + last_size = 0 + stable_count = 0 + + for i in range(timeout): + if system_reg.exists(): + current_size = system_reg.stat().st_size + logger.debug(f"system.reg size: {current_size} bytes") + + if current_size == last_size: + stable_count += 1 + if stable_count >= 3: # Stable for 3 seconds + logger.info(" system.reg size stable - prefix creation complete") + return True + else: + stable_count = 0 + last_size = current_size + + time.sleep(1) + + logger.warning(f"Timeout waiting for prefix completion after {timeout} seconds") + return False + + except Exception as e: + logger.error(f"Error monitoring prefix completion: {e}") + return False + + def create_prefix_with_proton_wrapper(self, appid: int) -> bool: + """ + Create a Proton prefix directly using Proton's wrapper and STEAM_COMPAT_DATA_PATH. + + Args: + appid: The AppID to create the prefix for + + Returns: + True if successful, False otherwise + """ + try: + # Determine Steam locations based on installation type + from ..handlers.path_handler import PathHandler + path_handler = PathHandler() + all_libraries = path_handler.get_all_steam_library_paths() + + # Check if we have Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths + is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries) + + if is_flatpak_steam and all_libraries: + # Flatpak Steam: Use the actual library root from libraryfolders.vdf + # Compatdata should be in the library root, not the client root + flatpak_library_root = all_libraries[0] # Use first library (typically the default) + flatpak_client_root = flatpak_library_root.parent.parent / ".steam/steam" + + if not flatpak_library_root.is_dir(): + logger.error( + f"Flatpak Steam library root does not exist: {flatpak_library_root}" + ) + return False + + steam_root = flatpak_client_root if flatpak_client_root.is_dir() else flatpak_library_root + # CRITICAL: compatdata must be in the library root, not client root + compatdata_dir = flatpak_library_root / "steamapps/compatdata" + proton_common_dir = flatpak_library_root / "steamapps/common" + else: + # Native Steam (or unknown): fall back to legacy ~/.steam/steam layout + steam_root = Path.home() / ".steam/steam" + compatdata_dir = steam_root / "steamapps/compatdata" + proton_common_dir = steam_root / "steamapps/common" + + # Ensure compatdata root exists and is a directory we actually want to use + if not compatdata_dir.is_dir(): + logger.error(f"Compatdata root does not exist: {compatdata_dir}. Aborting prefix creation.") + return False + + # Find a Proton wrapper to use + proton_path = self._find_proton_binary(proton_common_dir) + if not proton_path: + logger.error("No Proton wrapper found") + return False + + # Set up environment variables + env = os.environ.copy() + env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root) + env['STEAM_COMPAT_DATA_PATH'] = str(compatdata_dir / str(abs(appid))) + # Suppress GUI windows using jackify-engine's proven approach + env['DISPLAY'] = '' + env['WAYLAND_DISPLAY'] = '' + env['WINEDEBUG'] = '-all' + env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d' + + # Create the compatdata directory for this AppID (but never the whole tree) + compat_dir = compatdata_dir / str(abs(appid)) + compat_dir.mkdir(exist_ok=True) + + logger.info(f"Creating Proton prefix for AppID {appid}") + logger.info(f"STEAM_COMPAT_CLIENT_INSTALL_PATH={env['STEAM_COMPAT_CLIENT_INSTALL_PATH']}") + logger.info(f"STEAM_COMPAT_DATA_PATH={env['STEAM_COMPAT_DATA_PATH']}") + + # Run proton run wineboot -u to initialize the prefix + cmd = [str(proton_path), 'run', 'wineboot', '-u'] + logger.info(f"Running: {' '.join(cmd)}") + + # Adjust timeout for SD card installations on Steam Deck (slower I/O) + from ..services.platform_detection_service import PlatformDetectionService + platform_service = PlatformDetectionService.get_instance() + is_steamdeck_sdcard = (platform_service.is_steamdeck and + str(proton_path).startswith('/run/media/')) + timeout = 180 if is_steamdeck_sdcard else 60 + if is_steamdeck_sdcard: + logger.info(f"Using extended timeout ({timeout}s) for Steam Deck SD card Proton installation") + + # Use jackify-engine's approach: UseShellExecute=false, CreateNoWindow=true equivalent + result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout, + shell=False, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + logger.info(f"Proton exit code: {result.returncode}") + + if result.stdout: + logger.info(f"stdout: {result.stdout.strip()[:500]}") + if result.stderr: + logger.info(f"stderr: {result.stderr.strip()[:500]}") + + # Give a moment for files to land + time.sleep(3) + + # Check if prefix was created + pfx = compat_dir / 'pfx' + if pfx.exists(): + logger.info(f" Proton prefix created at: {pfx}") + return True + else: + logger.warning(f"Proton prefix not found at: {pfx}") + return False + + except subprocess.TimeoutExpired: + logger.warning("Proton timed out; prefix may still be initializing") + return False + except Exception as e: + logger.error(f"Error creating prefix: {e}") + return False + diff --git a/jackify/backend/services/automated_prefix_game_utils.py b/jackify/backend/services/automated_prefix_game_utils.py new file mode 100644 index 0000000..59a21da --- /dev/null +++ b/jackify/backend/services/automated_prefix_game_utils.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +Game utilities mixin for AutomatedPrefixService. + +Handles game-specific operations: +- Launch options generation +- Game detection +- User directory creation +- Proton version preferences +""" +import os +import logging +from pathlib import Path +from typing import Optional, List + +logger = logging.getLogger(__name__) + + +class GameUtilsMixin: + """Mixin for game-related utility operations""" + + def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]: + """ + Generate launch options for FNV/Enderal games that require vanilla compatdata. + + Args: + special_game_type: "fnv" or "enderal" + modlist_install_dir: Directory where the modlist is installed + + Returns: + Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed + """ + if not special_game_type or special_game_type not in ["fnv", "enderal"]: + return None + + logger.info(f"Generating {special_game_type.upper()} launch options") + + # Map game types to AppIDs + appid_map = {"fnv": "22380", "enderal": "976620"} + appid = appid_map[special_game_type] + + # Find vanilla game compatdata + from ..handlers.path_handler import PathHandler + compatdata_path = PathHandler.find_compat_data(appid) + if not compatdata_path: + logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})") + return None + + # Create STEAM_COMPAT_DATA_PATH string + compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"' + + # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist + compat_mounts_str = "" + try: + all_libs = PathHandler.get_all_steam_library_paths() + main_steam_lib_path_obj = PathHandler.find_steam_library() + if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": + main_steam_lib_path = main_steam_lib_path_obj.parent.parent + else: + main_steam_lib_path = main_steam_lib_path_obj + + mount_paths = [] + if main_steam_lib_path: + main_resolved = main_steam_lib_path.resolve() + for lib_path in all_libs: + if lib_path.resolve() != main_resolved: + mount_paths.append(str(lib_path.resolve())) + + if mount_paths: + mount_paths_str = ':'.join(mount_paths) + compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"' + logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}") + except Exception as e: + logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}") + + # Combine all launch options + launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip() + launch_options = ' '.join(launch_options.split()) # Clean up spacing + + logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}") + return launch_options + + def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]: + """Find a Steam game installation path by AppID and common names""" + import os + from pathlib import Path + + # Get Steam libraries from libraryfolders.vdf - check multiple possible locations + possible_config_paths = [ + Path.home() / ".steam/steam/config/libraryfolders.vdf", + Path.home() / ".local/share/Steam/config/libraryfolders.vdf", + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf" # Flatpak + ] + + steam_config_path = None + for path in possible_config_paths: + if path.exists(): + steam_config_path = path + break + + if not steam_config_path: + return None + + steam_libraries = [] + try: + with open(steam_config_path, 'r') as f: + content = f.read() + # Parse library paths from VDF + import re + library_matches = re.findall(r'"path"\s+"([^"]+)"', content) + steam_libraries = [Path(path) / "steamapps" / "common" for path in library_matches] + except Exception as e: + logger.warning(f"Failed to parse Steam library folders: {e}") + return None + + # Search for game in each library + for library_path in steam_libraries: + if not library_path.exists(): + continue + + # Check manifest file first (more reliable) + manifest_path = library_path.parent / "appmanifest_{}.acf".format(app_id) + if manifest_path.exists(): + try: + with open(manifest_path, 'r') as f: + content = f.read() + install_dir_match = re.search(r'"installdir"\s+"([^"]+)"', content) + if install_dir_match: + game_path = library_path / install_dir_match.group(1) + if game_path.exists(): + return str(game_path) + except Exception: + pass + + # Fallback: check common folder names + for name in common_names: + game_path = library_path / name + if game_path.exists(): + return str(game_path) + + return None + + def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str): + """ + Pre-create game-specific user directories to prevent first-launch issues. + + Creates both My Documents/My Games and AppData/Local directories for the game. + This prevents issues where games fail to create these on first launch under Proton. + """ + # Map game types to their directory names + game_dir_names = { + "skyrim": "Skyrim Special Edition", + "fnv": "FalloutNV", + "fo4": "Fallout4", + "oblivion": "Oblivion", + "oblivion_remastered": "Oblivion Remastered", + "enderal": "Enderal Special Edition", + "starfield": "Starfield" + } + + # Get the directory name for this game type + game_dir_name = game_dir_names.get(special_game_type) + if not game_dir_name: + logger.debug(f"No user directory mapping for game type: {special_game_type}") + return + + base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser") + + directories_to_create = [ + os.path.join(base_path, "Documents", "My Games", game_dir_name), + os.path.join(base_path, "AppData", "Local", game_dir_name) + ] + + created_count = 0 + for directory in directories_to_create: + try: + os.makedirs(directory, exist_ok=True) + logger.info(f"Created user directory: {directory}") + created_count += 1 + except Exception as e: + logger.warning(f"Failed to create directory {directory}: {e}") + + if created_count > 0: + logger.info(f"Created {created_count} user directories for {game_dir_name}") + + def _get_lorerim_preferred_proton(self): + """Get Lorerim's preferred Proton 9 version with specific priority order""" + try: + from jackify.backend.handlers.wine_utils import WineUtils + + # Get all available Proton versions + available_versions = WineUtils.scan_all_proton_versions() + + if not available_versions: + logger.warning("No Proton versions found for Lorerim override") + return None + + # Priority order for Lorerim: + # 1. GEProton9-27 (specific version) + # 2. Other GEProton-9 versions (latest first) + # 3. Valve Proton 9 (any version) + + preferred_candidates = [] + + for version in available_versions: + version_name = version['name'] + + # Priority 1: GEProton9-27 specifically + if version_name == 'GE-Proton9-27': + logger.info(f"Lorerim: Found preferred GE-Proton9-27") + return version_name + + # Priority 2: Other GE-Proton 9 versions + elif version_name.startswith('GE-Proton9-'): + preferred_candidates.append(('ge_proton_9', version_name, version)) + + # Priority 3: Valve Proton 9 + elif 'Proton 9' in version_name: + preferred_candidates.append(('valve_proton_9', version_name, version)) + + # Return best candidate if any found + if preferred_candidates: + # Sort by priority (GE-Proton first, then by name for latest) + preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) + best_candidate = preferred_candidates[0] + logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option") + return best_candidate[1] + + logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings") + return None + + except Exception as e: + logger.error(f"Error detecting Lorerim Proton preference: {e}") + return None + + def _store_proton_override_notification(self, modlist_name: str, proton_version: str): + """Store Proton override information for end-of-install notification""" + try: + # Store override info for later display + if not hasattr(self, '_proton_overrides'): + self._proton_overrides = [] + + self._proton_overrides.append({ + 'modlist': modlist_name, + 'proton_version': proton_version, + 'reason': f'{modlist_name} requires Proton 9 for optimal compatibility' + }) + + logger.debug(f"Stored Proton override notification: {modlist_name} → {proton_version}") + + except Exception as e: + logger.error(f"Failed to store Proton override notification: {e}") + + def _show_proton_override_notification(self, progress_callback=None): + """Display any Proton override notifications to the user""" + try: + if hasattr(self, '_proton_overrides') and self._proton_overrides: + for override in self._proton_overrides: + notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility" + + if progress_callback: + progress_callback("") + progress_callback(f"{self._get_progress_timestamp()} {notification_msg}") + + logger.info(notification_msg) + + # Clear notifications after display + self._proton_overrides = [] + + except Exception as e: + logger.error(f"Failed to show Proton override notification: {e}") + diff --git a/jackify/backend/services/automated_prefix_proton.py b/jackify/backend/services/automated_prefix_proton.py new file mode 100644 index 0000000..50d8a60 --- /dev/null +++ b/jackify/backend/services/automated_prefix_proton.py @@ -0,0 +1,673 @@ +"""Proton/compatibility tool methods for AutomatedPrefixService (Mixin).""" +from pathlib import Path +from typing import Optional +import logging +import os +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.""" + + def _get_user_proton_version(self, modlist_name: str = None): + """Get user's preferred Proton version from config, with fallback to auto-detection + + Args: + modlist_name: Optional modlist name for special handling (e.g., Lorerim) + """ + try: + from jackify.backend.handlers.config_handler import ConfigHandler + from jackify.backend.handlers.wine_utils import WineUtils + + # Check for Lorerim-specific Proton override first + modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else "" + if modlist_normalized == 'lorerim': + lorerim_proton = self._get_lorerim_preferred_proton() + if lorerim_proton: + logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings") + self._store_proton_override_notification("Lorerim", lorerim_proton) + return lorerim_proton + + # Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility) + if modlist_normalized == 'lostlegacy': + lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim + if lostlegacy_proton: + logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)") + self._store_proton_override_notification("Lost Legacy", lostlegacy_proton) + return lostlegacy_proton + + config_handler = ConfigHandler() + user_proton_path = config_handler.get_game_proton_path() + + if not user_proton_path or user_proton_path == 'auto': + logger.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence") + best = WineUtils.select_best_proton() + if best: + compat_name = best.get('steam_compat_name') or WineUtils.resolve_steam_compat_name(best['path']) + if compat_name: + logger.info(f"Auto-detected Proton: {compat_name}") + return compat_name + return "proton_experimental" + else: + # Resolve the actual Steam internal name from the Proton installation + resolved = WineUtils.resolve_steam_compat_name(user_proton_path) + if resolved: + logger.info(f"Using user-selected Proton: {resolved}") + return resolved + + # Fallback for Proton installations without compatibilitytool.vdf + logger.warning(f"Could not resolve compat name for '{user_proton_path}', using basename") + proton_version = os.path.basename(user_proton_path) + if proton_version.startswith('GE-Proton'): + return proton_version + steam_proton_name = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_') + if not steam_proton_name.startswith('proton'): + steam_proton_name = f"proton_{steam_proton_name}" + logger.info(f"Using fallback Proton name: {steam_proton_name}") + return steam_proton_name + + except Exception as e: + logger.error(f"Failed to get user Proton preference, using default: {e}") + return "proton_experimental" + + def find_proton_experimental(self) -> Optional[Path]: + """ + Find Proton Experimental installation. + + Returns: + Path to Proton Experimental, or None if not found + """ + proton_paths = [ + Path.home() / ".local/share/Steam/steamapps/common/Proton - Experimental", + Path.home() / ".steam/steam/steamapps/common/Proton - Experimental", + Path.home() / ".local/share/Steam/steamapps/common/Proton Experimental", + Path.home() / ".steam/steam/steamapps/common/Proton Experimental", + ] + + for path in proton_paths: + if path.exists(): + logger.info(f"Found Proton Experimental at: {path}") + return path + + logger.error("Proton Experimental not found") + return None + + def check_shortcut_proton_version(self, shortcut_name: str): + """ + Check if the shortcut has the Proton version set correctly. + + Args: + shortcut_name: Name of the shortcut to check + """ + # 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") + + def set_proton_version_for_shortcut(self, appid: int, proton_version: str) -> bool: + """ + Set the Proton version for a shortcut in config.vdf. + + Args: + appid: The AppID of the shortcut (negative for non-Steam shortcuts) + proton_version: The Proton version to set (e.g., 'proton_experimental') + + Returns: + True if successful, False otherwise + """ + try: + # Get the config.vdf path + config_path = self._get_config_path() + if not config_path: + logger.error("No config.vdf path found") + return False + + # Read current config (config.vdf is text format) + with open(config_path, 'r') as f: + config_data = vdf.load(f) + + # Navigate to the correct location in the VDF structure + if 'Software' not in config_data: + config_data['Software'] = {} + if 'Valve' not in config_data['Software']: + config_data['Software']['Valve'] = {} + if 'Steam' not in config_data['Software']['Valve']: + config_data['Software']['Valve']['Steam'] = {} + + # Get or create CompatToolMapping + if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']: + config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {} + + # Set the Proton version for this AppID using Steam's expected format + # Steam requires a dict with 'name', 'config', and 'priority' keys + config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = { + 'name': proton_version, + 'config': '', + 'priority': '250' + } + + # Write back to file (text format) + with open(config_path, 'w') as f: + vdf.dump(config_data, f) + + # Ensure file is fully written to disk before Steam restart + import os + 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") + + # Small delay to ensure filesystem write completes + import time + time.sleep(0.5) + + # Verify it was set correctly + 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}") + + return True + + except Exception as e: + logger.error(f"Error setting Proton version: {e}") + return False + + def set_compatool_on_shortcut(self, shortcut_name: str) -> bool: + """ + Set CompatTool on a shortcut immediately after STL creation. + This is CRITICAL to ensure the batch file shortcut has Proton set + so it can create a prefix when launched. + + Args: + shortcut_name: Name of the shortcut to modify + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name == name: + # Check current CompatTool setting + current_compat = shortcut.get('CompatTool', 'NOT_SET') + logger.info(f"Found shortcut '{name}' with CompatTool: '{current_compat}'") + + # Set CompatTool to ensure batch file can create prefix + shortcut['CompatTool'] = 'proton_experimental' + logger.info(f" Set CompatTool=proton_experimental on shortcut: {name}") + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + return True + + logger.error(f"Shortcut '{shortcut_name}' not found for CompatTool setting") + return False + + except Exception as e: + logger.error(f"Error setting CompatTool on shortcut: {e}") + return False + + def _set_proton_on_shortcut(self, shortcut_name: str) -> bool: + """ + Set Proton Experimental on a shortcut by name. + + Args: + shortcut_name: Name of the shortcut to modify + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name == name: + # Set CompatTool + shortcut['CompatTool'] = 'proton_experimental' + logger.info(f"Set CompatTool=proton_experimental on shortcut: {name}") + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + return True + + logger.error(f"Shortcut '{shortcut_name}' not found for Proton setting") + return False + + except Exception as e: + logger.error(f"Error setting Proton on shortcut: {e}") + return False + + def set_compatibility_tool_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool: + """ + Set compatibility tool using STL's exact method. + + This adds an entry to config.vdf's CompatToolMapping section using the unsigned AppID as the key, + exactly like STL does. + + Args: + unsigned_appid: The unsigned AppID (Grid ID) to use as the key + compat_tool: The compatibility tool name (e.g., 'proton_experimental') + + Returns: + True if successful, False otherwise + """ + try: + config_path = self._get_config_path() + if not config_path: + logger.error("No config.vdf path found") + return False + + # Read current config (config.vdf is text format) + with open(config_path, 'r') as f: + config_data = vdf.load(f) + + # Navigate to the correct location in the VDF structure + if 'Software' not in config_data: + config_data['Software'] = {} + if 'Valve' not in config_data['Software']: + config_data['Software']['Valve'] = {} + if 'Steam' not in config_data['Software']['Valve']: + config_data['Software']['Valve']['Steam'] = {} + + # Get or create CompatToolMapping + if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']: + config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {} + + # Create the compatibility tool entry exactly like STL does + compat_entry = { + 'name': compat_tool, + 'config': '', + 'priority': '250' + } + + # Set the compatibility tool for this AppID (using unsigned AppID as key) + 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}") + + # 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}") + + return True + + except Exception as e: + logger.error(f"Error setting compatibility tool STL-style: {e}") + return False + + def set_compatibility_tool_complete_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool: + """ + Set compatibility tool using STL's complete method with direct text manipulation. + + This replicates STL's approach by using direct text manipulation instead of VDF libraries + to preserve existing entries in both config.vdf and localconfig.vdf. + + Args: + unsigned_appid: The unsigned AppID (Grid ID) to use as the key + compat_tool: The compatibility tool name (e.g., 'proton_experimental') + + Returns: + True if successful, False otherwise + """ + try: + # Step 1: Update config.vdf using direct text manipulation (like STL does) + config_path = self._get_config_path() + if not config_path: + logger.error("No config.vdf path found") + return False + + # Read the entire file as text + with open(config_path, 'r') as f: + lines = f.readlines() + + # Find the CompatToolMapping section + compat_section_start = None + compat_section_end = None + for i, line in enumerate(lines): + if '"CompatToolMapping"' in line.strip(): + compat_section_start = i + # Find the end of the CompatToolMapping section + brace_count = 0 + for j in range(i + 1, len(lines)): + if '{' in lines[j]: + brace_count += 1 + if '}' in lines[j]: + brace_count -= 1 + if brace_count == 0: + compat_section_end = j + break + break + + if compat_section_start is None: + logger.error("CompatToolMapping section not found in config.vdf") + return False + + # Check if our AppID entry already exists + appid_entry_start = None + appid_entry_end = None + for i in range(compat_section_start, compat_section_end + 1): + if f'"{unsigned_appid}"' in lines[i]: + appid_entry_start = i + # Find the end of this AppID entry + brace_count = 0 + for j in range(i + 1, compat_section_end + 1): + if '{' in lines[j]: + brace_count += 1 + if '}' in lines[j]: + brace_count -= 1 + if brace_count == 0: + appid_entry_end = j + break + break + + # Create the new entry in Steam's exact format + new_entry_lines = [ + f'\t\t\t\t\t\t\t\t\t"{unsigned_appid}"\n', + f'\t\t\t\t\t\t\t\t\t{{\n', + f'\t\t\t\t\t\t\t\t\t\t"name"\t\t\t\t"{compat_tool}"\n', + f'\t\t\t\t\t\t\t\t\t\t"config"\t\t\t\t\t""\n', + f'\t\t\t\t\t\t\t\t\t\t"priority"\t\t\t\t\t"250"\n', + f'\t\t\t\t\t\t\t\t\t}}\n' + ] + + if appid_entry_start is None: + # AppID entry doesn't exist, add it before the closing brace of CompatToolMapping + lines.insert(compat_section_end, ''.join(new_entry_lines)) + else: + # AppID entry exists, replace it + del lines[appid_entry_start:appid_entry_end + 1] + lines.insert(appid_entry_start, ''.join(new_entry_lines)) + + # Write the updated file back + with open(config_path, 'w') as f: + f.writelines(lines) + + logger.info(f"Updated config.vdf: AppID {unsigned_appid} -> {compat_tool}") + + # Step 2: Update localconfig.vdf using direct text manipulation (like STL) + localconfig_path = self._get_localconfig_path() + if not localconfig_path: + logger.error("No localconfig.vdf path found") + return False + + # Calculate signed AppID (like STL does) + signed_appid = (unsigned_appid | 0x80000000) & 0xFFFFFFFF + # Convert to signed 32-bit integer + import ctypes + signed_appid_int = ctypes.c_int32(signed_appid).value + + # Read the entire file as text + with open(localconfig_path, 'r') as f: + lines = f.readlines() + + # Check if Apps section exists + apps_section_start = None + apps_section_end = None + for i, line in enumerate(lines): + if line.strip() == '"Apps"': + apps_section_start = i + # Find the end of the Apps section + brace_count = 0 + for j in range(i + 1, len(lines)): + if '{' in lines[j]: + brace_count += 1 + if '}' in lines[j]: + brace_count -= 1 + if brace_count == 0: + apps_section_end = j + break + break + + # If Apps section doesn't exist, create it at the end of the file + if apps_section_start is None: + logger.info("Apps section not found, creating it at the end of the file") + + # Find the last closing brace (before the final closing brace) + last_brace_pos = None + for i in range(len(lines) - 1, -1, -1): + if lines[i].strip() == '}': + last_brace_pos = i + break + + if last_brace_pos is None: + logger.error("Could not find closing brace in localconfig.vdf") + return False + + # Insert Apps section before the last closing brace + apps_section = [ + ' "Apps"\n', + ' {\n', + f' "{signed_appid_int}"\n', + ' {\n', + ' "OverlayAppEnable" "1"\n', + ' "DisableLaunchInVR" "1"\n', + ' }\n', + ' }\n' + ] + + lines.insert(last_brace_pos, ''.join(apps_section)) + + else: + # Apps section exists, check if our AppID entry exists + appid_entry_start = None + appid_entry_end = None + for i in range(apps_section_start, apps_section_end + 1): + if f'"{signed_appid_int}"' in lines[i]: + appid_entry_start = i + # Find the end of this AppID entry + brace_count = 0 + for j in range(i + 1, apps_section_end + 1): + if '{' in lines[j]: + brace_count += 1 + if '}' in lines[j]: + brace_count -= 1 + if brace_count == 0: + appid_entry_end = j + break + break + + if appid_entry_start is None: + # AppID entry doesn't exist, add it to the Apps section + logger.info(f"AppID {signed_appid_int} entry not found, adding it to Apps section") + + # Insert before the closing brace of the Apps section + appid_entry = [ + f' "{signed_appid_int}"\n', + ' {\n', + ' "OverlayAppEnable" "1"\n', + ' "DisableLaunchInVR" "1"\n', + ' }\n' + ] + + lines.insert(apps_section_end, ''.join(appid_entry)) + + else: + # AppID entry exists, update the values + logger.info(f"AppID {signed_appid_int} entry exists, updating values") + + # Check if the values already exist and update them + overlay_found = False + vr_found = False + + for i in range(appid_entry_start, appid_entry_end + 1): + if '"OverlayAppEnable"' in lines[i]: + lines[i] = ' "OverlayAppEnable" "1"\n' + overlay_found = True + elif '"DisableLaunchInVR"' in lines[i]: + lines[i] = ' "DisableLaunchInVR" "1"\n' + vr_found = True + + # Add missing values + if not overlay_found or not vr_found: + # Find the position to insert (before the closing brace of the AppID entry) + insert_pos = appid_entry_end + for i in range(appid_entry_start, appid_entry_end + 1): + if lines[i].strip() == '}': + insert_pos = i + break + + new_values = [] + if not overlay_found: + new_values.append(' "OverlayAppEnable" "1"\n') + if not vr_found: + new_values.append(' "DisableLaunchInVR" "1"\n') + + for value in new_values: + lines.insert(insert_pos, value) + + # Write the updated file back + with open(localconfig_path, 'w') as f: + 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") + + return True + + except Exception as e: + logger.error(f"Error setting compatibility tool complete STL-style: {e}") + return False + + def verify_compatibility_tool_persists(self, appid: int) -> bool: + """ + Verify that the compatibility tool setting persists with correct Proton version. + + Args: + appid: The AppID to check + + Returns: + True if compatibility tool is correctly set, False otherwise + """ + try: + config_path = Path.home() / ".steam/steam/config/config.vdf" + if not config_path.exists(): + logger.warning("Steam config.vdf not found") + return False + + with open(config_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Check if AppID exists and has a Proton version set + if f'"{appid}"' in content: + # Get the expected Proton version + expected_proton = self._get_user_proton_version() + + # Look for the Proton version in the compatibility tool mapping + if expected_proton in content: + logger.info(f" Compatibility tool persists: {expected_proton}") + return True + else: + logger.warning(f"AppID {appid} found but Proton version '{expected_proton}' not set") + return False + else: + logger.warning("Compatibility tool not found") + return False + + except Exception as e: + logger.error(f"Error verifying compatibility tool: {e}") + return False + + def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]: + """Locate a Proton wrapper script to use, respecting user's configuration.""" + try: + from jackify.backend.handlers.config_handler import ConfigHandler + from jackify.backend.handlers.wine_utils import WineUtils + + config = ConfigHandler() + user_proton_path = config.get_game_proton_path() + + # If user selected a specific Proton, try that first + if user_proton_path != 'auto': + # Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam + resolved_proton_path = os.path.realpath(user_proton_path) + + # Check for wine binary in different Proton structures + valve_proton_wine = Path(resolved_proton_path) / "dist" / "bin" / "wine" + ge_proton_wine = Path(resolved_proton_path) / "files" / "bin" / "wine" + + if valve_proton_wine.exists() or ge_proton_wine.exists(): + # Found user's Proton, now find the proton wrapper script + proton_wrapper = Path(resolved_proton_path) / "proton" + if proton_wrapper.exists(): + logger.info(f"Using user-selected Proton wrapper: {proton_wrapper}") + return proton_wrapper + else: + logger.warning(f"User-selected Proton missing wrapper script: {proton_wrapper}") + else: + logger.warning(f"User-selected Proton path invalid: {user_proton_path}") + + # Fall back to auto-detection + logger.info("Falling back to automatic Proton detection") + candidates = [] + preferred = [ + "Proton - Experimental", + "Proton 9.0", + "Proton 8.0", + "Proton Hotfix", + ] + + for name in preferred: + p = proton_common_dir / name / "proton" + if p.exists(): + candidates.append(p) + + # As a fallback, scan all Proton* dirs + if not candidates and proton_common_dir.exists(): + for p in proton_common_dir.glob("Proton*/proton"): + candidates.append(p) + + if not candidates: + logger.error("No Proton wrapper found under steamapps/common") + return None + + logger.info(f"Using auto-detected Proton wrapper: {candidates[0]}") + return candidates[0] + + except Exception as e: + logger.error(f"Error finding Proton binary: {e}") + return None + diff --git a/jackify/backend/services/automated_prefix_registry.py b/jackify/backend/services/automated_prefix_registry.py new file mode 100644 index 0000000..cf6487d --- /dev/null +++ b/jackify/backend/services/automated_prefix_registry.py @@ -0,0 +1,276 @@ +"""Registry operations mixin for AutomatedPrefixService.""" +import os +import subprocess +import logging +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +class RegistryOperationsMixin: + """Mixin providing Wine/Proton registry operations.""" + + def _update_registry_path(self, system_reg_path: str, section_name: str, path_key: str, new_path: str) -> bool: + """Update a specific path value in Wine registry, preserving other entries""" + if not os.path.exists(system_reg_path): + return False + + try: + # Read existing content + with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + in_target_section = False + path_updated = False + + # Determine Wine drive letter based on SD card detection + from jackify.backend.handlers.filesystem_handler import FileSystemHandler + from jackify.backend.handlers.path_handler import PathHandler + + linux_path = Path(new_path) + + if FileSystemHandler.is_sd_card(linux_path): + # SD card paths use D: drive + # Strip SD card prefix using the same method as other handlers + relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path) + wine_path = relative_sd_path_str.replace('/', '\\\\') + wine_drive = "D:" + logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}") + else: + # Regular paths use Z: drive with full path + wine_path = new_path.strip('/').replace('/', '\\\\') + wine_drive = "Z:" + logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}") + + # Update existing path if found + for i, line in enumerate(lines): + stripped_line = line.strip() + # Case-insensitive comparison for section name (Wine registry is case-insensitive) + if stripped_line.split(']')[0].lower() + ']' == section_name.lower() if ']' in stripped_line else stripped_line.lower() == section_name.lower(): + in_target_section = True + elif stripped_line.startswith('[') and in_target_section: + in_target_section = False + elif in_target_section and f'"{path_key}"' in line: + lines[i] = f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n' # Add trailing backslashes + path_updated = True + break + + # Add new section if path wasn't updated + if not path_updated: + lines.append(f'\n{section_name}\n') + lines.append(f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n') # Add trailing backslashes + + # Write updated content + with open(system_reg_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return True + + except Exception as e: + logger.error(f"Failed to update registry path: {e}") + return False + + def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str): + """Apply universal dotnet4.x compatibility registry fixes to ALL modlists""" + try: + prefix_path = os.path.join(modlist_compatdata_path, "pfx") + if not os.path.exists(prefix_path): + logger.warning(f"Prefix path not found: {prefix_path}") + return False + + logger.info("Applying universal dotnet4.x compatibility registry fixes...") + + # Find the appropriate Wine binary to use for registry operations + 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") + return False + + # Set environment for Wine registry operations + env = os.environ.copy() + env['WINEPREFIX'] = prefix_path + env['WINEDEBUG'] = '-all' # Suppress Wine debug output + + # 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' + ] + + 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") + 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 + + except Exception as e: + logger.error(f"Failed to apply universal dotnet4.x fixes: {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: + from ..handlers.config_handler import ConfigHandler + from ..handlers.wine_utils import WineUtils + + # Method 1: Use the user's configured Proton version from settings + config_handler = ConfigHandler() + user_proton_path = config_handler.get_game_proton_path() + + if user_proton_path and user_proton_path != 'auto': + # User has selected a specific Proton version + proton_path = Path(user_proton_path).expanduser() + + # Check for wine binary in both GE-Proton and Valve Proton structures + wine_candidates = [ + proton_path / "files" / "bin" / "wine", # GE-Proton structure + proton_path / "dist" / "bin" / "wine" # Valve Proton structure + ] + + for wine_path in wine_candidates: + if wine_path.exists() and wine_path.is_file(): + logger.info(f"Using Wine binary from user's configured Proton: {wine_path}") + return str(wine_path) + + # Wine binary not found at expected paths - search recursively in Proton directory + logger.debug(f"Wine binary not found at expected paths in {proton_path}, searching recursively...") + wine_binary = self._search_wine_in_proton_directory(proton_path) + if wine_binary: + logger.info(f"Found Wine binary via recursive search in Proton directory: {wine_binary}") + return wine_binary + + logger.warning(f"User's configured Proton path has no wine binary: {user_proton_path}") + + # Method 2: Fallback to auto-detection using WineUtils + best_proton = WineUtils.select_best_proton() + if best_proton: + wine_binary = WineUtils.find_proton_binary(best_proton['name']) + if wine_binary: + logger.info(f"Using Wine binary from detected Proton: {wine_binary}") + return wine_binary + + # NEVER fall back to system wine - it will break Proton prefixes with architecture mismatches + logger.error("No suitable Proton Wine binary found for registry operations") + return None + + except Exception as e: + logger.error(f"Error finding Wine binary: {e}") + return None + + def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]: + """ + Recursively search for wine binary within a Proton directory. + This handles cases where the directory structure might differ between Proton versions. + + Args: + proton_path: Path to the Proton directory to search + + Returns: + Path to wine binary if found, None otherwise + """ + try: + if not proton_path.exists() or not proton_path.is_dir(): + return None + + # Search for 'wine' executable (not 'wine64' or 'wine-preloader') + # Limit search depth to avoid scanning entire filesystem + max_depth = 5 + for root, dirs, files in os.walk(proton_path, followlinks=False): + # Calculate depth relative to proton_path + try: + depth = len(Path(root).relative_to(proton_path).parts) + except ValueError: + # Path is not relative to proton_path (shouldn't happen, but be safe) + continue + + if depth > max_depth: + dirs.clear() # Don't descend further + continue + + # Check if 'wine' is in this directory + if 'wine' in files: + wine_path = Path(root) / 'wine' + # Verify it's actually an executable file + if wine_path.is_file() and os.access(wine_path, os.X_OK): + logger.debug(f"Found wine binary at: {wine_path}") + return str(wine_path) + + return None + except Exception as e: + logger.debug(f"Error during recursive wine search in {proton_path}: {e}") + return None + + 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""" + 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") + return + + 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" + }, + "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" + } + } + + # Detect and inject each game + 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}") + success = self._update_registry_path( + system_reg_path, + config["registry_section"], + config["path_key"], + game_path + ) + if success: + logger.info(f"Updated registry entry for {config['name']}") + 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 036c756..cbed67b 100644 --- a/jackify/backend/services/automated_prefix_service.py +++ b/jackify/backend/services/automated_prefix_service.py @@ -17,13 +17,21 @@ import vdf logger = logging.getLogger(__name__) def debug_print(message): - """Print debug message only if debug mode is enabled""" + """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): - print(message) + logger.debug(message) -class AutomatedPrefixService: +from .automated_prefix_shortcuts import ShortcutOperationsMixin +from .automated_prefix_proton import ProtonOperationsMixin +from .automated_prefix_creation import PrefixCreationMixin +from .automated_prefix_stl import STLAlgorithmMixin +from .automated_prefix_workflow import WorkflowMixin +from .automated_prefix_registry import RegistryOperationsMixin +from .automated_prefix_game_utils import GameUtilsMixin + +class AutomatedPrefixService(ShortcutOperationsMixin, ProtonOperationsMixin, PrefixCreationMixin, STLAlgorithmMixin, WorkflowMixin, RegistryOperationsMixin, GameUtilsMixin): """ Service for automated Proton prefix creation using temporary batch files and direct Proton wrapper integration. @@ -41,271 +49,6 @@ class AutomatedPrefixService: from jackify.shared.timing import get_timestamp return get_timestamp() - def _get_user_proton_version(self): - """Get user's preferred Proton version from config, with fallback to auto-detection.""" - try: - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.handlers.wine_utils import WineUtils - - config_handler = ConfigHandler() - user_proton_path = config_handler.get_game_proton_path() - - if not user_proton_path or user_proton_path == 'auto': - logger.info("User selected auto-detect, using GE-Proton -> Experimental -> Proton precedence") - best = WineUtils.select_best_proton() - if best: - return best.get('steam_compat_name') or WineUtils.resolve_steam_compat_name(best['path']) - return "proton_experimental" - else: - steam_proton_name = WineUtils.resolve_steam_compat_name(user_proton_path) - if steam_proton_name: - logger.info(f"Using user-selected Proton: {steam_proton_name}") - return steam_proton_name - - logger.warning(f"Could not resolve compat name for '{user_proton_path}', falling back to auto") - best = WineUtils.select_best_proton() - if best: - return best.get('steam_compat_name') or WineUtils.resolve_steam_compat_name(best['path']) - return "proton_experimental" - - except Exception as e: - logger.error(f"Failed to get user Proton preference, using default: {e}") - return "proton_experimental" - - - def create_shortcut_with_native_service(self, shortcut_name: str, exe_path: str, - modlist_install_dir: str, custom_launch_options: str = None) -> Tuple[bool, Optional[int]]: - """ - Create a Steam shortcut using the native Steam service (no STL). - - Args: - shortcut_name: Name for the shortcut - exe_path: Path to the executable - modlist_install_dir: Directory where the modlist is installed - custom_launch_options: Pre-generated launch options (overrides default generation) - - Returns: - (success, unsigned_app_id) - """ - logger.info(f"Creating shortcut with native service: {shortcut_name}") - - try: - from ..services.native_steam_service import NativeSteamService - - # Initialize native Steam service - steam_service = NativeSteamService() - - # Use custom launch options if provided, otherwise generate default - if custom_launch_options: - launch_options = custom_launch_options - logger.info(f"Using pre-generated launch options: {launch_options}") - else: - # Generate STEAM_COMPAT_MOUNTS launch option for compatibility - launch_options = "%command%" - try: - from ..handlers.path_handler import PathHandler - path_handler = PathHandler() - - all_libs = path_handler.get_all_steam_library_paths() - main_steam_lib_path_obj = path_handler.find_steam_library() - if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": - main_steam_lib_path = main_steam_lib_path_obj.parent.parent - - filtered_libs = [lib for lib in all_libs if str(lib) != str(main_steam_lib_path)] - if filtered_libs: - mount_paths = ":".join(str(lib) for lib in filtered_libs) - launch_options = f'STEAM_COMPAT_MOUNTS="{mount_paths}" %command%' - logger.info(f"Generated launch options with mounts: {launch_options}") - except Exception as e: - logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}") - launch_options = "%command%" - - proton_version = self._get_user_proton_version() - - # Create shortcut with Proton using native service - success, app_id = steam_service.create_shortcut_with_proton( - app_name=shortcut_name, - exe_path=exe_path, - start_dir=modlist_install_dir, - launch_options=launch_options, - tags=["Jackify"], - proton_version=proton_version - ) - - if success and app_id: - logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}") - return True, app_id - else: - logger.error("Native Steam service failed to create shortcut") - return False, None - - except Exception as e: - logger.error(f"Error creating shortcut with native service: {e}") - return False, None - - def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]: - """ - Generate launch options for FNV/Enderal games that require vanilla compatdata. - - Args: - special_game_type: "fnv" or "enderal" - modlist_install_dir: Directory where the modlist is installed - - Returns: - Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed - """ - if not special_game_type or special_game_type not in ["fnv", "enderal"]: - return None - - logger.info(f"Generating {special_game_type.upper()} launch options") - - # Map game types to AppIDs - appid_map = {"fnv": "22380", "enderal": "976620"} - appid = appid_map[special_game_type] - - # Find vanilla game compatdata - from ..handlers.path_handler import PathHandler - compatdata_path = PathHandler.find_compat_data(appid) - if not compatdata_path: - logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})") - return None - - # Create STEAM_COMPAT_DATA_PATH string - compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"' - - # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist - compat_mounts_str = "" - try: - all_libs = PathHandler.get_all_steam_library_paths() - main_steam_lib_path_obj = PathHandler.find_steam_library() - if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": - main_steam_lib_path = main_steam_lib_path_obj.parent.parent - else: - main_steam_lib_path = main_steam_lib_path_obj - - mount_paths = [] - if main_steam_lib_path: - main_resolved = main_steam_lib_path.resolve() - for lib_path in all_libs: - if lib_path.resolve() != main_resolved: - mount_paths.append(str(lib_path.resolve())) - - if mount_paths: - mount_paths_str = ':'.join(mount_paths) - compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"' - logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}") - except Exception as e: - logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}") - - # Combine all launch options - launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip() - launch_options = ' '.join(launch_options.split()) # Clean up spacing - - logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}") - return launch_options - - def check_shortcut_proton_version(self, shortcut_name: str): - """ - Check if the shortcut has the Proton version set correctly. - - Args: - shortcut_name: Name of the shortcut to check - """ - # 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") - - - def handle_existing_shortcut_conflict(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Union[bool, List[Dict]]: - """ - Check for existing shortcut with same name and path, prompt user if found. - - Args: - shortcut_name: Name of the shortcut to create - exe_path: Path to the executable - modlist_install_dir: Directory where the modlist is installed - - Returns: - True if we should proceed (no conflict or user chose to replace), False if user cancelled - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return True # No shortcuts file, no conflict - - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - conflicts = [] - - # Look for shortcuts with the same name AND path - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - shortcut_exe = shortcut.get('Exe', '').strip('"') # Remove quotes - shortcut_startdir = shortcut.get('StartDir', '').strip('"') # Remove quotes - - # Check if name matches AND (exe path matches OR startdir matches) - # Use exact name match instead of partial match to avoid false positives - name_matches = shortcut_name == name - exe_matches = shortcut_exe == exe_path - startdir_matches = shortcut_startdir == modlist_install_dir - - if (name_matches and (exe_matches or startdir_matches)): - conflicts.append({ - 'index': i, - 'name': name, - 'exe': shortcut_exe, - 'startdir': shortcut_startdir - }) - - if conflicts: - logger.warning(f"Found {len(conflicts)} existing shortcut(s) with same name and path") - - # Log details about each conflict for debugging - for i, conflict in enumerate(conflicts): - logger.info(f"Conflict {i+1}: Name='{conflict['name']}', Exe='{conflict['exe']}', StartDir='{conflict['startdir']}'") - - # Return the conflict information so the frontend can handle it - return conflicts - else: - logger.debug("No conflicting shortcuts found") - return True - - except Exception as e: - logger.error(f"Error handling shortcut conflict: {e}") - return True # Proceed on error to avoid blocking - - def format_conflict_message(self, conflicts: List[Dict]) -> str: - """ - Format conflict information into a user-friendly message. - - Args: - conflicts: List of conflict dictionaries from handle_existing_shortcut_conflict - - Returns: - Formatted message for the user - """ - if not conflicts: - return "No conflicts found." - - message = f"Found {len(conflicts)} existing Steam shortcut(s) with the same name and path:\n\n" - - for i, conflict in enumerate(conflicts, 1): - message += f"{i}. **Name:** {conflict['name']}\n" - message += f" **Executable:** {conflict['exe']}\n" - message += f" **Start Directory:** {conflict['startdir']}\n\n" - - message += "**Options:**\n" - message += "• **Replace** - Remove the existing shortcut and create a new one\n" - message += "• **Cancel** - Keep the existing shortcut and stop the installation\n" - message += "• **Skip** - Continue without creating a Steam shortcut\n\n" - message += "The existing shortcut will be removed if you choose to replace it." - - return message - def _get_shortcuts_path(self) -> Optional[Path]: """Get the path to shortcuts.vdf using proper Steam path detection.""" try: @@ -393,128 +136,6 @@ exit""" except Exception as e: logger.error(f"Failed to create batch file: {e}") return None - - def find_proton_experimental(self) -> Optional[Path]: - """ - Find Proton Experimental installation. - - Returns: - Path to Proton Experimental, or None if not found - """ - proton_paths = [ - Path.home() / ".local/share/Steam/steamapps/common/Proton - Experimental", - Path.home() / ".steam/steam/steamapps/common/Proton - Experimental", - Path.home() / ".local/share/Steam/steamapps/common/Proton Experimental", - Path.home() / ".steam/steam/steamapps/common/Proton Experimental", - ] - - for path in proton_paths: - if path.exists(): - logger.info(f"Found Proton Experimental at: {path}") - return path - - logger.error("Proton Experimental not found") - return None - - - - def verify_shortcut_created(self, shortcut_name: str) -> Optional[int]: - """ - Verify the shortcut was created and get its AppID. - - Args: - shortcut_name: Name of the shortcut to look for - - Returns: - AppID if found, None otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return None - - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Look for our shortcut by name - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - - if shortcut_name in name: - appid = shortcut.get('appid') - exe_path = shortcut.get('Exe', '').strip('"') - - logger.info(f"Found shortcut: {name}") - logger.info(f" AppID: {appid}") - logger.info(f" Exe: {exe_path}") - logger.info(f" CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}") - - return appid - - logger.error(f"Shortcut '{shortcut_name}' not found") - return None - - except Exception as e: - logger.error(f"Error reading shortcuts: {e}") - return None - - 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 restart_steam(self) -> bool: """ @@ -532,415 +153,6 @@ exit""" logger.error(f"Error restarting Steam: {e}") return False - def generate_steam_short_id(self, signed_appid: int) -> int: - """ - Convert signed 32-bit integer to unsigned 32-bit integer (same as STL's generateSteamShortID). - - Args: - signed_appid: Signed 32-bit integer AppID - - Returns: - Unsigned 32-bit integer AppID - """ - return signed_appid & 0xFFFFFFFF - - 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_shortcut_directly(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool: - """ - Create a Steam shortcut directly by modifying shortcuts.vdf. - This is a fallback when STL fails. - - Args: - shortcut_name: Name for the shortcut - exe_path: Path to the executable - modlist_install_dir: Directory where the modlist is installed - - Returns: - True if successful, False otherwise - """ - try: - debug_print(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") - return False - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Find the next available index - next_index = str(len(shortcuts)) - - # Calculate AppID for the new shortcut (negative for non-Steam shortcuts) - import hashlib - app_name_bytes = shortcut_name.encode('utf-8') - exe_bytes = exe_path.encode('utf-8') - combined = app_name_bytes + exe_bytes - hash_value = int(hashlib.md5(combined).hexdigest()[:8], 16) - appid = -(hash_value & 0x7FFFFFFF) # Make it negative and within 32-bit range - - # Create new shortcut entry - new_shortcut = { - 'AppName': shortcut_name, - 'Exe': f'"{exe_path}"', - 'StartDir': f'"{modlist_install_dir}"', - 'appid': appid, - 'icon': '', - 'ShortcutPath': '', - 'LaunchOptions': '', - 'IsHidden': 0, - 'AllowDesktopConfig': 1, - 'AllowOverlay': 1, - 'openvr': 0, - 'Devkit': 0, - 'DevkitGameID': '', - 'LastPlayTime': 0, - 'FlatpakAppID': '', - 'tags': {}, - 'CompatTool': 'proton_experimental', # Set Proton Experimental - 'IsInstalled': 1 # Make it appear in "Locally Installed" filter - } - - # Add the new shortcut - shortcuts[next_index] = new_shortcut - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - logger.info(f"Created shortcut directly: {shortcut_name}") - return True - - except Exception as e: - 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 set_proton_version_for_shortcut(self, appid: int, proton_version: str) -> bool: - """ - Set the Proton version for a shortcut in config.vdf. - - Args: - appid: The AppID of the shortcut (negative for non-Steam shortcuts) - proton_version: The Proton version to set (e.g., 'proton_experimental') - - Returns: - True if successful, False otherwise - """ - try: - # Get the config.vdf path - config_path = self._get_config_path() - if not config_path: - logger.error("No config.vdf path found") - return False - - # Read current config (config.vdf is text format) - with open(config_path, 'r') as f: - config_data = vdf.load(f) - - # Navigate to the correct location in the VDF structure - if 'Software' not in config_data: - config_data['Software'] = {} - if 'Valve' not in config_data['Software']: - config_data['Software']['Valve'] = {} - if 'Steam' not in config_data['Software']['Valve']: - config_data['Software']['Valve']['Steam'] = {} - - # Get or create CompatToolMapping - if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']: - config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {} - - # Set the Proton version for this AppID using Steam's expected format - # Steam requires a dict with 'name', 'config', and 'priority' keys - config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = { - 'name': proton_version, - 'config': '', - 'priority': '250' - } - - # Write back to file (text format) - with open(config_path, 'w') as f: - vdf.dump(config_data, f) - - # Ensure file is fully written to disk before Steam restart - import os - 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") - - # Small delay to ensure filesystem write completes - import time - time.sleep(0.5) - - # Verify it was set correctly - 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}") - - return True - - except Exception as e: - logger.error(f"Error setting Proton version: {e}") - return False - def _get_config_path(self) -> Optional[Path]: """Get the path to config.vdf""" try: @@ -999,387 +211,6 @@ echo Prefix creation complete. except Exception as e: logger.error(f"Error killing processes: {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. - - First tries to find existing compatdata, then constructs path from libraryfolders.vdf - for creating new prefixes. - - Args: - appid: The AppID to get the path for - - Returns: - Path to the compatdata directory, or None if not found - """ - from ..handlers.path_handler import PathHandler - - # First, try to find existing compatdata - compatdata_path = PathHandler.find_compat_data(str(appid)) - if compatdata_path: - return compatdata_path - - # Prefix doesn't exist yet - determine where to create it from libraryfolders.vdf - library_paths = PathHandler.get_all_steam_library_paths() - if library_paths: - # Use the first library (typically the default library) - # Construct compatdata path: library_path/steamapps/compatdata/appid - first_library = library_paths[0] - compatdata_base = first_library / "steamapps" / "compatdata" - return compatdata_base / str(appid) - - # Only fallback if VDF parsing completely fails - logger.warning("Could not get library paths from libraryfolders.vdf, using fallback locations") - fallback_bases = [ - Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata", - Path.home() / ".steam/steam/steamapps/compatdata", - Path.home() / ".local/share/Steam/steamapps/compatdata", - ] - - for base_path in fallback_bases: - if base_path.is_dir(): - return base_path / str(appid) - - return None - - def verify_prefix_creation(self, prefix_path: Path) -> bool: - """ - Verify that the prefix was created successfully. - - Args: - prefix_path: Path to the prefix directory - - Returns: - True if prefix is valid, False otherwise - """ - try: - logger.info(f"Verifying prefix: {prefix_path}") - - # Check if prefix exists and has proper structure - if not prefix_path.exists(): - logger.error("Prefix directory does not exist") - return False - - pfx_dir = prefix_path / "pfx" - if not pfx_dir.exists(): - logger.error("Prefix exists but no pfx subdirectory") - return False - - # Check for key Wine files - system_reg = pfx_dir / "system.reg" - user_reg = pfx_dir / "user.reg" - drive_c = pfx_dir / "drive_c" - - if not system_reg.exists(): - logger.error("No system.reg found in prefix") - return False - - if not user_reg.exists(): - logger.error("No user.reg found in prefix") - return False - - if not drive_c.exists(): - logger.error("No drive_c directory found in prefix") - return False - - logger.info("Prefix structure verified successfully") - return True - - except Exception as e: - logger.error(f"Error verifying prefix: {e}") - return False - - def modify_shortcut_to_final_exe(self, shortcut_name: str, final_exe_path: str, - final_start_dir: str) -> bool: - """ - Update the existing batch file shortcut to point to the final executable. - This preserves the AppID and prefix association while changing the target. - - Args: - shortcut_name: Name of the shortcut to modify - final_exe_path: Path to the final executable (e.g., ModOrganizer.exe) - final_start_dir: Start directory for the executable - - Returns: - True if successful, False otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return False - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Find the batch file shortcut that created the prefix - logger.info(f"Looking for batch file shortcut '{shortcut_name}' among {len(shortcuts)} shortcuts...") - target_shortcut = None - target_index = None - - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - exe = shortcut.get('Exe', '') - - # Find the specific shortcut that points to our batch file (handle quoted paths) - if (name == shortcut_name and - exe and 'prefix_creation_' in exe and (exe.endswith('.bat') or exe.endswith('.bat"'))): - target_shortcut = shortcut - target_index = str(i) - logger.info(f"Found batch file shortcut '{shortcut_name}' at index {i}") - logger.info(f" Current Exe: {exe}") - logger.info(f" Current StartDir: {shortcut.get('StartDir', '')}") - logger.info(f" Current CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}") - logger.info(f" AppID: {shortcut.get('appid', 'NOT_SET')}") - break - - if target_shortcut is None: - logger.error(f"No batch file shortcut found with name '{shortcut_name}'") - # Debug: show all available shortcuts - logger.debug("Available shortcuts:") - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - exe = shortcut.get('Exe', '') - logger.debug(f" [{i}] {name} -> {exe}") - return False - - # Update the existing shortcut IN-PLACE (preserves AppID and all other fields) - logger.info(f"Updating shortcut at index {target_index} IN-PLACE...") - - # Only change Exe and StartDir - preserve everything else including AppID - old_exe = target_shortcut.get('Exe', '') - old_start_dir = target_shortcut.get('StartDir', '') - - target_shortcut['Exe'] = f'"{final_exe_path}"' - target_shortcut['StartDir'] = f'"{final_start_dir}"' - - # Ensure CompatTool is set (STL should have set this, but make sure) - if not target_shortcut.get('CompatTool', '').strip(): - target_shortcut['CompatTool'] = 'proton_experimental' - logger.info("Set CompatTool to proton_experimental (was not set)") - - logger.info(f" Updated shortcut '{shortcut_name}' at index {target_index}:") - logger.info(f" Exe: {old_exe} → {target_shortcut['Exe']}") - logger.info(f" StartDir: {old_start_dir} → {target_shortcut['StartDir']}") - logger.info(f" AppID: {target_shortcut.get('appid', 'NOT_SET')} (preserved)") - logger.info(f" CompatTool: {target_shortcut.get('CompatTool', 'NOT_SET')} (preserved)") - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - logger.info(" Shortcut updated successfully - no duplicates created") - return True - - except Exception as e: - logger.error(f"Error modifying shortcut: {e}") - return False - - def verify_final_shortcut(self, shortcut_name: str, expected_exe_path: str) -> bool: - """ - Verify the shortcut now points to the final executable. - - Args: - shortcut_name: Name of the shortcut to verify - expected_exe_path: Expected executable path - - Returns: - True if shortcut is correct, False otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return False - - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Find our shortcut - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - - if shortcut_name in name: - exe_path = shortcut.get('Exe', '') - start_dir = shortcut.get('StartDir', '') - - logger.info(f"Final shortcut configuration:") - logger.info(f" Name: {name}") - logger.info(f" Exe: {exe_path}") - logger.info(f" StartDir: {start_dir}") - - # Verify it points to the final executable - if expected_exe_path in exe_path: - logger.info("Shortcut correctly points to final executable") - return True - else: - logger.error("Shortcut does not point to final executable") - return False - - logger.error(f"Shortcut '{shortcut_name}' not found") - return False - - except Exception as e: - logger.error(f"Error reading shortcuts: {e}") - return False - - def wait_for_prefix_completion(self, prefix_id: str, timeout: int = 60) -> bool: - """ - Wait for system.reg to stop growing (indicates prefix creation is complete). - - Args: - prefix_id: The Steam prefix ID to monitor - timeout: Maximum seconds to wait - - Returns: - True if prefix creation completed, False if timeout - """ - try: - prefix_path = Path.home() / f".local/share/Steam/steamapps/compatdata/{prefix_id}" - system_reg = prefix_path / "pfx/system.reg" - - logger.info(f"Monitoring prefix completion: {system_reg}") - - last_size = 0 - stable_count = 0 - - for i in range(timeout): - if system_reg.exists(): - current_size = system_reg.stat().st_size - logger.debug(f"system.reg size: {current_size} bytes") - - if current_size == last_size: - stable_count += 1 - if stable_count >= 3: # Stable for 3 seconds - logger.info(" system.reg size stable - prefix creation complete") - return True - else: - stable_count = 0 - last_size = current_size - - time.sleep(1) - - logger.warning(f"Timeout waiting for prefix completion after {timeout} seconds") - return False - - except Exception as e: - logger.error(f"Error monitoring prefix completion: {e}") - return False def kill_mo_processes(self) -> int: """ @@ -1442,318 +273,6 @@ echo Prefix creation complete. logger.error(f"Error killing ModOrganizer processes: {e}") return 0 - 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!") - - 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 cleanup_old_batch_shortcuts(self, shortcut_name: str) -> bool: - """ - Clean up any old batch file shortcuts for this modlist to prevent duplicates. - - Args: - shortcut_name: Name of the shortcut to clean up old batch versions for - - Returns: - True if successful, False otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return False - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - indices_to_remove = [] - - # Find all batch file shortcuts with the same name - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - exe = shortcut.get('Exe', '') - - if (name == shortcut_name and - 'prefix_creation_' in exe and - exe.endswith('.bat')): - indices_to_remove.append(str(i)) - logger.info(f"Marking old batch shortcut for removal: {name} -> {exe}") - - if not indices_to_remove: - logger.debug(f"No old batch shortcuts found for '{shortcut_name}'") - return True - - # Remove shortcuts by rebuilding the shortcuts dict - new_shortcuts = {} - new_index = 0 - - for i in range(len(shortcuts)): - if str(i) not in indices_to_remove: - new_shortcuts[str(new_index)] = shortcuts[str(i)] - new_index += 1 - - shortcuts_data['shortcuts'] = new_shortcuts - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - logger.info(f"Cleaned up {len(indices_to_remove)} old batch shortcuts for '{shortcut_name}'") - return True - - except Exception as e: - logger.error(f"Error cleaning up old shortcuts: {e}") - return False - - def set_compatool_on_shortcut(self, shortcut_name: str) -> bool: - """ - Set CompatTool on a shortcut immediately after STL creation. - This is CRITICAL to ensure the batch file shortcut has Proton set - so it can create a prefix when launched. - - Args: - shortcut_name: Name of the shortcut to modify - - Returns: - True if successful, False otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return False - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Find the shortcut by name - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - - if shortcut_name == name: - # Check current CompatTool setting - current_compat = shortcut.get('CompatTool', 'NOT_SET') - logger.info(f"Found shortcut '{name}' with CompatTool: '{current_compat}'") - - # Set CompatTool to ensure batch file can create prefix - shortcut['CompatTool'] = 'proton_experimental' - logger.info(f" Set CompatTool=proton_experimental on shortcut: {name}") - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - return True - - logger.error(f"Shortcut '{shortcut_name}' not found for CompatTool setting") - return False - - except Exception as e: - logger.error(f"Error setting CompatTool on shortcut: {e}") - return False - - def _set_proton_on_shortcut(self, shortcut_name: str) -> bool: - """ - Set Proton Experimental on a shortcut by name. - - Args: - shortcut_name: Name of the shortcut to modify - - Returns: - True if successful, False otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return False - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Find the shortcut by name - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - - if shortcut_name == name: - # Set CompatTool - shortcut['CompatTool'] = 'proton_experimental' - logger.info(f"Set CompatTool=proton_experimental on shortcut: {name}") - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - return True - - logger.error(f"Shortcut '{shortcut_name}' not found for Proton setting") - return False - - except Exception as e: - logger.error(f"Error setting Proton on shortcut: {e}") - return False - @staticmethod def get_ttw_installer_path() -> Optional[Path]: """Get path to TTW_Linux_Installer if available""" @@ -1766,765 +285,6 @@ echo Prefix creation complete. pass return None - def run_working_workflow(self, shortcut_name: str, modlist_install_dir: str, - final_exe_path: str, progress_callback=None, steamdeck: Optional[bool] = None) -> Tuple[bool, Optional[Path], Optional[int], Optional[str]]: - """ - Run the proven working automated prefix creation workflow. - - This implements our tested and working approach: - 1. Create shortcut with native Steam service (pointing to ModOrganizer.exe initially) - 2. Restart Steam using Jackify's robust method - 3. Create Proton prefix invisibly using Proton wrapper with DISPLAY= - 4. Verify everything persists - - Args: - shortcut_name: Name for the Steam shortcut - modlist_install_dir: Directory where the modlist is installed - final_exe_path: Path to ModOrganizer.exe - progress_callback: Optional callback for progress updates - steamdeck: Optional Steam Deck detection override - - Returns: - Tuple of (success, prefix_path, appid, last_timestamp) - """ - logger.info("Starting proven working automated prefix creation workflow") - - # Show installation complete and configuration start headers FIRST - if progress_callback: - progress_callback("") - progress_callback("=" * 64) - progress_callback("= Installation phase complete =") - progress_callback("=" * 64) - progress_callback("") - progress_callback("=" * 64) - progress_callback("= Starting Configuration Phase =") - progress_callback("=" * 64) - progress_callback("") - - # Reset timing for Steam Integration section (part of Configuration Phase) - from jackify.shared.timing import start_new_phase - start_new_phase() - - # Show immediate feedback to user with section header - if progress_callback: - progress_callback("") # Blank line before Steam Integration - progress_callback("=== Steam Integration ===") - progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service") - - # Registry injection approach for both FNV and Enderal - from ..handlers.modlist_handler import ModlistHandler - 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 - custom_launch_options = None - if special_game_type in ["fnv", "enderal"]: - logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist") - else: - logger.debug("Standard modlist - no special game handling needed") - - try: - # Step 1: Create shortcut with native Steam service (pointing to ModOrganizer.exe initially) - logger.info("Step 1: Creating shortcut with native Steam service") - - # TEMPORARILY DISABLED: Check if shortcut already exists and handle conflict - # conflict_result = self.handle_existing_shortcut_conflict(shortcut_name, final_exe_path, modlist_install_dir) - # if isinstance(conflict_result, list): # Conflicts found - # logger.warning(f"Found {len(conflict_result)} existing shortcut(s) with same name and path") - # # Return a special tuple to indicate conflict that needs user resolution - # return ("CONFLICT", conflict_result, None) - # elif not conflict_result: # User cancelled or other failure - # logger.error("User cancelled due to shortcut conflict") - # return False, None, None, None - logger.info("Conflict detection temporarily disabled - proceeding with shortcut creation") - - # Create shortcut using native Steam service with special game launch options - success, appid = self.create_shortcut_with_native_service(shortcut_name, final_exe_path, modlist_install_dir, custom_launch_options) - if not success: - logger.error("Failed to create shortcut with native Steam service") - return False, None, None, None - - logger.info(f"Step 1 completed: Shortcut created with native service, AppID: {appid}") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully") - - # Apply Steam artwork if available - try: - from ..handlers.modlist_handler import ModlistHandler - modlist_handler = ModlistHandler() - modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir) - logger.info(f"Applied Steam artwork for shortcut '{shortcut_name}' (AppID: {appid})") - except Exception as e: - logger.warning(f"Failed to apply Steam artwork: {e}") - - # Step 2: Restart Steam using Jackify's robust method - logger.info("Step 2: Restarting Steam using Jackify's robust method") - 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 - - logger.info("Step 2 completed: Steam restarted") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully") - - # Step 3: Create Proton prefix invisibly using Proton wrapper - logger.info("Step 3: Creating Proton prefix invisibly") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...") - - if not self.create_prefix_with_proton_wrapper(appid): - logger.error("Failed to create Proton prefix") - return False, None, None, None - - logger.info("Step 3 completed: Proton prefix created") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully") - - # Step 4: Verify everything persists - logger.info("Step 4: Verifying compatibility tool persists") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Verifying setup...") - - if not self.verify_compatibility_tool_persists(appid): - logger.warning("Compatibility tool verification failed, but continuing") - - logger.info("Step 4 completed: Verification done") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Setup verification completed") - - # Step 5: Inject game registry entries for FNV and Enderal modlists - # Get prefix path (needed for logging regardless of game type) - prefix_path = self.get_prefix_path(appid) - - if special_game_type in ["fnv", "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...") - - if prefix_path: - self._inject_game_registry_entries(str(prefix_path), special_game_type) - else: - 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") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...") - - if prefix_path: - self._create_game_user_directories(str(prefix_path), special_game_type) - else: - logger.warning("Could not find prefix path for directory creation") - - last_timestamp = self._get_progress_timestamp() - logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}") - if progress_callback: - progress_callback(f"{last_timestamp} Steam integration complete") - progress_callback("") # Blank line after Steam integration complete - - if progress_callback: - progress_callback("") # Extra blank line to span across Configuration Summary - progress_callback("") # And one more to create space before Prefix Configuration - - return True, prefix_path, appid, last_timestamp - - except Exception as e: - logger.error(f"Error in working workflow: {e}") - if progress_callback: - progress_callback(f"Error: {str(e)}") - return False, None, None, None - - 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]]: - """ - Continue the workflow after a shortcut conflict has been resolved. - - Args: - shortcut_name: Name of the shortcut - modlist_install_dir: Directory where the modlist is installed - final_exe_path: Path to the final executable - appid: The AppID of the shortcut that was created/replaced - progress_callback: Optional callback for progress updates - - Returns: - Tuple of (success, prefix_path, appid) - """ - try: - logger.info("Continuing workflow after conflict resolution") - - # Step 2: Restart Steam using Jackify's robust method - logger.info("Step 2: Restarting Steam using Jackify's robust method") - 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 - - logger.info("Step 2 completed: Steam restarted") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully") - - # Step 3: Create Proton prefix invisibly using Proton wrapper - logger.info("Step 3: Creating Proton prefix invisibly") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...") - - if not self.create_prefix_with_proton_wrapper(appid): - logger.error("Failed to create Proton prefix") - return False, None, None, None - - logger.info("Step 3 completed: Proton prefix created") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully") - - # Step 4: Verify everything persists - logger.info("Step 4: Verifying compatibility tool persists") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Verifying setup...") - - if not self.verify_compatibility_tool_persists(appid): - logger.warning("Compatibility tool verification failed, but continuing") - - logger.info("Step 4 completed: Verification done") - if progress_callback: - progress_callback(f"{self._get_progress_timestamp()} Setup verification completed") - - # Get the prefix path - prefix_path = self.get_prefix_path(appid) - - last_timestamp = self._get_progress_timestamp() - logger.info(f" Workflow completed successfully after conflict resolution! AppID: {appid}, Prefix: {prefix_path}") - if progress_callback: - progress_callback(f"{last_timestamp} Automated Steam setup completed successfully!") - - return True, prefix_path, appid, last_timestamp - - except Exception as e: - logger.error(f"Error continuing workflow after conflict resolution: {e}") - if progress_callback: - progress_callback(f"Error: {str(e)}") - return False, None, None, None - - def modify_shortcut_to_batch_file(self, shortcut_name: str, batch_file_path: str, - modlist_install_dir: str) -> bool: - """ - Modify an existing shortcut to point to a temporary batch file. - - Args: - shortcut_name: Name of the shortcut to modify - batch_file_path: Path to the temporary batch file - modlist_install_dir: Directory where the modlist is installed - - Returns: - True if successful, False otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return False - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Find the shortcut by name - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - - if shortcut_name == name: - # Update the shortcut to point to the batch file - old_exe = shortcut.get('Exe', '') - shortcut['Exe'] = f'"{batch_file_path}"' - shortcut['StartDir'] = f'"{modlist_install_dir}"' - - logger.info(f"Modified shortcut '{shortcut_name}':") - logger.info(f" Exe: {old_exe} → {shortcut['Exe']}") - logger.info(f" StartDir: {shortcut['StartDir']}") - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - return True - - logger.error(f"Shortcut '{shortcut_name}' not found for modification") - return False - - except Exception as e: - logger.error(f"Error modifying shortcut to batch file: {e}") - return False - - def find_appid_in_shortcuts_vdf(self, shortcut_name: str) -> Optional[str]: - """ - Find the AppID for a shortcut by name directly in shortcuts.vdf. - This is a fallback method when protontricks detection fails. - - Args: - shortcut_name: Name of the shortcut to find - - Returns: - AppID as string, or None if not found - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return None - - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Look for shortcut by name - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - name = shortcut.get('AppName', '') - - if shortcut_name == name: - appid = shortcut.get('appid') - if appid: - logger.info(f"Found AppID {appid} for shortcut '{shortcut_name}' in shortcuts.vdf") - return str(appid) - - logger.warning(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf") - return None - - except Exception as e: - logger.error(f"Error finding AppID in shortcuts.vdf: {e}") - return None - - def predict_appid_using_stl_algorithm(self, shortcut_name: str, exe_path: str) -> Optional[int]: - """ - Predict the AppID using SteamTinkerLaunch's exact algorithm. - - This implements the same logic as STL's generateShortcutVDFAppId and generateSteamShortID functions: - 1. Combine AppName + ExePath - 2. Generate MD5 hash, take first 8 characters - 3. Convert to decimal, make negative, ensure < 1 billion - 4. Convert to unsigned 32-bit integer - - Args: - shortcut_name: Name of the shortcut - exe_path: Path to the executable - - Returns: - Predicted AppID as integer, or None if failed - """ - try: - import hashlib - - # Step 1: Combine AppName + ExePath (exactly like STL) - combined_string = f"{shortcut_name}{exe_path}" - logger.debug(f"Combined string for AppID prediction: '{combined_string}'") - - # Step 2: Generate MD5 hash and take first 8 characters - md5_hash = hashlib.md5(combined_string.encode()).hexdigest() - seed_hex = md5_hash[:8] - logger.debug(f"MD5 hash: {md5_hash}, seed hex: {seed_hex}") - - # Step 3: Convert to decimal, make negative, ensure < 1 billion - seed_decimal = int(seed_hex, 16) - signed_appid = -(seed_decimal % 1000000000) - logger.debug(f"Seed decimal: {seed_decimal}, signed AppID: {signed_appid}") - - # Step 4: Convert to unsigned 32-bit integer (STL's generateSteamShortID) - unsigned_appid = signed_appid & 0xFFFFFFFF - logger.debug(f"Unsigned AppID: {unsigned_appid}") - - logger.info(f"Predicted AppID using STL algorithm: {unsigned_appid} (signed: {signed_appid})") - return unsigned_appid - - except Exception as e: - logger.error(f"Error predicting AppID using STL algorithm: {e}") - return None - - def create_shortcut_with_stl_algorithm(self, shortcut_name: str, exe_path: str, start_dir: str, compatibility_tool: str = None) -> bool: - """ - Create a shortcut using STL's exact algorithm for consistent AppID calculation. - - Args: - shortcut_name: Name of the shortcut - exe_path: Path to the executable - start_dir: Start directory - compatibility_tool: Optional compatibility tool to set immediately (like STL does) - - Returns: - True if successful, False otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - return False - - # Read current shortcuts - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - shortcuts = shortcuts_data.get('shortcuts', {}) - - # Find the next available index - next_index = str(len(shortcuts)) - - # Calculate AppID using STL's algorithm - predicted_appid = self.predict_appid_using_stl_algorithm(shortcut_name, exe_path) - if not predicted_appid: - logger.error("Failed to predict AppID for shortcut creation") - return False - - # Convert to signed AppID (STL stores the signed version in shortcuts.vdf) - signed_appid = predicted_appid - if predicted_appid > 0x7FFFFFFF: # If it's a large positive number, make it negative - signed_appid = predicted_appid - 0x100000000 - - # Create new shortcut entry - new_shortcut = { - 'AppName': shortcut_name, - 'Exe': f'"{exe_path}"', - 'StartDir': f'"{start_dir}"', - 'appid': signed_appid, # Use the signed AppID - 'icon': '', - 'ShortcutPath': '', - 'LaunchOptions': '', - 'IsHidden': 0, - 'AllowDesktopConfig': 1, - 'AllowOverlay': 1, - 'openvr': 0, - 'Devkit': 0, - 'DevkitGameID': '', - 'LastPlayTime': 0, - 'FlatpakAppID': '', - 'tags': {}, - 'IsInstalled': 1 # Make it appear in "Locally Installed" filter - } - - # Add the new shortcut - shortcuts[next_index] = new_shortcut - - # Write back to file - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - logger.info(f"Created shortcut with STL algorithm: {shortcut_name} with AppID {signed_appid} (unsigned: {predicted_appid})") - - # Set compatibility tool immediately if provided (like STL does) - if compatibility_tool: - logger.info(f"Setting compatibility tool immediately: {compatibility_tool}") - success = self.set_compatibility_tool_complete_stl_style(predicted_appid, compatibility_tool) - if not success: - logger.warning("Failed to set compatibility tool immediately") - - return True - - except Exception as e: - logger.error(f"Error creating shortcut with STL algorithm: {e}") - return False - - def set_compatibility_tool_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool: - """ - Set compatibility tool using STL's exact method. - - This adds an entry to config.vdf's CompatToolMapping section using the unsigned AppID as the key, - exactly like STL does. - - Args: - unsigned_appid: The unsigned AppID (Grid ID) to use as the key - compat_tool: The compatibility tool name (e.g., 'proton_experimental') - - Returns: - True if successful, False otherwise - """ - try: - config_path = self._get_config_path() - if not config_path: - logger.error("No config.vdf path found") - return False - - # Read current config (config.vdf is text format) - with open(config_path, 'r') as f: - config_data = vdf.load(f) - - # Navigate to the correct location in the VDF structure - if 'Software' not in config_data: - config_data['Software'] = {} - if 'Valve' not in config_data['Software']: - config_data['Software']['Valve'] = {} - if 'Steam' not in config_data['Software']['Valve']: - config_data['Software']['Valve']['Steam'] = {} - - # Get or create CompatToolMapping - if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']: - config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {} - - # Create the compatibility tool entry exactly like STL does - compat_entry = { - 'name': compat_tool, - 'config': '', - 'priority': '250' - } - - # Set the compatibility tool for this AppID (using unsigned AppID as key) - 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}") - - # 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}") - - return True - - except Exception as e: - logger.error(f"Error setting compatibility tool STL-style: {e}") - return False - - def set_compatibility_tool_complete_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool: - """ - Set compatibility tool using STL's complete method with direct text manipulation. - - This replicates STL's approach by using direct text manipulation instead of VDF libraries - to preserve existing entries in both config.vdf and localconfig.vdf. - - Args: - unsigned_appid: The unsigned AppID (Grid ID) to use as the key - compat_tool: The compatibility tool name (e.g., 'proton_experimental') - - Returns: - True if successful, False otherwise - """ - try: - # Step 1: Update config.vdf using direct text manipulation (like STL does) - config_path = self._get_config_path() - if not config_path: - logger.error("No config.vdf path found") - return False - - # Read the entire file as text - with open(config_path, 'r') as f: - lines = f.readlines() - - # Find the CompatToolMapping section - compat_section_start = None - compat_section_end = None - for i, line in enumerate(lines): - if '"CompatToolMapping"' in line.strip(): - compat_section_start = i - # Find the end of the CompatToolMapping section - brace_count = 0 - for j in range(i + 1, len(lines)): - if '{' in lines[j]: - brace_count += 1 - if '}' in lines[j]: - brace_count -= 1 - if brace_count == 0: - compat_section_end = j - break - break - - if compat_section_start is None: - logger.error("CompatToolMapping section not found in config.vdf") - return False - - # Check if our AppID entry already exists - appid_entry_start = None - appid_entry_end = None - for i in range(compat_section_start, compat_section_end + 1): - if f'"{unsigned_appid}"' in lines[i]: - appid_entry_start = i - # Find the end of this AppID entry - brace_count = 0 - for j in range(i + 1, compat_section_end + 1): - if '{' in lines[j]: - brace_count += 1 - if '}' in lines[j]: - brace_count -= 1 - if brace_count == 0: - appid_entry_end = j - break - break - - # Create the new entry in Steam's exact format - new_entry_lines = [ - f'\t\t\t\t\t\t\t\t\t"{unsigned_appid}"\n', - f'\t\t\t\t\t\t\t\t\t{{\n', - f'\t\t\t\t\t\t\t\t\t\t"name"\t\t\t\t"{compat_tool}"\n', - f'\t\t\t\t\t\t\t\t\t\t"config"\t\t\t\t\t""\n', - f'\t\t\t\t\t\t\t\t\t\t"priority"\t\t\t\t\t"250"\n', - f'\t\t\t\t\t\t\t\t\t}}\n' - ] - - if appid_entry_start is None: - # AppID entry doesn't exist, add it before the closing brace of CompatToolMapping - lines.insert(compat_section_end, ''.join(new_entry_lines)) - else: - # AppID entry exists, replace it - del lines[appid_entry_start:appid_entry_end + 1] - lines.insert(appid_entry_start, ''.join(new_entry_lines)) - - # Write the updated file back - with open(config_path, 'w') as f: - f.writelines(lines) - - logger.info(f"Updated config.vdf: AppID {unsigned_appid} -> {compat_tool}") - - # Step 2: Update localconfig.vdf using direct text manipulation (like STL) - localconfig_path = self._get_localconfig_path() - if not localconfig_path: - logger.error("No localconfig.vdf path found") - return False - - # Calculate signed AppID (like STL does) - signed_appid = (unsigned_appid | 0x80000000) & 0xFFFFFFFF - # Convert to signed 32-bit integer - import ctypes - signed_appid_int = ctypes.c_int32(signed_appid).value - - # Read the entire file as text - with open(localconfig_path, 'r') as f: - lines = f.readlines() - - # Check if Apps section exists - apps_section_start = None - apps_section_end = None - for i, line in enumerate(lines): - if line.strip() == '"Apps"': - apps_section_start = i - # Find the end of the Apps section - brace_count = 0 - for j in range(i + 1, len(lines)): - if '{' in lines[j]: - brace_count += 1 - if '}' in lines[j]: - brace_count -= 1 - if brace_count == 0: - apps_section_end = j - break - break - - # If Apps section doesn't exist, create it at the end of the file - if apps_section_start is None: - logger.info("Apps section not found, creating it at the end of the file") - - # Find the last closing brace (before the final closing brace) - last_brace_pos = None - for i in range(len(lines) - 1, -1, -1): - if lines[i].strip() == '}': - last_brace_pos = i - break - - if last_brace_pos is None: - logger.error("Could not find closing brace in localconfig.vdf") - return False - - # Insert Apps section before the last closing brace - apps_section = [ - ' "Apps"\n', - ' {\n', - f' "{signed_appid_int}"\n', - ' {\n', - ' "OverlayAppEnable" "1"\n', - ' "DisableLaunchInVR" "1"\n', - ' }\n', - ' }\n' - ] - - lines.insert(last_brace_pos, ''.join(apps_section)) - - else: - # Apps section exists, check if our AppID entry exists - appid_entry_start = None - appid_entry_end = None - for i in range(apps_section_start, apps_section_end + 1): - if f'"{signed_appid_int}"' in lines[i]: - appid_entry_start = i - # Find the end of this AppID entry - brace_count = 0 - for j in range(i + 1, apps_section_end + 1): - if '{' in lines[j]: - brace_count += 1 - if '}' in lines[j]: - brace_count -= 1 - if brace_count == 0: - appid_entry_end = j - break - break - - if appid_entry_start is None: - # AppID entry doesn't exist, add it to the Apps section - logger.info(f"AppID {signed_appid_int} entry not found, adding it to Apps section") - - # Insert before the closing brace of the Apps section - appid_entry = [ - f' "{signed_appid_int}"\n', - ' {\n', - ' "OverlayAppEnable" "1"\n', - ' "DisableLaunchInVR" "1"\n', - ' }\n' - ] - - lines.insert(apps_section_end, ''.join(appid_entry)) - - else: - # AppID entry exists, update the values - logger.info(f"AppID {signed_appid_int} entry exists, updating values") - - # Check if the values already exist and update them - overlay_found = False - vr_found = False - - for i in range(appid_entry_start, appid_entry_end + 1): - if '"OverlayAppEnable"' in lines[i]: - lines[i] = ' "OverlayAppEnable" "1"\n' - overlay_found = True - elif '"DisableLaunchInVR"' in lines[i]: - lines[i] = ' "DisableLaunchInVR" "1"\n' - vr_found = True - - # Add missing values - if not overlay_found or not vr_found: - # Find the position to insert (before the closing brace of the AppID entry) - insert_pos = appid_entry_end - for i in range(appid_entry_start, appid_entry_end + 1): - if lines[i].strip() == '}': - insert_pos = i - break - - new_values = [] - if not overlay_found: - new_values.append(' "OverlayAppEnable" "1"\n') - if not vr_found: - new_values.append(' "DisableLaunchInVR" "1"\n') - - for value in new_values: - lines.insert(insert_pos, value) - - # Write the updated file back - with open(localconfig_path, 'w') as f: - 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") - - return True - - except Exception as e: - logger.error(f"Error setting compatibility tool complete STL-style: {e}") - return False - def modify_shortcut_to_batch_file(self, shortcut_name: str, new_exe_path: str, new_start_dir: str) -> bool: """ Modify an existing shortcut's target and start directory. @@ -2620,715 +380,37 @@ echo Prefix creation complete. logger.error("Could not find localconfig.vdf") return None - - - def modify_shortcut_target(self, shortcut_name: str, new_exe_path: str, new_start_dir: str) -> bool: - """ - Modify an existing shortcut's target and start directory. - Preserves existing launch options (including STEAM_COMPAT_MOUNTS). - - Args: - shortcut_name: The name of the shortcut to modify - new_exe_path: The new executable path - new_start_dir: The new start directory - - Returns: - True if successful, False otherwise - """ - try: - shortcuts_path = self._get_shortcuts_path() - if not shortcuts_path: - logger.error("No shortcuts.vdf path found") - return False - - # Read the current shortcuts.vdf - with open(shortcuts_path, 'rb') as f: - shortcuts_data = vdf.binary_load(f) - - if 'shortcuts' not in shortcuts_data: - logger.error("No shortcuts found in shortcuts.vdf") - return False - - shortcuts = shortcuts_data['shortcuts'] - shortcut_found = False - - # Find the shortcut by name - for i in range(len(shortcuts)): - shortcut = shortcuts[str(i)] - if shortcut.get('AppName', '') == shortcut_name: - # Preserve existing launch options - existing_launch_options = shortcut.get('LaunchOptions', '') - - # Update the shortcut EXACTLY as provided by the caller. - # - For temporary prefix creation we pass a Windows path (cmd.exe) - # - For final ModOrganizer.exe we pass the Linux path inside the modlist directory - shortcut['Exe'] = new_exe_path - shortcut['StartDir'] = new_start_dir - # Preserve the launch options (including STEAM_COMPAT_MOUNTS) - shortcut['LaunchOptions'] = existing_launch_options - - shortcut_found = True - logger.info(f"Modified shortcut '{shortcut_name}' to target: {new_exe_path}") - logger.info(f"Preserved launch options: {existing_launch_options}") - break - - if not shortcut_found: - logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf") - return False - - # Write the updated shortcuts.vdf back - with open(shortcuts_path, 'wb') as f: - vdf.binary_dump(shortcuts_data, f) - - logger.info(f"Successfully modified shortcut '{shortcut_name}'") - return True - - except Exception as e: - logger.error(f"Error modifying shortcut: {e}") - return False - - def create_prefix_with_proton_wrapper(self, appid: int) -> bool: - """ - Create a Proton prefix directly using Proton's wrapper and STEAM_COMPAT_DATA_PATH. - - Args: - appid: The AppID to create the prefix for - - Returns: - True if successful, False otherwise - """ - try: - # Determine Steam locations based on installation type - from ..handlers.path_handler import PathHandler - path_handler = PathHandler() - all_libraries = path_handler.get_all_steam_library_paths() - - # Check if we have Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths - is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries) - - if is_flatpak_steam and all_libraries: - # Flatpak Steam: Use the actual library root from libraryfolders.vdf - # Compatdata should be in the library root, not the client root - flatpak_library_root = all_libraries[0] # Use first library (typically the default) - flatpak_client_root = flatpak_library_root.parent.parent / ".steam/steam" - - if not flatpak_library_root.is_dir(): - logger.error( - f"Flatpak Steam library root does not exist: {flatpak_library_root}" - ) - return False - - steam_root = flatpak_client_root if flatpak_client_root.is_dir() else flatpak_library_root - # CRITICAL: compatdata must be in the library root, not client root - compatdata_dir = flatpak_library_root / "steamapps/compatdata" - proton_common_dir = flatpak_library_root / "steamapps/common" - else: - # Native Steam (or unknown): fall back to legacy ~/.steam/steam layout - steam_root = Path.home() / ".steam/steam" - compatdata_dir = steam_root / "steamapps/compatdata" - proton_common_dir = steam_root / "steamapps/common" - - # Ensure compatdata root exists and is a directory we actually want to use - if not compatdata_dir.is_dir(): - logger.error(f"Compatdata root does not exist: {compatdata_dir}. Aborting prefix creation.") - return False - - # Find a Proton wrapper to use - proton_path = self._find_proton_binary(proton_common_dir) - if not proton_path: - logger.error("No Proton wrapper found") - return False - - # Set up environment variables - env = os.environ.copy() - env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root) - env['STEAM_COMPAT_DATA_PATH'] = str(compatdata_dir / str(abs(appid))) - # Suppress GUI windows using jackify-engine's proven approach - env['DISPLAY'] = '' - env['WAYLAND_DISPLAY'] = '' - env['WINEDEBUG'] = '-all' - env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d' - - # Create the compatdata directory for this AppID (but never the whole tree) - compat_dir = compatdata_dir / str(abs(appid)) - compat_dir.mkdir(exist_ok=True) - - logger.info(f"Creating Proton prefix for AppID {appid}") - logger.info(f"STEAM_COMPAT_CLIENT_INSTALL_PATH={env['STEAM_COMPAT_CLIENT_INSTALL_PATH']}") - logger.info(f"STEAM_COMPAT_DATA_PATH={env['STEAM_COMPAT_DATA_PATH']}") - - # Run proton run wineboot -u to initialize the prefix - cmd = [str(proton_path), 'run', 'wineboot', '-u'] - logger.info(f"Running: {' '.join(cmd)}") - - # Adjust timeout for SD card installations on Steam Deck (slower I/O) - from ..services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - is_steamdeck_sdcard = (platform_service.is_steamdeck and - str(proton_path).startswith('/run/media/')) - timeout = 180 if is_steamdeck_sdcard else 120 - if is_steamdeck_sdcard: - logger.info(f"Using extended timeout ({timeout}s) for Steam Deck SD card Proton installation") - - # Use jackify-engine's approach: UseShellExecute=false, CreateNoWindow=true equivalent - result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout, - shell=False, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) - logger.info(f"Proton exit code: {result.returncode}") - - if result.stdout: - logger.info(f"stdout: {result.stdout.strip()[:500]}") - if result.stderr: - logger.info(f"stderr: {result.stderr.strip()[:500]}") - - # Give a moment for files to land - time.sleep(3) - - # Check if prefix was created - pfx = compat_dir / 'pfx' - if pfx.exists(): - logger.info(f" Proton prefix created at: {pfx}") - return True - else: - logger.warning(f"Proton prefix not found at: {pfx}") - return False - - except subprocess.TimeoutExpired: - logger.warning("Proton timed out; prefix may still be initializing") - return False - except Exception as e: - logger.error(f"Error creating prefix: {e}") - return False - - def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]: - """Locate a Proton wrapper script to use, respecting user's configuration.""" - try: - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.handlers.wine_utils import WineUtils - - config = ConfigHandler() - user_proton_path = config.get_game_proton_path() - - # If user selected a specific Proton, try that first - if user_proton_path != 'auto': - # Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam - resolved_proton_path = os.path.realpath(user_proton_path) - - # Check for wine binary in different Proton structures - valve_proton_wine = Path(resolved_proton_path) / "dist" / "bin" / "wine" - ge_proton_wine = Path(resolved_proton_path) / "files" / "bin" / "wine" - - if valve_proton_wine.exists() or ge_proton_wine.exists(): - # Found user's Proton, now find the proton wrapper script - proton_wrapper = Path(resolved_proton_path) / "proton" - if proton_wrapper.exists(): - logger.info(f"Using user-selected Proton wrapper: {proton_wrapper}") - return proton_wrapper - else: - logger.warning(f"User-selected Proton missing wrapper script: {proton_wrapper}") - else: - logger.warning(f"User-selected Proton path invalid: {user_proton_path}") - - # Fall back to auto-detection - logger.info("Falling back to automatic Proton detection") - candidates = [] - preferred = [ - "Proton - Experimental", - "Proton 9.0", - "Proton 8.0", - "Proton Hotfix", - ] - - for name in preferred: - p = proton_common_dir / name / "proton" - if p.exists(): - candidates.append(p) - - # As a fallback, scan all Proton* dirs - if not candidates and proton_common_dir.exists(): - for p in proton_common_dir.glob("Proton*/proton"): - candidates.append(p) - - if not candidates: - logger.error("No Proton wrapper found under steamapps/common") - return None - - logger.info(f"Using auto-detected Proton wrapper: {candidates[0]}") - return candidates[0] - - except Exception as e: - logger.error(f"Error finding Proton binary: {e}") - return None - - def replace_existing_shortcut(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Tuple[bool, Optional[int]]: - """ - Replace an existing shortcut with a new one using STL. - - Args: - shortcut_name: Name of the shortcut to replace - exe_path: Path to the executable - modlist_install_dir: Directory where the modlist is installed - - Returns: - Tuple of (success, appid) - """ - try: - logger.info(f"Replacing existing shortcut: {shortcut_name}") - - # First, remove the existing shortcut using STL - appdir = os.environ.get('APPDIR') - if appdir: - stl_path = Path(appdir) / "opt" / "jackify" / "steamtinkerlaunch" - else: - project_root = Path(__file__).parent.parent.parent.parent.parent - stl_path = project_root / "external_repos/steamtinkerlaunch/steamtinkerlaunch" - - if not stl_path.exists(): - logger.error(f"STL not found at: {stl_path}") - return False, None - - # Remove existing shortcut - remove_cmd = [str(stl_path), "rnsg", f"--appname={shortcut_name}"] - env = os.environ.copy() - env['STL_QUIET'] = '1' - - logger.info(f"Removing existing shortcut: {' '.join(remove_cmd)}") - result = subprocess.run(remove_cmd, capture_output=True, text=True, timeout=30, env=env) - - if result.returncode != 0: - logger.warning(f"Failed to remove existing shortcut: {result.stderr}") - # Continue anyway, STL might create a new one - - # Now create the new shortcut using NativeSteamService - success, app_id = self.create_shortcut_with_native_service(shortcut_name, exe_path, modlist_install_dir) - return success, app_id - - except Exception as e: - logger.error(f"Error replacing shortcut: {e}") - return False, None - - def verify_compatibility_tool_persists(self, appid: int) -> bool: - """ - Verify that the compatibility tool setting persists with correct Proton version. - - Args: - appid: The AppID to check - - Returns: - True if compatibility tool is correctly set, False otherwise - """ - try: - config_path = Path.home() / ".steam/steam/config/config.vdf" - if not config_path.exists(): - logger.warning("Steam config.vdf not found") - return False - - with open(config_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Check if AppID exists and has a Proton version set - if f'"{appid}"' in content: - # Get the expected Proton version - expected_proton = self._get_user_proton_version() - - # Look for the Proton version in the compatibility tool mapping - if expected_proton in content: - logger.info(f" Compatibility tool persists: {expected_proton}") - return True - else: - logger.warning(f"AppID {appid} found but Proton version '{expected_proton}' not set") - return False - else: - logger.warning("Compatibility tool not found") - return False - - except Exception as e: - logger.error(f"Error verifying compatibility tool: {e}") - return False - def get_prefix_path(self, appid: int) -> Optional[Path]: """ Get the path to the Proton prefix for the given AppID. - + Uses same Flatpak detection as create_prefix_with_proton_wrapper. + Args: appid: The AppID (unsigned, positive number) - + Returns: Path to the prefix directory, or None if not found """ - compatdata_dir = Path.home() / ".steam/steam/steamapps/compatdata" + from ..handlers.path_handler import PathHandler + path_handler = PathHandler() + all_libraries = path_handler.get_all_steam_library_paths() + + # Check if Flatpak Steam + is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries) + + if is_flatpak_steam and all_libraries: + # Flatpak Steam: use first library root + library_root = all_libraries[0] + compatdata_dir = library_root / "steamapps/compatdata" + else: + # Native Steam + compatdata_dir = Path.home() / ".steam/steam/steamapps/compatdata" + # Ensure we use the absolute value (unsigned AppID) prefix_dir = compatdata_dir / str(abs(appid)) - + if prefix_dir.exists(): return prefix_dir else: return None - def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]: - """Find a Steam game installation path by AppID and common names""" - import os - from pathlib import Path - - # Get Steam libraries from libraryfolders.vdf - check multiple possible locations - possible_config_paths = [ - Path.home() / ".steam/steam/config/libraryfolders.vdf", - Path.home() / ".local/share/Steam/config/libraryfolders.vdf", - Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf" # Flatpak - ] - - steam_config_path = None - for path in possible_config_paths: - if path.exists(): - steam_config_path = path - break - - if not steam_config_path: - return None - - steam_libraries = [] - try: - with open(steam_config_path, 'r') as f: - content = f.read() - # Parse library paths from VDF - import re - library_matches = re.findall(r'"path"\s+"([^"]+)"', content) - steam_libraries = [Path(path) / "steamapps" / "common" for path in library_matches] - except Exception as e: - logger.warning(f"Failed to parse Steam library folders: {e}") - return None - - # Search for game in each library - for library_path in steam_libraries: - if not library_path.exists(): - continue - - # Check manifest file first (more reliable) - manifest_path = library_path.parent / "appmanifest_{}.acf".format(app_id) - if manifest_path.exists(): - try: - with open(manifest_path, 'r') as f: - content = f.read() - install_dir_match = re.search(r'"installdir"\s+"([^"]+)"', content) - if install_dir_match: - game_path = library_path / install_dir_match.group(1) - if game_path.exists(): - return str(game_path) - except Exception: - pass - - # Fallback: check common folder names - for name in common_names: - game_path = library_path / name - if game_path.exists(): - return str(game_path) - - return None - - def _update_registry_path(self, system_reg_path: str, section_name: str, path_key: str, new_path: str) -> bool: - """Update a specific path value in Wine registry, preserving other entries""" - if not os.path.exists(system_reg_path): - return False - - try: - # Read existing content - with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines() - - in_target_section = False - path_updated = False - - # Determine Wine drive letter based on SD card detection - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.path_handler import PathHandler - - linux_path = Path(new_path) - - if FileSystemHandler.is_sd_card(linux_path): - # SD card paths use D: drive - # Strip SD card prefix using the same method as other handlers - relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path) - wine_path = relative_sd_path_str.replace('/', '\\\\') - wine_drive = "D:" - logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}") - else: - # Regular paths use Z: drive with full path - wine_path = new_path.strip('/').replace('/', '\\\\') - wine_drive = "Z:" - logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}") - - # Update existing path if found - for i, line in enumerate(lines): - stripped_line = line.strip() - # Case-insensitive comparison for section name (Wine registry is case-insensitive) - if stripped_line.split(']')[0].lower() + ']' == section_name.lower() if ']' in stripped_line else stripped_line.lower() == section_name.lower(): - in_target_section = True - elif stripped_line.startswith('[') and in_target_section: - in_target_section = False - elif in_target_section and f'"{path_key}"' in line: - lines[i] = f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n' # Add trailing backslashes - path_updated = True - break - - # Add new section if path wasn't updated - if not path_updated: - lines.append(f'\n{section_name}\n') - lines.append(f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n') # Add trailing backslashes - - # Write updated content - with open(system_reg_path, 'w', encoding='utf-8') as f: - f.writelines(lines) - - return True - - except Exception as e: - logger.error(f"Failed to update registry path: {e}") - return False - - def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str): - """Apply universal dotnet4.x compatibility registry fixes to ALL modlists""" - try: - prefix_path = os.path.join(modlist_compatdata_path, "pfx") - if not os.path.exists(prefix_path): - logger.warning(f"Prefix path not found: {prefix_path}") - return False - - logger.info("Applying universal dotnet4.x compatibility registry fixes...") - - # Find the appropriate Wine binary to use for registry operations - 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") - return False - - # Set environment for Wine registry operations - env = os.environ.copy() - env['WINEPREFIX'] = prefix_path - env['WINEDEBUG'] = '-all' # Suppress Wine debug output - - # Registry fix 1: Set *mscoree=native DLL override (asterisk for full override) - # This tells Wine to use native .NET runtime instead of Wine's implementation - 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' - ] - - 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") - else: - logger.warning(f"Failed to set *mscoree DLL override: {result1.stderr}") - - # Registry fix 2: Set OnlyUseLatestCLR=1 - # This prevents .NET version conflicts by using the latest CLR - 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 - - except Exception as e: - logger.error(f"Failed to apply universal dotnet4.x fixes: {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: - from ..handlers.config_handler import ConfigHandler - from ..handlers.wine_utils import WineUtils - - # Method 1: Use the user's configured Proton version from settings - config_handler = ConfigHandler() - user_proton_path = config_handler.get_game_proton_path() - - if user_proton_path and user_proton_path != 'auto': - # User has selected a specific Proton version - proton_path = Path(user_proton_path).expanduser() - - # Check for wine binary in both GE-Proton and Valve Proton structures - wine_candidates = [ - proton_path / "files" / "bin" / "wine", # GE-Proton structure - proton_path / "dist" / "bin" / "wine" # Valve Proton structure - ] - - for wine_path in wine_candidates: - if wine_path.exists() and wine_path.is_file(): - logger.info(f"Using Wine binary from user's configured Proton: {wine_path}") - return str(wine_path) - - # Wine binary not found at expected paths - search recursively in Proton directory - logger.debug(f"Wine binary not found at expected paths in {proton_path}, searching recursively...") - wine_binary = self._search_wine_in_proton_directory(proton_path) - if wine_binary: - logger.info(f"Found Wine binary via recursive search in Proton directory: {wine_binary}") - return wine_binary - - logger.warning(f"User's configured Proton path has no wine binary: {user_proton_path}") - - # Method 2: Fallback to auto-detection using WineUtils - best_proton = WineUtils.select_best_proton() - if best_proton: - wine_binary = WineUtils.find_proton_binary(best_proton['name']) - if wine_binary: - logger.info(f"Using Wine binary from detected Proton: {wine_binary}") - return wine_binary - - # NEVER fall back to system wine - it will break Proton prefixes with architecture mismatches - logger.error("No suitable Proton Wine binary found for registry operations") - return None - - except Exception as e: - logger.error(f"Error finding Wine binary: {e}") - return None - - def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]: - """ - Recursively search for wine binary within a Proton directory. - This handles cases where the directory structure might differ between Proton versions. - - Args: - proton_path: Path to the Proton directory to search - - Returns: - Path to wine binary if found, None otherwise - """ - try: - if not proton_path.exists() or not proton_path.is_dir(): - return None - - # Search for 'wine' executable (not 'wine64' or 'wine-preloader') - # Limit search depth to avoid scanning entire filesystem - max_depth = 5 - for root, dirs, files in os.walk(proton_path, followlinks=False): - # Calculate depth relative to proton_path - try: - depth = len(Path(root).relative_to(proton_path).parts) - except ValueError: - # Path is not relative to proton_path (shouldn't happen, but be safe) - continue - - if depth > max_depth: - dirs.clear() # Don't descend further - continue - - # Check if 'wine' is in this directory - if 'wine' in files: - wine_path = Path(root) / 'wine' - # Verify it's actually an executable file - if wine_path.is_file() and os.access(wine_path, os.X_OK): - logger.debug(f"Found wine binary at: {wine_path}") - return str(wine_path) - - return None - except Exception as e: - logger.debug(f"Error during recursive wine search in {proton_path}: {e}") - return None - - 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""" - 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") - return - - logger.info("Detecting game registry entries...") - - # NOTE: Universal dotnet4.x registry fixes now 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" - }, - "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" - } - } - - # Detect and inject each game - 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}") - success = self._update_registry_path( - system_reg_path, - config["registry_section"], - config["path_key"], - game_path - ) - if success: - logger.info(f"Updated registry entry for {config['name']}") - 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") - - def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str): - """ - Pre-create game-specific user directories to prevent first-launch issues. - - Creates both My Documents/My Games and AppData/Local directories for the game. - This prevents issues where games fail to create these on first launch under Proton. - """ - # Map game types to their directory names - game_dir_names = { - "skyrim": "Skyrim Special Edition", - "fnv": "FalloutNV", - "fo4": "Fallout4", - "oblivion": "Oblivion", - "oblivion_remastered": "Oblivion Remastered", - "enderal": "Enderal Special Edition", - "starfield": "Starfield" - } - - # Get the directory name for this game type - game_dir_name = game_dir_names.get(special_game_type) - if not game_dir_name: - logger.debug(f"No user directory mapping for game type: {special_game_type}") - return - - base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser") - - directories_to_create = [ - os.path.join(base_path, "Documents", "My Games", game_dir_name), - os.path.join(base_path, "AppData", "Local", game_dir_name) - ] - - created_count = 0 - for directory in directories_to_create: - try: - os.makedirs(directory, exist_ok=True) - logger.info(f"Created user directory: {directory}") - created_count += 1 - except Exception as e: - logger.warning(f"Failed to create directory {directory}: {e}") - - if created_count > 0: - logger.info(f"Created {created_count} user directories for {game_dir_name}") - - - diff --git a/jackify/backend/services/automated_prefix_shortcuts.py b/jackify/backend/services/automated_prefix_shortcuts.py new file mode 100644 index 0000000..737b795 --- /dev/null +++ b/jackify/backend/services/automated_prefix_shortcuts.py @@ -0,0 +1,534 @@ +"""Shortcut operation methods for AutomatedPrefixService (Mixin).""" +from pathlib import Path +from typing import Optional, Tuple, List, Dict +import logging +import os +import time +import vdf +import subprocess + +from .automated_prefix_shortcuts_cleanup import AutomatedPrefixShortcutsCleanupMixin + +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.""" + + def create_shortcut_with_native_service(self, shortcut_name: str, exe_path: str, + modlist_install_dir: str, custom_launch_options: str = None, + download_dir=None) -> Tuple[bool, Optional[int]]: + """ + Create a Steam shortcut using the native Steam service (no STL). + + Args: + shortcut_name: Name for the shortcut + exe_path: Path to the executable + modlist_install_dir: Directory where the modlist is installed + custom_launch_options: Pre-generated launch options (overrides default generation) + download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS + + Returns: + (success, unsigned_app_id) + """ + logger.info(f"Creating shortcut with native service: {shortcut_name}") + + try: + from ..services.native_steam_service import NativeSteamService + + # Initialize native Steam service + steam_service = NativeSteamService() + + # Use custom launch options if provided, otherwise generate default + if custom_launch_options: + launch_options = custom_launch_options + logger.info(f"Using pre-generated launch options: {launch_options}") + else: + # Generate STEAM_COMPAT_MOUNTS including install and download mountpoints + launch_options = "%command%" + try: + from ..handlers.path_handler import PathHandler + path_handler = PathHandler() + mount_paths = path_handler.get_steam_compat_mount_paths( + install_dir=modlist_install_dir, download_dir=download_dir + ) + if mount_paths: + launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%' + logger.info(f"Generated launch options with mounts: {launch_options}") + except Exception as e: + logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}") + launch_options = "%command%" + + # Get user's preferred Proton version (with Lorerim-specific override) + proton_version = self._get_user_proton_version(shortcut_name) + + # Create shortcut with Proton using native service + success, app_id = steam_service.create_shortcut_with_proton( + app_name=shortcut_name, + exe_path=exe_path, + start_dir=modlist_install_dir, + launch_options=launch_options, + tags=["Jackify"], + proton_version=proton_version + ) + + if success and app_id: + logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}") + return True, app_id + else: + logger.error("Native Steam service failed to create shortcut") + return False, None + + except Exception as e: + logger.error(f"Error creating shortcut with native service: {e}") + return False, None + + def verify_shortcut_created(self, shortcut_name: str) -> Optional[int]: + """ + Verify the shortcut was created and get its AppID. + + Args: + shortcut_name: Name of the shortcut to look for + + Returns: + AppID if found, None otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return None + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Look for our shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name in name: + appid = shortcut.get('appid') + exe_path = shortcut.get('Exe', '').strip('"') + + logger.info(f"Found shortcut: {name}") + logger.info(f" AppID: {appid}") + logger.info(f" Exe: {exe_path}") + logger.info(f" CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}") + + return appid + + logger.error(f"Shortcut '{shortcut_name}' not found") + return None + + except Exception as e: + logger.error(f"Error reading shortcuts: {e}") + return None + + def create_shortcut_directly(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool: + """ + Create a Steam shortcut directly by modifying shortcuts.vdf. + This is a fallback when STL fails. + + Args: + shortcut_name: Name for the shortcut + exe_path: Path to the executable + modlist_install_dir: Directory where the modlist is installed + + Returns: + True if successful, False otherwise + """ + try: + debug_print(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") + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the next available index + next_index = str(len(shortcuts)) + + # Calculate AppID for the new shortcut (negative for non-Steam shortcuts) + import hashlib + app_name_bytes = shortcut_name.encode('utf-8') + exe_bytes = exe_path.encode('utf-8') + combined = app_name_bytes + exe_bytes + hash_value = int(hashlib.md5(combined).hexdigest()[:8], 16) + appid = -(hash_value & 0x7FFFFFFF) # Make it negative and within 32-bit range + + # Create new shortcut entry + new_shortcut = { + 'AppName': shortcut_name, + 'Exe': f'"{exe_path}"', + 'StartDir': f'"{modlist_install_dir}"', + 'appid': appid, + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'openvr': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0, + 'FlatpakAppID': '', + 'tags': {}, + 'CompatTool': 'proton_experimental', # Set Proton Experimental + 'IsInstalled': 1 # Make it appear in "Locally Installed" filter + } + + # Add the new shortcut + shortcuts[next_index] = new_shortcut + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Created shortcut directly: {shortcut_name}") + return True + + except Exception as e: + 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: + """ + Update the existing batch file shortcut to point to the final executable. + This preserves the AppID and prefix association while changing the target. + + Args: + shortcut_name: Name of the shortcut to modify + final_exe_path: Path to the final executable (e.g., ModOrganizer.exe) + final_start_dir: Start directory for the executable + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the batch file shortcut that created the prefix + logger.info(f"Looking for batch file shortcut '{shortcut_name}' among {len(shortcuts)} shortcuts...") + target_shortcut = None + target_index = None + + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + exe = shortcut.get('Exe', '') + + # Find the specific shortcut that points to our batch file (handle quoted paths) + if (name == shortcut_name and + exe and 'prefix_creation_' in exe and (exe.endswith('.bat') or exe.endswith('.bat"'))): + target_shortcut = shortcut + target_index = str(i) + logger.info(f"Found batch file shortcut '{shortcut_name}' at index {i}") + logger.info(f" Current Exe: {exe}") + logger.info(f" Current StartDir: {shortcut.get('StartDir', '')}") + logger.info(f" Current CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}") + logger.info(f" AppID: {shortcut.get('appid', 'NOT_SET')}") + break + + if target_shortcut is None: + logger.error(f"No batch file shortcut found with name '{shortcut_name}'") + # Debug: show all available shortcuts + logger.debug("Available shortcuts:") + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + exe = shortcut.get('Exe', '') + logger.debug(f" [{i}] {name} -> {exe}") + return False + + # Update the existing shortcut IN-PLACE (preserves AppID and all other fields) + logger.info(f"Updating shortcut at index {target_index} IN-PLACE...") + + # Only change Exe and StartDir - preserve everything else including AppID + old_exe = target_shortcut.get('Exe', '') + old_start_dir = target_shortcut.get('StartDir', '') + + target_shortcut['Exe'] = f'"{final_exe_path}"' + target_shortcut['StartDir'] = f'"{final_start_dir}"' + + # Ensure CompatTool is set (STL should have set this, but make sure) + if not target_shortcut.get('CompatTool', '').strip(): + target_shortcut['CompatTool'] = 'proton_experimental' + logger.info("Set CompatTool to proton_experimental (was not set)") + + logger.info(f" Updated shortcut '{shortcut_name}' at index {target_index}:") + logger.info(f" Exe: {old_exe} → {target_shortcut['Exe']}") + logger.info(f" StartDir: {old_start_dir} → {target_shortcut['StartDir']}") + logger.info(f" AppID: {target_shortcut.get('appid', 'NOT_SET')} (preserved)") + logger.info(f" CompatTool: {target_shortcut.get('CompatTool', 'NOT_SET')} (preserved)") + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(" Shortcut updated successfully - no duplicates created") + return True + + except Exception as e: + logger.error(f"Error modifying shortcut: {e}") + return False + + def verify_final_shortcut(self, shortcut_name: str, expected_exe_path: str) -> bool: + """ + Verify the shortcut now points to the final executable. + + Args: + shortcut_name: Name of the shortcut to verify + expected_exe_path: Expected executable path + + Returns: + True if shortcut is correct, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find our shortcut + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name in name: + exe_path = shortcut.get('Exe', '') + start_dir = shortcut.get('StartDir', '') + + logger.info(f"Final shortcut configuration:") + logger.info(f" Name: {name}") + logger.info(f" Exe: {exe_path}") + logger.info(f" StartDir: {start_dir}") + + # Verify it points to the final executable + if expected_exe_path in exe_path: + logger.info("Shortcut correctly points to final executable") + return True + else: + logger.error("Shortcut does not point to final executable") + return False + + logger.error(f"Shortcut '{shortcut_name}' not found") + return False + + except Exception as e: + logger.error(f"Error reading shortcuts: {e}") + return False + diff --git a/jackify/backend/services/automated_prefix_shortcuts_cleanup.py b/jackify/backend/services/automated_prefix_shortcuts_cleanup.py new file mode 100644 index 0000000..8a78bfe --- /dev/null +++ b/jackify/backend/services/automated_prefix_shortcuts_cleanup.py @@ -0,0 +1,138 @@ +"""Cleanup and replacement logic for shortcut operations (Mixin).""" +from pathlib import Path +from typing import Optional, Tuple +import logging +import os +import vdf +import subprocess + +logger = logging.getLogger(__name__) + + +class AutomatedPrefixShortcutsCleanupMixin: + """Mixin providing cleanup_old_batch_shortcuts, modify_shortcut_target, replace_existing_shortcut.""" + + def cleanup_old_batch_shortcuts(self, shortcut_name: str) -> bool: + """Remove old batch file shortcuts for this modlist to prevent duplicates.""" + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + indices_to_remove = [] + + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + exe = shortcut.get('Exe', '') + + if (name == shortcut_name and + 'prefix_creation_' in exe and + exe.endswith('.bat')): + indices_to_remove.append(str(i)) + logger.info(f"Marking old batch shortcut for removal: {name} -> {exe}") + + if not indices_to_remove: + logger.debug(f"No old batch shortcuts found for '{shortcut_name}'") + return True + + new_shortcuts = {} + new_index = 0 + + for i in range(len(shortcuts)): + if str(i) not in indices_to_remove: + new_shortcuts[str(new_index)] = shortcuts[str(i)] + new_index += 1 + + shortcuts_data['shortcuts'] = new_shortcuts + + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Cleaned up {len(indices_to_remove)} old batch shortcuts for '{shortcut_name}'") + return True + + except Exception as e: + logger.error(f"Error cleaning up old shortcuts: {e}") + return False + + def modify_shortcut_target(self, shortcut_name: str, new_exe_path: str, new_start_dir: str) -> bool: + """Modify an existing shortcut's target and start directory. Preserves launch options.""" + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + logger.error("No shortcuts.vdf path found") + return False + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + if 'shortcuts' not in shortcuts_data: + logger.error("No shortcuts found in shortcuts.vdf") + return False + + shortcuts = shortcuts_data['shortcuts'] + shortcut_found = False + + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + if shortcut.get('AppName', '') == shortcut_name: + existing_launch_options = shortcut.get('LaunchOptions', '') + shortcut['Exe'] = new_exe_path + shortcut['StartDir'] = new_start_dir + shortcut['LaunchOptions'] = existing_launch_options + shortcut_found = True + logger.info(f"Modified shortcut '{shortcut_name}' to target: {new_exe_path}") + logger.info(f"Preserved launch options: {existing_launch_options}") + break + + if not shortcut_found: + logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf") + return False + + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Successfully modified shortcut '{shortcut_name}'") + return True + + except Exception as e: + logger.error(f"Error modifying shortcut: {e}") + return False + + def replace_existing_shortcut(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Tuple[bool, Optional[int]]: + """Replace an existing shortcut with a new one using STL, then create via native service.""" + try: + logger.info(f"Replacing existing shortcut: {shortcut_name}") + + appdir = os.environ.get('APPDIR') + if appdir: + stl_path = Path(appdir) / "opt" / "jackify" / "steamtinkerlaunch" + else: + project_root = Path(__file__).parent.parent.parent.parent.parent + stl_path = project_root / "external_repos/steamtinkerlaunch/steamtinkerlaunch" + + if not stl_path.exists(): + logger.error(f"STL not found at: {stl_path}") + return False, None + + remove_cmd = [str(stl_path), "rnsg", f"--appname={shortcut_name}"] + env = os.environ.copy() + env['STL_QUIET'] = '1' + + logger.info(f"Removing existing shortcut: {' '.join(remove_cmd)}") + result = subprocess.run(remove_cmd, capture_output=True, text=True, timeout=30, env=env) + + if result.returncode != 0: + logger.warning(f"Failed to remove existing shortcut: {result.stderr}") + + success, app_id = self.create_shortcut_with_native_service(shortcut_name, exe_path, modlist_install_dir) + return success, app_id + + except Exception as e: + logger.error(f"Error replacing shortcut: {e}") + return False, None diff --git a/jackify/backend/services/automated_prefix_stl.py b/jackify/backend/services/automated_prefix_stl.py new file mode 100644 index 0000000..5056331 --- /dev/null +++ b/jackify/backend/services/automated_prefix_stl.py @@ -0,0 +1,190 @@ +"""STL algorithm methods for AutomatedPrefixService (Mixin).""" +from pathlib import Path +from typing import Optional +import logging +import vdf +import binascii + +logger = logging.getLogger(__name__) + + +class STLAlgorithmMixin: + """Mixin providing Steam Tools Library algorithm methods for AutomatedPrefixService.""" + + def generate_steam_short_id(self, signed_appid: int) -> int: + """ + Convert signed 32-bit integer to unsigned 32-bit integer (same as STL's generateSteamShortID). + + Args: + signed_appid: Signed 32-bit integer AppID + + Returns: + Unsigned 32-bit integer AppID + """ + return signed_appid & 0xFFFFFFFF + + def find_appid_in_shortcuts_vdf(self, shortcut_name: str) -> Optional[str]: + """ + Find the AppID for a shortcut by name directly in shortcuts.vdf. + This is a fallback method when protontricks detection fails. + + Args: + shortcut_name: Name of the shortcut to find + + Returns: + AppID as string, or None if not found + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return None + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Look for shortcut by name + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + + if shortcut_name == name: + appid = shortcut.get('appid') + if appid: + logger.info(f"Found AppID {appid} for shortcut '{shortcut_name}' in shortcuts.vdf") + return str(appid) + + logger.warning(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf") + return None + + except Exception as e: + logger.error(f"Error finding AppID in shortcuts.vdf: {e}") + return None + + def predict_appid_using_stl_algorithm(self, shortcut_name: str, exe_path: str) -> Optional[int]: + """ + Predict the AppID using SteamTinkerLaunch's exact algorithm. + + This implements the same logic as STL's generateShortcutVDFAppId and generateSteamShortID functions: + 1. Combine AppName + ExePath + 2. Generate MD5 hash, take first 8 characters + 3. Convert to decimal, make negative, ensure < 1 billion + 4. Convert to unsigned 32-bit integer + + Args: + shortcut_name: Name of the shortcut + exe_path: Path to the executable + + Returns: + Predicted AppID as integer, or None if failed + """ + try: + import hashlib + + # Step 1: Combine AppName + ExePath (exactly like STL) + combined_string = f"{shortcut_name}{exe_path}" + logger.debug(f"Combined string for AppID prediction: '{combined_string}'") + + # Step 2: Generate MD5 hash and take first 8 characters + md5_hash = hashlib.md5(combined_string.encode()).hexdigest() + seed_hex = md5_hash[:8] + logger.debug(f"MD5 hash: {md5_hash}, seed hex: {seed_hex}") + + # Step 3: Convert to decimal, make negative, ensure < 1 billion + seed_decimal = int(seed_hex, 16) + signed_appid = -(seed_decimal % 1000000000) + logger.debug(f"Seed decimal: {seed_decimal}, signed AppID: {signed_appid}") + + # Step 4: Convert to unsigned 32-bit integer (STL's generateSteamShortID) + unsigned_appid = signed_appid & 0xFFFFFFFF + logger.debug(f"Unsigned AppID: {unsigned_appid}") + + logger.info(f"Predicted AppID using STL algorithm: {unsigned_appid} (signed: {signed_appid})") + return unsigned_appid + + except Exception as e: + logger.error(f"Error predicting AppID using STL algorithm: {e}") + return None + + def create_shortcut_with_stl_algorithm(self, shortcut_name: str, exe_path: str, start_dir: str, compatibility_tool: str = None) -> bool: + """ + Create a shortcut using STL's exact algorithm for consistent AppID calculation. + + Args: + shortcut_name: Name of the shortcut + exe_path: Path to the executable + start_dir: Start directory + compatibility_tool: Optional compatibility tool to set immediately (like STL does) + + Returns: + True if successful, False otherwise + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return False + + # Read current shortcuts + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + + # Find the next available index + next_index = str(len(shortcuts)) + + # Calculate AppID using STL's algorithm + predicted_appid = self.predict_appid_using_stl_algorithm(shortcut_name, exe_path) + if not predicted_appid: + logger.error("Failed to predict AppID for shortcut creation") + return False + + # Convert to signed AppID (STL stores the signed version in shortcuts.vdf) + signed_appid = predicted_appid + if predicted_appid > 0x7FFFFFFF: # If it's a large positive number, make it negative + signed_appid = predicted_appid - 0x100000000 + + # Create new shortcut entry + new_shortcut = { + 'AppName': shortcut_name, + 'Exe': f'"{exe_path}"', + 'StartDir': f'"{start_dir}"', + 'appid': signed_appid, # Use the signed AppID + 'icon': '', + 'ShortcutPath': '', + 'LaunchOptions': '', + 'IsHidden': 0, + 'AllowDesktopConfig': 1, + 'AllowOverlay': 1, + 'openvr': 0, + 'Devkit': 0, + 'DevkitGameID': '', + 'LastPlayTime': 0, + 'FlatpakAppID': '', + 'tags': {}, + 'IsInstalled': 1 # Make it appear in "Locally Installed" filter + } + + # Add the new shortcut + shortcuts[next_index] = new_shortcut + + # Write back to file + with open(shortcuts_path, 'wb') as f: + vdf.binary_dump(shortcuts_data, f) + + logger.info(f"Created shortcut with STL algorithm: {shortcut_name} with AppID {signed_appid} (unsigned: {predicted_appid})") + + # Set compatibility tool immediately if provided (like STL does) + if compatibility_tool: + logger.info(f"Setting compatibility tool immediately: {compatibility_tool}") + success = self.set_compatibility_tool_complete_stl_style(predicted_appid, compatibility_tool) + if not success: + logger.warning("Failed to set compatibility tool immediately") + + return True + + except Exception as e: + logger.error(f"Error creating shortcut with STL algorithm: {e}") + return False + diff --git a/jackify/backend/services/automated_prefix_workflow.py b/jackify/backend/services/automated_prefix_workflow.py new file mode 100644 index 0000000..10dac0f --- /dev/null +++ b/jackify/backend/services/automated_prefix_workflow.py @@ -0,0 +1,556 @@ +"""Workflow methods for AutomatedPrefixService (Mixin).""" +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.""" + + def handle_existing_shortcut_conflict(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Union[bool, List[Dict]]: + """ + Check for existing shortcut with same name and path, prompt user if found. + + Args: + shortcut_name: Name of the shortcut to create + exe_path: Path to the executable + modlist_install_dir: Directory where the modlist is installed + + Returns: + True if we should proceed (no conflict or user chose to replace), False if user cancelled + """ + try: + shortcuts_path = self._get_shortcuts_path() + if not shortcuts_path: + return True # No shortcuts file, no conflict + + with open(shortcuts_path, 'rb') as f: + shortcuts_data = vdf.binary_load(f) + + shortcuts = shortcuts_data.get('shortcuts', {}) + conflicts = [] + + # Look for shortcuts with the same name AND path + for i in range(len(shortcuts)): + shortcut = shortcuts[str(i)] + name = shortcut.get('AppName', '') + shortcut_exe = shortcut.get('Exe', '').strip('"') # Remove quotes + shortcut_startdir = shortcut.get('StartDir', '').strip('"') # Remove quotes + + # Check if name matches AND (exe path matches OR startdir matches) + # Use exact name match instead of partial match to avoid false positives + name_matches = shortcut_name == name + exe_matches = shortcut_exe == exe_path + startdir_matches = shortcut_startdir == modlist_install_dir + + if (name_matches and (exe_matches or startdir_matches)): + conflicts.append({ + 'index': i, + 'name': name, + 'exe': shortcut_exe, + 'startdir': shortcut_startdir + }) + + if conflicts: + logger.warning(f"Found {len(conflicts)} existing shortcut(s) with same name and path") + + # Log details about each conflict for debugging + for i, conflict in enumerate(conflicts): + logger.info(f"Conflict {i+1}: Name='{conflict['name']}', Exe='{conflict['exe']}', StartDir='{conflict['startdir']}'") + + # Return the conflict information so the frontend can handle it + return conflicts + else: + logger.debug("No conflicting shortcuts found") + return True + + except Exception as e: + logger.error(f"Error handling shortcut conflict: {e}") + return True # Proceed on error to avoid blocking + + def format_conflict_message(self, conflicts: List[Dict]) -> str: + """ + Format conflict information into a user-friendly message. + + Args: + conflicts: List of conflict dictionaries from handle_existing_shortcut_conflict + + Returns: + Formatted message for the user + """ + if not conflicts: + return "No conflicts found." + + message = f"Found {len(conflicts)} existing Steam shortcut(s) with the same name and path:\n\n" + + for i, conflict in enumerate(conflicts, 1): + message += f"{i}. **Name:** {conflict['name']}\n" + message += f" **Executable:** {conflict['exe']}\n" + message += f" **Start Directory:** {conflict['startdir']}\n\n" + + message += "**Options:**\n" + message += "• **Replace** - Remove the existing shortcut and create a new one\n" + message += "• **Cancel** - Keep the existing shortcut and stop the installation\n" + message += "• **Skip** - Continue without creating a Steam shortcut\n\n" + message += "The existing shortcut will be removed if you choose to replace it." + + 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]]: + """ + Run the proven working automated prefix creation workflow. + + This implements our tested and working approach: + 1. Create shortcut with native Steam service (pointing to ModOrganizer.exe initially) + 2. Restart Steam using Jackify's robust method + 3. Create Proton prefix invisibly using Proton wrapper with DISPLAY= + 4. Verify everything persists + + Args: + shortcut_name: Name for the Steam shortcut + modlist_install_dir: Directory where the modlist is installed + final_exe_path: Path to ModOrganizer.exe + progress_callback: Optional callback for progress updates + steamdeck: Optional Steam Deck detection override + download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS + auto_restart: If True, automatically restart Steam. If False, skip restart step. + + Returns: + Tuple of (success, prefix_path, appid, last_timestamp) + """ + logger.info("Starting proven working automated prefix creation workflow") + + # Show installation complete and configuration start headers FIRST + if progress_callback: + progress_callback("") + progress_callback("=" * 64) + progress_callback("= Installation phase complete =") + progress_callback("=" * 64) + progress_callback("") + progress_callback("=" * 64) + progress_callback("= Starting Configuration Phase =") + progress_callback("=" * 64) + progress_callback("") + + # Reset timing for Steam Integration section (part of Configuration Phase) + from jackify.shared.timing import start_new_phase + start_new_phase() + + # Show immediate feedback to user with section header + if progress_callback: + progress_callback("") # Blank line before Steam Integration + progress_callback("=== Steam Integration ===") + progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service") + + # Registry injection approach for both FNV and Enderal + from ..handlers.modlist_handler import ModlistHandler + 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 + custom_launch_options = None + if special_game_type in ["fnv", "enderal"]: + logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist") + else: + logger.debug("Standard modlist - no special game handling needed") + + try: + # Step 0: Shut down Steam before modifying VDF files + # Required to safely modify shortcuts.vdf and config.vdf without race conditions + logger.info("Step 0: Shutting down Steam before modifying VDF files") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Shutting down Steam...") + + from .steam_restart_service import shutdown_steam + try: + if not shutdown_steam(): + logger.warning("Steam shutdown returned False, continuing anyway") + except Exception as e: + logger.warning(f"Steam shutdown failed: {e}, continuing anyway") + + logger.info("Step 0 completed: Steam shut down") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam shut down") + + # Step 1: Create shortcut with native Steam service (Steam is now shut down) + logger.info("Step 1: Creating shortcut with native Steam service") + + # DISABLED: Shortcut conflict detection temporarily disabled pending rework + # Re-enable after conflict resolution workflow refactor + # When re-enabled, this will detect and handle cases where shortcuts with the same + # name and path already exist in Steam, allowing users to resolve conflicts + # Disabled pending workflow improvements - planned for future release + # conflict_result = self.handle_existing_shortcut_conflict(shortcut_name, final_exe_path, modlist_install_dir) + # if isinstance(conflict_result, list): # Conflicts found + # logger.warning(f"Found {len(conflict_result)} existing shortcut(s) with same name and path") + # # Return a special tuple to indicate conflict that needs user resolution + # return ("CONFLICT", conflict_result, None) + # elif not conflict_result: # User cancelled or other failure + # logger.error("User cancelled due to shortcut conflict") + # return False, None, None, None + logger.info("Conflict detection temporarily disabled - proceeding with shortcut creation") + + # Create shortcut using native Steam service with special game launch options + success, appid = self.create_shortcut_with_native_service( + shortcut_name, final_exe_path, modlist_install_dir, custom_launch_options, download_dir=download_dir + ) + if not success: + logger.error("Failed to create shortcut with native Steam service") + return False, None, None, None + + logger.info(f"Step 1 completed: Shortcut created with native service, AppID: {appid}") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully") + + # Apply Steam artwork if available + try: + from ..handlers.modlist_handler import ModlistHandler + modlist_handler = ModlistHandler() + modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir) + logger.info(f"Applied Steam artwork for shortcut '{shortcut_name}' (AppID: {appid})") + except Exception as e: + logger.warning(f"Failed to apply Steam artwork: {e}") + + # Step 2: Start Steam (if auto_restart enabled) + logger.info("Step 2: auto_restart=%s", auto_restart) + if auto_restart: + logger.info("Step 2: Starting Steam using Jackify's robust method") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Starting Steam...") + + restart_ok = self.restart_steam() + 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 + + logger.info("Step 2 completed: Steam started") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam started successfully") + else: + logger.info("Step 2 skipped: Auto-restart disabled by user") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam restart skipped (auto-restart disabled)") + + # Step 3: Create Proton prefix invisibly using Proton wrapper + logger.info("Step 3: Creating Proton prefix invisibly") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...") + + if not self.create_prefix_with_proton_wrapper(appid): + logger.error("Failed to create Proton prefix") + return False, None, None, None + + logger.info("Step 3 completed: Proton prefix created") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully") + + # Step 4: Verify everything persists + logger.info("Step 4: Verifying compatibility tool persists") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Verifying setup...") + + if not self.verify_compatibility_tool_persists(appid): + logger.warning("Compatibility tool verification failed, but continuing") + + logger.info("Step 4 completed: Verification done") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Setup verification completed") + + # Step 5: Inject game registry entries for FNV and Enderal modlists + # Get prefix path (needed for logging regardless of game type) + prefix_path = self.get_prefix_path(appid) + + if special_game_type in ["fnv", "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...") + + if prefix_path: + self._inject_game_registry_entries(str(prefix_path), special_game_type) + else: + 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") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...") + + if prefix_path: + self._create_game_user_directories(str(prefix_path), special_game_type) + else: + logger.warning("Could not find prefix path for directory creation") + + last_timestamp = self._get_progress_timestamp() + logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}") + if progress_callback: + progress_callback(f"{last_timestamp} Steam integration complete") + progress_callback("") # Blank line after Steam integration complete + + # Show Proton override notification if applicable + self._show_proton_override_notification(progress_callback) + + if progress_callback: + progress_callback("") # Extra blank line to span across Configuration Summary + progress_callback("") # And one more to create space before Prefix Configuration + + return True, prefix_path, appid, last_timestamp + + except Exception as e: + logger.error(f"Error in working workflow: {e}") + if progress_callback: + progress_callback(f"Error: {str(e)}") + return False, None, None, None + + 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]]: + """ + Continue the workflow after a shortcut conflict has been resolved. + + Args: + shortcut_name: Name of the shortcut + modlist_install_dir: Directory where the modlist is installed + final_exe_path: Path to the final executable + appid: The AppID of the shortcut that was created/replaced + progress_callback: Optional callback for progress updates + + Returns: + Tuple of (success, prefix_path, appid) + """ + try: + logger.info("Continuing workflow after conflict resolution") + + # Step 2: Restart Steam using Jackify's robust method + logger.info("Step 2: Restarting Steam using Jackify's robust method") + 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 + + logger.info("Step 2 completed: Steam restarted") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully") + + # Step 3: Create Proton prefix invisibly using Proton wrapper + logger.info("Step 3: Creating Proton prefix invisibly") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...") + + if not self.create_prefix_with_proton_wrapper(appid): + logger.error("Failed to create Proton prefix") + return False, None, None, None + + logger.info("Step 3 completed: Proton prefix created") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully") + + # Step 4: Verify everything persists + logger.info("Step 4: Verifying compatibility tool persists") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Verifying setup...") + + if not self.verify_compatibility_tool_persists(appid): + logger.warning("Compatibility tool verification failed, but continuing") + + logger.info("Step 4 completed: Verification done") + if progress_callback: + progress_callback(f"{self._get_progress_timestamp()} Setup verification completed") + + # Get the prefix path + prefix_path = self.get_prefix_path(appid) + + last_timestamp = self._get_progress_timestamp() + logger.info(f" Workflow completed successfully after conflict resolution! AppID: {appid}, Prefix: {prefix_path}") + if progress_callback: + progress_callback(f"{last_timestamp} Automated Steam setup completed successfully!") + + return True, prefix_path, appid, last_timestamp + + except Exception as e: + logger.error(f"Error continuing workflow after conflict resolution: {e}") + if progress_callback: + progress_callback(f"Error: {str(e)}") + return False, None, None, None + diff --git a/jackify/backend/services/modlist_gallery_service.py b/jackify/backend/services/modlist_gallery_service.py index 9106000..0a23dac 100644 --- a/jackify/backend/services/modlist_gallery_service.py +++ b/jackify/backend/services/modlist_gallery_service.py @@ -7,11 +7,14 @@ import json import subprocess import time import threading +import logging from pathlib import Path from typing import Optional, List, Dict from datetime import datetime, timedelta import urllib.request +logger = logging.getLogger(__name__) + from jackify.backend.models.modlist_metadata import ( ModlistMetadataResponse, ModlistMetadata, @@ -120,7 +123,7 @@ class ModlistGalleryService: # Execute command # CRITICAL: Use centralized clean environment to prevent AppImage recursive spawning - # This must happen AFTER engine path resolution + # Must happen AFTER engine path resolution from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env clean_env = get_clean_subprocess_env() @@ -290,7 +293,7 @@ class ModlistGalleryService: cmd[0] = str(engine_path) # CRITICAL: Use centralized clean environment to prevent AppImage recursive spawning - # This must happen AFTER engine path resolution + # Must happen AFTER engine path resolution from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env clean_env = get_clean_subprocess_env() @@ -394,7 +397,7 @@ class ModlistGalleryService: data = json.loads(response.read().decode('utf-8')) return data except Exception as e: - print(f"Warning: Could not load tag mappings: {e}") + logger.warning(f"Could not load tag mappings: {e}") return {} def load_allowed_tags(self) -> set: @@ -410,7 +413,7 @@ class ModlistGalleryService: data = json.loads(response.read().decode('utf-8')) return set(data) # Return as set preserving original case except Exception as e: - print(f"Warning: Could not load allowed tags: {e}") + logger.warning(f"Could not load allowed tags: {e}") return set() def _ensure_tag_metadata(self): diff --git a/jackify/backend/services/modlist_service.py b/jackify/backend/services/modlist_service.py index 2ee3e79..5237ce6 100644 --- a/jackify/backend/services/modlist_service.py +++ b/jackify/backend/services/modlist_service.py @@ -11,10 +11,12 @@ from pathlib import Path from ..models.modlist import ModlistContext, ModlistInfo from ..models.configuration import SystemInfo +from .modlist_service_installation import ModlistServiceInstallationMixin + logger = logging.getLogger(__name__) -class ModlistService: +class ModlistService(ModlistServiceInstallationMixin): """Service for managing modlist operations.""" def __init__(self, system_info: SystemInfo): @@ -143,268 +145,7 @@ class ModlistService: except Exception as e: logger.error(f"Failed to list modlists: {e}") raise - - def install_modlist(self, context: ModlistContext, - progress_callback=None, - output_callback=None) -> bool: - """Install a modlist (ONLY installation, no configuration). - - This method only runs the engine installation phase. - Configuration must be called separately after Steam setup. - - Args: - context: Modlist installation context - progress_callback: Optional callback for progress updates - output_callback: Optional callback for output/logging - - Returns: - True if installation successful, False otherwise - """ - logger.info(f"Installing modlist (INSTALLATION ONLY): {context.name}") - - try: - # Validate context - if not self._validate_install_context(context): - logger.error("Invalid installation context") - return False - # Prepare directories - fs_handler = self._get_filesystem_handler() - fs_handler.ensure_directory(context.install_dir) - fs_handler.ensure_directory(context.download_dir) - - # Use the working ModlistInstallCLI for discovery phase only - from ..core.modlist_operations import ModlistInstallCLI - - # Use new SystemInfo pattern - modlist_cli = ModlistInstallCLI(self.system_info) - - # Build context for ModlistInstallCLI - install_context = { - 'modlist_name': context.name, - 'install_dir': context.install_dir, - 'download_dir': context.download_dir, - 'nexus_api_key': context.nexus_api_key, - 'game_type': context.game_type, - 'modlist_value': context.modlist_value, - 'resolution': getattr(context, 'resolution', None), - 'skip_confirmation': True # Service layer should be non-interactive - } - - # Set GUI mode for non-interactive operation - import os - original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') - os.environ['JACKIFY_GUI_MODE'] = '1' - - try: - # Run discovery phase with pre-filled context - confirmed_context = modlist_cli.run_discovery_phase(context_override=install_context) - if not confirmed_context: - logger.error("Discovery phase failed or was cancelled") - return False - - # Now run ONLY the installation part (NOT configuration) - success = self._run_installation_only( - confirmed_context, - progress_callback=progress_callback, - output_callback=output_callback - ) - - if success: - logger.info("Modlist installation completed successfully (configuration will be done separately)") - return True - else: - logger.error("Modlist installation failed") - return False - - finally: - # Restore original GUI mode - if original_gui_mode is not None: - os.environ['JACKIFY_GUI_MODE'] = original_gui_mode - else: - os.environ.pop('JACKIFY_GUI_MODE', None) - - except Exception as e: - error_message = str(e) - logger.error(f"Failed to install modlist {context.name}: {error_message}") - - # Check for file descriptor limit issues and attempt to handle them - from .resource_manager import handle_file_descriptor_error - try: - if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']): - result = handle_file_descriptor_error(error_message, "modlist installation") - if result['auto_fix_success']: - logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") - elif result['error_detected']: - logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}") - if result['manual_instructions']: - distro = result['manual_instructions']['distribution'] - logger.info(f"Manual ulimit increase instructions available for {distro} distribution") - except Exception as resource_error: - logger.debug(f"Error checking for resource limit issues: {resource_error}") - - return False - - def _run_installation_only(self, context, progress_callback=None, output_callback=None) -> bool: - """Run only the installation phase using the engine (COPIED FROM WORKING CODE).""" - import subprocess - import os - import sys - from pathlib import Path - from ..core.modlist_operations import get_jackify_engine_path - - try: - # COPIED EXACTLY from working Archive_Do_Not_Write/modules/modlist_install_cli.py - - # Process paths (copied from working code) - install_dir_context = context['install_dir'] - if isinstance(install_dir_context, tuple): - actual_install_path = Path(install_dir_context[0]) - if install_dir_context[1]: - actual_install_path.mkdir(parents=True, exist_ok=True) - else: - actual_install_path = Path(install_dir_context) - install_dir_str = str(actual_install_path) - - download_dir_context = context['download_dir'] - if isinstance(download_dir_context, tuple): - actual_download_path = Path(download_dir_context[0]) - if download_dir_context[1]: - actual_download_path.mkdir(parents=True, exist_ok=True) - else: - actual_download_path = Path(download_dir_context) - download_dir_str = str(actual_download_path) - - # CRITICAL: Re-check authentication right before launching engine - # This ensures we use current auth state, not stale cached values from context - # (e.g., if user revoked OAuth after context was created) - from ..services.nexus_auth_service import NexusAuthService - auth_service = NexusAuthService() - current_api_key, current_oauth_info = auth_service.get_auth_for_engine() - - # Use current auth state, fallback to context values only if current check failed - api_key = current_api_key or context.get('nexus_api_key') - oauth_info = current_oauth_info or context.get('nexus_oauth_info') - - # Path to the engine binary (copied from working code) - engine_path = get_jackify_engine_path() - engine_dir = os.path.dirname(engine_path) - if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): - if output_callback: - output_callback(f"Jackify Install Engine not found or not executable at: {engine_path}") - return False - - # Build command (copied from working code) - cmd = [engine_path, 'install', '--show-file-progress'] - - modlist_value = context.get('modlist_value') - if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): - cmd += ['-w', modlist_value] - elif modlist_value: - cmd += ['-m', modlist_value] - elif context.get('machineid'): - cmd += ['-m', context['machineid']] - cmd += ['-o', install_dir_str, '-d', download_dir_str] - - # NOTE: API key is passed via environment variable only, not as command line argument - - # Store original environment values (copied from working code) - original_env_values = { - 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), - 'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), - 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') - } - - try: - # Environment setup - prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY - if oauth_info: - os.environ['NEXUS_OAUTH_INFO'] = oauth_info - # CRITICAL: Set client_id so engine can refresh tokens with correct client_id - # Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack" - from jackify.backend.services.nexus_oauth_service import NexusOAuthService - os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID - # Also set NEXUS_API_KEY for backward compatibility - if api_key: - os.environ['NEXUS_API_KEY'] = api_key - elif api_key: - os.environ['NEXUS_API_KEY'] = api_key - else: - # No auth available, clear any inherited values - if 'NEXUS_API_KEY' in os.environ: - del os.environ['NEXUS_API_KEY'] - if 'NEXUS_OAUTH_INFO' in os.environ: - del os.environ['NEXUS_OAUTH_INFO'] - - os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" - - pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) - if output_callback: - output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}") - - # Temporarily increase file descriptor limit for engine process - from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit - success, old_limit, new_limit, message = increase_file_descriptor_limit() - if output_callback: - if success: - output_callback(f"File descriptor limit: {message}") - else: - output_callback(f"File descriptor limit warning: {message}") - - # Subprocess call with cleaned environment to prevent AppImage variable inheritance - from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env - clean_env = get_clean_subprocess_env() - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir) - - # Output processing (copied from working code) - buffer = b'' - while True: - chunk = proc.stdout.read(1) - if not chunk: - break - buffer += chunk - - if chunk == b'\n': - line = buffer.decode('utf-8', errors='replace') - if output_callback: - output_callback(line.rstrip()) - buffer = b'' - elif chunk == b'\r': - line = buffer.decode('utf-8', errors='replace') - if output_callback: - output_callback(line.rstrip()) - buffer = b'' - - if buffer: - line = buffer.decode('utf-8', errors='replace') - if output_callback: - output_callback(line.rstrip()) - - proc.wait() - if proc.returncode != 0: - if output_callback: - output_callback(f"Jackify Install Engine exited with code {proc.returncode}.") - return False - else: - if output_callback: - output_callback("Installation completed successfully") - return True - - finally: - # Restore environment (copied from working code) - for key, original_value in original_env_values.items(): - if original_value is not None: - os.environ[key] = original_value - else: - if key in os.environ: - del os.environ[key] - - except Exception as e: - error_msg = f"Error running Jackify Install Engine: {e}" - logger.error(error_msg) - if output_callback: - output_callback(error_msg) - return False - def configure_modlist_post_steam(self, context: ModlistContext, progress_callback=None, manual_steps_callback=None, @@ -503,7 +244,8 @@ class ModlistService: 'skip_confirmation': True, # Service layer should be non-interactive 'manual_steps_completed': True, # Manual steps were done in GUI 'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam - 'engine_installed': getattr(context, 'engine_installed', False) # Path manipulation flag + 'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag + 'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None, } debug_callback(f"Configuration context built: {config_context}") @@ -682,7 +424,8 @@ class ModlistService: 'resolution': getattr(context, 'resolution', None), 'skip_confirmation': True, # Service layer should be non-interactive 'manual_steps_completed': False, - 'appid': getattr(context, 'app_id', None) # Fix: Include appid like other configuration paths + 'appid': getattr(context, 'app_id', None), # Fix: Include appid like other configuration paths + 'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None, } # DEBUG: Log what resolution we're passing diff --git a/jackify/backend/services/modlist_service_installation.py b/jackify/backend/services/modlist_service_installation.py new file mode 100644 index 0000000..e1d1b00 --- /dev/null +++ b/jackify/backend/services/modlist_service_installation.py @@ -0,0 +1,237 @@ +""" +Modlist installation phase for ModlistService (Mixin). + +Runs engine installation only; configuration is handled separately after Steam setup. +""" + +import logging +import os +import subprocess +from pathlib import Path + +from ..models.modlist import ModlistContext + +logger = logging.getLogger(__name__) + + +class ModlistServiceInstallationMixin: + """Mixin providing install_modlist and _run_installation_only for ModlistService.""" + + def install_modlist(self, context: ModlistContext, + progress_callback=None, + output_callback=None) -> bool: + """Install a modlist (installation only, no configuration). + + Configuration must be called separately after Steam setup. + """ + logger.info(f"Installing modlist (INSTALLATION ONLY): {context.name}") + + try: + if not self._validate_install_context(context): + logger.error("Invalid installation context") + return False + + fs_handler = self._get_filesystem_handler() + fs_handler.ensure_directory(context.install_dir) + fs_handler.ensure_directory(context.download_dir) + + from ..core.modlist_operations import ModlistInstallCLI + + modlist_cli = ModlistInstallCLI(self.system_info) + + install_context = { + 'modlist_name': context.name, + 'install_dir': context.install_dir, + 'download_dir': context.download_dir, + 'nexus_api_key': context.nexus_api_key, + 'game_type': context.game_type, + 'modlist_value': context.modlist_value, + 'resolution': getattr(context, 'resolution', None), + 'skip_confirmation': True + } + + original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') + os.environ['JACKIFY_GUI_MODE'] = '1' + + try: + confirmed_context = modlist_cli.run_discovery_phase(context_override=install_context) + if not confirmed_context: + logger.error("Discovery phase failed or was cancelled") + return False + + success = self._run_installation_only( + confirmed_context, + progress_callback=progress_callback, + output_callback=output_callback + ) + + if success: + logger.info("Modlist installation completed successfully (configuration done separately)") + return True + logger.error("Modlist installation failed") + return False + + finally: + if original_gui_mode is not None: + os.environ['JACKIFY_GUI_MODE'] = original_gui_mode + else: + os.environ.pop('JACKIFY_GUI_MODE', None) + + except Exception as e: + error_message = str(e) + logger.error(f"Failed to install modlist {context.name}: {error_message}") + + from .resource_manager import handle_file_descriptor_error + try: + if any(indicator in error_message.lower() for indicator in + ['too many open files', 'emfile', 'resource temporarily unavailable']): + result = handle_file_descriptor_error(error_message, "modlist installation") + if result['auto_fix_success']: + logger.info(f"File descriptor limit increased automatically. {result['recommendation']}") + elif result['error_detected']: + logger.warning(f"File descriptor issue detected but automatic fix failed. {result['recommendation']}") + if result.get('manual_instructions'): + distro = result['manual_instructions']['distribution'] + logger.info(f"Manual ulimit increase instructions available for {distro} distribution") + except Exception as resource_error: + logger.debug(f"Error checking for resource limit issues: {resource_error}") + + return False + + def _run_installation_only(self, context, progress_callback=None, output_callback=None) -> bool: + """Run only the installation phase using the engine.""" + from ..core.modlist_operations import get_jackify_engine_path + + try: + install_dir_context = context['install_dir'] + if isinstance(install_dir_context, tuple): + actual_install_path = Path(install_dir_context[0]) + if install_dir_context[1]: + actual_install_path.mkdir(parents=True, exist_ok=True) + else: + actual_install_path = Path(install_dir_context) + install_dir_str = str(actual_install_path) + + download_dir_context = context['download_dir'] + if isinstance(download_dir_context, tuple): + actual_download_path = Path(download_dir_context[0]) + if download_dir_context[1]: + actual_download_path.mkdir(parents=True, exist_ok=True) + else: + actual_download_path = Path(download_dir_context) + download_dir_str = str(actual_download_path) + + from ..services.nexus_auth_service import NexusAuthService + auth_service = NexusAuthService() + current_api_key, current_oauth_info = auth_service.get_auth_for_engine() + + api_key = current_api_key or context.get('nexus_api_key') + oauth_info = current_oauth_info or context.get('nexus_oauth_info') + + engine_path = get_jackify_engine_path() + engine_dir = os.path.dirname(engine_path) + if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): + if output_callback: + output_callback(f"Jackify Install Engine not found or not executable at: {engine_path}") + return False + + cmd = [engine_path, 'install', '--show-file-progress'] + + modlist_value = context.get('modlist_value') + if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): + cmd += ['-w', modlist_value] + elif modlist_value: + cmd += ['-m', modlist_value] + elif context.get('machineid'): + cmd += ['-m', context['machineid']] + cmd += ['-o', install_dir_str, '-d', download_dir_str] + + original_env_values = { + 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), + 'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'), + 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') + } + + try: + if oauth_info: + os.environ['NEXUS_OAUTH_INFO'] = oauth_info + from jackify.backend.services.nexus_oauth_service import NexusOAuthService + os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID + if api_key: + os.environ['NEXUS_API_KEY'] = api_key + elif api_key: + os.environ['NEXUS_API_KEY'] = api_key + else: + if 'NEXUS_API_KEY' in os.environ: + del os.environ['NEXUS_API_KEY'] + if 'NEXUS_OAUTH_INFO' in os.environ: + del os.environ['NEXUS_OAUTH_INFO'] + + os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" + + pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) + if output_callback: + output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}") + + from jackify.backend.handlers.subprocess_utils import ( + increase_file_descriptor_limit, + get_clean_subprocess_env, + ) + success, old_limit, new_limit, message = increase_file_descriptor_limit() + if output_callback: + if success: + output_callback(f"File descriptor limit: {message}") + else: + output_callback(f"File descriptor limit warning: {message}") + + clean_env = get_clean_subprocess_env() + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=False, env=clean_env, cwd=engine_dir + ) + + buffer = b'' + while True: + chunk = proc.stdout.read(1) + if not chunk: + break + buffer += chunk + + if chunk == b'\n': + line = buffer.decode('utf-8', errors='replace') + if output_callback: + output_callback(line.rstrip()) + buffer = b'' + elif chunk == b'\r': + line = buffer.decode('utf-8', errors='replace') + if output_callback: + output_callback(line.rstrip()) + buffer = b'' + + if buffer: + line = buffer.decode('utf-8', errors='replace') + if output_callback: + output_callback(line.rstrip()) + + proc.wait() + if proc.returncode != 0: + if output_callback: + output_callback(f"Jackify Install Engine exited with code {proc.returncode}.") + return False + if output_callback: + output_callback("Installation completed successfully") + return True + + finally: + for key, original_value in original_env_values.items(): + if original_value is not None: + os.environ[key] = original_value + elif key in os.environ: + del os.environ[key] + + except Exception as e: + error_msg = f"Error running Jackify Install Engine: {e}" + logger.error(error_msg) + if output_callback: + output_callback(error_msg) + return False diff --git a/jackify/backend/services/native_steam_service.py b/jackify/backend/services/native_steam_service.py index 835e349..a360dc7 100644 --- a/jackify/backend/services/native_steam_service.py +++ b/jackify/backend/services/native_steam_service.py @@ -451,7 +451,7 @@ class NativeSteamService: if app_id_exists: logger.info(f"AppID {app_id} already exists in CompatToolMapping, will be overwritten") # Remove the existing entry by finding and removing the entire block - # This is complex, so for now just add at the end + # Complex ordering -- just append for now # Create the new entry in STL's exact format (tabs between key and value) new_entry = f'\t\t\t\t\t"{app_id}"\n\t\t\t\t\t{{\n\t\t\t\t\t\t"name"\t\t"{proton_version}"\n\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"250"\n\t\t\t\t\t}}\n' @@ -574,8 +574,7 @@ class NativeSteamService: """ Create symlink to libraryfolders.vdf in Wine prefix for game detection. - This allows Wabbajack running in the prefix to detect Steam games. - Based on Wabbajack-Proton-AuCu implementation. + Allows Wabbajack running in the prefix to detect Steam games. Args: app_id: Steam AppID (unsigned) diff --git a/jackify/backend/services/nexus_auth_service.py b/jackify/backend/services/nexus_auth_service.py index 1459a12..23c8fd9 100644 --- a/jackify/backend/services/nexus_auth_service.py +++ b/jackify/backend/services/nexus_auth_service.py @@ -259,7 +259,7 @@ class NexusAuthService: oauth_data = token_data.get('oauth', {}) # Build NexusOAuthState JSON matching upstream Wabbajack format - # This allows engine to auto-refresh tokens during long installations + # Engine auto-refreshes tokens during long installations nexus_oauth_state = { "oauth": { "access_token": oauth_data.get('access_token'), diff --git a/jackify/backend/services/nexus_download_service.py b/jackify/backend/services/nexus_download_service.py index 1345f8b..ddb30c5 100644 --- a/jackify/backend/services/nexus_download_service.py +++ b/jackify/backend/services/nexus_download_service.py @@ -184,7 +184,9 @@ class NexusDownloadService: if file_name_filter: filtered = [f for f in files if file_name_filter.lower() in f.get('file_name', '').lower()] if not filtered: - return False, None, f"No files found matching '{file_name_filter}'" + available_files = [f.get('file_name', 'unknown') for f in files] + logger.warning(f"No files matching '{file_name_filter}' in: {available_files}") + return False, None, f"No files found matching '{file_name_filter}'. Available: {', '.join(available_files)}" files = filtered # Get the most recent file diff --git a/jackify/backend/services/nexus_oauth_callback.py b/jackify/backend/services/nexus_oauth_callback.py new file mode 100644 index 0000000..99e1297 --- /dev/null +++ b/jackify/backend/services/nexus_oauth_callback.py @@ -0,0 +1,147 @@ +""" +Nexus OAuth callback: _generate_self_signed_cert, _create_callback_handler, _wait_for_callback. +""" + +import os +import time +import logging +import tempfile +import urllib.parse +from pathlib import Path +from http.server import BaseHTTPRequestHandler +from typing import Optional, Tuple + +logger = logging.getLogger(__name__) + + +class NexusOAuthCallbackMixin: + """Mixin providing callback server and wait logic for NexusOAuthService.""" + + def _generate_self_signed_cert(self) -> Tuple[Optional[str], Optional[str]]: + """Generate self-signed certificate for HTTPS localhost. Returns (cert_file_path, key_file_path) or (None, None).""" + redirect_host = getattr(self, 'REDIRECT_HOST', '127.0.0.1') + try: + from cryptography import x509 + from cryptography.x509.oid import NameOID + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives import serialization + import datetime + import ipaddress + logger.info("Generating self-signed certificate for OAuth callback") + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Jackify"), + x509.NameAttribute(NameOID.COMMON_NAME, redirect_host), + ]) + cert = x509.CertificateBuilder().subject_name(subject).issuer_name(issuer).public_key( + private_key.public_key() + ).serial_number(x509.random_serial_number()).not_valid_before( + datetime.datetime.now(datetime.UTC) + ).not_valid_after( + datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365) + ).add_extension( + x509.SubjectAlternativeName([x509.IPAddress(ipaddress.IPv4Address(redirect_host))]), + critical=False, + ).sign(private_key, hashes.SHA256()) + temp_dir = tempfile.mkdtemp() + cert_file = os.path.join(temp_dir, "oauth_cert.pem") + key_file = os.path.join(temp_dir, "oauth_key.pem") + with open(cert_file, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + with open(key_file, "wb") as f: + f.write(private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + )) + return cert_file, key_file + except ImportError: + logger.error("cryptography package not installed - required for OAuth") + return None, None + except Exception as e: + logger.error("Failed to generate SSL certificate: %s", e) + return None, None + + def _create_callback_handler(self): + """Create HTTP request handler class for OAuth callback.""" + service = self + class OAuthCallbackHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + logger.debug("OAuth callback: %s", format % args) + def do_GET(self): + logger.info("OAuth callback received: %s", self.path) + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + if parsed.path == '/favicon.ico': + self.send_response(404) + self.end_headers() + return + if 'code' in params: + service._auth_code = params['code'][0] + service._auth_state = params.get('state', [None])[0] + logger.info("OAuth authorization code received: %s...", service._auth_code[:10]) + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + html = """Authorization Successful

Authorization Successful!

You can close this window and return to Jackify.

""" + self.wfile.write(html.encode()) + elif 'error' in params: + service._auth_error = params['error'][0] + error_desc = params.get('error_description', ['Unknown error'])[0] + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + html = f"Authorization Failed

Authorization Failed

Error: {service._auth_error}

{error_desc}

You can close this window and try again in Jackify.

" + self.wfile.write(html.encode()) + else: + logger.warning("OAuth callback with no code or error: %s", params) + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + html = "Invalid Request

Invalid OAuth Callback

You can close this window.

" + self.wfile.write(html.encode()) + service._server_done.set() + logger.debug("OAuth callback handler signaled server to shut down") + return OAuthCallbackHandler + + def _wait_for_callback(self) -> bool: + """Wait for OAuth callback via jackify:// protocol handler. Returns True if callback received.""" + callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp" + if callback_file.exists(): + callback_file.unlink() + logger.info("Waiting for OAuth callback via jackify:// protocol") + start_time = time.time() + last_reminder = 0 + while (time.time() - start_time) < self.CALLBACK_TIMEOUT: + if callback_file.exists(): + try: + lines = callback_file.read_text().strip().split('\n') + if len(lines) >= 2: + self._auth_code = lines[0] + self._auth_state = lines[1] + logger.info("OAuth callback received: code=%s...", self._auth_code[:10]) + callback_file.unlink() + return True + except Exception as e: + logger.error("Failed to read callback file: %s", e) + return False + elapsed = time.time() - start_time + if elapsed - last_reminder > 30: + logger.info("Still waiting for OAuth callback... (%ss elapsed)", int(elapsed)) + if elapsed > 60: + logger.warning( + "If you see a blank browser tab, check for browser notifications asking to " + "'Open Jackify', or use 'Paste callback URL' in Jackify to paste the URL from the address bar" + ) + last_reminder = elapsed + time.sleep(0.5) + logger.error("OAuth callback timeout after %s seconds", self.CALLBACK_TIMEOUT) + logger.error( + "Protocol handler may not be working. Check:\n" + " 1. Browser asked 'Open Jackify?' and you clicked Allow\n" + " 2. No popup blocker notifications\n" + " 3. Desktop file exists: ~/.local/share/applications/com.jackify.app.desktop" + ) + return False diff --git a/jackify/backend/services/nexus_oauth_protocol.py b/jackify/backend/services/nexus_oauth_protocol.py new file mode 100644 index 0000000..904930b --- /dev/null +++ b/jackify/backend/services/nexus_oauth_protocol.py @@ -0,0 +1,127 @@ +""" +Nexus OAuth protocol handler registration: _ensure_protocol_registered. +""" + +import os +import sys +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class NexusOAuthProtocolMixin: + """Mixin providing jackify:// protocol registration for NexusOAuthService.""" + + def _ensure_protocol_registered(self) -> bool: + """Ensure jackify:// protocol is registered with the OS.""" + import subprocess + if not sys.platform.startswith('linux'): + logger.debug("Protocol registration only needed on Linux") + return True + try: + desktop_file = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop" + env = os.environ + is_appimage = ( + 'APPIMAGE' in env or 'APPDIR' in env or + (sys.argv[0] and sys.argv[0].endswith('.AppImage')) + ) + if is_appimage: + if 'APPIMAGE' in env: + exec_path = env['APPIMAGE'] + logger.info("Using APPIMAGE env var: %s", exec_path) + elif sys.argv[0] and Path(sys.argv[0]).exists(): + exec_path = str(Path(sys.argv[0]).resolve()) + logger.info("Using resolved sys.argv[0]: %s", exec_path) + else: + exec_path = sys.argv[0] + logger.warning("Using sys.argv[0] as fallback: %s", exec_path) + else: + src_dir = Path(__file__).resolve().parent.parent.parent.parent + exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --' + logger.info("DEV mode exec path: %s", exec_path) + logger.info("Source directory: %s", src_dir) + needs_update = False + if not desktop_file.exists(): + needs_update = True + logger.info("Creating desktop file for protocol handler") + else: + current_content = desktop_file.read_text() + if is_appimage: + expected_exec = f'Exec="{exec_path}" %u' + else: + expected_exec = f"Exec={exec_path} %u" + if expected_exec not in current_content: + needs_update = True + logger.info("Updating desktop file with new Exec path: %s", exec_path) + if is_appimage and ' ' in exec_path: + import re + if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content): + needs_update = True + logger.info("Fixing malformed desktop file (unquoted path with spaces)") + if needs_update: + desktop_file.parent.mkdir(parents=True, exist_ok=True) + if is_appimage: + desktop_content = f"""[Desktop Entry] +Type=Application +Name=Jackify +Comment=Wabbajack modlist manager for Linux +Exec="{exec_path}" %u +Icon=com.jackify.app +Terminal=false +Categories=Game;Utility; +MimeType=x-scheme-handler/jackify; +""" + else: + src_dir = Path(__file__).resolve().parent.parent.parent.parent + desktop_content = f"""[Desktop Entry] +Type=Application +Name=Jackify +Comment=Wabbajack modlist manager for Linux +Exec={exec_path} %u +Icon=com.jackify.app +Terminal=false +Categories=Game;Utility; +MimeType=x-scheme-handler/jackify; +Path={src_dir} +""" + desktop_file.write_text(desktop_content) + logger.info("Desktop file written: %s", desktop_file) + logger.info("Exec path: %s", exec_path) + logger.info("AppImage mode: %s", is_appimage) + logger.info("Registering jackify:// protocol handler") + apps_dir = Path.home() / ".local" / "share" / "applications" + subprocess.run(['update-desktop-database', str(apps_dir)], capture_output=True, timeout=10) + subprocess.run( + ['xdg-mime', 'default', 'com.jackify.app.desktop', 'x-scheme-handler/jackify'], + capture_output=True, timeout=10 + ) + subprocess.run( + ['xdg-settings', 'set', 'default-url-scheme-handler', 'jackify', 'com.jackify.app.desktop'], + capture_output=True, timeout=10 + ) + mimeapps_path = Path.home() / ".config" / "mimeapps.list" + try: + if mimeapps_path.exists(): + content = mimeapps_path.read_text() + else: + mimeapps_path.parent.mkdir(parents=True, exist_ok=True) + content = "[Default Applications]\n" + if 'x-scheme-handler/jackify=' not in content: + if '[Default Applications]' not in content: + content = "[Default Applications]\n" + content + lines = content.split('\n') + for i, line in enumerate(lines): + if line.strip() == '[Default Applications]': + lines.insert(i + 1, 'x-scheme-handler/jackify=com.jackify.app.desktop') + break + content = '\n'.join(lines) + mimeapps_path.write_text(content) + logger.info("Added jackify handler to mimeapps.list") + except Exception as e: + logger.warning("Failed to update mimeapps.list: %s", e) + logger.info("jackify:// protocol registered successfully") + return True + except Exception as e: + logger.warning("Failed to register jackify:// protocol: %s", e) + return False diff --git a/jackify/backend/services/nexus_oauth_service.py b/jackify/backend/services/nexus_oauth_service.py index fd6fa4f..cd6610b 100644 --- a/jackify/backend/services/nexus_oauth_service.py +++ b/jackify/backend/services/nexus_oauth_service.py @@ -11,21 +11,21 @@ import hashlib import secrets import webbrowser import urllib.parse -from http.server import HTTPServer, BaseHTTPRequestHandler import requests import json import threading -import ssl -import tempfile import logging import time import subprocess from typing import Optional, Tuple, Dict +from .nexus_oauth_protocol import NexusOAuthProtocolMixin +from .nexus_oauth_callback import NexusOAuthCallbackMixin + logger = logging.getLogger(__name__) -class NexusOAuthService: +class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin): """ Handles OAuth 2.0 authentication with Nexus Mods Uses PKCE flow with system browser and localhost callback @@ -77,451 +77,35 @@ class NexusOAuthService: return code_verifier, code_challenge, state - def _ensure_protocol_registered(self) -> bool: - """ - Ensure jackify:// protocol is registered with the OS - - Returns: - True if registration successful or already registered - """ - import subprocess - import sys - from pathlib import Path - - if not sys.platform.startswith('linux'): - logger.debug("Protocol registration only needed on Linux") - return True - - try: - # Ensure desktop file exists and has correct Exec path - desktop_file = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop" - - # Get environment for AppImage detection - env = os.environ - - # Determine executable path (DEV mode vs AppImage) - # Check multiple indicators for AppImage execution - is_appimage = ( - 'APPIMAGE' in env or # AppImage environment variable - 'APPDIR' in env or # AppImage directory variable - (sys.argv[0] and sys.argv[0].endswith('.AppImage')) # Executable name - ) - - if is_appimage: - # Running from AppImage - use the AppImage path directly - # CRITICAL: Never use -m flag in AppImage mode - it causes __main__.py windows - if 'APPIMAGE' in env: - # APPIMAGE env var gives us the exact path to the AppImage - exec_path = env['APPIMAGE'] - logger.info(f"Using APPIMAGE env var: {exec_path}") - elif sys.argv[0] and Path(sys.argv[0]).exists(): - # Use sys.argv[0] if it's a valid path - exec_path = str(Path(sys.argv[0]).resolve()) - logger.info(f"Using resolved sys.argv[0]: {exec_path}") - else: - # Fallback to sys.argv[0] as-is - exec_path = sys.argv[0] - logger.warning(f"Using sys.argv[0] as fallback: {exec_path}") - else: - # Running from source (DEV mode) - # Need to ensure we run from the correct directory - src_dir = Path(__file__).parent.parent.parent.parent # Go up to src/ - # Use bash -c with proper quoting for paths with spaces - exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --' - logger.info(f"DEV mode exec path: {exec_path}") - logger.info(f"Source directory: {src_dir}") - - # Check if desktop file needs creation or update - needs_update = False - if not desktop_file.exists(): - needs_update = True - logger.info("Creating desktop file for protocol handler") - else: - # Check if Exec path matches current mode - current_content = desktop_file.read_text() - # Check for both quoted (AppImage) and unquoted (DEV mode with bash -c) formats - if is_appimage: - expected_exec = f'Exec="{exec_path}" %u' - else: - expected_exec = f"Exec={exec_path} %u" - - if expected_exec not in current_content: - needs_update = True - logger.info(f"Updating desktop file with new Exec path: {exec_path}") - - # Explicitly detect and fix malformed entries (unquoted paths with spaces) - # Check if any Exec line exists without quotes but contains spaces - if is_appimage and ' ' in exec_path: - import re - # Look for Exec= without quotes - if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content): - needs_update = True - logger.info("Fixing malformed desktop file (unquoted path with spaces)") - - if needs_update: - desktop_file.parent.mkdir(parents=True, exist_ok=True) - - # Build desktop file content with proper working directory - if is_appimage: - # AppImage - quote path to handle spaces - desktop_content = f"""[Desktop Entry] -Type=Application -Name=Jackify -Comment=Wabbajack modlist manager for Linux -Exec="{exec_path}" %u -Icon=com.jackify.app -Terminal=false -Categories=Game;Utility; -MimeType=x-scheme-handler/jackify; -""" - else: - # DEV mode - exec_path already contains bash -c with proper quoting - src_dir = Path(__file__).parent.parent.parent.parent # Go up to src/ - desktop_content = f"""[Desktop Entry] -Type=Application -Name=Jackify -Comment=Wabbajack modlist manager for Linux -Exec={exec_path} %u -Icon=com.jackify.app -Terminal=false -Categories=Game;Utility; -MimeType=x-scheme-handler/jackify; -Path={src_dir} -""" - - desktop_file.write_text(desktop_content) - logger.info(f"Desktop file written: {desktop_file}") - logger.info(f"Exec path: {exec_path}") - logger.info(f"AppImage mode: {is_appimage}") - - # Always ensure full registration (don't trust xdg-settings alone) - # PopOS/Ubuntu need mimeapps.list even if xdg-settings says registered - logger.info("Registering jackify:// protocol handler") - - # Update MIME cache (required for Firefox dialog) - apps_dir = Path.home() / ".local" / "share" / "applications" - subprocess.run( - ['update-desktop-database', str(apps_dir)], - capture_output=True, - timeout=10 - ) - - # Set as default handler using xdg-mime (Firefox compatibility) - subprocess.run( - ['xdg-mime', 'default', 'com.jackify.app.desktop', 'x-scheme-handler/jackify'], - capture_output=True, - timeout=10 - ) - - # Also use xdg-settings as backup (some systems need both) - subprocess.run( - ['xdg-settings', 'set', 'default-url-scheme-handler', 'jackify', 'com.jackify.app.desktop'], - capture_output=True, - timeout=10 - ) - - # Manually ensure entry in mimeapps.list (PopOS/Ubuntu require this for GIO) - mimeapps_path = Path.home() / ".config" / "mimeapps.list" - try: - # Read existing content - if mimeapps_path.exists(): - content = mimeapps_path.read_text() - else: - mimeapps_path.parent.mkdir(parents=True, exist_ok=True) - content = "[Default Applications]\n" - - # Add jackify handler if not present - if 'x-scheme-handler/jackify=' not in content: - if '[Default Applications]' not in content: - content = "[Default Applications]\n" + content - - # Insert after [Default Applications] line - lines = content.split('\n') - for i, line in enumerate(lines): - if line.strip() == '[Default Applications]': - lines.insert(i + 1, 'x-scheme-handler/jackify=com.jackify.app.desktop') - break - - content = '\n'.join(lines) - mimeapps_path.write_text(content) - logger.info("Added jackify handler to mimeapps.list") - except Exception as e: - logger.warning(f"Failed to update mimeapps.list: {e}") - - logger.info("jackify:// protocol registered successfully") - return True - - except Exception as e: - logger.warning(f"Failed to register jackify:// protocol: {e}") - return False - - def _generate_self_signed_cert(self) -> Tuple[Optional[str], Optional[str]]: - """ - Generate self-signed certificate for HTTPS localhost - - Returns: - Tuple of (cert_file_path, key_file_path) or (None, None) on failure - """ - try: - from cryptography import x509 - from cryptography.x509.oid import NameOID - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.hazmat.primitives import serialization - import datetime - import ipaddress - - logger.info("Generating self-signed certificate for OAuth callback") - - # Generate private key - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - ) - - # Create certificate - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Jackify"), - x509.NameAttribute(NameOID.COMMON_NAME, self.REDIRECT_HOST), - ]) - - cert = x509.CertificateBuilder().subject_name( - subject - ).issuer_name( - issuer - ).public_key( - private_key.public_key() - ).serial_number( - x509.random_serial_number() - ).not_valid_before( - datetime.datetime.now(datetime.UTC) - ).not_valid_after( - datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365) - ).add_extension( - x509.SubjectAlternativeName([ - x509.IPAddress(ipaddress.IPv4Address(self.REDIRECT_HOST)), - ]), - critical=False, - ).sign(private_key, hashes.SHA256()) - - # Save to temp files - temp_dir = tempfile.mkdtemp() - cert_file = os.path.join(temp_dir, "oauth_cert.pem") - key_file = os.path.join(temp_dir, "oauth_key.pem") - - with open(cert_file, "wb") as f: - f.write(cert.public_bytes(serialization.Encoding.PEM)) - - with open(key_file, "wb") as f: - f.write(private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - )) - - return cert_file, key_file - - except ImportError: - logger.error("cryptography package not installed - required for OAuth") - return None, None - except Exception as e: - logger.error(f"Failed to generate SSL certificate: {e}") - return None, None - def _build_authorization_url(self, code_challenge: str, state: str) -> str: """ - Build OAuth authorization URL - - Args: - code_challenge: PKCE code challenge - state: CSRF protection state - - Returns: - Authorization URL + Build the Nexus OAuth 2.0 authorisation URL with PKCE parameters. """ params = { - 'response_type': 'code', - 'client_id': self.CLIENT_ID, - 'redirect_uri': self.REDIRECT_URI, - 'scope': self.SCOPES, - 'code_challenge': code_challenge, - 'code_challenge_method': 'S256', - 'state': state + "response_type": "code", + "client_id": self.CLIENT_ID, + "redirect_uri": self.REDIRECT_URI, + "scope": self.SCOPES, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", } + query = urllib.parse.urlencode(params) + return f"{self.AUTH_URL}?{query}" - return f"{self.AUTH_URL}?{urllib.parse.urlencode(params)}" - - def _create_callback_handler(self): - """Create HTTP request handler class for OAuth callback""" - service = self - - class OAuthCallbackHandler(BaseHTTPRequestHandler): - """HTTP request handler for OAuth callback""" - - def log_message(self, format, *args): - """Log OAuth callback requests""" - logger.debug(f"OAuth callback: {format % args}") - - def do_GET(self): - """Handle GET request from OAuth redirect""" - logger.info(f"OAuth callback received: {self.path}") - - # Parse query parameters - parsed = urllib.parse.urlparse(self.path) - params = urllib.parse.parse_qs(parsed.query) - - # Ignore favicon and other non-OAuth requests - if parsed.path == '/favicon.ico': - self.send_response(404) - self.end_headers() - return - - if 'code' in params: - service._auth_code = params['code'][0] - service._auth_state = params.get('state', [None])[0] - logger.info(f"OAuth authorization code received: {service._auth_code[:10]}...") - - # Send success response - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - - html = """ - - Authorization Successful - -

Authorization Successful!

-

You can close this window and return to Jackify.

- - - - """ - self.wfile.write(html.encode()) - - elif 'error' in params: - service._auth_error = params['error'][0] - error_desc = params.get('error_description', ['Unknown error'])[0] - - # Send error response - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - - html = f""" - - Authorization Failed - -

Authorization Failed

-

Error: {service._auth_error}

-

{error_desc}

-

You can close this window and try again in Jackify.

- - - """ - self.wfile.write(html.encode()) - else: - # Unexpected callback format - logger.warning(f"OAuth callback with no code or error: {params}") - self.send_response(400) - self.send_header('Content-type', 'text/html') - self.end_headers() - html = """ - - Invalid Request - -

Invalid OAuth Callback

-

You can close this window.

- - - """ - self.wfile.write(html.encode()) - - # Signal server to shut down - service._server_done.set() - logger.debug("OAuth callback handler signaled server to shut down") - - return OAuthCallbackHandler - - def _wait_for_callback(self) -> bool: - """ - Wait for OAuth callback via jackify:// protocol handler - - Returns: - True if callback received, False on timeout - """ - from pathlib import Path - import time - - callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp" - - # Delete any old callback file - if callback_file.exists(): - callback_file.unlink() - - logger.info("Waiting for OAuth callback via jackify:// protocol") - - # Poll for callback file with periodic user feedback - start_time = time.time() - last_reminder = 0 - while (time.time() - start_time) < self.CALLBACK_TIMEOUT: - if callback_file.exists(): - try: - # Read callback data - lines = callback_file.read_text().strip().split('\n') - if len(lines) >= 2: - self._auth_code = lines[0] - self._auth_state = lines[1] - logger.info(f"OAuth callback received: code={self._auth_code[:10]}...") - - # Clean up - callback_file.unlink() - return True - except Exception as e: - logger.error(f"Failed to read callback file: {e}") - return False - - # Show periodic reminder about protocol handler - elapsed = time.time() - start_time - if elapsed - last_reminder > 30: # Every 30 seconds - logger.info(f"Still waiting for OAuth callback... ({int(elapsed)}s elapsed)") - if elapsed > 60: - logger.warning( - "If you see a blank browser tab or popup blocker, " - "check for browser notifications asking to 'Open Jackify'" - ) - last_reminder = elapsed - - time.sleep(0.5) # Poll every 500ms - - logger.error(f"OAuth callback timeout after {self.CALLBACK_TIMEOUT} seconds") - logger.error( - "Protocol handler may not be working. Check:\n" - " 1. Browser asked 'Open Jackify?' and you clicked Allow\n" - " 2. No popup blocker notifications\n" - " 3. Desktop file exists: ~/.local/share/applications/com.jackify.app.desktop" - ) - return False - - def _send_desktop_notification(self, title: str, message: str): - """ - Send desktop notification if available - - Args: - title: Notification title - message: Notification message - """ + def _send_desktop_notification(self, title: str, message: str) -> None: + """Send a desktop notification via notify-send (Linux). No-op on failure.""" try: - # Try notify-send (Linux) subprocess.run( - ['notify-send', title, message], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=2 + ["notify-send", title, message], + capture_output=True, + timeout=5, + env={k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "PYTHONPATH", "QT_PLUGIN_PATH")}, ) - except (FileNotFoundError, subprocess.TimeoutExpired): - pass + except (FileNotFoundError, subprocess.TimeoutExpired) as e: + logger.debug("Desktop notification skipped: %s", e) + except Exception as e: + logger.debug("Desktop notification failed: %s", e) def _exchange_code_for_token( self, @@ -742,32 +326,35 @@ Path={src_dir} f"Please open this URL manually:\n{auth_url}" ) - # Wait for callback via jackify:// protocol - if not self._wait_for_callback(): - return None + try: + # Wait for callback via jackify:// protocol + if not self._wait_for_callback(): + return None - # Check for errors - if self._auth_error: - logger.error(f"Authorization failed: {self._auth_error}") - return None + # Check for errors + if self._auth_error: + logger.error(f"Authorization failed: {self._auth_error}") + return None - if not self._auth_code: - logger.error("No authorization code received") - return None + if not self._auth_code: + logger.error("No authorization code received") + return None - # Verify state matches - if self._auth_state != state: - logger.error("State mismatch - possible CSRF attack") - return None + # Verify state matches + if self._auth_state != state: + logger.error("State mismatch - possible CSRF attack") + return None - logger.info("Authorization code received, exchanging for token") + logger.info("Authorization code received, exchanging for token") - # Exchange code for token - token_data = self._exchange_code_for_token(self._auth_code, code_verifier) + # Exchange code for token + token_data = self._exchange_code_for_token(self._auth_code, code_verifier) - if token_data: - logger.info("OAuth authorization flow completed successfully") - else: - logger.error("Failed to exchange authorization code for token") + if token_data: + logger.info("OAuth authorization flow completed successfully") + else: + logger.error("Failed to exchange authorization code for token") - return token_data + return token_data + finally: + self._expected_oauth_state = None diff --git a/jackify/backend/services/protontricks_detection_service.py b/jackify/backend/services/protontricks_detection_service.py index 8acbb2c..cf369cf 100644 --- a/jackify/backend/services/protontricks_detection_service.py +++ b/jackify/backend/services/protontricks_detection_service.py @@ -133,7 +133,7 @@ class ProtontricksDetectionService: return False, error_msg # Install command - use --user flag for user-level installation (works on Steam Deck) - # This avoids requiring system-wide installation permissions + # Avoids system-wide installation permissions install_cmd = ["flatpak", "install", "--user", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"] # Use clean environment @@ -186,7 +186,7 @@ class ProtontricksDetectionService: elif "network" in stderr_msg.lower() or "connection" in stderr_msg.lower(): error_msg = f"Network error during installation. Check your internet connection.\n\nDetails: {stderr_msg}" elif "already installed" in stderr_msg.lower(): - # This might actually be success - clear cache and re-detect + # Might be success -- clear cache and re-detect logger.info("Protontricks appears to already be installed (according to flatpak output)") self._cached_detection_valid = False return True, "Protontricks is already installed." diff --git a/jackify/backend/services/steam_restart_service.py b/jackify/backend/services/steam_restart_service.py index 129417b..d979bca 100644 --- a/jackify/backend/services/steam_restart_service.py +++ b/jackify/backend/services/steam_restart_service.py @@ -11,7 +11,7 @@ from typing import Callable, Optional logger = logging.getLogger(__name__) STRATEGY_JACKIFY = "jackify" -STRATEGY_NAK_SIMPLE = "nak_simple" +STRATEGY_SIMPLE = "simple" def _get_restart_strategy() -> str: @@ -20,7 +20,9 @@ def _get_restart_strategy() -> str: from jackify.backend.handlers.config_handler import ConfigHandler strategy = ConfigHandler().get("steam_restart_strategy", STRATEGY_JACKIFY) - if strategy not in (STRATEGY_JACKIFY, STRATEGY_NAK_SIMPLE): + if strategy == "nak_simple": + strategy = STRATEGY_SIMPLE + if strategy not in (STRATEGY_JACKIFY, STRATEGY_SIMPLE): return STRATEGY_JACKIFY return strategy except Exception as exc: # pragma: no cover - defensive logging only @@ -29,8 +31,8 @@ def _get_restart_strategy() -> str: def _strategy_label(strategy: str) -> str: - if strategy == STRATEGY_NAK_SIMPLE: - return "NaK simple restart" + if strategy == STRATEGY_SIMPLE: + return "Simple restart" return "Jackify hardened restart" def _get_clean_subprocess_env(): @@ -137,31 +139,80 @@ def is_steam_deck() -> bool: logger.debug(f"Error detecting Steam Deck: {e}") return False -def is_flatpak_steam() -> bool: - """Detect if Steam is installed as a Flatpak.""" +def steam_path_indicates_flatpak(steam_path) -> bool: + """True if this Steam path is under the Flatpak Steam app dir (user is running Flatpak Steam).""" + if steam_path is None: + return False + path_str = os.fspath(steam_path) + return ".var" in path_str and "app" in path_str and "com.valvesoftware.Steam" in path_str + + +def _flatpak_steam_data_path_exists() -> bool: + """True if the Flatpak Steam data directory exists (fallback when resolved_path is None, e.g. AppImage).""" try: - # First check if flatpak command exists - if not shutil.which('flatpak'): + from pathlib import Path + base = Path.home() / ".var" / "app" / "com.valvesoftware.Steam" + for rel in ("data/Steam", ".local/share/Steam", "home/.local/share/Steam"): + candidate = base / rel + if (candidate / "config" / "loginusers.vdf").exists(): + return True + return False + except Exception as e: + logger.debug("Flatpak Steam path check failed: %s", e) + return False + + +def _get_flatpak_command(): + """Resolve flatpak executable (for detection when PATH is minimal, e.g. AppImage).""" + exe = shutil.which("flatpak") + if exe: + return exe + for p in ("/usr/bin/flatpak", "/usr/local/bin/flatpak"): + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + return None + + +def is_flatpak_steam() -> bool: + """Detect if Steam is installed as a Flatpak. Uses flatpak CLI only (no dir heuristic) + so we don't wrongly choose Flatpak when the user has both Flatpak and native Steam.""" + try: + flatpak_cmd = _get_flatpak_command() + if not flatpak_cmd: return False - - # Verify the app is actually installed (not just directory exists) - result = subprocess.run(['flatpak', 'list', '--app'], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, # Suppress stderr to avoid error messages - text=True, - timeout=5) + env = _get_clean_subprocess_env() + result = subprocess.run( + [flatpak_cmd, "list", "--app"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=10, + env=env, + ) if result.returncode == 0: - # Check for exact match - "com.valvesoftware.Steam" as a whole word - # This prevents matching "com.valvesoftware.SteamLink" or similar for line in result.stdout.splitlines(): parts = line.split() - if parts and parts[0] == 'com.valvesoftware.Steam': + if parts and parts[0] == "com.valvesoftware.Steam": return True return False except Exception as e: logger.debug(f"Error detecting Flatpak Steam: {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 + path_env = env.get("PATH", "") + exe = shutil.which("steam", path=path_env) + if exe: + return exe + for candidate in ("/usr/games/steam", "/usr/bin/steam"): + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return "steam" + + def get_steam_processes() -> list: """Return a list of psutil.Process objects for running Steam processes.""" steam_procs = [] @@ -194,53 +245,46 @@ def wait_for_steam_exit(timeout: int = 60, check_interval: float = 0.5) -> bool: time.sleep(check_interval) return False -def _start_steam_nak_style(is_steamdeck_flag=False, is_flatpak_flag=False, env_override=None) -> bool: +def _start_steam_simple(is_steamdeck_flag=False, is_flatpak_flag=False, env_override=None) -> bool: """ - Start Steam using a simplified NaK-style restart (single command, no env cleanup). - - CRITICAL: Do NOT use start_new_session - Steam needs to inherit the session - to connect to display/tray. Ensure all GUI environment variables are preserved. + Start Steam using a simplified restart (single command, no env cleanup). + Do NOT use start_new_session - Steam needs to inherit the session for display/tray. """ env = env_override if env_override is not None else os.environ.copy() - - # Log critical GUI variables for debugging + gui_vars = ['DISPLAY', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR'] for var in gui_vars: if var in env: - logger.debug(f"NaK-style restart: {var}={env[var][:50] if len(str(env[var])) > 50 else env[var]}") + logger.debug(f"Simple restart: {var}={env[var][:50] if len(str(env[var])) > 50 else env[var]}") else: - logger.warning(f"NaK-style restart: {var} is NOT SET - Steam GUI may fail!") - + logger.warning(f"Simple restart: {var} is NOT SET - Steam GUI may fail!") + try: if is_steamdeck_flag: - logger.info("NaK-style restart: Steam Deck detected, restarting via systemctl.") + logger.info("Simple restart: Steam Deck detected, restarting via systemctl.") subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env) elif is_flatpak_flag: - logger.info("NaK-style restart: Flatpak Steam detected, running flatpak command.") - subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam"], - env=env, stderr=subprocess.DEVNULL) + logger.info("Simple restart: Flatpak Steam detected, running flatpak command.") + flatpak_cmd = _get_flatpak_command() or "flatpak" + subprocess.Popen([flatpak_cmd, "run", "com.valvesoftware.Steam"], + env=env, stderr=subprocess.DEVNULL) else: - logger.info("NaK-style restart: launching Steam directly (inheriting session for GUI).") - # NaK uses simple "steam" command without -foreground flag - # Do NOT use start_new_session - Steam needs session access for GUI - # Use shell=True to ensure proper environment inheritance - # This helps with GUI display access on some systems + logger.info("Simple restart: launching Steam directly (inheriting session for GUI).") subprocess.Popen("steam", shell=True, env=env) time.sleep(5) - # Use steamwebhelper for detection (actual Steam process, not steam-powerbuttond) check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env) if check_result.returncode == 0: - logger.info("NaK-style restart detected running Steam process.") + logger.info("Simple restart detected running Steam process.") return True - logger.warning("NaK-style restart did not detect Steam process after launch.") + logger.warning("Simple restart did not detect Steam process after launch.") return False except FileNotFoundError as exc: - logger.error(f"NaK-style restart command not found: {exc}") + logger.error(f"Simple restart command not found: {exc}") return False except Exception as exc: - logger.error(f"NaK-style restart encountered an error: {exc}") + logger.error(f"Simple restart encountered an error: {exc}") return False @@ -254,8 +298,8 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None, env_override: Optional environment dictionary for subprocess calls strategy: Restart strategy identifier """ - if strategy == STRATEGY_NAK_SIMPLE: - return _start_steam_nak_style( + if strategy == STRATEGY_SIMPLE: + return _start_steam_simple( is_steamdeck_flag=is_steamdeck_flag, is_flatpak_flag=is_flatpak_flag, env_override=env_override or os.environ.copy(), @@ -284,10 +328,10 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None, if _is_flatpak: logger.info("Flatpak Steam detected - trying flatpak run command first") try: - # Try without flags first (most reliable for Ubuntu/PopOS) - logger.debug("Executing: flatpak run com.valvesoftware.Steam") - subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam"], - env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + flatpak_cmd = _get_flatpak_command() or "flatpak" + logger.debug("Executing: %s run com.valvesoftware.Steam", flatpak_cmd) + subprocess.Popen([flatpak_cmd, "run", "com.valvesoftware.Steam"], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) time.sleep(7) # Give Flatpak more time to start # For Flatpak Steam, check for the flatpak process, not steamwebhelper check_result = subprocess.run(['pgrep', '-f', 'com.valvesoftware.Steam'], capture_output=True, timeout=10, env=env) @@ -301,11 +345,11 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None, logger.error(f"Flatpak Steam start failed: {e}") return False # Flatpak Steam must use flatpak command, don't fall back - # Use startup methods with -foreground flag to ensure GUI opens + steam_exe = _get_steam_executable(env) start_methods = [ - {"name": "Popen", "cmd": ["steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}}, - {"name": "setsid", "cmd": ["setsid", "steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "env": env}}, - {"name": "nohup", "cmd": ["nohup", "steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "preexec_fn": os.setpgrp, "env": env}} + {"name": "Popen", "cmd": [steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}}, + {"name": "setsid", "cmd": ["setsid", steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "env": env}}, + {"name": "nohup", "cmd": ["nohup", steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}} ] for method in start_methods: @@ -335,6 +379,106 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None, logger.error(f"Error starting Steam: {e}") return False +def _resolve_steam_path_for_restart(): + """Return the Steam path we're using (for shortcuts/config). Used to decide Flatpak vs native when CLI detection fails.""" + try: + from jackify.backend.services.native_steam_service import NativeSteamService + svc = NativeSteamService() + if svc.find_steam_user() and svc.steam_path: + return svc.steam_path + except Exception as e: + logger.debug("Could not resolve Steam path for restart: %s", e) + return None + + +def shutdown_steam(progress_callback: Optional[Callable[[str], None]] = None, system_info=None) -> bool: + """ + Shut down Steam completely across all distros. + Required before modifying VDF files to prevent race conditions. + + Args: + progress_callback: Optional callback for progress updates + system_info: Optional SystemInfo object with pre-detected Steam installation types + + Returns: + True if shutdown successful, False otherwise + """ + shutdown_env = _get_clean_subprocess_env() + + _is_steam_deck = system_info.is_steamdeck if system_info else is_steam_deck() + resolved_path = _resolve_steam_path_for_restart() + if resolved_path is not None: + _is_flatpak = steam_path_indicates_flatpak(resolved_path) + logger.info("Steam path in use: %s -> flatpak=%s", resolved_path, _is_flatpak) + else: + _is_flatpak = _flatpak_steam_data_path_exists() + if _is_flatpak: + logger.info("Steam path in use: (flatpak data path detected) -> flatpak=True") + else: + _is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam() + + def report(msg): + if progress_callback: + progress_callback(msg) + else: + logger.info(msg) + + report("Shutting down Steam...") + + # Steam Deck: Use systemctl for shutdown + if _is_steam_deck: + try: + report("Steam Deck detected - using systemctl shutdown...") + subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'], + timeout=15, check=False, capture_output=True, env=shutdown_env) + time.sleep(2) + except Exception as e: + logger.debug(f"systemctl stop failed on Steam Deck: {e}") + # Flatpak Steam: Use flatpak kill command + elif _is_flatpak: + try: + report("Flatpak Steam detected - stopping via flatpak...") + flatpak_cmd = _get_flatpak_command() or "flatpak" + subprocess.run([flatpak_cmd, "kill", "com.valvesoftware.Steam"], + timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env) + time.sleep(2) + except Exception as e: + logger.debug(f"flatpak kill failed: {e}") + + # All systems: Use pkill approach + try: + pkill_result = subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env) + logger.debug(f"pkill steam result: {pkill_result.returncode}") + time.sleep(2) + + # Check if Steam is still running + check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env) + if check_result.returncode == 0: + # Force kill if still running + report("Steam still running - force terminating...") + force_result = subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env) + logger.debug(f"pkill -9 steam result: {force_result.returncode}") + time.sleep(2) + + # Final check + final_check = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env) + if final_check.returncode != 0: + logger.info("Steam processes successfully force terminated.") + else: + logger.warning("Steam processes may still be running after termination attempts.") + report("Steam shutdown incomplete") + return False + else: + logger.info("Steam processes successfully terminated.") + except Exception as e: + logger.warning(f"Error during Steam shutdown: {e}") + report("Steam shutdown had issues") + return False + + report("Steam shut down successfully") + return True + + def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = None, timeout: int = 60, system_info=None) -> bool: """ Robustly restart Steam across all distros. Returns True on success, False on failure. @@ -350,14 +494,24 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No strategy = _get_restart_strategy() start_env = shutdown_env if strategy == STRATEGY_JACKIFY else os.environ.copy() - # Use cached detection from system_info if available, otherwise detect _is_steam_deck = system_info.is_steamdeck if system_info else is_steam_deck() - _is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam() + resolved_path = _resolve_steam_path_for_restart() + if resolved_path is not None: + _is_flatpak = steam_path_indicates_flatpak(resolved_path) + logger.info("Steam path in use: %s -> flatpak=%s", resolved_path, _is_flatpak) + else: + _is_flatpak = _flatpak_steam_data_path_exists() + if _is_flatpak: + logger.info("Steam path in use: (flatpak data path detected) -> flatpak=True") + else: + _is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam() def report(msg): - logger.info(msg) if progress_callback: progress_callback(msg) + else: + # Only log directly if no callback (callback chain handles logging) + logger.info(msg) report("Shutting down Steam...") report(f"Steam restart strategy: {_strategy_label(strategy)}") @@ -375,8 +529,9 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No elif _is_flatpak: try: report("Flatpak Steam detected - stopping via flatpak...") - subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'], - timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env) + flatpak_cmd = _get_flatpak_command() or "flatpak" + subprocess.run([flatpak_cmd, "kill", "com.valvesoftware.Steam"], + timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env) time.sleep(2) except Exception as e: logger.debug(f"flatpak kill failed: {e}") diff --git a/jackify/backend/services/update_service.py b/jackify/backend/services/update_service.py index 255ce9c..a5459d0 100644 --- a/jackify/backend/services/update_service.py +++ b/jackify/backend/services/update_service.py @@ -354,7 +354,7 @@ class UpdateService: script_content = f'''#!/bin/bash # Jackify Update Helper Script -# This script safely replaces the current AppImage with the new version +# Safely replaces current AppImage with new version CURRENT_APPIMAGE="{current_appimage}" NEW_APPIMAGE="{new_appimage}" diff --git a/jackify/backend/services/vnv_post_install_service.py b/jackify/backend/services/vnv_post_install_service.py index aa2a19e..fdc381b 100644 --- a/jackify/backend/services/vnv_post_install_service.py +++ b/jackify/backend/services/vnv_post_install_service.py @@ -271,7 +271,7 @@ class VNVPostInstallService: if not patcher_path: # Try to download from Nexus - # Note: The Linux version is named "FNV4GB for Proton", not "linux" + # Linux version is named "FNV4GB for Proton", not "linux" success, patcher_path, msg = self.download_service.download_latest_file( self.GAME_DOMAIN, self.LINUX_4GB_PATCHER_MOD_ID, @@ -410,11 +410,12 @@ class VNVPostInstallService: logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}") else: # Try to download from Nexus + # Look for files with .mpi extension (TTW installer format) success, mpi_path, msg = self.download_service.download_latest_file( self.GAME_DOMAIN, self.FNV_BSA_DECOMPRESSOR_MOD_ID, self.cache_dir, - file_name_filter="mpi", + file_name_filter=".mpi", progress_callback=progress_callback ) diff --git a/jackify/backend/services/wabbajack_installer_service.py b/jackify/backend/services/wabbajack_installer_service.py new file mode 100644 index 0000000..35a9a19 --- /dev/null +++ b/jackify/backend/services/wabbajack_installer_service.py @@ -0,0 +1,270 @@ +""" +Wabbajack Installer Service + +Backend service for orchestrating complete Wabbajack installation workflow. +Handles all 12 steps including Steam shortcuts, prefix creation, and configuration. +""" + +import logging +import subprocess +import time +from pathlib import Path +from typing import Optional, Callable, Tuple + +from ..handlers.wabbajack_installer_handler import WabbajackInstallerHandler +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 +) +from .automated_prefix_service import AutomatedPrefixService + +logger = logging.getLogger(__name__) + + +class WabbajackInstallerService: + """Service for orchestrating Wabbajack installation workflow""" + + def __init__(self): + self.handler = WabbajackInstallerHandler() + self.steam_service = NativeSteamService() + self.config_handler = ConfigHandler() + self.prefix_service = AutomatedPrefixService() + + def _resolve_proton_path_and_name(self) -> Tuple[Optional[Path], Optional[str]]: + """Resolve user's Install Proton path and Steam compat name. Fallback to Proton Experimental.""" + user_path = self.config_handler.get_proton_path() + if user_path and user_path != 'auto': + path = Path(user_path).expanduser() + if path.is_dir(): + compat_name = WineUtils.resolve_steam_compat_name(path) + if compat_name: + return path, compat_name + dir_name = path.name + if dir_name.startswith('GE-Proton'): + return path, dir_name + steam_name = dir_name.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_') + 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 + + def install_wabbajack( + self, + install_folder: Path, + shortcut_name: str = "Wabbajack", + enable_gog: bool = True, + progress_callback: Optional[Callable[[str, int], None]] = None, + log_callback: Optional[Callable[[str], None]] = None + ) -> Tuple[bool, Optional[int], Optional[str], Optional[int], Optional[str], Optional[str]]: + """ + Execute complete Wabbajack installation workflow. + + Args: + install_folder: Directory to install Wabbajack.exe + shortcut_name: Name for Steam shortcut + enable_gog: Whether to detect and inject GOG games + progress_callback: Optional callback(status, percentage) + log_callback: Optional callback for log messages + + Returns: + Tuple of (success, app_id, launch_options, gog_count, time_taken_str, error_message) + """ + start_time = time.time() + total_steps = 12 + app_id = None + launch_options = "" + gog_count = 0 + + def update_progress(message: str, step: int, percentage: int = None): + if progress_callback: + if percentage is None: + percentage = int((step / total_steps) * 100) + progress_callback(message, percentage) + if log_callback: + log_callback(message) + else: + # Only log directly if no callback (callback already logs) + logger.info(message) + + # Detect Steam installation type once at the start for consistent use throughout + _is_steam_deck = is_steam_deck() + _is_flatpak = is_flatpak_steam() + + try: + # Step 1: Check requirements + update_progress("Checking requirements...", 1, 5) + proton_path, proton_compat_name = self._resolve_proton_path_and_name() + if not proton_path: + return False, None, None, None, None, "Proton not found. Install a Proton version in Steam or set Install Proton in Settings." + update_progress(f"Using Proton: {proton_path.name}", 1, 5) + + userdata = self.handler.find_steam_userdata_path() + if not userdata: + return False, None, None, None, None, "Steam userdata not found. Please ensure Steam is installed and you're logged in." + update_progress(f"Found Steam userdata: {userdata}", 1, 5) + + # Step 2: Download Wabbajack.exe + update_progress("Downloading Wabbajack.exe...", 2, 15) + wabbajack_exe = self.handler.download_wabbajack(install_folder) + if not wabbajack_exe: + return False, None, None, None, None, "Failed to download Wabbajack.exe" + update_progress(f"Downloaded to: {wabbajack_exe}", 2, 15) + + # Step 3: Create dotnet cache + update_progress("Creating .NET cache directory...", 3, 20) + self.handler.create_dotnet_cache(install_folder) + update_progress(".NET cache created", 3, 20) + + # Step 4: Stop Steam briefly (required to safely modify shortcuts.vdf) + # We'll do a full restart after creating the shortcut + update_progress("Stopping Steam to modify shortcuts...", 4, 25) + try: + shutdown_env = _get_clean_subprocess_env() + + if _is_steam_deck: + subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'], + timeout=15, check=False, capture_output=True, env=shutdown_env) + elif _is_flatpak: + subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'], + timeout=15, check=False, capture_output=True, env=shutdown_env) + + subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env) + time.sleep(2) + + check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env) + if check_result.returncode == 0: + subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env) + time.sleep(2) + + update_progress("Steam stopped", 4, 25) + except Exception as e: + update_progress(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...", 4, 25) + + # Step 5: Create Steam shortcut using NativeSteamService + update_progress("Adding Wabbajack to Steam shortcuts...", 5, 30) + + # Generate launch options with STEAM_COMPAT_MOUNTS + launch_options = "" + try: + from ..handlers.path_handler import PathHandler + path_handler = PathHandler() + mount_paths = path_handler.get_steam_compat_mount_paths(install_dir=str(install_folder)) + if mount_paths: + launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%' + update_progress(f"Added STEAM_COMPAT_MOUNTS for Steam libraries: {mount_paths}", 5, 30) + else: + update_progress("No additional Steam libraries found - using empty launch options", 5, 30) + except Exception as e: + update_progress(f"Could not generate STEAM_COMPAT_MOUNTS (non-critical): {e}", 5, 30) + + success, app_id = self.steam_service.create_shortcut_with_proton( + app_name=shortcut_name, + exe_path=str(wabbajack_exe), + start_dir=str(wabbajack_exe.parent), + launch_options=launch_options, + tags=["Jackify"], + proton_version=proton_compat_name + ) + if not success or app_id is None: + return False, None, None, None, None, "Failed to create Steam shortcut" + update_progress(f"Created Steam shortcut with AppID: {app_id}", 5, 30) + + # Step 5b: Restart Steam (same pattern as modlist workflows) + update_progress("Restarting Steam...", 5, 35) + def restart_callback(msg): + update_progress(msg, 5, 35) + + if not robust_steam_restart(progress_callback=restart_callback): + update_progress("Warning: Steam restart had issues, continuing anyway...", 5, 35) + else: + update_progress("Steam restarted successfully", 5, 40) + + # Step 6: Initialize Wine prefix (using same method as modlist workflows) + update_progress("Creating Proton prefix...", 6, 45) + try: + if self.prefix_service.create_prefix_with_proton_wrapper(app_id): + prefix_path = self.prefix_service.get_prefix_path(app_id) + update_progress(f"Proton prefix created: {prefix_path}", 6, 45) + else: + update_progress("Warning: Prefix creation returned False, continuing anyway...", 6, 45) + except Exception as e: + update_progress(f"Warning: Failed to create prefix: {e}", 6, 45) + update_progress("Continuing anyway...", 6, 45) + + # Step 7: Install WebView2 + update_progress("Installing WebView2 runtime...", 7, 60) + try: + self.handler.install_webview2(app_id, install_folder, proton_path=proton_path) + update_progress("WebView2 installed successfully", 7, 60) + except Exception as e: + update_progress(f"WARNING: WebView2 installation may have failed: {e}", 7, 60) + update_progress("This may prevent Nexus login in Wabbajack. You can manually install WebView2 later.", 7, 60) + + # Step 8: Apply Win7 registry + update_progress("Applying Windows 7 registry settings...", 8, 75) + try: + self.handler.apply_win7_registry(app_id, proton_path=proton_path) + update_progress("Registry settings applied", 8, 75) + except Exception as e: + update_progress(f"Warning: Failed to apply registry settings: {e}", 8, 75) + update_progress("Continuing anyway...", 8, 75) + + # Step 9: GOG game detection (optional) + if enable_gog: + update_progress("Detecting GOG games from Heroic...", 9, 80) + try: + gog_count = self.handler.inject_gog_registry(app_id) + if gog_count > 0: + update_progress(f"Detected and injected {gog_count} GOG games", 9, 80) + else: + update_progress("No GOG games found in Heroic", 9, 80) + except Exception as e: + update_progress(f"GOG injection failed (non-critical): {e}", 9, 80) + else: + update_progress("Skipping GOG game detection", 9, 80) + + # Step 10: Create Steam library symlinks + update_progress("Creating Steam library symlinks...", 10, 85) + try: + self.steam_service.create_steam_library_symlinks(app_id) + update_progress("Steam library symlinks created", 10, 85) + except Exception as e: + update_progress(f"Warning: Failed to create symlinks: {e}", 10, 85) + + # Step 11: Verify Proton compatibility (was set at shortcut creation) + update_progress(f"Proton version: {proton_compat_name}", 11, 90) + + # Step 12: Verify Steam is running (was restarted after shortcut creation) + update_progress("Verifying Steam is running...", 12, 95) + check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10) + if check_result.returncode == 0: + update_progress("Steam is running", 12, 95) + else: + update_progress("Starting Steam...", 12, 95) + if start_steam(is_steamdeck_flag=_is_steam_deck, is_flatpak_flag=_is_flatpak): + update_progress("Steam started successfully", 12, 95) + time.sleep(3) + else: + update_progress("Warning: Please start Steam manually", 12, 95) + + # Calculate time taken + time_taken = int(time.time() - start_time) + mins, secs = divmod(time_taken, 60) + time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds" + + update_progress("Installation complete!", 12, 100) + update_progress(f"Wabbajack installed to: {install_folder}", 12, 100) + update_progress(f"Steam AppID: {app_id}", 12, 100) + + return True, app_id, launch_options, gog_count, time_str, None + + except Exception as e: + error_msg = f"Installation failed: {str(e)}" + logger.error(error_msg, exc_info=True) + if log_callback: + log_callback(f"ERROR: {error_msg}") + return False, None, None, None, None, error_msg + diff --git a/jackify/frontends/cli/commands/install_wabbajack.py b/jackify/frontends/cli/commands/install_wabbajack.py new file mode 100644 index 0000000..8f85b62 --- /dev/null +++ b/jackify/frontends/cli/commands/install_wabbajack.py @@ -0,0 +1,106 @@ +""" +Install Wabbajack Application Command +Provides CLI interface for automated Wabbajack installation +Uses backend service for complete workflow orchestration +""" + +import logging +from pathlib import Path + +from jackify.backend.services.wabbajack_installer_service import WabbajackInstallerService +from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SUCCESS, COLOR_ERROR + + +class InstallWabbajackCommand: + """CLI command for installing Wabbajack application""" + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def run(self): + """Execute Wabbajack installation workflow using backend service""" + print(f"\n{COLOR_INFO}=== Install Wabbajack Application ==={COLOR_RESET}\n") + print("This will download and configure Wabbajack.exe via Proton.") + print("Wabbajack will be added to your Steam library as a non-Steam game.\n") + + # Prompt for installation directory + default_dir = str(Path.home() / "Games" / "Wabbajack") + install_dir_input = input( + f"{COLOR_PROMPT}Installation directory [{default_dir}]: {COLOR_RESET}" + ).strip() + + install_dir = Path(install_dir_input) if install_dir_input else Path(default_dir) + + # Prompt for shortcut name + shortcut_name = "Wabbajack" + shortcut_input = input( + f"{COLOR_PROMPT}Shortcut name [{shortcut_name}]: {COLOR_RESET}" + ).strip() + if shortcut_input: + shortcut_name = shortcut_input + + # Confirm installation with Steam restart warning + print(f"\n{COLOR_INFO}Installation directory: {install_dir}{COLOR_RESET}") + print(f"{COLOR_INFO}Shortcut name: {shortcut_name}{COLOR_RESET}") + print(f"\n{COLOR_PROMPT}{'='*60}{COLOR_RESET}") + print(f"{COLOR_PROMPT}Important: Steam will be restarted during installation.{COLOR_RESET}") + print(f"{COLOR_PROMPT}Please do not manually start or close Steam until installation is complete.{COLOR_RESET}") + print(f"{COLOR_PROMPT}{'='*60}{COLOR_RESET}") + confirm = input(f"\n{COLOR_PROMPT}Proceed with installation? (Y/n): {COLOR_RESET}").strip().lower() + + if confirm == 'n': + print("Installation cancelled.") + return + + # Execute installation using backend service + print(f"\n{COLOR_INFO}Starting Wabbajack installation...{COLOR_RESET}\n") + + service = WabbajackInstallerService() + + def progress_callback(message: str, percentage: int): + step_num = int((percentage / 100) * 12) if percentage < 100 else 12 + print(f"{COLOR_INFO}[{step_num}/12] {message}{COLOR_RESET}") + + def log_callback(message: str): + if "ERROR" in message or "WARNING" in message or "Failed" in message: + print(f"{COLOR_ERROR}{message}{COLOR_RESET}") + elif "successfully" in message.lower() or "created" in message.lower() or "installed" in message.lower(): + print(f"{COLOR_SUCCESS}{message}{COLOR_RESET}") + else: + print(f"{COLOR_INFO}{message}{COLOR_RESET}") + + success, app_id, launch_options, gog_count, time_taken, error_msg = service.install_wabbajack( + install_folder=install_dir, + shortcut_name=shortcut_name, + enable_gog=True, + progress_callback=progress_callback, + log_callback=log_callback + ) + + if success: + print(f"\n{COLOR_SUCCESS}{'='*60}{COLOR_RESET}") + print(f"{COLOR_SUCCESS}Wabbajack installation complete!{COLOR_RESET}") + print(f"{COLOR_SUCCESS}{'='*60}{COLOR_RESET}\n") + + print(f"{COLOR_INFO}Installation directory: {install_dir}{COLOR_RESET}") + print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}") + if time_taken: + print(f"{COLOR_INFO}Time taken: {time_taken}{COLOR_RESET}") + + # Show launch options note (matches GUI) + if launch_options and "STEAM_COMPAT_MOUNTS" in launch_options: + print(f"\n{COLOR_INFO}Note: To access other drives, add paths to launch options (Steam → Properties).{COLOR_RESET}") + print(f"{COLOR_INFO}Append with colons: STEAM_COMPAT_MOUNTS=\"/existing:/new/path\" %command%{COLOR_RESET}") + elif not launch_options: + print(f"\n{COLOR_INFO}Note: To access other drives, add to launch options (Steam → Properties):{COLOR_RESET}") + print(f"{COLOR_INFO}STEAM_COMPAT_MOUNTS=\"/path/to/directory\" %command%{COLOR_RESET}") + + print(f"\n{COLOR_INFO}Next steps:{COLOR_RESET}") + print(f" 1. Find '{shortcut_name}' in your Steam library") + print(f" 2. Launch Wabbajack from Steam") + else: + print(f"\n{COLOR_ERROR}Installation 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 eb1889e..092499b 100755 --- a/jackify/frontends/cli/main.py +++ b/jackify/frontends/cli/main.py @@ -423,8 +423,10 @@ class JackifyCLI: elif command == "configure-modlist": return self.commands['configure_modlist'].execute(args) elif command == "install-wabbajack": - # Legacy functionality - TODO: extract to command handler - return self._handle_legacy_install_wabbajack() + print("Wabbajack installation is available through the interactive menu:") + print(" Run: jackify --cli") + print(" Then select: Additional Tasks > Install Wabbajack") + return 0 elif command == "install-mo2": print("MO2 installation not yet implemented") print("This functionality is coming soon!") @@ -493,12 +495,6 @@ class JackifyCLI: logger.error(f"Steam restart failed with exception: {e}") return 1 - def _handle_legacy_install_wabbajack(self): - """Handle install-wabbajack command (legacy functionality)""" - print("Install Wabbajack functionality not yet migrated to new structure") - return 1 - - return 1 def _handle_legacy_recovery(self, args): """Handle recovery command (legacy functionality)""" @@ -513,7 +509,10 @@ class JackifyCLI: # LEGACY BRIDGE: Methods that menu handlers expect to find on cli_instance def _cmd_install_wabbajack(self, args): """LEGACY BRIDGE: Install Wabbajack application""" - return self._handle_legacy_install_wabbajack() + from jackify.frontends.cli.commands.install_wabbajack import InstallWabbajackCommand + command_instance = InstallWabbajackCommand() + command_instance.run() + return 0 def main(): @@ -522,6 +521,6 @@ def main(): if __name__ == "__main__": - # This should not be called directly - use __main__.py instead + # Do not call directly -- use __main__.py print("Please use: python -m jackify.frontends.cli") sys.exit(1) diff --git a/jackify/frontends/cli/menus/additional_menu.py b/jackify/frontends/cli/menus/additional_menu.py index 1f6a1a8..eae6df9 100644 --- a/jackify/frontends/cli/menus/additional_menu.py +++ b/jackify/frontends/cli/menus/additional_menu.py @@ -35,7 +35,8 @@ class AdditionalMenuHandler: print(f" {COLOR_ACTION}→ Authorize with Nexus using OAuth or manage API key{COLOR_RESET}") print(f"{COLOR_SELECTION}2.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation") print(f" {COLOR_ACTION}→ Install TTW using TTW_Linux_Installer{COLOR_RESET}") - print(f"{COLOR_SELECTION}3.{COLOR_RESET} Coming Soon...") + 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}0.{COLOR_RESET} Return to Main Menu") selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip() @@ -46,8 +47,7 @@ class AdditionalMenuHandler: elif selection == "2": self._execute_ttw_install(cli_instance) elif selection == "3": - print(f"\n{COLOR_INFO}More features coming soon!{COLOR_RESET}") - input("\nPress Enter to return to menu...") + self._execute_install_wabbajack(cli_instance) elif selection == "0": break else: @@ -65,7 +65,7 @@ class AdditionalMenuHandler: def _execute_legacy_recovery_menu(self, cli_instance): """LEGACY BRIDGE: Execute recovery menu""" - # This will be handled by the RecoveryMenuHandler + # Handled by RecoveryMenuHandler from .recovery_menu import RecoveryMenuHandler recovery_handler = RecoveryMenuHandler() @@ -107,9 +107,25 @@ class AdditionalMenuHandler: input("Press Enter to return to menu...") return - # Prompt for TTW .mpi file + # Prompt for TTW .mpi file with tab completion + try: + import readline + from ....backend.handlers.completers import path_completer + READLINE_AVAILABLE = True + except ImportError: + READLINE_AVAILABLE = False + print(f"\n{COLOR_PROMPT}TTW Installer File (.mpi){COLOR_RESET}") - mpi_path = input(f"{COLOR_PROMPT}Path to TTW .mpi file: {COLOR_RESET}").strip() + if READLINE_AVAILABLE: + readline.set_completer_delims(' \t\n;') + readline.set_completer(path_completer) + readline.parse_and_bind("tab: complete") + try: + mpi_path = input(f"{COLOR_PROMPT}Path to TTW .mpi file: {COLOR_RESET}").strip() + finally: + if READLINE_AVAILABLE: + readline.set_completer(None) + if not mpi_path: print(f"{COLOR_WARNING}No .mpi file specified. Cancelling.{COLOR_RESET}") input("Press Enter to return to menu...") @@ -121,10 +137,19 @@ class AdditionalMenuHandler: input("Press Enter to return to menu...") return - # Prompt for output directory + # Prompt for output directory with tab completion print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}") default_output = Path.home() / "ModdedGames" / "TTW" - output_path = input(f"{COLOR_PROMPT}TTW install directory (Enter for default: {default_output}): {COLOR_RESET}").strip() + if READLINE_AVAILABLE: + readline.set_completer_delims(' \t\n;') + readline.set_completer(path_completer) + readline.parse_and_bind("tab: complete") + try: + output_path = input(f"{COLOR_PROMPT}TTW install directory (Enter for default: {default_output}): {COLOR_RESET}").strip() + finally: + if READLINE_AVAILABLE: + readline.set_completer(None) + if not output_path: output_path = default_output else: @@ -280,3 +305,12 @@ class AdditionalMenuHandler: else: print(f"\n{COLOR_ERROR}Invalid selection.{COLOR_RESET}") time.sleep(1) + + def _execute_install_wabbajack(self, cli_instance): + """Execute Wabbajack application installation""" + from jackify.frontends.cli.commands.install_wabbajack import InstallWabbajackCommand + + command = InstallWabbajackCommand() + if self.logger: + self.logger.debug("AdditionalMenuHandler: Executing Install Wabbajack command") + command.run() diff --git a/jackify/frontends/cli/menus/main_menu.py b/jackify/frontends/cli/menus/main_menu.py index e0c1a15..3dbd2ea 100644 --- a/jackify/frontends/cli/menus/main_menu.py +++ b/jackify/frontends/cli/menus/main_menu.py @@ -43,7 +43,7 @@ class MainMenuHandler: print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks") print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}") print(f"{COLOR_SELECTION}2.{COLOR_RESET} Additional Tasks & Tools") - print(f" {COLOR_ACTION}→ TTW automation, Wabbajack via Wine, MO2, NXM Handling, Recovery{COLOR_RESET}") + print(f" {COLOR_ACTION}→ Nexus OAuth, TTW Installation, Install Wabbajack{COLOR_RESET}") print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify") choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() diff --git a/jackify/frontends/cli/menus/wabbajack_menu.py b/jackify/frontends/cli/menus/wabbajack_menu.py index 3530344..9053667 100644 --- a/jackify/frontends/cli/menus/wabbajack_menu.py +++ b/jackify/frontends/cli/menus/wabbajack_menu.py @@ -37,9 +37,6 @@ class WabbajackMenuHandler: print(f" {COLOR_ACTION}→ Modlist already downloaded? Configure and add to Steam{COLOR_RESET}") print(f"{COLOR_SELECTION}3.{COLOR_RESET} Configure Existing Modlist (In Steam)") print(f" {COLOR_ACTION}→ Modlist already in Steam? Re-configure it here{COLOR_RESET}") - # HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY - # print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application") - # print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via WINE){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() @@ -52,9 +49,6 @@ class WabbajackMenuHandler: self._execute_legacy_configure_new_modlist(cli_instance) elif selection == "3": self._execute_legacy_configure_existing_modlist(cli_instance) - # HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY - # elif selection == "4": - # self._execute_legacy_install_wabbajack(cli_instance) elif selection == "0": break else: diff --git a/jackify/frontends/gui/dialogs/settings_dialog.py b/jackify/frontends/gui/dialogs/settings_dialog.py new file mode 100644 index 0000000..3c02603 --- /dev/null +++ b/jackify/frontends/gui/dialogs/settings_dialog.py @@ -0,0 +1,386 @@ +"""Settings dialog for Jackify GUI.""" +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QCheckBox, + QTabWidget, QFileDialog, QMessageBox, QProgressDialog, QApplication, QToolButton +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon +from pathlib import Path +import json +import os +import logging + +from jackify.frontends.gui.services.message_service import MessageService +from .settings_dialog_tabs import SettingsDialogTabsMixin +from .settings_dialog_proton import SettingsDialogProtonMixin + +logger = logging.getLogger(__name__) + + +class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog): + def __init__(self, parent=None): + try: + super().__init__(parent) + from jackify.backend.handlers.config_handler import ConfigHandler + import logging + self.logger = logging.getLogger(__name__) + self.config_handler = ConfigHandler() + self._original_debug_mode = self.config_handler.get('debug_mode', False) + self.setWindowTitle("Settings") + self.setModal(True) + self.setMinimumWidth(650) + self.setMaximumWidth(800) + self.setStyleSheet("QDialog { background-color: #232323; color: #eee; } QPushButton:hover { background-color: #333; }") + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + # Create tab widget + self.tab_widget = QTabWidget() + self.tab_widget.setStyleSheet(""" + QTabWidget::pane { border: 1px solid #555; background: #232323; } + QTabBar::tab { background: #333; color: #eee; padding: 8px 16px; margin: 2px; } + QTabBar::tab:selected { background: #555; } + QTabBar::tab:hover { background: #444; } + """) + main_layout.addWidget(self.tab_widget) + + # Create tabs + self._create_general_tab() + self._create_advanced_tab() + + # --- Save/Close/Help Buttons --- + btn_layout = QHBoxLayout() + self.help_btn = QPushButton("Help") + self.help_btn.setToolTip("Help/documentation coming soon!") + self.help_btn.clicked.connect(self._show_help) + btn_layout.addWidget(self.help_btn) + btn_layout.addStretch(1) + save_btn = QPushButton("Save") + close_btn = QPushButton("Close") + save_btn.clicked.connect(self._save) + close_btn.clicked.connect(self.reject) + btn_layout.addWidget(save_btn) + btn_layout.addWidget(close_btn) + + # Add error label for validation messages + self.error_label = QLabel("") + self.error_label.setStyleSheet("QLabel { color: #ff6b6b; }") + main_layout.addWidget(self.error_label) + + main_layout.addSpacing(10) + main_layout.addLayout(btn_layout) + + except Exception as e: + print(f"[ERROR] Exception in SettingsDialog.__init__: {e}") + import traceback + traceback.print_exc() + + def _toggle_api_key_visibility(self, checked): + eye_icon = QIcon.fromTheme("view-visible") + if not eye_icon.isNull(): + self.api_show_btn.setIcon(eye_icon) + self.api_show_btn.setText("") + else: + self.api_show_btn.setIcon(QIcon()) + self.api_show_btn.setText("\U0001F441") + if checked: + self.api_key_edit.setEchoMode(QLineEdit.Normal) + self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }") + else: + self.api_key_edit.setEchoMode(QLineEdit.Password) + self.api_show_btn.setStyleSheet("") + + 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) + + def _show_help(self): + MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low") + + def _load_json(self, path): + if os.path.exists(path): + try: + with open(path, 'r') as f: + return json.load(f) + except Exception: + return {} + return {} + + def _save_json(self, path, data): + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + MessageService.warning(self, "Error", f"Failed to save {path}: {e}", safety_level="medium") + + def _clear_api_key(self): + self.api_key_edit.setText("") + self.config_handler.clear_api_key() + MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low") + + def _on_api_key_changed(self, text): + api_key = text.strip() + self.config_handler.save_api_key(api_key) + + def _update_oauth_status(self): + from jackify.backend.services.nexus_auth_service import NexusAuthService + 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") + self.oauth_status_label.setStyleSheet("color: #3fd0ea;") + self.oauth_btn.setText("Revoke") + elif method == 'oauth_expired': + self.oauth_status_label.setText("OAuth token expired") + self.oauth_status_label.setStyleSheet("color: #FFA726;") + self.oauth_btn.setText("Re-authorise") + else: + self.oauth_status_label.setText("Not authorised") + self.oauth_status_label.setStyleSheet("color: #f44336;") + self.oauth_btn.setText("Authorise") + + def _handle_oauth_click(self): + from jackify.backend.services.nexus_auth_service import NexusAuthService + auth_service = NexusAuthService() + authenticated, method, _ = auth_service.get_auth_status() + if authenticated and method == 'oauth': + reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low") + if reply == QMessageBox.Yes: + auth_service.revoke_oauth() + self._update_oauth_status() + MessageService.information(self, "Revoked", "OAuth authorisation has been revoked.", safety_level="low") + else: + reply = MessageService.question(self, "Authorise with Nexus", + "Your browser will open for Nexus authorisation.\n\n" + "Note: Your browser may ask permission to open 'xdg-open'\n" + "or Jackify's protocol handler - please click 'Open' or 'Allow'.\n\n" + "Please log in and authorise Jackify when prompted.\n\n" + "Continue?", safety_level="low") + if reply != QMessageBox.Yes: + return + progress = QProgressDialog( + "Waiting for authorisation...\n\nPlease check your browser.", + "Cancel", 0, 0, self + ) + progress.setWindowTitle("Nexus OAuth") + progress.setWindowModality(Qt.WindowModal) + progress.setMinimumDuration(0) + progress.setMinimumWidth(400) + progress.show() + QApplication.processEvents() + def show_message(msg): + progress.setLabelText(f"Waiting for authorisation...\n\n{msg}") + QApplication.processEvents() + success = auth_service.authorize_oauth(show_browser_message_callback=show_message) + progress.close() + QApplication.processEvents() + self._update_oauth_status() + if success: + _, _, username = auth_service.get_auth_status() + msg = "OAuth authorisation successful!" + if username: + msg += f"\n\nAuthorised as: {username}" + MessageService.information(self, "Success", msg, safety_level="low") + else: + MessageService.warning(self, "Failed", "OAuth authorisation failed or was cancelled.", safety_level="low") + + def _save(self): + try: + # Validate values (only if resource_edits exist) + for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items(): + if max_tasks_spin.value() > 128: + self.error_label.setText(f"Invalid value for {k}: Max Tasks must be <= 128.") + return + if self.bandwidth_spin and self.bandwidth_spin.value() > 1000000: + self.error_label.setText("Bandwidth limit must be <= 1,000,000 KB/s.") + return + self.error_label.setText("") + + # Save resource settings + for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items(): + resource_data = self.resource_settings.get(k, {}) + resource_data['MaxTasks'] = max_tasks_spin.value() + self.resource_settings[k] = resource_data + + # Save bandwidth limit to Downloads resource MaxThroughput (only if bandwidth UI exists) + if self.bandwidth_spin: + if "Downloads" not in self.resource_settings: + self.resource_settings["Downloads"] = {"MaxTasks": 16} # Provide default MaxTasks + # Convert KB/s to bytes/s for storage (resource_settings.json expects bytes) + bandwidth_kb = self.bandwidth_spin.value() + bandwidth_bytes = bandwidth_kb * 1024 + self.resource_settings["Downloads"]["MaxThroughput"] = bandwidth_bytes + + # Save all resource settings (including bandwidth) in one operation + self._save_json(self.resource_settings_path, self.resource_settings) + + # Save debug mode to config + self.config_handler.set('debug_mode', self.debug_checkbox.isChecked()) + # OAuth disabled for v0.1.8 - no fallback setting needed + # Save API key + api_key = self.api_key_edit.text().strip() + self.config_handler.save_api_key(api_key) + # Save modlist base dirs + self.config_handler.set("modlist_install_base_dir", self.install_dir_edit.text().strip()) + self.config_handler.set("modlist_downloads_base_dir", self.download_dir_edit.text().strip()) + # Save jackify data directory (always store actual path, never None) + jackify_data_dir = self.jackify_data_dir_edit.text().strip() + self.config_handler.set("jackify_data_dir", jackify_data_dir) + + # Initialize with existing config values as fallback (prevents UnboundLocalError if auto-detection fails) + resolved_install_path = self.config_handler.get("proton_path", "") + resolved_install_version = self.config_handler.get("proton_version", "") + + # Save Install Proton selection - resolve "auto" to actual path + selected_install_proton_path = self.install_proton_dropdown.currentData() + if selected_install_proton_path == "none": + # No Proton detected - warn user but allow saving other settings + MessageService.warning( + self, + "No Compatible Proton Installed", + "Jackify requires Proton 9.0+, Proton Experimental, or GE-Proton 10+ to install modlists.\n\n" + "To install Proton:\n" + "1. Install any Windows game in Steam (Proton downloads automatically), OR\n" + "2. Install GE-Proton using ProtonPlus or ProtonUp-Qt, OR\n" + "3. Download GE-Proton manually from:\n" + " https://github.com/GloriousEggroll/proton-ge-custom/releases\n\n" + "Your other settings will be saved, but modlist installation may not work without Proton.", + safety_level="medium" + ) + logger.warning("No Proton detected - user warned, allowing save to proceed for other settings") + # Don't modify Proton config, but continue to save other settings + elif selected_install_proton_path == "auto": + # Resolve "auto" to actual best Proton path using unified detection + try: + from jackify.backend.handlers.wine_utils import WineUtils + best_proton = WineUtils.select_best_proton() + + if best_proton: + resolved_install_path = str(best_proton['path']) + resolved_install_version = best_proton['name'] + self.config_handler.set("proton_path", resolved_install_path) + self.config_handler.set("proton_version", resolved_install_version) + else: + # No Proton found - don't write anything, let engine auto-detect + logger.warning("Auto Proton selection failed: No Proton versions found") + # Don't modify existing config values + except Exception as e: + # Exception during detection - log it and don't write anything + logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True) + # Don't modify existing config values + else: + # User selected specific Proton version + resolved_install_path = selected_install_proton_path + resolved_install_version = self.install_proton_dropdown.currentText() + self.config_handler.set("proton_path", resolved_install_path) + self.config_handler.set("proton_version", resolved_install_version) + + # Save Game Proton selection + selected_game_proton_path = self.game_proton_dropdown.currentData() + if selected_game_proton_path == "same_as_install": + # Use same as install proton + resolved_game_path = resolved_install_path + resolved_game_version = resolved_install_version + else: + # User selected specific game Proton version + resolved_game_path = selected_game_proton_path + resolved_game_version = self.game_proton_dropdown.currentText() + + self.config_handler.set("game_proton_path", resolved_game_path) + self.config_handler.set("game_proton_version", resolved_game_version) + + # Save component installation method preference + if self.winetricks_radio.isChecked(): + method = 'winetricks' + else: # protontricks_radio (alternative) + method = 'system_protontricks' + + old_method = self.config_handler.get('component_installation_method', 'winetricks') + method_changed = (old_method != method) + + self.config_handler.set("component_installation_method", method) + self.config_handler.set("use_winetricks_for_components", method == 'winetricks') + + # Force immediate save and verify + save_result = self.config_handler.save_config() + if not save_result: + self.logger.error("Failed to save Proton configuration") + else: + self.logger.info(f"Saved Proton config: install_path={resolved_install_path}, game_path={resolved_game_path}") + # Verify the save worked by reading it back + saved_path = self.config_handler.get("proton_path") + if saved_path != resolved_install_path: + self.logger.error(f"Config save verification failed: expected {resolved_install_path}, got {saved_path}") + else: + self.logger.debug("Config save verified successfully") + + # Refresh cached paths in GUI screens if Jackify directory changed + self._refresh_gui_paths() + + # 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") + if reply == QMessageBox.Yes: + import os, sys + # User requested restart - do it regardless of execution environment + self.accept() + + # Check if running from AppImage + if os.environ.get('APPIMAGE'): + # AppImage: restart the AppImage + os.execv(os.environ['APPIMAGE'], [os.environ['APPIMAGE']] + sys.argv[1:]) + else: + # Dev mode: restart the Python module + os.execv(sys.executable, [sys.executable, '-m', 'jackify.frontends.gui'] + sys.argv[1:]) + return + + # If we get here, no restart was needed + # Check protontricks if user just switched to it + if method_changed and method == 'system_protontricks': + main_window = self.parent() + if main_window and hasattr(main_window, 'protontricks_service'): + is_installed, installation_type, details = main_window.protontricks_service.detect_protontricks(use_cache=False) + if not is_installed: + from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog + dialog = ProtontricksErrorDialog(main_window.protontricks_service, main_window) + dialog.exec() + + MessageService.information(self, "Settings Saved", "Settings have been saved successfully.", safety_level="low") + self.accept() + + except Exception as e: + self.logger.error(f"Error saving settings: {e}") + MessageService.warning(self, "Save Error", f"Failed to save settings: {e}", safety_level="medium") + + def _refresh_gui_paths(self): + """Refresh cached paths in all GUI screens.""" + try: + # Get the main window through parent relationship + main_window = self.parent() + if not main_window or not hasattr(main_window, 'stacked_widget'): + return + + # Refresh paths in all screens that have the method + screens_to_refresh = [ + getattr(main_window, 'install_modlist_screen', None), + getattr(main_window, 'configure_new_modlist_screen', None), + getattr(main_window, 'configure_existing_modlist_screen', None), + ] + + for screen in screens_to_refresh: + if screen and hasattr(screen, 'refresh_paths'): + screen.refresh_paths() + + except Exception as e: + print(f"Warning: Could not refresh GUI paths: {e}") + + def _bold_label(self, text): + label = QLabel(text) + 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 new file mode 100644 index 0000000..ba417a2 --- /dev/null +++ b/jackify/frontends/gui/dialogs/settings_dialog_proton.py @@ -0,0 +1,114 @@ +""" +Settings dialog Proton dropdown population and refresh. +""" + +import logging + +logger = logging.getLogger(__name__) + + +class SettingsDialogProtonMixin: + """Mixin providing Proton dropdown population and refresh for SettingsDialog.""" + + def _get_proton_10_path(self): + try: + 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.'): + return proton['path'] + return 'auto' + except Exception: + return 'auto' + + def _populate_install_proton_dropdown(self): + try: + from jackify.backend.handlers.wine_utils import WineUtils + available_protons = WineUtils.scan_all_proton_versions() + has_proton = len(available_protons) > 0 + if has_proton: + self.install_proton_dropdown.addItem("Auto (Recommended)", "auto") + else: + self.install_proton_dropdown.addItem("No Proton Versions Detected", "none") + + fast_protons = [] + slow_protons = [] + for proton in available_protons: + proton_name = proton.get('name', 'Unknown Proton') + proton_type = proton.get('type', 'Unknown') + if proton_type not in ('GE-Proton', 'Valve-Proton'): + logger.debug( + "Skipping %s (%s) from Install Proton dropdown - third-party builds may have compatibility issues", + proton_name, proton_type + ) + continue + slow_warning = False + is_fast_proton = False + display_name = proton_name + if proton_name == "Proton - Experimental": + is_fast_proton = True + elif proton_type == 'GE-Proton': + major_version = proton.get('major_version') + if major_version is not None and isinstance(major_version, int) and major_version >= 10: + is_fast_proton = True + elif 'GE-Proton9' in proton_name or 'GE-Proton8' in proton_name: + slow_warning = True + display_name = f"{proton_name} (GE)" + elif proton_type == 'Valve-Proton': + if proton_name.startswith("Proton 9") or "9.0" in proton_name: + slow_warning = True + if slow_warning: + display_name = f"{display_name} (Slow texture processing)" + slow_protons.append((display_name, str(proton['path']))) + else: + fast_protons.append((display_name, str(proton['path']))) + + for display_name, path in fast_protons: + self.install_proton_dropdown.addItem(display_name, path) + if slow_protons: + self.install_proton_dropdown.insertSeparator(self.install_proton_dropdown.count()) + for display_name, path in slow_protons: + self.install_proton_dropdown.addItem(display_name, path) + saved_proton = self.config_handler.get('proton_path', self._get_proton_10_path()) + self._set_dropdown_selection(self.install_proton_dropdown, saved_proton) + except Exception as e: + logger.error("Failed to populate install Proton dropdown: %s", e) + self.install_proton_dropdown.addItem("Auto (Recommended)", "auto") + + def _populate_game_proton_dropdown(self): + try: + from jackify.backend.handlers.wine_utils import WineUtils + available_protons = WineUtils.scan_all_proton_versions() + self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install") + for proton in available_protons: + proton_name = proton.get('name', 'Unknown Proton') + proton_type = proton.get('type', 'Unknown') + display_name = f"{proton_name} (GE)" if proton_type == 'GE-Proton' else proton_name + self.game_proton_dropdown.addItem(display_name, str(proton['path'])) + saved_game_proton = self.config_handler.get('game_proton_path', 'same_as_install') + self._set_dropdown_selection(self.game_proton_dropdown, saved_game_proton) + except Exception as e: + logger.error("Failed to populate game Proton dropdown: %s", e) + self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install") + + def _set_dropdown_selection(self, dropdown, saved_value): + found_match = False + for i in range(dropdown.count()): + if dropdown.itemData(i) == saved_value: + dropdown.setCurrentIndex(i) + found_match = True + break + if not found_match and saved_value not in ["auto", "same_as_install"]: + dropdown.setCurrentIndex(0) + + def _refresh_install_proton_dropdown(self): + current_selection = self.install_proton_dropdown.currentData() + self.install_proton_dropdown.clear() + self._populate_install_proton_dropdown() + self._set_dropdown_selection(self.install_proton_dropdown, current_selection) + + def _refresh_game_proton_dropdown(self): + current_selection = self.game_proton_dropdown.currentData() + self.game_proton_dropdown.clear() + self._populate_game_proton_dropdown() + self._set_dropdown_selection(self.game_proton_dropdown, current_selection) diff --git a/jackify/frontends/gui/dialogs/settings_dialog_tabs.py b/jackify/frontends/gui/dialogs/settings_dialog_tabs.py new file mode 100644 index 0000000..37cc25b --- /dev/null +++ b/jackify/frontends/gui/dialogs/settings_dialog_tabs.py @@ -0,0 +1,280 @@ +""" +Settings dialog tab creation: General and Advanced tabs. +""" + +import os +import logging +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QCheckBox, + QComboBox, QGroupBox, QFormLayout, QGridLayout, QSpinBox, QRadioButton, QButtonGroup, + QToolButton +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon + +logger = logging.getLogger(__name__) + + +class SettingsDialogTabsMixin: + """Mixin providing _create_general_tab and _create_advanced_tab for SettingsDialog.""" + + def _create_general_tab(self): + general_tab = QWidget() + general_layout = QVBoxLayout(general_tab) + + dir_group = QGroupBox("Directory Paths") + dir_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + dir_layout = QFormLayout() + dir_group.setLayout(dir_layout) + self.install_dir_edit = QLineEdit(self.config_handler.get("modlist_install_base_dir", "")) + self.install_dir_edit.setToolTip("Default directory for modlist installations.") + self.install_dir_btn = QPushButton() + self.install_dir_btn.setIcon(QIcon.fromTheme("folder-open")) + self.install_dir_btn.setToolTip("Browse for directory") + self.install_dir_btn.setFixedWidth(32) + self.install_dir_btn.clicked.connect(lambda: self._pick_directory(self.install_dir_edit)) + install_dir_row = QHBoxLayout() + install_dir_row.addWidget(self.install_dir_edit) + install_dir_row.addWidget(self.install_dir_btn) + dir_layout.addRow(QLabel("Install Base Dir:"), install_dir_row) + self.download_dir_edit = QLineEdit(self.config_handler.get("modlist_downloads_base_dir", "")) + self.download_dir_edit.setToolTip("Default directory for modlist downloads.") + self.download_dir_btn = QPushButton() + self.download_dir_btn.setIcon(QIcon.fromTheme("folder-open")) + self.download_dir_btn.setToolTip("Browse for directory") + self.download_dir_btn.setFixedWidth(32) + self.download_dir_btn.clicked.connect(lambda: self._pick_directory(self.download_dir_edit)) + download_dir_row = QHBoxLayout() + download_dir_row.addWidget(self.download_dir_edit) + download_dir_row.addWidget(self.download_dir_btn) + dir_layout.addRow(QLabel("Downloads Base Dir:"), download_dir_row) + + from jackify.shared.paths import get_jackify_data_dir + current_jackify_dir = str(get_jackify_data_dir()) + self.jackify_data_dir_edit = QLineEdit(current_jackify_dir) + self.jackify_data_dir_edit.setToolTip("Directory for Jackify data (logs, downloads, temp files). Default: ~/Jackify") + self.jackify_data_dir_btn = QPushButton() + self.jackify_data_dir_btn.setIcon(QIcon.fromTheme("folder-open")) + self.jackify_data_dir_btn.setToolTip("Browse for directory") + self.jackify_data_dir_btn.setFixedWidth(32) + self.jackify_data_dir_btn.clicked.connect(lambda: self._pick_directory(self.jackify_data_dir_edit)) + jackify_data_dir_row = QHBoxLayout() + jackify_data_dir_row.addWidget(self.jackify_data_dir_edit) + jackify_data_dir_row.addWidget(self.jackify_data_dir_btn) + reset_jackify_dir_btn = QPushButton("Reset") + reset_jackify_dir_btn.setToolTip("Reset to default (~/ Jackify)") + reset_jackify_dir_btn.setFixedWidth(50) + reset_jackify_dir_btn.clicked.connect(lambda: self.jackify_data_dir_edit.setText(str(Path.home() / "Jackify"))) + jackify_data_dir_row.addWidget(reset_jackify_dir_btn) + dir_layout.addRow(QLabel("Jackify Data Dir:"), jackify_data_dir_row) + general_layout.addWidget(dir_group) + general_layout.addSpacing(12) + + proton_group = QGroupBox("Proton Version Settings") + proton_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + proton_layout = QVBoxLayout() + proton_group.setLayout(proton_layout) + install_proton_layout = QHBoxLayout() + self.install_proton_dropdown = QComboBox() + self.install_proton_dropdown.setToolTip("Proton version for modlist installation and texture processing (requires fast Proton)") + self.install_proton_dropdown.setMinimumWidth(200) + install_refresh_btn = QPushButton("\u21BB") + install_refresh_btn.setFixedSize(30, 30) + install_refresh_btn.setToolTip("Refresh install Proton version list") + install_refresh_btn.clicked.connect(self._refresh_install_proton_dropdown) + install_proton_layout.addWidget(QLabel("Install Proton:")) + install_proton_layout.addWidget(self.install_proton_dropdown) + install_proton_layout.addWidget(install_refresh_btn) + install_proton_layout.addStretch() + game_proton_layout = QHBoxLayout() + self.game_proton_dropdown = QComboBox() + self.game_proton_dropdown.setToolTip("Proton version for game shortcuts (can be any Proton 9+)") + self.game_proton_dropdown.setMinimumWidth(200) + game_refresh_btn = QPushButton("\u21BB") + game_refresh_btn.setFixedSize(30, 30) + game_refresh_btn.setToolTip("Refresh game Proton version list") + game_refresh_btn.clicked.connect(self._refresh_game_proton_dropdown) + game_proton_layout.addWidget(QLabel("Game Proton:")) + game_proton_layout.addWidget(self.game_proton_dropdown) + game_proton_layout.addWidget(game_refresh_btn) + game_proton_layout.addStretch() + proton_layout.addLayout(install_proton_layout) + proton_layout.addLayout(game_proton_layout) + self._populate_install_proton_dropdown() + self._populate_game_proton_dropdown() + general_layout.addWidget(proton_group) + general_layout.addSpacing(12) + + from jackify.frontends.gui.services.message_service import MessageService + oauth_group = QGroupBox("Nexus Authentication") + oauth_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + oauth_layout = QVBoxLayout() + oauth_group.setLayout(oauth_layout) + oauth_status_layout = QHBoxLayout() + self.oauth_status_label = QLabel("Checking...") + self.oauth_status_label.setStyleSheet("color: #ccc;") + self.oauth_btn = QPushButton("Authorise") + self.oauth_btn.setMaximumWidth(100) + self.oauth_btn.clicked.connect(self._handle_oauth_click) + oauth_status_layout.addWidget(QLabel("Status:")) + oauth_status_layout.addWidget(self.oauth_status_label) + oauth_status_layout.addWidget(self.oauth_btn) + oauth_status_layout.addStretch() + oauth_layout.addLayout(oauth_status_layout) + self._update_oauth_status() + general_layout.addWidget(oauth_group) + general_layout.addSpacing(12) + + debug_group = QGroupBox("Enable Debug") + debug_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + debug_layout = QVBoxLayout() + debug_group.setLayout(debug_layout) + self.debug_checkbox = QCheckBox("Enable debug mode (requires restart)") + self.debug_checkbox.setChecked(self.config_handler.get('debug_mode', False)) + self.debug_checkbox.setToolTip("Enable verbose debug logging. Requires Jackify restart to take effect.") + self.debug_checkbox.setStyleSheet("color: #fff;") + debug_layout.addWidget(self.debug_checkbox) + general_layout.addWidget(debug_group) + general_layout.addStretch() + self.tab_widget.addTab(general_tab, "General") + + def _create_advanced_tab(self): + advanced_tab = QWidget() + advanced_layout = QVBoxLayout(advanced_tab) + auth_group = QGroupBox("Nexus Authentication") + auth_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + auth_layout = QVBoxLayout() + auth_group.setLayout(auth_layout) + api_layout = QHBoxLayout() + self.api_key_edit = QLineEdit() + self.api_key_edit.setEchoMode(QLineEdit.Password) + api_key = self.config_handler.get_api_key() + self.api_key_edit.setText(api_key if api_key else "") + self.api_key_edit.setToolTip("Your Nexus API Key (legacy authentication method)") + self.api_key_edit.textChanged.connect(self._on_api_key_changed) + self.api_show_btn = QToolButton() + self.api_show_btn.setCheckable(True) + self.api_show_btn.setIcon(QIcon.fromTheme("view-visible")) + self.api_show_btn.setToolTip("Show or hide your API key") + self.api_show_btn.toggled.connect(self._toggle_api_key_visibility) + clear_api_btn = QPushButton("Clear") + clear_api_btn.clicked.connect(self._clear_api_key) + clear_api_btn.setMaximumWidth(60) + api_layout.addWidget(QLabel("API Key:")) + api_layout.addWidget(self.api_key_edit) + api_layout.addWidget(self.api_show_btn) + api_layout.addWidget(clear_api_btn) + auth_layout.addLayout(api_layout) + advanced_layout.addWidget(auth_group) + advanced_layout.addSpacing(12) + + self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json") + self.resource_settings = self._load_json(self.resource_settings_path) + self.resource_edits = {} + resource_group = QGroupBox("Resource Limits") + resource_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + resource_outer_layout = QVBoxLayout() + resource_group.setLayout(resource_outer_layout) + if not self.resource_settings: + info_label = QLabel("Resource Limit settings will be generated once a modlist install action is performed") + info_label.setStyleSheet("color: #aaa; font-style: italic; padding: 20px; font-size: 11pt;") + info_label.setWordWrap(True) + info_label.setAlignment(Qt.AlignCenter) + info_label.setMinimumHeight(60) + resource_outer_layout.addWidget(info_label) + else: + resource_grid = QGridLayout() + resource_grid.setVerticalSpacing(4) + resource_grid.setHorizontalSpacing(8) + resource_grid.setColumnMinimumWidth(2, 40) + resource_grid.addWidget(self._bold_label("Resource"), 0, 0, 1, 1, Qt.AlignLeft) + resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 1, 1, 1, Qt.AlignLeft) + resource_grid.addWidget(self._bold_label("Resource"), 0, 3, 1, 1, Qt.AlignLeft) + resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 4, 1, 1, Qt.AlignLeft) + resource_items = list(self.resource_settings.items()) + bandwidth_kb = 0 + if "Downloads" in self.resource_settings: + bandwidth_kb = self.resource_settings["Downloads"].get("MaxThroughput", 0) // 1024 or 0 + left_row = 1 + for k, v in resource_items[:4]: + try: + resource_grid.addWidget(QLabel(f"{k}:", parent=self), left_row, 0, 1, 1, Qt.AlignLeft) + max_tasks_spin = QSpinBox() + max_tasks_spin.setMinimum(1) + max_tasks_spin.setMaximum(128) + max_tasks_spin.setValue(v.get('MaxTasks', 16)) + max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.") + max_tasks_spin.setFixedWidth(100) + resource_grid.addWidget(max_tasks_spin, left_row, 1) + self.resource_edits[k] = (None, max_tasks_spin) + left_row += 1 + except Exception as e: + self.logger.error("Failed to create widgets for resource '%s': %s", k, e) + continue + right_row = 1 + for k, v in resource_items[4:]: + try: + resource_grid.addWidget(QLabel(f"{k}:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft) + max_tasks_spin = QSpinBox() + max_tasks_spin.setMinimum(1) + max_tasks_spin.setMaximum(128) + max_tasks_spin.setValue(v.get('MaxTasks', 16)) + max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.") + max_tasks_spin.setFixedWidth(100) + resource_grid.addWidget(max_tasks_spin, right_row, 4) + self.resource_edits[k] = (None, max_tasks_spin) + right_row += 1 + except Exception as e: + self.logger.error("Failed to create widgets for resource '%s': %s", k, e) + continue + if "Downloads" in self.resource_settings: + resource_grid.addWidget(QLabel("Bandwidth Limit:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft) + self.bandwidth_spin = QSpinBox() + self.bandwidth_spin.setMinimum(0) + self.bandwidth_spin.setMaximum(1000000) + self.bandwidth_spin.setValue(bandwidth_kb) + self.bandwidth_spin.setSuffix(" KB/s") + self.bandwidth_spin.setFixedWidth(100) + self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.") + bandwidth_widget_layout = QHBoxLayout() + bandwidth_widget_layout.setContentsMargins(0, 0, 0, 0) + bandwidth_widget_layout.addWidget(self.bandwidth_spin) + bandwidth_note = QLabel("(0 = unlimited)") + bandwidth_note.setStyleSheet("color: #aaa; font-size: 9pt;") + bandwidth_widget_layout.addWidget(bandwidth_note) + bandwidth_widget_layout.addStretch() + bandwidth_container = QWidget() + bandwidth_container.setLayout(bandwidth_widget_layout) + resource_grid.addWidget(bandwidth_container, right_row, 4, 1, 1, Qt.AlignLeft) + else: + self.bandwidth_spin = None + resource_grid.setColumnStretch(5, 1) + resource_outer_layout.addLayout(resource_grid) + advanced_layout.addWidget(resource_group) + + component_group = QGroupBox("Advanced Tool Options") + component_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") + component_layout = QVBoxLayout() + component_group.setLayout(component_layout) + component_layout.addWidget(QLabel("Wine Components Installation:")) + self.component_method_group = QButtonGroup() + component_method_layout = QVBoxLayout() + current_method = self.config_handler.get('component_installation_method', 'winetricks') + if current_method == 'bundled_protontricks': + current_method = 'system_protontricks' + self.winetricks_radio = QRadioButton("Winetricks (Default)") + self.winetricks_radio.setChecked(current_method == 'winetricks') + self.winetricks_radio.setToolTip("Use bundled winetricks for component installation. Faster and more reliable.") + self.component_method_group.addButton(self.winetricks_radio, 0) + component_method_layout.addWidget(self.winetricks_radio) + self.protontricks_radio = QRadioButton("Protontricks (Alternative)") + self.protontricks_radio.setChecked(current_method == 'system_protontricks') + self.protontricks_radio.setToolTip("Use system-installed protontricks (flatpak or native). Fallback option if winetricks fails.") + self.component_method_group.addButton(self.protontricks_radio, 1) + component_method_layout.addWidget(self.protontricks_radio) + component_layout.addLayout(component_method_layout) + advanced_layout.addWidget(component_group) + advanced_layout.addStretch() + self.tab_widget.addTab(advanced_tab, "Advanced") diff --git a/jackify/frontends/gui/dialogs/success_dialog.py b/jackify/frontends/gui/dialogs/success_dialog.py index 07ce598..f36d34e 100644 --- a/jackify/frontends/gui/dialogs/success_dialog.py +++ b/jackify/frontends/gui/dialogs/success_dialog.py @@ -244,7 +244,7 @@ class SuccessDialog(QDialog): else: base_message = f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!" - # Note: ENB-specific Proton warning is now shown in a separate dialog when ENB is detected + # ENB Proton warning shown in separate dialog return base_message def _update_countdown(self): diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py index f827c13..7d71748 100644 --- a/jackify/frontends/gui/main.py +++ b/jackify/frontends/gui/main.py @@ -115,6 +115,13 @@ from jackify.backend.services.modlist_service import ModlistService from jackify.frontends.gui.services.message_service import MessageService from jackify.frontends.gui.shared_theme import DEBUG_BORDERS from jackify.frontends.gui.utils import get_screen_geometry, set_responsive_minimum +from jackify.frontends.gui.dialogs.settings_dialog import SettingsDialog +from jackify.frontends.gui.mixins.main_window_geometry import MainWindowGeometryMixin +from jackify.frontends.gui.mixins.main_window_backend import MainWindowBackendMixin +from jackify.frontends.gui.mixins.main_window_ui import MainWindowUIMixin +from jackify.frontends.gui.mixins.main_window_startup import MainWindowStartupMixin +from jackify.frontends.gui.mixins.main_window_dialogs import MainWindowDialogsMixin +from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder ENABLE_WINDOW_HEIGHT_ANIMATION = False @@ -141,929 +148,14 @@ MENU_ITEMS = [ ] -class FeaturePlaceholder(QWidget): - """Placeholder widget for features not yet implemented""" - - def __init__(self, stacked_widget=None): - super().__init__() - layout = QVBoxLayout() - - label = QLabel("[Feature screen placeholder]") - label.setAlignment(Qt.AlignCenter) - layout.addWidget(label) - - back_btn = QPushButton("Back to Main Menu") - if stacked_widget: - back_btn.clicked.connect(lambda: stacked_widget.setCurrentIndex(0)) - layout.addWidget(back_btn) - - self.setLayout(layout) - - -class SettingsDialog(QDialog): - def __init__(self, parent=None): - try: - super().__init__(parent) - from jackify.backend.handlers.config_handler import ConfigHandler - import logging - self.logger = logging.getLogger(__name__) - self.config_handler = ConfigHandler() - self._original_debug_mode = self.config_handler.get('debug_mode', False) - self.setWindowTitle("Settings") - self.setModal(True) - self.setMinimumWidth(650) - self.setMaximumWidth(800) - self.setStyleSheet("QDialog { background-color: #232323; color: #eee; } QPushButton:hover { background-color: #333; }") - - main_layout = QVBoxLayout() - self.setLayout(main_layout) - - # Create tab widget - self.tab_widget = QTabWidget() - self.tab_widget.setStyleSheet(""" - QTabWidget::pane { border: 1px solid #555; background: #232323; } - QTabBar::tab { background: #333; color: #eee; padding: 8px 16px; margin: 2px; } - QTabBar::tab:selected { background: #555; } - QTabBar::tab:hover { background: #444; } - """) - main_layout.addWidget(self.tab_widget) - - # Create tabs - self._create_general_tab() - self._create_advanced_tab() - - # --- Save/Close/Help Buttons --- - btn_layout = QHBoxLayout() - self.help_btn = QPushButton("Help") - self.help_btn.setToolTip("Help/documentation coming soon!") - self.help_btn.clicked.connect(self._show_help) - btn_layout.addWidget(self.help_btn) - btn_layout.addStretch(1) - save_btn = QPushButton("Save") - close_btn = QPushButton("Close") - save_btn.clicked.connect(self._save) - close_btn.clicked.connect(self.reject) - btn_layout.addWidget(save_btn) - btn_layout.addWidget(close_btn) - - # Add error label for validation messages - self.error_label = QLabel("") - self.error_label.setStyleSheet("QLabel { color: #ff6b6b; }") - main_layout.addWidget(self.error_label) - - main_layout.addSpacing(10) - main_layout.addLayout(btn_layout) - - except Exception as e: - print(f"[ERROR] Exception in SettingsDialog.__init__: {e}") - import traceback - traceback.print_exc() - - def _create_general_tab(self): - """Create the General settings tab""" - general_tab = QWidget() - general_layout = QVBoxLayout(general_tab) - - # --- Directory Paths Section (moved to top as most essential) --- - dir_group = QGroupBox("Directory Paths") - dir_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") - dir_layout = QFormLayout() - dir_group.setLayout(dir_layout) - self.install_dir_edit = QLineEdit(self.config_handler.get("modlist_install_base_dir", "")) - self.install_dir_edit.setToolTip("Default directory for modlist installations.") - self.install_dir_btn = QPushButton() - self.install_dir_btn.setIcon(QIcon.fromTheme("folder-open")) - self.install_dir_btn.setToolTip("Browse for directory") - self.install_dir_btn.setFixedWidth(32) - self.install_dir_btn.clicked.connect(lambda: self._pick_directory(self.install_dir_edit)) - install_dir_row = QHBoxLayout() - install_dir_row.addWidget(self.install_dir_edit) - install_dir_row.addWidget(self.install_dir_btn) - dir_layout.addRow(QLabel("Install Base Dir:"), install_dir_row) - self.download_dir_edit = QLineEdit(self.config_handler.get("modlist_downloads_base_dir", "")) - self.download_dir_edit.setToolTip("Default directory for modlist downloads.") - self.download_dir_btn = QPushButton() - self.download_dir_btn.setIcon(QIcon.fromTheme("folder-open")) - self.download_dir_btn.setToolTip("Browse for directory") - self.download_dir_btn.setFixedWidth(32) - self.download_dir_btn.clicked.connect(lambda: self._pick_directory(self.download_dir_edit)) - download_dir_row = QHBoxLayout() - download_dir_row.addWidget(self.download_dir_edit) - download_dir_row.addWidget(self.download_dir_btn) - dir_layout.addRow(QLabel("Downloads Base Dir:"), download_dir_row) - - # Jackify Data Directory - from jackify.shared.paths import get_jackify_data_dir - current_jackify_dir = str(get_jackify_data_dir()) - self.jackify_data_dir_edit = QLineEdit(current_jackify_dir) - self.jackify_data_dir_edit.setToolTip("Directory for Jackify data (logs, downloads, temp files). Default: ~/Jackify") - self.jackify_data_dir_btn = QPushButton() - self.jackify_data_dir_btn.setIcon(QIcon.fromTheme("folder-open")) - self.jackify_data_dir_btn.setToolTip("Browse for directory") - self.jackify_data_dir_btn.setFixedWidth(32) - self.jackify_data_dir_btn.clicked.connect(lambda: self._pick_directory(self.jackify_data_dir_edit)) - jackify_data_dir_row = QHBoxLayout() - jackify_data_dir_row.addWidget(self.jackify_data_dir_edit) - jackify_data_dir_row.addWidget(self.jackify_data_dir_btn) - - # Reset to default button - reset_jackify_dir_btn = QPushButton("Reset") - reset_jackify_dir_btn.setToolTip("Reset to default (~/ Jackify)") - reset_jackify_dir_btn.setFixedWidth(50) - reset_jackify_dir_btn.clicked.connect(lambda: self.jackify_data_dir_edit.setText(str(Path.home() / "Jackify"))) - jackify_data_dir_row.addWidget(reset_jackify_dir_btn) - - dir_layout.addRow(QLabel("Jackify Data Dir:"), jackify_data_dir_row) - general_layout.addWidget(dir_group) - general_layout.addSpacing(12) - - # --- Proton Version Settings Section --- - proton_group = QGroupBox("Proton Version Settings") - proton_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") - proton_layout = QVBoxLayout() - proton_group.setLayout(proton_layout) - - # Install Proton Version (for jackify-engine texture processing) - install_proton_layout = QHBoxLayout() - self.install_proton_dropdown = QComboBox() - self.install_proton_dropdown.setToolTip("Proton version for modlist installation and texture processing (requires fast Proton)") - self.install_proton_dropdown.setMinimumWidth(200) - - install_refresh_btn = QPushButton("↻") - install_refresh_btn.setFixedSize(30, 30) - install_refresh_btn.setToolTip("Refresh install Proton version list") - install_refresh_btn.clicked.connect(self._refresh_install_proton_dropdown) - - install_proton_layout.addWidget(QLabel("Install Proton:")) - install_proton_layout.addWidget(self.install_proton_dropdown) - install_proton_layout.addWidget(install_refresh_btn) - install_proton_layout.addStretch() - - # Game Proton Version (for game shortcuts) - game_proton_layout = QHBoxLayout() - self.game_proton_dropdown = QComboBox() - self.game_proton_dropdown.setToolTip("Proton version for game shortcuts (can be any Proton 9+)") - self.game_proton_dropdown.setMinimumWidth(200) - - game_refresh_btn = QPushButton("↻") - game_refresh_btn.setFixedSize(30, 30) - game_refresh_btn.setToolTip("Refresh game Proton version list") - game_refresh_btn.clicked.connect(self._refresh_game_proton_dropdown) - - game_proton_layout.addWidget(QLabel("Game Proton:")) - game_proton_layout.addWidget(self.game_proton_dropdown) - game_proton_layout.addWidget(game_refresh_btn) - game_proton_layout.addStretch() - - proton_layout.addLayout(install_proton_layout) - proton_layout.addLayout(game_proton_layout) - - # Populate both Proton dropdowns - self._populate_install_proton_dropdown() - self._populate_game_proton_dropdown() - - general_layout.addWidget(proton_group) - general_layout.addSpacing(12) - - # --- Nexus OAuth Section --- - oauth_group = QGroupBox("Nexus Authentication") - oauth_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") - oauth_layout = QVBoxLayout() - oauth_group.setLayout(oauth_layout) - - # OAuth status and button - oauth_status_layout = QHBoxLayout() - self.oauth_status_label = QLabel("Checking...") - self.oauth_status_label.setStyleSheet("color: #ccc;") - - self.oauth_btn = QPushButton("Authorise") - self.oauth_btn.setMaximumWidth(100) - self.oauth_btn.clicked.connect(self._handle_oauth_click) - - oauth_status_layout.addWidget(QLabel("Status:")) - oauth_status_layout.addWidget(self.oauth_status_label) - oauth_status_layout.addWidget(self.oauth_btn) - oauth_status_layout.addStretch() - - oauth_layout.addLayout(oauth_status_layout) - - # Update OAuth status on init - self._update_oauth_status() - - general_layout.addWidget(oauth_group) - general_layout.addSpacing(12) - - # --- Enable Debug Section (moved to bottom as advanced option) --- - debug_group = QGroupBox("Enable Debug") - debug_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") - debug_layout = QVBoxLayout() - debug_group.setLayout(debug_layout) - self.debug_checkbox = QCheckBox("Enable debug mode (requires restart)") - # Load debug_mode from config - self.debug_checkbox.setChecked(self.config_handler.get('debug_mode', False)) - self.debug_checkbox.setToolTip("Enable verbose debug logging. Requires Jackify restart to take effect.") - self.debug_checkbox.setStyleSheet("color: #fff;") - debug_layout.addWidget(self.debug_checkbox) - general_layout.addWidget(debug_group) - general_layout.addStretch() # Add stretch to push content to top - - self.tab_widget.addTab(general_tab, "General") - - def _create_advanced_tab(self): - """Create the Advanced settings tab""" - advanced_tab = QWidget() - advanced_layout = QVBoxLayout(advanced_tab) - - # --- Nexus Authentication Section --- - auth_group = QGroupBox("Nexus Authentication") - auth_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") - auth_layout = QVBoxLayout() - auth_group.setLayout(auth_layout) - - # OAuth temporarily disabled for v0.1.8 - API key is primary auth method - # API Key Fallback Checkbox (hidden until OAuth re-enabled) - # self.api_key_fallback_checkbox = QCheckBox("Enable API Key Fallback (Legacy)") - # self.api_key_fallback_checkbox.setChecked(self.config_handler.get("api_key_fallback_enabled", False)) - # self.api_key_fallback_checkbox.setToolTip("Allow using API key if OAuth fails or is unavailable (not recommended)") - # auth_layout.addWidget(self.api_key_fallback_checkbox) - - # API Key Section - api_layout = QHBoxLayout() - self.api_key_edit = QLineEdit() - self.api_key_edit.setEchoMode(QLineEdit.Password) - api_key = self.config_handler.get_api_key() - if api_key: - self.api_key_edit.setText(api_key) - else: - self.api_key_edit.setText("") - self.api_key_edit.setToolTip("Your Nexus API Key (legacy authentication method)") - self.api_key_edit.textChanged.connect(self._on_api_key_changed) - - self.api_show_btn = QToolButton() - self.api_show_btn.setCheckable(True) - self.api_show_btn.setIcon(QIcon.fromTheme("view-visible")) - self.api_show_btn.setToolTip("Show or hide your API key") - self.api_show_btn.toggled.connect(self._toggle_api_key_visibility) - - clear_api_btn = QPushButton("Clear") - clear_api_btn.clicked.connect(self._clear_api_key) - clear_api_btn.setMaximumWidth(60) - - api_layout.addWidget(QLabel("API Key:")) - api_layout.addWidget(self.api_key_edit) - api_layout.addWidget(self.api_show_btn) - api_layout.addWidget(clear_api_btn) - auth_layout.addLayout(api_layout) - - advanced_layout.addWidget(auth_group) - advanced_layout.addSpacing(12) - - resource_group = QGroupBox("Resource Limits") - resource_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") - resource_outer_layout = QVBoxLayout() - resource_group.setLayout(resource_outer_layout) - - self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json") - self.resource_settings = self._load_json(self.resource_settings_path) - self.resource_edits = {} - - # If no resources exist, show helpful message - if not self.resource_settings: - info_label = QLabel("Resource Limit settings will be generated once a modlist install action is performed") - info_label.setStyleSheet("color: #aaa; font-style: italic; padding: 20px; font-size: 11pt;") - info_label.setWordWrap(True) - info_label.setAlignment(Qt.AlignCenter) - info_label.setMinimumHeight(60) - resource_outer_layout.addWidget(info_label) - else: - # Two-column layout for better space usage - # Use a single grid with proper column spacing - resource_grid = QGridLayout() - resource_grid.setVerticalSpacing(4) - resource_grid.setHorizontalSpacing(8) - resource_grid.setColumnMinimumWidth(2, 40) # Spacing between columns - - # Headers for left column (columns 0-1) - resource_grid.addWidget(self._bold_label("Resource"), 0, 0, 1, 1, Qt.AlignLeft) - resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 1, 1, 1, Qt.AlignLeft) - - # Headers for right column (columns 3-4, skip column 2 for spacing) - resource_grid.addWidget(self._bold_label("Resource"), 0, 3, 1, 1, Qt.AlignLeft) - resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 4, 1, 1, Qt.AlignLeft) - - # Split resources between left and right columns (4 + 4) - resource_items = list(self.resource_settings.items()) - - # Find Bandwidth info from Downloads resource if it exists - bandwidth_kb = 0 - if "Downloads" in self.resource_settings: - downloads_throughput_bytes = self.resource_settings["Downloads"].get("MaxThroughput", 0) - bandwidth_kb = downloads_throughput_bytes // 1024 if downloads_throughput_bytes > 0 else 0 - - # Left column gets first 4 resources (columns 0-1) - left_row = 1 - for k, v in resource_items[:4]: - try: - resource_grid.addWidget(QLabel(f"{k}:", parent=self), left_row, 0, 1, 1, Qt.AlignLeft) - - max_tasks_spin = QSpinBox() - max_tasks_spin.setMinimum(1) - max_tasks_spin.setMaximum(128) - max_tasks_spin.setValue(v.get('MaxTasks', 16)) - max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.") - max_tasks_spin.setFixedWidth(100) - resource_grid.addWidget(max_tasks_spin, left_row, 1) - - self.resource_edits[k] = (None, max_tasks_spin) - left_row += 1 - except Exception as e: - print(f"[ERROR] Failed to create widgets for resource '{k}': {e}") - continue - - # Right column gets next 4 resources (columns 3-4, skip column 2 for spacing) - right_row = 1 - for k, v in resource_items[4:]: - try: - resource_grid.addWidget(QLabel(f"{k}:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft) - - max_tasks_spin = QSpinBox() - max_tasks_spin.setMinimum(1) - max_tasks_spin.setMaximum(128) - max_tasks_spin.setValue(v.get('MaxTasks', 16)) - max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.") - max_tasks_spin.setFixedWidth(100) - resource_grid.addWidget(max_tasks_spin, right_row, 4) - - self.resource_edits[k] = (None, max_tasks_spin) - right_row += 1 - except Exception as e: - print(f"[ERROR] Failed to create widgets for resource '{k}': {e}") - continue - - # Add Bandwidth Limit at the bottom of right column - if "Downloads" in self.resource_settings: - resource_grid.addWidget(QLabel("Bandwidth Limit:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft) - - self.bandwidth_spin = QSpinBox() - self.bandwidth_spin.setMinimum(0) - self.bandwidth_spin.setMaximum(1000000) - self.bandwidth_spin.setValue(bandwidth_kb) - self.bandwidth_spin.setSuffix(" KB/s") - self.bandwidth_spin.setFixedWidth(100) - self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.") - - # Create a layout for the spinbox and note - bandwidth_widget_layout = QHBoxLayout() - bandwidth_widget_layout.setContentsMargins(0, 0, 0, 0) - bandwidth_widget_layout.addWidget(self.bandwidth_spin) - - bandwidth_note = QLabel("(0 = unlimited)") - bandwidth_note.setStyleSheet("color: #aaa; font-size: 9pt;") - bandwidth_widget_layout.addWidget(bandwidth_note) - bandwidth_widget_layout.addStretch() - - # Create container widget for the layout - bandwidth_container = QWidget() - bandwidth_container.setLayout(bandwidth_widget_layout) - resource_grid.addWidget(bandwidth_container, right_row, 4, 1, 1, Qt.AlignLeft) - else: - self.bandwidth_spin = None - - # Add stretch column at the end to push content left - resource_grid.setColumnStretch(5, 1) - - resource_outer_layout.addLayout(resource_grid) - - advanced_layout.addWidget(resource_group) - - # Advanced Tool Options Section - component_group = QGroupBox("Advanced Tool Options") - component_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }") - component_layout = QVBoxLayout() - component_group.setLayout(component_layout) - - # Label for the radio buttons - method_label = QLabel("Wine Components Installation:") - component_layout.addWidget(method_label) - - # Radio button group for component installation method - self.component_method_group = QButtonGroup() - component_method_layout = QVBoxLayout() - - # Get current setting (default to winetricks) - current_method = self.config_handler.get('component_installation_method', 'winetricks') - # Migrate old bundled_protontricks users to system_protontricks - if current_method == 'bundled_protontricks': - current_method = 'system_protontricks' - - # Winetricks (default) - self.winetricks_radio = QRadioButton("Winetricks (Default)") - self.winetricks_radio.setChecked(current_method == 'winetricks') - self.winetricks_radio.setToolTip( - "Use bundled winetricks for component installation. Faster and more reliable." - ) - self.component_method_group.addButton(self.winetricks_radio, 0) - component_method_layout.addWidget(self.winetricks_radio) - - # Protontricks (alternative) - self.protontricks_radio = QRadioButton("Protontricks (Alternative)") - self.protontricks_radio.setChecked(current_method == 'system_protontricks') - self.protontricks_radio.setToolTip( - "Use system-installed protontricks (flatpak or native). Fallback option if winetricks fails." - ) - self.component_method_group.addButton(self.protontricks_radio, 1) - component_method_layout.addWidget(self.protontricks_radio) - - component_layout.addLayout(component_method_layout) - - advanced_layout.addWidget(component_group) - advanced_layout.addStretch() # Add stretch to push content to top - - self.tab_widget.addTab(advanced_tab, "Advanced") - - def _toggle_api_key_visibility(self, checked): - # Always use the same eyeball icon, only change color when toggled - eye_icon = QIcon.fromTheme("view-visible") - if not eye_icon.isNull(): - self.api_show_btn.setIcon(eye_icon) - self.api_show_btn.setText("") - else: - self.api_show_btn.setIcon(QIcon()) - self.api_show_btn.setText("\U0001F441") # 👁 - if checked: - self.api_key_edit.setEchoMode(QLineEdit.Normal) - self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }") # Jackify blue - else: - self.api_key_edit.setEchoMode(QLineEdit.Password) - self.api_show_btn.setStyleSheet("") - - 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) - - def _show_help(self): - from jackify.frontends.gui.services.message_service import MessageService - MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low") - - def _load_json(self, path): - if os.path.exists(path): - try: - with open(path, 'r') as f: - return json.load(f) - except Exception: - return {} - return {} - - def _save_json(self, path, data): - try: - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'w') as f: - json.dump(data, f, indent=2) - except Exception as e: - MessageService.warning(self, "Error", f"Failed to save {path}: {e}", safety_level="medium") - - def _clear_api_key(self): - self.api_key_edit.setText("") - self.config_handler.clear_api_key() - MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low") - - def _on_api_key_changed(self, text): - """Handle immediate API key saving when text changes""" - api_key = text.strip() - self.config_handler.save_api_key(api_key) - - def _update_oauth_status(self): - """Update OAuth status label and button""" - from jackify.backend.services.nexus_auth_service import NexusAuthService - 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") - self.oauth_status_label.setStyleSheet("color: #3fd0ea;") - self.oauth_btn.setText("Revoke") - elif method == 'oauth_expired': - self.oauth_status_label.setText("OAuth token expired") - self.oauth_status_label.setStyleSheet("color: #FFA726;") - self.oauth_btn.setText("Re-authorise") - else: - self.oauth_status_label.setText("Not authorised") - self.oauth_status_label.setStyleSheet("color: #f44336;") - self.oauth_btn.setText("Authorise") - - def _handle_oauth_click(self): - """Handle OAuth button click (Authorise or Revoke)""" - from jackify.backend.services.nexus_auth_service import NexusAuthService - from jackify.frontends.gui.services.message_service import MessageService - from PySide6.QtWidgets import QMessageBox, QProgressDialog, QApplication - from PySide6.QtCore import Qt - - auth_service = NexusAuthService() - authenticated, method, _ = auth_service.get_auth_status() - - if authenticated and method == 'oauth': - # Revoke OAuth - reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low") - if reply == QMessageBox.Yes: - auth_service.revoke_oauth() - self._update_oauth_status() - MessageService.information(self, "Revoked", "OAuth authorisation has been revoked.", safety_level="low") - else: - # Authorise with OAuth - reply = MessageService.question(self, "Authorise with Nexus", - "Your browser will open for Nexus authorisation.\n\n" - "Note: Your browser may ask permission to open 'xdg-open'\n" - "or Jackify's protocol handler - please click 'Open' or 'Allow'.\n\n" - "Please log in and authorise Jackify when prompted.\n\n" - "Continue?", safety_level="low") - - if reply != QMessageBox.Yes: - return - - progress = QProgressDialog( - "Waiting for authorisation...\n\nPlease check your browser.", - "Cancel", - 0, 0, - self - ) - progress.setWindowTitle("Nexus OAuth") - progress.setWindowModality(Qt.WindowModal) - progress.setMinimumDuration(0) - progress.setMinimumWidth(400) - progress.show() - QApplication.processEvents() - - def show_message(msg): - progress.setLabelText(f"Waiting for authorisation...\n\n{msg}") - QApplication.processEvents() - - success = auth_service.authorize_oauth(show_browser_message_callback=show_message) - progress.close() - QApplication.processEvents() - - self._update_oauth_status() - - if success: - _, _, username = auth_service.get_auth_status() - msg = "OAuth authorisation successful!" - if username: - msg += f"\n\nAuthorised as: {username}" - MessageService.information(self, "Success", msg, safety_level="low") - else: - MessageService.warning(self, "Failed", "OAuth authorisation failed or was cancelled.", safety_level="low") - - def _get_proton_10_path(self): - """Get Proton 10 path if available, fallback to auto""" - try: - from jackify.backend.handlers.wine_utils import WineUtils - available_protons = WineUtils.scan_valve_proton_versions() - - # Look for Proton 10.x - for proton in available_protons: - if proton['version'].startswith('10.'): - return proton['path'] - - # Fallback to auto if no Proton 10 found - return 'auto' - except: - return 'auto' - - def _populate_install_proton_dropdown(self): - """Populate Install Proton dropdown (Experimental/GE-Proton 10+ only for fast texture processing)""" - try: - from jackify.backend.handlers.wine_utils import WineUtils - - # Get all available Proton versions - available_protons = WineUtils.scan_all_proton_versions() - - # Check if any Proton versions were found - has_proton = len(available_protons) > 0 - - # Add "Auto" or "No Proton" option first based on detection - if has_proton: - self.install_proton_dropdown.addItem("Auto (Recommended)", "auto") - else: - self.install_proton_dropdown.addItem("No Proton Versions Detected", "none") - - # Filter to only known-compatible Protons for component installation - # Third-party builds (CachyOS, etc.) may have compatibility issues with Windows installers - fast_protons = [] - slow_protons = [] - - for proton in available_protons: - proton_name = proton.get('name', 'Unknown Proton') - proton_type = proton.get('type', 'Unknown') - - # Only include known-compatible Proton types for Install Proton - # Exclude third-party builds that may have component installation issues - if proton_type not in ('GE-Proton', 'Valve-Proton'): - # Skip third-party Protons (CachyOS, etc.) - they may not work reliably for component installation - logger.debug(f"Skipping {proton_name} ({proton_type}) from Install Proton dropdown - third-party builds may have compatibility issues") - continue - - # Determine if this Proton is explicitly slow for texture processing - slow_warning = False - - if proton_type == 'GE-Proton': - # Older GE (< 10) are known to be slower for heavy texture processing. - major_version = proton.get('major_version') - # Check if we have a valid major_version and it's < 10 - if major_version is not None and isinstance(major_version, int) and major_version < 10: - slow_warning = True - # Also check name pattern as fallback (e.g., "GE-Proton9-27") - elif 'GE-Proton9' in proton_name or 'GE-Proton8' in proton_name: - slow_warning = True - display_name = f"{proton_name} (GE)" - elif proton_type == 'Valve-Proton': - # Valve Proton 9.x is slower for BC7/BC6H workloads; newer Valve Proton is fine. - display_name = proton_name - if proton_name.startswith("Proton 9") or "9.0" in proton_name: - slow_warning = True - - # Add slow label if needed - if slow_warning: - display_name = f"{display_name} (Slow texture processing)" - slow_protons.append((display_name, str(proton['path']))) - else: - # Everything else (fast) goes above the separator - fast_protons.append((display_name, str(proton['path']))) - - # Add fast Protons first - for display_name, path in fast_protons: - self.install_proton_dropdown.addItem(display_name, path) - - # Add separator and slow Protons with warnings - if slow_protons: - self.install_proton_dropdown.insertSeparator(self.install_proton_dropdown.count()) - for display_name, path in slow_protons: - self.install_proton_dropdown.addItem(display_name, path) - - # Load saved preference - saved_proton = self.config_handler.get('proton_path', self._get_proton_10_path()) - self._set_dropdown_selection(self.install_proton_dropdown, saved_proton) - - except Exception as e: - logger.error(f"Failed to populate install Proton dropdown: {e}") - self.install_proton_dropdown.addItem("Auto (Recommended)", "auto") - - def _populate_game_proton_dropdown(self): - """Populate Game Proton dropdown (any Proton 9+ for game compatibility)""" - try: - from jackify.backend.handlers.wine_utils import WineUtils - - # Get all available Proton versions - available_protons = WineUtils.scan_all_proton_versions() - - # Add "Same as Install" option first - self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install") - - # Add all Proton 9+ versions - for proton in available_protons: - proton_name = proton.get('name', 'Unknown Proton') - proton_type = proton.get('type', 'Unknown') - - # Add type indicator for clarity - if proton_type == 'GE-Proton': - display_name = f"{proton_name} (GE)" - else: - display_name = proton_name - - self.game_proton_dropdown.addItem(display_name, str(proton['path'])) - - # Load saved preference - saved_game_proton = self.config_handler.get('game_proton_path', 'same_as_install') - self._set_dropdown_selection(self.game_proton_dropdown, saved_game_proton) - - except Exception as e: - logger.error(f"Failed to populate game Proton dropdown: {e}") - self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install") - - def _set_dropdown_selection(self, dropdown, saved_value): - """Helper to set dropdown selection based on saved value""" - found_match = False - for i in range(dropdown.count()): - if dropdown.itemData(i) == saved_value: - dropdown.setCurrentIndex(i) - found_match = True - break - - # If no exact match and not auto/same_as_install, select first option - if not found_match and saved_value not in ["auto", "same_as_install"]: - dropdown.setCurrentIndex(0) - - def _refresh_install_proton_dropdown(self): - """Refresh Install Proton dropdown""" - current_selection = self.install_proton_dropdown.currentData() - self.install_proton_dropdown.clear() - self._populate_install_proton_dropdown() - self._set_dropdown_selection(self.install_proton_dropdown, current_selection) - - def _refresh_game_proton_dropdown(self): - """Refresh Game Proton dropdown""" - current_selection = self.game_proton_dropdown.currentData() - self.game_proton_dropdown.clear() - self._populate_game_proton_dropdown() - self._set_dropdown_selection(self.game_proton_dropdown, current_selection) - - def _save(self): - try: - # Validate values (only if resource_edits exist) - for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items(): - if max_tasks_spin.value() > 128: - self.error_label.setText(f"Invalid value for {k}: Max Tasks must be <= 128.") - return - if self.bandwidth_spin and self.bandwidth_spin.value() > 1000000: - self.error_label.setText("Bandwidth limit must be <= 1,000,000 KB/s.") - return - self.error_label.setText("") - - # Save resource settings - for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items(): - resource_data = self.resource_settings.get(k, {}) - resource_data['MaxTasks'] = max_tasks_spin.value() - self.resource_settings[k] = resource_data - - # Save bandwidth limit to Downloads resource MaxThroughput (only if bandwidth UI exists) - if self.bandwidth_spin: - if "Downloads" not in self.resource_settings: - self.resource_settings["Downloads"] = {"MaxTasks": 16} # Provide default MaxTasks - # Convert KB/s to bytes/s for storage (resource_settings.json expects bytes) - bandwidth_kb = self.bandwidth_spin.value() - bandwidth_bytes = bandwidth_kb * 1024 - self.resource_settings["Downloads"]["MaxThroughput"] = bandwidth_bytes - - # Save all resource settings (including bandwidth) in one operation - self._save_json(self.resource_settings_path, self.resource_settings) - - # Save debug mode to config - self.config_handler.set('debug_mode', self.debug_checkbox.isChecked()) - # OAuth disabled for v0.1.8 - no fallback setting needed - # Save API key - api_key = self.api_key_edit.text().strip() - self.config_handler.save_api_key(api_key) - # Save modlist base dirs - self.config_handler.set("modlist_install_base_dir", self.install_dir_edit.text().strip()) - self.config_handler.set("modlist_downloads_base_dir", self.download_dir_edit.text().strip()) - # Save jackify data directory (always store actual path, never None) - jackify_data_dir = self.jackify_data_dir_edit.text().strip() - self.config_handler.set("jackify_data_dir", jackify_data_dir) - - # Initialize with existing config values as fallback (prevents UnboundLocalError if auto-detection fails) - resolved_install_path = self.config_handler.get("proton_path", "") - resolved_install_version = self.config_handler.get("proton_version", "") - - # Save Install Proton selection - resolve "auto" to actual path - selected_install_proton_path = self.install_proton_dropdown.currentData() - if selected_install_proton_path == "none": - # No Proton detected - warn user but allow saving other settings - MessageService.warning( - self, - "No Compatible Proton Installed", - "Jackify requires Proton 9.0+, Proton Experimental, or GE-Proton 10+ to install modlists.\n\n" - "To install Proton:\n" - "1. Install any Windows game in Steam (Proton downloads automatically), OR\n" - "2. Install GE-Proton using ProtonPlus or ProtonUp-Qt, OR\n" - "3. Download GE-Proton manually from:\n" - " https://github.com/GloriousEggroll/proton-ge-custom/releases\n\n" - "Your other settings will be saved, but modlist installation may not work without Proton.", - safety_level="medium" - ) - logger.warning("No Proton detected - user warned, allowing save to proceed for other settings") - # Don't modify Proton config, but continue to save other settings - elif selected_install_proton_path == "auto": - # Resolve "auto" to actual best Proton path using unified detection - try: - from jackify.backend.handlers.wine_utils import WineUtils - best_proton = WineUtils.select_best_proton() - - if best_proton: - resolved_install_path = str(best_proton['path']) - resolved_install_version = best_proton['name'] - self.config_handler.set("proton_path", resolved_install_path) - self.config_handler.set("proton_version", resolved_install_version) - else: - # No Proton found - don't write anything, let engine auto-detect - logger.warning("Auto Proton selection failed: No Proton versions found") - # Don't modify existing config values - except Exception as e: - # Exception during detection - log it and don't write anything - logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True) - # Don't modify existing config values - else: - # User selected specific Proton version - resolved_install_path = selected_install_proton_path - resolved_install_version = self.install_proton_dropdown.currentText() - self.config_handler.set("proton_path", resolved_install_path) - self.config_handler.set("proton_version", resolved_install_version) - - # Save Game Proton selection - selected_game_proton_path = self.game_proton_dropdown.currentData() - if selected_game_proton_path == "same_as_install": - # Use same as install proton - resolved_game_path = resolved_install_path - resolved_game_version = resolved_install_version - else: - # User selected specific game Proton version - resolved_game_path = selected_game_proton_path - resolved_game_version = self.game_proton_dropdown.currentText() - - self.config_handler.set("game_proton_path", resolved_game_path) - self.config_handler.set("game_proton_version", resolved_game_version) - - # Save component installation method preference - if self.winetricks_radio.isChecked(): - method = 'winetricks' - else: # protontricks_radio (alternative) - method = 'system_protontricks' - - old_method = self.config_handler.get('component_installation_method', 'winetricks') - method_changed = (old_method != method) - - self.config_handler.set("component_installation_method", method) - self.config_handler.set("use_winetricks_for_components", method == 'winetricks') - - # Force immediate save and verify - save_result = self.config_handler.save_config() - if not save_result: - self.logger.error("Failed to save Proton configuration") - else: - self.logger.info(f"Saved Proton config: install_path={resolved_install_path}, game_path={resolved_game_path}") - # Verify the save worked by reading it back - saved_path = self.config_handler.get("proton_path") - if saved_path != resolved_install_path: - self.logger.error(f"Config save verification failed: expected {resolved_install_path}, got {saved_path}") - else: - self.logger.debug("Config save verified successfully") - - # Refresh cached paths in GUI screens if Jackify directory changed - self._refresh_gui_paths() - - # 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") - if reply == QMessageBox.Yes: - import os, sys - # User requested restart - do it regardless of execution environment - self.accept() - - # Check if running from AppImage - if os.environ.get('APPIMAGE'): - # AppImage: restart the AppImage - os.execv(os.environ['APPIMAGE'], [os.environ['APPIMAGE']] + sys.argv[1:]) - else: - # Dev mode: restart the Python module - os.execv(sys.executable, [sys.executable, '-m', 'jackify.frontends.gui'] + sys.argv[1:]) - return - - # If we get here, no restart was needed - # Check protontricks if user just switched to it - if method_changed and method == 'system_protontricks': - main_window = self.parent() - if main_window and hasattr(main_window, 'protontricks_service'): - is_installed, installation_type, details = main_window.protontricks_service.detect_protontricks(use_cache=False) - if not is_installed: - from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog - dialog = ProtontricksErrorDialog(main_window.protontricks_service, main_window) - dialog.exec() - - MessageService.information(self, "Settings Saved", "Settings have been saved successfully.", safety_level="low") - self.accept() - - except Exception as e: - self.logger.error(f"Error saving settings: {e}") - MessageService.warning(self, "Save Error", f"Failed to save settings: {e}", safety_level="medium") - - def _refresh_gui_paths(self): - """Refresh cached paths in all GUI screens.""" - try: - # Get the main window through parent relationship - main_window = self.parent() - if not main_window or not hasattr(main_window, 'stacked_widget'): - return - - # Refresh paths in all screens that have the method - screens_to_refresh = [ - getattr(main_window, 'install_modlist_screen', None), - getattr(main_window, 'configure_new_modlist_screen', None), - getattr(main_window, 'configure_existing_modlist_screen', None), - ] - - for screen in screens_to_refresh: - if screen and hasattr(screen, 'refresh_paths'): - screen.refresh_paths() - - except Exception as e: - print(f"Warning: Could not refresh GUI paths: {e}") - - def _bold_label(self, text): - label = QLabel(text) - label.setStyleSheet("font-weight: bold; color: #fff;") - return label - - -class JackifyMainWindow(QMainWindow): +class JackifyMainWindow( + MainWindowGeometryMixin, + MainWindowBackendMixin, + MainWindowUIMixin, + MainWindowStartupMixin, + MainWindowDialogsMixin, + QMainWindow, +): """Main window for Jackify GUI application""" def __init__(self, dev_mode=False): @@ -1103,788 +195,11 @@ class JackifyMainWindow(QMainWindow): # Start background preload of gallery cache for instant gallery opening self._start_gallery_cache_preload() - # DISABLED: Window geometry saving causes issues with expanded state being memorized - # QApplication.instance().aboutToQuit.connect(self._save_geometry_on_quit) - # self.resizeEvent = self._on_resize_event_geometry - - def _apply_standard_window_flags(self): - window_flags = self.windowFlags() - window_flags |= ( - Qt.Window | - Qt.WindowTitleHint | - Qt.WindowSystemMenuHint | - Qt.WindowMinimizeButtonHint | - Qt.WindowMaximizeButtonHint | - Qt.WindowCloseButtonHint - ) - window_flags &= ~Qt.CustomizeWindowHint - self.setWindowFlags(window_flags) - - def _restore_geometry(self): - """Restore window geometry from QSettings (standard Qt approach)""" - # DISABLED: Don't restore saved geometry to avoid expanded state issues - # Always start with fresh calculated size - width, height = self._calculate_initial_window_size() - # Ensure we use compact height, not expanded - height = min(height, self._compact_height) - self.resize(width, height) - self._center_on_screen(width, height) - - def _save_geometry_on_quit(self): - """Save window geometry on application quit (only if in compact mode)""" - # Only save if window is in compact mode (not expanded with "Show Details") - # Also ensure we don't save expanded geometry - always start collapsed - if self._is_compact_mode(): - self._save_geometry() - else: - # If Show Details is enabled, clear saved geometry so we start collapsed next time - from PySide6.QtCore import QSettings - settings = QSettings("Jackify", "Jackify") - settings.remove("windowGeometry") - - def _is_compact_mode(self) -> bool: - """Check if window is in compact mode (not expanded with Show Details)""" - # Check if any child screen has "Show Details" checked - try: - if hasattr(self, 'install_modlist_screen'): - if hasattr(self.install_modlist_screen, 'show_details_checkbox'): - if self.install_modlist_screen.show_details_checkbox.isChecked(): - return False - if hasattr(self, 'install_ttw_screen'): - if hasattr(self.install_ttw_screen, 'show_details_checkbox'): - if self.install_ttw_screen.show_details_checkbox.isChecked(): - return False - if hasattr(self, 'configure_new_modlist_screen'): - if hasattr(self.configure_new_modlist_screen, 'show_details_checkbox'): - if self.configure_new_modlist_screen.show_details_checkbox.isChecked(): - return False - if hasattr(self, 'configure_existing_modlist_screen'): - if hasattr(self.configure_existing_modlist_screen, 'show_details_checkbox'): - if self.configure_existing_modlist_screen.show_details_checkbox.isChecked(): - return False - except Exception: - pass - return True - - def _save_geometry(self): - """Save window geometry to QSettings""" - from PySide6.QtCore import QSettings - settings = QSettings("Jackify", "Jackify") - settings.setValue("windowGeometry", self.saveGeometry()) + def resizeEvent(self, event): + self._on_resize_event_geometry(event) - def apply_responsive_minimum(self, min_width: int = 1100, min_height: int = 600): - """Apply minimum size that respects current screen bounds.""" - set_responsive_minimum(self, min_width=min_width, min_height=min_height, margin=self._window_margin) - - def _calculate_initial_window_size(self): - """Determine initial window size that fits within available screen space.""" - _, _, screen_width, screen_height = get_screen_geometry(self) - if not screen_width or not screen_height: - return (self._base_min_width, self._base_min_height) - - width = min( - max(self._base_min_width, int(screen_width * 0.85)), - screen_width - self._window_margin - ) - height = min( - max(self._base_min_height, int(screen_height * 0.75)), - screen_height - self._window_margin - ) - return (width, height) - - def _center_on_screen(self, width: int, height: int): - """Center window on the current screen.""" - _, _, screen_width, screen_height = get_screen_geometry(self) - if not screen_width or not screen_height: - return - x = max(0, (screen_width - width) // 2) - y = max(0, (screen_height - height) // 2) - self.move(x, y) - - def _ensure_within_available_geometry(self): - """Ensure restored geometry fits on the visible screen.""" - from PySide6.QtCore import QRect - _, _, screen_width, screen_height = get_screen_geometry(self) - if not screen_width or not screen_height: - return - current_geometry: QRect = self.geometry() - new_width = min(current_geometry.width(), screen_width - self._window_margin) - new_height = min(current_geometry.height(), screen_height - self._window_margin) - new_width = max(new_width, self.minimumWidth()) - new_height = max(new_height, self.minimumHeight()) - new_x = min(max(current_geometry.x(), 0), screen_width - new_width) - new_y = min(max(current_geometry.y(), 0), screen_height - new_height) - self.setGeometry(new_x, new_y, new_width, new_height) - - def _on_resize_event_geometry(self, event): - """Handle window resize - save geometry if in compact mode""" - super().resizeEvent(event) - # Save geometry with a delay to avoid excessive writes - # Only save if in compact mode - if self._is_compact_mode(): - from PySide6.QtCore import QTimer - if not hasattr(self, '_geometry_save_timer'): - self._geometry_save_timer = QTimer() - self._geometry_save_timer.setSingleShot(True) - self._geometry_save_timer.timeout.connect(self._save_geometry) - self._geometry_save_timer.stop() - self._geometry_save_timer.start(500) # Save after 500ms of no resizing - def showEvent(self, event): - super().showEvent(event) - if not self._initial_show_adjusted: - self._initial_show_adjusted = True - # On Steam Deck, keep maximized state; on other systems, set normal window state - if not (hasattr(self, 'system_info') and self.system_info.is_steamdeck): - self.setWindowState(Qt.WindowNoState) - self.apply_responsive_minimum(self._base_min_width, self._base_min_height) - self._ensure_within_available_geometry() - - def _initialize_backend(self): - """Initialize backend services for direct use (no subprocess)""" - # Detect Steam installation types once at startup - from ...shared.steam_utils import detect_steam_installation_types - is_flatpak, is_native = detect_steam_installation_types() - - # Determine system info with Steam detection - self.system_info = SystemInfo( - is_steamdeck=self._is_steamdeck(), - is_flatpak_steam=is_flatpak, - is_native_steam=is_native - ) - - # Apply resource limits for optimal operation - self._apply_resource_limits() - - # Initialize config handler - from jackify.backend.handlers.config_handler import ConfigHandler - self.config_handler = ConfigHandler() - - # Initialize backend services - self.backend_services = { - 'modlist_service': ModlistService(self.system_info) - } - - # Initialize GUI services - self.gui_services = {} - - # Initialize protontricks detection service - from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService - self.protontricks_service = ProtontricksDetectionService(steamdeck=self.system_info.is_steamdeck) - - # Initialize update service - from jackify.backend.services.update_service import UpdateService - self.update_service = UpdateService(__version__) - - debug_print(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}") - - def _is_steamdeck(self): - """Check if running on Steam Deck""" - try: - if os.path.exists("/etc/os-release"): - with open("/etc/os-release", "r") as f: - content = f.read() - if "steamdeck" in content: - return True - return False - except Exception: - return False - - def _apply_resource_limits(self): - """Apply recommended resource limits for optimal Jackify operation""" - try: - from jackify.backend.services.resource_manager import ResourceManager - - resource_manager = ResourceManager() - success = resource_manager.apply_recommended_limits() - - 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']}") - else: - print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})") - else: - # Log the issue but don't block startup - status = resource_manager.get_limit_status() - print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}") - - # Check if debug mode is enabled for additional info - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if config_handler.get('debug_mode', False): - instructions = resource_manager.get_manual_increase_instructions() - print(f"Manual increase instructions available for {instructions['distribution']}") - - except Exception as e: - # Don't block startup on resource management errors - print(f"Warning: Error applying resource limits: {e}") - - def _setup_ui(self, dev_mode=False): - """Set up the user interface""" - # Create stacked widget for screen navigation - self.stacked_widget = QStackedWidget() - - # Create screens using refactored codebase - 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 - - 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 - ) - self.configure_new_modlist_screen = ConfigureNewModlistScreen( - stacked_widget=self.stacked_widget, - main_menu_index=0 - ) - self.configure_existing_modlist_screen = ConfigureExistingModlistScreen( - stacked_widget=self.stacked_widget, - main_menu_index=0 - ) - 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 - ) - - # Let TTW screen request window resize for expand/collapse - try: - self.install_ttw_screen.resize_request.connect(self._on_child_resize_request) - except Exception: - pass - # Let Install Modlist screen request window resize for expand/collapse - try: - self.install_modlist_screen.resize_request.connect(self._on_child_resize_request) - except Exception: - pass - # Let Configure screens request window resize for expand/collapse - 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 - # Let Wabbajack Installer screen request window resize for expand/collapse - try: - self.wabbajack_installer_screen.resize_request.connect(self._on_child_resize_request) - except Exception: - pass - - # Add screens to stacked widget - self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu - self.stacked_widget.addWidget(self.feature_placeholder) # Index 1: Placeholder - self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 2: Modlist Tasks - self.stacked_widget.addWidget(self.additional_tasks_screen) # Index 3: Additional Tasks - self.stacked_widget.addWidget(self.install_modlist_screen) # Index 4: Install Modlist - self.stacked_widget.addWidget(self.install_ttw_screen) # Index 5: Install TTW - self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 6: Configure New - self.stacked_widget.addWidget(self.wabbajack_installer_screen) # Index 7: Wabbajack Installer - self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 8: Configure Existing - - # Add debug tracking for screen changes - self.stacked_widget.currentChanged.connect(self._debug_screen_change) - # Ensure fullscreen is maintained on Steam Deck when switching screens - self.stacked_widget.currentChanged.connect(self._maintain_fullscreen_on_deck) - - # --- Persistent Bottom Bar --- - bottom_bar = QWidget() - bottom_bar_layout = QHBoxLayout() - bottom_bar_layout.setContentsMargins(10, 2, 10, 2) - bottom_bar_layout.setSpacing(0) - bottom_bar.setLayout(bottom_bar_layout) - bottom_bar.setFixedHeight(32) - bottom_bar_style = "background-color: #181818; border-top: 1px solid #222;" - if DEBUG_BORDERS: - bottom_bar_style += " border: 2px solid lime;" - bottom_bar.setStyleSheet(bottom_bar_style) - - # Version label (left) - version_label = QLabel(f"Jackify v{__version__}") - version_label.setStyleSheet("color: #bbb; font-size: 13px;") - bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft) - - # Spacer - bottom_bar_layout.addStretch(1) - - # Ko-Fi support link (center) - kofi_link = QLabel('♥ Support on Ko-fi') - kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;") - kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction) - kofi_link.setOpenExternalLinks(False) - kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1")) - kofi_link.setToolTip("Support Jackify development") - bottom_bar_layout.addWidget(kofi_link, alignment=Qt.AlignCenter) - - # Spacer - bottom_bar_layout.addStretch(1) - - # Settings button (right side) - settings_btn = QLabel('Settings') - settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;") - settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction) - settings_btn.setOpenExternalLinks(False) - settings_btn.linkActivated.connect(self.open_settings_dialog) - bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight) - - # About button (right side) - about_btn = QLabel('About') - about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;") - about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction) - about_btn.setOpenExternalLinks(False) - about_btn.linkActivated.connect(self.open_about_dialog) - bottom_bar_layout.addWidget(about_btn, alignment=Qt.AlignRight) - - # --- Main Layout --- - central_widget = QWidget() - main_layout = QVBoxLayout() - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - # Don't use stretch - let screens size to their content - main_layout.addWidget(self.stacked_widget) # Screen sizes to content - main_layout.addWidget(bottom_bar) # Bottom bar stays at bottom - # Set stacked widget to not expand unnecessarily - self.stacked_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - central_widget.setLayout(main_layout) - self.setCentralWidget(central_widget) - - # Start with main menu - self.stacked_widget.setCurrentIndex(0) - - # Check for protontricks after UI is set up - self._check_protontricks_on_startup() - - def _maintain_fullscreen_on_deck(self, index): - """Maintain maximized state on Steam Deck when switching screens.""" - if hasattr(self, 'system_info') and self.system_info.is_steamdeck: - # Ensure window stays maximized on Steam Deck - if not self.isMaximized(): - self.showMaximized() - - def _debug_screen_change(self, index): - """Handle screen changes - debug logging and state reset""" - # Reset screen state when switching to workflow screens - widget = self.stacked_widget.widget(index) - if widget and hasattr(widget, 'reset_screen_to_defaults'): - widget.reset_screen_to_defaults() - - # Only show debug info if debug mode is enabled - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - if not config_handler.get('debug_mode', False): - return - - screen_names = { - 0: "Main Menu", - 1: "Feature Placeholder", - 2: "Modlist Tasks Menu", - 3: "Additional Tasks Menu", - 4: "Install Modlist Screen", - 5: "Install TTW Screen", - 6: "Configure New Modlist", - 7: "Wabbajack Installer", - 8: "Configure Existing Modlist", - } - screen_name = screen_names.get(index, f"Unknown Screen (Index {index})") - widget = self.stacked_widget.widget(index) - widget_class = widget.__class__.__name__ if widget else "None" - # Only print screen change debug to stderr to avoid workflow log pollution - import sys - print(f"[DEBUG] Screen changed to Index {index}: {screen_name} (Widget: {widget_class})", file=sys.stderr) - - # Additional debug for the install modlist screen - if index == 4: - print(f" 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) - if hasattr(widget, 'windowTitle'): - print(f" - Window title: {widget.windowTitle()}", file=sys.stderr) - 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) - - def _start_gallery_cache_preload(self): - """Start background preloading of modlist metadata for instant gallery opening""" - from PySide6.QtCore import QThread, Signal - - # Create background thread to preload gallery cache - class GalleryCachePreloadThread(QThread): - finished_signal = Signal(bool, str) - - def run(self): - try: - from jackify.backend.services.modlist_gallery_service import ModlistGalleryService - service = ModlistGalleryService() - - # Fetch with search index to build cache (invisible background operation) - metadata = service.fetch_modlist_metadata( - include_validation=False, # Skip validation for speed - include_search_index=True, # Include mods for search - sort_by="title", - force_refresh=False # Use cache if valid - ) - - 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)") - else: - debug_print("Gallery cache updated") - else: - debug_print("Failed to load gallery cache") - - except Exception as e: - debug_print(f"Gallery cache preload error: {str(e)}") - - # Start thread (non-blocking, runs in background) - self._gallery_cache_preload_thread = GalleryCachePreloadThread() - self._gallery_cache_preload_thread.start() - - debug_print("Started background gallery cache preload") - - def _check_protontricks_on_startup(self): - """Check for protontricks installation on startup""" - try: - # Only check for protontricks if user has selected it in settings - method = self.config_handler.get('component_installation_method', 'winetricks') - if method != 'system_protontricks': - debug_print(f"Skipping protontricks check (current method: {method}).") - return - - is_installed, installation_type, details = self.protontricks_service.detect_protontricks() - - if not is_installed: - print(f"Protontricks not found: {details}") - # Show error dialog - from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog - dialog = ProtontricksErrorDialog(self.protontricks_service, self) - result = dialog.exec() - - if result == QDialog.Rejected: - # User chose to exit - print("User chose to exit due to missing protontricks") - sys.exit(1) - else: - debug_print(f"Protontricks detected: {details}") - - except Exception as e: - print(f"Error checking protontricks: {e}") - # Continue anyway - don't block startup on detection errors - - def _check_for_updates_on_startup(self): - """Check for updates on startup - non-blocking background check""" - try: - debug_print("Checking for updates on startup...") - - # Run update check in background thread to avoid blocking GUI startup - class UpdateCheckThread(QThread): - update_available = Signal(object) # Signal to pass update_info to main thread - - def __init__(self, update_service): - super().__init__() - self.update_service = update_service - - def run(self): - update_info = self.update_service.check_for_updates() - if update_info: - self.update_available.emit(update_info) - - def on_update_available(update_info): - """Handle update check result in main thread""" - debug_print(f"Update available: v{update_info.version}") - - # Show update dialog after a short delay to ensure GUI is fully loaded - def show_update_dialog(): - from .dialogs.update_dialog import UpdateDialog - dialog = UpdateDialog(update_info, self.update_service, self) - dialog.exec() - - QTimer.singleShot(1000, show_update_dialog) - - # Start background thread - self._update_thread = UpdateCheckThread(self.update_service) - 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}") - # Continue anyway - don't block startup on update check errors - - def cleanup_processes(self): - """Clean up any running processes before closing""" - try: - # Clean up background threads first - 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 - - 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 - - # Clean up GUI services - for service in self.gui_services.values(): - if hasattr(service, 'cleanup'): - service.cleanup() - - # Clean up screen processes - screens = [ - self.modlist_tasks_screen, self.install_modlist_screen, - self.configure_new_modlist_screen, self.configure_existing_modlist_screen - ] - for screen in screens: - if hasattr(screen, 'cleanup_processes'): - screen.cleanup_processes() - elif hasattr(screen, 'cleanup'): - screen.cleanup() - - # Final safety net: kill any remaining jackify-engine processes - try: - import subprocess - subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True) - except Exception: - pass # pkill might fail if no processes found, which is fine - - except Exception as e: - print(f"Error during cleanup: {e}") - - def closeEvent(self, event): - """Handle window close event""" - self.cleanup_processes() - event.accept() - - def open_settings_dialog(self): - """Open settings dialog, preventing duplicate instances""" - try: - # Check if dialog already exists and is visible - if self._settings_dialog is not None: - try: - if self._settings_dialog.isVisible(): - # Dialog is already open - raise it to front - self._settings_dialog.raise_() - self._settings_dialog.activateWindow() - return - else: - # Dialog exists but is closed - clean up reference - self._settings_dialog = None - except RuntimeError: - # Dialog was deleted - clean up reference - self._settings_dialog = None - - # Create new dialog - dlg = SettingsDialog(self) - self._settings_dialog = dlg - - # Clean up reference when dialog is closed - def on_dialog_finished(): - self._settings_dialog = None - - dlg.finished.connect(on_dialog_finished) - dlg.exec() - except Exception as e: - print(f"[ERROR] Exception in open_settings_dialog: {e}") - import traceback - traceback.print_exc() - self._settings_dialog = None - - def open_about_dialog(self): - """Open about dialog, preventing duplicate instances""" - try: - from jackify.frontends.gui.dialogs.about_dialog import AboutDialog - - # Check if dialog already exists and is visible - if self._about_dialog is not None: - try: - if self._about_dialog.isVisible(): - # Dialog is already open - raise it to front - self._about_dialog.raise_() - self._about_dialog.activateWindow() - return - else: - # Dialog exists but is closed - clean up reference - self._about_dialog = None - except RuntimeError: - # Dialog was deleted - clean up reference - self._about_dialog = None - - # Create new dialog - dlg = AboutDialog(self.system_info, self) - self._about_dialog = dlg - - # Clean up reference when dialog is closed - def on_dialog_finished(): - self._about_dialog = None - - dlg.finished.connect(on_dialog_finished) - dlg.exec() - except Exception as e: - print(f"[ERROR] Exception in open_about_dialog: {e}") - import traceback - traceback.print_exc() - self._about_dialog = None - - def _open_url(self, url: str): - """Open URL with clean environment to avoid AppImage library conflicts.""" - import subprocess - import os - - env = os.environ.copy() - - # Remove AppImage-specific environment variables - appimage_vars = [ - 'LD_LIBRARY_PATH', - 'PYTHONPATH', - 'PYTHONHOME', - 'QT_PLUGIN_PATH', - 'QML2_IMPORT_PATH', - ] - - if 'APPIMAGE' in env or 'APPDIR' in env: - for var in appimage_vars: - if var in env: - del env[var] - - subprocess.Popen( - ['xdg-open', url], - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True - ) - - def _on_child_resize_request(self, mode: str): - """ - Handle child screen resize requests (expand/collapse console). - Allow window expansion/collapse for Show Details toggle, but keep fixed sizing for navigation. - """ - debug_print(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}") - # On Steam Deck we keep the stable, full-size layout and ignore child resize - try: - if self.system_info and self.system_info.is_steamdeck: - debug_print("DEBUG: Steam Deck detected, ignoring resize request") - # Hide the checkbox if present (Deck uses full layout) - try: - if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox: - self.install_ttw_screen.show_details_checkbox.setVisible(False) - except Exception: - pass - return - except Exception: - pass - - # Allow expansion/collapse for Show Details toggle - # This is different from navigation resizing - we want this to work - if mode == "expand": - # Expand window to accommodate console - current_size = self.size() - current_pos = self.pos() - # Calculate target height and clamp to available space - target_height = self._compact_height + self._details_extra_height - self._resize_height(target_height) - elif mode == "collapse": - # Collapse window back to compact size - self._resize_height(self._compact_height) - else: - # Unknown mode - just ensure minimums - self.apply_responsive_minimum(self._base_min_width, self._base_min_height) - - def _resize_height(self, requested_height: int): - """Resize the window to a given height while keeping it on-screen.""" - target_height = self._clamp_height_to_screen(requested_height) - self.apply_responsive_minimum(self._base_min_width, self._base_min_height) - if ENABLE_WINDOW_HEIGHT_ANIMATION: - self._animate_height(target_height) - return - - geom = self.geometry() - new_y = geom.y() - _, _, _, screen_height = get_screen_geometry(self) - max_bottom = max(self._base_min_height, screen_height - self._window_margin) - if new_y + target_height > max_bottom: - new_y = max(0, max_bottom - target_height) - self._programmatic_resize = True - self.setGeometry(geom.x(), new_y, geom.width(), target_height) - QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False)) - - def _clamp_height_to_screen(self, requested_height: int) -> int: - """Clamp requested height to available screen space.""" - _, _, _, screen_height = get_screen_geometry(self) - available = max(self._base_min_height, screen_height - self._window_margin) - return max(self._base_min_height, min(requested_height, available)) - - def _animate_height(self, target_height: int, duration_ms: int = 180): - """Smoothly animate the window height to target_height. - - Kept local imports to minimize global impact and avoid touching module headers. - """ - try: - from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QRect - except Exception: - # Fallback to immediate resize if animation types are unavailable - 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()}") - from PySide6.QtCore import QTimer - QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False)) - return - - # Build end rect with same x/y/width and target height - start_rect = self.geometry() - end_rect = QRect(start_rect.x(), start_rect.y(), start_rect.width(), self._clamp_height_to_screen(target_height)) - - # Check if expanded window would go off-screen and adjust position if needed - screen = QApplication.primaryScreen() - if screen: - screen_geometry = screen.availableGeometry() - # Calculate where bottom would be with target_height - would_be_bottom = start_rect.y() + target_height - if would_be_bottom > screen_geometry.bottom(): - # Window would go off bottom - move it up - new_y = screen_geometry.bottom() - target_height - if new_y < screen_geometry.top(): - new_y = screen_geometry.top() - end_rect.moveTop(new_y) - - # Hold reference to avoid GC stopping the animation - self._resize_anim = QPropertyAnimation(self, b"geometry") - self._resize_anim.setDuration(duration_ms) - self._resize_anim.setEasingCurve(QEasingCurve.OutCubic) - self._resize_anim.setStartValue(start_rect) - self._resize_anim.setEndValue(end_rect) - # Mark as programmatic during animation - self._programmatic_resize = True - self._resize_anim.finished.connect(lambda: setattr(self, '_programmatic_resize', False)) - self._resize_anim.start() + self._geometry_show_event(event) @@ -1910,7 +225,7 @@ def resource_path(relative_path): def main(): """Main entry point for the GUI application""" # CRITICAL: Enable faulthandler for segfault debugging - # This will print Python stack traces on segfault + # Print Python stack traces on segfault import faulthandler import signal # Enable faulthandler to both stderr and file @@ -1954,7 +269,7 @@ def main(): root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True, debug_mode=debug_mode) # Empty name = root logger # CRITICAL: Set root logger level BEFORE any child loggers are used - # This ensures DEBUG messages from child loggers propagate correctly + # DEBUG messages from child loggers must propagate if debug_mode: root_logger.setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG) # Also set on root via getLogger() for compatibility diff --git a/jackify/frontends/gui/mixins/main_window_backend.py b/jackify/frontends/gui/mixins/main_window_backend.py new file mode 100644 index 0000000..0e85421 --- /dev/null +++ b/jackify/frontends/gui/mixins/main_window_backend.py @@ -0,0 +1,73 @@ +""" +Main window backend initialization mixin. +System info, config, modlist service, protontricks service, resource limits. +""" + +import os + +from jackify.backend.models.configuration import SystemInfo +from jackify.backend.services.modlist_service import ModlistService + + +def _debug_print(message): + from jackify.backend.handlers.config_handler import ConfigHandler + ch = ConfigHandler() + if ch.get('debug_mode', False): + print(message) + + +class MainWindowBackendMixin: + """Mixin for backend service initialization.""" + + def _initialize_backend(self): + from jackify.shared.steam_utils import detect_steam_installation_types + is_flatpak, is_native = detect_steam_installation_types() + self.system_info = SystemInfo( + is_steamdeck=self._is_steamdeck(), + is_flatpak_steam=is_flatpak, + is_native_steam=is_native + ) + self._apply_resource_limits() + from jackify.backend.handlers.config_handler import ConfigHandler + self.config_handler = ConfigHandler() + self.backend_services = {'modlist_service': ModlistService(self.system_info)} + self.gui_services = {} + from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService + self.protontricks_service = ProtontricksDetectionService(steamdeck=self.system_info.is_steamdeck) + 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}") + + def _is_steamdeck(self): + try: + if os.path.exists("/etc/os-release"): + with open("/etc/os-release", "r") as f: + content = f.read() + if "steamdeck" in content: + return True + return False + except Exception: + return False + + def _apply_resource_limits(self): + try: + from jackify.backend.services.resource_manager import ResourceManager + resource_manager = ResourceManager() + success = resource_manager.apply_recommended_limits() + 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']}") + else: + print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})") + else: + status = resource_manager.get_limit_status() + print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}") + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if config_handler.get('debug_mode', False): + instructions = resource_manager.get_manual_increase_instructions() + print(f"Manual increase instructions available for {instructions['distribution']}") + except Exception as e: + print(f"Warning: Error applying resource limits: {e}") diff --git a/jackify/frontends/gui/mixins/main_window_dialogs.py b/jackify/frontends/gui/mixins/main_window_dialogs.py new file mode 100644 index 0000000..818865c --- /dev/null +++ b/jackify/frontends/gui/mixins/main_window_dialogs.py @@ -0,0 +1,117 @@ +""" +Main window dialogs and cleanup mixin. +Settings, About, open URL, cleanup_processes, closeEvent. +""" + +import os +import subprocess + +from jackify.frontends.gui.dialogs.settings_dialog import SettingsDialog + + +class MainWindowDialogsMixin: + """Mixin for settings/about dialogs, open URL, and cleanup.""" + + def open_settings_dialog(self): + try: + if self._settings_dialog is not None: + try: + if self._settings_dialog.isVisible(): + self._settings_dialog.raise_() + self._settings_dialog.activateWindow() + return + else: + self._settings_dialog = None + except RuntimeError: + self._settings_dialog = None + dlg = SettingsDialog(self) + self._settings_dialog = dlg + + def on_dialog_finished(): + self._settings_dialog = None + dlg.finished.connect(on_dialog_finished) + dlg.exec() + except Exception as e: + print(f"[ERROR] Exception in open_settings_dialog: {e}") + import traceback + traceback.print_exc() + self._settings_dialog = None + + def open_about_dialog(self): + try: + from jackify.frontends.gui.dialogs.about_dialog import AboutDialog + if self._about_dialog is not None: + try: + if self._about_dialog.isVisible(): + self._about_dialog.raise_() + self._about_dialog.activateWindow() + return + else: + self._about_dialog = None + except RuntimeError: + self._about_dialog = None + dlg = AboutDialog(self.system_info, self) + self._about_dialog = dlg + + def on_dialog_finished(): + self._about_dialog = None + dlg.finished.connect(on_dialog_finished) + dlg.exec() + except Exception as e: + print(f"[ERROR] Exception in open_about_dialog: {e}") + import traceback + traceback.print_exc() + self._about_dialog = None + + def _open_url(self, url: str): + env = os.environ.copy() + appimage_vars = [ + 'LD_LIBRARY_PATH', 'PYTHONPATH', 'PYTHONHOME', + 'QT_PLUGIN_PATH', 'QML2_IMPORT_PATH', + ] + if 'APPIMAGE' in env or 'APPDIR' in env: + for var in appimage_vars: + env.pop(var, None) + subprocess.Popen( + ['xdg-open', url], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + + 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 + 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 + 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, + ] + for screen in screens: + if hasattr(screen, 'cleanup_processes'): + screen.cleanup_processes() + elif hasattr(screen, 'cleanup'): + screen.cleanup() + try: + subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True) + except Exception: + pass + except Exception as e: + print(f"Error during cleanup: {e}") + + def closeEvent(self, event): + self._save_geometry_on_quit() + self.cleanup_processes() + event.accept() diff --git a/jackify/frontends/gui/mixins/main_window_geometry.py b/jackify/frontends/gui/mixins/main_window_geometry.py new file mode 100644 index 0000000..097ee22 --- /dev/null +++ b/jackify/frontends/gui/mixins/main_window_geometry.py @@ -0,0 +1,207 @@ +""" +Main window geometry and resize mixin. +Window flags, save/restore geometry, compact mode, responsive minimum, resize handling. +""" + +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 + +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.""" + + def _apply_standard_window_flags(self): + window_flags = self.windowFlags() + window_flags |= ( + Qt.Window + | Qt.WindowTitleHint + | Qt.WindowSystemMenuHint + | Qt.WindowMinimizeButtonHint + | Qt.WindowMaximizeButtonHint + | Qt.WindowCloseButtonHint + ) + window_flags &= ~Qt.CustomizeWindowHint + self.setWindowFlags(window_flags) + + def _restore_geometry(self): + width, height = self._calculate_initial_window_size() + height = min(height, self._compact_height) + self.resize(width, height) + self._center_on_screen(width, height) + + def _save_geometry_on_quit(self): + if self._is_compact_mode(): + self._save_geometry() + else: + from PySide6.QtCore import QSettings + settings = QSettings("Jackify", "Jackify") + settings.remove("windowGeometry") + + def _is_compact_mode(self) -> bool: + try: + if hasattr(self, 'install_modlist_screen') and hasattr(self.install_modlist_screen, 'show_details_checkbox'): + if self.install_modlist_screen.show_details_checkbox.isChecked(): + return False + if hasattr(self, 'install_ttw_screen') and hasattr(self.install_ttw_screen, 'show_details_checkbox'): + if self.install_ttw_screen.show_details_checkbox.isChecked(): + return False + if hasattr(self, 'configure_new_modlist_screen') and hasattr(self.configure_new_modlist_screen, 'show_details_checkbox'): + if self.configure_new_modlist_screen.show_details_checkbox.isChecked(): + return False + if hasattr(self, 'configure_existing_modlist_screen') and hasattr(self.configure_existing_modlist_screen, 'show_details_checkbox'): + if self.configure_existing_modlist_screen.show_details_checkbox.isChecked(): + return False + except Exception: + pass + return True + + def _save_geometry(self): + from PySide6.QtCore import QSettings + settings = QSettings("Jackify", "Jackify") + settings.setValue("windowGeometry", self.saveGeometry()) + + def apply_responsive_minimum(self, min_width: int = 1100, min_height: int = 600): + set_responsive_minimum(self, min_width=min_width, min_height=min_height, margin=self._window_margin) + + def _calculate_initial_window_size(self): + _, _, screen_width, screen_height = get_screen_geometry(self) + if not screen_width or not screen_height: + return (self._base_min_width, self._base_min_height) + width = min( + max(self._base_min_width, int(screen_width * 0.85)), + screen_width - self._window_margin + ) + height = min( + max(self._base_min_height, int(screen_height * 0.75)), + screen_height - self._window_margin + ) + return (width, height) + + def _center_on_screen(self, width: int, height: int): + _, _, screen_width, screen_height = get_screen_geometry(self) + if not screen_width or not screen_height: + return + x = max(0, (screen_width - width) // 2) + y = max(0, (screen_height - height) // 2) + self.move(x, y) + + def _ensure_within_available_geometry(self): + from PySide6.QtCore import QRect + _, _, screen_width, screen_height = get_screen_geometry(self) + if not screen_width or not screen_height: + return + current_geometry = self.geometry() + new_width = min(current_geometry.width(), screen_width - self._window_margin) + new_height = min(current_geometry.height(), screen_height - self._window_margin) + new_width = max(new_width, self.minimumWidth()) + new_height = max(new_height, self.minimumHeight()) + new_x = min(max(current_geometry.x(), 0), screen_width - new_width) + new_y = min(max(current_geometry.y(), 0), screen_height - new_height) + self.setGeometry(new_x, new_y, new_width, new_height) + + def _on_resize_event_geometry(self, event): + super().resizeEvent(event) + if self._is_compact_mode(): + if not hasattr(self, '_geometry_save_timer'): + self._geometry_save_timer = QTimer() + self._geometry_save_timer.setSingleShot(True) + self._geometry_save_timer.timeout.connect(self._save_geometry) + self._geometry_save_timer.stop() + self._geometry_save_timer.start(500) + + def _geometry_show_event(self, event): + super().showEvent(event) + if not self._initial_show_adjusted: + self._initial_show_adjusted = True + if not (hasattr(self, 'system_info') and self.system_info.is_steamdeck): + self.setWindowState(Qt.WindowNoState) + self.apply_responsive_minimum(self._base_min_width, self._base_min_height) + self._ensure_within_available_geometry() + + def _maintain_fullscreen_on_deck(self, index): + if hasattr(self, 'system_info') and self.system_info.is_steamdeck: + if not self.isMaximized(): + 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()}") + try: + if self.system_info and self.system_info.is_steamdeck: + _debug_print("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) + except Exception: + pass + return + except Exception: + pass + if mode == "expand": + target_height = self._compact_height + self._details_extra_height + self._resize_height(target_height) + elif mode == "collapse" or mode == "compact": + self._resize_height(self._compact_height) + else: + self.apply_responsive_minimum(self._base_min_width, self._base_min_height) + + def _resize_height(self, requested_height: int): + target_height = self._clamp_height_to_screen(requested_height) + self.apply_responsive_minimum(self._base_min_width, self._base_min_height) + if ENABLE_WINDOW_HEIGHT_ANIMATION: + self._animate_height(target_height) + return + geom = self.geometry() + new_y = geom.y() + _, _, _, screen_height = get_screen_geometry(self) + max_bottom = max(self._base_min_height, screen_height - self._window_margin) + if new_y + target_height > max_bottom: + new_y = max(0, max_bottom - target_height) + self._programmatic_resize = True + self.setGeometry(geom.x(), new_y, geom.width(), target_height) + QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False)) + + def _clamp_height_to_screen(self, requested_height: int) -> int: + _, _, _, screen_height = get_screen_geometry(self) + available = max(self._base_min_height, screen_height - self._window_margin) + return max(self._base_min_height, min(requested_height, available)) + + def _animate_height(self, target_height: int, duration_ms: int = 180): + try: + from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QRect + except Exception: + 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()}") + QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False)) + return + start_rect = self.geometry() + end_rect = QRect(start_rect.x(), start_rect.y(), start_rect.width(), self._clamp_height_to_screen(target_height)) + screen = QApplication.primaryScreen() + if screen: + screen_geometry = screen.availableGeometry() + would_be_bottom = start_rect.y() + target_height + if would_be_bottom > screen_geometry.bottom(): + new_y = screen_geometry.bottom() - target_height + if new_y < screen_geometry.top(): + new_y = screen_geometry.top() + end_rect.moveTop(new_y) + self._resize_anim = QPropertyAnimation(self, b"geometry") + self._resize_anim.setDuration(duration_ms) + self._resize_anim.setEasingCurve(QEasingCurve.OutCubic) + self._resize_anim.setStartValue(start_rect) + self._resize_anim.setEndValue(end_rect) + self._programmatic_resize = True + self._resize_anim.finished.connect(lambda: setattr(self, '_programmatic_resize', False)) + self._resize_anim.start() diff --git a/jackify/frontends/gui/mixins/main_window_startup.py b/jackify/frontends/gui/mixins/main_window_startup.py new file mode 100644 index 0000000..670e8ee --- /dev/null +++ b/jackify/frontends/gui/mixins/main_window_startup.py @@ -0,0 +1,102 @@ +""" +Main window startup and background tasks mixin. +Gallery cache preload, protontricks check, update check. +""" + +import sys + +from PySide6.QtCore import QThread, Signal, QTimer +from PySide6.QtWidgets import QDialog + + +def _debug_print(message): + from jackify.backend.handlers.config_handler import ConfigHandler + ch = ConfigHandler() + if ch.get('debug_mode', False): + print(message) + + +class MainWindowStartupMixin: + """Mixin for startup and background tasks.""" + + def _start_gallery_cache_preload(self): + from PySide6.QtCore import QThread, Signal + + class GalleryCachePreloadThread(QThread): + finished_signal = Signal(bool, str) + + def run(self): + try: + from jackify.backend.services.modlist_gallery_service import ModlistGalleryService + service = ModlistGalleryService() + metadata = service.fetch_modlist_metadata( + include_validation=False, + include_search_index=True, + sort_by="title", + force_refresh=False + ) + 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)") + else: + _debug_print("Gallery cache updated") + else: + _debug_print("Failed to load gallery cache") + except Exception as e: + _debug_print(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") + + 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}).") + return + is_installed, installation_type, details = self.protontricks_service.detect_protontricks() + if not is_installed: + print(f"Protontricks not found: {details}") + from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog + dialog = ProtontricksErrorDialog(self.protontricks_service, self) + result = dialog.exec() + if result == QDialog.Rejected: + print("User chose to exit due to missing protontricks") + sys.exit(1) + else: + _debug_print(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...") + + class UpdateCheckThread(QThread): + update_available = Signal(object) + + def __init__(self, update_service): + super().__init__() + self.update_service = update_service + + def run(self): + update_info = self.update_service.check_for_updates() + if update_info: + self.update_available.emit(update_info) + + def on_update_available(update_info): + _debug_print(f"Update available: v{update_info.version}") + + def show_update_dialog(): + from jackify.frontends.gui.dialogs.update_dialog import UpdateDialog + dialog = UpdateDialog(update_info, self.update_service, self) + dialog.exec() + QTimer.singleShot(1000, show_update_dialog) + + self._update_thread = UpdateCheckThread(self.update_service) + 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}") diff --git a/jackify/frontends/gui/mixins/main_window_ui.py b/jackify/frontends/gui/mixins/main_window_ui.py new file mode 100644 index 0000000..c324439 --- /dev/null +++ b/jackify/frontends/gui/mixins/main_window_ui.py @@ -0,0 +1,187 @@ +""" +Main window UI setup mixin. +Stacked widget, screens, bottom bar, screen change handling. +""" + +import sys + +from PySide6.QtWidgets import ( + QWidget, QLabel, QVBoxLayout, QHBoxLayout, + QStackedWidget, QSizePolicy, +) +from PySide6.QtCore import Qt + +from jackify import __version__ +from jackify.frontends.gui.shared_theme import DEBUG_BORDERS +from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder + + +def _debug_print(message): + from jackify.backend.handlers.config_handler import ConfigHandler + ch = ConfigHandler() + if ch.get('debug_mode', False): + print(message) + + +class MainWindowUIMixin: + """Mixin for main window UI: stacked widget, screens, bottom bar.""" + + def _setup_ui(self, dev_mode=False): + 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 + + 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 + ) + + 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 + + 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) + + self.stacked_widget.currentChanged.connect(self._debug_screen_change) + self.stacked_widget.currentChanged.connect(self._maintain_fullscreen_on_deck) + + bottom_bar = QWidget() + bottom_bar_layout = QHBoxLayout() + bottom_bar_layout.setContentsMargins(10, 2, 10, 2) + bottom_bar_layout.setSpacing(0) + bottom_bar.setLayout(bottom_bar_layout) + bottom_bar.setFixedHeight(32) + bottom_bar_style = "background-color: #181818; border-top: 1px solid #222;" + if DEBUG_BORDERS: + bottom_bar_style += " border: 2px solid lime;" + bottom_bar.setStyleSheet(bottom_bar_style) + + version_label = QLabel(f"Jackify v{__version__}") + version_label.setStyleSheet("color: #bbb; font-size: 13px;") + bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft) + bottom_bar_layout.addStretch(1) + kofi_link = QLabel('Support on Ko-fi') + kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;") + kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction) + kofi_link.setOpenExternalLinks(False) + kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1")) + kofi_link.setToolTip("Support Jackify development") + bottom_bar_layout.addWidget(kofi_link, alignment=Qt.AlignCenter) + bottom_bar_layout.addStretch(1) + settings_btn = QLabel('Settings') + settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;") + settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction) + settings_btn.setOpenExternalLinks(False) + settings_btn.linkActivated.connect(self.open_settings_dialog) + bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight) + about_btn = QLabel('About') + about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;") + about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction) + about_btn.setOpenExternalLinks(False) + about_btn.linkActivated.connect(self.open_about_dialog) + bottom_bar_layout.addWidget(about_btn, alignment=Qt.AlignRight) + + central_widget = QWidget() + main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(self.stacked_widget) + main_layout.addWidget(bottom_bar) + self.stacked_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + self.stacked_widget.setCurrentIndex(0) + self._check_protontricks_on_startup() + + def _debug_screen_change(self, index): + try: + idx = int(index) if index is not None else 0 + widget = self.stacked_widget.widget(idx) + except (OverflowError, TypeError, ValueError): + widget = self.stacked_widget.currentWidget() + idx = None + if widget and hasattr(widget, 'reset_screen_to_defaults'): + widget.reset_screen_to_defaults() + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + if not config_handler.get('debug_mode', False): + return + if idx is None: + return + try: + screen_names = { + 0: "Main Menu", + 1: "Feature Placeholder", + 2: "Modlist Tasks Menu", + 3: "Additional Tasks Menu", + 4: "Install Modlist Screen", + 5: "Install TTW Screen", + 6: "Configure New Modlist", + 7: "Wabbajack Installer", + 8: "Configure Existing Modlist", + } + 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) + 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) + if hasattr(widget, 'windowTitle'): + print(f" - Window title: {widget.windowTitle()}", file=sys.stderr) + 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) diff --git a/jackify/frontends/gui/screens/additional_tasks.py b/jackify/frontends/gui/screens/additional_tasks.py index d89596f..8b5ee61 100644 --- a/jackify/frontends/gui/screens/additional_tasks.py +++ b/jackify/frontends/gui/screens/additional_tasks.py @@ -36,7 +36,7 @@ class AdditionalTasksScreen(QWidget): def _setup_ui(self): """Set up the user interface following ModlistTasksScreen pattern""" layout = QVBoxLayout() - layout.setContentsMargins(30, 30, 30, 30) # Reduced from 40 + layout.setContentsMargins(30, 30, 30, 30) layout.setSpacing(12) # Match main menu spacing # Header section @@ -98,11 +98,11 @@ class AdditionalTasksScreen(QWidget): # Create grid layout for buttons (mirror ModlistTasksScreen pattern) button_grid = QGridLayout() - button_grid.setSpacing(12) # Reduced from 16 + button_grid.setSpacing(12) button_grid.setAlignment(Qt.AlignHCenter) button_width = 400 - button_height = 40 # Reduced from 50 + button_height = 40 for i, (label, action_id, description) in enumerate(MENU_ITEMS): # Create button @@ -130,7 +130,7 @@ class AdditionalTasksScreen(QWidget): # Description label desc_label = QLabel(description) desc_label.setAlignment(Qt.AlignHCenter) - desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px + desc_label.setStyleSheet("color: #999; font-size: 11px;") desc_label.setWordWrap(True) desc_label.setFixedWidth(button_width) diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py index 1dfd098..c5bfdab 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -23,6 +23,11 @@ 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 +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""" @@ -31,707 +36,74 @@ def debug_print(message): if config_handler.get('debug_mode', False): print(message) -class ConfigureExistingModlistScreen(QWidget): +class ConfigureExistingModlistScreen( + ScreenBackMixin, + ConfigureExistingModlistUIMixin, + ConfigureExistingModlistWorkflowMixin, + ConfigureExistingModlistShortcutsMixin, + ConfigureExistingModlistConsoleMixin, + QWidget, +): steam_restart_finished = Signal(bool, str) resize_request = Signal(str) - def __init__(self, stacked_widget=None, main_menu_index=0): - super().__init__() - debug_print("DEBUG: ConfigureExistingModlistScreen __init__ called") - self.stacked_widget = stacked_widget - self.main_menu_index = main_menu_index - self.debug = DEBUG_BORDERS - self.refresh_paths() - - # --- Detect Steam Deck --- - from jackify.backend.services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - steamdeck = platform_service.is_steamdeck - self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck) - - # Initialize services early - from jackify.backend.services.api_key_service import APIKeyService - from jackify.backend.services.resolution_service import ResolutionService - from jackify.backend.handlers.config_handler import ConfigHandler - self.api_key_service = APIKeyService() - self.resolution_service = ResolutionService() - self.config_handler = ConfigHandler() - - # --- Fetch shortcuts for ModOrganizer.exe - deferred to showEvent to avoid blocking init --- - # Initialize empty list, will be populated when screen is shown - self.mo2_shortcuts = [] - self._shortcuts_loaded = False - self._shortcut_loader = None # Thread for async shortcut loading - # Initialize progress reporting components - self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) - self.progress_indicator.set_status("Ready to configure", 0) - self.file_progress_list = FileProgressList() - - # Create "Show Details" checkbox - self.show_details_checkbox = QCheckBox("Show details") - self.show_details_checkbox.setChecked(False) # Start collapsed - self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") - self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) - - # --- UI Layout --- - main_overall_vbox = QVBoxLayout(self) - main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin - if self.debug: - self.setStyleSheet("border: 2px solid magenta;") - # --- Header (title, description) --- - header_layout = QVBoxLayout() - header_layout.setSpacing(1) # Reduce spacing between title and description - title = QLabel("Configure Existing Modlist") - title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") - title.setAlignment(Qt.AlignHCenter) - title.setMaximumHeight(30) # Force compact height - header_layout.addWidget(title) - desc = QLabel( - "This screen allows you to configure an existing modlist in Jackify. " - "Select your Steam shortcut for ModOrganizer.exe, set your resolution, and complete post-install configuration." - ) - desc.setWordWrap(True) - desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") - desc.setAlignment(Qt.AlignHCenter) - desc.setMaximumHeight(40) # Force compact height for description - header_layout.addWidget(desc) - header_widget = QWidget() - header_widget.setLayout(header_layout) - header_widget.setMaximumHeight(75) # Match other screens - if self.debug: - header_widget.setStyleSheet("border: 2px solid pink;") - header_widget.setToolTip("HEADER_SECTION") - main_overall_vbox.addWidget(header_widget) - # --- Upper section: shortcut selector (left) + process monitor (right) --- - upper_hbox = QHBoxLayout() - upper_hbox.setContentsMargins(0, 0, 0, 0) - upper_hbox.setSpacing(16) - user_config_vbox = QVBoxLayout() - user_config_vbox.setAlignment(Qt.AlignTop) - # --- [Options] header (moved here for alignment) --- - 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) - user_config_vbox.addWidget(options_header) - form_grid = QGridLayout() - form_grid.setHorizontalSpacing(12) - form_grid.setVerticalSpacing(6) # Reduced from 8 to 6 for better readability - form_grid.setContentsMargins(0, 0, 0, 0) - # --- Shortcut selector --- - shortcut_label = QLabel("Select Modlist:") - self.shortcut_combo = QComboBox() - self.shortcut_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.shortcut_combo.addItem("Please Select...") - self.shortcut_map = [] - for shortcut in self.mo2_shortcuts: - display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})" - self.shortcut_combo.addItem(display) - self.shortcut_map.append(shortcut) - - # Add refresh button next to dropdown - refresh_btn = QPushButton("↻") - refresh_btn.setToolTip("Refresh modlist list") - refresh_btn.setFixedSize(30, 30) - refresh_btn.clicked.connect(self.refresh_modlist_list) - - # Create horizontal layout for dropdown and refresh button - shortcut_hbox = QHBoxLayout() - shortcut_hbox.addWidget(self.shortcut_combo) - shortcut_hbox.addWidget(refresh_btn) - shortcut_hbox.setSpacing(4) - shortcut_hbox.setStretch(0, 1) # Make dropdown expand - - form_grid.addWidget(shortcut_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(shortcut_hbox, 0, 1) - # --- Info message under shortcut selector --- - info_label = QLabel("If you don't see your modlist entry in this list, please ensure you have added it to Steam as a non-steam game, set a proton version in properties, and have started the modlist Steam entry at least once. You can also click the refresh button (↻) to update the list.") - info_label.setWordWrap(True) - form_grid.addWidget(info_label, 1, 0, 1, 2) - # --- Resolution selector --- - resolution_label = QLabel("Resolution:") - self.resolution_combo = QComboBox() - self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.resolution_combo.addItem("Leave unchanged") - self.resolution_combo.addItems([ - "1280x720", - "1280x800 (Steam Deck)", - "1366x768", - "1440x900", - "1600x900", - "1600x1200", - "1680x1050", - "1920x1080", - "1920x1200", - "2048x1152", - "2560x1080", - "2560x1440", - "2560x1600", - "3440x1440", - "3840x1600", - "3840x2160", - "3840x2400", - "5120x1440", - "5120x2160", - "7680x4320" - ]) - form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addWidget(self.resolution_combo, 2, 1) - - # Load saved resolution if available - saved_resolution = self.resolution_service.get_saved_resolution() - is_steam_deck = False - try: - if os.path.exists('/etc/os-release'): - with open('/etc/os-release') as f: - if 'steamdeck' in f.read().lower(): - is_steam_deck = True - except Exception: - pass - if saved_resolution: - combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] - resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) - self.resolution_combo.setCurrentIndex(resolution_index) - debug_print(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())] - if "1280x800 (Steam Deck)" in combo_items: - self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) - else: - self.resolution_combo.setCurrentIndex(0) - # Otherwise, default is 'Leave unchanged' (index 0) - form_section_widget = QWidget() - form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - form_section_widget.setLayout(form_grid) - form_section_widget.setMinimumHeight(160) # Reduced to match compact form - form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown - if self.debug: - form_section_widget.setStyleSheet("border: 2px solid blue;") - form_section_widget.setToolTip("FORM_SECTION") - user_config_vbox.addWidget(form_section_widget) - btn_row = QHBoxLayout() - btn_row.setAlignment(Qt.AlignHCenter) - self.start_btn = QPushButton("Start Configuration") - btn_row.addWidget(self.start_btn) - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.cancel_and_cleanup) - btn_row.addWidget(cancel_btn) - user_config_widget = QWidget() - user_config_widget.setLayout(user_config_vbox) - if self.debug: - user_config_widget.setStyleSheet("border: 2px solid orange;") - user_config_widget.setToolTip("USER_CONFIG_WIDGET") - # Right: Tabbed interface with Activity and Process Monitor - # Both tabs are always available, user can switch between them - self.process_monitor = QTextEdit() - self.process_monitor.setReadOnly(True) - self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) - self.process_monitor.setMinimumSize(QSize(300, 20)) - self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") - self.process_monitor_heading = QLabel("[Process Monitor]") - self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") - self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - process_vbox = QVBoxLayout() - process_vbox.setContentsMargins(0, 0, 0, 0) - process_vbox.setSpacing(2) - process_vbox.addWidget(self.process_monitor_heading) - process_vbox.addWidget(self.process_monitor) - process_monitor_widget = QWidget() - process_monitor_widget.setLayout(process_vbox) - process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - if self.debug: - process_monitor_widget.setStyleSheet("border: 2px solid purple;") - process_monitor_widget.setToolTip("PROCESS_MONITOR") - self.process_monitor_widget = process_monitor_widget - - # Set up File Progress List (Activity tab) - self.file_progress_list.setMinimumSize(QSize(300, 20)) - self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - # Create tab widget to hold both Activity and Process Monitor - self.activity_tabs = QTabWidget() - self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") - self.activity_tabs.setContentsMargins(0, 0, 0, 0) - self.activity_tabs.setDocumentMode(False) - self.activity_tabs.setTabPosition(QTabWidget.North) - if self.debug: - self.activity_tabs.setStyleSheet("border: 2px solid cyan;") - self.activity_tabs.setToolTip("ACTIVITY_TABS") - - # Add both widgets as tabs - self.activity_tabs.addTab(self.file_progress_list, "Activity") - self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") - - upper_hbox.addWidget(user_config_widget, stretch=11) - upper_hbox.addWidget(self.activity_tabs, stretch=9) - upper_hbox.setAlignment(Qt.AlignTop) - upper_section_widget = QWidget() - upper_section_widget.setLayout(upper_hbox) - # Use Fixed size policy for consistent height - upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown - if self.debug: - upper_section_widget.setStyleSheet("border: 2px solid green;") - upper_section_widget.setToolTip("UPPER_SECTION") - main_overall_vbox.addWidget(upper_section_widget) - - # Status banner with progress indicator and "Show details" toggle - banner_row = QHBoxLayout() - banner_row.setContentsMargins(0, 0, 0, 0) - banner_row.setSpacing(8) - banner_row.addWidget(self.progress_indicator, 1) - banner_row.addStretch() - banner_row.addWidget(self.show_details_checkbox) - banner_row_widget = QWidget() - banner_row_widget.setLayout(banner_row) - banner_row_widget.setMaximumHeight(45) # Compact height - banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - main_overall_vbox.addWidget(banner_row_widget) - - # Console output area (shown when "Show details" is checked) - 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) # Hidden by default (compact mode) - if self.debug: - self.console.setStyleSheet("border: 2px solid yellow;") - self.console.setToolTip("CONSOLE") - - # Set up scroll tracking for professional auto-scroll behavior - self._setup_scroll_tracking() - - # Wrap button row in widget for debug borders - btn_row_widget = QWidget() - btn_row_widget.setLayout(btn_row) - btn_row_widget.setMaximumHeight(50) - if self.debug: - btn_row_widget.setStyleSheet("border: 2px solid red;") - btn_row_widget.setToolTip("BUTTON_ROW") - - # Create a container that holds console + button row with proper spacing - console_and_buttons_widget = QWidget() - console_and_buttons_layout = QVBoxLayout() - console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) - console_and_buttons_layout.setSpacing(8) - - console_and_buttons_layout.addWidget(self.console, stretch=1) - console_and_buttons_layout.addWidget(btn_row_widget) - - console_and_buttons_widget.setLayout(console_and_buttons_layout) - console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - console_and_buttons_widget.setFixedHeight(50) # Lock to button row height when console is hidden - if self.debug: - console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") - console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") - # Add without stretch to prevent squashing upper section - main_overall_vbox.addWidget(console_and_buttons_widget) - - # Store references for toggle functionality - self.console_and_buttons_widget = console_and_buttons_widget - self.console_and_buttons_layout = console_and_buttons_layout - self.main_overall_vbox = main_overall_vbox - - self.setLayout(main_overall_vbox) - self.process = None - self.log_timer = None - self.last_log_pos = 0 - self.top_timer = QTimer(self) - 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 - self._was_at_bottom = True - - # Time tracking for workflow completion - self._workflow_start_time = None - - # Initialize empty controls list - will be populated after UI is built - self._actionable_controls = [] - - # 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 = [ - # Main action button - self.start_btn, - # Form fields - self.shortcut_combo, - # Resolution controls - 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 - # Remove any fixed height constraints to allow natural stretching - 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() - scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) - 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() - # Use tolerance to account for rounding and rapid updates - self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 - - # If user manually scrolls to bottom, reset manual scroll flag - if self._was_at_bottom and self._user_manually_scrolled: - # Small delay to allow user to scroll away if they want - 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 - try: - parent = self.parent() - while parent and not isinstance(parent, QMainWindow): - parent = parent.parent() - if parent and isinstance(parent, QMainWindow): - main_window = parent - except Exception: - pass - - if is_checked: - # Show console - self.console.setVisible(True) - self.console.setMinimumHeight(200) - self.console.setMaximumHeight(16777215) - self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - # Allow expansion when console is visible - if hasattr(self, 'console_and_buttons_widget'): - self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.console_and_buttons_widget.setMinimumHeight(0) - self.console_and_buttons_widget.setMaximumHeight(16777215) - self.console_and_buttons_widget.updateGeometry() - - # Stop CPU tracking when showing console + def cleanup_processes(self): + """Clean up any running processes when the window closes or is cancelled""" + # Stop CPU tracking if active + if hasattr(self, 'file_progress_list'): self.file_progress_list.stop_cpu_tracking() - # Expand window - if main_window: - try: - from PySide6.QtCore import QSize - # On Steam Deck, keep fullscreen; on other systems, set normal window state - if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): - main_window.showNormal() - main_window.setMaximumHeight(16777215) - main_window.setMinimumHeight(0) - expanded_min = 900 - current_size = main_window.size() - target_height = max(expanded_min, 900) - main_window.setMinimumHeight(expanded_min) - main_window.resize(current_size.width(), target_height) - self.main_overall_vbox.invalidate() - self.updateGeometry() - except Exception: - pass - else: - # Hide console - self.console.setVisible(False) - self.console.setMinimumHeight(0) - self.console.setMaximumHeight(0) - self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) + # Clean up configuration thread if running + if hasattr(self, 'config_thread') and self.config_thread.isRunning(): + self.config_thread.terminate() + self.config_thread.wait(1000) - # Lock height when console is hidden - if hasattr(self, 'console_and_buttons_widget'): - self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.console_and_buttons_widget.setFixedHeight(50) - self.console_and_buttons_widget.updateGeometry() + def cancel_and_cleanup(self): + """Handle Cancel button - clean up processes and go back""" + self.cleanup_processes() + self.collapse_show_details_before_leave() + self.go_back() - # CPU tracking will start when user clicks "Start Configuration", not here - # (Removed to avoid blocking showEvent) + def showEvent(self, event): + """Called when the widget becomes visible - ensure collapsed state""" + super().showEvent(event) - # Collapse window - if main_window: - try: - from PySide6.QtCore import QSize - # Use fixed compact height for consistency across all workflow screens - compact_height = 620 - # On Steam Deck, keep fullscreen; on other systems, set normal window state - if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): - main_window.showNormal() - set_responsive_minimum(main_window, min_width=960, min_height=compact_height) - current_size = main_window.size() - main_window.resize(current_size.width(), compact_height) - except Exception: - pass - - def _handle_progress_update(self, text): - """Handle progress updates - update console, activity window, and progress indicator""" - # Always append to console - self._safe_append_text(text) - - # Parse the message to update UI widgets - message_lower = text.lower() - - # Update progress indicator based on key status messages - if "setting protontricks permissions" in message_lower or "permissions" in message_lower: - self.progress_indicator.set_status("Setting permissions...", 20) - elif "applying curated registry" in message_lower or "registry" in message_lower: - self.progress_indicator.set_status("Applying registry files...", 40) - elif "installing wine components" in message_lower or "wine component" in message_lower: - self.progress_indicator.set_status("Installing wine components...", 60) - elif "dotnet" in message_lower and "fix" in message_lower: - self.progress_indicator.set_status("Applying dotnet fixes...", 75) - elif "setting ownership" in message_lower or "ownership and permissions" in message_lower: - self.progress_indicator.set_status("Setting permissions...", 85) - elif "verifying" in message_lower: - self.progress_indicator.set_status("Verifying setup...", 90) - elif "steam integration complete" in message_lower or "configuration complete" in message_lower: - self.progress_indicator.set_status("Configuration complete", 100) - - # Update activity window with generic configuration status - # Only update if message contains meaningful progress (not blank lines or separators) - if text.strip() and not text.strip().startswith('='): - # Show generic "Configuring modlist..." in activity window - self.file_progress_list.update_files( - [], - current_phase="Configuring", - summary_info={"current": 1, "total": 1, "label": "Setting up modlist"} - ) - - def _safe_append_text(self, text): - """Append text with professional auto-scroll behavior""" - # Write all messages to log file - self._write_to_log_file(text) - - scrollbar = self.console.verticalScrollBar() - # Check if user was at bottom BEFORE adding text - was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance - - # Add the text - self.console.append(text) - - # Auto-scroll if user was at bottom and hasn't manually scrolled - # Re-check bottom state after text addition for better reliability - if (was_at_bottom and not self._user_manually_scrolled) or \ - (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): - scrollbar.setValue(scrollbar.maximum()) - # Ensure user can still manually scroll up during rapid updates - if scrollbar.value() == scrollbar.maximum(): - self._was_at_bottom = True - - def _write_to_log_file(self, message): - """Write message to workflow log file with timestamp""" + # Ensure initial collapsed layout first so UI is stable before async load try: - from datetime import datetime - 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") - except Exception: - # Logging should never break the workflow - pass - - def validate_and_start_configure(self): - # Reload config to pick up any settings changes made in Settings dialog - self.config_handler.reload_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) - - # Initialize progress indicator - self.progress_indicator.set_status("Preparing to configure...", 0) - - # Start CPU tracking - self.file_progress_list.start_cpu_tracking() - - # Disable controls during configuration - self._disable_controls_during_operation() - - # Get selected shortcut - idx = self.shortcut_combo.currentIndex() - 1 # Account for 'Please Select...' - from jackify.frontends.gui.services.message_service import MessageService - if idx < 0 or idx >= len(self.shortcut_map): - MessageService.critical(self, "No Shortcut Selected", "Please select a ModOrganizer.exe Steam shortcut to configure.", safety_level="medium") - self._enable_controls_after_operation() - return - shortcut = self.shortcut_map[idx] - modlist_name = shortcut.get('AppName', shortcut.get('appname', '')) - install_dir = shortcut.get('StartDir', shortcut.get('startdir', '')) - if not modlist_name or not install_dir: - MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium") - self._enable_controls_after_operation() - return - resolution = self.resolution_combo.currentText() - # Handle resolution saving - if resolution and resolution != "Leave unchanged": - success = self.resolution_service.save_resolution(resolution) - if success: - debug_print(f"DEBUG: Resolution saved successfully: {resolution}") - else: - debug_print("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") - # 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 - # This ensures Proton version and winetricks settings are current - self.config_handler._load_config() - - # Store install_dir for later use in on_configuration_complete - self._current_install_dir = install_dir - - try: - # Start time tracking - self._workflow_start_time = time.time() - - from jackify import __version__ as jackify_version - self._safe_append_text(f"Jackify v{jackify_version}") - self._safe_append_text("[Jackify] Starting post-install configuration...") - - # Create configuration thread using backend service - from PySide6.QtCore import QThread, Signal - - class ConfigurationThread(QThread): - progress_update = Signal(str) - configuration_complete = Signal(bool, str, str, bool) - error_occurred = Signal(str) - - def __init__(self, modlist_name, install_dir, resolution): - super().__init__() - self.modlist_name = modlist_name - self.install_dir = install_dir - self.resolution = resolution - - 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 - import os - - # Initialize backend service - system_info = SystemInfo(is_steamdeck=False) # TODO: Detect Steam Deck - modlist_service = ModlistService(system_info) - - # Create modlist context for existing modlist configuration - mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe") - modlist_context = ModlistContext( - name=self.modlist_name, - install_dir=Path(self.install_dir), - download_dir=Path(self.install_dir).parent / 'Downloads', # Default - game_type='skyrim', # Default for now - TODO: detect from modlist - nexus_api_key='', # Not needed for configuration-only - modlist_value='', # Not needed for existing modlist - modlist_source='existing', - skip_confirmation=True - ) - - # For existing modlists, add resolution if specified - if self.resolution != "Leave unchanged": - modlist_context.resolution = self.resolution.split()[0] - # Note: If "Leave unchanged" is selected, resolution stays None (no fallback needed) - - # 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): - # Existing modlists shouldn't need manual steps, but handle gracefully - self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})") - - # Call the working configuration service method - self.progress_update.emit("Starting existing modlist configuration...") - - # For existing modlists, call configure_modlist_post_steam directly - # since Steam setup and manual steps should already be done - success = 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 success: - self.error_occurred.emit("Configuration failed - check logs for details") - - except Exception as e: - import traceback - error_msg = f"Configuration error: {e}\n{traceback.format_exc()}" - self.error_occurred.emit(error_msg) - - # Create and start the configuration thread - self.config_thread = ConfigurationThread(modlist_name, install_dir, resolution) - self.config_thread.progress_update.connect(self._handle_progress_update) - self.config_thread.configuration_complete.connect(self.on_configuration_complete) - self.config_thread.error_occurred.connect(self.on_configuration_error) - self.config_thread.start() + from PySide6.QtCore import Qt as _Qt + 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._toggle_console_visibility(False) + # Only set minimum size - DO NOT RESIZE + main_window = self.window() + if main_window: + from PySide6.QtCore import QSize + main_window.setMaximumSize(QSize(16777215, 16777215)) + set_responsive_minimum(main_window, min_width=960, min_height=420) 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") - + 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 + + def hideEvent(self, event): + """Clean up thread when screen is hidden (terminate without blocking main thread)""" + super().hideEvent(event) + if self._shortcut_loader is not None: + if self._shortcut_loader.isRunning(): + try: + self._shortcut_loader.finished_signal.disconnect() + except Exception: + pass + self._shortcut_loader.terminate() + self._shortcut_loader = None + def on_configuration_complete(self, success, message, modlist_name, enb_detected=False): """Handle configuration completion""" # Re-enable all controls when workflow completes @@ -758,6 +130,16 @@ class ConfigureExistingModlistScreen(QWidget): parent=self ) success_dialog.show() + + # Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection) + if enb_detected: + try: + from ..dialogs.enb_proton_dialog import ENBProtonDialog + enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self) + enb_dialog.exec() + except Exception as e: + import logging + 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", @@ -771,401 +153,6 @@ class ConfigureExistingModlistScreen(QWidget): self._safe_append_text(f"Configuration error: {error_message}") MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") - def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str): - """Check if VNV automation should run and execute if applicable - - Args: - modlist_name: Name of the installed modlist - install_dir: Installation directory path - """ - try: - from pathlib import Path - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - from jackify.backend.handlers.path_handler import PathHandler - - # Get paths first (needed for VNV detection) - install_path = Path(install_dir) - - # Quick check before importing more (pass install location for ModOrganizer.ini check) - if not should_offer_vnv_automation(modlist_name, install_path): - return - game_paths = PathHandler().find_vanilla_game_paths() - game_root = game_paths.get('Fallout New Vegas') - - if not game_root: - debug_print("DEBUG: VNV automation skipped - FNV game root not found") - return - - # Confirmation callback - show dialog to user - def confirmation_callback(description: str) -> bool: - from ..services.message_service import MessageService - reply = MessageService.question( - self, - "VNV Post-Install Automation", - description, - critical=False, - safety_level="medium" - ) - return reply == QMessageBox.Yes - - # Manual file callback for non-Premium users - def manual_file_callback(title: str, instructions: str) -> Optional[Path]: - from PySide6.QtWidgets import QFileDialog - from ..services.message_service import MessageService - - # Show instructions - MessageService.information(self, title, instructions) - - # Open file picker - file_path, _ = QFileDialog.getOpenFileName( - self, - title, - str(Path.home() / "Downloads"), - "All Files (*.*)" - ) - - if file_path: - return Path(file_path) - return None - - # Run automation - automation_ran, error = run_vnv_automation_if_applicable( - modlist_name=modlist_name, - modlist_install_location=install_path, - game_root=game_root, - ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), - progress_callback=None, # GUI doesn't need progress updates for post-install - manual_file_callback=manual_file_callback, - confirmation_callback=confirmation_callback - ) - - if error: - from ..services.message_service import MessageService - MessageService.warning( - self, - "VNV Automation Failed", - f"VNV post-install automation encountered an error:\n\n{error}\n\n" - "You can complete these steps manually by following the guide at:\n" - "https://vivanewvegas.moddinglinked.com/wabbajack.html" - ) - - except Exception as e: - debug_print(f"ERROR: Failed to run VNV automation: {e}") - import traceback - debug_print(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" - msg = ( - f"Manual Proton Setup Required for {modlist_name}
" - "After Steam restarts, complete the following steps in Steam:
" - f"1. Locate the '{modlist_name}' entry in your Steam Library
" - "2. Right-click and select 'Properties'
" - "3. Switch to the 'Compatibility' tab
" - "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" - "5. Select 'Proton - Experimental' from the dropdown menu
" - "6. Close the Properties window
" - f"7. Launch '{modlist_name}' from your Steam Library
" - "8. Wait for Wabbajack to download its files and fully load
" - "9. Once Wabbajack has fully loaded, CLOSE IT completely and return here
" - "
Once you have completed ALL the steps above, click OK to continue." - f"{extra_warning}" - ) - reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") - if reply == QMessageBox.Yes: - if self.config_process and self.config_process.state() == QProcess.Running: - self.config_process.write(b'\n') - self.config_process.waitForBytesWritten(1000) - self._config_prompt_state = None - self._manual_steps_buffer = [] - else: - # User clicked Cancel or closed the dialog - cancel the workflow - self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.") - # Terminate the configuration process - if self.config_process and self.config_process.state() == QProcess.Running: - self.config_process.terminate() - self.config_process.waitForFinished(2000) - # Re-enable all controls - 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) - dlg.setWindowTitle("Next Steps") - dlg.setModal(True) - layout = QVBoxLayout(dlg) - label = QLabel(message) - label.setWordWrap(True) - layout.addWidget(label) - btn_row = QHBoxLayout() - btn_return = QPushButton("Return") - btn_exit = QPushButton("Exit") - btn_row.addWidget(btn_return) - btn_row.addWidget(btn_exit) - layout.addLayout(btn_row) - def on_return(): - dlg.accept() - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(0) - def on_exit(): - QApplication.quit() - btn_return.clicked.connect(on_return) - btn_exit.clicked.connect(on_exit) - dlg.exec() - - def go_back(self): - """Navigate back to main menu and restore window size""" - # Emit collapse signal to restore compact mode - self.resize_request.emit('collapse') - - # Restore window size before navigating away - try: - main_window = self.window() - if main_window: - from PySide6.QtCore import QSize - from ..utils import apply_window_size_and_position - - # Only set minimum size - DO NOT RESIZE - main_window.setMaximumSize(QSize(16777215, 16777215)) - set_responsive_minimum(main_window, min_width=960, min_height=420) - # DO NOT resize - let window stay at current size - except Exception: - pass - - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(self.main_menu_index) - - def cleanup_processes(self): - """Clean up any running processes when the window closes or is cancelled""" - # Stop CPU tracking if active - if hasattr(self, 'file_progress_list'): - self.file_progress_list.stop_cpu_tracking() - - # Clean up configuration thread if running - if hasattr(self, 'config_thread') and self.config_thread.isRunning(): - self.config_thread.terminate() - self.config_thread.wait(1000) - - def cancel_and_cleanup(self): - """Handle Cancel button - clean up processes and go back""" - self.cleanup_processes() - self.go_back() - - def showEvent(self, event): - """Called when the widget becomes visible - ensure collapsed state""" - super().showEvent(event) - - # Load shortcuts asynchronously (only once, on first show) to avoid blocking UI - if not self._shortcuts_loaded: - # Load in background thread to avoid blocking UI - from PySide6.QtCore import QTimer - QTimer.singleShot(0, self._load_shortcuts_async) - self._shortcuts_loaded = True - - # Ensure initial collapsed layout each time this screen is opened - try: - from PySide6.QtCore import Qt as _Qt - # Ensure checkbox is unchecked without emitting signals - if self.show_details_checkbox.isChecked(): - self.show_details_checkbox.blockSignals(True) - self.show_details_checkbox.setChecked(False) - self.show_details_checkbox.blockSignals(False) - # Force collapsed state - self._toggle_console_visibility(False) - - # Only set minimum size - DO NOT RESIZE - main_window = self.window() - if main_window: - from PySide6.QtCore import QSize - main_window.setMaximumSize(QSize(16777215, 16777215)) - set_responsive_minimum(main_window, min_width=960, min_height=420) - # DO NOT resize - let window stay at current size - except Exception as e: - # If initial collapse fails, log but don't crash - print(f"Warning: Failed to set initial collapsed state: {e}") - - def hideEvent(self, event): - """Clean up thread when screen is hidden""" - super().hideEvent(event) - # Clean up shortcut loader thread if it's still running - if self._shortcut_loader is not None: - if self._shortcut_loader.isRunning(): - self._shortcut_loader.finished_signal.disconnect() - self._shortcut_loader.terminate() - self._shortcut_loader.wait(1000) # Wait up to 1 second for cleanup - self._shortcut_loader = None - - def _load_shortcuts_async(self): - """Load ModOrganizer.exe shortcuts asynchronously to avoid blocking UI""" - from PySide6.QtCore import QThread, Signal, QObject - - class ShortcutLoaderThread(QThread): - finished_signal = Signal(list) # Emits list of shortcuts when done - error_signal = Signal(str) # Emits error message if something goes wrong - - def run(self): - try: - # Suppress all logging/output in background thread to avoid reentrant stderr issues - import logging - import sys - - # Temporarily redirect stderr to avoid reentrant calls - old_stderr = sys.stderr - try: - # Use a null device or StringIO to capture errors without writing to stderr - from io import StringIO - sys.stderr = StringIO() - - # Fetch shortcuts for ModOrganizer.exe using existing backend functionality - from jackify.backend.handlers.modlist_handler import ModlistHandler - - # Initialize modlist handler with empty config dict to use default initialization - modlist_handler = ModlistHandler({}) - discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe") - - # Convert to shortcut_handler format for UI compatibility - shortcuts = [] - for modlist in discovered_modlists: - # Convert discovered modlist format to shortcut format - shortcut = { - 'AppName': modlist.get('name', 'Unknown'), - 'AppID': modlist.get('appid', ''), - 'StartDir': modlist.get('path', ''), - 'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe" - } - shortcuts.append(shortcut) - - # Restore stderr before emitting signal - sys.stderr = old_stderr - self.finished_signal.emit(shortcuts) - except Exception as inner_e: - # Restore stderr before emitting error - sys.stderr = old_stderr - error_msg = str(inner_e) - self.error_signal.emit(error_msg) - self.finished_signal.emit([]) - except Exception as e: - # Fallback error handling - error_msg = str(e) - self.error_signal.emit(error_msg) - self.finished_signal.emit([]) - - # Show loading state in dropdown - if hasattr(self, 'shortcut_combo'): - self.shortcut_combo.clear() - self.shortcut_combo.addItem("Loading modlists...") - self.shortcut_combo.setEnabled(False) - - # Clean up any existing thread first - if self._shortcut_loader is not None: - if self._shortcut_loader.isRunning(): - self._shortcut_loader.finished_signal.disconnect() - self._shortcut_loader.terminate() - self._shortcut_loader.wait(1000) # Wait up to 1 second - self._shortcut_loader = None - - # 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 - - # Update the dropdown - if hasattr(self, 'shortcut_combo'): - self.shortcut_combo.clear() - self.shortcut_combo.setEnabled(True) - self.shortcut_combo.addItem("Please Select...") - self.shortcut_map.clear() - - for shortcut in self.mo2_shortcuts: - display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})" - 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}") - # 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") - - def update_top_panel(self): - try: - result = subprocess.run([ - "ps", "-eo", "pcpu,pmem,comm,args" - ], stdout=subprocess.PIPE, text=True, timeout=2) - lines = result.stdout.splitlines() - header = "CPU%\tMEM%\tCOMMAND" - filtered = [header] - process_rows = [] - for line in lines[1:]: - line_lower = line.lower() - # Include jackify-engine and related heavy processes - heavy_processes = ( - "jackify-engine" in line_lower or "7zz" in line_lower or - "texconv" in line_lower or "wine" in line_lower or - "wine64" in line_lower or "protontricks" in line_lower - ) - # Include Python processes running configure-modlist command - configure_processes = ( - "python" in line_lower and "configure-modlist" in line_lower - ) - # Include configuration threads that might be running - config_threads = ( - hasattr(self, 'config_thread') and - self.config_thread and - self.config_thread.isRunning() and - ("python" in line_lower or "jackify" in line_lower) - ) - - if (heavy_processes or configure_processes or config_threads) and "jackify-gui.py" not in line_lower: - cols = line.strip().split(None, 3) - if len(cols) >= 3: - process_rows.append(cols) - process_rows.sort(key=lambda x: float(x[0]), reverse=True) - for cols in process_rows: - filtered.append('\t'.join(cols)) - if len(filtered) == 1: - filtered.append("[No Jackify-related processes found]") - self.process_monitor.setPlainText('\n'.join(filtered)) - except Exception as e: - self.process_monitor.setPlainText(f"[process info unavailable: {e}]") - - 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: - return "unknown time" - - elapsed_seconds = time.time() - self._workflow_start_time - elapsed_minutes = int(elapsed_seconds // 60) - elapsed_seconds_remainder = int(elapsed_seconds % 60) - - if elapsed_minutes > 0: - if elapsed_minutes == 1: - return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds" - else: - return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" - else: - return f"{elapsed_seconds_remainder} seconds" - def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" # Clear the shortcut selection diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_console.py b/jackify/frontends/gui/screens/configure_existing_modlist_console.py new file mode 100644 index 0000000..6c262b3 --- /dev/null +++ b/jackify/frontends/gui/screens/configure_existing_modlist_console.py @@ -0,0 +1,150 @@ +"""Console output management for ConfigureExistingModlistScreen (Mixin).""" +import re +import time + +from PySide6.QtCore import QTimer + +from jackify.shared.progress_models import FileProgress, OperationType + + +class ConfigureExistingModlistConsoleMixin: + """Mixin providing console output management for ConfigureExistingModlistScreen.""" + + def _handle_progress_update(self, text): + """Handle progress updates - update console, activity window, and progress indicator""" + # Always append to console + self._safe_append_text(text) + + # Parse the message to update UI widgets + message_lower = text.lower() + + # Update progress indicator based on key status messages + if "setting protontricks permissions" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Setting permissions...", 20) + self.file_progress_list.update_or_add_item("__phase__", "Setting permissions...", 0.0) + elif "applying curated registry" in message_lower or "registry" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Applying registry files...", 40) + self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0) + elif "installing wine components" in message_lower or "wine component" in message_lower: + self.progress_indicator.set_status("Installing wine components...", 60) + if not hasattr(self, '_component_install_timer') or not self._component_install_timer: + self._start_component_install_pulse() + comp_list = self._parse_wine_components_message(text) + if comp_list: + self._start_component_install_pulse_with_components(comp_list) + elif "wine components verified" in message_lower or "wine components installed" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Wine components installed", 65) + self.file_progress_list.update_or_add_item("__phase__", "Wine components installed", 0.0) + elif "dotnet" in message_lower and "fix" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Applying dotnet fixes...", 75) + self.file_progress_list.update_or_add_item("__phase__", "Applying dotnet fixes...", 0.0) + elif "setting ownership" in message_lower or "ownership and permissions" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Setting permissions...", 85) + self.file_progress_list.update_or_add_item("__phase__", "Setting permissions...", 0.0) + elif "verifying" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Verifying setup...", 90) + self.file_progress_list.update_or_add_item("__phase__", "Verifying setup...", 0.0) + elif "steam integration complete" in message_lower or "configuration complete" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Configuration complete", 100) + self.file_progress_list.update_or_add_item("__phase__", "Configuration complete", 0.0) + + + def _safe_append_text(self, text): + """Append text with professional auto-scroll behavior""" + # Write all messages to log file + self._write_to_log_file(text) + + scrollbar = self.console.verticalScrollBar() + # Check if user was at bottom BEFORE adding text + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + + # Add the text + self.console.append(text) + + # Auto-scroll if user was at bottom and hasn't manually scrolled + # Re-check bottom state after text addition for better reliability + if (was_at_bottom and not self._user_manually_scrolled) or \ + (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): + scrollbar.setValue(scrollbar.maximum()) + # Ensure user can still manually scroll up during rapid updates + if scrollbar.value() == scrollbar.maximum(): + self._was_at_bottom = True + + + def _write_to_log_file(self, message): + """Write message to workflow log file with timestamp""" + try: + from datetime import datetime + 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") + except Exception: + pass + + def _parse_wine_components_message(self, text: str): + """Extract list of wine component names from backend status message, or None.""" + if "installing wine components:" not in text.lower() and "installing wine components via protontricks:" not in text.lower(): + return None + match = re.search(r"installing wine components(?:\s+via protontricks)?:\s*(.+)", text, re.IGNORECASE) + if not match: + return None + raw = match.group(1).strip() + if not raw: + return None + return [c.strip() for c in raw.split(",") if c.strip()] + + def _start_component_install_pulse(self): + """Start pulsing Activity item for Wine component installation (single generic item).""" + self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) + if not getattr(self, '_component_install_timer', None): + self._component_install_timer = QTimer(self) + self._component_install_timer.timeout.connect(self._component_install_heartbeat) + self._component_install_timer.start(100) + self._component_install_start_time = time.time() + + def _start_component_install_pulse_with_components(self, components: list): + """Replace single item with one Activity entry per component, each with pulsing progress.""" + self._component_install_list = components + progresses = [ + FileProgress( + filename=f"Wine component: {comp}", + operation=OperationType.UNKNOWN, + percent=0.0, + ) + for comp in components + ] + self.file_progress_list.update_files(progresses, current_phase=None) + + def _component_install_heartbeat(self): + """Heartbeat to keep component install item(s) pulsing.""" + if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time: + return + if hasattr(self, '_component_install_list') and self._component_install_list: + progresses = [ + FileProgress( + filename=f"Wine component: {comp}", + operation=OperationType.UNKNOWN, + percent=0.0, + ) + for comp in self._component_install_list + ] + self.file_progress_list.update_files(progresses, current_phase=None) + else: + self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) + + def _stop_component_install_pulse(self): + """Stop the component install pulsing timer.""" + if hasattr(self, '_component_install_timer') and self._component_install_timer: + self._component_install_timer.stop() + self._component_install_timer = None + if hasattr(self, '_component_install_list'): + del self._component_install_list + + diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py b/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py new file mode 100644 index 0000000..341eaf3 --- /dev/null +++ b/jackify/frontends/gui/screens/configure_existing_modlist_shortcuts.py @@ -0,0 +1,117 @@ +"""Shortcut loading for ConfigureExistingModlistScreen (Mixin).""" +from PySide6.QtCore import QThread, Signal, QObject + +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 ConfigureExistingModlistShortcutsMixin: + """Mixin providing shortcut loading for ConfigureExistingModlistScreen.""" + + def _load_shortcuts_async(self): + """Load ModOrganizer.exe shortcuts asynchronously to avoid blocking UI""" + from PySide6.QtCore import QThread, Signal, QObject + + class ShortcutLoaderThread(QThread): + finished_signal = Signal(list) # Emits list of shortcuts when done + error_signal = Signal(str) # Emits error message if something goes wrong + + def run(self): + try: + # Suppress all logging/output in background thread to avoid reentrant stderr issues + import logging + import sys + + # Temporarily redirect stderr to avoid reentrant calls + old_stderr = sys.stderr + try: + # Use a null device or StringIO to capture errors without writing to stderr + from io import StringIO + sys.stderr = StringIO() + + # Fetch shortcuts for ModOrganizer.exe using existing backend functionality + from jackify.backend.handlers.modlist_handler import ModlistHandler + + # Initialize modlist handler with empty config dict to use default initialization + modlist_handler = ModlistHandler({}) + discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe") + + # Convert to shortcut_handler format for UI compatibility + shortcuts = [] + for modlist in discovered_modlists: + # Convert discovered modlist format to shortcut format + shortcut = { + 'AppName': modlist.get('name', 'Unknown'), + 'AppID': modlist.get('appid', ''), + 'StartDir': modlist.get('path', ''), + 'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe" + } + shortcuts.append(shortcut) + + # Restore stderr before emitting signal + sys.stderr = old_stderr + self.finished_signal.emit(shortcuts) + except Exception as inner_e: + # Restore stderr before emitting error + sys.stderr = old_stderr + error_msg = str(inner_e) + self.error_signal.emit(error_msg) + self.finished_signal.emit([]) + except Exception as e: + # Fallback error handling + error_msg = str(e) + self.error_signal.emit(error_msg) + self.finished_signal.emit([]) + + # Show loading state in dropdown + if hasattr(self, 'shortcut_combo'): + self.shortcut_combo.clear() + 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) + if self._shortcut_loader is not None: + if self._shortcut_loader.isRunning(): + self._shortcut_loader.finished_signal.disconnect() + self._shortcut_loader.terminate() + self._shortcut_loader = None + + # 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 + + # Update the dropdown + if hasattr(self, 'shortcut_combo'): + self.shortcut_combo.clear() + self.shortcut_combo.setEnabled(True) + self.shortcut_combo.addItem("Please Select...") + self.shortcut_map.clear() + + for shortcut in self.mo2_shortcuts: + display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})" + 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}") + # 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 new file mode 100644 index 0000000..2097780 --- /dev/null +++ b/jackify/frontends/gui/screens/configure_existing_modlist_ui.py @@ -0,0 +1,564 @@ +"""UI setup and control management for ConfigureExistingModlistScreen (Mixin).""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, + QGridLayout, QTextEdit, QSizePolicy, QTabWidget, QCheckBox, QMainWindow +) +from PySide6.QtCore import Qt, QSize, QTimer +import os +import subprocess +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 +from jackify.backend.handlers.shortcut_handler import ShortcutHandler + +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 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") + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + from jackify.backend.models.configuration import SystemInfo + self.system_info = system_info or SystemInfo(is_steamdeck=False) + self.debug = DEBUG_BORDERS + self.refresh_paths() + + # --- Detect Steam Deck --- + from jackify.backend.services.platform_detection_service import PlatformDetectionService + platform_service = PlatformDetectionService.get_instance() + steamdeck = platform_service.is_steamdeck + self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck) + + # Initialize services early + from jackify.backend.services.api_key_service import APIKeyService + from jackify.backend.services.resolution_service import ResolutionService + from jackify.backend.handlers.config_handler import ConfigHandler + self.api_key_service = APIKeyService() + self.resolution_service = ResolutionService() + self.config_handler = ConfigHandler() + + # --- Fetch shortcuts for ModOrganizer.exe - deferred to showEvent to avoid blocking init --- + # Initialize empty list, will be populated when screen is shown + self.mo2_shortcuts = [] + self._shortcuts_loaded = False + self._shortcut_loader = None # Thread for async shortcut loading + + # Initialize progress reporting components + self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) + self.progress_indicator.set_status("Ready to configure", 0) + self.file_progress_list = FileProgressList() + + # Create "Show Details" checkbox + self.show_details_checkbox = QCheckBox("Show details") + self.show_details_checkbox.setChecked(False) # Start collapsed + self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") + self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) + + # --- UI Layout --- + main_overall_vbox = QVBoxLayout(self) + main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + # --- Header (title, description) --- + header_layout = QVBoxLayout() + header_layout.setSpacing(1) # Reduce spacing between title and description + title = QLabel("Configure Existing Modlist") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") + title.setAlignment(Qt.AlignHCenter) + title.setMaximumHeight(30) # Force compact height + header_layout.addWidget(title) + desc = QLabel( + "This screen allows you to configure an existing modlist in Jackify. " + "Select your Steam shortcut for ModOrganizer.exe, set your resolution, and complete post-install configuration." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(40) # Force compact height for description + header_layout.addWidget(desc) + header_widget = QWidget() + header_widget.setLayout(header_layout) + header_widget.setMaximumHeight(75) # Match other screens + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + header_widget.setToolTip("HEADER_SECTION") + main_overall_vbox.addWidget(header_widget) + # --- Upper section: shortcut selector (left) + process monitor (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + # --- [Options] header (moved here for alignment) --- + 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) + user_config_vbox.addWidget(options_header) + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) + form_grid.setContentsMargins(0, 0, 0, 0) + # --- Shortcut selector --- + shortcut_label = QLabel("Select Modlist:") + self.shortcut_combo = QComboBox() + self.shortcut_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.shortcut_combo.addItem("Please Select...") + self.shortcut_map = [] + for shortcut in self.mo2_shortcuts: + display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})" + self.shortcut_combo.addItem(display) + self.shortcut_map.append(shortcut) + + # Add refresh button next to dropdown + refresh_btn = QPushButton("↻") + refresh_btn.setToolTip("Refresh modlist list") + refresh_btn.setFixedSize(30, 30) + refresh_btn.clicked.connect(self.refresh_modlist_list) + + # Create horizontal layout for dropdown and refresh button + shortcut_hbox = QHBoxLayout() + shortcut_hbox.addWidget(self.shortcut_combo) + shortcut_hbox.addWidget(refresh_btn) + shortcut_hbox.setSpacing(4) + shortcut_hbox.setStretch(0, 1) # Make dropdown expand + + form_grid.addWidget(shortcut_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(shortcut_hbox, 0, 1) + # --- Info message under shortcut selector --- + info_label = QLabel("If you don't see your modlist entry in this list, please ensure you have added it to Steam as a non-steam game, set a proton version in properties, and have started the modlist Steam entry at least once. You can also click the refresh button (↻) to update the list.") + info_label.setWordWrap(True) + form_grid.addWidget(info_label, 1, 0, 1, 2) + # --- Resolution selector --- + resolution_label = QLabel("Resolution:") + self.resolution_combo = QComboBox() + self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.resolution_combo.addItem("Leave unchanged") + self.resolution_combo.addItems([ + "1280x720", + "1280x800 (Steam Deck)", + "1366x768", + "1440x900", + "1600x900", + "1600x1200", + "1680x1050", + "1920x1080", + "1920x1200", + "2048x1152", + "2560x1080", + "2560x1440", + "2560x1600", + "3440x1440", + "3840x1600", + "3840x2160", + "3840x2400", + "5120x1440", + "5120x2160", + "7680x4320" + ]) + form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addWidget(self.resolution_combo, 2, 1) + + # Load saved resolution if available + saved_resolution = self.resolution_service.get_saved_resolution() + is_steam_deck = False + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + is_steam_deck = True + except Exception: + pass + if saved_resolution: + combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] + resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) + self.resolution_combo.setCurrentIndex(resolution_index) + debug_print(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())] + if "1280x800 (Steam Deck)" in combo_items: + self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) + else: + self.resolution_combo.setCurrentIndex(0) + # Otherwise, default is 'Leave unchanged' (index 0) + form_section_widget = QWidget() + form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + form_section_widget.setLayout(form_grid) + form_section_widget.setMinimumHeight(160) # Reduced to match compact form + form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown + if self.debug: + form_section_widget.setStyleSheet("border: 2px solid blue;") + form_section_widget.setToolTip("FORM_SECTION") + user_config_vbox.addWidget(form_section_widget) + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + self.start_btn = QPushButton("Start Configuration") + btn_row.addWidget(self.start_btn) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.cancel_and_cleanup) + btn_row.addWidget(cancel_btn) + user_config_widget = QWidget() + user_config_widget.setLayout(user_config_vbox) + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + user_config_widget.setToolTip("USER_CONFIG_WIDGET") + # Right: Tabbed interface with Activity and Process Monitor + # Both tabs are always available, user can switch between them + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") + self.process_monitor_heading = QLabel("[Process Monitor]") + self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") + self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor_heading) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + process_monitor_widget.setToolTip("PROCESS_MONITOR") + self.process_monitor_widget = process_monitor_widget + + # Set up File Progress List (Activity tab) + self.file_progress_list.setMinimumSize(QSize(300, 20)) + self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # Create tab widget to hold both Activity and Process Monitor + self.activity_tabs = QTabWidget() + self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") + self.activity_tabs.setContentsMargins(0, 0, 0, 0) + self.activity_tabs.setDocumentMode(False) + self.activity_tabs.setTabPosition(QTabWidget.North) + if self.debug: + self.activity_tabs.setStyleSheet("border: 2px solid cyan;") + self.activity_tabs.setToolTip("ACTIVITY_TABS") + + # Add both widgets as tabs + self.activity_tabs.addTab(self.file_progress_list, "Activity") + self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") + + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(self.activity_tabs, stretch=9) + upper_hbox.setAlignment(Qt.AlignTop) + upper_section_widget = QWidget() + upper_section_widget.setLayout(upper_hbox) + # Use Fixed size policy for consistent height + upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown + if self.debug: + upper_section_widget.setStyleSheet("border: 2px solid green;") + upper_section_widget.setToolTip("UPPER_SECTION") + main_overall_vbox.addWidget(upper_section_widget) + + # Status banner with progress indicator and "Show details" toggle + banner_row = QHBoxLayout() + banner_row.setContentsMargins(0, 0, 0, 0) + banner_row.setSpacing(8) + banner_row.addWidget(self.progress_indicator, 1) + banner_row.addStretch() + banner_row.addWidget(self.show_details_checkbox) + banner_row_widget = QWidget() + banner_row_widget.setLayout(banner_row) + banner_row_widget.setMaximumHeight(45) # Compact height + banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + main_overall_vbox.addWidget(banner_row_widget) + + # Console output area (shown when "Show details" is checked) + 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) # Hidden by default (compact mode) + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + self.console.setToolTip("CONSOLE") + + # Set up scroll tracking for professional auto-scroll behavior + self._setup_scroll_tracking() + + # Wrap button row in widget for debug borders + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + btn_row_widget.setToolTip("BUTTON_ROW") + + # Create a container that holds console + button row with proper spacing + console_and_buttons_widget = QWidget() + console_and_buttons_layout = QVBoxLayout() + console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) + console_and_buttons_layout.setSpacing(8) + + console_and_buttons_layout.addWidget(self.console, stretch=1) + console_and_buttons_layout.addWidget(btn_row_widget) + + console_and_buttons_widget.setLayout(console_and_buttons_layout) + console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + console_and_buttons_widget.setFixedHeight(50) # Lock to button row height when console is hidden + if self.debug: + console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") + console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") + # Add without stretch to prevent squashing upper section + main_overall_vbox.addWidget(console_and_buttons_widget) + + # Store references for toggle functionality + self.console_and_buttons_widget = console_and_buttons_widget + self.console_and_buttons_layout = console_and_buttons_layout + self.main_overall_vbox = main_overall_vbox + + self.setLayout(main_overall_vbox) + self.process = None + self.log_timer = None + self.last_log_pos = 0 + self.top_timer = QTimer(self) + 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 + self._was_at_bottom = True + + # Time tracking for workflow completion + self._workflow_start_time = None + + # Initialize empty controls list - will be populated after UI is built + self._actionable_controls = [] + + # 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 = [ + # Main action button + self.start_btn, + # Form fields + self.shortcut_combo, + # Resolution controls + 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 + # Remove any fixed height constraints to allow natural stretching + 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() + scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) + 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() + # Use tolerance to account for rounding and rapid updates + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + # If user manually scrolls to bottom, reset manual scroll flag + if self._was_at_bottom and self._user_manually_scrolled: + # Small delay to allow user to scroll away if they want + 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 + try: + parent = self.parent() + while parent and not isinstance(parent, QMainWindow): + parent = parent.parent() + if parent and isinstance(parent, QMainWindow): + main_window = parent + except Exception: + pass + + if is_checked: + # Show console + self.console.setVisible(True) + self.console.setMinimumHeight(200) + self.console.setMaximumHeight(16777215) + self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # Allow expansion when console is visible + if hasattr(self, 'console_and_buttons_widget'): + self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.console_and_buttons_widget.setMinimumHeight(0) + self.console_and_buttons_widget.setMaximumHeight(16777215) + self.console_and_buttons_widget.updateGeometry() + + # Stop CPU tracking when showing console + self.file_progress_list.stop_cpu_tracking() + + # Expand window + if main_window: + try: + from PySide6.QtCore import QSize + # On Steam Deck, keep fullscreen; on other systems, set normal window state + if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): + main_window.showNormal() + main_window.setMaximumHeight(16777215) + main_window.setMinimumHeight(0) + expanded_min = 900 + current_size = main_window.size() + target_height = max(expanded_min, 900) + main_window.setMinimumHeight(expanded_min) + main_window.resize(current_size.width(), target_height) + self.main_overall_vbox.invalidate() + self.updateGeometry() + except Exception: + pass + else: + # Hide console + self.console.setVisible(False) + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) + + # Lock height when console is hidden + if hasattr(self, 'console_and_buttons_widget'): + self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.console_and_buttons_widget.setFixedHeight(50) + self.console_and_buttons_widget.updateGeometry() + + # CPU tracking will start when user clicks "Start Configuration", not here + # (Removed to avoid blocking showEvent) + + # Collapse window + if main_window: + try: + from PySide6.QtCore import QSize + # Use fixed compact height for consistency across all workflow screens + compact_height = 620 + # On Steam Deck, keep fullscreen; on other systems, set normal window state + if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): + main_window.showNormal() + set_responsive_minimum(main_window, min_width=960, min_height=compact_height) + current_size = main_window.size() + main_window.resize(current_size.width(), compact_height) + except Exception: + pass + + + def update_top_panel(self): + try: + result = subprocess.run([ + "ps", "-eo", "pcpu,pmem,comm,args" + ], stdout=subprocess.PIPE, text=True, timeout=2) + lines = result.stdout.splitlines() + header = "CPU%\tMEM%\tCOMMAND" + filtered = [header] + process_rows = [] + for line in lines[1:]: + line_lower = line.lower() + # Include jackify-engine and related heavy processes + heavy_processes = ( + "jackify-engine" in line_lower or "7zz" in line_lower or + "texconv" in line_lower or "wine" in line_lower or + "wine64" in line_lower or "protontricks" in line_lower + ) + # Include Python processes running configure-modlist command + configure_processes = ( + "python" in line_lower and "configure-modlist" in line_lower + ) + # Include configuration threads that might be running + config_threads = ( + hasattr(self, 'config_thread') and + self.config_thread and + self.config_thread.isRunning() and + ("python" in line_lower or "jackify" in line_lower) + ) + + if (heavy_processes or configure_processes or config_threads) and "jackify-gui.py" not in line_lower: + cols = line.strip().split(None, 3) + if len(cols) >= 3: + process_rows.append(cols) + process_rows.sort(key=lambda x: float(x[0]), reverse=True) + for cols in process_rows: + filtered.append('\t'.join(cols)) + if len(filtered) == 1: + filtered.append("[No Jackify-related processes found]") + self.process_monitor.setPlainText('\n'.join(filtered)) + 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 new file mode 100644 index 0000000..6c9672b --- /dev/null +++ b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py @@ -0,0 +1,392 @@ +"""Workflow management for ConfigureExistingModlistScreen (Mixin).""" +from PySide6.QtCore import QThread, Signal +import os +import time +import logging +from pathlib import Path +from typing import Optional +from jackify.shared.resolution_utils import get_resolution_fallback + +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.""" + + 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 + + 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 validate_and_start_configure(self): + # Reload config to pick up any settings changes made in Settings dialog + self.config_handler.reload_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) + + # Initialize progress indicator + self.progress_indicator.set_status("Preparing to configure...", 0) + + # Start CPU tracking + self.file_progress_list.start_cpu_tracking() + + # Disable controls during configuration + self._disable_controls_during_operation() + + # Get selected shortcut + idx = self.shortcut_combo.currentIndex() - 1 # Account for 'Please Select...' + from jackify.frontends.gui.services.message_service import MessageService + if idx < 0 or idx >= len(self.shortcut_map): + MessageService.critical(self, "No Shortcut Selected", "Please select a ModOrganizer.exe Steam shortcut to configure.", safety_level="medium") + self._enable_controls_after_operation() + return + shortcut = self.shortcut_map[idx] + modlist_name = shortcut.get('AppName', shortcut.get('appname', '')) + install_dir = shortcut.get('StartDir', shortcut.get('startdir', '')) + if not modlist_name or not install_dir: + MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium") + self._enable_controls_after_operation() + return + resolution = self.resolution_combo.currentText() + # Handle resolution saving + if resolution and resolution != "Leave unchanged": + success = self.resolution_service.save_resolution(resolution) + if success: + debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + else: + debug_print("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") + # 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 + # Refresh Proton version and winetricks settings + self.config_handler._load_config() + + # Store install_dir for later use in on_configuration_complete + self._current_install_dir = install_dir + + try: + # Start time tracking + self._workflow_start_time = time.time() + + from jackify import __version__ as jackify_version + self._safe_append_text(f"Jackify v{jackify_version}") + self._safe_append_text("[Jackify] Starting post-install configuration...") + + # Create configuration thread using backend service + from PySide6.QtCore import QThread, Signal + from jackify.backend.models.configuration import SystemInfo + from jackify.backend.services.platform_detection_service import PlatformDetectionService + + # Capture parent's method and create system_info + detect_game_type_func = self._detect_game_type_from_mo2_ini + platform_service = PlatformDetectionService.get_instance() + parent_system_info = SystemInfo(is_steamdeck=platform_service.is_steamdeck) + + class ConfigurationThread(QThread): + progress_update = Signal(str) + configuration_complete = Signal(bool, str, str, bool) + error_occurred = Signal(str) + + def __init__(self, modlist_name, install_dir, resolution, system_info, detect_func): + super().__init__() + self.modlist_name = modlist_name + self.install_dir = install_dir + self.resolution = resolution + self.system_info = system_info + self.detect_game_type = detect_func + + def run(self): + try: + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + import os + + # Initialize backend service + modlist_service = ModlistService(self.system_info) + + # Detect game type from ModOrganizer.ini using captured function + detected_game_type = self.detect_game_type(self.install_dir) + + # Create modlist context for existing modlist configuration + mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe") + modlist_context = ModlistContext( + name=self.modlist_name, + install_dir=Path(self.install_dir), + download_dir=Path(self.install_dir).parent / 'Downloads', # Default + game_type=detected_game_type, + nexus_api_key='', # Not needed for configuration-only + modlist_value='', # Not needed for existing modlist + modlist_source='existing', + skip_confirmation=True + ) + + # For existing modlists, add resolution if specified + if self.resolution != "Leave unchanged": + modlist_context.resolution = self.resolution.split()[0] + # If "Leave unchanged" selected, resolution stays None + + # 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): + # Existing modlists shouldn't need manual steps, but handle gracefully + self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})") + + # Call the working configuration service method + self.progress_update.emit("Starting existing modlist configuration...") + + # For existing modlists, call configure_modlist_post_steam directly + # since Steam setup and manual steps should already be done + success = 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 success: + self.error_occurred.emit("Configuration failed - check logs for details") + + except Exception as e: + import traceback + error_msg = f"Configuration error: {e}\n{traceback.format_exc()}" + self.error_occurred.emit(error_msg) + + # Create and start the configuration thread + self.config_thread = ConfigurationThread(modlist_name, install_dir, resolution, parent_system_info, detect_game_type_func) + self.config_thread.progress_update.connect(self._handle_progress_update) + 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] Failed to start configuration: {e}") + MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium") + + + def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str): + """Check if VNV automation should run and execute if applicable + + Args: + modlist_name: Name of the installed modlist + install_dir: Installation directory path + """ + try: + from pathlib import Path + from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + from jackify.backend.handlers.path_handler import PathHandler + + # Get paths first (needed for VNV detection) + install_path = Path(install_dir) + + # Quick check before importing more (pass install location for ModOrganizer.ini check) + if not should_offer_vnv_automation(modlist_name, install_path): + return + game_paths = PathHandler().find_vanilla_game_paths() + game_root = game_paths.get('Fallout New Vegas') + + if not game_root: + debug_print("DEBUG: VNV automation skipped - FNV game root not found") + return + + # Confirmation callback - show dialog to user + def confirmation_callback(description: str) -> bool: + from ..services.message_service import MessageService + reply = MessageService.question( + self, + "VNV Post-Install Automation", + description, + critical=False, + safety_level="medium" + ) + return reply == QMessageBox.Yes + + # Manual file callback for non-Premium users + def manual_file_callback(title: str, instructions: str) -> Optional[Path]: + from PySide6.QtWidgets import QFileDialog + from ..services.message_service import MessageService + + # Show instructions + MessageService.information(self, title, instructions) + + # Open file picker + file_path, _ = QFileDialog.getOpenFileName( + self, + title, + str(Path.home() / "Downloads"), + "All Files (*.*)" + ) + + if file_path: + return Path(file_path) + return None + + # Run automation + automation_ran, error = run_vnv_automation_if_applicable( + modlist_name=modlist_name, + modlist_install_location=install_path, + game_root=game_root, + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + progress_callback=None, # GUI doesn't need progress updates for post-install + manual_file_callback=manual_file_callback, + confirmation_callback=confirmation_callback + ) + + if error: + from ..services.message_service import MessageService + MessageService.warning( + self, + "VNV Automation Failed", + f"VNV post-install automation encountered an error:\n\n{error}\n\n" + "You can complete these steps manually by following the guide at:\n" + "https://vivanewvegas.moddinglinked.com/wabbajack.html" + ) + + except Exception as e: + debug_print(f"ERROR: Failed to run VNV automation: {e}") + import traceback + debug_print(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" + msg = ( + f"Manual Proton Setup Required for {modlist_name}
" + "After Steam restarts, complete the following steps in Steam:
" + f"1. Locate the '{modlist_name}' entry in your Steam Library
" + "2. Right-click and select 'Properties'
" + "3. Switch to the 'Compatibility' tab
" + "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" + "5. Select 'Proton - Experimental' from the dropdown menu
" + "6. Close the Properties window
" + f"7. Launch '{modlist_name}' from your Steam Library
" + "8. Wait for Wabbajack to download its files and fully load
" + "9. Once Wabbajack has fully loaded, CLOSE IT completely and return here
" + "
Once you have completed ALL the steps above, click OK to continue." + f"{extra_warning}" + ) + reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") + if reply == QMessageBox.Yes: + if self.config_process and self.config_process.state() == QProcess.Running: + self.config_process.write(b'\n') + self.config_process.waitForBytesWritten(1000) + self._config_prompt_state = None + self._manual_steps_buffer = [] + else: + # User clicked Cancel or closed the dialog - cancel the workflow + self._safe_append_text("\nManual steps cancelled by user. Workflow stopped.") + # Terminate the configuration process + if self.config_process and self.config_process.state() == QProcess.Running: + self.config_process.terminate() + self.config_process.waitForFinished(2000) + # Re-enable all controls + 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) + dlg.setWindowTitle("Next Steps") + dlg.setModal(True) + layout = QVBoxLayout(dlg) + label = QLabel(message) + label.setWordWrap(True) + layout.addWidget(label) + btn_row = QHBoxLayout() + btn_return = QPushButton("Return") + btn_exit = QPushButton("Exit") + btn_row.addWidget(btn_return) + btn_row.addWidget(btn_exit) + layout.addLayout(btn_row) + def on_return(): + dlg.accept() + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(0) + def on_exit(): + QApplication.quit() + btn_return.clicked.connect(on_return) + 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: + return "unknown time" + + elapsed_seconds = time.time() - self._workflow_start_time + elapsed_minutes = int(elapsed_seconds // 60) + elapsed_seconds_remainder = int(elapsed_seconds % 60) + + if elapsed_minutes > 0: + if elapsed_minutes == 1: + return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" + 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 d8f4b91..3549b22 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist.py +++ b/jackify/frontends/gui/screens/configure_new_modlist.py @@ -28,6 +28,11 @@ 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 .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 logger = logging.getLogger(__name__) @@ -38,681 +43,13 @@ def debug_print(message): if config_handler.get('debug_mode', False): print(message) -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): - super().__init__() - self.cli_path = cli_path - self.game_type = game_type - self.project_root = project_root - self.log_path = log_path - self.mode = mode - self.modlist_name = modlist_name - self.install_dir = install_dir - self.download_dir = download_dir - def run(self): - # CRITICAL: Use safe Python executable to prevent AppImage recursive spawning - from jackify.backend.handlers.subprocess_utils import get_safe_python_executable - python_exe = get_safe_python_executable() - - if self.mode == 'list-modlists': - cmd = [python_exe, self.cli_path, '--install-modlist', '--list-modlists', '--game-type', self.game_type] - elif self.mode == 'install': - cmd = [python_exe, self.cli_path, '--install-modlist', '--install', '--modlist-name', self.modlist_name, '--install-dir', self.install_dir, '--download-dir', self.download_dir, '--game-type', self.game_type] - else: - self.result.emit([], '[ModlistFetchThread] Unknown mode') - return - try: - with open(self.log_path, 'a') as logf: - logf.write(f"\n[Modlist Fetch CMD] {cmd}\n") - # Use clean subprocess environment to prevent AppImage variable inheritance - from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env - env = get_clean_subprocess_env() - proc = subprocess.Popen(cmd, cwd=self.project_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) - stdout, stderr = proc.communicate() - logf.write(f"[stdout]\n{stdout}\n[stderr]\n{stderr}\n") - if proc.returncode == 0: - modlist_ids = [line.strip() for line in stdout.splitlines() if line.strip()] - self.result.emit(modlist_ids, '') - else: - self.result.emit([], stderr) - except Exception as e: - self.result.emit([], str(e)) - -class SelectionDialog(QDialog): - def __init__(self, title, items, parent=None): - super().__init__(parent) - self.setWindowTitle(title) - self.setModal(True) - self.setMinimumWidth(350) - self.setMinimumHeight(300) - layout = QVBoxLayout(self) - self.list_widget = QListWidget() - self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - for item in items: - QListWidgetItem(item, self.list_widget) - layout.addWidget(self.list_widget) - self.selected_item = None - self.list_widget.itemClicked.connect(self.on_item_clicked) - def on_item_clicked(self, item): - self.selected_item = item.text() - self.accept() - -class ConfigureNewModlistScreen(QWidget): +class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, QWidget): resize_request = Signal(str) - def __init__(self, stacked_widget=None, main_menu_index=0): - super().__init__() - debug_print("DEBUG: ConfigureNewModlistScreen __init__ called") - self.stacked_widget = stacked_widget - self.main_menu_index = main_menu_index - self.debug = DEBUG_BORDERS - self.online_modlists = {} # {game_type: [modlist_dict, ...]} - self.modlist_details = {} # {modlist_name: modlist_dict} - - # Initialize services early - from jackify.backend.services.api_key_service import APIKeyService - from jackify.backend.services.resolution_service import ResolutionService - from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService - from jackify.backend.handlers.config_handler import ConfigHandler - self.api_key_service = APIKeyService() - self.resolution_service = ResolutionService() - self.config_handler = ConfigHandler() - self.protontricks_service = ProtontricksDetectionService() - - # Path for workflow log - self.refresh_paths() - - # Scroll tracking for professional auto-scroll behavior - self._user_manually_scrolled = False - self._was_at_bottom = True - - # Time tracking for workflow completion - self._workflow_start_time = None - - # Initialize progress reporting components - self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) - self.progress_indicator.set_status("Ready to configure", 0) - self.file_progress_list = FileProgressList() - - # Create "Show Details" checkbox - self.show_details_checkbox = QCheckBox("Show details") - self.show_details_checkbox.setChecked(False) # Start collapsed - self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") - self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) - - main_overall_vbox = QVBoxLayout(self) - main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin - if self.debug: - self.setStyleSheet("border: 2px solid magenta;") - - # --- Header (title, description) --- - header_layout = QVBoxLayout() - header_layout.setSpacing(1) # Reduce spacing between title and description - # Title (no logo) - title = QLabel("Configure New Modlist") - title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") - title.setAlignment(Qt.AlignHCenter) - title.setMaximumHeight(30) # Force compact height - header_layout.addWidget(title) - # Description - desc = QLabel( - "This screen allows you to configure a newly installed modlist in Jackify. " - "Set up your Steam shortcut, restart Steam, and complete post-install configuration." - ) - desc.setWordWrap(True) - desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") - desc.setAlignment(Qt.AlignHCenter) - desc.setMaximumHeight(40) # Force compact height for description - header_layout.addWidget(desc) - header_widget = QWidget() - header_widget.setLayout(header_layout) - header_widget.setMaximumHeight(75) # Match other screens - if self.debug: - header_widget.setStyleSheet("border: 2px solid pink;") - header_widget.setToolTip("HEADER_SECTION") - main_overall_vbox.addWidget(header_widget) - - # --- Upper section: user-configurables (left) + process monitor (right) --- - upper_hbox = QHBoxLayout() - upper_hbox.setContentsMargins(0, 0, 0, 0) - upper_hbox.setSpacing(16) - # Left: user-configurables (form and controls) - user_config_vbox = QVBoxLayout() - user_config_vbox.setAlignment(Qt.AlignTop) - # --- [Options] header (moved here for alignment) --- - 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) - user_config_vbox.addWidget(options_header) - # --- Install/Downloads Dir/API Key (reuse Tuxborn style) --- - form_grid = QGridLayout() - form_grid.setHorizontalSpacing(12) - form_grid.setVerticalSpacing(6) # Reduced from 8 to 6 for better readability - form_grid.setContentsMargins(0, 0, 0, 0) - # Modlist Name (NEW FIELD) - modlist_name_label = QLabel("Modlist Name:") - self.modlist_name_edit = QLineEdit() - self.modlist_name_edit.setMaximumHeight(25) # Force compact height - form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addWidget(self.modlist_name_edit, 0, 1) - # Install Dir - install_dir_label = QLabel("ModOrganizer.exe Path:") - self.install_dir_edit = QLineEdit("/path/to/Modlist/ModOrganizer.exe") - self.install_dir_edit.setMaximumHeight(25) # Force compact height - browse_install_btn = QPushButton("Browse") - browse_install_btn.clicked.connect(self.browse_install_dir) - install_dir_hbox = QHBoxLayout() - install_dir_hbox.addWidget(self.install_dir_edit) - install_dir_hbox.addWidget(browse_install_btn) - form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(install_dir_hbox, 1, 1) - # --- Resolution Dropdown --- - resolution_label = QLabel("Resolution:") - self.resolution_combo = QComboBox() - self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.resolution_combo.addItem("Leave unchanged") - self.resolution_combo.addItems([ - "1280x720", - "1280x800 (Steam Deck)", - "1366x768", - "1440x900", - "1600x900", - "1600x1200", - "1680x1050", - "1920x1080", - "1920x1200", - "2048x1152", - "2560x1080", - "2560x1440", - "2560x1600", - "3440x1440", - "3840x1600", - "3840x2160", - "3840x2400", - "5120x1440", - "5120x2160", - "7680x4320" - ]) - form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - - # Load saved resolution if available - saved_resolution = self.resolution_service.get_saved_resolution() - is_steam_deck = False - try: - if os.path.exists('/etc/os-release'): - with open('/etc/os-release') as f: - if 'steamdeck' in f.read().lower(): - is_steam_deck = True - except Exception: - pass - if saved_resolution: - combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] - resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) - self.resolution_combo.setCurrentIndex(resolution_index) - debug_print(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())] - if "1280x800 (Steam Deck)" in combo_items: - self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) - else: - self.resolution_combo.setCurrentIndex(0) - # Otherwise, default is 'Leave unchanged' (index 0) - - # Horizontal layout for resolution dropdown and auto-restart checkbox - resolution_and_restart_layout = QHBoxLayout() - resolution_and_restart_layout.setSpacing(12) - - # Resolution dropdown (made smaller) - self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing - resolution_and_restart_layout.addWidget(self.resolution_combo) - - # Add stretch to push checkbox to the right - resolution_and_restart_layout.addStretch() - - # Auto-accept Steam restart checkbox (right-aligned) - self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart") - self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session - self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended configuration") - resolution_and_restart_layout.addWidget(self.auto_restart_checkbox) - - # Update the form grid to use the combined layout - form_grid.addLayout(resolution_and_restart_layout, 2, 1) - - form_section_widget = QWidget() - form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - form_section_widget.setLayout(form_grid) - form_section_widget.setMinimumHeight(120) # Reduced to match compact form - form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown - if self.debug: - form_section_widget.setStyleSheet("border: 2px solid blue;") - form_section_widget.setToolTip("FORM_SECTION") - user_config_vbox.addWidget(form_section_widget) - # --- Buttons --- - btn_row = QHBoxLayout() - btn_row.setAlignment(Qt.AlignHCenter) - self.start_btn = QPushButton("Start Configuration") - btn_row.addWidget(self.start_btn) - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.cancel_and_cleanup) - btn_row.addWidget(cancel_btn) - user_config_widget = QWidget() - user_config_widget.setLayout(user_config_vbox) - if self.debug: - user_config_widget.setStyleSheet("border: 2px solid orange;") - user_config_widget.setToolTip("USER_CONFIG_WIDGET") - # Right: Tabbed interface with Activity and Process Monitor - # Both tabs are always available, user can switch between them - self.process_monitor = QTextEdit() - self.process_monitor.setReadOnly(True) - self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) - self.process_monitor.setMinimumSize(QSize(300, 20)) - self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") - self.process_monitor_heading = QLabel("[Process Monitor]") - self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") - self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - process_vbox = QVBoxLayout() - process_vbox.setContentsMargins(0, 0, 0, 0) - process_vbox.setSpacing(2) - process_vbox.addWidget(self.process_monitor_heading) - process_vbox.addWidget(self.process_monitor) - process_monitor_widget = QWidget() - process_monitor_widget.setLayout(process_vbox) - process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - if self.debug: - process_monitor_widget.setStyleSheet("border: 2px solid purple;") - process_monitor_widget.setToolTip("PROCESS_MONITOR") - self.process_monitor_widget = process_monitor_widget - - # Set up File Progress List (Activity tab) - self.file_progress_list.setMinimumSize(QSize(300, 20)) - self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - # Create tab widget to hold both Activity and Process Monitor - self.activity_tabs = QTabWidget() - self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") - self.activity_tabs.setContentsMargins(0, 0, 0, 0) - self.activity_tabs.setDocumentMode(False) - self.activity_tabs.setTabPosition(QTabWidget.North) - if self.debug: - self.activity_tabs.setStyleSheet("border: 2px solid cyan;") - self.activity_tabs.setToolTip("ACTIVITY_TABS") - - # Add both widgets as tabs - self.activity_tabs.addTab(self.file_progress_list, "Activity") - self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") - - upper_hbox.addWidget(user_config_widget, stretch=11) - upper_hbox.addWidget(self.activity_tabs, stretch=9) - upper_hbox.setAlignment(Qt.AlignTop) - upper_section_widget = QWidget() - upper_section_widget.setLayout(upper_hbox) - # Use Fixed size policy for consistent height - upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown - if self.debug: - upper_section_widget.setStyleSheet("border: 2px solid green;") - upper_section_widget.setToolTip("UPPER_SECTION") - main_overall_vbox.addWidget(upper_section_widget) - - # Status banner with progress indicator and "Show details" toggle - banner_row = QHBoxLayout() - banner_row.setContentsMargins(0, 0, 0, 0) - banner_row.setSpacing(8) - banner_row.addWidget(self.progress_indicator, 1) - banner_row.addStretch() - banner_row.addWidget(self.show_details_checkbox) - banner_row_widget = QWidget() - banner_row_widget.setLayout(banner_row) - banner_row_widget.setMaximumHeight(45) # Compact height - banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - main_overall_vbox.addWidget(banner_row_widget) - - # Console output area (shown when "Show details" is checked) - 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) # Hidden by default (compact mode) - if self.debug: - self.console.setStyleSheet("border: 2px solid yellow;") - self.console.setToolTip("CONSOLE") - - # Set up scroll tracking for professional auto-scroll behavior - self._setup_scroll_tracking() - - # Wrap button row in widget for debug borders - btn_row_widget = QWidget() - btn_row_widget.setLayout(btn_row) - btn_row_widget.setMaximumHeight(50) - if self.debug: - btn_row_widget.setStyleSheet("border: 2px solid red;") - btn_row_widget.setToolTip("BUTTON_ROW") - - # Create a container that holds console + button row with proper spacing - console_and_buttons_widget = QWidget() - console_and_buttons_layout = QVBoxLayout() - console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) - console_and_buttons_layout.setSpacing(8) - - console_and_buttons_layout.addWidget(self.console, stretch=1) - console_and_buttons_layout.addWidget(btn_row_widget) - - console_and_buttons_widget.setLayout(console_and_buttons_layout) - console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - console_and_buttons_widget.setFixedHeight(50) # Lock to button row height when console is hidden - if self.debug: - console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") - console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") - # Add without stretch to prevent squashing upper section - main_overall_vbox.addWidget(console_and_buttons_widget) - - # Store references for toggle functionality - self.console_and_buttons_widget = console_and_buttons_widget - self.console_and_buttons_layout = console_and_buttons_layout - self.main_overall_vbox = main_overall_vbox - - self.setLayout(main_overall_vbox) - - # --- Process Monitor (right) --- - self.process = None - self.log_timer = None - self.last_log_pos = 0 - # --- Process Monitor Timer --- - self.top_timer = QTimer(self) - self.top_timer.timeout.connect(self.update_top_panel) - self.top_timer.start(2000) - # --- Start Configuration button --- - self.start_btn.clicked.connect(self.validate_and_start_configure) - - # Initialize empty controls list - will be populated after UI is built - self._actionable_controls = [] - - # 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 = [ - # Main action button - self.start_btn, - # Form fields - self.modlist_name_edit, - self.install_dir_edit, - # Resolution controls - self.resolution_combo, - # Checkboxes - 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 - # Remove any fixed height constraints to allow natural stretching - 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() - scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) - 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() - # Use tolerance to account for rounding and rapid updates - self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 - - # If user manually scrolls to bottom, reset manual scroll flag - if self._was_at_bottom and self._user_manually_scrolled: - # Small delay to allow user to scroll away if they want - 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 - try: - parent = self.parent() - while parent and not isinstance(parent, QMainWindow): - parent = parent.parent() - if parent and isinstance(parent, QMainWindow): - main_window = parent - except Exception: - pass - - if is_checked: - # Show console - self.console.setVisible(True) - self.console.setMinimumHeight(200) - self.console.setMaximumHeight(16777215) - self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - # Allow expansion when console is visible - if hasattr(self, 'console_and_buttons_widget'): - self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.console_and_buttons_widget.setMinimumHeight(0) - self.console_and_buttons_widget.setMaximumHeight(16777215) - self.console_and_buttons_widget.updateGeometry() - - # Stop CPU tracking when showing console - self.file_progress_list.stop_cpu_tracking() - - # Expand window - if main_window: - try: - from PySide6.QtCore import QSize - # On Steam Deck, keep fullscreen; on other systems, set normal window state - if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): - main_window.showNormal() - main_window.setMaximumHeight(16777215) - main_window.setMinimumHeight(0) - expanded_min = 900 - current_size = main_window.size() - target_height = max(expanded_min, 900) - main_window.setMinimumHeight(expanded_min) - main_window.resize(current_size.width(), target_height) - self.main_overall_vbox.invalidate() - self.updateGeometry() - except Exception: - pass - else: - # Hide console - self.console.setVisible(False) - self.console.setMinimumHeight(0) - self.console.setMaximumHeight(0) - self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - - # Lock height when console is hidden - if hasattr(self, 'console_and_buttons_widget'): - self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.console_and_buttons_widget.setFixedHeight(50) - self.console_and_buttons_widget.updateGeometry() - - # CPU tracking will start when user clicks "Start Configuration", not here - # (Removed to avoid blocking showEvent) - - # Collapse window - if main_window: - try: - from PySide6.QtCore import QSize - # Only set minimum size - DO NOT RESIZE - # On Steam Deck, keep fullscreen; on other systems, set normal window state - if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): - main_window.showNormal() - main_window.setMaximumSize(QSize(16777215, 16777215)) - set_responsive_minimum(main_window, min_width=960, min_height=420) - # DO NOT resize - let window stay at current size - except Exception: - pass - - def _handle_progress_update(self, text): - """Handle progress updates - update console, activity window, and progress indicator""" - # Always append to console - self._safe_append_text(text) - - # Parse the message to update UI widgets - message_lower = text.lower() - - # Update progress indicator based on key status messages - if "creating steam shortcut" in message_lower: - self.progress_indicator.set_status("Creating Steam shortcut...", 10) - elif "restarting steam" in message_lower or "restart steam" in message_lower: - self.progress_indicator.set_status("Restarting Steam...", 20) - elif "steam restart" in message_lower and "success" in message_lower: - self.progress_indicator.set_status("Steam restarted successfully", 30) - elif "creating proton prefix" in message_lower or "prefix creation" in message_lower: - self.progress_indicator.set_status("Creating Proton prefix...", 50) - elif "prefix created" in message_lower or "prefix creation" in message_lower and "success" in message_lower: - self.progress_indicator.set_status("Proton prefix created", 70) - elif "applying curated registry" in message_lower or "registry" in message_lower: - self.progress_indicator.set_status("Applying registry files...", 75) - elif "installing wine components" in message_lower or "wine component" in message_lower: - self.progress_indicator.set_status("Installing wine components...", 80) - elif "dotnet" in message_lower and "fix" in message_lower: - self.progress_indicator.set_status("Applying dotnet fixes...", 85) - elif "setting ownership" in message_lower or "ownership and permissions" in message_lower: - self.progress_indicator.set_status("Setting permissions...", 90) - elif "verifying" in message_lower: - self.progress_indicator.set_status("Verifying setup...", 95) - elif "steam integration complete" in message_lower or "configuration complete" in message_lower: - self.progress_indicator.set_status("Configuration complete", 100) - - # Update activity window with generic configuration status - # Only update if message contains meaningful progress (not blank lines or separators) - if text.strip() and not text.strip().startswith('='): - # Show generic "Configuring modlist..." in activity window - self.file_progress_list.update_files( - [], - current_phase="Configuring", - summary_info={"current": 1, "total": 1, "label": "Setting up modlist"} - ) - - def _safe_append_text(self, text): - """Append text with professional auto-scroll behavior""" - # Write all messages to log file - self._write_to_log_file(text) - - scrollbar = self.console.verticalScrollBar() - # Check if user was at bottom BEFORE adding text - was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance - - # Add the text - self.console.append(text) - - # Auto-scroll if user was at bottom and hasn't manually scrolled - # Re-check bottom state after text addition for better reliability - if (was_at_bottom and not self._user_manually_scrolled) or \ - (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): - scrollbar.setValue(scrollbar.maximum()) - # Ensure user can still manually scroll up during rapid updates - if scrollbar.value() == scrollbar.maximum(): - self._was_at_bottom = True - - def _write_to_log_file(self, message): - """Write message to workflow log file with timestamp""" - try: - from datetime import datetime - 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") - except Exception: - # Logging should never break the workflow - pass - - 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) - - def go_back(self): - """Navigate back to main menu and restore window size""" - # Emit collapse signal to restore compact mode - self.resize_request.emit('collapse') - - # Restore window size before navigating away - try: - main_window = self.window() - if main_window: - from PySide6.QtCore import QSize - from ..utils import apply_window_size_and_position - - # Only set minimum size - DO NOT RESIZE - main_window.setMaximumSize(QSize(16777215, 16777215)) - set_responsive_minimum(main_window, min_width=960, min_height=420) - # DO NOT resize - let window stay at current size - except Exception: - pass - - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(self.main_menu_index) - - def cleanup_processes(self): - """Clean up any running processes when the window closes or is cancelled""" - # Stop CPU tracking if active - if hasattr(self, 'file_progress_list'): - self.file_progress_list.stop_cpu_tracking() - - # Clean up automated prefix thread if running - if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread.isRunning(): - self.automated_prefix_thread.terminate() - self.automated_prefix_thread.wait(1000) - - # Clean up configuration thread if running - if hasattr(self, 'config_thread') and self.config_thread.isRunning(): - self.config_thread.terminate() - self.config_thread.wait(1000) def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" self.cleanup_processes() + self.collapse_show_details_before_leave() self.go_back() def showEvent(self, event): @@ -729,735 +66,13 @@ class ConfigureNewModlistScreen(QWidget): self.show_details_checkbox.blockSignals(False) # Force collapsed state - self._toggle_console_visibility(False) + # Set console to hidden state without emitting signals + self.console.setVisible(False) + self.resize_request.emit("compact") except Exception as e: # If initial collapse fails, log but don't crash print(f"Warning: Failed to set initial collapsed state: {e}") - def update_top_panel(self): - try: - result = subprocess.run([ - "ps", "-eo", "pcpu,pmem,comm,args" - ], stdout=subprocess.PIPE, text=True, timeout=2) - lines = result.stdout.splitlines() - header = "CPU%\tMEM%\tCOMMAND" - filtered = [header] - process_rows = [] - for line in lines[1:]: - line_lower = line.lower() - # Include jackify-engine and related heavy processes - heavy_processes = ( - "jackify-engine" in line_lower or "7zz" in line_lower or - "texconv" in line_lower or "wine" in line_lower or - "wine64" in line_lower or "protontricks" in line_lower - ) - # Include Python processes running configure-modlist command - configure_processes = ( - "python" in line_lower and "configure-modlist" in line_lower - ) - # Include QProcess processes that might be configuration-related - qprocess_config = ( - hasattr(self, 'config_process') and - self.config_process and - self.config_process.state() == QProcess.Running and - ("python" in line_lower or "jackify" in line_lower) - ) - - if (heavy_processes or configure_processes or qprocess_config) and "jackify-gui.py" not in line_lower: - cols = line.strip().split(None, 3) - if len(cols) >= 3: - process_rows.append(cols) - process_rows.sort(key=lambda x: float(x[0]), reverse=True) - for cols in process_rows: - filtered.append('\t'.join(cols)) - if len(filtered) == 1: - filtered.append("[No Jackify-related processes found]") - self.process_monitor.setPlainText('\n'.join(filtered)) - 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: - if self.protontricks_service.is_bundled_mode(): - return True - - is_installed, installation_type, details = self.protontricks_service.detect_protontricks() - - if not is_installed: - # Show protontricks error dialog - from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog - dialog = ProtontricksErrorDialog(self.protontricks_service, self) - result = dialog.exec() - - if result == QDialog.Rejected: - return False - - # Re-check after dialog - is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False) - return is_installed - - return True - - except Exception as e: - print(f"Error checking protontricks: {e}") - from jackify.frontends.gui.services.message_service import MessageService - MessageService.warning(self, "Protontricks Check Failed", - f"Unable to verify protontricks installation: {e}\n\n" - "Continuing anyway, but some features may not work correctly.") - return True # Continue anyway - - def validate_and_start_configure(self): - # Reload config to pick up any settings changes made in Settings dialog - self.config_handler.reload_config() - - # Check protontricks before proceeding - if not self._check_protontricks(): - return - - # 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) - - # Validate ModOrganizer.exe path - mo2_path = self.install_dir_edit.text().strip() - from jackify.frontends.gui.services.message_service import MessageService - if not mo2_path: - MessageService.warning(self, "Missing Path", "Please specify the path to ModOrganizer.exe", safety_level="low") - return - if not os.path.isfile(mo2_path): - MessageService.warning(self, "Invalid Path", "The specified path does not point to a valid file", safety_level="low") - return - if not mo2_path.endswith('ModOrganizer.exe'): - MessageService.warning(self, "Invalid File", "The specified file is not ModOrganizer.exe", safety_level="low") - return - - # Start time tracking - self._workflow_start_time = time.time() - - # Initialize progress indicator - self.progress_indicator.set_status("Preparing to configure...", 0) - - # Start CPU tracking - self.file_progress_list.start_cpu_tracking() - - # Disable controls during configuration (after validation passes) - self._disable_controls_during_operation() - - # Validate modlist name - modlist_name = self.modlist_name_edit.text().strip() - if not modlist_name: - MessageService.warning(self, "Missing Name", "Please specify a name for your modlist", safety_level="low") - self._enable_controls_after_operation() - return - - # Handle resolution saving - resolution = self.resolution_combo.currentText() - if resolution and resolution != "Leave unchanged": - success = self.resolution_service.save_resolution(resolution) - if success: - debug_print(f"DEBUG: Resolution saved successfully: {resolution}") - else: - debug_print("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") - - # 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 - # This ensures Proton version and winetricks settings are current - 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() - modlist_name = self.modlist_name_edit.text().strip() - mo2_exe_path = self.install_dir_edit.text().strip() - 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") - return - - # 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...") - - # 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""" - 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...") - - # Disable the start button to prevent multiple workflows - self.start_btn.setEnabled(False) - - # Create and start the automated prefix thread - class AutomatedPrefixThread(QThread): - progress_update = Signal(str) - workflow_complete = Signal(object) # Will emit the result tuple - error_occurred = Signal(str) - - def __init__(self, modlist_name, install_dir, mo2_exe_path, steamdeck): - super().__init__() - self.modlist_name = modlist_name - self.install_dir = install_dir - self.mo2_exe_path = mo2_exe_path - self.steamdeck = steamdeck - - def run(self): - try: - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - - # Initialize the automated prefix service - prefix_service = AutomatedPrefixService() - - # Define progress callback for GUI updates - def progress_callback(message): - self.progress_update.emit(message) - - # Run the automated workflow (this contains the blocking operations) - result = prefix_service.run_working_workflow( - self.modlist_name, self.install_dir, self.mo2_exe_path, - progress_callback, steamdeck=self.steamdeck - ) - - # Emit the result - self.workflow_complete.emit(result) - - except Exception as e: - self.error_occurred.emit(str(e)) - - # Detect Steam Deck once using centralized service - from jackify.backend.services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - _is_steamdeck = platform_service.is_steamdeck - - # Create and start the thread - self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path, _is_steamdeck) - self.automated_prefix_thread.progress_update.connect(self._handle_progress_update) - self.automated_prefix_thread.workflow_complete.connect(self._on_automated_prefix_complete) - 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: - # Handle the result - check for conflicts - if isinstance(result, tuple) and len(result) == 4: - if result[0] == "CONFLICT": - # Conflict detected - show conflict resolution dialog - conflicts = result[1] - self.show_shortcut_conflict_dialog(conflicts) - return - else: - # Normal result - success, prefix_path, new_appid, last_timestamp = result - 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, passing the last timestamp - 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(), - last_timestamp) - 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) - elif isinstance(result, tuple) and len(result) == 3: - # Fallback for old format (backward compatibility) - success, prefix_path, new_appid = result - 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(), - 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: - 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): - """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" - ) - - self._enable_controls_after_operation() - - def show_shortcut_conflict_dialog(self, conflicts): - """Show dialog to resolve shortcut name conflicts""" - conflict_names = [c['name'] for c in conflicts] - conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" - - modlist_name = self.modlist_name_edit.text().strip() - - # Create dialog with Jackify styling - from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout - from PySide6.QtCore import Qt - - dialog = QDialog(self) - dialog.setWindowTitle("Steam Shortcut Conflict") - dialog.setModal(True) - dialog.resize(450, 180) - - # Apply Jackify dark theme styling - dialog.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - color: #ffffff; - } - QLabel { - color: #ffffff; - font-size: 14px; - padding: 10px 0px; - } - QLineEdit { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px; - font-size: 14px; - selection-background-color: #3fd0ea; - } - QLineEdit:focus { - border-color: #3fd0ea; - } - QPushButton { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px 16px; - font-size: 14px; - min-width: 120px; - } - QPushButton:hover { - background-color: #505050; - border-color: #3fd0ea; - } - QPushButton:pressed { - background-color: #303030; - } - """) - - layout = QVBoxLayout(dialog) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Conflict message - conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") - layout.addWidget(conflict_label) - - # Text input for new name - name_input = QLineEdit(modlist_name) - name_input.selectAll() - layout.addWidget(name_input) - - # Buttons - button_layout = QHBoxLayout() - button_layout.setSpacing(10) - - create_button = QPushButton("Create with New Name") - cancel_button = QPushButton("Cancel") - - button_layout.addStretch() - button_layout.addWidget(cancel_button) - button_layout.addWidget(create_button) - layout.addLayout(button_layout) - - # Connect signals - def on_create(): - new_name = name_input.text().strip() - if new_name and new_name != modlist_name: - dialog.accept() - # Retry workflow with new name - self.retry_automated_workflow_with_new_name(new_name) - elif new_name == modlist_name: - # Same name - show warning - from jackify.backend.services.message_service import MessageService - MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") - else: - # Empty name - from jackify.backend.services.message_service import MessageService - MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") - - def on_cancel(): - dialog.reject() - self._safe_append_text("Shortcut creation cancelled by user") - - create_button.clicked.connect(on_create) - cancel_button.clicked.connect(on_cancel) - - # Make Enter key work - name_input.returnPressed.connect(on_create) - - 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 - original_name = self.modlist_name_edit.text() - self.modlist_name_edit.setText(new_name) - - # Restart the automated workflow - 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()) - - # Old CLI-based handlers removed - now using backend service directly - - # Manual steps methods removed - now using automated prefix service - """Validate that manual steps were actually completed and handle retry logic""" - modlist_name = self.modlist_name_edit.text().strip() - 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() - mo2_exe_path = self.install_dir_edit.text().strip() - - # 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...") - from jackify.backend.handlers.shortcut_handler import ShortcutHandler - from jackify.backend.services.platform_detection_service import PlatformDetectionService - - platform_service = PlatformDetectionService.get_instance() - shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck) - current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) - - if not current_appid or not current_appid.isdigit(): - self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") - self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") - self.handle_validation_failure("Could not find Steam shortcut") - 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}") - - # Check manual steps completion (same validation as Tuxborn) - validation_passed = True - missing_details = [] - - # Check 1: Proton version - proton_ok = False - try: - from jackify.backend.handlers.modlist_handler import ModlistHandler - from jackify.backend.handlers.path_handler import PathHandler - - # Initialize ModlistHandler with correct parameters - path_handler = PathHandler() - - # Use centralized Steam Deck detection - from jackify.backend.services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - - modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False) - - # Set required properties manually after initialization - modlist_handler.modlist_dir = install_dir - modlist_handler.appid = current_appid # Use the re-detected AppID - modlist_handler.game_var = "skyrimspecialedition" # Default for now - - # Set compat_data_path for Proton detection using the re-detected AppID - compat_data_path_str = path_handler.find_compat_data(current_appid) - if compat_data_path_str: - from pathlib import Path - modlist_handler.compat_data_path = Path(compat_data_path_str) - - # Check Proton version using the re-detected AppID - self._safe_append_text(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}'") - - if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower(): - self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}") - proton_ok = True - else: - self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") - else: - self._safe_append_text("Error: Could not detect Proton version from any source") - - except Exception as e: - self._safe_append_text(f"Error checking Proton version: {e}") - - if not proton_ok: - validation_passed = False - missing_details.append("Error: Proton version not set to 'Proton - Experimental'") - - # Check 2: Compatdata directory exists - compatdata_ok = False - try: - from jackify.backend.handlers.path_handler import PathHandler - path_handler = PathHandler() - self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...") - prefix_path_str = path_handler.find_compat_data(current_appid) - self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'") - - if prefix_path_str: - from pathlib import Path - prefix_path = Path(prefix_path_str) - if prefix_path.exists() and prefix_path.is_dir(): - self._safe_append_text(f"Compatdata directory found: {prefix_path_str}") - compatdata_ok = True - elif prefix_path.exists(): - self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") - else: - self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") - else: - self._safe_append_text(f"ERROR: No compatdata directory found for AppID {current_appid}") - except Exception as e: - self._safe_append_text(f"Error checking compatdata: {e}") - - if not compatdata_ok: - validation_passed = False - missing_details.append("Error: Modlist was not launched from Steam (no compatdata directory)") - - if validation_passed: - self._safe_append_text("Manual steps validation passed!") - self._safe_append_text("Continuing configuration with updated AppID...") - - # Continue with configuration (same as Tuxborn) - self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) - else: - missing_text = "\n".join(missing_details) - self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") - self.handle_validation_failure(missing_text) - - 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: - # Get resolution from UI - resolution = self.resolution_combo.currentText() - resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else None - - # Update the context with the new AppID (same format as manual steps) - mo2_exe_path = self.install_dir_edit.text().strip() - updated_context = { - 'name': modlist_name, - 'path': install_dir, - 'mo2_exe_path': mo2_exe_path, - 'modlist_value': None, - 'modlist_source': None, - 'resolution': resolution_value, - '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': '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}") - - # Create new config thread with updated context - class ConfigThread(QThread): - progress_update = Signal(str) - configuration_complete = Signal(bool, str, str, bool) - error_occurred = Signal(str) - - def __init__(self, context): - super().__init__() - self.context = context - - 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 - system_info = SystemInfo(is_steamdeck=False) - modlist_service = ModlistService(system_info) - - # 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='skyrim', # Default for now - 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') or get_resolution_fallback(None), - skip_confirmation=True - ) - - # 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 - self.progress_update.emit("") - self.progress_update.emit("=== Configuration Phase ===") - self.progress_update.emit("") - self.progress_update.emit("Starting modlist 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) - self.config_thread.progress_update.connect(self._handle_progress_update) - 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 - mo2_exe_path = self.install_dir_edit.text().strip() - resolution = self.resolution_combo.currentText() - - updated_context = { - 'name': modlist_name, - 'path': install_dir, - 'mo2_exe_path': mo2_exe_path, - 'resolution': resolution.split()[0] if resolution != "Leave unchanged" else None, - 'skip_confirmation': True, - 'manual_steps_completed': True, # Mark as completed - '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}") - - # Create new config thread with updated context (same as Tuxborn) - from PySide6.QtCore import QThread, Signal - - class ConfigThread(QThread): - progress_update = Signal(str) - configuration_complete = Signal(bool, str, str, bool) - error_occurred = Signal(str) - - def __init__(self, context): - super().__init__() - self.context = context - - 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 - system_info = SystemInfo(is_steamdeck=False) - modlist_service = ModlistService(system_info) - - # 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='skyrim', # Default for configure new - nexus_api_key='', # Not needed for configuration - modlist_value='', # Not needed for existing modlist - modlist_source='existing', - resolution=self.context.get('resolution') or get_resolution_fallback(None), - skip_confirmation=True - ) - - # 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, 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 manual steps should be done - self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") - - # Call the working configuration service method - self.progress_update.emit("Starting configuration with backend service...") - - success = 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 success: - self.error_occurred.emit("Configuration failed - check logs for details") - - except Exception as e: - import traceback - error_msg = f"Configuration error: {e}\n{traceback.format_exc()}" - self.error_occurred.emit(error_msg) - - # Create and start the configuration thread - self.config_thread = ConfigThread(updated_context) - self.config_thread.progress_update.connect(self._handle_progress_update) - 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}") - MessageService.critical(self, "Configuration Error", f"Failed to continue configuration: {e}", safety_level="medium") - def on_configuration_complete(self, success, message, modlist_name, enb_detected=False): """Handle configuration completion (same as Tuxborn)""" # Re-enable all controls when workflow completes @@ -1507,152 +122,9 @@ class ConfigureNewModlistScreen(QWidget): self._safe_append_text(f"Configuration error: {error_message}") MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") - 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") - # Show manual steps dialog again - extra_warning = "" - if self._manual_steps_retry_count >= 2: - extra_warning = "
It looks like you have not completed the manual steps yet. Please try again." - 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") - self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self.modlist_name_edit.text().strip()) - # Old CLI-based process finished handler removed - now using backend service callbacks - def _calculate_time_taken(self) -> str: - """Calculate and format the time taken for the workflow""" - if self._workflow_start_time is None: - return "unknown time" - - elapsed_seconds = time.time() - self._workflow_start_time - elapsed_minutes = int(elapsed_seconds // 60) - elapsed_seconds_remainder = int(elapsed_seconds % 60) - - if elapsed_minutes > 0: - if elapsed_minutes == 1: - return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds" - else: - return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" - else: - return f"{elapsed_seconds_remainder} seconds" - def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str): - """Check if VNV automation should run and execute if applicable - - Args: - modlist_name: Name of the installed modlist - install_dir: Installation directory path - """ - try: - from pathlib import Path - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - from jackify.backend.handlers.path_handler import PathHandler - - # Get paths first (needed for VNV detection) - install_path = Path(install_dir) - - # Quick check before importing more (pass install location for ModOrganizer.ini check) - if not should_offer_vnv_automation(modlist_name, install_path): - return - game_paths = PathHandler().find_vanilla_game_paths() - game_root = game_paths.get('Fallout New Vegas') - - if not game_root: - debug_print("DEBUG: VNV automation skipped - FNV game root not found") - return - - # Confirmation callback - show dialog to user - def confirmation_callback(description: str) -> bool: - from ..services.message_service import MessageService - reply = MessageService.question( - self, - "VNV Post-Install Automation", - description, - critical=False, - safety_level="medium" - ) - return reply == QMessageBox.Yes - - # Manual file callback for non-Premium users - def manual_file_callback(title: str, instructions: str) -> Optional[Path]: - from PySide6.QtWidgets import QFileDialog - from ..services.message_service import MessageService - - # Show instructions - MessageService.information(self, title, instructions) - - # Open file picker - file_path, _ = QFileDialog.getOpenFileName( - self, - title, - str(Path.home() / "Downloads"), - "All Files (*.*)" - ) - - if file_path: - return Path(file_path) - return None - - # Run automation - automation_ran, error = run_vnv_automation_if_applicable( - modlist_name=modlist_name, - modlist_install_location=install_path, - game_root=game_root, - ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), - progress_callback=None, # GUI doesn't need progress updates for post-install - manual_file_callback=manual_file_callback, - confirmation_callback=confirmation_callback - ) - - if error: - from ..services.message_service import MessageService - MessageService.warning( - self, - "VNV Automation Failed", - f"VNV post-install automation encountered an error:\n\n{error}\n\n" - "You can complete these steps manually by following the guide at:\n" - "https://vivanewvegas.moddinglinked.com/wabbajack.html" - ) - - except Exception as e: - debug_print(f"ERROR: Failed to run VNV automation: {e}") - import traceback - debug_print(f"Traceback: {traceback.format_exc()}") - - def show_next_steps_dialog(self, message): - dlg = QDialog(self) - dlg.setWindowTitle("Next Steps") - dlg.setModal(True) - layout = QVBoxLayout(dlg) - label = QLabel(message) - label.setWordWrap(True) - layout.addWidget(label) - btn_row = QHBoxLayout() - btn_return = QPushButton("Return") - btn_exit = QPushButton("Exit") - btn_row.addWidget(btn_return) - btn_row.addWidget(btn_exit) - layout.addLayout(btn_row) - def on_return(): - dlg.accept() - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(0) - def on_exit(): - QApplication.quit() - btn_return.clicked.connect(on_return) - btn_exit.clicked.connect(on_exit) - dlg.exec() def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" diff --git a/jackify/frontends/gui/screens/configure_new_modlist_console.py b/jackify/frontends/gui/screens/configure_new_modlist_console.py new file mode 100644 index 0000000..92afa81 --- /dev/null +++ b/jackify/frontends/gui/screens/configure_new_modlist_console.py @@ -0,0 +1,172 @@ +"""Console output management for ConfigureNewModlistScreen (Mixin).""" +import os +import re +import time + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QFileDialog + +from jackify.shared.progress_models import FileProgress, OperationType + + +class ConfigureNewModlistConsoleMixin: + """Mixin providing console output management for ConfigureNewModlistScreen.""" + + def _handle_progress_update(self, text): + """Handle progress updates - update console, activity window, and progress indicator.""" + self._safe_append_text(text) + + message_lower = text.lower() + + if "creating steam shortcut" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Creating Steam shortcut...", 10) + self.file_progress_list.update_or_add_item("__phase__", "Creating Steam shortcut...", 0.0) + elif "restarting steam" in message_lower or "restart steam" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Restarting Steam...", 20) + self.file_progress_list.update_or_add_item("__phase__", "Restarting Steam...", 0.0) + elif "steam restart" in message_lower and "success" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Steam restarted successfully", 30) + self.file_progress_list.update_or_add_item("__phase__", "Steam restarted", 0.0) + elif "creating proton prefix" in message_lower or "prefix creation" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Creating Proton prefix...", 50) + self.file_progress_list.update_or_add_item("__phase__", "Creating Proton prefix...", 0.0) + elif "prefix created" in message_lower or ("prefix creation" in message_lower and "success" in message_lower): + self._stop_component_install_pulse() + self.progress_indicator.set_status("Proton prefix created", 70) + self.file_progress_list.update_or_add_item("__phase__", "Proton prefix created", 0.0) + elif "applying curated registry" in message_lower or "registry" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Applying registry files...", 75) + self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0) + elif "installing wine components" in message_lower or "wine component" in message_lower: + self.progress_indicator.set_status("Installing wine components...", 80) + if not hasattr(self, '_component_install_timer') or not self._component_install_timer: + self._start_component_install_pulse() + comp_list = self._parse_wine_components_message(text) + if comp_list: + self._start_component_install_pulse_with_components(comp_list) + elif "wine components verified" in message_lower or "wine components installed" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Wine components installed", 85) + self.file_progress_list.update_or_add_item("__phase__", "Wine components installed", 0.0) + elif "dotnet" in message_lower and "fix" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Applying dotnet fixes...", 85) + self.file_progress_list.update_or_add_item("__phase__", "Applying dotnet fixes...", 0.0) + elif "setting ownership" in message_lower or "ownership and permissions" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Setting permissions...", 90) + self.file_progress_list.update_or_add_item("__phase__", "Setting permissions...", 0.0) + elif "verifying" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Verifying setup...", 95) + self.file_progress_list.update_or_add_item("__phase__", "Verifying setup...", 0.0) + elif "steam integration complete" in message_lower or "configuration complete" in message_lower: + self._stop_component_install_pulse() + self.progress_indicator.set_status("Configuration complete", 100) + self.file_progress_list.update_or_add_item("__phase__", "Configuration complete", 0.0) + + + def _safe_append_text(self, text): + """Append text with professional auto-scroll behavior""" + # Write all messages to log file + self._write_to_log_file(text) + + scrollbar = self.console.verticalScrollBar() + # Check if user was at bottom BEFORE adding text + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + + # Add the text + self.console.append(text) + + # Auto-scroll if user was at bottom and hasn't manually scrolled + # Re-check bottom state after text addition for better reliability + if (was_at_bottom and not self._user_manually_scrolled) or \ + (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): + scrollbar.setValue(scrollbar.maximum()) + # Ensure user can still manually scroll up during rapid updates + if scrollbar.value() == scrollbar.maximum(): + self._was_at_bottom = True + + + def _write_to_log_file(self, message): + """Write message to workflow log file with timestamp""" + try: + from datetime import datetime + 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") + except Exception: + # Logging should never break the workflow + pass + + + def _parse_wine_components_message(self, text: str): + """Extract list of wine component names from backend status message, or None.""" + if "installing wine components:" not in text.lower() and "installing wine components via protontricks:" not in text.lower(): + return None + match = re.search(r"installing wine components(?:\s+via protontricks)?:\s*(.+)", text, re.IGNORECASE) + if not match: + return None + raw = match.group(1).strip() + if not raw: + return None + return [c.strip() for c in raw.split(",") if c.strip()] + + def _start_component_install_pulse(self): + """Start pulsing Activity item for Wine component installation (single generic item).""" + self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) + if not getattr(self, '_component_install_timer', None): + self._component_install_timer = QTimer(self) + self._component_install_timer.timeout.connect(self._component_install_heartbeat) + self._component_install_timer.start(100) + self._component_install_start_time = time.time() + + def _start_component_install_pulse_with_components(self, components: list): + """Replace single item with one Activity entry per component, each with pulsing progress.""" + self._component_install_list = components + progresses = [ + FileProgress( + filename=f"Wine component: {comp}", + operation=OperationType.UNKNOWN, + percent=0.0, + ) + for comp in components + ] + self.file_progress_list.update_files(progresses, current_phase=None) + + def _component_install_heartbeat(self): + """Heartbeat to keep component install item(s) pulsing. Duration is shown in the progress banner only.""" + if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time: + return + if hasattr(self, '_component_install_list') and self._component_install_list: + progresses = [ + FileProgress( + filename=f"Wine component: {comp}", + operation=OperationType.UNKNOWN, + percent=0.0, + ) + for comp in self._component_install_list + ] + self.file_progress_list.update_files(progresses, current_phase=None) + else: + self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) + + def _stop_component_install_pulse(self): + """Stop the component install pulsing timer.""" + if hasattr(self, '_component_install_timer') and self._component_install_timer: + self._component_install_timer.stop() + self._component_install_timer = None + if hasattr(self, '_component_install_list'): + del self._component_install_list + + 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) + + diff --git a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py new file mode 100644 index 0000000..7706817 --- /dev/null +++ b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py @@ -0,0 +1,354 @@ +"""Dialog management for ConfigureNewModlistScreen (Mixin).""" +from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QFileDialog, QMessageBox, QApplication, QListWidget, QListWidgetItem +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QTextCursor +from pathlib import Path +from typing import Optional +import subprocess +from jackify.frontends.gui.services.message_service import MessageService + +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, cli_path, game_type, project_root, log_path, mode='list-modlists', modlist_name=None, install_dir=None, download_dir=None): + super().__init__() + self.cli_path = cli_path + self.game_type = game_type + self.project_root = project_root + self.log_path = log_path + self.mode = mode + self.modlist_name = modlist_name + self.install_dir = install_dir + self.download_dir = download_dir + def run(self): + # CRITICAL: Use safe Python executable to prevent AppImage recursive spawning + from jackify.backend.handlers.subprocess_utils import get_safe_python_executable + python_exe = get_safe_python_executable() + + if self.mode == 'list-modlists': + cmd = [python_exe, self.cli_path, '--install-modlist', '--list-modlists', '--game-type', self.game_type] + elif self.mode == 'install': + cmd = [python_exe, self.cli_path, '--install-modlist', '--install', '--modlist-name', self.modlist_name, '--install-dir', self.install_dir, '--download-dir', self.download_dir, '--game-type', self.game_type] + else: + self.result.emit([], '[ModlistFetchThread] Unknown mode') + return + try: + with open(self.log_path, 'a') as logf: + logf.write(f"\n[Modlist Fetch CMD] {cmd}\n") + # Use clean subprocess environment to prevent AppImage variable inheritance + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + env = get_clean_subprocess_env() + proc = subprocess.Popen(cmd, cwd=self.project_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) + stdout, stderr = proc.communicate() + logf.write(f"[stdout]\n{stdout}\n[stderr]\n{stderr}\n") + if proc.returncode == 0: + modlist_ids = [line.strip() for line in stdout.splitlines() if line.strip()] + self.result.emit(modlist_ids, '') + else: + self.result.emit([], stderr) + except Exception as e: + self.result.emit([], str(e)) + + +class SelectionDialog(QDialog): + def __init__(self, title, items, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setMinimumWidth(350) + self.setMinimumHeight(300) + layout = QVBoxLayout(self) + self.list_widget = QListWidget() + self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + from PySide6.QtWidgets import QSizePolicy + self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + for item in items: + QListWidgetItem(item, self.list_widget) + layout.addWidget(self.list_widget) + self.selected_item = None + self.list_widget.itemClicked.connect(self.on_item_clicked) + def on_item_clicked(self, item): + self.selected_item = item.text() + self.accept() + + +class ConfigureNewModlistDialogsMixin: + """Mixin providing dialog management for ConfigureNewModlistScreen.""" + + def cleanup_processes(self): + """Clean up any running processes when the window closes or is cancelled""" + # Stop CPU tracking if active + if hasattr(self, 'file_progress_list'): + self.file_progress_list.stop_cpu_tracking() + + # Clean up automated prefix thread if running + if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread.isRunning(): + self.automated_prefix_thread.terminate() + self.automated_prefix_thread.wait(1000) + + # Clean up configuration thread if running + if hasattr(self, 'config_thread') and self.config_thread.isRunning(): + 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] + conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" + + modlist_name = self.modlist_name_edit.text().strip() + + # Create dialog with Jackify styling + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout + from PySide6.QtCore import Qt + + dialog = QDialog(self) + dialog.setWindowTitle("Steam Shortcut Conflict") + dialog.setModal(True) + dialog.resize(450, 180) + + # Apply Jackify dark theme styling + dialog.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + color: #ffffff; + } + QLabel { + color: #ffffff; + font-size: 14px; + padding: 10px 0px; + } + QLineEdit { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px; + font-size: 14px; + selection-background-color: #3fd0ea; + } + QLineEdit:focus { + border-color: #3fd0ea; + } + QPushButton { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + min-width: 120px; + } + QPushButton:hover { + background-color: #505050; + border-color: #3fd0ea; + } + QPushButton:pressed { + background-color: #303030; + } + """) + + layout = QVBoxLayout(dialog) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # Conflict message + conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") + layout.addWidget(conflict_label) + + # Text input for new name + name_input = QLineEdit(modlist_name) + name_input.selectAll() + layout.addWidget(name_input) + + # Buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + create_button = QPushButton("Create with New Name") + cancel_button = QPushButton("Cancel") + + button_layout.addStretch() + button_layout.addWidget(cancel_button) + button_layout.addWidget(create_button) + layout.addLayout(button_layout) + + # Connect signals + def on_create(): + new_name = name_input.text().strip() + if new_name and new_name != modlist_name: + dialog.accept() + # Retry workflow with new name + self.retry_automated_workflow_with_new_name(new_name) + elif new_name == modlist_name: + # Same name - show warning + from jackify.backend.services.message_service import MessageService + MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") + else: + # Empty name + from jackify.backend.services.message_service import MessageService + MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + + def on_cancel(): + dialog.reject() + self._safe_append_text("Shortcut creation cancelled by user") + + create_button.clicked.connect(on_create) + cancel_button.clicked.connect(on_cancel) + + # Make Enter key work + name_input.returnPressed.connect(on_create) + + 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 + original_name = self.modlist_name_edit.text() + self.modlist_name_edit.setText(new_name) + + # Restart the automated workflow + 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") + # Show manual steps dialog again + extra_warning = "" + if self._manual_steps_retry_count >= 2: + extra_warning = "
It looks like you have not completed the manual steps yet. Please try again." + 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") + 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 + + Args: + modlist_name: Name of the installed modlist + install_dir: Installation directory path + """ + try: + from pathlib import Path + from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + from jackify.backend.handlers.path_handler import PathHandler + + # Get paths first (needed for VNV detection) + install_path = Path(install_dir) + + # Quick check before importing more (pass install location for ModOrganizer.ini check) + if not should_offer_vnv_automation(modlist_name, install_path): + return + game_paths = PathHandler().find_vanilla_game_paths() + game_root = game_paths.get('Fallout New Vegas') + + if not game_root: + debug_print("DEBUG: VNV automation skipped - FNV game root not found") + return + + # Confirmation callback - show dialog to user + def confirmation_callback(description: str) -> bool: + from ..services.message_service import MessageService + reply = MessageService.question( + self, + "VNV Post-Install Automation", + description, + critical=False, + safety_level="medium" + ) + return reply == QMessageBox.Yes + + # Manual file callback for non-Premium users + def manual_file_callback(title: str, instructions: str) -> Optional[Path]: + from PySide6.QtWidgets import QFileDialog + from ..services.message_service import MessageService + + # Show instructions + MessageService.information(self, title, instructions) + + # Open file picker + file_path, _ = QFileDialog.getOpenFileName( + self, + title, + str(Path.home() / "Downloads"), + "All Files (*.*)" + ) + + if file_path: + return Path(file_path) + return None + + # Run automation + automation_ran, error = run_vnv_automation_if_applicable( + modlist_name=modlist_name, + modlist_install_location=install_path, + game_root=game_root, + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(), + progress_callback=None, # GUI doesn't need progress updates for post-install + manual_file_callback=manual_file_callback, + confirmation_callback=confirmation_callback + ) + + if error: + from ..services.message_service import MessageService + MessageService.warning( + self, + "VNV Automation Failed", + f"VNV post-install automation encountered an error:\n\n{error}\n\n" + "You can complete these steps manually by following the guide at:\n" + "https://vivanewvegas.moddinglinked.com/wabbajack.html" + ) + + except Exception as e: + debug_print(f"ERROR: Failed to run VNV automation: {e}") + import traceback + debug_print(f"Traceback: {traceback.format_exc()}") + + + def show_next_steps_dialog(self, message): + dlg = QDialog(self) + dlg.setWindowTitle("Next Steps") + dlg.setModal(True) + layout = QVBoxLayout(dlg) + label = QLabel(message) + label.setWordWrap(True) + layout.addWidget(label) + btn_row = QHBoxLayout() + btn_return = QPushButton("Return") + btn_exit = QPushButton("Exit") + btn_row.addWidget(btn_return) + btn_row.addWidget(btn_exit) + layout.addLayout(btn_row) + def on_return(): + dlg.accept() + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(0) + def on_exit(): + QApplication.quit() + btn_return.clicked.connect(on_return) + 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 new file mode 100644 index 0000000..99757d9 --- /dev/null +++ b/jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py @@ -0,0 +1,631 @@ +"""UI setup and control management for ConfigureNewModlistScreen (Mixin).""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QLineEdit, QPushButton, + QGridLayout, QTextEdit, QSizePolicy, QTabWidget, QCheckBox, QMainWindow, QDialog +) +from PySide6.QtCore import Qt, QSize, QTimer, QProcess +import os +import subprocess +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 + +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 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") + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + self.dev_mode = dev_mode + from jackify.backend.models.configuration import SystemInfo + self.system_info = system_info or SystemInfo(is_steamdeck=False) + self.debug = DEBUG_BORDERS + self.online_modlists = {} # {game_type: [modlist_dict, ...]} + self.modlist_details = {} # {modlist_name: modlist_dict} + + # Initialize services early + from jackify.backend.services.api_key_service import APIKeyService + from jackify.backend.services.resolution_service import ResolutionService + from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService + from jackify.backend.handlers.config_handler import ConfigHandler + self.api_key_service = APIKeyService() + self.resolution_service = ResolutionService() + self.config_handler = ConfigHandler() + self.protontricks_service = ProtontricksDetectionService() + + # Path for workflow log + self.refresh_paths() + + # Scroll tracking for professional auto-scroll behavior + self._user_manually_scrolled = False + self._was_at_bottom = True + + # Time tracking for workflow completion + self._workflow_start_time = None + + # Retry count for manual steps validation (used by dialogs mixin) + self._manual_steps_retry_count = 0 + + # Initialize progress reporting components + self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) + self.progress_indicator.set_status("Ready to configure", 0) + self.file_progress_list = FileProgressList() + + # Create "Show Details" checkbox + self.show_details_checkbox = QCheckBox("Show details") + self.show_details_checkbox.setChecked(False) # Start collapsed + self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") + self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) + + main_overall_vbox = QVBoxLayout(self) + main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + + # --- Header (title, description) --- + header_layout = QVBoxLayout() + header_layout.setSpacing(1) # Reduce spacing between title and description + # Title (no logo) + title = QLabel("Configure New Modlist") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") + title.setAlignment(Qt.AlignHCenter) + title.setMaximumHeight(30) # Force compact height + header_layout.addWidget(title) + # Description + desc = QLabel( + "This screen allows you to configure a newly installed modlist in Jackify. " + "Set up your Steam shortcut, restart Steam, and complete post-install configuration." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(40) # Force compact height for description + header_layout.addWidget(desc) + header_widget = QWidget() + header_widget.setLayout(header_layout) + header_widget.setMaximumHeight(75) # Match other screens + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + header_widget.setToolTip("HEADER_SECTION") + main_overall_vbox.addWidget(header_widget) + + # --- Upper section: user-configurables (left) + process monitor (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + # Left: user-configurables (form and controls) + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + # --- [Options] header (moved here for alignment) --- + 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) + user_config_vbox.addWidget(options_header) + # --- Install/Downloads Dir/API Key (reuse Tuxborn style) --- + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) + form_grid.setContentsMargins(0, 0, 0, 0) + # Modlist Name (NEW FIELD) + modlist_name_label = QLabel("Modlist Name:") + self.modlist_name_edit = QLineEdit() + self.modlist_name_edit.setMaximumHeight(25) # Force compact height + form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addWidget(self.modlist_name_edit, 0, 1) + # Install Dir + install_dir_label = QLabel("ModOrganizer.exe Path:") + self.install_dir_edit = QLineEdit("/path/to/Modlist/ModOrganizer.exe") + self.install_dir_edit.setMaximumHeight(25) # Force compact height + browse_install_btn = QPushButton("Browse") + browse_install_btn.clicked.connect(self.browse_install_dir) + install_dir_hbox = QHBoxLayout() + install_dir_hbox.addWidget(self.install_dir_edit) + install_dir_hbox.addWidget(browse_install_btn) + form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(install_dir_hbox, 1, 1) + # --- Resolution Dropdown --- + resolution_label = QLabel("Resolution:") + self.resolution_combo = QComboBox() + self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.resolution_combo.addItem("Leave unchanged") + self.resolution_combo.addItems([ + "1280x720", + "1280x800 (Steam Deck)", + "1366x768", + "1440x900", + "1600x900", + "1600x1200", + "1680x1050", + "1920x1080", + "1920x1200", + "2048x1152", + "2560x1080", + "2560x1440", + "2560x1600", + "3440x1440", + "3840x1600", + "3840x2160", + "3840x2400", + "5120x1440", + "5120x2160", + "7680x4320" + ]) + form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + + # Load saved resolution if available + saved_resolution = self.resolution_service.get_saved_resolution() + is_steam_deck = False + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + is_steam_deck = True + except Exception: + pass + if saved_resolution: + combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] + resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) + self.resolution_combo.setCurrentIndex(resolution_index) + debug_print(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())] + if "1280x800 (Steam Deck)" in combo_items: + self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) + else: + self.resolution_combo.setCurrentIndex(0) + # Otherwise, default is 'Leave unchanged' (index 0) + + # Horizontal layout for resolution dropdown and auto-restart checkbox + resolution_and_restart_layout = QHBoxLayout() + resolution_and_restart_layout.setSpacing(12) + + # Resolution dropdown (made smaller) + self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing + resolution_and_restart_layout.addWidget(self.resolution_combo) + + # Add stretch to push checkbox to the right + resolution_and_restart_layout.addStretch() + + # Auto-accept Steam restart checkbox (right-aligned) + self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart") + self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session + self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended configuration") + resolution_and_restart_layout.addWidget(self.auto_restart_checkbox) + + # Update the form grid to use the combined layout + form_grid.addLayout(resolution_and_restart_layout, 2, 1) + + form_section_widget = QWidget() + form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + form_section_widget.setLayout(form_grid) + form_section_widget.setMinimumHeight(120) # Reduced to match compact form + form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown + if self.debug: + form_section_widget.setStyleSheet("border: 2px solid blue;") + form_section_widget.setToolTip("FORM_SECTION") + user_config_vbox.addWidget(form_section_widget) + # --- Buttons --- + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + self.start_btn = QPushButton("Start Configuration") + btn_row.addWidget(self.start_btn) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.cancel_and_cleanup) + btn_row.addWidget(cancel_btn) + user_config_widget = QWidget() + user_config_widget.setLayout(user_config_vbox) + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + user_config_widget.setToolTip("USER_CONFIG_WIDGET") + # Right: Tabbed interface with Activity and Process Monitor + # Both tabs are always available, user can switch between them + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") + self.process_monitor_heading = QLabel("[Process Monitor]") + self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") + self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor_heading) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + process_monitor_widget.setToolTip("PROCESS_MONITOR") + self.process_monitor_widget = process_monitor_widget + + # Set up File Progress List (Activity tab) + self.file_progress_list.setMinimumSize(QSize(300, 20)) + self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # Create tab widget to hold both Activity and Process Monitor + self.activity_tabs = QTabWidget() + self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") + self.activity_tabs.setContentsMargins(0, 0, 0, 0) + self.activity_tabs.setDocumentMode(False) + self.activity_tabs.setTabPosition(QTabWidget.North) + if self.debug: + self.activity_tabs.setStyleSheet("border: 2px solid cyan;") + self.activity_tabs.setToolTip("ACTIVITY_TABS") + + # Add both widgets as tabs + self.activity_tabs.addTab(self.file_progress_list, "Activity") + self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") + + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(self.activity_tabs, stretch=9) + upper_hbox.setAlignment(Qt.AlignTop) + upper_section_widget = QWidget() + upper_section_widget.setLayout(upper_hbox) + # Use Fixed size policy for consistent height + upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown + if self.debug: + upper_section_widget.setStyleSheet("border: 2px solid green;") + upper_section_widget.setToolTip("UPPER_SECTION") + main_overall_vbox.addWidget(upper_section_widget) + + # Status banner with progress indicator and "Show details" toggle + banner_row = QHBoxLayout() + banner_row.setContentsMargins(0, 0, 0, 0) + banner_row.setSpacing(8) + banner_row.addWidget(self.progress_indicator, 1) + banner_row.addStretch() + banner_row.addWidget(self.show_details_checkbox) + banner_row_widget = QWidget() + banner_row_widget.setLayout(banner_row) + banner_row_widget.setMaximumHeight(45) # Compact height + banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + main_overall_vbox.addWidget(banner_row_widget) + + # Console output area (shown when "Show details" is checked) + 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) # Hidden by default (compact mode) + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + self.console.setToolTip("CONSOLE") + + # Set up scroll tracking for professional auto-scroll behavior + self._setup_scroll_tracking() + + # Wrap button row in widget for debug borders + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + btn_row_widget.setToolTip("BUTTON_ROW") + + # Create a container that holds console + button row with proper spacing + console_and_buttons_widget = QWidget() + console_and_buttons_layout = QVBoxLayout() + console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) + console_and_buttons_layout.setSpacing(8) + + console_and_buttons_layout.addWidget(self.console, stretch=1) + console_and_buttons_layout.addWidget(btn_row_widget) + + console_and_buttons_widget.setLayout(console_and_buttons_layout) + console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + console_and_buttons_widget.setFixedHeight(50) # Lock to button row height when console is hidden + if self.debug: + console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") + console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") + # Add without stretch to prevent squashing upper section + main_overall_vbox.addWidget(console_and_buttons_widget) + + # Store references for toggle functionality + self.console_and_buttons_widget = console_and_buttons_widget + self.console_and_buttons_layout = console_and_buttons_layout + self.main_overall_vbox = main_overall_vbox + + self.setLayout(main_overall_vbox) + + # --- Process Monitor (right) --- + self.process = None + self.log_timer = None + self.last_log_pos = 0 + # --- Process Monitor Timer --- + self.top_timer = QTimer(self) + self.top_timer.timeout.connect(self.update_top_panel) + self.top_timer.start(2000) + # --- Start Configuration button --- + self.start_btn.clicked.connect(self.validate_and_start_configure) + + # Initialize empty controls list - will be populated after UI is built + self._actionable_controls = [] + + # 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 = [ + # Main action button + self.start_btn, + # Form fields + self.modlist_name_edit, + self.install_dir_edit, + # Resolution controls + self.resolution_combo, + # Checkboxes + 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 + # Remove any fixed height constraints to allow natural stretching + 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() + scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) + 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() + # Use tolerance to account for rounding and rapid updates + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + # If user manually scrolls to bottom, reset manual scroll flag + if self._was_at_bottom and self._user_manually_scrolled: + # Small delay to allow user to scroll away if they want + 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 + try: + parent = self.parent() + while parent and not isinstance(parent, QMainWindow): + parent = parent.parent() + if parent and isinstance(parent, QMainWindow): + main_window = parent + except Exception: + pass + + if is_checked: + # Show console + self.console.setVisible(True) + self.console.setMinimumHeight(200) + self.console.setMaximumHeight(16777215) + self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # Allow expansion when console is visible + if hasattr(self, 'console_and_buttons_widget'): + self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.console_and_buttons_widget.setMinimumHeight(0) + self.console_and_buttons_widget.setMaximumHeight(16777215) + self.console_and_buttons_widget.updateGeometry() + + # Set stretch factor for console in layout + if hasattr(self, 'main_overall_vbox'): + try: + self.main_overall_vbox.setStretchFactor(self.console, 1) + except Exception: + pass + + # Expand window + if main_window: + try: + from PySide6.QtCore import QSize + # On Steam Deck, keep fullscreen; on other systems, set normal window state + if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): + main_window.showNormal() + main_window.setMaximumHeight(16777215) + main_window.setMinimumHeight(0) + expanded_min = 900 + current_size = main_window.size() + target_height = max(expanded_min, 900) + main_window.setMinimumHeight(expanded_min) + main_window.resize(current_size.width(), target_height) + if hasattr(self, 'main_overall_vbox'): + self.main_overall_vbox.invalidate() + self.updateGeometry() + except Exception: + pass + + # Notify parent to expand + self.resize_request.emit("expand") + else: + # Hide console + self.console.setVisible(False) + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + + # Lock height when console is hidden + if hasattr(self, 'console_and_buttons_widget'): + self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.console_and_buttons_widget.setFixedHeight(50) + self.console_and_buttons_widget.updateGeometry() + + # Remove stretch factor for console + if hasattr(self, 'main_overall_vbox'): + try: + self.main_overall_vbox.setStretchFactor(self.console, 0) + except Exception: + pass + + # Collapse window + if main_window: + try: + from PySide6.QtCore import QSize + compact_height = 620 + # On Steam Deck, keep fullscreen; on other systems, set normal window state + if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): + main_window.showNormal() + set_responsive_minimum(main_window, min_width=960, min_height=compact_height) + current_size = main_window.size() + main_window.resize(current_size.width(), compact_height) + except Exception: + pass + + # Notify parent to collapse + self.resize_request.emit("compact") + + + def update_top_panel(self): + try: + result = subprocess.run([ + "ps", "-eo", "pcpu,pmem,comm,args" + ], stdout=subprocess.PIPE, text=True, timeout=2) + lines = result.stdout.splitlines() + header = "CPU%\tMEM%\tCOMMAND" + filtered = [header] + process_rows = [] + for line in lines[1:]: + line_lower = line.lower() + # Include jackify-engine and related heavy processes + heavy_processes = ( + "jackify-engine" in line_lower or "7zz" in line_lower or + "texconv" in line_lower or "wine" in line_lower or + "wine64" in line_lower or "protontricks" in line_lower + ) + # Include Python processes running configure-modlist command + configure_processes = ( + "python" in line_lower and "configure-modlist" in line_lower + ) + # Include QProcess processes that might be configuration-related + qprocess_config = ( + hasattr(self, 'config_process') and + self.config_process and + self.config_process.state() == QProcess.Running and + ("python" in line_lower or "jackify" in line_lower) + ) + + if (heavy_processes or configure_processes or qprocess_config) and "jackify-gui.py" not in line_lower: + cols = line.strip().split(None, 3) + if len(cols) >= 3: + process_rows.append(cols) + process_rows.sort(key=lambda x: float(x[0]), reverse=True) + for cols in process_rows: + filtered.append('\t'.join(cols)) + if len(filtered) == 1: + filtered.append("[No Jackify-related processes found]") + self.process_monitor.setPlainText('\n'.join(filtered)) + 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: + if self.protontricks_service.is_bundled_mode(): + return True + + is_installed, installation_type, details = self.protontricks_service.detect_protontricks() + + if not is_installed: + # Show protontricks error dialog + from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog + dialog = ProtontricksErrorDialog(self.protontricks_service, self) + result = dialog.exec() + + if result == QDialog.Rejected: + return False + + # Re-check after dialog + is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False) + return is_installed + + return True + + except Exception as e: + print(f"Error checking protontricks: {e}") + from jackify.frontends.gui.services.message_service import MessageService + MessageService.warning(self, "Protontricks Check Failed", + f"Unable to verify protontricks installation: {e}\n\n" + "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 new file mode 100644 index 0000000..eece443 --- /dev/null +++ b/jackify/frontends/gui/screens/configure_new_modlist_workflow.py @@ -0,0 +1,524 @@ +"""Workflow management for ConfigureNewModlistScreen (Mixin).""" +from PySide6.QtCore import QThread, Signal +import os +import time +import logging +from jackify.shared.resolution_utils import get_resolution_fallback + +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.""" + + 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 + + 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 validate_and_start_configure(self): + # Reload config to pick up any settings changes made in Settings dialog + self.config_handler.reload_config() + + # Check protontricks before proceeding + if not self._check_protontricks(): + return + + # 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) + + # Validate ModOrganizer.exe path + mo2_path = self.install_dir_edit.text().strip() + from jackify.frontends.gui.services.message_service import MessageService + if not mo2_path: + MessageService.warning(self, "Missing Path", "Please specify the path to ModOrganizer.exe", safety_level="low") + return + if not os.path.isfile(mo2_path): + MessageService.warning(self, "Invalid Path", "The specified path does not point to a valid file", safety_level="low") + return + if not mo2_path.endswith('ModOrganizer.exe'): + MessageService.warning(self, "Invalid File", "The specified file is not ModOrganizer.exe", safety_level="low") + return + + # Start time tracking + self._workflow_start_time = time.time() + + # Initialize progress indicator + self.progress_indicator.set_status("Preparing to configure...", 0) + + # Start CPU tracking + self.file_progress_list.start_cpu_tracking() + + # Disable controls during configuration (after validation passes) + self._disable_controls_during_operation() + + # Validate modlist name + modlist_name = self.modlist_name_edit.text().strip() + if not modlist_name: + MessageService.warning(self, "Missing Name", "Please specify a name for your modlist", safety_level="low") + self._enable_controls_after_operation() + return + + # Handle resolution saving + resolution = self.resolution_combo.currentText() + if resolution and resolution != "Leave unchanged": + success = self.resolution_service.save_resolution(resolution) + if success: + debug_print(f"DEBUG: Resolution saved successfully: {resolution}") + else: + debug_print("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") + + # 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() + modlist_name = self.modlist_name_edit.text().strip() + mo2_exe_path = self.install_dir_edit.text().strip() + 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") + return + + # 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...") + + # 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""" + 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...") + + # Disable the start button to prevent multiple workflows + self.start_btn.setEnabled(False) + + # Create and start the automated prefix thread + class AutomatedPrefixThread(QThread): + progress_update = Signal(str) + workflow_complete = Signal(object) # Will emit the result tuple + error_occurred = Signal(str) + + def __init__(self, modlist_name, install_dir, mo2_exe_path, steamdeck, auto_restart): + super().__init__() + self.modlist_name = modlist_name + self.install_dir = install_dir + self.mo2_exe_path = mo2_exe_path + self.steamdeck = steamdeck + self.auto_restart = auto_restart + + def run(self): + try: + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + + # Initialize the automated prefix service + prefix_service = AutomatedPrefixService() + + # Define progress callback for GUI updates + def progress_callback(message): + self.progress_update.emit(message) + + # Run the automated workflow (this contains the blocking operations) + result = prefix_service.run_working_workflow( + self.modlist_name, self.install_dir, self.mo2_exe_path, + progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart + ) + + # Emit the result + self.workflow_complete.emit(result) + + except Exception as e: + self.error_occurred.emit(str(e)) + + # Detect Steam Deck once using centralized service + from jackify.backend.services.platform_detection_service import PlatformDetectionService + platform_service = PlatformDetectionService.get_instance() + _is_steamdeck = platform_service.is_steamdeck + + # Decide whether to restart Steam: checkbox checked = yes; unchecked = ask (only skip if user clicks No) + from PySide6.QtWidgets import QMessageBox + from jackify.frontends.gui.services.message_service import MessageService + + auto_restart = self.auto_restart_checkbox.isChecked() + if not auto_restart: + reply = MessageService.question( + self, + "Restart Steam?", + "Steam will need to restart to detect the new shortcut. Do you want Jackify to restart Steam when the time comes?", + safety_level="medium" + ) + # Only skip restart when user explicitly clicks No; treat Yes or dialog close as restart + auto_restart = reply != QMessageBox.No + + logger.info("Configure New Modlist: starting automated prefix workflow with auto_restart=%s", auto_restart) + + # Create and start the thread + self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path, _is_steamdeck, auto_restart) + self.automated_prefix_thread.progress_update.connect(self._handle_progress_update) + self.automated_prefix_thread.workflow_complete.connect(self._on_automated_prefix_complete) + 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: + # Handle the result - check for conflicts + if isinstance(result, tuple) and len(result) == 4: + if result[0] == "CONFLICT": + # Conflict detected - show conflict resolution dialog + conflicts = result[1] + self.show_shortcut_conflict_dialog(conflicts) + return + else: + # Normal result + success, prefix_path, new_appid, last_timestamp = result + 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, passing the last timestamp + 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(), + last_timestamp) + 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) + elif isinstance(result, tuple) and len(result) == 3: + # Fallback for old format (backward compatibility) + success, prefix_path, new_appid = result + 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(), + 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: + 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): + """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" + ) + + 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") + + debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") + try: + # Get resolution from UI + resolution = self.resolution_combo.currentText() + resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else None + + # Update the context with the new AppID (same format as manual steps) + mo2_exe_path = self.install_dir_edit.text().strip() + updated_context = { + 'name': modlist_name, + 'path': install_dir, + 'mo2_exe_path': mo2_exe_path, + 'modlist_value': None, + 'modlist_source': None, + 'resolution': resolution_value, + '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': '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}") + + # Create new config thread with updated context + from PySide6.QtCore import QThread, Signal + + # Capture parent's method and system_info + detect_game_type_func = self._detect_game_type_from_mo2_ini + parent_system_info = self.system_info + + class ConfigThread(QThread): + progress_update = Signal(str) + configuration_complete = Signal(bool, str, str, bool) + error_occurred = Signal(str) + + def __init__(self, context, system_info, detect_func): + super().__init__() + self.context = context + self.system_info = system_info + self.detect_game_type = detect_func + + def run(self): + try: + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + + # Initialize backend service + modlist_service = ModlistService(self.system_info) + + # Detect game type from ModOrganizer.ini using captured function + detected_game_type = self.detect_game_type(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') or get_resolution_fallback(None), + skip_confirmation=True + ) + + # 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 + self.progress_update.emit("") + self.progress_update.emit("=== Configuration Phase ===") + self.progress_update.emit("") + self.progress_update.emit("Starting modlist 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, parent_system_info, detect_game_type_func) + self.config_thread.progress_update.connect(self._handle_progress_update) + 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 + mo2_exe_path = self.install_dir_edit.text().strip() + resolution = self.resolution_combo.currentText() + + updated_context = { + 'name': modlist_name, + 'path': install_dir, + 'mo2_exe_path': mo2_exe_path, + 'resolution': resolution.split()[0] if resolution != "Leave unchanged" else None, + 'skip_confirmation': True, + 'manual_steps_completed': True, # Mark as completed + '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}") + + # Create new config thread with updated context (same as Tuxborn) + from PySide6.QtCore import QThread, Signal + + # Capture parent's method and system_info + detect_game_type_func = self._detect_game_type_from_mo2_ini + parent_system_info = self.system_info + + class ConfigThread(QThread): + progress_update = Signal(str) + configuration_complete = Signal(bool, str, str, bool) + error_occurred = Signal(str) + + def __init__(self, context, system_info, detect_func): + super().__init__() + self.context = context + self.system_info = system_info + self.detect_game_type = detect_func + + def run(self): + try: + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.modlist import ModlistContext + from pathlib import Path + + # Initialize backend service + modlist_service = ModlistService(self.system_info) + + # Detect game type from ModOrganizer.ini using captured function + detected_game_type = self.detect_game_type(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='', # Not needed for existing modlist + modlist_source='existing', + resolution=self.context.get('resolution') or get_resolution_fallback(None), + skip_confirmation=True + ) + + # 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, enb_detected=False): + self.configuration_complete.emit(success, message, modlist_name, enb_detected) + + 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 working configuration service method + self.progress_update.emit("Starting configuration with backend service...") + + success = 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 success: + self.error_occurred.emit("Configuration failed - check logs for details") + + except Exception as e: + import traceback + error_msg = f"Configuration error: {e}\n{traceback.format_exc()}" + self.error_occurred.emit(error_msg) + + # Create and start the configuration thread + self.config_thread = ConfigThread(updated_context, parent_system_info, detect_game_type_func) + self.config_thread.progress_update.connect(self._handle_progress_update) + 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}") + MessageService.critical(self, "Configuration Error", f"Failed to continue configuration: {e}", safety_level="medium") + + + def _calculate_time_taken(self) -> str: + """Calculate and format the time taken for the workflow""" + if self._workflow_start_time is None: + return "unknown time" + + elapsed_seconds = time.time() - self._workflow_start_time + elapsed_minutes = int(elapsed_seconds // 60) + elapsed_seconds_remainder = int(elapsed_seconds % 60) + + if elapsed_minutes > 0: + if elapsed_minutes == 1: + return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" + else: + return f"{elapsed_seconds_remainder} seconds" + + diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py index d69ff43..4bdfe4c 100644 --- a/jackify/frontends/gui/screens/install_modlist.py +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -33,6 +33,19 @@ from jackify.frontends.gui.widgets.file_progress_list import FileProgressList from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType, FileProgress # Modlist gallery (imported at module level to avoid import delay when opening dialog) from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog +from .install_modlist_dialogs import ModlistFetchThread, SelectionDialog +from .install_modlist_ui_setup import InstallModlistUISetupMixin +from .install_modlist_console import ConsoleOutputMixin +from .install_modlist_progress import ProgressHandlersMixin +from .install_modlist_postinstall import PostInstallFeedbackMixin +from .install_modlist_automated_prefix import AutomatedPrefixHandlersMixin +from .install_modlist_configuration import ConfigurationPhaseMixin +from .install_modlist_ttw import TTWIntegrationMixin +from .install_modlist_vnv import VNVAutomationMixin +from .install_modlist_workflow import InstallWorkflowMixin +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""" @@ -41,816 +54,9 @@ def debug_print(message): 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'): - super().__init__() - self.game_type = game_type - self.log_path = log_path - self.mode = mode - - def run(self): - try: - # Use proper backend service - NOT the misnamed CLI class - from jackify.backend.services.modlist_service import ModlistService - from jackify.backend.models.configuration import SystemInfo - - # Initialize backend service - # Detect if we're on Steam Deck - is_steamdeck = False - try: - 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: - pass - - system_info = SystemInfo(is_steamdeck=is_steamdeck) - modlist_service = ModlistService(system_info) - - # Get modlists using proper backend service - modlist_infos = modlist_service.list_modlists(game_type=self.game_type) - - # Return full modlist objects instead of just IDs to preserve enhanced metadata - self.result.emit(modlist_infos, '') - - except Exception as e: - error_msg = f"Backend service error: {str(e)}" - # Don't write to log file before workflow starts - just return error - self.result.emit([], error_msg) - - -class SelectionDialog(QDialog): - def __init__(self, title, items, parent=None, show_search=True, placeholder_text="Search modlists...", show_legend=False): - super().__init__(parent) - self.setWindowTitle(title) - self.setModal(True) - self.setMinimumWidth(600) - self.setMinimumHeight(300) - layout = QVBoxLayout(self) - - self.show_search = show_search - if self.show_search: - # Search box with clear button - search_layout = QHBoxLayout() - self.search_box = QLineEdit() - self.search_box.setPlaceholderText(placeholder_text) - # Make placeholder text lighter - self.search_box.setStyleSheet("QLineEdit { color: #ccc; } QLineEdit:placeholder { color: #aaa; }") - self.clear_btn = QPushButton("Clear") - self.clear_btn.setFixedWidth(50) - search_layout.addWidget(self.search_box) - search_layout.addWidget(self.clear_btn) - layout.addLayout(search_layout) - - if show_legend: - # Use table for modlist selection with proper columns - self.table_widget = QTableWidget() - self.table_widget.setColumnCount(4) - self.table_widget.setHorizontalHeaderLabels(["Modlist Name", "Download", "Install", "Total"]) - - # Configure table appearance - self.table_widget.setSelectionBehavior(QTableWidget.SelectRows) - self.table_widget.setSelectionMode(QTableWidget.SingleSelection) - self.table_widget.verticalHeader().setVisible(False) - self.table_widget.setAlternatingRowColors(True) - - # Set column widths - header = self.table_widget.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.Stretch) # Modlist name takes remaining space - header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Download size - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Install size - header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Total size - - - self._all_items = list(items) - self._populate_table(self._all_items) - layout.addWidget(self.table_widget) - - # Apply initial NSFW filter since checkbox starts unchecked - self._filter_nsfw(False) - else: - # Use list for non-modlist dialogs (backward compatibility) - self.list_widget = QListWidget() - self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self._all_items = list(items) - self._populate_list(self._all_items) - layout.addWidget(self.list_widget) - - # Add interactive legend bar only for modlist selection dialogs - if show_legend: - legend_layout = QHBoxLayout() - legend_layout.setContentsMargins(10, 5, 10, 5) - - # Status indicator explanation (far left) - status_label = QLabel('[DOWN] Unavailable') - status_label.setStyleSheet("color: #bbb;") - legend_layout.addWidget(status_label) - - # Spacer after DOWN legend - legend_layout.addSpacing(15) - - # No need for size format explanation since we have table headers now - # Just add some spacing - - # Main spacer to push NSFW checkbox to far right - legend_layout.addStretch() - - # NSFW filter checkbox (far right) - self.nsfw_checkbox = QCheckBox("Show NSFW") - self.nsfw_checkbox.setStyleSheet("color: #bbb; font-size: 11px;") - self.nsfw_checkbox.setChecked(False) # Default to hiding NSFW content - self.nsfw_checkbox.toggled.connect(self._filter_nsfw) - legend_layout.addWidget(self.nsfw_checkbox) - - # Legend container - legend_widget = QWidget() - legend_widget.setLayout(legend_layout) - legend_widget.setStyleSheet("background-color: #333; border-radius: 3px; margin: 2px;") - layout.addWidget(legend_widget) - - self.selected_item = None - - # Connect appropriate signals based on widget type - if show_legend: - self.table_widget.itemClicked.connect(self.on_table_item_clicked) - if self.show_search: - self.search_box.textChanged.connect(self._filter_table) - self.clear_btn.clicked.connect(self._clear_search) - self.search_box.returnPressed.connect(self._focus_table) - self.search_box.installEventFilter(self) - else: - self.list_widget.itemClicked.connect(self.on_item_clicked) - if self.show_search: - self.search_box.textChanged.connect(self._filter_list) - self.clear_btn.clicked.connect(self._clear_search) - self.search_box.returnPressed.connect(self._focus_list) - self.search_box.installEventFilter(self) - - def _populate_list(self, items): - self.list_widget.clear() - for item in items: - # Create list item - custom delegate handles all styling - QListWidgetItem(item, self.list_widget) - - def _populate_table(self, items): - self.table_widget.setRowCount(len(items)) - for row, item in enumerate(items): - # Parse the item string to extract components - # Format: "[STATUS] Modlist Name Download|Install|Total" - - # Extract status indicators - status_down = '[DOWN]' in item - status_nsfw = '[NSFW]' in item - - # Clean the item string - clean_item = item.replace('[DOWN]', '').replace('[NSFW]', '').strip() - - # Split into name and sizes - # The format should be "Name Download|Install|Total" - parts = clean_item.rsplit(' ', 1) # Split from right to separate name from sizes - if len(parts) == 2: - name = parts[0].strip() - sizes = parts[1].strip() - size_parts = sizes.split('|') - if len(size_parts) == 3: - download_size, install_size, total_size = [s.strip() for s in size_parts] - else: - # Fallback if format is unexpected - download_size = install_size = total_size = sizes - else: - # Fallback if format is unexpected - name = clean_item - download_size = install_size = total_size = "" - - # Create table items - name_item = QTableWidgetItem(name) - download_item = QTableWidgetItem(download_size) - install_item = QTableWidgetItem(install_size) - total_item = QTableWidgetItem(total_size) - - # Apply styling - if status_down: - # Gray out and strikethrough for DOWN items - for item_widget in [name_item, download_item, install_item, total_item]: - item_widget.setForeground(QColor('#999999')) - font = item_widget.font() - font.setStrikeOut(True) - item_widget.setFont(font) - elif status_nsfw: - # Red text for NSFW items - but only the name, sizes stay white - name_item.setForeground(QColor('#ff4444')) - for item_widget in [download_item, install_item, total_item]: - item_widget.setForeground(QColor('#ffffff')) - else: - # White text for normal items - for item_widget in [name_item, download_item, install_item, total_item]: - item_widget.setForeground(QColor('#ffffff')) - - # Add status indicators to name if present - if status_nsfw: - name_item.setText(f"[NSFW] {name}") - if status_down: - # For DOWN items, we want [DOWN] normal and the name strikethrough - # Since we can't easily mix fonts in a single QTableWidgetItem, - # we'll style the whole item but the visual effect will be clear - name_item.setText(f"[DOWN] {name_item.text()}") - - # Right-align size columns - download_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) - install_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) - total_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) - - # Add items to table - self.table_widget.setItem(row, 0, name_item) - self.table_widget.setItem(row, 1, download_item) - self.table_widget.setItem(row, 2, install_item) - self.table_widget.setItem(row, 3, total_item) - - # Store original item text as data for filtering - name_item.setData(Qt.UserRole, item) - - def _filter_list(self, text): - text = text.strip().lower() - if not text: - filtered = self._all_items - else: - filtered = [item for item in self._all_items if text in item.lower()] - self._populate_list(filtered) - if filtered: - self.list_widget.setCurrentRow(0) - - def _clear_search(self): - self.search_box.clear() - self.search_box.setFocus() - - def _focus_list(self): - self.list_widget.setFocus() - self.list_widget.setCurrentRow(0) - - def _focus_table(self): - self.table_widget.setFocus() - self.table_widget.setCurrentCell(0, 0) - - def _filter_table(self, text): - text = text.strip().lower() - if not text: - # Show all rows - for row in range(self.table_widget.rowCount()): - self.table_widget.setRowHidden(row, False) - else: - # Filter rows based on modlist name - for row in range(self.table_widget.rowCount()): - name_item = self.table_widget.item(row, 0) - if name_item: - # Search in the modlist name - match = text in name_item.text().lower() - self.table_widget.setRowHidden(row, not match) - - def on_table_item_clicked(self, item): - # Get the original item text from the name column - row = item.row() - name_item = self.table_widget.item(row, 0) - if name_item: - original_item = name_item.data(Qt.UserRole) - self.selected_item = original_item - self.accept() - - def _filter_nsfw(self, show_nsfw): - """Filter NSFW modlists based on checkbox state""" - if show_nsfw: - # Show all items - filtered_items = self._all_items - else: - # Hide NSFW items - filtered_items = [item for item in self._all_items if '[NSFW]' not in item] - - # Use appropriate populate method based on widget type - if hasattr(self, 'table_widget'): - self._populate_table(filtered_items) - # Apply search filter if there's search text - if hasattr(self, 'search_box') and self.search_box.text().strip(): - self._filter_table(self.search_box.text()) - else: - self._populate_list(filtered_items) - # Apply search filter if there's search text - if hasattr(self, 'search_box') and self.search_box.text().strip(): - self._filter_list(self.search_box.text()) - - def eventFilter(self, obj, event): - if self.show_search and obj == self.search_box and event.type() == event.Type.KeyPress: - if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Tab): - # Focus appropriate widget - if hasattr(self, 'table_widget'): - self._focus_table() - else: - self._focus_list() - return True - return super().eventFilter(obj, event) - - def on_item_clicked(self, item): - self.selected_item = item.text() - self.accept() - -class InstallModlistScreen(QWidget): +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 __init__(self, stacked_widget=None, main_menu_index=0): - super().__init__() - # Set size policy to prevent unnecessary expansion - let content determine size - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - self.stacked_widget = stacked_widget - self.main_menu_index = main_menu_index - self.debug = DEBUG_BORDERS - # Remember original main window geometry/min-size to restore on expand (like TTW screen) - self._saved_geometry = None - self._saved_min_size = None - self.online_modlists = {} # {game_type: [modlist_dict, ...]} - self.modlist_details = {} # {modlist_name: modlist_dict} - - # Initialize log path (can be refreshed via refresh_paths method) - self.refresh_paths() - - # Initialize services early - from jackify.backend.services.api_key_service import APIKeyService - from jackify.backend.services.nexus_auth_service import NexusAuthService - from jackify.backend.services.resolution_service import ResolutionService - from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService - from jackify.backend.handlers.config_handler import ConfigHandler - self.api_key_service = APIKeyService() - self.auth_service = NexusAuthService() - self.resolution_service = ResolutionService() - self.config_handler = ConfigHandler() - self.protontricks_service = ProtontricksDetectionService() - - # Somnium guidance tracking - self._show_somnium_guidance = False - self._somnium_install_dir = None - - # Console deduplication tracking - self._last_console_line = None - - # Gallery cache preloading tracking - self._gallery_cache_preload_started = False - self._gallery_cache_preload_thread = None - - # Scroll tracking for professional auto-scroll behavior - self._user_manually_scrolled = False - self._was_at_bottom = True - - # Initialize Wabbajack parser for game detection - self.wabbajack_parser = WabbajackParser() - - # R&D: Initialize progress reporting components - self.progress_state_manager = ProgressStateManager() - self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) - 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_notified = False - 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 - self._post_install_active = False - self._post_install_last_label = "" - self._bsa_hold_deadline = 0.0 - - # No throttling needed - render loop handles smooth updates at 60fps - - # R&D: Create "Show Details" checkbox (reuse TTW pattern) - self.show_details_checkbox = QCheckBox("Show details") - self.show_details_checkbox.setChecked(False) # Start collapsed - self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") - self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) - - main_overall_vbox = QVBoxLayout(self) - self.main_overall_vbox = main_overall_vbox # Store reference for expand/collapse - main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin - main_overall_vbox.setSpacing(0) # No spacing between widgets to eliminate gaps - if self.debug: - self.setStyleSheet("border: 2px solid magenta;") - - # --- Header (title, description) --- - header_layout = QVBoxLayout() - header_layout.setSpacing(1) # Reduce spacing between title and description - # Title (no logo) - title = QLabel("Install a Modlist (Automated)") - title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") - title.setAlignment(Qt.AlignHCenter) - title.setMaximumHeight(30) # Force compact height - header_layout.addWidget(title) - # Description - desc = QLabel( - "This screen allows you to install a Wabbajack modlist using Jackify. " - "Configure your options and start the installation." - ) - desc.setWordWrap(True) - desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") - desc.setAlignment(Qt.AlignHCenter) - desc.setMaximumHeight(40) # Force compact height for description - header_layout.addWidget(desc) - header_widget = QWidget() - header_widget.setLayout(header_layout) - header_widget.setMaximumHeight(75) # Increase header height by 25% (60 + 15) - if self.debug: - header_widget.setStyleSheet("border: 2px solid pink;") - header_widget.setToolTip("HEADER_SECTION") - main_overall_vbox.addWidget(header_widget) - - # --- Upper section: user-configurables (left) + process monitor (right) --- - upper_hbox = QHBoxLayout() - upper_hbox.setContentsMargins(0, 0, 0, 0) - upper_hbox.setSpacing(16) - upper_hbox.setAlignment(Qt.AlignTop) # Align both sides at the top - # Left: user-configurables (form and controls) - user_config_vbox = QVBoxLayout() - user_config_vbox.setAlignment(Qt.AlignTop) - user_config_vbox.setSpacing(4) # Reduce spacing between major form sections - user_config_vbox.setContentsMargins(0, 0, 0, 0) # No margins to ensure tab alignment - # --- Tabs for source selection --- - self.source_tabs = QTabWidget() - self.source_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") - self.source_tabs.setContentsMargins(0, 0, 0, 0) # Ensure no margins for alignment - self.source_tabs.setDocumentMode(False) # Keep frame for consistency - self.source_tabs.setTabPosition(QTabWidget.North) # Ensure tabs are at top - if self.debug: - self.source_tabs.setStyleSheet("border: 2px solid cyan;") - self.source_tabs.setToolTip("SOURCE_TABS") - # --- Online List Tab --- - online_tab = QWidget() - online_tab_vbox = QVBoxLayout() - online_tab_vbox.setAlignment(Qt.AlignTop) - # Online List Controls - self.online_group = QWidget() - online_layout = QHBoxLayout() - online_layout.setContentsMargins(0, 0, 0, 0) - # --- Game Type Selection --- - self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Other"] - self.game_type_btn = QPushButton("Please Select...") - self.game_type_btn.setMinimumWidth(200) - self.game_type_btn.clicked.connect(self.open_game_type_dialog) - # --- Modlist Selection --- - self.modlist_btn = QPushButton("Select Modlist") - self.modlist_btn.setMinimumWidth(300) - self.modlist_btn.clicked.connect(self.open_modlist_dialog) - self.modlist_btn.setEnabled(False) - online_layout.addWidget(QLabel("Game Type:")) - online_layout.addWidget(self.game_type_btn) - online_layout.addSpacing(4) # Reduced from 16 to 4 - online_layout.addWidget(QLabel("Modlist:")) - online_layout.addWidget(self.modlist_btn) - self.online_group.setLayout(online_layout) - online_tab_vbox.addWidget(self.online_group) - online_tab.setLayout(online_tab_vbox) - self.source_tabs.addTab(online_tab, "Select Modlist") - # --- File Picker Tab --- - file_tab = QWidget() - file_tab_vbox = QVBoxLayout() - file_tab_vbox.setAlignment(Qt.AlignTop) - self.file_group = QWidget() - file_layout = QHBoxLayout() - file_layout.setContentsMargins(0, 0, 0, 0) - self.file_edit = QLineEdit() - self.file_edit.setMinimumWidth(400) - self.file_btn = QPushButton("Browse") - self.file_btn.clicked.connect(self.browse_wabbajack_file) - file_layout.addWidget(QLabel(".wabbajack File:")) - file_layout.addWidget(self.file_edit) - file_layout.addWidget(self.file_btn) - self.file_group.setLayout(file_layout) - file_tab_vbox.addWidget(self.file_group) - file_tab.setLayout(file_tab_vbox) - self.source_tabs.addTab(file_tab, "Use .wabbajack File") - user_config_vbox.addWidget(self.source_tabs) - # --- Install/Downloads Dir/API Key (reuse Tuxborn style) --- - form_grid = QGridLayout() - form_grid.setHorizontalSpacing(12) - form_grid.setVerticalSpacing(6) # Increased from 1 to 6 for better readability - form_grid.setContentsMargins(0, 0, 0, 0) - # Modlist Name (NEW FIELD) - modlist_name_label = QLabel("Modlist Name:") - self.modlist_name_edit = QLineEdit() - self.modlist_name_edit.setMaximumHeight(25) # Force compact height - form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addWidget(self.modlist_name_edit, 0, 1) - # Install Dir - install_dir_label = QLabel("Install Directory:") - self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir()) - self.install_dir_edit.setMaximumHeight(25) # Force compact height - self.browse_install_btn = QPushButton("Browse") - self.browse_install_btn.clicked.connect(self.browse_install_dir) - install_dir_hbox = QHBoxLayout() - install_dir_hbox.addWidget(self.install_dir_edit) - install_dir_hbox.addWidget(self.browse_install_btn) - form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(install_dir_hbox, 1, 1) - # Downloads Dir - downloads_dir_label = QLabel("Downloads Directory:") - self.downloads_dir_edit = QLineEdit(self.config_handler.get_modlist_downloads_base_dir()) - self.downloads_dir_edit.setMaximumHeight(25) # Force compact height - self.browse_downloads_btn = QPushButton("Browse") - self.browse_downloads_btn.clicked.connect(self.browse_downloads_dir) - downloads_dir_hbox = QHBoxLayout() - downloads_dir_hbox.addWidget(self.downloads_dir_edit) - downloads_dir_hbox.addWidget(self.browse_downloads_btn) - form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(downloads_dir_hbox, 2, 1) - - # Nexus Login (OAuth) - nexus_login_label = QLabel("Nexus Login:") - self.nexus_status = QLabel("Checking...") - self.nexus_status.setStyleSheet("color: #ccc;") - self.nexus_login_btn = QPushButton("Authorise") - self.nexus_login_btn.setStyleSheet(""" - QPushButton:hover { opacity: 0.95; } - QPushButton:disabled { opacity: 0.6; } - """) - self.nexus_login_btn.setMaximumWidth(90) - self.nexus_login_btn.setVisible(False) - self.nexus_login_btn.clicked.connect(self._handle_nexus_login_click) - - nexus_hbox = QHBoxLayout() - nexus_hbox.setContentsMargins(0, 0, 0, 0) - nexus_hbox.setSpacing(8) - nexus_hbox.addWidget(self.nexus_login_btn) - nexus_hbox.addWidget(self.nexus_status) - nexus_hbox.addStretch() - - form_grid.addWidget(nexus_login_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(nexus_hbox, 3, 1) - - # Update nexus status on init - self._update_nexus_status() - - # --- Resolution Dropdown --- - resolution_label = QLabel("Resolution:") - self.resolution_combo = QComboBox() - self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.resolution_combo.addItem("Leave unchanged") - self.resolution_combo.addItems([ - "1280x720", - "1280x800 (Steam Deck)", - "1366x768", - "1440x900", - "1600x900", - "1600x1200", - "1680x1050", - "1920x1080", - "1920x1200", - "2048x1152", - "2560x1080", - "2560x1440", - "2560x1600", - "3440x1440", - "3840x1600", - "3840x2160", - "3840x2400", - "5120x1440", - "5120x2160", - "7680x4320" - ]) - # Load saved resolution if available - saved_resolution = self.resolution_service.get_saved_resolution() - is_steam_deck = False - try: - if os.path.exists('/etc/os-release'): - with open('/etc/os-release') as f: - if 'steamdeck' in f.read().lower(): - is_steam_deck = True - except Exception: - pass - if saved_resolution: - combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] - resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) - self.resolution_combo.setCurrentIndex(resolution_index) - debug_print(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())] - if "1280x800 (Steam Deck)" in combo_items: - self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) - else: - self.resolution_combo.setCurrentIndex(0) - # Otherwise, default is 'Leave unchanged' (index 0) - form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - - # Horizontal layout for resolution dropdown and auto-restart checkbox - resolution_and_restart_layout = QHBoxLayout() - resolution_and_restart_layout.setSpacing(12) - - # Resolution dropdown (made smaller) - self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing - resolution_and_restart_layout.addWidget(self.resolution_combo) - - # Add stretch to push checkbox to the right - resolution_and_restart_layout.addStretch() - - # Auto-accept Steam restart checkbox (right-aligned) - self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart") - self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session - self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation") - resolution_and_restart_layout.addWidget(self.auto_restart_checkbox) - - form_grid.addLayout(resolution_and_restart_layout, 5, 1) - form_section_widget = QWidget() - form_section_widget.setLayout(form_grid) - # Let form section size naturally to its content - # Don't force a fixed height - let it calculate based on grid content - form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - if self.debug: - form_section_widget.setStyleSheet("border: 2px solid blue;") - form_section_widget.setToolTip("FORM_SECTION") - user_config_vbox.addWidget(form_section_widget) - # --- Buttons --- - btn_row = QHBoxLayout() - btn_row.setAlignment(Qt.AlignHCenter) - self.start_btn = QPushButton("Start Installation") - btn_row.addWidget(self.start_btn) - - - - # Cancel button (goes back to menu) - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.clicked.connect(self.cancel_and_cleanup) - btn_row.addWidget(self.cancel_btn) - - # Cancel Installation button (appears during installation) - self.cancel_install_btn = QPushButton("Cancel Installation") - self.cancel_install_btn.clicked.connect(self.cancel_installation) - self.cancel_install_btn.setVisible(False) # Hidden by default - btn_row.addWidget(self.cancel_install_btn) - - # Wrap button row in widget for debug borders - btn_row_widget = QWidget() - btn_row_widget.setLayout(btn_row) - btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact - if self.debug: - btn_row_widget.setStyleSheet("border: 2px solid red;") - btn_row_widget.setToolTip("BUTTON_ROW") - user_config_widget = QWidget() - self.user_config_widget = user_config_widget # Store reference for height calculation - user_config_widget.setLayout(user_config_vbox) - user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) # Fixed height - don't expand unnecessarily - if self.debug: - user_config_widget.setStyleSheet("border: 2px solid orange;") - user_config_widget.setToolTip("USER_CONFIG_WIDGET") - # Right: Tabbed interface with Activity and Process Monitor - # Both tabs are always available, user can switch between them - self.process_monitor = QTextEdit() - self.process_monitor.setReadOnly(True) - self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) - self.process_monitor.setMinimumSize(QSize(300, 20)) - self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") - self.process_monitor_heading = QLabel("[Process Monitor]") - self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") - self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - process_vbox = QVBoxLayout() - process_vbox.setContentsMargins(0, 0, 0, 0) - process_vbox.setSpacing(2) - process_vbox.addWidget(self.process_monitor_heading) - process_vbox.addWidget(self.process_monitor) - process_monitor_widget = QWidget() - process_monitor_widget.setLayout(process_vbox) - # Match size policy - Process Monitor should expand to fill available space - process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - if self.debug: - process_monitor_widget.setStyleSheet("border: 2px solid purple;") - process_monitor_widget.setToolTip("PROCESS_MONITOR") - # Store reference - self.process_monitor_widget = process_monitor_widget - - # Set up File Progress List (Activity tab) - # Match Process Monitor size policy exactly - expand to fill available space - self.file_progress_list.setMinimumSize(QSize(300, 20)) - self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - # Create tab widget to hold both Activity and Process Monitor - # Match styling of source_tabs on the left for consistency - self.activity_tabs = QTabWidget() - self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") - self.activity_tabs.setContentsMargins(0, 0, 0, 0) # Ensure no margins for alignment - self.activity_tabs.setDocumentMode(False) # Match left tabs - self.activity_tabs.setTabPosition(QTabWidget.North) # Ensure tabs are at top - if self.debug: - self.activity_tabs.setStyleSheet("border: 2px solid cyan;") - self.activity_tabs.setToolTip("ACTIVITY_TABS") - - # Add both widgets as tabs - self.activity_tabs.addTab(self.file_progress_list, "Activity") - self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") - - upper_hbox.addWidget(user_config_widget, stretch=1, alignment=Qt.AlignTop) - # Add tab widget with stretch=3 to match original Process Monitor stretch - upper_hbox.addWidget(self.activity_tabs, stretch=3, alignment=Qt.AlignTop) - upper_section_widget = QWidget() - self.upper_section_widget = upper_section_widget # Store reference for showEvent - upper_section_widget.setLayout(upper_hbox) - # Use Fixed size policy - the height should be based on LEFT side only - # This ensures consistent height whether Active Files or Process Monitor is shown - upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - # Calculate height based on LEFT side (user_config_widget) only - # This ensures the same height regardless of which right widget is visible - self._upper_section_fixed_height = None # Will be set in showEvent based on left side - if self.debug: - upper_section_widget.setStyleSheet("border: 2px solid green;") - upper_section_widget.setToolTip("UPPER_SECTION") - main_overall_vbox.addWidget(upper_section_widget) - - # Add spacing between upper section and progress banner - main_overall_vbox.addSpacing(8) - - # R&D: Progress indicator banner row (similar to TTW screen) - banner_row = QHBoxLayout() - banner_row.setContentsMargins(0, 0, 0, 0) - banner_row.setSpacing(8) - banner_row.addWidget(self.progress_indicator, 1) - banner_row.addStretch() - banner_row.addWidget(self.show_details_checkbox) - banner_row_widget = QWidget() - banner_row_widget.setLayout(banner_row) - # Constrain height to prevent unwanted vertical expansion - banner_row_widget.setMaximumHeight(45) # Compact height: 34px label + small margin - banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - main_overall_vbox.addWidget(banner_row_widget) - - # Add spacing between progress banner and console/details area - main_overall_vbox.addSpacing(8) - - # R&D: File progress list is now in the upper section (replacing Process Monitor) - # Console shows below when "Show details" is checked - # NOTE: File progress list is already added to upper_hbox above - - # Remove spacing - console should expand to fill available space - # --- Console output area (full width, placeholder for now) --- - self.console = QTextEdit() - self.console.setReadOnly(True) - self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) - # R&D: Console starts hidden (only shows when "Show details" is checked) - self.console.setMinimumHeight(0) - self.console.setMaximumHeight(0) - self.console.setVisible(False) - self.console.setFontFamily('monospace') - if self.debug: - self.console.setStyleSheet("border: 2px solid yellow;") - self.console.setToolTip("CONSOLE") - - # Set up scroll tracking for professional auto-scroll behavior - self._setup_scroll_tracking() - - # Create a container that holds console + button row with proper spacing - console_and_buttons_widget = QWidget() - console_and_buttons_layout = QVBoxLayout() - console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) - console_and_buttons_layout.setSpacing(0) # No spacing - console is hidden initially - - # Console with stretch only when visible, buttons always at natural size - console_and_buttons_layout.addWidget(self.console) # No stretch initially - will be set dynamically - console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container - - console_and_buttons_widget.setLayout(console_and_buttons_layout) - self.console_and_buttons_widget = console_and_buttons_widget # Store reference for stretch control - self.console_and_buttons_layout = console_and_buttons_layout # Store reference for spacing control - # Use Minimum size policy - takes only the minimum space needed - console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) - # Constrain height to button row only when console is hidden - match button row height exactly - # Button row is 50px max, so container should be exactly that when collapsed - console_and_buttons_widget.setFixedHeight(50) # Lock to exact button row height when console is hidden - if self.debug: - console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") - console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") - # Add without stretch - let it size naturally to content - main_overall_vbox.addWidget(console_and_buttons_widget) - self.setLayout(main_overall_vbox) - - self.current_modlists = [] - - # --- Process Monitor (right) --- - self.process = None - self.log_timer = None - self.last_log_pos = 0 - # --- Process Monitor Timer --- - self.top_timer = QTimer(self) - self.top_timer.timeout.connect(self.update_top_panel) - 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 - - # Initialize empty controls list - will be populated after UI is built - self._actionable_controls = [] - - # 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 = [ @@ -935,13 +141,12 @@ class InstallModlistScreen(QWidget): super().showEvent(event) # Refresh Nexus auth status when screen becomes visible - # This ensures auth status is updated after user completes OAuth from Settings menu + # Refresh auth status after OAuth from Settings self._update_nexus_status() # Do NOT load saved parent directories - # Note: Gallery cache preload now happens at app startup (see JackifyMainWindow.__init__) - # This ensures cache is ready before user even navigates to this screen + # Gallery cache preloads at app startup (see JackifyMainWindow.__init__) # Ensure initial collapsed layout each time this screen is opened (like TTW screen) try: @@ -963,7 +168,7 @@ class InstallModlistScreen(QWidget): if self._saved_min_size is None: self._saved_min_size = main_window.minimumSize() # Use Qt's standard approach: let layout size naturally, only set minimum - # This allows manual resizing and prevents content cut-off + # Allow manual resizing, prevent content cut-off from PySide6.QtCore import QTimer, QSize from PySide6.QtWidgets import QApplication @@ -998,7 +203,7 @@ class InstallModlistScreen(QWidget): pass # Calculate heights immediately after forcing layout update - # This prevents visible layout shift + # Prevents visible layout shift self.updateGeometry() self.layout().update() QApplication.processEvents() @@ -1065,25 +270,8 @@ class InstallModlistScreen(QWidget): debug_print("DEBUG: Started background gallery cache preload") def hideEvent(self, event): - """Called when the widget is hidden - restore window size constraints""" + """Called when the widget is hidden. Do not clear main window constraints so collapse from go_back() sticks.""" super().hideEvent(event) - try: - # Check if we're on Steam Deck - if so, clear constraints to prevent affecting other screens - main_window = self.window() - is_steamdeck = False - if hasattr(main_window, 'system_info') and main_window.system_info: - is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False) - - if main_window: - from PySide6.QtCore import QSize - # Clear any size constraints that might have been set to prevent affecting other screens - # This is especially important for Steam Deck but also helps on desktop - main_window.setMaximumSize(QSize(16777215, 16777215)) - main_window.setMinimumSize(QSize(0, 0)) - debug_print("DEBUG: Install Modlist hideEvent - cleared window size constraints") - except Exception as e: - debug_print(f"DEBUG: hideEvent exception: {e}") - pass def _load_saved_parent_directories(self): """No-op: do not pre-populate install/download directories from saved values.""" @@ -1116,461 +304,6 @@ class InstallModlistScreen(QWidget): """Removed automatic saving - user should set defaults in settings""" pass - def _update_nexus_status(self): - """Update the Nexus login status display""" - authenticated, method, username = self.auth_service.get_auth_status() - - if authenticated and method == 'oauth': - # OAuth authorised - status_text = "Authorised" - if username: - status_text += f" ({username})" - self.nexus_status.setText(status_text) - self.nexus_status.setStyleSheet("color: #3fd0ea;") - self.nexus_login_btn.setText("Revoke") - self.nexus_login_btn.setVisible(True) - elif authenticated and method == 'api_key': - # API Key in use (fallback - configured in Settings) - self.nexus_status.setText("API Key") - self.nexus_status.setStyleSheet("color: #FFA726;") - self.nexus_login_btn.setText("Authorise") - self.nexus_login_btn.setVisible(True) - else: - # Not authorised - self.nexus_status.setText("Not Authorised") - self.nexus_status.setStyleSheet("color: #f44336;") - self.nexus_login_btn.setText("Authorise") - self.nexus_login_btn.setVisible(True) - - def _show_copyable_url_dialog(self, url: str): - """Show a dialog with a copyable URL""" - from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QApplication - from PySide6.QtCore import Qt - - dialog = QDialog(self) - dialog.setWindowTitle("Manual Browser Open Required") - dialog.setModal(True) - dialog.setMinimumWidth(600) - - layout = QVBoxLayout() - layout.setSpacing(15) - - # Explanation label - info_label = QLabel( - "Could not open browser automatically.\n\n" - "Please copy the URL below and paste it into your browser:" - ) - info_label.setWordWrap(True) - info_label.setStyleSheet("color: #ccc; font-size: 12px;") - layout.addWidget(info_label) - - # URL input (read-only but selectable) - url_input = QLineEdit() - url_input.setText(url) - url_input.setReadOnly(True) - url_input.selectAll() # Pre-select text for easy copying - url_input.setStyleSheet(""" - QLineEdit { - background-color: #1a1a1a; - color: #3fd0ea; - border: 1px solid #444; - border-radius: 4px; - padding: 8px; - font-family: monospace; - font-size: 11px; - } - """) - layout.addWidget(url_input) - - # Button row - button_layout = QHBoxLayout() - button_layout.addStretch() - - # Copy button - copy_btn = QPushButton("Copy URL") - copy_btn.setStyleSheet(""" - QPushButton { - background-color: #3fd0ea; - color: #000; - border: none; - border-radius: 4px; - padding: 8px 20px; - font-weight: bold; - } - QPushButton:hover { - background-color: #5fdfff; - } - """) - def copy_to_clipboard(): - clipboard = QApplication.clipboard() - clipboard.setText(url) - copy_btn.setText("Copied!") - copy_btn.setEnabled(False) - copy_btn.clicked.connect(copy_to_clipboard) - button_layout.addWidget(copy_btn) - - # Close button - close_btn = QPushButton("Close") - close_btn.setStyleSheet(""" - QPushButton { - background-color: #444; - color: #ccc; - border: none; - border-radius: 4px; - padding: 8px 20px; - } - QPushButton:hover { - background-color: #555; - } - """) - close_btn.clicked.connect(dialog.accept) - button_layout.addWidget(close_btn) - - layout.addLayout(button_layout) - - dialog.setLayout(layout) - dialog.exec() - - def _handle_nexus_login_click(self): - """Handle Nexus login button click""" - from jackify.frontends.gui.services.message_service import MessageService - from PySide6.QtWidgets import QMessageBox, QProgressDialog, QApplication - from PySide6.QtCore import Qt, QThread, Signal - - authenticated, method, _ = self.auth_service.get_auth_status() - if authenticated and method == 'oauth': - # OAuth is active - offer to revoke - reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low") - if reply == QMessageBox.Yes: - self.auth_service.revoke_oauth() - self._update_nexus_status() - else: - # Not authorised or using API key - offer to authorise with OAuth - reply = MessageService.question(self, "Authorise with Nexus", - "Your browser will open for Nexus authorisation.\n\n" - "Note: Your browser may ask permission to open 'xdg-open'\n" - "or Jackify's protocol handler - please click 'Open' or 'Allow'.\n\n" - "Please log in and authorise Jackify when prompted.\n\n" - "Continue?", safety_level="low") - - if reply != QMessageBox.Yes: - return - - # Create progress dialog - progress = QProgressDialog( - "Waiting for authorisation...\n\nPlease check your browser.", - "Cancel", - 0, 0, - self - ) - progress.setWindowTitle("Nexus OAuth") - progress.setWindowModality(Qt.WindowModal) - progress.setMinimumDuration(0) - progress.setMinimumWidth(400) - - # Track cancellation - oauth_cancelled = [False] - - def on_cancel(): - oauth_cancelled[0] = True - - progress.canceled.connect(on_cancel) - progress.show() - QApplication.processEvents() - - # Create OAuth thread to prevent GUI freeze - class OAuthThread(QThread): - finished_signal = Signal(bool) - message_signal = Signal(str) - manual_url_signal = Signal(str) # Signal when browser fails to open - - def __init__(self, auth_service, parent=None): - super().__init__(parent) - self.auth_service = auth_service - - def run(self): - def show_message(msg): - # Check if this is a "browser failed" message with URL - if "Could not open browser" in msg and "Please open this URL manually:" in msg: - # Extract URL from message - url_start = msg.find("Please open this URL manually:") + len("Please open this URL manually:") - url = msg[url_start:].strip() - self.manual_url_signal.emit(url) - else: - self.message_signal.emit(msg) - - success = self.auth_service.authorize_oauth(show_browser_message_callback=show_message) - self.finished_signal.emit(success) - - oauth_thread = OAuthThread(self.auth_service, self) - - # Connect message signal to update progress dialog - def update_progress_message(msg): - if not oauth_cancelled[0]: - progress.setLabelText(f"Waiting for authorisation...\n\n{msg}") - QApplication.processEvents() - - # Connect manual URL signal to show copyable dialog - def show_manual_url_dialog(url): - if not oauth_cancelled[0]: - progress.hide() # Hide progress dialog temporarily - self._show_copyable_url_dialog(url) - progress.show() - - oauth_thread.message_signal.connect(update_progress_message) - oauth_thread.manual_url_signal.connect(show_manual_url_dialog) - - # Wait for thread completion - oauth_success = [False] - def on_oauth_finished(success): - oauth_success[0] = success - - oauth_thread.finished_signal.connect(on_oauth_finished) - oauth_thread.start() - - # Wait for thread to finish (non-blocking event loop) - while oauth_thread.isRunning(): - QApplication.processEvents() - oauth_thread.wait(100) # Check every 100ms - if oauth_cancelled[0]: - # User cancelled - thread will still complete but we ignore result - oauth_thread.wait(2000) - if oauth_thread.isRunning(): - oauth_thread.terminate() - break - - progress.close() - QApplication.processEvents() - - self._update_nexus_status() - self._enable_controls_after_operation() - - # Check success first - if OAuth succeeded, ignore cancellation flag - # (progress dialog close can trigger cancel handler even on success) - if oauth_success[0]: - _, _, username = self.auth_service.get_auth_status() - if username: - msg = f"OAuth authorisation successful!

Authorised as: {username}" - else: - msg = "OAuth authorisation successful!" - MessageService.information(self, "Success", msg, safety_level="low") - elif oauth_cancelled[0]: - MessageService.information(self, "Cancelled", "OAuth authorisation cancelled.", safety_level="low") - else: - MessageService.warning( - self, - "Authorisation Failed", - "OAuth authorisation failed.\n\n" - "If you see 'redirect URI mismatch' in your browser,\n" - "the OAuth redirect URI needs to be configured by Nexus.\n\n" - "You can configure an API key in Settings as a fallback.", - safety_level="medium" - ) - - def open_game_type_dialog(self): - dlg = SelectionDialog("Select Game Type", self.game_types, self, show_search=False) - if dlg.exec() == QDialog.Accepted and dlg.selected_item: - self.game_type_btn.setText(dlg.selected_item) - # Store game type for gallery filter - self.current_game_type = dlg.selected_item - # Enable modlist button immediately - gallery will fetch its own data - self.modlist_btn.setEnabled(True) - self.modlist_btn.setText("Select Modlist") - # No need to fetch modlists here - gallery does it when opened - - def fetch_modlists_for_game_type(self, game_type): - self.current_game_type = game_type # Store for display formatting - self.modlist_btn.setText("Fetching modlists...") - self.modlist_btn.setEnabled(False) - game_type_map = { - "Skyrim": "skyrim", - "Fallout 4": "fallout4", - "Fallout New Vegas": "falloutnv", - "Oblivion": "oblivion", - "Starfield": "starfield", - "Oblivion Remastered": "oblivion_remastered", - "Enderal": "enderal", - "Other": "other" - } - cli_game_type = game_type_map.get(game_type, "other") - log_path = self.modlist_log_path - # Use backend service directly - NO CLI CALLS - self.fetch_thread = ModlistFetchThread( - cli_game_type, log_path, mode='list-modlists') - self.fetch_thread.result.connect(self.on_modlists_fetched) - self.fetch_thread.start() - - def on_modlists_fetched(self, modlist_infos, error): - # Handle the case where modlist_infos might be strings (backward compatibility) - if modlist_infos and isinstance(modlist_infos[0], str): - # Old format - just IDs as strings - filtered = [m for m in modlist_infos if m and not m.startswith('DEBUG:')] - self.current_modlists = filtered - self.current_modlist_display = filtered - else: - # New format - full modlist objects with enhanced metadata - filtered_modlists = [m for m in modlist_infos if m and hasattr(m, 'id')] - filtered = filtered_modlists # Set filtered for the condition check below - self.current_modlists = [m.id for m in filtered_modlists] # Keep IDs for selection - - # Create enhanced display strings with size info and status indicators - display_strings = [] - for modlist in filtered_modlists: - # Get enhanced metadata - download_size = getattr(modlist, 'download_size', '') - install_size = getattr(modlist, 'install_size', '') - total_size = getattr(modlist, 'total_size', '') - status_down = getattr(modlist, 'status_down', False) - status_nsfw = getattr(modlist, 'status_nsfw', False) - - # Format display string without redundant game type: "Modlist Name - Download|Install|Total" - # For "Other" category, include game type in brackets for clarity - # Use padding to create alignment: left-aligned name, right-aligned sizes - if hasattr(self, 'current_game_type') and self.current_game_type == "Other": - name_part = f"{modlist.name} [{modlist.game}]" - else: - name_part = modlist.name - size_part = f"{download_size}|{install_size}|{total_size}" - - # Create aligned display using string formatting (approximate alignment) - display_str = f"{name_part:<50} {size_part:>15}" - - # Add status indicators at the beginning if present - if status_down or status_nsfw: - status_parts = [] - if status_down: - status_parts.append("[DOWN]") - if status_nsfw: - status_parts.append("[NSFW]") - display_str = " ".join(status_parts) + " " + display_str - - display_strings.append(display_str) - - self.current_modlist_display = display_strings - - # Create mapping from display string back to modlist ID for selection - self._modlist_id_map = {} - if len(self.current_modlist_display) == len(self.current_modlists): - self._modlist_id_map = {display: modlist_id for display, modlist_id in - zip(self.current_modlist_display, self.current_modlists)} - else: - # Fallback for backward compatibility - self._modlist_id_map = {mid: mid for mid in self.current_modlists} - if error: - self.modlist_btn.setText("Error fetching modlists.") - self.modlist_btn.setEnabled(False) - # Don't write to log file before workflow starts - just show error in UI - elif filtered: - self.modlist_btn.setText("Select Modlist") - self.modlist_btn.setEnabled(True) - else: - self.modlist_btn.setText("No modlists found.") - self.modlist_btn.setEnabled(False) - - def open_modlist_dialog(self): - # CRITICAL: Prevent opening gallery without game type selected - # This prevents potential issues with engine path resolution and subprocess spawning - if not hasattr(self, 'current_game_type') or not self.current_game_type: - from PySide6.QtWidgets import QMessageBox - QMessageBox.warning( - self, - "Game Type Required", - "Please select a game type before opening the modlist gallery." - ) - return - - from PySide6.QtWidgets import QApplication - self.modlist_btn.setEnabled(False) - cursor_overridden = False - try: - QApplication.setOverrideCursor(Qt.WaitCursor) - cursor_overridden = True - - game_type_to_human_friendly = { - "Skyrim": "Skyrim Special Edition", - "Fallout 4": "Fallout 4", - "Fallout New Vegas": "Fallout New Vegas", - "Oblivion": "Oblivion", - "Starfield": "Starfield", - "Oblivion Remastered": "Oblivion", - "Enderal": "Enderal Special Edition", - "Other": None - } - - game_filter = None - if hasattr(self, 'current_game_type'): - game_filter = game_type_to_human_friendly.get(self.current_game_type) - - dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self) - if cursor_overridden: - QApplication.restoreOverrideCursor() - cursor_overridden = False - - if dlg.exec() == QDialog.Accepted and dlg.selected_metadata: - metadata = dlg.selected_metadata - self.modlist_btn.setText(metadata.title) - self.selected_modlist_info = { - 'machine_url': metadata.namespacedName, - 'title': metadata.title, - 'author': metadata.author, - 'game': metadata.gameHumanFriendly, - 'description': metadata.description, - 'nsfw': metadata.nsfw, - 'force_down': metadata.forceDown - } - self.modlist_name_edit.setText(metadata.title) - - # Auto-append modlist name to install directory - base_install_dir = self.config_handler.get_modlist_install_base_dir() - if base_install_dir: - # Sanitize modlist title for filesystem use - import re - safe_title = re.sub(r'[<>:"/\\|?*]', '', metadata.title) - safe_title = safe_title.strip() - modlist_install_path = os.path.join(base_install_dir, safe_title) - self.install_dir_edit.setText(modlist_install_path) - finally: - if cursor_overridden: - QApplication.restoreOverrideCursor() - self.modlist_btn.setEnabled(True) - - 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) - - 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) - - 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) - - def go_back(self): - """Navigate back to main menu and restore window size""" - # Emit collapse signal to restore compact mode - self.resize_request.emit('collapse') - - # Restore window size before navigating away - try: - main_window = self.window() - if main_window: - from PySide6.QtCore import QSize - from ..utils import apply_window_size_and_position - - # Only set minimum size - DO NOT RESIZE - main_window.setMaximumSize(QSize(16777215, 16777215)) - set_responsive_minimum(main_window, min_width=960, min_height=420) - # DO NOT resize - let window stay at current size - except Exception: - pass - - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(self.main_menu_index) - def update_top_panel(self): try: result = subprocess.run([ @@ -1630,3157 +363,11 @@ class InstallModlistScreen(QWidget): "Continuing anyway, but some features may not work correctly.") return True # Continue anyway - def _check_ttw_eligibility(self, modlist_name: str, game_type: str, install_dir: str) -> bool: - """Check if modlist is FNV, TTW-compatible, and doesn't already have TTW - - Args: - modlist_name: Name of the installed modlist - game_type: Game type (e.g., 'falloutnv') - install_dir: Modlist installation directory - - Returns: - bool: True if should offer TTW integration - """ - try: - # Check 1: Must be Fallout New Vegas - if game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']: - return False - - # Check 2: Must be on whitelist - from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible - if not is_ttw_compatible(modlist_name): - return False - - # Check 3: TTW must not already be installed - if self._detect_existing_ttw(install_dir): - debug_print("DEBUG: TTW already installed, skipping prompt") - return False - - return True - - except Exception as e: - debug_print(f"DEBUG: Error checking TTW eligibility: {e}") - return False - - def _detect_existing_ttw(self, install_dir: str) -> bool: - """Check if TTW is already installed in the modlist - - Args: - install_dir: Modlist installation directory - - Returns: - bool: True if TTW is already present - """ - try: - from pathlib import Path - - mods_dir = Path(install_dir) / "mods" - if not mods_dir.exists(): - return False - - # Check for folders containing "Tale of Two Wastelands" that have actual TTW content - # Exclude separators and placeholder folders - for folder in mods_dir.iterdir(): - if not folder.is_dir(): - continue - - folder_name_lower = folder.name.lower() - - # Skip separator folders and placeholders - if "_separator" in folder_name_lower or "put" in folder_name_lower or "here" in folder_name_lower: - continue - - # Check if folder name contains TTW indicator - if "tale of two wastelands" in folder_name_lower: - # Verify it has actual TTW content by checking for the main ESM - ttw_esm = folder / "TaleOfTwoWastelands.esm" - if ttw_esm.exists(): - debug_print(f"DEBUG: Found existing TTW installation: {folder.name}") - return True - else: - debug_print(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}") - - return False - - except Exception as e: - debug_print(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): - """Navigate to TTW screen and set it up for modlist integration - - Args: - modlist_name: Name of the modlist that needs TTW integration - install_dir: Path to the modlist installation directory - """ - try: - # Store modlist context for later use when TTW completes - self._ttw_modlist_name = modlist_name - self._ttw_install_dir = install_dir - - # Get reference to TTW screen BEFORE navigation - if self.stacked_widget: - 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) - - # Connect to completion signal to show success dialog after TTW - if hasattr(ttw_screen, 'integration_complete'): - ttw_screen.integration_complete.connect(self._on_ttw_integration_complete) - else: - 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) - - # Force collapsed state shortly after navigation to avoid any - # showEvent/layout timing races that may leave it expanded - try: - from PySide6.QtCore import QTimer - QTimer.singleShot(50, lambda: getattr(ttw_screen, 'force_collapsed_state', lambda: None)()) - except Exception: - pass - - except Exception as e: - debug_print(f"ERROR: Failed to initiate TTW workflow: {e}") - MessageService.critical( - self, - "TTW Navigation Failed", - f"Failed to navigate to TTW installation screen: {str(e)}" - ) - - def _on_ttw_integration_complete(self, success: bool, ttw_version: str = ""): - """Handle completion of TTW integration and show final success dialog - - Args: - success: Whether TTW integration completed successfully - ttw_version: Version of TTW that was installed - """ - try: - if not success: - MessageService.critical( - self, - "TTW Integration Failed", - "Tale of Two Wastelands integration did not complete successfully." - ) - return - - # Navigate back to this screen to show success dialog - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(4) - - # Calculate elapsed time from workflow start - import time - if hasattr(self, '_install_workflow_start_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" - else: - time_str = "unknown" - - # Build success message including TTW installation - modlist_name = getattr(self, '_ttw_modlist_name', 'Unknown') - game_name = "Fallout New Vegas" - - # Check for VNV post-install automation after TTW installation - vnv_automation_running = False - if hasattr(self, '_ttw_install_dir') and hasattr(self, '_ttw_modlist_name'): - vnv_automation_running = self._check_and_run_vnv_automation(self._ttw_modlist_name, self._ttw_install_dir) - - if vnv_automation_running: - # Store success dialog params for later (after VNV automation completes) - self._pending_success_dialog_params = { - 'modlist_name': modlist_name, - 'time_taken': time_str, - 'game_name': game_name, - 'enb_detected': False, # TTW installs don't have ENB - 'ttw_version': ttw_version if 'ttw_version' in locals() else None - } - # Keep post-install feedback active during VNV automation - # Don't show success dialog yet - will be shown in _on_vnv_complete - return - - # No VNV automation - end post-install feedback now - self._end_post_install_feedback(True) - - # Clear Activity window before showing success dialog - self.file_progress_list.clear() - - # Show enhanced success dialog - success_dialog = SuccessDialog( - modlist_name=modlist_name, - workflow_type="install", - time_taken=time_str, - game_name=game_name, - parent=self - ) - - # Add TTW installation info to dialog if possible - if 'ttw_version' in locals() and hasattr(success_dialog, 'add_info_line'): - success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully") - - success_dialog.show() - - except Exception as e: - debug_print(f"ERROR: Failed to show final success dialog: {e}") - MessageService.critical( - self, - "Display Error", - f"TTW integration completed but failed to show success dialog: {str(e)}" - ) - - def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool: - """Check if VNV automation should run and execute if applicable in background thread - - Args: - modlist_name: Name of the installed modlist - install_dir: Installation directory path - - Returns: - True if VNV automation is starting (success dialog should be deferred) - False if no VNV automation needed (show success dialog immediately) - """ - try: - from pathlib import Path - from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation - from jackify.backend.handlers.path_handler import PathHandler - from jackify.backend.services.vnv_post_install_service import VNVPostInstallService - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - - # Get paths first (needed for VNV detection) - install_path = Path(install_dir) - - # Quick check before importing more (pass install location for ModOrganizer.ini check) - if not should_offer_vnv_automation(modlist_name, install_path): - return False - - game_paths = PathHandler().find_vanilla_game_paths() - game_root = game_paths.get('Fallout New Vegas') - - if not game_root: - debug_print("DEBUG: VNV automation skipped - FNV game root not found") - return False - - # Initialize service to check completion status - vnv_service = VNVPostInstallService( - modlist_install_location=install_path, - game_root=game_root, - ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path() - ) - - # Check what's already done - completed = vnv_service.check_already_completed() - # Only skip if ALL three steps are completed - if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']: - logger.info("VNV automation steps already completed") - return False - - # Get automation description for confirmation - description = vnv_service.get_automation_description() - - # Show confirmation dialog ON MAIN THREAD (not in worker thread!) - from ..services.message_service import MessageService - reply = MessageService.question( - self, - "VNV Post-Install Automation", - description, - critical=False, - safety_level="medium" - ) - - if reply != QMessageBox.Yes: - logger.info("User declined VNV automation") - return False - - # Manual file callback for non-Premium users - def manual_file_callback(title: str, instructions: str) -> Optional[Path]: - from PySide6.QtWidgets import QFileDialog - from ..services.message_service import MessageService - - # Show instructions - MessageService.information(self, title, instructions) - - # Open file picker - file_path, _ = QFileDialog.getOpenFileName( - self, - title, - str(Path.home() / "Downloads"), - "All Files (*.*)" - ) - - if file_path: - return Path(file_path) - return None - - # Enable post-install progress tracking for VNV automation - self._begin_post_install_feedback() - - # User confirmed - start automation in background thread - self._run_vnv_automation_threaded( - modlist_name, - install_path, - game_root, - manual_file_callback - ) - - return True # VNV automation is running, defer success dialog - - except Exception as e: - debug_print(f"ERROR: Failed to start VNV automation: {e}") - import traceback - debug_print(f"Traceback: {traceback.format_exc()}") - return False # Error - show success dialog anyway - - def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root, - manual_file_callback): - """Run VNV automation in a background thread with progress updates - - Note: User confirmation should already be obtained before calling this method. - """ - from PySide6.QtCore import QThread, Signal - from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable - from jackify.backend.services.automated_prefix_service import AutomatedPrefixService - - class VNVAutomationWorker(QThread): - progress_update = Signal(str) - completed = Signal(bool, str) # (success, error_message) - - def __init__(self, modlist_name, install_path, game_root, ttw_installer_path, - manual_file_callback): - super().__init__() - self.modlist_name = modlist_name - self.install_path = install_path - self.game_root = game_root - self.ttw_installer_path = ttw_installer_path - self.manual_file_callback = manual_file_callback - - def run(self): - try: - # User already confirmed, pass lambda that always returns True - automation_ran, error = run_vnv_automation_if_applicable( - modlist_name=self.modlist_name, - modlist_install_location=self.install_path, - game_root=self.game_root, - ttw_installer_path=self.ttw_installer_path, - progress_callback=self.progress_update.emit, - manual_file_callback=self.manual_file_callback, - confirmation_callback=lambda desc: True # Already confirmed on main thread - ) - self.completed.emit(error is None, error or "") - except Exception as e: - import traceback - self.completed.emit(False, f"Exception: {str(e)}\n{traceback.format_exc()}") - - # Create and start worker - self.vnv_worker = VNVAutomationWorker( - modlist_name, - install_path, - game_root, - AutomatedPrefixService.get_ttw_installer_path(), - manual_file_callback - ) - - # Connect signals - self.vnv_worker.progress_update.connect(self._on_vnv_progress) - self.vnv_worker.completed.connect(self._on_vnv_complete) - self.vnv_worker.finished.connect(self.vnv_worker.deleteLater) - - # Start worker - self.vnv_worker.start() - - def _on_vnv_progress(self, message: str): - """Handle VNV automation progress updates""" - self._safe_append_text(message) - # Also update progress indicator, Activity window, and Details window - self._handle_post_install_progress(message) - - def _on_vnv_complete(self, success: bool, error: str): - """Handle VNV automation completion and show deferred success dialog""" - # End post-install feedback now that VNV automation is complete - self._end_post_install_feedback(True) - - if not success and error: - from ..services.message_service import MessageService - MessageService.warning( - self, - "VNV Automation Failed", - f"VNV post-install automation encountered an error:\n\n{error}\n\n" - "You can complete these steps manually by following the guide at:\n" - "https://vivanewvegas.moddinglinked.com/wabbajack.html" - ) - elif success: - self._safe_append_text("VNV post-install automation completed successfully") - - # Show the deferred success dialog now that VNV automation is complete - if hasattr(self, '_pending_success_dialog_params'): - params = self._pending_success_dialog_params - del self._pending_success_dialog_params # Clean up - - # Clear Activity window before showing success dialog - self.file_progress_list.clear() - - # Show success dialog - from ..dialogs import SuccessDialog - success_dialog = SuccessDialog( - modlist_name=params['modlist_name'], - workflow_type="install", - time_taken=params['time_taken'], - game_name=params['game_name'], - parent=self - ) - success_dialog.show() - - # Show ENB Proton dialog if ENB was detected - if params.get('enb_detected'): - try: - from ..dialogs.enb_proton_dialog import ENBProtonDialog - enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self) - enb_dialog.exec() # Modal dialog - blocks until user clicks OK - except Exception as e: - # Non-blocking: if dialog fails, just log and continue - logger.warning(f"Failed to show ENB dialog: {e}") - - - def validate_and_start_install(self): - import time - self._install_workflow_start_time = time.time() - debug_print('DEBUG: validate_and_start_install called') - - # Immediately show "Initialising" status to provide feedback - self.progress_indicator.set_status("Initialising...", 0) - QApplication.processEvents() # Force UI update - - # Reload config to pick up any settings changes made in Settings dialog - self.config_handler.reload_config() - - # Check protontricks before proceeding - if not self._check_protontricks(): - self.progress_indicator.reset() - return - - # Disable all controls during installation (except Cancel) - self._disable_controls_during_operation() - - try: - tab_index = self.source_tabs.currentIndex() - install_mode = 'online' - if tab_index == 1: # .wabbajack File tab - modlist = self.file_edit.text().strip() - if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'): - self._abort_with_message( - "warning", - "Invalid Modlist", - "Please select a valid .wabbajack file." - ) - return - install_mode = 'file' - else: - # For online modlists, ALWAYS use machine_url from selected_modlist_info - # Button text is now the display name (title), NOT the machine URL - if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info: - self._abort_with_message( - "warning", - "Invalid Modlist", - "Modlist information is missing. Please select the modlist again from the gallery." - ) - return - - machine_url = self.selected_modlist_info.get('machine_url') - if not machine_url: - self._abort_with_message( - "warning", - "Invalid Modlist", - "Modlist information is incomplete. Please select the modlist again from the gallery." - ) - return - - # CRITICAL: Use machine_url, NOT button text - modlist = machine_url - install_dir = self.install_dir_edit.text().strip() - downloads_dir = self.downloads_dir_edit.text().strip() - - # Get authentication token (OAuth or API key) with automatic refresh - api_key, oauth_info = self.auth_service.get_auth_for_engine() - if not api_key: - self._abort_with_message( - "warning", - "Authorisation Required", - "Please authorise with Nexus Mods before installing modlists.\n\n" - "Click the 'Authorise' button above to log in with OAuth,\n" - "or configure an API key in Settings.", - safety_level="medium" - ) - 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") - logger.info(f"Method: {auth_method or 'UNKNOWN'}") - logger.info(f"Token length: {len(api_key)} chars") - if len(api_key) >= 8: - logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}") - - if auth_method == 'oauth': - token_handler = self.auth_service.token_handler - token_info = token_handler.get_token_info() - if 'expires_in_minutes' in token_info: - logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes") - if token_info.get('refresh_token_likely_expired'): - logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)") - logger.info("=" * 60) - - modlist_name = self.modlist_name_edit.text().strip() - missing_fields = [] - if not modlist_name: - missing_fields.append("Modlist Name") - if not install_dir: - missing_fields.append("Install Directory") - if not downloads_dir: - missing_fields.append("Downloads Directory") - if missing_fields: - self._abort_with_message( - "warning", - "Missing Required Fields", - "Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields) - ) - return - validation_handler = ValidationHandler() - from pathlib import Path - is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir)) - if not is_safe: - dlg = WarningDialog(reason, parent=self) - result = dlg.exec() - if not result or not dlg.confirmed: - self._abort_install_validation() - return - 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 - ) - 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}") - self._abort_install_validation() - return - else: - self._abort_install_validation() - return - if not os.path.isdir(downloads_dir): - create = MessageService.question(self, "Create Directory?", - f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?", - critical=False # Non-critical, won't steal focus - ) - if create == QMessageBox.Yes: - try: - os.makedirs(downloads_dir, exist_ok=True) - except Exception as e: - MessageService.critical(self, "Error", f"Failed to create downloads directory:\n{e}") - self._abort_install_validation() - return - else: - self._abort_install_validation() - return - - # Handle resolution saving - resolution = self.resolution_combo.currentText() - if resolution and resolution != "Leave unchanged": - success = self.resolution_service.save_resolution(resolution) - if success: - debug_print(f"DEBUG: Resolution saved successfully: {resolution}") - else: - debug_print("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") - - # Handle parent directory saving - self._save_parent_directories(install_dir, downloads_dir) - - # Detect game type and check support - game_type = None - game_name = None - - if install_mode == 'file': - # Parse .wabbajack file to get game type - from pathlib import Path - wabbajack_path = Path(modlist) - result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path) - if result: - if isinstance(result, tuple): - game_type, raw_game_type = result - # Get display name for the game - display_names = { - 'skyrim': 'Skyrim', - 'fallout4': 'Fallout 4', - 'falloutnv': 'Fallout New Vegas', - 'oblivion': 'Oblivion', - 'starfield': 'Starfield', - 'oblivion_remastered': 'Oblivion Remastered', - 'enderal': 'Enderal' - } - if game_type == 'unknown' and raw_game_type: - game_name = raw_game_type - else: - game_name = display_names.get(game_type, game_type) - else: - game_type = result - 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(game_type, game_type) - else: - # 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', '') - debug_print(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'") - - # Map game name to game type - game_mapping = { - 'skyrim special edition': 'skyrim', - 'skyrim': 'skyrim', - 'fallout 4': 'fallout4', - 'fallout new vegas': 'falloutnv', - 'oblivion': 'oblivion', - 'starfield': 'starfield', - 'oblivion_remastered': 'oblivion_remastered', - 'enderal': 'enderal', - 'enderal special edition': 'enderal' - } - game_type = game_mapping.get(game_name.lower()) - debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") - if not game_type: - game_type = 'unknown' - debug_print(f"DEBUG: Game type not found in mapping, setting to 'unknown'") - else: - debug_print(f"DEBUG: No selected_modlist_info found") - game_type = 'unknown' - - # Store game type and name for later use - self._current_game_type = game_type - self._current_game_name = game_name - - # Check if game is supported - debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported") - debug_print(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}") - - if game_type and not is_supported: - debug_print(f"DEBUG: Game '{game_type}' is not supported, showing dialog") - # Show unsupported game dialog - dialog = UnsupportedGameDialog(self, game_name) - if not dialog.show_dialog(self, game_name): - self._abort_install_validation() - return - - self.console.clear() - self.process_monitor.clear() - - # R&D: Reset progress indicator for new installation - self.progress_indicator.reset() - self.progress_state_manager.reset() - 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_notified = False - self._token_error_notified = False # Reset token error notification - self._premium_failure_active = False - self._post_install_active = False - self._post_install_current_step = 0 - # Activity tab is always visible (tabs handle visibility automatically) - - # Update button states for installation - self.start_btn.setEnabled(False) - self.cancel_btn.setVisible(False) - self.cancel_install_btn.setVisible(True) - - # CRITICAL: Final safety check - ensure online modlists use machine_url - if install_mode == 'online': - if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: - expected_machine_url = self.selected_modlist_info.get('machine_url') - if expected_machine_url: - modlist = expected_machine_url # Force use machine_url - else: - self._abort_with_message( - "critical", - "Installation Error", - "Cannot determine modlist machine URL. Please select the modlist again." - ) - return - else: - self._abort_with_message( - "critical", - "Installation Error", - "Modlist information is missing. Please select the modlist again from the gallery." - ) - return - - debug_print(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: - 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 - 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") - - def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None): - debug_print('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 - 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() - from jackify import __version__ as jackify_version - self._safe_append_text(f"Jackify v{jackify_version}") - self._safe_append_text("Starting modlist installation with custom progress handling...") - - # 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 InstallationThread(QThread): - output_received = Signal(str) - progress_received = Signal(str) - progress_updated = Signal(object) # R&D: Emits InstallationProgress object - installation_finished = Signal(bool, str) - premium_required_detected = Signal(str) - - def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name, install_mode='online', progress_state_manager=None, auth_service=None, oauth_info=None): - super().__init__() - self.modlist = modlist - self.install_dir = install_dir - self.downloads_dir = downloads_dir - self.api_key = api_key - self.modlist_name = modlist_name - self.install_mode = install_mode - self.cancelled = False - self.process_manager = None - # R&D: Progress state manager for parsing - self.progress_state_manager = progress_state_manager - self.auth_service = auth_service - self.oauth_info = oauth_info - self._premium_signal_sent = False - # Rolling buffer for Premium detection diagnostics - self._engine_output_buffer = [] - self._buffer_size = 10 - - def cancel(self): - self.cancelled = True - if self.process_manager: - self.process_manager.cancel() - - def run(self): - try: - engine_path = get_jackify_engine_path() - - # Verify engine exists and is executable - if not os.path.exists(engine_path): - error_msg = f"Engine not found at: {engine_path}" - debug_print(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}") - self.installation_finished.emit(False, error_msg) - return - - debug_print(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: - cmd = [engine_path, "install", "--show-file-progress", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] - - # Check for debug mode and add --debug flag - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - if debug_mode: - cmd.append('--debug') - debug_print("DEBUG: Added --debug flag to jackify-engine command") - - # CRITICAL: Print the FULL command so we can see exactly what's being passed - debug_print(f"DEBUG: FULL Engine command: {' '.join(cmd)}") - debug_print(f"DEBUG: modlist value being passed: '{self.modlist}'") - - # Use clean subprocess environment to prevent AppImage variable inheritance - from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env - env_vars = {'NEXUS_API_KEY': self.api_key} - if self.oauth_info: - env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info - # CRITICAL: Set client_id so engine can refresh tokens with correct client_id - # Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack" - from jackify.backend.services.nexus_oauth_service import NexusOAuthService - env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID - env = get_clean_subprocess_env(env_vars) - self.process_manager = ProcessManager(cmd, env=env, text=False) - ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') - buffer = b'' - last_was_blank = False - while True: - if self.cancelled: - self.cancel() - break - char = self.process_manager.read_stdout_char() - if not char: - break - buffer += char - while b'\n' in buffer or b'\r' in buffer: - if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True): - line, buffer = buffer.split(b'\r', 1) - line = ansi_escape.sub(b'', line) - decoded = line.decode('utf-8', errors='replace') - - # Notify when Nexus requires Premium before continuing - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - - # Check for Premium detection - 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 - - # DIAGNOSTIC LOGGING: Capture false positive details - import logging - logger = logging.getLogger(__name__) - logger.warning("=" * 80) - logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)") - logger.warning("=" * 80) - logger.warning(f"Matched pattern: '{matched_pattern}'") - logger.warning(f"Triggering line: '{decoded.strip()}'") - - # Detailed auth diagnostics - logger.warning("") - logger.warning("AUTHENTICATION DIAGNOSTICS:") - logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}") - if self.api_key: - logger.warning(f" Auth value length: {len(self.api_key)} chars") - if len(self.api_key) >= 8: - logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}") - - # Determine auth method and get detailed status - auth_method = self.auth_service.get_auth_method() - logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}") - - if auth_method == 'oauth': - # Get detailed OAuth token status - token_handler = self.auth_service.token_handler - token_info = token_handler.get_token_info() - - logger.warning(" OAuth Token Status:") - logger.warning(f" Has token file: {token_info.get('has_token', False)}") - logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}") - - if 'expires_in_minutes' in token_info: - logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes") - logger.warning(f" Is expired: {token_info.get('is_expired', False)}") - logger.warning(f" Expires soon (5min): {token_info.get('expires_soon_5min', False)}") - - if 'refresh_token_age_days' in token_info: - logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days") - logger.warning(f" Refresh token likely expired: {token_info.get('refresh_token_likely_expired', False)}") - - if token_info.get('error'): - logger.warning(f" Error: {token_info['error']}") - - logger.warning("") - logger.warning("Previous engine output (last 10 lines):") - for i, buffered_line in enumerate(self._engine_output_buffer, 1): - logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}") - logger.warning("") - logger.warning("If user HAS Premium, this is a FALSE POSITIVE") - logger.warning("Report to: https://github.com/Omni-guides/Jackify/issues/111") - logger.warning("=" * 80) - - self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") - - # Maintain rolling buffer of engine output for diagnostics - self._engine_output_buffer.append(decoded.strip()) - if len(self._engine_output_buffer) > self._buffer_size: - self._engine_output_buffer.pop(0) - - # R&D: Process through progress parser - if self.progress_state_manager: - updated = self.progress_state_manager.process_line(decoded) - if updated: - progress_state = self.progress_state_manager.get_state() - # Debug: Log when we detect file progress - 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]}") - self.progress_updated.emit(progress_state) - # Filter FILE_PROGRESS spam but keep the status line before it - if '[FILE_PROGRESS]' in decoded: - parts = decoded.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - self.progress_received.emit(parts[0].rstrip()) - else: - # Preserve \r line ending for progress updates - self.progress_received.emit(decoded + '\r') - elif b'\n' in buffer: - line, buffer = buffer.split(b'\n', 1) - line = ansi_escape.sub(b'', line) - decoded = line.decode('utf-8', errors='replace') - - # Notify when Nexus requires Premium before continuing - 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 - - # DIAGNOSTIC LOGGING: Capture false positive details - import logging - logger = logging.getLogger(__name__) - logger.warning("=" * 80) - logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)") - logger.warning("=" * 80) - logger.warning(f"Matched pattern: '{matched_pattern}'") - logger.warning(f"Triggering line: '{decoded.strip()}'") - - # Detailed auth diagnostics - logger.warning("") - logger.warning("AUTHENTICATION DIAGNOSTICS:") - logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}") - if self.api_key: - logger.warning(f" Auth value length: {len(self.api_key)} chars") - if len(self.api_key) >= 8: - logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}") - - # Determine auth method and get detailed status - auth_method = self.auth_service.get_auth_method() - logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}") - - if auth_method == 'oauth': - # Get detailed OAuth token status - token_handler = self.auth_service.token_handler - token_info = token_handler.get_token_info() - - logger.warning(" OAuth Token Status:") - logger.warning(f" Has token file: {token_info.get('has_token', False)}") - logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}") - - if 'expires_in_minutes' in token_info: - logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes") - logger.warning(f" Is expired: {token_info.get('is_expired', False)}") - logger.warning(f" Expires soon (5min): {token_info.get('expires_soon_5min', False)}") - - if 'refresh_token_age_days' in token_info: - logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days") - logger.warning(f" Refresh token likely expired: {token_info.get('refresh_token_likely_expired', False)}") - - if token_info.get('error'): - logger.warning(f" Error: {token_info['error']}") - - logger.warning("") - logger.warning("Previous engine output (last 10 lines):") - for i, buffered_line in enumerate(self._engine_output_buffer, 1): - logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}") - logger.warning("") - logger.warning("If user HAS Premium, this is a FALSE POSITIVE") - logger.warning("Report to: https://github.com/Omni-guides/Jackify/issues/111") - logger.warning("=" * 80) - - self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") - - # Maintain rolling buffer of engine output for diagnostics - self._engine_output_buffer.append(decoded.strip()) - if len(self._engine_output_buffer) > self._buffer_size: - self._engine_output_buffer.pop(0) - - # R&D: Process through progress parser - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - debug_mode = config_handler.get('debug_mode', False) - if self.progress_state_manager: - updated = self.progress_state_manager.process_line(decoded) - if updated: - progress_state = self.progress_state_manager.get_state() - # Debug: Log when we detect file progress - 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]}") - self.progress_updated.emit(progress_state) - # Filter FILE_PROGRESS spam but keep the status line before it - if '[FILE_PROGRESS]' in decoded: - parts = decoded.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - self.output_received.emit(parts[0].rstrip()) - last_was_blank = False - continue - - # Collapse multiple blank lines to one - if decoded.strip() == '': - if not last_was_blank: - self.output_received.emit('\n') - last_was_blank = True - else: - # Preserve \n line ending for normal output - self.output_received.emit(decoded + '\n') - last_was_blank = False - if buffer: - line = ansi_escape.sub(b'', buffer) - decoded = line.decode('utf-8', errors='replace') - # Filter FILE_PROGRESS from final buffer flush too - if '[FILE_PROGRESS]' in decoded: - parts = decoded.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - self.output_received.emit(parts[0].rstrip()) - else: - self.output_received.emit(decoded) - - # Wait for process to complete - returncode = self.process_manager.wait() - - # Capture any remaining output after process ends - if self.process_manager.proc and self.process_manager.proc.stdout: - try: - remaining = self.process_manager.proc.stdout.read() - 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]}") - # Filter FILE_PROGRESS from remaining output too - if '[FILE_PROGRESS]' in decoded_remaining: - parts = decoded_remaining.split('[FILE_PROGRESS]', 1) - if parts[0].strip(): - self.output_received.emit(parts[0].rstrip()) - else: - self.output_received.emit(decoded_remaining) - except Exception as e: - debug_print(f"DEBUG: Error reading remaining output: {e}") - - 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}") - # Try to get more details from the process - if self.process_manager.proc: - debug_print(f"DEBUG: Process stderr/stdout may contain error details") - self.installation_finished.emit(False, error_msg) - except Exception as e: - self.installation_finished.emit(False, f"Installation error: {str(e)}") - finally: - if self.cancelled and self.process_manager: - self.process_manager.cancel() - - # After the InstallationThread class definition, add: - self.install_thread = InstallationThread( - modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode, - progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager - auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics - oauth_info=oauth_info # Pass OAuth state for auto-refresh - ) - self.install_thread.output_received.connect(self.on_installation_output) - self.install_thread.progress_received.connect(self.on_installation_progress) - self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update - self.install_thread.installation_finished.connect(self.on_installation_finished) - self.install_thread.premium_required_detected.connect(self.on_premium_required_detected) - # R&D: Pass progress state manager to thread - self.install_thread.progress_state_manager = self.progress_state_manager - self.install_thread.start() - - def on_installation_output(self, message): - """Handle regular output from installation thread""" - # 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 - - # CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode) - msg_lower = message.lower() - token_error_keywords = [ - 'token has expired', - 'token expired', - 'oauth token', - 'authentication failed', - 'unauthorized', - '401', - '403', - 'refresh token', - 'authorization failed', - 'nexus.*premium.*required', - 'premium.*required', - ] - - 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'): - self._token_error_notified = True - # Show error dialog immediately - from jackify.frontends.gui.services.message_service import MessageService - MessageService.error( - self, - "Authentication Error", - ( - "Nexus Mods authentication has failed. This may be due to:\n\n" - "• OAuth token expired and refresh failed\n" - "• Nexus Premium required for this modlist\n" - "• Network connectivity issues\n\n" - "Please check the console output (Show Details) for more information.\n" - "You may need to re-authorize in Settings." - ), - safety_level="high" - ) - # Also show in console - guidance = ( - "\n[Jackify] ⚠️ CRITICAL: Authentication/Token Error Detected!\n" - "[Jackify] This may cause downloads to stop. Check the error message above.\n" - "[Jackify] If OAuth token expired, go to Settings and re-authorize.\n" - ) - self._safe_append_text(guidance) - # Force console to be visible so user can see the error - if not self.show_details_checkbox.isChecked(): - self.show_details_checkbox.setChecked(True) - - # Detect known engine bugs and provide helpful guidance - if 'destination array was not long enough' in msg_lower or \ - ('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower): - # This is a known bug in jackify-engine 0.4.0 during .wabbajack download - if not hasattr(self, '_array_error_notified'): - self._array_error_notified = True - guidance = ( - "\n[Jackify] Engine Error Detected: Buffer size issue during .wabbajack download.\n" - "[Jackify] This is a known bug in jackify-engine 0.4.0.\n" - "[Jackify] Workaround: Delete any partial .wabbajack files in your downloads directory and try again.\n" - ) - self._safe_append_text(guidance) - - # R&D: Always write output to console buffer so it's available when user toggles Show Details - # The console visibility is controlled by the checkbox, not whether we write to it - self._safe_append_text(message) - - def on_installation_progress(self, progress_message): - """ - Handle progress messages from installation thread. - - NOTE: This is called for MOST engine output, not just progress lines! - The name is misleading - it's actually the main output path. - """ - # Always write output to console buffer (same as on_installation_output) - self._safe_append_text(progress_message) - - def on_premium_required_detected(self, engine_line: str): - """Handle detection of Nexus Premium requirement.""" - if self._premium_notice_shown: - return - - self._premium_notice_shown = True - self._premium_failure_active = True - - user_message = ( - "Nexus Mods rejected the automated download because this account is not Premium. " - "Jackify currently requires a Nexus Premium membership for automated installs, " - "and non-premium support is still planned." - ) - - if engine_line: - self._safe_append_text(f"[Jackify] Engine message: {engine_line}") - self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.") - - MessageService.critical( - self, - "Nexus Premium Required", - f"{user_message}\n\nDetected engine output:\n{engine_line or 'Buy Nexus Premium to automate this process.'}", - safety_level="medium" - ) - - if hasattr(self, 'install_thread') and self.install_thread: - self.install_thread.cancel() - - def on_progress_updated(self, progress_state): - """R&D: Handle structured progress updates from parser""" - # Calculate proper overall progress during BSA building - # During BSA building, file installation is at 100% but BSAs are still being built - # Override overall_percent to show BSA building progress instead - if progress_state.bsa_building_total > 0 and progress_state.bsa_building_current > 0: - bsa_percent = (progress_state.bsa_building_current / progress_state.bsa_building_total) * 100.0 - progress_state.overall_percent = min(99.0, bsa_percent) # Cap at 99% until fully complete - - # CRITICAL: Detect stalled downloads (0.0MB/s for extended period) - # This catches cases where token refresh fails silently or network issues occur - # IMPORTANT: Only check during DOWNLOAD phase, not during VALIDATE phase - # Validation checks existing files and shows 0.0MB/s, which is expected behavior - import time - if progress_state.phase == InstallationPhase.DOWNLOAD: - speed_display = progress_state.get_overall_speed_display() - # Check if speed is 0 or very low (< 0.1MB/s) for more than 2 minutes - # Only trigger if we're actually in download phase (not validation) - is_stalled = not speed_display or speed_display == "0.0B/s" or \ - (speed_display and any(x in speed_display.lower() for x in ['0.0mb/s', '0.0kb/s', '0b/s'])) - - # Additional check: Only consider it stalled if we have active download files - # If no files are being downloaded, it might just be between downloads - has_active_downloads = any( - f.operation == OperationType.DOWNLOAD and not f.is_complete - for f in progress_state.active_files - ) - - if is_stalled and has_active_downloads: - if self._stalled_download_start_time is None: - self._stalled_download_start_time = time.time() - 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 - from jackify.frontends.gui.services.message_service import MessageService - MessageService.warning( - self, - "Download Stalled", - ( - "Downloads have been stalled (0.0MB/s) for over 2 minutes.\n\n" - "Possible causes:\n" - "• OAuth token expired and refresh failed\n" - "• Network connectivity issues\n" - "• Nexus Mods server issues\n\n" - "Please check the console output (Show Details) for error messages.\n" - "If authentication failed, you may need to re-authorize in Settings." - ), - safety_level="low" - ) - # Force console to be visible - if not self.show_details_checkbox.isChecked(): - self.show_details_checkbox.setChecked(True) - # Add warning to console - self._safe_append_text( - "\n[Jackify] ⚠️ WARNING: Downloads have stalled (0.0MB/s for 2+ minutes)\n" - "[Jackify] This may indicate an authentication or network issue.\n" - "[Jackify] Check the console above for error messages.\n" - ) - else: - # Downloads are active - reset stall timer - self._stalled_download_start_time = None - self._stalled_download_notified = False - - # Update progress indicator widget - self.progress_indicator.update_progress(progress_state) - - # Only show file progress list if console is not visible (mutually exclusive) - console_visible = self.show_details_checkbox.isChecked() - - # Determine phase display name up front (short/stable label) - phase_label = progress_state.get_phase_label() - - # During installation or extraction phase, show summary counter instead of individual files - # This avoids cluttering the UI with hundreds of completed files - is_installation_phase = ( - progress_state.phase == InstallationPhase.INSTALL or - (progress_state.phase_name and 'install' in progress_state.phase_name.lower()) - ) - is_extraction_phase = ( - progress_state.phase == InstallationPhase.EXTRACT or - (progress_state.phase_name and 'extract' in progress_state.phase_name.lower()) - ) - - # Detect BSA building phase - check multiple indicators - is_bsa_building = False - - # Check phase name for BSA indicators - if progress_state.phase_name: - phase_lower = progress_state.phase_name.lower() - if 'bsa' in phase_lower or ('building' in phase_lower and progress_state.phase == InstallationPhase.INSTALL): - is_bsa_building = True - - # Check message/status text for BSA building indicators - if not is_bsa_building and progress_state.message: - msg_lower = progress_state.message.lower() - if ('building' in msg_lower or 'writing' in msg_lower or 'verifying' in msg_lower) and '.bsa' in msg_lower: - is_bsa_building = True - - # Check if we have BSA files being processed (even if they're at 100%, they indicate BSA phase) - if not is_bsa_building and progress_state.active_files: - bsa_files = [f for f in progress_state.active_files if f.filename.lower().endswith('.bsa')] - if len(bsa_files) > 0: - # If we have any BSA files and we're in INSTALL phase, likely BSA building - if progress_state.phase == InstallationPhase.INSTALL: - is_bsa_building = True - - # Also check display text for BSA mentions (fallback) - if not is_bsa_building: - display_lower = progress_state.display_text.lower() - if 'bsa' in display_lower and progress_state.phase == InstallationPhase.INSTALL: - is_bsa_building = True - - now_mono = time.monotonic() - if is_bsa_building: - self._bsa_hold_deadline = now_mono + 1.5 - elif now_mono < self._bsa_hold_deadline: - is_bsa_building = True - else: - self._bsa_hold_deadline = now_mono - - if is_installation_phase: - # During installation, we may have BSA building AND file installation happening - # Show both: install summary + any active BSA files - # Render loop handles smooth updates - just set target state - - current_step = progress_state.phase_step - - display_items = [] - - # Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size) - if current_step > 0 or progress_state.phase_max_steps > 0: - install_line = FileProgress( - filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}", - operation=OperationType.INSTALL, - percent=0.0, - speed=-1.0 - ) - install_line._no_progress_bar = True # Flag to hide progress bar - display_items.append(install_line) - - # Lines 2+: Show converting textures and BSA files - # Extract and categorize active files - for f in progress_state.active_files: - if f.operation == OperationType.INSTALL: - if f.filename.lower().endswith('.bsa') or f.filename.lower().endswith('.ba2'): - # BSA: filename.bsa (42/89) - Use state-level BSA counter - if progress_state.bsa_building_total > 0: - display_filename = f"BSA: {f.filename} ({progress_state.bsa_building_current}/{progress_state.bsa_building_total})" - else: - display_filename = f"BSA: {f.filename}" - - display_file = FileProgress( - filename=display_filename, - operation=f.operation, - percent=f.percent, - current_size=0, # Don't show size - total_size=0, - speed=-1.0 # No speed - ) - display_items.append(display_file) - if len(display_items) >= 4: # Max 1 install line + 3 operations - break - elif f.filename.lower().endswith(('.dds', '.png', '.tga', '.bmp')): - # Converting Texture: filename.dds (234/1078) - # Use state-level texture counter (more reliable than file-level) - if progress_state.texture_conversion_total > 0: - display_filename = f"Converting Texture: {f.filename} ({progress_state.texture_conversion_current}/{progress_state.texture_conversion_total})" - else: - # No texture counter available, just show filename - display_filename = f"Converting Texture: {f.filename}" - - display_file = FileProgress( - filename=display_filename, - operation=f.operation, - percent=f.percent, - current_size=0, # Don't show size - total_size=0, - speed=-1.0 # No speed - ) - display_items.append(display_file) - if len(display_items) >= 4: # Max 1 install line + 3 operations - break - - # Update target state (render loop handles smooth display) - # Explicitly pass None for summary_info to clear any stale summary data - if display_items: - self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None) - return - elif is_extraction_phase: - # Show summary info for Extracting phase (step count) - # Render loop handles smooth updates - just set target state - # Explicitly pass empty list for file_progresses to clear any stale file list - current_step = progress_state.phase_step - summary_info = { - 'current_step': current_step, - 'max_steps': progress_state.phase_max_steps, - } - phase_display_name = phase_label or "Extracting" - self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info) - return - elif progress_state.active_files: - if self.debug: - debug_print(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})") - # Pass phase label to update header (e.g., "[Activity - Downloading]") - # Explicitly clear summary_info when showing file list - try: - self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None) - except RuntimeError as e: - # 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}") - 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}") - import logging - logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) - else: - # Show empty state so widget stays visible even when no files are active - try: - self.file_progress_list.update_files([], current_phase=phase_label) - except RuntimeError as e: - # Widget was deleted - ignore to prevent coredump - if "already deleted" in str(e): - return - raise - except Exception as e: - # Catch any other exceptions to prevent coredump - import logging - logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) - def _on_show_details_toggled(self, checked: bool): """R&D: Toggle console visibility (reuse TTW pattern)""" from PySide6.QtCore import Qt as _Qt self._toggle_console_visibility(_Qt.Checked if checked else _Qt.Unchecked) - def _toggle_console_visibility(self, state): - """R&D: Toggle console visibility only - - When "Show Details" is checked: - - Show Console (below tabs) - - Expand window height - When "Show Details" is unchecked: - - Hide Console - - Collapse window height - - Note: Activity and Process Monitor tabs are always available via tabs. - """ - is_checked = (state == Qt.Checked) - - # Get main window reference (like TTW screen) - main_window = None - try: - from PySide6.QtWidgets import QApplication - app = QApplication.instance() - if app: - main_window = app.activeWindow() - # Try to find the actual main window (parent of stacked widget) - if self.stacked_widget and self.stacked_widget.parent(): - main_window = self.stacked_widget.parent() - except Exception: - pass - - # Save geometry on first expand (like TTW screen) - if is_checked and main_window and self._saved_geometry is None: - try: - self._saved_geometry = main_window.geometry() - self._saved_min_size = main_window.minimumSize() - except Exception: - pass - - if is_checked: - # Keep upper section height consistent - don't change it - # This prevents buttons from being cut off - try: - if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: - # Maintain consistent height - ALWAYS use the stored fixed height - # Never recalculate - use the exact same height calculated in showEvent - if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None: - self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height) - self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it - # If somehow not stored, it should have been set in showEvent - don't recalculate here - self.upper_section_widget.updateGeometry() - except Exception: - pass - # Show console - self.console.setVisible(True) - self.console.show() - self.console.setMinimumHeight(200) - self.console.setMaximumHeight(16777215) # Remove height limit - try: - self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - # Set stretch on console in its layout to fill space - console_layout = self.console.parent().layout() - if console_layout: - console_layout.setStretchFactor(console_layout.indexOf(self.console), 1) - # Restore spacing when console is visible - console_layout.setSpacing(4) - except Exception: - pass - try: - # Set spacing in console_and_buttons_layout when console is visible - if hasattr(self, 'console_and_buttons_layout'): - self.console_and_buttons_layout.setSpacing(4) # Small gap between console and buttons - # Set stretch on console_and_buttons_widget to fill space when expanded - if hasattr(self, 'console_and_buttons_widget'): - self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 1) - # Allow expansion when console is visible - remove fixed height constraint - self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - # Clear fixed height by setting min/max (setFixedHeight sets both, so we override it) - self.console_and_buttons_widget.setMinimumHeight(0) - self.console_and_buttons_widget.setMaximumHeight(16777215) - self.console_and_buttons_widget.updateGeometry() - except Exception: - pass - - # Notify parent to expand - let main window handle resizing - try: - self.resize_request.emit('expand') - except Exception: - pass - else: - # Keep upper section height consistent - use same constraint - # This prevents buttons from being cut off - try: - if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: - # Use the same stored fixed height for consistency - # ALWAYS use the stored height - never recalculate to avoid drift - if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None: - self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height) - self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it - # If somehow not stored, it should have been set in showEvent - don't recalculate here - self.upper_section_widget.updateGeometry() - except Exception: - pass - # Hide console and ensure it takes zero space - self.console.setVisible(False) - self.console.setMinimumHeight(0) - self.console.setMaximumHeight(0) - # Use Ignored size policy so it doesn't participate in layout calculations - self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - try: - # Remove stretch from console in its layout - console_layout = self.console.parent().layout() - if console_layout: - console_layout.setStretchFactor(console_layout.indexOf(self.console), 0) - # CRITICAL: Set spacing to 0 when console is hidden to eliminate gap - console_layout.setSpacing(0) - except Exception: - pass - try: - # CRITICAL: Set spacing to 0 in console_and_buttons_layout when console is hidden - if hasattr(self, 'console_and_buttons_layout'): - self.console_and_buttons_layout.setSpacing(0) - # Remove stretch from console container when collapsed - console_container = self.console.parent() - if console_container: - self.main_overall_vbox.setStretchFactor(console_container, 0) - # Also remove stretch from console_and_buttons_widget to prevent large gaps - if hasattr(self, 'console_and_buttons_widget'): - self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 0) - # Use Minimum size policy - takes only the minimum space needed (just buttons) - self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - # Lock height to exactly button row height when collapsed - self.console_and_buttons_widget.setFixedHeight(50) # Match button row height exactly - # Update geometry to force recalculation - self.console_and_buttons_widget.updateGeometry() - except Exception: - pass - - # Notify parent to collapse - let main window handle resizing - try: - self.resize_request.emit('collapse') - except Exception: - pass - - def on_installation_finished(self, success, message): - """Handle installation completion""" - debug_print(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 - self.file_progress_list.list_widget.clear() - self.file_progress_list._file_items.clear() - self.file_progress_list._summary_widget = None - self.file_progress_list._transition_label = None - self.file_progress_list._last_phase = None - - if success: - # Update progress indicator with completion - from jackify.shared.progress_models import InstallationProgress, InstallationPhase - final_state = InstallationProgress( - phase=InstallationPhase.FINALIZE, - phase_name="Installation Complete", - overall_percent=100.0 - ) - self.progress_indicator.update_progress(final_state) - - if self.show_details_checkbox.isChecked(): - self._safe_append_text(f"\nSuccess: {message}") - self.process_finished(0, QProcess.NormalExit) # Simulate successful completion - else: - # Reset to initial state on failure - self.progress_indicator.reset() - - if self._premium_failure_active: - message = "Installation stopped because Nexus Premium is required for automated downloads." - - 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}") - # 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") - - - if exit_code == 0: - # Check if this was an unsupported game - game_type = getattr(self, '_current_game_type', None) - game_name = getattr(self, '_current_game_name', None) - - if game_type and not self.wabbajack_parser.is_supported_game(game_type): - # Show success message for unsupported games without post-install configuration - MessageService.information( - self, "Modlist Install Complete!", - f"Modlist installation completed successfully!\n\n" - 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." - ) - 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: - # Check if auto-restart is enabled - auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked() - - if auto_restart_enabled: - # Auto-accept Steam restart - proceed without dialog - self._safe_append_text("\nAuto-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 - ) - - if reply == QMessageBox.Yes: - # --- Create Steam shortcut BEFORE restarting Steam --- - # Proceed directly to automated prefix creation - self.start_automated_prefix_workflow() - else: - # User selected "No" - show completion message and keep GUI open - self._safe_append_text("\nModlist installation completed successfully!") - self._safe_append_text("Note: You can manually configure Steam integration later if needed.") - MessageService.information( - self, "Installation Complete", - "Modlist installation completed successfully!\n\n" - "The modlist has been installed but Steam integration was skipped.\n" - "You can manually add the modlist to Steam later if desired.", - safety_level="medium" - ) - # Re-enable controls since operation is complete - self._enable_controls_after_operation() - else: - # Check for user cancellation first - check message parameter first, then console - if self._premium_failure_active: - MessageService.warning( - self, - "Nexus Premium Required", - "Jackify stopped the installation because Nexus Mods reported that this account is not Premium.\n\n" - "Automatic installs currently require Nexus Premium. Non-premium support is planned.", - safety_level="medium" - ) - self._safe_append_text("\nInstall stopped: Nexus Premium required.") - self._premium_failure_active = False - elif hasattr(self, '_cancellation_requested') and self._cancellation_requested: - # User explicitly cancelled via cancel button - MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") - self._cancellation_requested = False - else: - # Check console as fallback - 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.") - self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") - self.console.moveCursor(QTextCursor.End) - - def _setup_scroll_tracking(self): - """Set up scroll tracking for professional auto-scroll behavior""" - scrollbar = self.console.verticalScrollBar() - scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) - 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() - # Use tolerance to account for rounding and rapid updates - self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 - - # If user manually scrolls to bottom, reset manual scroll flag - if self._was_at_bottom and self._user_manually_scrolled: - # Small delay to allow user to scroll away if they want - from PySide6.QtCore import QTimer - QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) - - def _build_post_install_sequence(self): - """ - Define the ordered steps for post-install (Jackify-managed) operations. - - These steps represent Jackify's automated Steam integration and configuration workflow - that runs AFTER the jackify-engine completes modlist installation. Progress is shown as - "X/Y" in the progress banner and Activity window. - - The post-install steps are: - 1. Preparing Steam integration - Initial setup before creating Steam shortcut - 2. Creating Steam shortcut - Add modlist to Steam library with proper Proton settings - 3. Restarting Steam - Restart Steam to make shortcut visible and create AppID - 4. Creating Proton prefix - Launch temporary batch file to initialize Proton prefix - 5. Verifying Steam setup - Confirm prefix exists and Proton version is correct - 6. Steam integration complete - Steam setup finished successfully - 7. Installing Wine components - Install vcrun, dotnet, and other Wine dependencies - 8. Applying registry files - Import .reg files for game configuration - 9. Installing .NET fixes - Apply .NET framework workarounds if needed - 10. Enabling dotfiles - Make hidden config files visible in file manager - 11. Setting permissions - Ensure modlist files have correct permissions - 12. Backing up configuration - Create backup of ModOrganizer.ini - 13. Finalising Jackify configuration - All post-install steps complete - """ - return [ - { - 'id': 'prepare', - 'label': "Preparing Steam integration", - 'keywords': [ - "starting automated steam setup", - "starting configuration phase", - "starting configuration" - ], - }, - { - 'id': 'steam_shortcut', - 'label': "Creating Steam shortcut", - 'keywords': [ - "creating steam shortcut", - "steam shortcut created successfully" - ], - }, - { - 'id': 'steam_restart', - 'label': "Restarting Steam", - 'keywords': [ - "restarting steam", - "steam restarted successfully" - ], - }, - { - 'id': 'proton_prefix', - 'label': "Creating Proton prefix", - 'keywords': [ - "creating proton prefix", - "proton prefix created successfully", - "temporary batch file launched", - "verifying prefix creation" - ], - }, - { - 'id': 'steam_verify', - 'label': "Verifying Steam setup", - 'keywords': [ - "verifying setup", - "verifying prefix", - "setup verification completed", - "detecting actual appid", - "steam configuration complete" - ], - }, - { - 'id': 'steam_complete', - 'label': "Steam integration complete", - 'keywords': [ - "steam integration complete", - "steam integration", - "steam configuration complete!" - ], - }, - { - 'id': 'wine_components', - 'label': "Installing Wine components", - 'keywords': [ - "installing wine components", - "wine components", - "vcrun", - "dotnet", - "running winetricks", - ], - }, - { - 'id': 'registry_files', - 'label': "Applying registry files", - 'keywords': [ - "applying registry", - "importing registry", - ".reg file", - "registry files", - ], - }, - { - 'id': 'dotnet_fixes', - 'label': "Installing .NET fixes", - 'keywords': [ - "dotnet fix", - ".net fix", - "installing .net", - ], - }, - { - 'id': 'enable_dotfiles', - 'label': "Enabling dotfiles", - 'keywords': [ - "enabling dotfiles", - "dotfiles", - "hidden files", - ], - }, - { - 'id': 'set_permissions', - 'label': "Setting permissions", - 'keywords': [ - "setting permissions", - "chmod", - "permissions", - ], - }, - { - 'id': 'backup_config', - 'label': "Backing up configuration", - 'keywords': [ - "backing up", - "modorganizer.ini", - "backup", - ], - }, - { - 'id': 'vnv_root_mods', - 'label': "VNV: Copying root mods", - 'keywords': [ - "step 1/3: copying root mods", - "copying root mods to game directory", - "root mods:", - ], - }, - { - 'id': 'vnv_4gb_patch', - 'label': "VNV: Applying 4GB patch", - 'keywords': [ - "step 2/3: downloading and running 4gb patcher", - "downloading fnv4gb", - "downloading:", - "fetching file list", - "running 4gb patcher", - "4gb patcher:", - ], - }, - { - 'id': 'vnv_bsa_decompress', - 'label': "VNV: Decompressing BSA files", - 'keywords': [ - "step 3/3: downloading and running bsa decompressor", - "downloading:", - "fetching file list", - "running bsa decompressor", - "decompressing bsa files:", - "bsa decompression:", - ], - }, - { - 'id': 'config_finalize', - 'label': "Finalising Jackify configuration", - 'keywords': [ - "configuration completed successfully", - "configuration complete", - "manual steps validation failed", - "configuration failed", - "vnv post-install completed successfully" - ], - }, - ] - - def _begin_post_install_feedback(self): - """Reset trackers and surface post-install progress in collapsed mode.""" - self._post_install_active = True - self._post_install_current_step = 0 - self._post_install_last_label = "Preparing Steam integration" - total = max(1, self._post_install_total_steps) - self._update_post_install_ui(self._post_install_last_label, 0, total) - - def _handle_post_install_progress(self, message: str): - """Translate backend progress messages into collapsed-mode feedback.""" - if not self._post_install_active or not message: - return - - text = message.strip() - if not text: - return - normalized = text.lower() - total = max(1, self._post_install_total_steps) - matched = False - matched_step = None - for idx, step in enumerate(self._post_install_sequence, start=1): - if any(keyword in normalized for keyword in step['keywords']): - matched = True - matched_step = idx - # Always update to the highest step we've seen (don't go backwards) - if idx >= self._post_install_current_step: - self._post_install_current_step = idx - self._post_install_last_label = step['label'] - # CRITICAL: Always use the current step (not the matched step) to ensure consistency - # This prevents Activity window showing different step than progress banner - self._update_post_install_ui(step['label'], self._post_install_current_step, total, detail=text) - break - - # If no match but we have a current step, update with that step (not a new one) - if not matched and self._post_install_current_step > 0: - label = self._post_install_last_label or "Post-installation" - # CRITICAL: Use _post_install_current_step (not a new step) to keep displays in sync - self._update_post_install_ui(label, self._post_install_current_step, total, detail=text) - - def _strip_timestamp_prefix(self, text: str) -> str: - """Remove timestamp prefix like '[00:03:15]' from text.""" - import re - # Match timestamps like [00:03:15], [01:23:45], etc. - timestamp_pattern = r'^\[\d{2}:\d{2}:\d{2}\]\s*' - return re.sub(timestamp_pattern, '', text) - - def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None): - """Update progress indicator + activity summary for post-install steps.""" - # Use the label as the primary display, but include step info in Activity window - display_label = label - if detail: - # Remove timestamp prefix from detail messages - clean_detail = self._strip_timestamp_prefix(detail.strip()) - if clean_detail: - # For Activity window, show the detail with step counter - # But keep label simple for progress banner - if clean_detail.lower().startswith(label.lower()): - display_label = clean_detail - else: - display_label = clean_detail - total = max(1, total) - step_clamped = max(0, min(step, total)) - overall_percent = (step_clamped / total) * 100.0 - - # CRITICAL: Ensure both displays use the SAME step counter - # Progress banner uses phase_step/phase_max_steps from progress_state - progress_state = InstallationProgress( - phase=InstallationPhase.FINALIZE, - phase_name=display_label, # This will show in progress banner - phase_step=step_clamped, # This creates [step/total] in display_text - phase_max_steps=total, - overall_percent=overall_percent - ) - self.progress_indicator.update_progress(progress_state) - - # Activity window uses summary_info with the SAME step counter - summary_info = { - 'current_step': step_clamped, # Must match phase_step above - 'max_steps': total, # Must match phase_max_steps above - } - # Use the same label for consistency - self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info) - - def _end_post_install_feedback(self, success: bool): - """Mark the end of post-install feedback.""" - if not self._post_install_active: - return - total = max(1, self._post_install_total_steps) - final_step = total if success else max(0, self._post_install_current_step) - label = "Post-installation complete" if success else "Post-installation stopped" - self._update_post_install_ui(label, final_step, total) - self._post_install_active = False - self._post_install_last_label = label - - 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 _safe_append_text(self, text): - """ - Append text with professional auto-scroll behavior. - - Handles carriage return (\\r) for in-place updates and newline (\\n) for new lines. - """ - # Write all messages to log file (including internal messages) - self._write_to_log_file(text) - - # Filter out internal status messages from user console display - if text.strip().startswith('[Jackify]'): - # Internal messages are logged but not shown in user console - return - - # Check if this is a carriage return update (should replace last line) - if '\r' in text and '\n' not in text: - # Carriage return - replace last line - self._replace_last_console_line(text.replace('\r', '')) - return - - # Handle mixed \r\n or just \n - normal append - # Clean up any remaining \r characters - clean_text = text.replace('\r', '') - - scrollbar = self.console.verticalScrollBar() - # Check if user was at bottom BEFORE adding text - was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance - - # Add the text - self.console.append(clean_text) - - # Auto-scroll if user was at bottom and hasn't manually scrolled - # Re-check bottom state after text addition for better reliability - if (was_at_bottom and not self._user_manually_scrolled) or \ - (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): - scrollbar.setValue(scrollbar.maximum()) - # Ensure user can still manually scroll up during rapid updates - if scrollbar.value() == scrollbar.maximum(): - self._was_at_bottom = True - - def _is_similar_progress_line(self, text): - """Check if this line is a similar progress update to the last line""" - if not hasattr(self, '_last_console_line') or not self._last_console_line: - return False - - # Don't deduplicate if either line contains important markers - important_markers = [ - 'complete', - 'failed', - 'error', - 'warning', - 'starting', - '===', - '---', - 'SUCCESS', - 'FAILED', - ] - - text_lower = text.lower() - last_lower = self._last_console_line.lower() - - for marker in important_markers: - if marker.lower() in text_lower or marker.lower() in last_lower: - return False - - # Patterns that indicate this is a progress line that should replace the previous - # These are the status lines that update rapidly with changing numbers - progress_patterns = [ - 'Installing files', - 'Extracting files', - 'Downloading:', - 'Building BSAs', - 'Validating', - ] - - # Check if both current and last line contain the same progress pattern - # AND the lines are actually different (not exact duplicates) - for pattern in progress_patterns: - if pattern in text and pattern in self._last_console_line: - # Only deduplicate if the numbers/progress changed (not exact duplicate) - if text.strip() != self._last_console_line.strip(): - return True - - # Special case: texture conversion status is embedded in Installing files lines - # Match lines like "Installing files X/Y (A/B) - Converting textures: N/M" - if '- Converting textures:' in text and '- Converting textures:' in self._last_console_line: - if text.strip() != self._last_console_line.strip(): - return True - - return False - - def _replace_last_console_line(self, text): - """Replace the last line in the console with new text""" - scrollbar = self.console.verticalScrollBar() - was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) - - # Move cursor to end and select the last line - cursor = self.console.textCursor() - cursor.movePosition(QTextCursor.End) - cursor.select(QTextCursor.LineUnderCursor) - cursor.removeSelectedText() - cursor.deletePreviousChar() # Remove the newline - - # Insert the new text - self.console.append(text) - - # Track this line - self._last_console_line = text - - # Restore scroll position - if was_at_bottom or not self._user_manually_scrolled: - scrollbar.setValue(scrollbar.maximum()) - - def _write_to_log_file(self, message): - """Write message to workflow log file with timestamp""" - try: - from datetime import datetime - 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") - except Exception: - # Logging should never break the workflow - pass - - 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 - shortcut_handler = ShortcutHandler(steamdeck=False) # TODO: Use proper system info - - 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: - import subprocess - import time - # 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 - # This ensures the user can continue with the installation workflow - debug_print("DEBUG: Bringing Jackify window back to focus") - try: - # 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() - - # Force focus with multiple attempts (some window managers need this) - from PySide6.QtCore import QTimer - QTimer.singleShot(50, lambda: main_window.activateWindow() if main_window else None) - QTimer.singleShot(200, lambda: (main_window.raise_(), main_window.activateWindow()) if main_window else None) - QTimer.singleShot(500, lambda: main_window.activateWindow() if main_window else None) - - debug_print(f"DEBUG: Jackify window brought back to focus (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 - # This ensures Proton version and winetricks settings are current - self.config_handler._load_config() - - # Ensure _current_resolution is always set before starting workflow - if not hasattr(self, '_current_resolution') or self._current_resolution is None: - resolution = self.resolution_combo.currentText() if hasattr(self, 'resolution_combo') else None - # 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 - - try: - # 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() - 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 - - self._begin_post_install_feedback() - - # 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 = self.modlist_name_edit.text().strip() - 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() - self._end_post_install_feedback(success=False) - 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() - self._end_post_install_feedback(success=False) - - def on_automated_prefix_progress(self, progress_msg): - """Handle progress updates from automated prefix creation""" - self._safe_append_text(progress_msg) - self._handle_post_install_progress(progress_msg) - - def on_configuration_progress(self, progress_msg): - """Handle progress updates from modlist configuration""" - self._safe_append_text(progress_msg) - self._handle_post_install_progress(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: - # Stop CPU tracking now that everything is complete - self.file_progress_list.stop_cpu_tracking() - # Re-enable controls now that installation/configuration is complete - self._enable_controls_after_operation() - # Don't end post-install feedback yet - may continue with VNV automation - # Will be called in _on_vnv_complete or after VNV check - - 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) - - # 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): - # Offer TTW installation - reply = MessageService.question( - self, - "Install TTW?", - f"{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" - "• Attempt to integrate TTW into your modlist automatically\n" - "• Configure load order if integration is supported\n\n" - "Note: Automatic integration works for some modlists (like Begin Again). " - "Other modlists may require manual TTW setup. " - "TTW installation can take a while.\n\n" - "You can also install TTW later from Additional Tasks & Tools.", - critical=False, - safety_level="medium" - ) - - if reply == QMessageBox.Yes: - # Navigate to TTW screen - self._initiate_ttw_workflow(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: - # Store success dialog params for later (after VNV automation completes) - self._pending_success_dialog_params = { - 'modlist_name': modlist_name, - 'time_taken': time_str, - 'game_name': game_name, - 'enb_detected': enb_detected - } - # Keep post-install feedback active during VNV automation - # Don't show success dialog yet - will be shown in _on_vnv_complete - return - - # No VNV automation - end post-install feedback now - self._end_post_install_feedback(True) - - # Clear Activity window before showing success dialog - self.file_progress_list.clear() - - # Show normal success dialog - success_dialog = SuccessDialog( - modlist_name=modlist_name, - workflow_type="install", - time_taken=time_str, - game_name=game_name, - parent=self - ) - success_dialog.show() - - # Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection) - if enb_detected: - try: - from ..dialogs.enb_proton_dialog import ENBProtonDialog - enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self) - enb_dialog.exec() # Modal dialog - blocks until user clicks OK - except Exception as e: - # Non-blocking: if dialog fails, just log and continue - 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.") - 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.") - 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 show_manual_steps_dialog(self, extra_warning=""): - modlist_name = self.modlist_name_edit.text().strip() or "your modlist" - msg = ( - f"Manual Proton Setup Required for {modlist_name}
" - "After Steam restarts, complete the following steps in Steam:
" - f"1. Locate the '{modlist_name}' entry in your Steam Library
" - "2. Right-click and select 'Properties'
" - "3. Switch to the 'Compatibility' tab
" - "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" - "5. Select 'Proton - Experimental' from the dropdown menu
" - "6. Close the Properties window
" - f"7. Launch '{modlist_name}' from your Steam Library
" - "8. Wait for Mod Organizer 2 to fully open
" - "9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here
" - "
Once you have completed ALL the steps above, click OK to continue." - f"{extra_warning}" - ) - reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") - if reply == QMessageBox.Yes: - self.validate_manual_steps_completion() - else: - # User clicked Cancel or closed the dialog - cancel the workflow - self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.") - # Re-enable all controls when workflow is cancelled - self._enable_controls_after_operation() - self.cancel_btn.setVisible(True) - self.cancel_install_btn.setVisible(False) - - def _get_mo2_path(self, install_dir, modlist_name): - """Get ModOrganizer.exe path, handling Somnium's non-standard structure""" - mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe") - if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower(): - somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe") - if os.path.exists(somnium_path): - mo2_exe_path = somnium_path - return mo2_exe_path - - def validate_manual_steps_completion(self): - """Validate that manual steps were actually completed and handle retry logic""" - modlist_name = self.modlist_name_edit.text().strip() - install_dir = self.install_dir_edit.text().strip() - 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...") - import time - 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...") - from jackify.backend.handlers.shortcut_handler import ShortcutHandler - from jackify.backend.services.platform_detection_service import PlatformDetectionService - - platform_service = PlatformDetectionService.get_instance() - shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck) - current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) - - if not current_appid or not current_appid.isdigit(): - self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") - self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") - self._safe_append_text("Suggestion: Check that Steam is running and shortcuts are visible in library") - self.handle_validation_failure("Could not find Steam shortcut") - 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}") - - # Check 1: Proton version - proton_ok = False - try: - from jackify.backend.handlers.modlist_handler import ModlistHandler - from jackify.backend.handlers.path_handler import PathHandler - - # Initialize ModlistHandler with correct parameters - path_handler = PathHandler() - - # Use centralized Steam Deck detection - from jackify.backend.services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - - modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False) - - # Set required properties manually after initialization - modlist_handler.modlist_dir = install_dir - modlist_handler.appid = current_appid - modlist_handler.game_var = "skyrimspecialedition" # Default for now - - # Set compat_data_path for Proton detection - compat_data_path_str = path_handler.find_compat_data(current_appid) - if compat_data_path_str: - from pathlib import Path - 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}...") - if modlist_handler._detect_proton_version(): - self._safe_append_text(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}") - else: - self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") - else: - self._safe_append_text("Error: Could not detect Proton version from any source") - - except Exception as e: - self._safe_append_text(f"Error checking Proton version: {e}") - proton_ok = False - - # Check 2: Compatdata directory exists - compatdata_ok = False - try: - 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...") - prefix_path_str = path_handler.find_compat_data(current_appid) - self._safe_append_text(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}") - else: - if prefix_path_str: - self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") - else: - self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") - self._safe_append_text("Suggestion: Ensure you launched the shortcut from Steam at least once") - self._safe_append_text("Suggestion: Check if Steam is using Flatpak (different file paths)") - - except Exception as e: - self._safe_append_text(f"Error checking compatdata: {e}") - compatdata_ok = False - - # 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...") - - # Continue configuration with the corrected AppID and context - self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) - else: - # Validation failed - handle retry logic - missing_items = [] - if not proton_ok: - missing_items.append("• Proton - Experimental not set") - if not compatdata_ok: - missing_items.append("• Shortcut not launched from Steam (no compatdata)") - - missing_text = "\n".join(missing_items) - self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") - self.handle_validation_failure(missing_text) - - def show_shortcut_conflict_dialog(self, conflicts): - """Show dialog to resolve shortcut name conflicts""" - conflict_names = [c['name'] for c in conflicts] - conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" - - modlist_name = self.modlist_name_edit.text().strip() - - # Create dialog with Jackify styling - from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout - from PySide6.QtCore import Qt - - dialog = QDialog(self) - dialog.setWindowTitle("Steam Shortcut Conflict") - dialog.setModal(True) - dialog.resize(450, 180) - - # Apply Jackify dark theme styling - dialog.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - color: #ffffff; - } - QLabel { - color: #ffffff; - font-size: 14px; - padding: 10px 0px; - } - QLineEdit { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px; - font-size: 14px; - selection-background-color: #3fd0ea; - } - QLineEdit:focus { - border-color: #3fd0ea; - } - QPushButton { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px 16px; - font-size: 14px; - min-width: 120px; - } - QPushButton:hover { - background-color: #505050; - border-color: #3fd0ea; - } - QPushButton:pressed { - background-color: #303030; - } - """) - - layout = QVBoxLayout(dialog) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Conflict message - conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") - layout.addWidget(conflict_label) - - # Text input for new name - name_input = QLineEdit(modlist_name) - name_input.selectAll() - layout.addWidget(name_input) - - # Buttons - button_layout = QHBoxLayout() - button_layout.setSpacing(10) - - create_button = QPushButton("Create with New Name") - cancel_button = QPushButton("Cancel") - - button_layout.addStretch() - button_layout.addWidget(cancel_button) - button_layout.addWidget(create_button) - layout.addLayout(button_layout) - - # Connect signals - def on_create(): - new_name = name_input.text().strip() - if new_name and new_name != modlist_name: - dialog.accept() - # Retry workflow with new name - self.retry_automated_workflow_with_new_name(new_name) - elif new_name == modlist_name: - # Same name - show warning - from jackify.frontends.gui.services.message_service import MessageService - MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") - else: - # Empty name - from jackify.frontends.gui.services.message_service import MessageService - MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") - - def on_cancel(): - dialog.reject() - self._safe_append_text("Shortcut creation cancelled by user") - - create_button.clicked.connect(on_create) - cancel_button.clicked.connect(on_cancel) - - # Make Enter key work - name_input.returnPressed.connect(on_create) - - 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 - original_name = self.modlist_name_edit.text() - self.modlist_name_edit.setText(new_name) - - # Restart the automated workflow - self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") - self.start_automated_prefix_workflow() - - 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, bool) - 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) - - # 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='skyrim', # Default for now - 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) - - # 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='skyrim', # Default for now - 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): - # This shouldn't happen since manual steps should be done - 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) - def handle_validation_failure(self, missing_text): """Handle failed validation with retry logic""" self._manual_steps_retry_count += 1 @@ -4813,7 +400,6 @@ class InstallModlistScreen(QWidget): self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) def show_next_steps_dialog(self, message): - # EXACT LEGACY show_next_steps_dialog from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication dlg = QDialog(self) dlg.setWindowTitle("Next Steps") @@ -4871,7 +457,7 @@ class InstallModlistScreen(QWidget): ) if reply == QMessageBox.Yes: - self._safe_append_text("\n🛑 Cancelling installation...") + self._safe_append_text("\nCancelling installation...") # Set flag so we can detect cancellation reliably self._cancellation_requested = True @@ -4915,6 +501,13 @@ class InstallModlistScreen(QWidget): self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) + # Collapse window if "Show Details" is checked + if hasattr(self, 'show_details_checkbox') and self.show_details_checkbox.isChecked(): + self.resize_request.emit('collapse') + self.show_details_checkbox.blockSignals(True) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.blockSignals(False) + except Exception as e: debug_print(f"ERROR: Exception during cancellation cleanup: {e}") import traceback @@ -4947,6 +540,7 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html""" def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" self.cleanup_processes() + self.collapse_show_details_before_leave() self.go_back() def reset_screen_to_defaults(self): diff --git a/jackify/frontends/gui/screens/install_modlist_automated_prefix.py b/jackify/frontends/gui/screens/install_modlist_automated_prefix.py new file mode 100644 index 0000000..8b422f1 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_automated_prefix.py @@ -0,0 +1,377 @@ +"""Automated prefix workflow handlers for InstallModlistScreen (Mixin).""" +from PySide6.QtCore import QThread, Signal, Qt, QTimer +from PySide6.QtWidgets import QProgressDialog, QMainWindow +from jackify.frontends.gui.services.message_service import MessageService +from jackify.backend.services.automated_prefix_service import AutomatedPrefixService +from pathlib import Path +import traceback +import threading +import subprocess +import time +import os + + +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 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 + # Refresh Proton version and winetricks settings + self.config_handler._load_config() + + # Ensure _current_resolution is always set before starting workflow + if not hasattr(self, '_current_resolution') or self._current_resolution is None: + resolution = self.resolution_combo.currentText() if hasattr(self, 'resolution_combo') else None + # 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 + + try: + # 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() + 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 + + self._begin_post_install_feedback() + + # Run automated prefix creation in separate thread + 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, downloads_dir=None): + super().__init__() + self.modlist_name = modlist_name + self.install_dir = install_dir + self.final_exe_path = final_exe_path + self.downloads_dir = downloads_dir + + def run(self): + try: + 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: + _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, download_dir=self.downloads_dir + ) + + # 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 (pass downloads_dir for STEAM_COMPAT_MOUNTS) + downloads_dir = self.downloads_dir_edit.text().strip() if getattr(self, 'downloads_dir_edit', None) 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) + 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}") + 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 = self.modlist_name_edit.text().strip() + 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() + self._end_post_install_feedback(success=False) + 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() + self._end_post_install_feedback(success=False) + + def on_automated_prefix_progress(self, progress_msg): + """Handle progress updates from automated prefix creation""" + self._safe_append_text(progress_msg) + self._handle_post_install_progress(progress_msg) + diff --git a/jackify/frontends/gui/screens/install_modlist_configuration.py b/jackify/frontends/gui/screens/install_modlist_configuration.py new file mode 100644 index 0000000..7d6a212 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_configuration.py @@ -0,0 +1,625 @@ +"""Configuration phase workflow for InstallModlistScreen (Mixin).""" +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.frontends.gui.dialogs import SuccessDialog +from jackify.backend.handlers.validation_handler import ValidationHandler +from jackify.backend.models.modlist import ModlistContext +from pathlib import Path +import traceback +import os +import time + +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.""" + + def on_configuration_progress(self, progress_msg): + """Handle progress updates from modlist configuration""" + self._safe_append_text(progress_msg) + self._handle_post_install_progress(progress_msg) + + def show_steam_restart_progress(self, message): + """Show Steam restart progress dialog""" + 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 _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 on_configuration_complete(self, success, message, modlist_name, enb_detected=False): + """Handle configuration completion on main thread""" + try: + # Stop CPU tracking now that everything is complete + self.file_progress_list.stop_cpu_tracking() + # Re-enable controls now that installation/configuration is complete + self._enable_controls_after_operation() + # Don't end post-install feedback yet - may continue with VNV automation + # Will be called in _on_vnv_complete or after VNV check + + 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 + 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) + + # 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): + # Offer TTW installation + reply = MessageService.question( + self, + "Install TTW?", + f"{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" + "• Attempt to integrate TTW into your modlist automatically\n" + "• Configure load order if integration is supported\n\n" + "Note: Automatic integration works for some modlists (like Begin Again). " + "Other modlists may require manual TTW setup. " + "TTW installation can take a while.\n\n" + "You can also install TTW later from Additional Tasks & Tools.", + critical=False, + safety_level="medium" + ) + + if reply == QMessageBox.Yes: + # Navigate to TTW screen + self._initiate_ttw_workflow(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: + # Store success dialog params for later (after VNV automation completes) + self._pending_success_dialog_params = { + 'modlist_name': modlist_name, + 'time_taken': time_str, + 'game_name': game_name, + 'enb_detected': enb_detected + } + # Keep post-install feedback active during VNV automation + # Don't show success dialog yet - will be shown in _on_vnv_complete + return + + # No VNV automation - end post-install feedback now + self._end_post_install_feedback(True) + + # Clear Activity window before showing success dialog + self.file_progress_list.clear() + + # Show normal success dialog + success_dialog = SuccessDialog( + modlist_name=modlist_name, + workflow_type="install", + time_taken=time_str, + game_name=game_name, + parent=self + ) + success_dialog.show() + + # Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection) + if enb_detected: + try: + from ..dialogs.enb_proton_dialog import ENBProtonDialog + enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self) + 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.") + 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.") + 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 show_manual_steps_dialog(self, extra_warning=""): + modlist_name = self.modlist_name_edit.text().strip() or "your modlist" + msg = ( + f"Manual Proton Setup Required for {modlist_name}
" + "After Steam restarts, complete the following steps in Steam:
" + f"1. Locate the '{modlist_name}' entry in your Steam Library
" + "2. Right-click and select 'Properties'
" + "3. Switch to the 'Compatibility' tab
" + "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" + "5. Select 'Proton - Experimental' from the dropdown menu
" + "6. Close the Properties window
" + f"7. Launch '{modlist_name}' from your Steam Library
" + "8. Wait for Mod Organizer 2 to fully open
" + "9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here
" + "
Once you have completed ALL the steps above, click OK to continue." + f"{extra_warning}" + ) + reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") + if reply == QMessageBox.Yes: + self.validate_manual_steps_completion() + else: + # User clicked Cancel or closed the dialog - cancel the workflow + self._safe_append_text("\nManual steps cancelled by user. Workflow stopped.") + # Re-enable all controls when workflow is cancelled + self._enable_controls_after_operation() + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + def _get_mo2_path(self, install_dir, modlist_name): + """Get ModOrganizer.exe path, handling Somnium's non-standard structure""" + mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe") + if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower(): + somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe") + if os.path.exists(somnium_path): + mo2_exe_path = somnium_path + return mo2_exe_path + + def validate_manual_steps_completion(self): + """Validate that manual steps were actually completed and handle retry logic""" + modlist_name = self.modlist_name_edit.text().strip() + install_dir = self.install_dir_edit.text().strip() + 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...") + 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...") + from jackify.backend.handlers.shortcut_handler import ShortcutHandler + from jackify.backend.services.platform_detection_service import PlatformDetectionService + + platform_service = PlatformDetectionService.get_instance() + shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck) + current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) + + if not current_appid or not current_appid.isdigit(): + self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") + self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") + self._safe_append_text("Suggestion: Check that Steam is running and shortcuts are visible in library") + self.handle_validation_failure("Could not find Steam shortcut") + 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}") + + # Check 1: Proton version + proton_ok = False + try: + from jackify.backend.handlers.modlist_handler import ModlistHandler + from jackify.backend.handlers.path_handler import PathHandler + + # Initialize ModlistHandler with correct parameters + path_handler = PathHandler() + + # Use centralized Steam Deck detection + platform_service = PlatformDetectionService.get_instance() + + modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False) + + # Set required properties manually after initialization + modlist_handler.modlist_dir = install_dir + modlist_handler.appid = current_appid + modlist_handler.game_var = "skyrimspecialedition" # Default for now + + # Set compat_data_path for Proton detection + compat_data_path_str = path_handler.find_compat_data(current_appid) + if compat_data_path_str: + 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}...") + if modlist_handler._detect_proton_version(): + self._safe_append_text(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}") + else: + self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") + else: + self._safe_append_text("Error: Could not detect Proton version from any source") + + except Exception as e: + self._safe_append_text(f"Error checking Proton version: {e}") + proton_ok = False + + # Check 2: Compatdata directory exists + compatdata_ok = False + try: + 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...") + prefix_path_str = path_handler.find_compat_data(current_appid) + self._safe_append_text(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}") + else: + if prefix_path_str: + self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") + else: + self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") + self._safe_append_text("Suggestion: Ensure you launched the shortcut from Steam at least once") + self._safe_append_text("Suggestion: Check if Steam is using Flatpak (different file paths)") + + except Exception as e: + self._safe_append_text(f"Error checking compatdata: {e}") + compatdata_ok = False + + # 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...") + + # Continue configuration with the corrected AppID and context + self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) + else: + # Validation failed - handle retry logic + missing_items = [] + if not proton_ok: + missing_items.append("• Proton - Experimental not set") + if not compatdata_ok: + missing_items.append("• Shortcut not launched from Steam (no compatdata)") + + missing_text = "\n".join(missing_items) + self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") + self.handle_validation_failure(missing_text) + + 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 + # Capture parent's method for game type detection + detect_game_type_func = self._detect_game_type_from_mo2_ini + + class ConfigThread(QThread): + progress_update = Signal(str) + configuration_complete = Signal(bool, str, str, bool) + error_occurred = Signal(str) + + def __init__(self, context, is_steamdeck, detect_func): + super().__init__() + self.context = context + self.is_steamdeck = is_steamdeck + self.detect_game_type = detect_func + + 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 + + # 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 using captured function + detected_game_type = self.detect_game_type(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): + # Should not reach here -- prefix creation already 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, detect_game_type_func) + 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._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""" + # 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 + + # Capture parent's method for game type detection + detect_game_type_func = self._detect_game_type_from_mo2_ini + + class ConfigThread(QThread): + progress_update = Signal(str) + configuration_complete = Signal(bool, str, str) + error_occurred = Signal(str) + + def __init__(self, context, is_steamdeck, detect_func, parent=None): + super().__init__(parent) + self.context = context + self.is_steamdeck = is_steamdeck + self.detect_game_type = detect_func + + 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 + + # 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 using captured function + detected_game_type = self.detect_game_type(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: + 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, 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 new file mode 100644 index 0000000..173f5c1 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_console.py @@ -0,0 +1,368 @@ +"""Console output management for InstallModlistScreen (Mixin).""" +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: + """Mixin providing console output and scroll tracking for InstallModlistScreen.""" + + def _toggle_console_visibility(self, state): + """R&D: Toggle console visibility only + + When "Show Details" is checked: + - Show Console (below tabs) + - Expand window height + When "Show Details" is unchecked: + - Hide Console + - Collapse window height + + Note: Activity and Process Monitor tabs are always available via tabs. + """ + is_checked = (state == Qt.Checked) + + # Get main window reference (like TTW screen) + main_window = None + try: + app = QApplication.instance() + if app: + main_window = app.activeWindow() + # Try to find the actual main window (parent of stacked widget) + if self.stacked_widget and self.stacked_widget.parent(): + main_window = self.stacked_widget.parent() + except Exception: + pass + + # Save geometry on first expand (like TTW screen) + if is_checked and main_window and self._saved_geometry is None: + try: + self._saved_geometry = main_window.geometry() + self._saved_min_size = main_window.minimumSize() + except Exception: + pass + + if is_checked: + # Keep upper section height consistent - don't change it + # Prevent buttons from being cut off + try: + if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: + # Maintain consistent height - ALWAYS use the stored fixed height + # Never recalculate - use the exact same height calculated in showEvent + if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None: + self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height) + self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it + # If somehow not stored, it should have been set in showEvent - don't recalculate here + self.upper_section_widget.updateGeometry() + except Exception: + pass + # Show console + self.console.setVisible(True) + self.console.show() + self.console.setMinimumHeight(200) + self.console.setMaximumHeight(16777215) # Remove height limit + try: + self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + # Set stretch on console in its layout to fill space + console_layout = self.console.parent().layout() + if console_layout: + console_layout.setStretchFactor(console_layout.indexOf(self.console), 1) + # Restore spacing when console is visible + console_layout.setSpacing(4) + except Exception: + pass + try: + # Set spacing in console_and_buttons_layout when console is visible + if hasattr(self, 'console_and_buttons_layout'): + self.console_and_buttons_layout.setSpacing(4) # Small gap between console and buttons + # Set stretch on console_and_buttons_widget to fill space when expanded + if hasattr(self, 'console_and_buttons_widget'): + self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 1) + # Allow expansion when console is visible - remove fixed height constraint + self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + # Clear fixed height by setting min/max (setFixedHeight sets both, so we override it) + self.console_and_buttons_widget.setMinimumHeight(0) + self.console_and_buttons_widget.setMaximumHeight(16777215) + self.console_and_buttons_widget.updateGeometry() + except Exception: + pass + + # Notify parent to expand - let main window handle resizing + try: + self.resize_request.emit('expand') + except Exception: + pass + else: + # Keep upper section height consistent - use same constraint + # Prevent buttons from being cut off + try: + if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: + # Use the same stored fixed height for consistency + # ALWAYS use the stored height - never recalculate to avoid drift + if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None: + self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height) + self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it + # If somehow not stored, it should have been set in showEvent - don't recalculate here + self.upper_section_widget.updateGeometry() + except Exception: + pass + # Hide console and ensure it takes zero space + self.console.setVisible(False) + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + # Use Ignored size policy so it doesn't participate in layout calculations + self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) + try: + # Remove stretch from console_and_buttons_widget when collapsed + if hasattr(self, 'console_and_buttons_widget'): + self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 0) + # Set fixed height when console is hidden + self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + # Calculate height based on buttons only (console takes 0 space) + button_height = 0 + if hasattr(self, 'console_and_buttons_layout'): + for i in range(self.console_and_buttons_layout.count()): + item = self.console_and_buttons_layout.itemAt(i) + if item and item.widget() and item.widget() != self.console: + button_height = max(button_height, item.widget().sizeHint().height()) + self.console_and_buttons_widget.setFixedHeight(button_height + 8) # Add small padding + # Clear spacing when console is hidden + if hasattr(self, 'console_and_buttons_layout'): + self.console_and_buttons_layout.setSpacing(0) + except Exception: + pass + + # Notify parent to collapse - let main window handle resizing + try: + self.resize_request.emit('collapse') + except Exception: + pass + + def on_installation_output(self, message): + """Handle regular output from installation thread""" + # 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 + + # CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode) + msg_lower = message.lower() + token_error_keywords = [ + 'token has expired', + 'token expired', + 'oauth token', + 'authentication failed', + 'unauthorized', + '401', + '403', + 'refresh token', + 'authorization failed', + 'nexus.*premium.*required', + 'premium.*required', + ] + + 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'): + self._token_error_notified = True + # Show error dialog immediately + MessageService.error( + self, + "Authentication Error", + ( + "Nexus Mods authentication has failed. This may be due to:\n\n" + "• OAuth token expired and refresh failed\n" + "• Nexus Premium required for this modlist\n" + "• Network connectivity issues\n\n" + "Please check the console output (Show Details) for more information.\n" + "You may need to re-authorize in Settings." + ), + safety_level="high" + ) + # Also show in console + guidance = ( + "\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n" + "[Jackify] This may cause downloads to stop. Check the error message above.\n" + "[Jackify] If OAuth token expired, go to Settings and re-authorize.\n" + ) + self._safe_append_text(guidance) + # Force console to be visible so user can see the error + if not self.show_details_checkbox.isChecked(): + self.show_details_checkbox.setChecked(True) + + # Detect known engine bugs and provide helpful guidance + if 'destination array was not long enough' in msg_lower or \ + ('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower): + # Known bug in jackify-engine 0.4.0 during .wabbajack download + if not hasattr(self, '_array_error_notified'): + self._array_error_notified = True + guidance = ( + "\n[Jackify] Engine Error Detected: Buffer size issue during .wabbajack download.\n" + "[Jackify] This is a known bug in jackify-engine 0.4.0.\n" + "[Jackify] Workaround: Delete any partial .wabbajack files in your downloads directory and try again.\n" + ) + self._safe_append_text(guidance) + + # R&D: Always write output to console buffer so it's available when user toggles Show Details + # The console visibility is controlled by the checkbox, not whether we write to it + self._safe_append_text(message) + + def _setup_scroll_tracking(self): + """Set up scroll tracking for professional auto-scroll behavior""" + scrollbar = self.console.verticalScrollBar() + scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) + 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() + # Use tolerance to account for rounding and rapid updates + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + # If user manually scrolls to bottom, reset manual scroll flag + if self._was_at_bottom and self._user_manually_scrolled: + # Small delay to allow user to scroll away if they want + 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 _safe_append_text(self, text): + """ + Append text with professional auto-scroll behavior. + + Handles carriage return (\\r) for in-place updates and newline (\\n) for new lines. + """ + # Write all messages to log file (including internal messages) + self._write_to_log_file(text) + + # Filter out internal status messages from user console display + if text.strip().startswith('[Jackify]'): + # Internal messages are logged but not shown in user console + return + + # Check if this is a carriage return update (should replace last line) + if '\r' in text and '\n' not in text: + # Carriage return - replace last line + self._replace_last_console_line(text.replace('\r', '')) + return + + # Handle mixed \r\n or just \n - normal append + # Clean up any remaining \r characters + clean_text = text.replace('\r', '') + + scrollbar = self.console.verticalScrollBar() + # Check if user was at bottom BEFORE adding text + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + + # Add the text + self.console.append(clean_text) + + # Auto-scroll if user was at bottom and hasn't manually scrolled + # Re-check bottom state after text addition for better reliability + if (was_at_bottom and not self._user_manually_scrolled) or \ + (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): + scrollbar.setValue(scrollbar.maximum()) + # Ensure user can still manually scroll up during rapid updates + if scrollbar.value() == scrollbar.maximum(): + self._was_at_bottom = True + + def _is_similar_progress_line(self, text): + """Check if this line is a similar progress update to the last line""" + if not hasattr(self, '_last_console_line') or not self._last_console_line: + return False + + # Don't deduplicate if either line contains important markers + important_markers = [ + 'complete', + 'failed', + 'error', + 'warning', + 'starting', + '===', + '---', + 'SUCCESS', + 'FAILED', + ] + + text_lower = text.lower() + last_lower = self._last_console_line.lower() + + for marker in important_markers: + if marker.lower() in text_lower or marker.lower() in last_lower: + return False + + # Patterns that indicate this is a progress line that should replace the previous + # These are the status lines that update rapidly with changing numbers + progress_patterns = [ + 'Installing files', + 'Extracting files', + 'Downloading:', + 'Building BSAs', + 'Validating', + ] + + # Check if both current and last line contain the same progress pattern + # AND the lines are actually different (not exact duplicates) + for pattern in progress_patterns: + if pattern in text and pattern in self._last_console_line: + # Only deduplicate if the numbers/progress changed (not exact duplicate) + if text.strip() != self._last_console_line.strip(): + return True + + # Special case: texture conversion status is embedded in Installing files lines + # Match lines like "Installing files X/Y (A/B) - Converting textures: N/M" + if '- Converting textures:' in text and '- Converting textures:' in self._last_console_line: + if text.strip() != self._last_console_line.strip(): + return True + + return False + + def _replace_last_console_line(self, text): + """Replace the last line in the console with new text""" + scrollbar = self.console.verticalScrollBar() + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) + + # Move cursor to end and select the last line + cursor = self.console.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.select(QTextCursor.LineUnderCursor) + cursor.removeSelectedText() + cursor.deletePreviousChar() # Remove the newline + + # Insert the new text + self.console.append(text) + + # Track this line + self._last_console_line = text + + # Restore scroll position + if was_at_bottom or not self._user_manually_scrolled: + scrollbar.setValue(scrollbar.maximum()) + + def _write_to_log_file(self, message): + """Write message to workflow log file with timestamp""" + try: + from datetime import datetime + 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") + except Exception: + # Logging should never break the workflow + pass + diff --git a/jackify/frontends/gui/screens/install_modlist_dialogs.py b/jackify/frontends/gui/screens/install_modlist_dialogs.py new file mode 100644 index 0000000..4144fdb --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_dialogs.py @@ -0,0 +1,327 @@ +""" +Helper dialog classes for InstallModlistScreen +""" +from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QListWidget, + QListWidgetItem, QPushButton, QLineEdit, QTableWidget, QTableWidgetItem, + QHeaderView, QCheckBox, QAbstractItemView, QLabel, QWidget, QSizePolicy) +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QColor, QBrush +import logging +import os + +logger = logging.getLogger(__name__) + + +class ModlistFetchThread(QThread): + result = Signal(list, str) + def __init__(self, game_type, log_path, mode='list-modlists'): + super().__init__() + self.game_type = game_type + self.log_path = log_path + self.mode = mode + + def run(self): + try: + # Use proper backend service - NOT the misnamed CLI class + from jackify.backend.services.modlist_service import ModlistService + from jackify.backend.models.configuration import SystemInfo + + # Initialize backend service + # Detect if we're on Steam Deck + is_steamdeck = False + try: + 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: + pass + + system_info = SystemInfo(is_steamdeck=is_steamdeck) + modlist_service = ModlistService(system_info) + + # Get modlists using proper backend service + modlist_infos = modlist_service.list_modlists(game_type=self.game_type) + + # Return full modlist objects instead of just IDs to preserve enhanced metadata + self.result.emit(modlist_infos, '') + + except Exception as e: + error_msg = f"Backend service error: {str(e)}" + # Don't write to log file before workflow starts - just return error + self.result.emit([], error_msg) + + +class SelectionDialog(QDialog): + def __init__(self, title, items, parent=None, show_search=True, placeholder_text="Search modlists...", show_legend=False): + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setMinimumWidth(600) + self.setMinimumHeight(300) + layout = QVBoxLayout(self) + + self.show_search = show_search + if self.show_search: + # Search box with clear button + search_layout = QHBoxLayout() + self.search_box = QLineEdit() + self.search_box.setPlaceholderText(placeholder_text) + # Make placeholder text lighter + self.search_box.setStyleSheet("QLineEdit { color: #ccc; } QLineEdit:placeholder { color: #aaa; }") + self.clear_btn = QPushButton("Clear") + self.clear_btn.setFixedWidth(50) + search_layout.addWidget(self.search_box) + search_layout.addWidget(self.clear_btn) + layout.addLayout(search_layout) + + if show_legend: + # Use table for modlist selection with proper columns + self.table_widget = QTableWidget() + self.table_widget.setColumnCount(4) + self.table_widget.setHorizontalHeaderLabels(["Modlist Name", "Download", "Install", "Total"]) + + # Configure table appearance + self.table_widget.setSelectionBehavior(QTableWidget.SelectRows) + self.table_widget.setSelectionMode(QTableWidget.SingleSelection) + self.table_widget.verticalHeader().setVisible(False) + self.table_widget.setAlternatingRowColors(True) + + # Set column widths + header = self.table_widget.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) # Modlist name takes remaining space + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Download size + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Install size + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Total size + + + self._all_items = list(items) + self._populate_table(self._all_items) + layout.addWidget(self.table_widget) + + # Apply initial NSFW filter since checkbox starts unchecked + self._filter_nsfw(False) + else: + # Use list for non-modlist dialogs (backward compatibility) + self.list_widget = QListWidget() + self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._all_items = list(items) + self._populate_list(self._all_items) + layout.addWidget(self.list_widget) + + # Add interactive legend bar only for modlist selection dialogs + if show_legend: + legend_layout = QHBoxLayout() + legend_layout.setContentsMargins(10, 5, 10, 5) + + # Status indicator explanation (far left) + status_label = QLabel('[DOWN] Unavailable') + status_label.setStyleSheet("color: #bbb;") + legend_layout.addWidget(status_label) + + # Spacer after DOWN legend + legend_layout.addSpacing(15) + + # No need for size format explanation since we have table headers now + # Just add some spacing + + # Main spacer to push NSFW checkbox to far right + legend_layout.addStretch() + + # NSFW filter checkbox (far right) + self.nsfw_checkbox = QCheckBox("Show NSFW") + self.nsfw_checkbox.setStyleSheet("color: #bbb; font-size: 11px;") + self.nsfw_checkbox.setChecked(False) # Default to hiding NSFW content + self.nsfw_checkbox.toggled.connect(self._filter_nsfw) + legend_layout.addWidget(self.nsfw_checkbox) + + # Legend container + legend_widget = QWidget() + legend_widget.setLayout(legend_layout) + legend_widget.setStyleSheet("background-color: #333; border-radius: 3px; margin: 2px;") + layout.addWidget(legend_widget) + + self.selected_item = None + + # Connect appropriate signals based on widget type + if show_legend: + self.table_widget.itemClicked.connect(self.on_table_item_clicked) + if self.show_search: + self.search_box.textChanged.connect(self._filter_table) + self.clear_btn.clicked.connect(self._clear_search) + self.search_box.returnPressed.connect(self._focus_table) + self.search_box.installEventFilter(self) + else: + self.list_widget.itemClicked.connect(self.on_item_clicked) + if self.show_search: + self.search_box.textChanged.connect(self._filter_list) + self.clear_btn.clicked.connect(self._clear_search) + self.search_box.returnPressed.connect(self._focus_list) + self.search_box.installEventFilter(self) + + def _populate_list(self, items): + self.list_widget.clear() + for item in items: + # Create list item - custom delegate handles all styling + QListWidgetItem(item, self.list_widget) + + def _populate_table(self, items): + self.table_widget.setRowCount(len(items)) + for row, item in enumerate(items): + # Parse the item string to extract components + # Format: "[STATUS] Modlist Name Download|Install|Total" + + # Extract status indicators + status_down = '[DOWN]' in item + status_nsfw = '[NSFW]' in item + + # Clean the item string + clean_item = item.replace('[DOWN]', '').replace('[NSFW]', '').strip() + + # Split into name and sizes + # The format should be "Name Download|Install|Total" + parts = clean_item.rsplit(' ', 1) # Split from right to separate name from sizes + if len(parts) == 2: + name = parts[0].strip() + sizes = parts[1].strip() + size_parts = sizes.split('|') + if len(size_parts) == 3: + download_size, install_size, total_size = [s.strip() for s in size_parts] + else: + # Fallback if format is unexpected + download_size = install_size = total_size = sizes + else: + # Fallback if format is unexpected + name = clean_item + download_size = install_size = total_size = "" + + # Create table items + name_item = QTableWidgetItem(name) + download_item = QTableWidgetItem(download_size) + install_item = QTableWidgetItem(install_size) + total_item = QTableWidgetItem(total_size) + + # Apply styling + if status_down: + # Gray out and strikethrough for DOWN items + for item_widget in [name_item, download_item, install_item, total_item]: + item_widget.setForeground(QColor('#999999')) + font = item_widget.font() + font.setStrikeOut(True) + item_widget.setFont(font) + elif status_nsfw: + # Red text for NSFW items - but only the name, sizes stay white + name_item.setForeground(QColor('#ff4444')) + for item_widget in [download_item, install_item, total_item]: + item_widget.setForeground(QColor('#ffffff')) + else: + # White text for normal items + for item_widget in [name_item, download_item, install_item, total_item]: + item_widget.setForeground(QColor('#ffffff')) + + # Add status indicators to name if present + if status_nsfw: + name_item.setText(f"[NSFW] {name}") + if status_down: + # For DOWN items, we want [DOWN] normal and the name strikethrough + # Since we can't easily mix fonts in a single QTableWidgetItem, + # we'll style the whole item but the visual effect will be clear + name_item.setText(f"[DOWN] {name_item.text()}") + + # Right-align size columns + download_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + install_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + total_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + + # Add items to table + self.table_widget.setItem(row, 0, name_item) + self.table_widget.setItem(row, 1, download_item) + self.table_widget.setItem(row, 2, install_item) + self.table_widget.setItem(row, 3, total_item) + + # Store original item text as data for filtering + name_item.setData(Qt.UserRole, item) + + def _filter_list(self, text): + text = text.strip().lower() + if not text: + filtered = self._all_items + else: + filtered = [item for item in self._all_items if text in item.lower()] + self._populate_list(filtered) + if filtered: + self.list_widget.setCurrentRow(0) + + def _clear_search(self): + self.search_box.clear() + self.search_box.setFocus() + + def _focus_list(self): + self.list_widget.setFocus() + self.list_widget.setCurrentRow(0) + + def _focus_table(self): + self.table_widget.setFocus() + self.table_widget.setCurrentCell(0, 0) + + def _filter_table(self, text): + text = text.strip().lower() + if not text: + # Show all rows + for row in range(self.table_widget.rowCount()): + self.table_widget.setRowHidden(row, False) + else: + # Filter rows based on modlist name + for row in range(self.table_widget.rowCount()): + name_item = self.table_widget.item(row, 0) + if name_item: + # Search in the modlist name + match = text in name_item.text().lower() + self.table_widget.setRowHidden(row, not match) + + def on_table_item_clicked(self, item): + # Get the original item text from the name column + row = item.row() + name_item = self.table_widget.item(row, 0) + if name_item: + original_item = name_item.data(Qt.UserRole) + self.selected_item = original_item + self.accept() + + def _filter_nsfw(self, show_nsfw): + """Filter NSFW modlists based on checkbox state""" + if show_nsfw: + # Show all items + filtered_items = self._all_items + else: + # Hide NSFW items + filtered_items = [item for item in self._all_items if '[NSFW]' not in item] + + # Use appropriate populate method based on widget type + if hasattr(self, 'table_widget'): + self._populate_table(filtered_items) + # Apply search filter if there's search text + if hasattr(self, 'search_box') and self.search_box.text().strip(): + self._filter_table(self.search_box.text()) + else: + self._populate_list(filtered_items) + # Apply search filter if there's search text + if hasattr(self, 'search_box') and self.search_box.text().strip(): + self._filter_list(self.search_box.text()) + + def eventFilter(self, obj, event): + if self.show_search and obj == self.search_box and event.type() == event.Type.KeyPress: + if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Tab): + # Focus appropriate widget + if hasattr(self, 'table_widget'): + self._focus_table() + else: + self._focus_list() + return True + return super().eventFilter(obj, event) + + def on_item_clicked(self, item): + self.selected_item = item.text() + self.accept() + diff --git a/jackify/frontends/gui/screens/install_modlist_installer_thread.py b/jackify/frontends/gui/screens/install_modlist_installer_thread.py new file mode 100644 index 0000000..104ebf6 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_installer_thread.py @@ -0,0 +1,257 @@ +""" +InstallerThread: QThread subclass for running jackify-engine install. +Signals are defined at class level (required for Qt signal/slot). +""" + +import os +import re + +from PySide6.QtCore import QThread, Signal + + +class InstallerThread(QThread): + """Runs jackify-engine install in a background thread. Signals at class level.""" + + output_received = Signal(str) + progress_received = Signal(str) + progress_updated = Signal(object) + installation_finished = Signal(bool, str) + premium_required_detected = Signal(str) + + def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name, + install_mode='online', progress_state_manager=None, auth_service=None, oauth_info=None): + super().__init__() + self.modlist = modlist + self.install_dir = install_dir + self.downloads_dir = downloads_dir + self.api_key = api_key + self.modlist_name = modlist_name + self.install_mode = install_mode + self.cancelled = False + self.process_manager = None + self.progress_state_manager = progress_state_manager + self.auth_service = auth_service + self.oauth_info = oauth_info + self._premium_signal_sent = False + self._engine_output_buffer = [] + self._buffer_size = 10 + + def cancel(self): + self.cancelled = True + if self.process_manager: + self.process_manager.cancel() + + 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}") + 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}") + self.installation_finished.emit(False, error_msg) + return + debug_print(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: + cmd = [engine_path, "install", "--show-file-progress", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + 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}'") + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + env_vars = {'NEXUS_API_KEY': self.api_key} + if self.oauth_info: + env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info + from jackify.backend.services.nexus_oauth_service import NexusOAuthService + 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) + ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') + buffer = b'' + last_was_blank = False + while True: + if self.cancelled: + self.cancel() + break + char = self.process_manager.read_stdout_char() + if not char: + break + buffer += char + while b'\n' in buffer or b'\r' in buffer: + if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True): + line, buffer = buffer.split(b'\r', 1) + line = ansi_escape.sub(b'', line) + decoded = line.decode('utf-8', errors='replace') + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator + 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) + logger.warning(f"Matched pattern: '{matched_pattern}'") + logger.warning(f"Triggering line: '{decoded.strip()}'") + logger.warning("AUTHENTICATION DIAGNOSTICS:") + logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}") + if self.api_key: + logger.warning(f" Auth value length: {len(self.api_key)} chars") + if len(self.api_key) >= 8: + logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}") + auth_method = self.auth_service.get_auth_method() if self.auth_service else None + logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}") + if auth_method == 'oauth' and self.auth_service: + token_handler = self.auth_service.token_handler + token_info = token_handler.get_token_info() + logger.warning(" OAuth Token Status:") + logger.warning(f" Has token file: {token_info.get('has_token', False)}") + logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}") + if 'expires_in_minutes' in token_info: + logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes") + if 'refresh_token_age_days' in token_info: + logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days") + if token_info.get('error'): + logger.warning(f" Error: {token_info['error']}") + logger.warning("Previous engine output (last 10 lines):") + for i, buffered_line in enumerate(self._engine_output_buffer, 1): + logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}") + logger.warning("If user HAS Premium, this is a FALSE POSITIVE") + logger.warning("=" * 80) + self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") + self._engine_output_buffer.append(decoded.strip()) + if len(self._engine_output_buffer) > self._buffer_size: + self._engine_output_buffer.pop(0) + if self.progress_state_manager: + updated = self.progress_state_manager.process_line(decoded) + 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]}") + self.progress_updated.emit(progress_state) + if '[FILE_PROGRESS]' in decoded: + parts = decoded.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + self.progress_received.emit(parts[0].rstrip()) + else: + self.progress_received.emit(decoded + '\r') + elif b'\n' in buffer: + line, buffer = buffer.split(b'\n', 1) + line = ansi_escape.sub(b'', line) + decoded = line.decode('utf-8', errors='replace') + from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator + 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) + logger.warning(f"Matched pattern: '{matched_pattern}'") + logger.warning(f"Triggering line: '{decoded.strip()}'") + logger.warning("AUTHENTICATION DIAGNOSTICS:") + logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}") + if self.api_key: + logger.warning(f" Auth value length: {len(self.api_key)} chars") + if len(self.api_key) >= 8: + logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}") + auth_method = self.auth_service.get_auth_method() if self.auth_service else None + logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}") + if auth_method == 'oauth' and self.auth_service: + token_handler = self.auth_service.token_handler + token_info = token_handler.get_token_info() + logger.warning(" OAuth Token Status:") + logger.warning(f" Has token file: {token_info.get('has_token', False)}") + logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}") + if 'expires_in_minutes' in token_info: + logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes") + if 'refresh_token_age_days' in token_info: + logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days") + if token_info.get('error'): + logger.warning(f" Error: {token_info['error']}") + logger.warning("Previous engine output (last 10 lines):") + for i, buffered_line in enumerate(self._engine_output_buffer, 1): + logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}") + logger.warning("If user HAS Premium, this is a FALSE POSITIVE") + logger.warning("=" * 80) + self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") + self._engine_output_buffer.append(decoded.strip()) + if len(self._engine_output_buffer) > self._buffer_size: + self._engine_output_buffer.pop(0) + config_handler = ConfigHandler() + debug_mode = config_handler.get('debug_mode', False) + if self.progress_state_manager: + updated = self.progress_state_manager.process_line(decoded) + 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]}") + self.progress_updated.emit(progress_state) + if '[FILE_PROGRESS]' in decoded: + parts = decoded.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + self.output_received.emit(parts[0].rstrip()) + last_was_blank = False + continue + if decoded.strip() == '': + if not last_was_blank: + self.output_received.emit('\n') + last_was_blank = True + else: + self.output_received.emit(decoded + '\n') + last_was_blank = False + if buffer: + line = ansi_escape.sub(b'', buffer) + decoded = line.decode('utf-8', errors='replace') + if '[FILE_PROGRESS]' in decoded: + parts = decoded.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + self.output_received.emit(parts[0].rstrip()) + else: + self.output_received.emit(decoded) + returncode = self.process_manager.wait() + if self.process_manager.proc and self.process_manager.proc.stdout: + try: + remaining = self.process_manager.proc.stdout.read() + 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]}") + if '[FILE_PROGRESS]' in decoded_remaining: + parts = decoded_remaining.split('[FILE_PROGRESS]', 1) + if parts[0].strip(): + self.output_received.emit(parts[0].rstrip()) + else: + self.output_received.emit(decoded_remaining) + except Exception as e: + debug_print(f"DEBUG: Error reading remaining output: {e}") + 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") + self.installation_finished.emit(False, error_msg) + except Exception as e: + self.installation_finished.emit(False, f"Installation error: {str(e)}") + finally: + if self.cancelled and self.process_manager: + self.process_manager.cancel() diff --git a/jackify/frontends/gui/screens/install_modlist_nexus.py b/jackify/frontends/gui/screens/install_modlist_nexus.py new file mode 100644 index 0000000..f8061ce --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_nexus.py @@ -0,0 +1,260 @@ +"""Nexus authentication methods for InstallModlistScreen (Mixin).""" +from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QProgressDialog, QApplication +from PySide6.QtCore import Qt, QTimer, QThread, Signal +from PySide6.QtGui import QDesktopServices, QGuiApplication +import logging +import webbrowser + +logger = logging.getLogger(__name__) + + +class NexusAuthMixin: + """Mixin providing Nexus authentication methods for InstallModlistScreen.""" + + def _update_nexus_status(self): + """Update the Nexus login status display""" + authenticated, method, username = self.auth_service.get_auth_status() + + if authenticated and method == 'oauth': + # OAuth authorised + status_text = "Authorised" + if username: + status_text += f" ({username})" + self.nexus_status.setText(status_text) + self.nexus_status.setStyleSheet("color: #3fd0ea;") + self.nexus_login_btn.setText("Revoke") + self.nexus_login_btn.setVisible(True) + elif authenticated and method == 'api_key': + # API Key in use (fallback - configured in Settings) + self.nexus_status.setText("API Key") + self.nexus_status.setStyleSheet("color: #FFA726;") + self.nexus_login_btn.setText("Authorise") + self.nexus_login_btn.setVisible(True) + else: + # Not authorised + self.nexus_status.setText("Not Authorised") + self.nexus_status.setStyleSheet("color: #f44336;") + self.nexus_login_btn.setText("Authorise") + self.nexus_login_btn.setVisible(True) + + def _show_copyable_url_dialog(self, url: str): + """Show a dialog with a copyable URL""" + dialog = QDialog(self) + dialog.setWindowTitle("Manual Browser Open Required") + dialog.setModal(True) + dialog.setMinimumWidth(600) + + layout = QVBoxLayout() + layout.setSpacing(15) + + # Explanation label + info_label = QLabel( + "Could not open browser automatically.\n\n" + "Please copy the URL below and paste it into your browser:" + ) + info_label.setWordWrap(True) + info_label.setStyleSheet("color: #ccc; font-size: 12px;") + layout.addWidget(info_label) + + # URL input (read-only but selectable) + url_input = QLineEdit() + url_input.setText(url) + url_input.setReadOnly(True) + url_input.selectAll() # Pre-select text for easy copying + url_input.setStyleSheet(""" + QLineEdit { + background-color: #1a1a1a; + color: #3fd0ea; + border: 1px solid #444; + border-radius: 4px; + padding: 8px; + font-family: monospace; + font-size: 11px; + } + """) + layout.addWidget(url_input) + + # Button row + button_layout = QHBoxLayout() + button_layout.addStretch() + + # Copy button + copy_btn = QPushButton("Copy URL") + copy_btn.setStyleSheet(""" + QPushButton { + background-color: #3fd0ea; + color: #000; + border: none; + border-radius: 4px; + padding: 8px 20px; + font-weight: bold; + } + QPushButton:hover { + background-color: #5fdfff; + } + """) + def copy_to_clipboard(): + clipboard = QApplication.clipboard() + clipboard.setText(url) + copy_btn.setText("Copied!") + copy_btn.setEnabled(False) + copy_btn.clicked.connect(copy_to_clipboard) + button_layout.addWidget(copy_btn) + + # Close button + close_btn = QPushButton("Close") + close_btn.setStyleSheet(""" + QPushButton { + background-color: #444; + color: #ccc; + border: none; + border-radius: 4px; + padding: 8px 20px; + } + QPushButton:hover { + background-color: #555; + } + """) + close_btn.clicked.connect(dialog.accept) + button_layout.addWidget(close_btn) + + layout.addLayout(button_layout) + + dialog.setLayout(layout) + dialog.exec() + + def _handle_nexus_login_click(self): + """Handle Nexus login button click""" + from jackify.frontends.gui.services.message_service import MessageService + + authenticated, method, _ = self.auth_service.get_auth_status() + if authenticated and method == 'oauth': + # OAuth is active - offer to revoke + reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low") + if reply == QMessageBox.Yes: + self.auth_service.revoke_oauth() + self._update_nexus_status() + else: + # Not authorised or using API key - offer to authorise with OAuth + reply = MessageService.question(self, "Authorise with Nexus", + "Your browser will open for Nexus authorisation.\n\n" + "Note: Your browser may ask permission to open 'xdg-open'\n" + "or Jackify's protocol handler - please click 'Open' or 'Allow'.\n\n" + "Please log in and authorise Jackify when prompted.\n\n" + "Continue?", safety_level="low") + + if reply != QMessageBox.Yes: + return + + # Create progress dialog + progress = QProgressDialog( + "Waiting for authorisation...\n\nPlease check your browser.", + "Cancel", + 0, 0, + self + ) + progress.setWindowTitle("Nexus OAuth") + progress.setWindowModality(Qt.WindowModal) + progress.setMinimumDuration(0) + progress.setMinimumWidth(400) + + # Track cancellation + oauth_cancelled = [False] + + def on_cancel(): + oauth_cancelled[0] = True + + progress.canceled.connect(on_cancel) + progress.show() + QApplication.processEvents() + + # Create OAuth thread to prevent GUI freeze + class OAuthThread(QThread): + finished_signal = Signal(bool) + message_signal = Signal(str) + manual_url_signal = Signal(str) # Signal when browser fails to open + + def __init__(self, auth_service, parent=None): + super().__init__(parent) + self.auth_service = auth_service + + def run(self): + def show_message(msg): + # Check if this is a "browser failed" message with URL + if "Could not open browser" in msg and "Please open this URL manually:" in msg: + # Extract URL from message + url_start = msg.find("Please open this URL manually:") + len("Please open this URL manually:") + url = msg[url_start:].strip() + self.manual_url_signal.emit(url) + else: + self.message_signal.emit(msg) + + success = self.auth_service.authorize_oauth(show_browser_message_callback=show_message) + self.finished_signal.emit(success) + + oauth_thread = OAuthThread(self.auth_service, self) + + # Connect message signal to update progress dialog + def update_progress_message(msg): + if not oauth_cancelled[0]: + progress.setLabelText(f"Waiting for authorisation...\n\n{msg}") + QApplication.processEvents() + + # Connect manual URL signal to show copyable dialog + def show_manual_url_dialog(url): + if not oauth_cancelled[0]: + progress.hide() # Hide progress dialog temporarily + self._show_copyable_url_dialog(url) + progress.show() + + oauth_thread.message_signal.connect(update_progress_message) + oauth_thread.manual_url_signal.connect(show_manual_url_dialog) + + # Wait for thread completion + oauth_success = [False] + def on_oauth_finished(success): + oauth_success[0] = success + + oauth_thread.finished_signal.connect(on_oauth_finished) + oauth_thread.start() + + # Wait for thread to finish (non-blocking event loop) + while oauth_thread.isRunning(): + QApplication.processEvents() + oauth_thread.wait(100) # Check every 100ms + if oauth_cancelled[0]: + # User cancelled - thread will still complete but we ignore result + oauth_thread.wait(2000) + if oauth_thread.isRunning(): + oauth_thread.terminate() + break + + progress.close() + QApplication.processEvents() + + self._update_nexus_status() + self._enable_controls_after_operation() + + # Check success first - if OAuth succeeded, ignore cancellation flag + # (progress dialog close can trigger cancel handler even on success) + if oauth_success[0]: + _, _, username = self.auth_service.get_auth_status() + if username: + msg = f"OAuth authorisation successful!

Authorised as: {username}" + else: + msg = "OAuth authorisation successful!" + MessageService.information(self, "Success", msg, safety_level="low") + elif oauth_cancelled[0]: + MessageService.information(self, "Cancelled", "OAuth authorisation cancelled.", safety_level="low") + else: + MessageService.warning( + self, + "Authorisation Failed", + "OAuth authorisation failed.\n\n" + "If your browser showed a blank page (e.g. Firefox on Steam Deck),\n" + "try again and use 'Paste callback URL' to paste the URL from the address bar.\n\n" + "If you see 'redirect URI mismatch', the OAuth redirect URI must be configured by Nexus.\n\n" + "You can configure an API key in Settings as a fallback.", + safety_level="medium" + ) + diff --git a/jackify/frontends/gui/screens/install_modlist_output_mixin.py b/jackify/frontends/gui/screens/install_modlist_output_mixin.py new file mode 100644 index 0000000..5152603 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_output_mixin.py @@ -0,0 +1,228 @@ +""" +InstallModlistOutputMixin: handlers for InstallerThread signals. +on_installation_output, on_installation_progress, on_premium_required_detected, on_progress_updated. +""" + +import time + +from jackify.shared.progress_models import InstallationPhase, OperationType, FileProgress + + +class InstallModlistOutputMixin: + """Mixin providing signal handlers for InstallerThread output/progress/premium/progress_updated.""" + + def on_installation_output(self, message): + """Handle regular output from installation thread.""" + if message.strip().startswith('[Jackify]'): + self._write_to_log_file(message) + return + msg_lower = message.lower() + token_error_keywords = [ + 'token has expired', 'token expired', 'oauth token', 'authentication failed', + 'unauthorized', '401', '403', 'refresh token', 'authorization failed', + 'nexus.*premium.*required', 'premium.*required', + ] + 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'): + self._token_error_notified = True + from jackify.frontends.gui.services.message_service import MessageService + MessageService.error( + self, + "Authentication Error", + ( + "Nexus Mods authentication has failed. This may be due to:\n\n" + "• OAuth token expired and refresh failed\n" + "• Nexus Premium required for this modlist\n" + "• Network connectivity issues\n\n" + "Please check the console output (Show Details) for more information.\n" + "You may need to re-authorize in Settings." + ), + safety_level="high" + ) + guidance = ( + "\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n" + "[Jackify] This may cause downloads to stop. Check the error message above.\n" + "[Jackify] If OAuth token expired, go to Settings and re-authorize.\n" + ) + self._safe_append_text(guidance) + if not self.show_details_checkbox.isChecked(): + self.show_details_checkbox.setChecked(True) + if 'destination array was not long enough' in msg_lower or \ + ('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower): + if not hasattr(self, '_array_error_notified'): + self._array_error_notified = True + guidance = ( + "\n[Jackify] Engine Error Detected: Buffer size issue during .wabbajack download.\n" + "[Jackify] This is a known bug in jackify-engine 0.4.0.\n" + "[Jackify] Workaround: Delete any partial .wabbajack files in your downloads directory and try again.\n" + ) + self._safe_append_text(guidance) + self._safe_append_text(message) + + def on_installation_progress(self, progress_message): + """Handle progress messages from installation thread (main output path).""" + self._safe_append_text(progress_message) + + def on_premium_required_detected(self, engine_line: str): + """Handle detection of Nexus Premium requirement.""" + if self._premium_notice_shown: + return + self._premium_notice_shown = True + self._premium_failure_active = True + user_message = ( + "Nexus Mods rejected the automated download because this account is not Premium. " + "Jackify currently requires a Nexus Premium membership for automated installs, " + "and non-premium support is still planned." + ) + if engine_line: + self._safe_append_text(f"[Jackify] Engine message: {engine_line}") + self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.") + from jackify.frontends.gui.services.message_service import MessageService + MessageService.critical( + self, + "Nexus Premium Required", + f"{user_message}\n\nDetected engine output:\n{engine_line or 'Buy Nexus Premium to automate this process.'}", + safety_level="medium" + ) + if hasattr(self, 'install_thread') and self.install_thread: + self.install_thread.cancel() + + def on_progress_updated(self, progress_state): + """Handle structured progress updates from parser.""" + if progress_state.bsa_building_total > 0 and progress_state.bsa_building_current > 0: + bsa_percent = (progress_state.bsa_building_current / progress_state.bsa_building_total) * 100.0 + progress_state.overall_percent = min(99.0, bsa_percent) + if progress_state.phase == InstallationPhase.DOWNLOAD: + speed_display = progress_state.get_overall_speed_display() + is_stalled = not speed_display or speed_display == "0.0B/s" or \ + (speed_display and any(x in speed_display.lower() for x in ['0.0mb/s', '0.0kb/s', '0b/s'])) + has_active_downloads = any( + f.operation == OperationType.DOWNLOAD and not f.is_complete + for f in progress_state.active_files + ) + if is_stalled and has_active_downloads: + if self._stalled_download_start_time is None: + self._stalled_download_start_time = time.time() + else: + stalled_duration = time.time() - self._stalled_download_start_time + if stalled_duration > 120 and not self._stalled_download_notified: + self._stalled_download_notified = True + from jackify.frontends.gui.services.message_service import MessageService + MessageService.warning( + self, + "Download Stalled", + ( + "Downloads have been stalled (0.0MB/s) for over 2 minutes.\n\n" + "Possible causes:\n" + "• OAuth token expired and refresh failed\n" + "• Network connectivity issues\n" + "• Nexus Mods server issues\n\n" + "Please check the console output (Show Details) for error messages.\n" + "If authentication failed, you may need to re-authorize in Settings." + ), + safety_level="low" + ) + if not self.show_details_checkbox.isChecked(): + self.show_details_checkbox.setChecked(True) + self._safe_append_text( + "\n[Jackify] WARNING: Downloads have stalled (0.0MB/s for 2+ minutes)\n" + "[Jackify] This may indicate an authentication or network issue.\n" + "[Jackify] Check the console above for error messages.\n" + ) + else: + self._stalled_download_start_time = None + self._stalled_download_notified = False + self.progress_indicator.update_progress(progress_state) + phase_label = progress_state.get_phase_label() + is_installation_phase = ( + progress_state.phase == InstallationPhase.INSTALL or + (progress_state.phase_name and 'install' in progress_state.phase_name.lower()) + ) + is_extraction_phase = ( + progress_state.phase == InstallationPhase.EXTRACT or + (progress_state.phase_name and 'extract' in progress_state.phase_name.lower()) + ) + is_bsa_building = False + if progress_state.phase_name: + phase_lower = progress_state.phase_name.lower() + if 'bsa' in phase_lower or ('building' in phase_lower and progress_state.phase == InstallationPhase.INSTALL): + is_bsa_building = True + if not is_bsa_building and progress_state.message: + msg_lower = progress_state.message.lower() + if ('building' in msg_lower or 'writing' in msg_lower or 'verifying' in msg_lower) and '.bsa' in msg_lower: + is_bsa_building = True + if not is_bsa_building and progress_state.active_files: + bsa_files = [f for f in progress_state.active_files if f.filename.lower().endswith('.bsa')] + if bsa_files and progress_state.phase == InstallationPhase.INSTALL: + is_bsa_building = True + if not is_bsa_building: + display_text = getattr(progress_state, 'display_text', None) or '' + if 'bsa' in display_text.lower() and progress_state.phase == InstallationPhase.INSTALL: + is_bsa_building = True + now_mono = time.monotonic() + if is_bsa_building: + self._bsa_hold_deadline = now_mono + 1.5 + elif now_mono < self._bsa_hold_deadline: + is_bsa_building = True + else: + self._bsa_hold_deadline = now_mono + if is_installation_phase: + current_step = progress_state.phase_step + display_items = [] + if current_step > 0 or progress_state.phase_max_steps > 0: + install_line = FileProgress( + filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}", + operation=OperationType.INSTALL, percent=0.0, speed=-1.0 + ) + install_line._no_progress_bar = True + display_items.append(install_line) + for f in progress_state.active_files: + if f.operation == OperationType.INSTALL: + if f.filename.lower().endswith('.bsa') or f.filename.lower().endswith('.ba2'): + display_filename = f"BSA: {f.filename} ({progress_state.bsa_building_current}/{progress_state.bsa_building_total})" if progress_state.bsa_building_total > 0 else f"BSA: {f.filename}" + display_file = FileProgress(filename=display_filename, operation=f.operation, percent=f.percent, current_size=0, total_size=0, speed=-1.0) + display_items.append(display_file) + if len(display_items) >= 4: + break + elif f.filename.lower().endswith(('.dds', '.png', '.tga', '.bmp')): + display_filename = f"Converting Texture: {f.filename} ({progress_state.texture_conversion_current}/{progress_state.texture_conversion_total})" if progress_state.texture_conversion_total > 0 else f"Converting Texture: {f.filename}" + display_file = FileProgress(filename=display_filename, operation=f.operation, percent=f.percent, current_size=0, total_size=0, speed=-1.0) + display_items.append(display_file) + if len(display_items) >= 4: + break + if display_items: + self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None) + return + if is_extraction_phase: + current_step = progress_state.phase_step + summary_info = {'current_step': current_step, 'max_steps': progress_state.phase_max_steps} + phase_display_name = phase_label or "Extracting" + self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info) + return + if progress_state.active_files: + try: + self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None) + 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}") + 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}") + import logging + logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) + else: + try: + self.file_progress_list.update_files([], current_phase=phase_label) + except RuntimeError as e: + if "already deleted" in str(e): + return + raise + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) diff --git a/jackify/frontends/gui/screens/install_modlist_postinstall.py b/jackify/frontends/gui/screens/install_modlist_postinstall.py new file mode 100644 index 0000000..aca9a0f --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_postinstall.py @@ -0,0 +1,470 @@ +"""Post-install UI feedback management for InstallModlistScreen (Mixin).""" +import re +import time +from typing import Optional + +from PySide6.QtCore import QTimer + +from jackify.shared.progress_models import InstallationProgress, InstallationPhase, FileProgress, OperationType + + +class PostInstallFeedbackMixin: + """Mixin providing post-install progress tracking and UI feedback for InstallModlistScreen.""" + + def _build_post_install_sequence(self): + """ + Define the ordered steps for post-install (Jackify-managed) operations. + + These steps represent Jackify's automated Steam integration and configuration workflow + that runs AFTER the jackify-engine completes modlist installation. Progress is shown as + "X/Y" in the progress banner and Activity window. + + The post-install steps are: + 1. Preparing Steam integration - Initial setup before creating Steam shortcut + 2. Creating Steam shortcut - Add modlist to Steam library with proper Proton settings + 3. Restarting Steam - Restart Steam to make shortcut visible and create AppID + 4. Creating Proton prefix - Launch temporary batch file to initialize Proton prefix + 5. Verifying Steam setup - Confirm prefix exists and Proton version is correct + 6. Steam integration complete - Steam setup finished successfully + 7. Installing Wine components - Install vcrun, dotnet, and other Wine dependencies + 8. Applying registry files - Import .reg files for game configuration + 9. Installing .NET fixes - Apply .NET framework workarounds if needed + 10. Enabling dotfiles - Make hidden config files visible in file manager + 11. Setting permissions - Ensure modlist files have correct permissions + 12. Backing up configuration - Create backup of ModOrganizer.ini + 13. Finalising Jackify configuration - All post-install steps complete + """ + return [ + { + 'id': 'prepare', + 'label': "Preparing Steam integration", + 'keywords': [ + "starting automated steam setup", + "starting configuration phase", + "starting configuration" + ], + }, + { + 'id': 'steam_shortcut', + 'label': "Creating Steam shortcut", + 'keywords': [ + "creating steam shortcut", + "steam shortcut created successfully" + ], + }, + { + 'id': 'steam_restart', + 'label': "Restarting Steam", + 'keywords': [ + "restarting steam", + "steam restarted successfully" + ], + }, + { + 'id': 'proton_prefix', + 'label': "Creating Proton prefix", + 'keywords': [ + "creating proton prefix", + "proton prefix created successfully", + "temporary batch file launched", + "verifying prefix creation" + ], + }, + { + 'id': 'steam_verify', + 'label': "Verifying Steam setup", + 'keywords': [ + "verifying setup", + "verifying prefix", + "setup verification completed", + "detecting actual appid", + "steam configuration complete" + ], + }, + { + 'id': 'steam_complete', + 'label': "Steam integration complete", + 'keywords': [ + "steam integration complete", + "steam integration", + "steam configuration complete!" + ], + }, + { + 'id': 'wine_components', + 'label': "Installing Wine components", + 'keywords': [ + "installing wine components", + "wine components", + "vcrun", + "dotnet", + "running winetricks", + ], + }, + { + 'id': 'registry_files', + 'label': "Applying registry files", + 'keywords': [ + "applying registry", + "importing registry", + ".reg file", + "registry files", + ], + }, + { + 'id': 'dotnet_fixes', + 'label': "Installing .NET fixes", + 'keywords': [ + "dotnet fix", + ".net fix", + "installing .net", + ], + }, + { + 'id': 'enable_dotfiles', + 'label': "Enabling dotfiles", + 'keywords': [ + "enabling dotfiles", + "dotfiles", + "hidden files", + ], + }, + { + 'id': 'set_permissions', + 'label': "Setting permissions", + 'keywords': [ + "setting permissions", + "chmod", + "permissions", + ], + }, + { + 'id': 'backup_config', + 'label': "Backing up configuration", + 'keywords': [ + "backing up", + "modorganizer.ini", + "backup", + ], + }, + { + 'id': 'vnv_root_mods', + 'label': "VNV: Copying root mods", + 'keywords': [ + "step 1/3: copying root mods", + "copying root mods to game directory", + "root mods:", + ], + }, + { + 'id': 'vnv_4gb_patch', + 'label': "VNV: Applying 4GB patch", + 'keywords': [ + "step 2/3: downloading and running 4gb patcher", + "downloading fnv4gb", + "downloading:", + "fetching file list", + "running 4gb patcher", + "4gb patcher:", + ], + }, + { + 'id': 'vnv_bsa_decompress', + 'label': "VNV: Decompressing BSA files", + 'keywords': [ + "step 3/3: downloading and running bsa decompressor", + "downloading:", + "fetching file list", + "running bsa decompressor", + "decompressing bsa files:", + "bsa decompression:", + ], + }, + { + 'id': 'config_finalize', + 'label': "Finalising Jackify configuration", + 'keywords': [ + "configuration completed successfully", + "configuration complete", + "manual steps validation failed", + "configuration failed", + "vnv post-install completed successfully" + ], + }, + ] + + def _begin_post_install_feedback(self): + """Reset trackers and surface post-install progress in collapsed mode.""" + self._post_install_active = True + self._post_install_current_step = 0 + self._post_install_last_label = "Preparing Steam integration" + total = max(1, self._post_install_total_steps) + self._update_post_install_ui(self._post_install_last_label, 0, total) + + def _handle_post_install_progress(self, message: str): + """Translate backend progress messages into collapsed-mode feedback.""" + if not self._post_install_active or not message: + return + + text = message.strip() + if not text: + return + normalized = text.lower() + total = max(1, self._post_install_total_steps) + matched = False + matched_step = None + + # Check for wine components completion first + if "wine components verified" in normalized or "wine components installed" in normalized: + self._stop_component_install_pulse() + + for idx, step in enumerate(self._post_install_sequence, start=1): + if any(keyword in normalized for keyword in step['keywords']): + matched = True + matched_step = idx + # Always update to the highest step we've seen (don't go backwards) + if idx >= self._post_install_current_step: + # Stop pulser when moving away from wine_components step + if self._post_install_current_step > 0: + prev_step = self._post_install_sequence[self._post_install_current_step - 1] + if prev_step['id'] == 'wine_components' and step['id'] != 'wine_components': + self._stop_component_install_pulse() + + self._post_install_current_step = idx + self._post_install_last_label = step['label'] + + # Wine components: pulser manages Activity window directly. + # Must remove summary widget so pulser items display immediately + # (otherwise the 0.5s hold blocks update_files from adding items). + if step['id'] == 'wine_components': + self.file_progress_list.clear_summary() + self.progress_indicator.set_status( + "Installing Wine components...", + int((self._post_install_current_step / total) * 100) + ) + if not hasattr(self, '_component_install_timer') or not self._component_install_timer: + self._start_component_install_pulse() + # Always check for component list updates (may come in later messages) + comp_list = self._parse_wine_components_message(text) + if comp_list: + self._start_component_install_pulse_with_components(comp_list) + break + + # Keep Activity window in sync with progress banner + # If we're already in wine_components step, check for component list updates + # Skip _update_post_install_ui() for wine_components - pulser manages Activity window directly + if step['id'] == 'wine_components': + comp_list = self._parse_wine_components_message(text) + if comp_list: + self._start_component_install_pulse_with_components(comp_list) + # Don't call _update_post_install_ui() - it would clear the component items + break + + # CRITICAL: If pulser is active (wine components still installing), don't update progress banner + # Keep it on "Installing Wine components..." until pulser stops + if getattr(self, '_component_install_timer', None) and self._component_install_timer.isActive(): + # Find wine_components step and keep banner on that + wine_step = None + wine_step_idx = None + for wine_idx, wine_s in enumerate(self._post_install_sequence, start=1): + if wine_s['id'] == 'wine_components': + wine_step = wine_s + wine_step_idx = wine_idx + break + if wine_step: + # Update step counter internally but keep banner on wine components + # Filter out winetricks/protontricks internal messages from detail + filtered_detail = text + if text and any(keyword in text.lower() for keyword in ['perl:', 'wine:', 'winetricks:', 'protontricks:']): + filtered_detail = None + self._update_post_install_ui( + wine_step['label'], + wine_step_idx, + total, + detail=filtered_detail + ) + break + + self._update_post_install_ui(step['label'], self._post_install_current_step, total, detail=text) + break + + # If no match but we have a current step, update with that step (not a new one) + # Skip when pulser is active -- it manages Activity window directly + if not matched and self._post_install_current_step > 0: + # CRITICAL: If pulser is active, we're still installing wine components + # Keep progress banner on "Installing Wine components..." regardless of step counter + if getattr(self, '_component_install_timer', None) and self._component_install_timer.isActive(): + # Find wine_components step in sequence + wine_step = None + wine_step_idx = None + for idx, step in enumerate(self._post_install_sequence, start=1): + if step['id'] == 'wine_components': + wine_step = step + wine_step_idx = idx + break + + if wine_step: + # Always check for component list updates, even if message doesn't match keywords + comp_list = self._parse_wine_components_message(text) + if comp_list: + self._start_component_install_pulse_with_components(comp_list) + # Update progress banner to show wine components installation (pulser manages Activity window directly) + # Filter out winetricks/protontricks internal messages from detail + filtered_detail = text + if text and any(keyword in text.lower() for keyword in ['perl:', 'wine:', 'winetricks:', 'protontricks:']): + filtered_detail = None + total = len(self._post_install_sequence) + self._update_post_install_ui( + wine_step['label'], + wine_step_idx, + total, + detail=filtered_detail + ) + return + + # Check if we're in wine_components step (by step counter) + current_step = self._post_install_sequence[self._post_install_current_step - 1] if self._post_install_current_step > 0 else None + if current_step and current_step['id'] == 'wine_components': + # Always check for component list updates, even if message doesn't match keywords + comp_list = self._parse_wine_components_message(text) + if comp_list: + self._start_component_install_pulse_with_components(comp_list) + # Update progress banner to keep it current (pulser manages Activity window directly) + # Filter out winetricks/protontricks internal messages from detail + filtered_detail = text + if text and any(keyword in text.lower() for keyword in ['perl:', 'wine:', 'winetricks:', 'protontricks:']): + filtered_detail = None + total = len(self._post_install_sequence) + self._update_post_install_ui( + current_step['label'], + self._post_install_current_step, + total, + detail=filtered_detail + ) + return + + if not getattr(self, '_component_install_timer', None): + label = self._post_install_last_label or "Post-installation" + # Filter out winetricks/protontricks internal messages from detail + filtered_detail = text + if text and any(keyword in text.lower() for keyword in ['perl:', 'wine:', 'winetricks:', 'protontricks:']): + filtered_detail = None + self._update_post_install_ui(label, self._post_install_current_step, total, detail=filtered_detail) + + def _strip_timestamp_prefix(self, text: str) -> str: + """Remove timestamp prefix like '[00:03:15]' from text.""" + # Match timestamps like [00:03:15], [01:23:45], etc. + timestamp_pattern = r'^\[\d{2}:\d{2}:\d{2}\]\s*' + return re.sub(timestamp_pattern, '', text) + + def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None): + """Update progress indicator + activity summary for post-install steps.""" + # Use the label as the primary display, but include step info in Activity window + display_label = label + if detail: + # Remove timestamp prefix from detail messages + clean_detail = self._strip_timestamp_prefix(detail.strip()) + if clean_detail: + # Filter out winetricks/protontricks internal messages (perl, wine paths, etc.) + # These are implementation details, not user-facing status + if any(keyword in clean_detail.lower() for keyword in ['perl:', 'wine:', '/usr/bin/', 'winetricks:', 'protontricks:']): + # Use original label, ignore internal tool messages + pass + elif clean_detail.lower().startswith(label.lower()): + display_label = clean_detail + else: + display_label = clean_detail + total = max(1, total) + step_clamped = max(0, min(step, total)) + overall_percent = (step_clamped / total) * 100.0 + + # CRITICAL: Ensure both displays use the SAME step counter + # Progress banner uses phase_step/phase_max_steps from progress_state + progress_state = InstallationProgress( + phase=InstallationPhase.FINALIZE, + phase_name=display_label, # This will show in progress banner + phase_step=step_clamped, # This creates [step/total] in display_text + phase_max_steps=total, + overall_percent=overall_percent + ) + self.progress_indicator.update_progress(progress_state) + + # Activity window uses summary_info with the SAME step counter + summary_info = { + 'current_step': step_clamped, # Must match phase_step above + 'max_steps': total, # Must match phase_max_steps above + } + # Use the same label for consistency + self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info) + + def _end_post_install_feedback(self, success: bool): + """Mark the end of post-install feedback.""" + if not self._post_install_active: + return + self._stop_component_install_pulse() + total = max(1, self._post_install_total_steps) + final_step = total if success else max(0, self._post_install_current_step) + label = "Post-installation complete" if success else "Post-installation stopped" + self._update_post_install_ui(label, final_step, total) + self._post_install_active = False + self._post_install_last_label = label + + def _parse_wine_components_message(self, text: str): + """Extract list of wine component names from backend status message, or None.""" + if "installing wine components:" not in text.lower() and "installing wine components via protontricks:" not in text.lower(): + return None + match = re.search(r"installing wine components(?:\s+via protontricks)?:\s*(.+)", text, re.IGNORECASE) + if not match: + return None + raw = match.group(1).strip() + if not raw: + return None + return [c.strip() for c in raw.split(",") if c.strip()] + + def _start_component_install_pulse(self): + """Start pulsing Activity item for Wine component installation.""" + self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) + if not getattr(self, '_component_install_timer', None): + self._component_install_timer = QTimer(self) + self._component_install_timer.timeout.connect(self._component_install_heartbeat) + self._component_install_timer.start(100) + self._component_install_start_time = time.time() + + def _start_component_install_pulse_with_components(self, components: list): + """Replace single item with one Activity entry per component, each with pulsing progress.""" + self._component_install_list = components + progresses = [ + FileProgress( + filename=f"Wine component: {comp}", + operation=OperationType.UNKNOWN, + percent=0.0, + ) + for comp in components + ] + self.file_progress_list.update_files(progresses, current_phase=None) + + def _component_install_heartbeat(self): + """Heartbeat to keep component install item(s) pulsing.""" + if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time: + return + if hasattr(self, '_component_install_list') and self._component_install_list: + progresses = [ + FileProgress( + filename=f"Wine component: {comp}", + operation=OperationType.UNKNOWN, + percent=0.0, + ) + for comp in self._component_install_list + ] + self.file_progress_list.update_files(progresses, current_phase=None) + else: + self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0) + + def _stop_component_install_pulse(self): + """Stop the component install pulsing timer.""" + if hasattr(self, '_component_install_timer') and self._component_install_timer: + self._component_install_timer.stop() + self._component_install_timer = None + if hasattr(self, '_component_install_list'): + del self._component_install_list + diff --git a/jackify/frontends/gui/screens/install_modlist_progress.py b/jackify/frontends/gui/screens/install_modlist_progress.py new file mode 100644 index 0000000..128eb00 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_progress.py @@ -0,0 +1,413 @@ +"""Progress and installation event handlers for InstallModlistScreen (Mixin).""" +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.progress_models import InstallationPhase, OperationType, InstallationProgress, FileProgress +from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator +import time + + +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 ProgressHandlersMixin: + """Mixin providing progress tracking and installation event handlers for InstallModlistScreen.""" + + def on_installation_progress(self, progress_message): + """ + Handle progress messages from installation thread. + + NOTE: This is called for MOST engine output, not just progress lines! + The name is misleading - it's actually the main output path. + """ + # Always write output to console buffer (same as on_installation_output) + self._safe_append_text(progress_message) + + def on_premium_required_detected(self, engine_line: str): + """Handle detection of Nexus Premium requirement.""" + if self._premium_notice_shown: + return + + self._premium_notice_shown = True + self._premium_failure_active = True + + user_message = ( + "Nexus Mods rejected the automated download because this account is not Premium. " + "Jackify currently requires a Nexus Premium membership for automated installs, " + "and non-premium support is still planned." + ) + + if engine_line: + self._safe_append_text(f"[Jackify] Engine message: {engine_line}") + self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.") + + MessageService.critical( + self, + "Nexus Premium Required", + f"{user_message}\n\nDetected engine output:\n{engine_line or 'Buy Nexus Premium to automate this process.'}", + safety_level="medium" + ) + + if hasattr(self, 'install_thread') and self.install_thread: + self.install_thread.cancel() + + def on_progress_updated(self, progress_state): + """R&D: Handle structured progress updates from parser""" + # Calculate proper overall progress during BSA building + # During BSA building, file installation is at 100% but BSAs are still being built + # Override overall_percent to show BSA building progress instead + if progress_state.bsa_building_total > 0 and progress_state.bsa_building_current > 0: + bsa_percent = (progress_state.bsa_building_current / progress_state.bsa_building_total) * 100.0 + progress_state.overall_percent = min(99.0, bsa_percent) # Cap at 99% until fully complete + + # CRITICAL: Detect stalled downloads (0.0MB/s for extended period) + # Catch silent token refresh failures or network issues + # IMPORTANT: Only check during DOWNLOAD phase, not during VALIDATE phase + # Validation checks existing files and shows 0.0MB/s, which is expected behavior + if progress_state.phase == InstallationPhase.DOWNLOAD: + speed_display = progress_state.get_overall_speed_display() + # Check if speed is 0 or very low (< 0.1MB/s) for more than 2 minutes + # Only trigger if we're actually in download phase (not validation) + is_stalled = not speed_display or speed_display == "0.0B/s" or \ + (speed_display and any(x in speed_display.lower() for x in ['0.0mb/s', '0.0kb/s', '0b/s'])) + + # Additional check: Only consider it stalled if we have active download files + # If no files are being downloaded, it might just be between downloads + has_active_downloads = any( + f.operation == OperationType.DOWNLOAD and not f.is_complete + for f in progress_state.active_files + ) + + if is_stalled and has_active_downloads: + if self._stalled_download_start_time is None: + self._stalled_download_start_time = time.time() + 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 + MessageService.warning( + self, + "Download Stalled", + ( + "Downloads have been stalled (0.0MB/s) for over 2 minutes.\n\n" + "Possible causes:\n" + "• OAuth token expired and refresh failed\n" + "• Network connectivity issues\n" + "• Nexus Mods server issues\n\n" + "Please check the console output (Show Details) for error messages.\n" + "If authentication failed, you may need to re-authorize in Settings." + ), + safety_level="low" + ) + # Force console to be visible + if not self.show_details_checkbox.isChecked(): + self.show_details_checkbox.setChecked(True) + # Add warning to console + self._safe_append_text( + "\n[Jackify] WARNING: Downloads have stalled (0.0MB/s for 2+ minutes)\n" + "[Jackify] This may indicate an authentication or network issue.\n" + "[Jackify] Check the console above for error messages.\n" + ) + else: + # Downloads are active - reset stall timer + self._stalled_download_start_time = None + self._stalled_download_notified = False + + # Update progress indicator widget + self.progress_indicator.update_progress(progress_state) + + # Only show file progress list if console is not visible (mutually exclusive) + console_visible = self.show_details_checkbox.isChecked() + + # Determine phase display name up front (short/stable label) + phase_label = progress_state.get_phase_label() + + # During installation or extraction phase, show summary counter instead of individual files + # Avoid cluttering UI with completed files + is_installation_phase = ( + progress_state.phase == InstallationPhase.INSTALL or + (progress_state.phase_name and 'install' in progress_state.phase_name.lower()) + ) + is_extraction_phase = ( + progress_state.phase == InstallationPhase.EXTRACT or + (progress_state.phase_name and 'extract' in progress_state.phase_name.lower()) + ) + + # Detect BSA building phase - check multiple indicators + is_bsa_building = False + + # Check phase name for BSA indicators + if progress_state.phase_name: + phase_lower = progress_state.phase_name.lower() + if 'bsa' in phase_lower or ('building' in phase_lower and progress_state.phase == InstallationPhase.INSTALL): + is_bsa_building = True + + # Check message/status text for BSA building indicators + if not is_bsa_building and progress_state.message: + msg_lower = progress_state.message.lower() + if ('building' in msg_lower or 'writing' in msg_lower or 'verifying' in msg_lower) and '.bsa' in msg_lower: + is_bsa_building = True + + # Check if we have BSA files being processed (even if they're at 100%, they indicate BSA phase) + if not is_bsa_building and progress_state.active_files: + bsa_files = [f for f in progress_state.active_files if f.filename.lower().endswith('.bsa')] + if len(bsa_files) > 0: + # If we have any BSA files and we're in INSTALL phase, likely BSA building + if progress_state.phase == InstallationPhase.INSTALL: + is_bsa_building = True + + # Also check display text for BSA mentions (fallback) + if not is_bsa_building: + display_lower = progress_state.display_text.lower() + if 'bsa' in display_lower and progress_state.phase == InstallationPhase.INSTALL: + is_bsa_building = True + + now_mono = time.monotonic() + if is_bsa_building: + self._bsa_hold_deadline = now_mono + 1.5 + elif now_mono < self._bsa_hold_deadline: + is_bsa_building = True + else: + self._bsa_hold_deadline = now_mono + + if is_installation_phase: + # During installation, we may have BSA building AND file installation happening + # Show both: install summary + any active BSA files + # Render loop handles smooth updates - just set target state + + current_step = progress_state.phase_step + + display_items = [] + + # Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size) + if current_step > 0 or progress_state.phase_max_steps > 0: + install_line = FileProgress( + filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}", + operation=OperationType.INSTALL, + percent=0.0, + speed=-1.0 + ) + install_line._no_progress_bar = True # Flag to hide progress bar + display_items.append(install_line) + + # Lines 2+: Show converting textures and BSA files + # Extract and categorize active files + for f in progress_state.active_files: + if f.operation == OperationType.INSTALL: + if f.filename.lower().endswith('.bsa') or f.filename.lower().endswith('.ba2'): + # BSA: filename.bsa (42/89) - Use state-level BSA counter + if progress_state.bsa_building_total > 0: + display_filename = f"BSA: {f.filename} ({progress_state.bsa_building_current}/{progress_state.bsa_building_total})" + else: + display_filename = f"BSA: {f.filename}" + + display_file = FileProgress( + filename=display_filename, + operation=f.operation, + percent=f.percent, + current_size=0, # Don't show size + total_size=0, + speed=-1.0 # No speed + ) + display_items.append(display_file) + if len(display_items) >= 4: # Max 1 install line + 3 operations + break + elif f.filename.lower().endswith(('.dds', '.png', '.tga', '.bmp')): + # Converting Texture: filename.dds (234/1078) + # Use state-level texture counter (more reliable than file-level) + if progress_state.texture_conversion_total > 0: + display_filename = f"Converting Texture: {f.filename} ({progress_state.texture_conversion_current}/{progress_state.texture_conversion_total})" + else: + # No texture counter available, just show filename + display_filename = f"Converting Texture: {f.filename}" + + display_file = FileProgress( + filename=display_filename, + operation=f.operation, + percent=f.percent, + current_size=0, # Don't show size + total_size=0, + speed=-1.0 # No speed + ) + display_items.append(display_file) + if len(display_items) >= 4: # Max 1 install line + 3 operations + break + + # Update target state (render loop handles smooth display) + # Explicitly pass None for summary_info to clear any stale summary data + if display_items: + self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None) + return + elif is_extraction_phase: + # Show summary info for Extracting phase (step count) + # Render loop handles smooth updates - just set target state + # Explicitly pass empty list for file_progresses to clear any stale file list + current_step = progress_state.phase_step + summary_info = { + 'current_step': current_step, + 'max_steps': progress_state.phase_max_steps, + } + phase_display_name = phase_label or "Extracting" + self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info) + return + elif progress_state.active_files: + if self.debug: + debug_print(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})") + # Pass phase label to update header (e.g., "[Activity - Downloading]") + # Explicitly clear summary_info when showing file list + try: + self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None) + except RuntimeError as e: + # 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}") + 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}") + import logging + logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) + else: + # Show empty state so widget stays visible even when no files are active + try: + self.file_progress_list.update_files([], current_phase=phase_label) + except RuntimeError as e: + # Widget was deleted - ignore to prevent coredump + if "already deleted" in str(e): + return + raise + except Exception as e: + # Catch any other exceptions to prevent coredump + import logging + logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) + + def on_installation_finished(self, success, message): + """Handle installation completion""" + debug_print(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 + self.file_progress_list.list_widget.clear() + self.file_progress_list._file_items.clear() + self.file_progress_list._summary_widget = None + self.file_progress_list._transition_label = None + self.file_progress_list._last_phase = None + + if success: + # Update progress indicator with completion + final_state = InstallationProgress( + phase=InstallationPhase.FINALIZE, + phase_name="Installation Complete", + overall_percent=100.0 + ) + self.progress_indicator.update_progress(final_state) + + if self.show_details_checkbox.isChecked(): + self._safe_append_text(f"\nSuccess: {message}") + self.process_finished(0, QProcess.NormalExit) # Simulate successful completion + else: + # Reset to initial state on failure + self.progress_indicator.reset() + + if self._premium_failure_active: + message = "Installation stopped because Nexus Premium is required for automated downloads." + + 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}") + # 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") + + + if exit_code == 0: + # Check if this was an unsupported game + game_type = getattr(self, '_current_game_type', None) + game_name = getattr(self, '_current_game_name', None) + + if game_type and not self.wabbajack_parser.is_supported_game(game_type): + # Show success message for unsupported games without post-install configuration + MessageService.information( + self, "Modlist Install Complete!", + f"Modlist installation completed successfully!\n\n" + 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." + ) + 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: + # Check if auto-restart is enabled + auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked() + + if auto_restart_enabled: + # Auto-accept Steam restart - proceed without dialog + self._safe_append_text("\nAuto-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 + ) + + if reply == QMessageBox.Yes: + # --- Create Steam shortcut BEFORE restarting Steam --- + # Proceed directly to automated prefix creation + self.start_automated_prefix_workflow() + else: + # User selected "No" - show completion message and keep GUI open + self._safe_append_text("\nModlist installation completed successfully!") + self._safe_append_text("Note: You can manually configure Steam integration later if needed.") + MessageService.information( + self, "Installation Complete", + "Modlist installation completed successfully!\n\n" + "The modlist has been installed but Steam integration was skipped.\n" + "You can manually add the modlist to Steam later if desired.", + safety_level="medium" + ) + # Re-enable controls since operation is complete + self._enable_controls_after_operation() + else: + # Check for user cancellation first - check message parameter first, then console + if self._premium_failure_active: + MessageService.warning( + self, + "Nexus Premium Required", + "Jackify stopped the installation because Nexus Mods reported that this account is not Premium.\n\n" + "Automatic installs currently require Nexus Premium. Non-premium support is planned.", + safety_level="medium" + ) + self._safe_append_text("\nInstall stopped: Nexus Premium required.") + self._premium_failure_active = False + elif hasattr(self, '_cancellation_requested') and self._cancellation_requested: + # User explicitly cancelled via cancel button + MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") + self._cancellation_requested = False + else: + # Check console as fallback + 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.") + 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 new file mode 100644 index 0000000..8217665 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_selection.py @@ -0,0 +1,195 @@ +"""Modlist selection methods for InstallModlistScreen (Mixin).""" +from pathlib import Path +from PySide6.QtWidgets import QFileDialog, QMessageBox, QApplication, QDialog +from PySide6.QtCore import QTimer, Qt +import logging +import os +import re +# Runtime imports to avoid circular dependencies +from .install_modlist_dialogs import SelectionDialog, ModlistFetchThread # Runtime import +from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog # Runtime import + +logger = logging.getLogger(__name__) + + +class ModlistSelectionMixin: + """Mixin providing modlist selection methods for InstallModlistScreen.""" + + def open_game_type_dialog(self): + dlg = SelectionDialog("Select Game Type", self.game_types, self, show_search=False) + if dlg.exec() == QDialog.Accepted and dlg.selected_item: + self.game_type_btn.setText(dlg.selected_item) + # Store game type for gallery filter + self.current_game_type = dlg.selected_item + # Enable modlist button immediately - gallery will fetch its own data + self.modlist_btn.setEnabled(True) + self.modlist_btn.setText("Select Modlist") + # No need to fetch modlists here - gallery does it when opened + + def fetch_modlists_for_game_type(self, game_type): + self.current_game_type = game_type # Store for display formatting + self.modlist_btn.setText("Fetching modlists...") + self.modlist_btn.setEnabled(False) + game_type_map = { + "Skyrim": "skyrim", + "Fallout 4": "fallout4", + "Fallout New Vegas": "falloutnv", + "Oblivion": "oblivion", + "Starfield": "starfield", + "Oblivion Remastered": "oblivion_remastered", + "Enderal": "enderal", + "Other": "other" + } + cli_game_type = game_type_map.get(game_type, "other") + log_path = self.modlist_log_path + # Use backend service directly - NO CLI CALLS + self.fetch_thread = ModlistFetchThread( + cli_game_type, log_path, mode='list-modlists') + self.fetch_thread.result.connect(self.on_modlists_fetched) + self.fetch_thread.start() + + def on_modlists_fetched(self, modlist_infos, error): + # Handle the case where modlist_infos might be strings (backward compatibility) + if modlist_infos and isinstance(modlist_infos[0], str): + filtered = [m for m in modlist_infos if m and not m.startswith('DEBUG:')] + self.current_modlists = filtered + self.current_modlist_display = filtered + else: + # New format - full modlist objects with enhanced metadata + filtered_modlists = [m for m in modlist_infos if m and hasattr(m, 'id')] + filtered = filtered_modlists # Set filtered for the condition check below + self.current_modlists = [m.id for m in filtered_modlists] # Keep IDs for selection + + # Create enhanced display strings with size info and status indicators + display_strings = [] + for modlist in filtered_modlists: + # Get enhanced metadata + download_size = getattr(modlist, 'download_size', '') + install_size = getattr(modlist, 'install_size', '') + total_size = getattr(modlist, 'total_size', '') + status_down = getattr(modlist, 'status_down', False) + status_nsfw = getattr(modlist, 'status_nsfw', False) + + # Format display string without redundant game type: "Modlist Name - Download|Install|Total" + # For "Other" category, include game type in brackets for clarity + # Use padding to create alignment: left-aligned name, right-aligned sizes + if hasattr(self, 'current_game_type') and self.current_game_type == "Other": + name_part = f"{modlist.name} [{modlist.game}]" + else: + name_part = modlist.name + size_part = f"{download_size}|{install_size}|{total_size}" + + # Create aligned display using string formatting (approximate alignment) + display_str = f"{name_part:<50} {size_part:>15}" + + # Add status indicators at the beginning if present + if status_down or status_nsfw: + status_parts = [] + if status_down: + status_parts.append("[DOWN]") + if status_nsfw: + status_parts.append("[NSFW]") + display_str = " ".join(status_parts) + " " + display_str + + display_strings.append(display_str) + + self.current_modlist_display = display_strings + + # Create mapping from display string back to modlist ID for selection + self._modlist_id_map = {} + if len(self.current_modlist_display) == len(self.current_modlists): + self._modlist_id_map = {display: modlist_id for display, modlist_id in + zip(self.current_modlist_display, self.current_modlists)} + else: + # Fallback for backward compatibility + self._modlist_id_map = {mid: mid for mid in self.current_modlists} + if error: + self.modlist_btn.setText("Error fetching modlists.") + self.modlist_btn.setEnabled(False) + # Don't write to log file before workflow starts - just show error in UI + elif filtered: + self.modlist_btn.setText("Select Modlist") + self.modlist_btn.setEnabled(True) + else: + self.modlist_btn.setText("No modlists found.") + self.modlist_btn.setEnabled(False) + + def open_modlist_dialog(self): + # CRITICAL: Prevent opening gallery without game type selected + # Prevent engine path resolution / subprocess issues + if not hasattr(self, 'current_game_type') or not self.current_game_type: + QMessageBox.warning( + self, + "Game Type Required", + "Please select a game type before opening the modlist gallery." + ) + return + + self.modlist_btn.setEnabled(False) + cursor_overridden = False + try: + QApplication.setOverrideCursor(Qt.WaitCursor) + cursor_overridden = True + + game_type_to_human_friendly = { + "Skyrim": "Skyrim Special Edition", + "Fallout 4": "Fallout 4", + "Fallout New Vegas": "Fallout New Vegas", + "Oblivion": "Oblivion", + "Starfield": "Starfield", + "Oblivion Remastered": "Oblivion", + "Enderal": "Enderal Special Edition", + "Other": None + } + + game_filter = None + if hasattr(self, 'current_game_type'): + game_filter = game_type_to_human_friendly.get(self.current_game_type) + + dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self) + if cursor_overridden: + QApplication.restoreOverrideCursor() + cursor_overridden = False + + if dlg.exec() == QDialog.Accepted and dlg.selected_metadata: + metadata = dlg.selected_metadata + self.modlist_btn.setText(metadata.title) + self.selected_modlist_info = { + 'machine_url': metadata.namespacedName, + 'title': metadata.title, + 'author': metadata.author, + 'game': metadata.gameHumanFriendly, + 'description': metadata.description, + 'nsfw': metadata.nsfw, + 'force_down': metadata.forceDown + } + self.modlist_name_edit.setText(metadata.title) + + # Auto-append modlist name to install directory + base_install_dir = self.config_handler.get_modlist_install_base_dir() + if base_install_dir: + # Sanitize modlist title for filesystem use + safe_title = re.sub(r'[<>:"/\\|?*]', '', metadata.title) + safe_title = safe_title.strip() + modlist_install_path = os.path.join(base_install_dir, safe_title) + self.install_dir_edit.setText(modlist_install_path) + finally: + if cursor_overridden: + QApplication.restoreOverrideCursor() + self.modlist_btn.setEnabled(True) + + 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) + + 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) + + 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) + diff --git a/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py b/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py new file mode 100644 index 0000000..3a0890e --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py @@ -0,0 +1,114 @@ +"""Steam shortcut conflict dialog and retry workflow for InstallModlistScreen (Mixin).""" +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QHBoxLayout, +) +from jackify.frontends.gui.services.message_service import MessageService + + +class InstallModlistShortcutDialogMixin: + """Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen.""" + + def show_shortcut_conflict_dialog(self, conflicts): + """Show dialog to resolve shortcut name conflicts.""" + conflict_names = [c['name'] for c in conflicts] + conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" + + modlist_name = self.modlist_name_edit.text().strip() + + dialog = QDialog(self) + dialog.setWindowTitle("Steam Shortcut Conflict") + dialog.setModal(True) + dialog.resize(450, 180) + + dialog.setStyleSheet(""" + QDialog { + background-color: #2b2b2b; + color: #ffffff; + } + QLabel { + color: #ffffff; + font-size: 14px; + padding: 10px 0px; + } + QLineEdit { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px; + font-size: 14px; + selection-background-color: #3fd0ea; + } + QLineEdit:focus { + border-color: #3fd0ea; + } + QPushButton { + background-color: #404040; + color: #ffffff; + border: 2px solid #555555; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + min-width: 120px; + } + QPushButton:hover { + background-color: #505050; + border-color: #3fd0ea; + } + QPushButton:pressed { + background-color: #303030; + } + """) + + layout = QVBoxLayout(dialog) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") + layout.addWidget(conflict_label) + + name_input = QLineEdit(modlist_name) + name_input.selectAll() + layout.addWidget(name_input) + + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + create_button = QPushButton("Create with New Name") + cancel_button = QPushButton("Cancel") + + button_layout.addStretch() + button_layout.addWidget(cancel_button) + button_layout.addWidget(create_button) + layout.addLayout(button_layout) + + def on_create(): + new_name = name_input.text().strip() + if new_name and new_name != modlist_name: + dialog.accept() + self.retry_automated_workflow_with_new_name(new_name) + elif new_name == modlist_name: + MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") + else: + MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") + + def on_cancel(): + dialog.reject() + self._safe_append_text("Shortcut creation cancelled by user") + + create_button.clicked.connect(on_create) + cancel_button.clicked.connect(on_cancel) + name_input.returnPressed.connect(on_create) + + dialog.exec() + + def retry_automated_workflow_with_new_name(self, new_name): + """Retry the automated workflow with a new shortcut name.""" + self.modlist_name_edit.setText(new_name) + self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") + self.start_automated_prefix_workflow() diff --git a/jackify/frontends/gui/screens/install_modlist_ttw.py b/jackify/frontends/gui/screens/install_modlist_ttw.py new file mode 100644 index 0000000..4032e5a --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_ttw.py @@ -0,0 +1,222 @@ +"""TTW integration methods for InstallModlistScreen (Mixin).""" +from pathlib import Path +from PySide6.QtCore import QTimer +import logging +import os + +logger = logging.getLogger(__name__) + + +class TTWIntegrationMixin: + """Mixin providing TTW integration methods for InstallModlistScreen.""" + + def _check_ttw_eligibility(self, modlist_name: str, game_type: str, install_dir: str) -> bool: + """Check if modlist is FNV, TTW-compatible, and doesn't already have TTW + + Args: + modlist_name: Name of the installed modlist + game_type: Game type (e.g., 'falloutnv') + install_dir: Modlist installation directory + + Returns: + bool: True if should offer TTW integration + """ + try: + # Check 1: Must be Fallout New Vegas + if game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']: + return False + + # Check 2: Must be on whitelist + from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible + if not is_ttw_compatible(modlist_name): + return False + + # 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") + return False + + return True + + except Exception as e: + from .install_modlist import debug_print + debug_print(f"DEBUG: Error checking TTW eligibility: {e}") + return False + + def _detect_existing_ttw(self, install_dir: str) -> bool: + """Check if TTW is already installed in the modlist + + Args: + install_dir: Modlist installation directory + + Returns: + bool: True if TTW is already present + """ + try: + mods_dir = Path(install_dir) / "mods" + if not mods_dir.exists(): + return False + + # Check for folders containing "Tale of Two Wastelands" that have actual TTW content + # Exclude separators and placeholder folders + for folder in mods_dir.iterdir(): + if not folder.is_dir(): + continue + + folder_name_lower = folder.name.lower() + + # Skip separator folders and placeholders + if "_separator" in folder_name_lower or "put" in folder_name_lower or "here" in folder_name_lower: + continue + + # Check if folder name contains TTW indicator + if "tale of two wastelands" in folder_name_lower: + # 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}") + return True + else: + from .install_modlist import debug_print + debug_print(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}") + return False # Assume not installed on error + + def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str): + """Navigate to TTW screen and set it up for modlist integration + + Args: + modlist_name: Name of the modlist that needs TTW integration + install_dir: Path to the modlist installation directory + """ + try: + # Store modlist context for later use when TTW completes + self._ttw_modlist_name = modlist_name + self._ttw_install_dir = install_dir + + # Get reference to TTW screen BEFORE navigation + if self.stacked_widget: + 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) + + # Connect to completion signal to show success dialog after TTW + 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) + + # Force collapsed state shortly after navigation to avoid any + # showEvent/layout timing races that may leave it expanded + try: + QTimer.singleShot(50, lambda: getattr(ttw_screen, 'force_collapsed_state', lambda: None)()) + except Exception: + pass + + except Exception as e: + from .install_modlist import debug_print + debug_print(f"ERROR: Failed to initiate TTW workflow: {e}") + from jackify.frontends.gui.services.message_service import MessageService + MessageService.critical( + self, + "TTW Navigation Failed", + f"Failed to navigate to TTW installation screen: {str(e)}" + ) + + def _on_ttw_integration_complete(self, success: bool, ttw_version: str = ""): + """Handle completion of TTW integration and show final success dialog + + Args: + success: Whether TTW integration completed successfully + ttw_version: Version of TTW that was installed + """ + try: + if not success: + from jackify.frontends.gui.services.message_service import MessageService + MessageService.critical( + self, + "TTW Integration Failed", + "Tale of Two Wastelands integration did not complete successfully." + ) + return + + # Navigate back to this screen to show success dialog + if self.stacked_widget: + self.stacked_widget.setCurrentIndex(4) + + # Calculate elapsed time from workflow start + import time + if hasattr(self, '_install_workflow_start_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" + else: + time_str = "unknown" + + # Build success message including TTW installation + modlist_name = getattr(self, '_ttw_modlist_name', 'Unknown') + game_name = "Fallout New Vegas" + + # Check for VNV post-install automation after TTW installation + vnv_automation_running = False + if hasattr(self, '_ttw_install_dir') and hasattr(self, '_ttw_modlist_name'): + vnv_automation_running = self._check_and_run_vnv_automation(self._ttw_modlist_name, self._ttw_install_dir) + + if vnv_automation_running: + # Store success dialog params for later (after VNV automation completes) + self._pending_success_dialog_params = { + 'modlist_name': modlist_name, + 'time_taken': time_str, + 'game_name': game_name, + 'enb_detected': False, # TTW installs don't have ENB + 'ttw_version': ttw_version if 'ttw_version' in locals() else None + } + # Keep post-install feedback active during VNV automation + # Don't show success dialog yet - will be shown in _on_vnv_complete + return + + # No VNV automation - end post-install feedback now + self._end_post_install_feedback(True) + + # Clear Activity window before showing success dialog + self.file_progress_list.clear() + + # Show enhanced success dialog + from ..dialogs import SuccessDialog + success_dialog = SuccessDialog( + modlist_name=modlist_name, + workflow_type="install", + time_taken=time_str, + game_name=game_name, + parent=self + ) + + # Add TTW installation info to dialog if possible + if 'ttw_version' in locals() and hasattr(success_dialog, 'add_info_line'): + success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully") + + success_dialog.show() + + except Exception as e: + from .install_modlist import debug_print + debug_print(f"ERROR: Failed to show final success dialog: {e}") + from jackify.frontends.gui.services.message_service import MessageService + MessageService.critical( + self, + "Display Error", + f"TTW integration completed but failed to show success dialog: {str(e)}" + ) + diff --git a/jackify/frontends/gui/screens/install_modlist_ui_setup.py b/jackify/frontends/gui/screens/install_modlist_ui_setup.py new file mode 100644 index 0000000..7e171c0 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_ui_setup.py @@ -0,0 +1,519 @@ +"""UI setup methods for InstallModlistScreen (Mixin).""" +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QTableWidget, QTableWidgetItem, QHeaderView, QMainWindow +from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl +from PySide6.QtGui import QPixmap, QTextCursor, QColor, QPainter, QFont +from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS +from ..utils import ansi_to_html, set_responsive_minimum +from jackify.backend.handlers.wabbajack_parser import WabbajackParser +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 + + +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 InstallModlistUISetupMixin: + """Mixin providing UI initialization for InstallModlistScreen.""" + + def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None): + super().__init__() + # Set size policy to prevent unnecessary expansion - let content determine size + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + from jackify.backend.models.configuration import SystemInfo + self.system_info = system_info or SystemInfo(is_steamdeck=False) + self.debug = DEBUG_BORDERS + # Remember original main window geometry/min-size to restore on expand (like TTW screen) + self._saved_geometry = None + self._saved_min_size = None + self.online_modlists = {} # {game_type: [modlist_dict, ...]} + self.modlist_details = {} # {modlist_name: modlist_dict} + + # Initialize log path (can be refreshed via refresh_paths method) + self.refresh_paths() + + # Initialize services early + from jackify.backend.services.api_key_service import APIKeyService + from jackify.backend.services.nexus_auth_service import NexusAuthService + from jackify.backend.services.resolution_service import ResolutionService + from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService + from jackify.backend.handlers.config_handler import ConfigHandler + self.api_key_service = APIKeyService() + self.auth_service = NexusAuthService() + self.resolution_service = ResolutionService() + self.config_handler = ConfigHandler() + self.protontricks_service = ProtontricksDetectionService() + + # Somnium guidance tracking + self._show_somnium_guidance = False + self._somnium_install_dir = None + + # Console deduplication tracking + self._last_console_line = None + + # Gallery cache preloading tracking + self._gallery_cache_preload_started = False + self._gallery_cache_preload_thread = None + + # Scroll tracking for professional auto-scroll behavior + self._user_manually_scrolled = False + self._was_at_bottom = True + + # Initialize Wabbajack parser for game detection + self.wabbajack_parser = WabbajackParser() + + # R&D: Initialize progress reporting components + self.progress_state_manager = ProgressStateManager() + self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) + 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_notified = False + 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 + self._post_install_active = False + self._post_install_last_label = "" + self._bsa_hold_deadline = 0.0 + + # No throttling needed - render loop handles smooth updates at 60fps + + # R&D: Create "Show Details" checkbox (reuse TTW pattern) + self.show_details_checkbox = QCheckBox("Show details") + self.show_details_checkbox.setChecked(False) # Start collapsed + self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") + self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) + + main_overall_vbox = QVBoxLayout(self) + self.main_overall_vbox = main_overall_vbox # Store reference for expand/collapse + main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin + main_overall_vbox.setSpacing(0) # No spacing between widgets to eliminate gaps + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + + # --- Header (title, description) --- + header_layout = QVBoxLayout() + header_layout.setSpacing(1) # Reduce spacing between title and description + # Title (no logo) + title = QLabel("Install a Modlist (Automated)") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") + title.setAlignment(Qt.AlignHCenter) + title.setMaximumHeight(30) # Force compact height + header_layout.addWidget(title) + # Description + desc = QLabel( + "This screen allows you to install a Wabbajack modlist using Jackify. " + "Configure your options and start the installation." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(40) # Force compact height for description + header_layout.addWidget(desc) + header_widget = QWidget() + header_widget.setLayout(header_layout) + header_widget.setMaximumHeight(75) # Increase header height by 25% (60 + 15) + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + header_widget.setToolTip("HEADER_SECTION") + main_overall_vbox.addWidget(header_widget) + + # --- Upper section: user-configurables (left) + process monitor (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + upper_hbox.setAlignment(Qt.AlignTop) # Align both sides at the top + # Left: user-configurables (form and controls) + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + user_config_vbox.setSpacing(4) # Reduce spacing between major form sections + user_config_vbox.setContentsMargins(0, 0, 0, 0) # No margins to ensure tab alignment + # --- Tabs for source selection --- + self.source_tabs = QTabWidget() + self.source_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") + self.source_tabs.setContentsMargins(0, 0, 0, 0) # Ensure no margins for alignment + self.source_tabs.setDocumentMode(False) # Keep frame for consistency + self.source_tabs.setTabPosition(QTabWidget.North) # Ensure tabs are at top + if self.debug: + self.source_tabs.setStyleSheet("border: 2px solid cyan;") + self.source_tabs.setToolTip("SOURCE_TABS") + # --- Online List Tab --- + online_tab = QWidget() + online_tab_vbox = QVBoxLayout() + online_tab_vbox.setAlignment(Qt.AlignTop) + # Online List Controls + self.online_group = QWidget() + online_layout = QHBoxLayout() + online_layout.setContentsMargins(0, 0, 0, 0) + # --- Game Type Selection --- + self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Other"] + self.game_type_btn = QPushButton("Please Select...") + self.game_type_btn.setMinimumWidth(200) + self.game_type_btn.clicked.connect(self.open_game_type_dialog) + # --- Modlist Selection --- + self.modlist_btn = QPushButton("Select Modlist") + self.modlist_btn.setMinimumWidth(300) + self.modlist_btn.clicked.connect(self.open_modlist_dialog) + self.modlist_btn.setEnabled(False) + online_layout.addWidget(QLabel("Game Type:")) + online_layout.addWidget(self.game_type_btn) + online_layout.addSpacing(4) + online_layout.addWidget(QLabel("Modlist:")) + online_layout.addWidget(self.modlist_btn) + self.online_group.setLayout(online_layout) + online_tab_vbox.addWidget(self.online_group) + online_tab.setLayout(online_tab_vbox) + self.source_tabs.addTab(online_tab, "Select Modlist") + # --- File Picker Tab --- + file_tab = QWidget() + file_tab_vbox = QVBoxLayout() + file_tab_vbox.setAlignment(Qt.AlignTop) + self.file_group = QWidget() + file_layout = QHBoxLayout() + file_layout.setContentsMargins(0, 0, 0, 0) + self.file_edit = QLineEdit() + self.file_edit.setMinimumWidth(400) + self.file_btn = QPushButton("Browse") + self.file_btn.clicked.connect(self.browse_wabbajack_file) + file_layout.addWidget(QLabel(".wabbajack File:")) + file_layout.addWidget(self.file_edit) + file_layout.addWidget(self.file_btn) + self.file_group.setLayout(file_layout) + file_tab_vbox.addWidget(self.file_group) + file_tab.setLayout(file_tab_vbox) + self.source_tabs.addTab(file_tab, "Use .wabbajack File") + user_config_vbox.addWidget(self.source_tabs) + # --- Install/Downloads Dir/API Key (reuse Tuxborn style) --- + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) # Increased from 1 to 6 for better readability + form_grid.setContentsMargins(0, 0, 0, 0) + # Modlist Name (NEW FIELD) + modlist_name_label = QLabel("Modlist Name:") + self.modlist_name_edit = QLineEdit() + self.modlist_name_edit.setMaximumHeight(25) # Force compact height + form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addWidget(self.modlist_name_edit, 0, 1) + # Install Dir + install_dir_label = QLabel("Install Directory:") + self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir()) + self.install_dir_edit.setMaximumHeight(25) # Force compact height + self.browse_install_btn = QPushButton("Browse") + self.browse_install_btn.clicked.connect(self.browse_install_dir) + install_dir_hbox = QHBoxLayout() + install_dir_hbox.addWidget(self.install_dir_edit) + install_dir_hbox.addWidget(self.browse_install_btn) + form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(install_dir_hbox, 1, 1) + # Downloads Dir + downloads_dir_label = QLabel("Downloads Directory:") + self.downloads_dir_edit = QLineEdit(self.config_handler.get_modlist_downloads_base_dir()) + self.downloads_dir_edit.setMaximumHeight(25) # Force compact height + self.browse_downloads_btn = QPushButton("Browse") + self.browse_downloads_btn.clicked.connect(self.browse_downloads_dir) + downloads_dir_hbox = QHBoxLayout() + downloads_dir_hbox.addWidget(self.downloads_dir_edit) + downloads_dir_hbox.addWidget(self.browse_downloads_btn) + form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(downloads_dir_hbox, 2, 1) + + # Nexus Login (OAuth) + nexus_login_label = QLabel("Nexus Login:") + self.nexus_status = QLabel("Checking...") + self.nexus_status.setStyleSheet("color: #ccc;") + self.nexus_login_btn = QPushButton("Authorise") + self.nexus_login_btn.setStyleSheet(""" + QPushButton:hover { opacity: 0.95; } + QPushButton:disabled { opacity: 0.6; } + """) + self.nexus_login_btn.setMaximumWidth(90) + self.nexus_login_btn.setVisible(False) + self.nexus_login_btn.clicked.connect(self._handle_nexus_login_click) + + nexus_hbox = QHBoxLayout() + nexus_hbox.setContentsMargins(0, 0, 0, 0) + nexus_hbox.setSpacing(8) + nexus_hbox.addWidget(self.nexus_login_btn) + nexus_hbox.addWidget(self.nexus_status) + nexus_hbox.addStretch() + + form_grid.addWidget(nexus_login_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(nexus_hbox, 3, 1) + + # Update nexus status on init + self._update_nexus_status() + + # --- Resolution Dropdown --- + resolution_label = QLabel("Resolution:") + self.resolution_combo = QComboBox() + self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.resolution_combo.addItem("Leave unchanged") + self.resolution_combo.addItems([ + "1280x720", + "1280x800 (Steam Deck)", + "1366x768", + "1440x900", + "1600x900", + "1600x1200", + "1680x1050", + "1920x1080", + "1920x1200", + "2048x1152", + "2560x1080", + "2560x1440", + "2560x1600", + "3440x1440", + "3840x1600", + "3840x2160", + "3840x2400", + "5120x1440", + "5120x2160", + "7680x4320" + ]) + # Load saved resolution if available + saved_resolution = self.resolution_service.get_saved_resolution() + is_steam_deck = False + try: + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + if 'steamdeck' in f.read().lower(): + is_steam_deck = True + except Exception: + pass + if saved_resolution: + combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] + resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) + self.resolution_combo.setCurrentIndex(resolution_index) + debug_print(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())] + if "1280x800 (Steam Deck)" in combo_items: + self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) + else: + self.resolution_combo.setCurrentIndex(0) + # Otherwise, default is 'Leave unchanged' (index 0) + form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + + # Horizontal layout for resolution dropdown and auto-restart checkbox + resolution_and_restart_layout = QHBoxLayout() + resolution_and_restart_layout.setSpacing(12) + + # Resolution dropdown (made smaller) + self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing + resolution_and_restart_layout.addWidget(self.resolution_combo) + + # Add stretch to push checkbox to the right + resolution_and_restart_layout.addStretch() + + # Auto-accept Steam restart checkbox (right-aligned) + self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart") + self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session + self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation") + resolution_and_restart_layout.addWidget(self.auto_restart_checkbox) + + form_grid.addLayout(resolution_and_restart_layout, 5, 1) + form_section_widget = QWidget() + form_section_widget.setLayout(form_grid) + # Let form section size naturally to its content + # Don't force a fixed height - let it calculate based on grid content + form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + if self.debug: + form_section_widget.setStyleSheet("border: 2px solid blue;") + form_section_widget.setToolTip("FORM_SECTION") + user_config_vbox.addWidget(form_section_widget) + # --- Buttons --- + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + self.start_btn = QPushButton("Start Installation") + btn_row.addWidget(self.start_btn) + + + + # Cancel button (goes back to menu) + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.cancel_and_cleanup) + btn_row.addWidget(self.cancel_btn) + + # Cancel Installation button (appears during installation) + self.cancel_install_btn = QPushButton("Cancel Installation") + self.cancel_install_btn.clicked.connect(self.cancel_installation) + self.cancel_install_btn.setVisible(False) # Hidden by default + btn_row.addWidget(self.cancel_install_btn) + + # Wrap button row in widget for debug borders + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + btn_row_widget.setToolTip("BUTTON_ROW") + user_config_widget = QWidget() + self.user_config_widget = user_config_widget # Store reference for height calculation + user_config_widget.setLayout(user_config_vbox) + user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) # Fixed height - don't expand unnecessarily + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + user_config_widget.setToolTip("USER_CONFIG_WIDGET") + # Right: Tabbed interface with Activity and Process Monitor + # Both tabs are always available, user can switch between them + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") + self.process_monitor_heading = QLabel("[Process Monitor]") + self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") + self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor_heading) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + # Match size policy - Process Monitor should expand to fill available space + process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + process_monitor_widget.setToolTip("PROCESS_MONITOR") + # Store reference + self.process_monitor_widget = process_monitor_widget + + # Set up File Progress List (Activity tab) + # Match Process Monitor size policy exactly - expand to fill available space + self.file_progress_list.setMinimumSize(QSize(300, 20)) + self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # Create tab widget to hold both Activity and Process Monitor + # Match styling of source_tabs on the left for consistency + self.activity_tabs = QTabWidget() + self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") + self.activity_tabs.setContentsMargins(0, 0, 0, 0) # Ensure no margins for alignment + self.activity_tabs.setDocumentMode(False) # Match left tabs + self.activity_tabs.setTabPosition(QTabWidget.North) # Ensure tabs are at top + if self.debug: + self.activity_tabs.setStyleSheet("border: 2px solid cyan;") + self.activity_tabs.setToolTip("ACTIVITY_TABS") + + # Add both widgets as tabs + self.activity_tabs.addTab(self.file_progress_list, "Activity") + self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") + + upper_hbox.addWidget(user_config_widget, stretch=1, alignment=Qt.AlignTop) + # Add tab widget with stretch=3 to match original Process Monitor stretch + upper_hbox.addWidget(self.activity_tabs, stretch=3, alignment=Qt.AlignTop) + upper_section_widget = QWidget() + self.upper_section_widget = upper_section_widget # Store reference for showEvent + upper_section_widget.setLayout(upper_hbox) + # Use Fixed size policy - the height should be based on LEFT side only + # Consistent height for both Active Files and Process Monitor + upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + # Calculate height based on LEFT side (user_config_widget) only + self._upper_section_fixed_height = None # Will be set in showEvent based on left side + if self.debug: + upper_section_widget.setStyleSheet("border: 2px solid green;") + upper_section_widget.setToolTip("UPPER_SECTION") + main_overall_vbox.addWidget(upper_section_widget) + + # Add spacing between upper section and progress banner + main_overall_vbox.addSpacing(8) + + # R&D: Progress indicator banner row (similar to TTW screen) + banner_row = QHBoxLayout() + banner_row.setContentsMargins(0, 0, 0, 0) + banner_row.setSpacing(8) + banner_row.addWidget(self.progress_indicator, 1) + banner_row.addStretch() + banner_row.addWidget(self.show_details_checkbox) + banner_row_widget = QWidget() + banner_row_widget.setLayout(banner_row) + # Constrain height to prevent unwanted vertical expansion + banner_row_widget.setMaximumHeight(45) # Compact height: 34px label + small margin + banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + main_overall_vbox.addWidget(banner_row_widget) + + # Add spacing between progress banner and console/details area + main_overall_vbox.addSpacing(8) + + # R&D: File progress list is now in the upper section (replacing Process Monitor) + # Console shows below when "Show details" is checked + # File progress list is already added to upper_hbox above + + # Remove spacing - console should expand to fill available space + # --- Console output area (full width, placeholder for now) --- + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + # R&D: Console starts hidden (only shows when "Show details" is checked) + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setVisible(False) + self.console.setFontFamily('monospace') + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + self.console.setToolTip("CONSOLE") + + # Set up scroll tracking for professional auto-scroll behavior + self._setup_scroll_tracking() + + # Create a container that holds console + button row with proper spacing + console_and_buttons_widget = QWidget() + console_and_buttons_layout = QVBoxLayout() + console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) + console_and_buttons_layout.setSpacing(0) # No spacing - console is hidden initially + + # Console with stretch only when visible, buttons always at natural size + console_and_buttons_layout.addWidget(self.console) # No stretch initially - will be set dynamically + console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container + + console_and_buttons_widget.setLayout(console_and_buttons_layout) + self.console_and_buttons_widget = console_and_buttons_widget # Store reference for stretch control + self.console_and_buttons_layout = console_and_buttons_layout # Store reference for spacing control + # Use Minimum size policy - takes only the minimum space needed + console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + # Constrain height to button row only when console is hidden - match button row height exactly + # Button row is 50px max, so container should be exactly that when collapsed + console_and_buttons_widget.setFixedHeight(50) # Lock to exact button row height when console is hidden + if self.debug: + console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") + console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") + # Add without stretch - let it size naturally to content + main_overall_vbox.addWidget(console_and_buttons_widget) + self.setLayout(main_overall_vbox) + + self.current_modlists = [] + + # --- Process Monitor (right) --- + self.process = None + self.log_timer = None + self.last_log_pos = 0 + # --- Process Monitor Timer --- + self.top_timer = QTimer(self) + self.top_timer.timeout.connect(self.update_top_panel) + 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 + + # Initialize empty controls list - will be populated after UI is built + self._actionable_controls = [] + + # Now collect all actionable controls after UI is fully built + self._collect_actionable_controls() + diff --git a/jackify/frontends/gui/screens/install_modlist_vnv.py b/jackify/frontends/gui/screens/install_modlist_vnv.py new file mode 100644 index 0000000..89fe9fc --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_vnv.py @@ -0,0 +1,208 @@ +"""VNV automation methods for InstallModlistScreen (Mixin).""" +from pathlib import Path +from PySide6.QtCore import QTimer +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + + +class VNVAutomationMixin: + """Mixin providing VNV automation methods for InstallModlistScreen.""" + + def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool: + """Check if VNV automation should run and execute if applicable in background thread + + Args: + modlist_name: Name of the installed modlist + install_dir: Installation directory path + + Returns: + True if VNV automation is starting (success dialog should be deferred) + False if no VNV automation needed (show success dialog immediately) + """ + try: + from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation + from jackify.backend.handlers.path_handler import PathHandler + from jackify.backend.services.vnv_post_install_service import VNVPostInstallService + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + + # Get paths first (needed for VNV detection) + install_path = Path(install_dir) + + # Quick check before importing more (pass install location for ModOrganizer.ini check) + if not should_offer_vnv_automation(modlist_name, install_path): + return False + + game_paths = PathHandler().find_vanilla_game_paths() + 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") + return False + + # Initialize service to check completion status + vnv_service = VNVPostInstallService( + modlist_install_location=install_path, + game_root=game_root, + ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path() + ) + + # Check what's already done + completed = vnv_service.check_already_completed() + # Only skip if ALL three steps are completed + if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']: + logger.info("VNV automation steps already completed") + return False + + # Get automation description for confirmation + description = vnv_service.get_automation_description() + + # Show confirmation dialog ON MAIN THREAD (not in worker thread!) + from ..services.message_service import MessageService + from PySide6.QtWidgets import QMessageBox + reply = MessageService.question( + self, + "VNV Post-Install Automation", + description, + critical=False, + safety_level="medium" + ) + + if reply != QMessageBox.Yes: + logger.info("User declined VNV automation") + return False + + # Enable post-install progress tracking for VNV automation + self._begin_post_install_feedback() + + # User confirmed - start automation in background thread + # Note: manual_file_callback is not passed because Qt GUI operations + # cannot be called from a background thread. If downloads fail, + # the service will return instructions for manual download instead. + self._run_vnv_automation_threaded( + modlist_name, + install_path, + game_root + ) + + 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}") + import traceback + debug_print(f"Traceback: {traceback.format_exc()}") + return False # Error - show success dialog anyway + + def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root): + """Run VNV automation in a background thread with progress updates + + Note: User confirmation should already be obtained before calling this method. + Manual file selection is not supported from background threads - if downloads + fail, the service will return instructions for manual download. + """ + from PySide6.QtCore import QThread, Signal + from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable + from jackify.backend.services.automated_prefix_service import AutomatedPrefixService + + class VNVAutomationWorker(QThread): + progress_update = Signal(str) + completed = Signal(bool, str) # (success, error_message) + + def __init__(self, modlist_name, install_path, game_root, ttw_installer_path): + super().__init__() + self.modlist_name = modlist_name + self.install_path = install_path + self.game_root = game_root + self.ttw_installer_path = ttw_installer_path + + def run(self): + try: + # User already confirmed, pass lambda that always returns True + # manual_file_callback is None - downloads that fail will return + # instructions for manual download instead of showing Qt dialogs + automation_ran, error = run_vnv_automation_if_applicable( + modlist_name=self.modlist_name, + modlist_install_location=self.install_path, + game_root=self.game_root, + ttw_installer_path=self.ttw_installer_path, + progress_callback=self.progress_update.emit, + manual_file_callback=None, + confirmation_callback=lambda desc: True # Already confirmed on main thread + ) + self.completed.emit(error is None, error or "") + except Exception as e: + import traceback + self.completed.emit(False, f"Exception: {str(e)}\n{traceback.format_exc()}") + + # Create and start worker + self.vnv_worker = VNVAutomationWorker( + modlist_name, + install_path, + game_root, + AutomatedPrefixService.get_ttw_installer_path() + ) + + # Connect signals + self.vnv_worker.progress_update.connect(self._on_vnv_progress) + self.vnv_worker.completed.connect(self._on_vnv_complete) + self.vnv_worker.finished.connect(self.vnv_worker.deleteLater) + + # Start worker + self.vnv_worker.start() + + def _on_vnv_progress(self, message: str): + """Handle VNV automation progress updates""" + self._safe_append_text(message) + # Also update progress indicator, Activity window, and Details window + self._handle_post_install_progress(message) + + def _on_vnv_complete(self, success: bool, error: str): + """Handle VNV automation completion and show deferred success dialog""" + # End post-install feedback now that VNV automation is complete + self._end_post_install_feedback(True) + + if not success and error: + from ..services.message_service import MessageService + MessageService.warning( + self, + "VNV Automation Failed", + f"VNV post-install automation encountered an error:\n\n{error}\n\n" + "You can complete these steps manually by following the guide at:\n" + "https://vivanewvegas.moddinglinked.com/wabbajack.html" + ) + elif success: + self._safe_append_text("VNV post-install automation completed successfully") + + # Show the deferred success dialog now that VNV automation is complete + if hasattr(self, '_pending_success_dialog_params'): + params = self._pending_success_dialog_params + del self._pending_success_dialog_params # Clean up + + # Clear Activity window before showing success dialog + self.file_progress_list.clear() + + # Show success dialog + from ..dialogs import SuccessDialog + success_dialog = SuccessDialog( + modlist_name=params['modlist_name'], + workflow_type="install", + time_taken=params['time_taken'], + game_name=params['game_name'], + parent=self + ) + success_dialog.show() + + # Show ENB Proton dialog if ENB was detected + if params.get('enb_detected'): + try: + from ..dialogs.enb_proton_dialog import ENBProtonDialog + enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self) + enb_dialog.exec() # Modal dialog - blocks until user clicks OK + except Exception as e: + # Non-blocking: if dialog fails, just log and continue + logger.warning(f"Failed to show ENB dialog: {e}") + diff --git a/jackify/frontends/gui/screens/install_modlist_workflow.py b/jackify/frontends/gui/screens/install_modlist_workflow.py new file mode 100644 index 0000000..3a32b72 --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_workflow.py @@ -0,0 +1,371 @@ +"""Installation workflow methods for InstallModlistScreen (Mixin).""" +from pathlib import Path +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QMessageBox +import logging +import os +import re +import time + +from .install_modlist_installer_thread import InstallerThread +from .install_modlist_output_mixin import InstallModlistOutputMixin + +logger = logging.getLogger(__name__) + + +class InstallWorkflowMixin(InstallModlistOutputMixin): + """Mixin providing installation workflow methods for InstallModlistScreen.""" + + 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') + + # Immediately show "Initialising" status to provide feedback + self.progress_indicator.set_status("Initialising...", 0) + from PySide6.QtWidgets import QApplication + QApplication.processEvents() # Force UI update + + # Reload config to pick up any settings changes made in Settings dialog + self.config_handler.reload_config() + + # Check protontricks before proceeding + if not self._check_protontricks(): + self.progress_indicator.reset() + return + + # Disable all controls during installation (except Cancel) + self._disable_controls_during_operation() + + try: + tab_index = self.source_tabs.currentIndex() + install_mode = 'online' + if tab_index == 1: # .wabbajack File tab + modlist = self.file_edit.text().strip() + if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'): + self._abort_with_message( + "warning", + "Invalid Modlist", + "Please select a valid .wabbajack file." + ) + return + install_mode = 'file' + else: + # For online modlists, ALWAYS use machine_url from selected_modlist_info + # Button text is now the display name (title), NOT the machine URL + if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info: + self._abort_with_message( + "warning", + "Invalid Modlist", + "Modlist information is missing. Please select the modlist again from the gallery." + ) + return + + machine_url = self.selected_modlist_info.get('machine_url') + if not machine_url: + self._abort_with_message( + "warning", + "Invalid Modlist", + "Modlist information is incomplete. Please select the modlist again from the gallery." + ) + return + + # CRITICAL: Use machine_url, NOT button text + modlist = machine_url + install_dir = self.install_dir_edit.text().strip() + downloads_dir = self.downloads_dir_edit.text().strip() + + # Get authentication token (OAuth or API key) with automatic refresh + api_key, oauth_info = self.auth_service.get_auth_for_engine() + if not api_key: + self._abort_with_message( + "warning", + "Authorisation Required", + "Please authorise with Nexus Mods before installing modlists.\n\n" + "Click the 'Authorise' button above to log in with OAuth,\n" + "or configure an API key in Settings.", + safety_level="medium" + ) + 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") + logger.info(f"Method: {auth_method or 'UNKNOWN'}") + logger.info(f"Token length: {len(api_key)} chars") + if len(api_key) >= 8: + logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}") + + if auth_method == 'oauth': + token_handler = self.auth_service.token_handler + token_info = token_handler.get_token_info() + if 'expires_in_minutes' in token_info: + logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes") + if token_info.get('refresh_token_likely_expired'): + logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)") + logger.info("=" * 60) + + modlist_name = self.modlist_name_edit.text().strip() + missing_fields = [] + if not modlist_name: + missing_fields.append("Modlist Name") + if not install_dir: + missing_fields.append("Install Directory") + if not downloads_dir: + missing_fields.append("Downloads Directory") + if missing_fields: + self._abort_with_message( + "warning", + "Missing Required Fields", + "Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields) + ) + return + from jackify.backend.handlers.validation_handler import ValidationHandler + validation_handler = ValidationHandler() + is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir)) + if not is_safe: + from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog + dlg = WarningDialog(reason, parent=self) + result = dlg.exec() + if not result or not dlg.confirmed: + self._abort_install_validation() + return + if not os.path.isdir(install_dir): + from ..services.message_service import MessageService + 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 + ) + 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}") + self._abort_install_validation() + return + else: + self._abort_install_validation() + return + if not os.path.isdir(downloads_dir): + from ..services.message_service import MessageService + create = MessageService.question(self, "Create Directory?", + f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?", + critical=False # Non-critical, won't steal focus + ) + if create == QMessageBox.Yes: + try: + os.makedirs(downloads_dir, exist_ok=True) + except Exception as e: + MessageService.critical(self, "Error", f"Failed to create downloads directory:\n{e}") + self._abort_install_validation() + return + else: + self._abort_install_validation() + return + + # Handle resolution saving + resolution = self.resolution_combo.currentText() + 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}") + else: + from .install_modlist import debug_print + debug_print("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") + + # Handle parent directory saving + self._save_parent_directories(install_dir, downloads_dir) + + # Detect game type and check support + game_type = None + game_name = None + + if install_mode == 'file': + # Parse .wabbajack file to get game type + wabbajack_path = Path(modlist) + result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path) + if result: + if isinstance(result, tuple): + game_type, raw_game_type = result + # Get display name for the game + display_names = { + 'skyrim': 'Skyrim', + 'fallout4': 'Fallout 4', + 'falloutnv': 'Fallout New Vegas', + 'oblivion': 'Oblivion', + 'starfield': 'Starfield', + 'oblivion_remastered': 'Oblivion Remastered', + 'enderal': 'Enderal' + } + if game_type == 'unknown' and raw_game_type: + game_name = raw_game_type + else: + game_name = display_names.get(game_type, game_type) + else: + game_type = result + 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(game_type, game_type) + else: + # 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}'") + + # Map game name to game type + game_mapping = { + 'skyrim special edition': 'skyrim', + 'skyrim': 'skyrim', + 'fallout 4': 'fallout4', + 'fallout new vegas': 'falloutnv', + 'oblivion': 'oblivion', + 'starfield': 'starfield', + 'oblivion_remastered': 'oblivion_remastered', + 'enderal': 'enderal', + '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}'") + 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'") + else: + from .install_modlist import debug_print + debug_print(f"DEBUG: No selected_modlist_info found") + game_type = 'unknown' + + # Store game type and name for later use + self._current_game_type = game_type + 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}'") + 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}") + + 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") + # Show unsupported game dialog + from ..widgets.unsupported_game_dialog import UnsupportedGameDialog + dialog = UnsupportedGameDialog(self, game_name) + if not dialog.show_dialog(self, game_name): + self._abort_install_validation() + return + + self.console.clear() + self.process_monitor.clear() + + # R&D: Reset progress indicator for new installation + self.progress_indicator.reset() + self.progress_state_manager.reset() + 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_notified = False + self._token_error_notified = False # Reset token error notification + self._premium_failure_active = False + self._post_install_active = False + self._post_install_current_step = 0 + # Activity tab is always visible (tabs handle visibility automatically) + + # Update button states for installation + self.start_btn.setEnabled(False) + self.cancel_btn.setVisible(False) + self.cancel_install_btn.setVisible(True) + + # CRITICAL: Final safety check - ensure online modlists use machine_url + if install_mode == 'online': + if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: + expected_machine_url = self.selected_modlist_info.get('machine_url') + if expected_machine_url: + modlist = expected_machine_url # Force use machine_url + else: + self._abort_with_message( + "critical", + "Installation Error", + "Cannot determine modlist machine URL. Please select the modlist again." + ) + return + else: + self._abort_with_message( + "critical", + "Installation Error", + "Modlist information is missing. Please select the modlist again from the gallery." + ) + 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}') + 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}") + import traceback + debug_print(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") + + 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') + + # 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 + self._safe_append_text(f"Jackify v{jackify_version}") + self._safe_append_text("Starting modlist installation with custom progress handling...") + + # Update UI state for installation + self.start_btn.setEnabled(False) + self.cancel_btn.setVisible(False) + self.cancel_install_btn.setVisible(True) + + self.install_thread = InstallerThread( + modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode, + progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager + auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics + oauth_info=oauth_info # Pass OAuth state for auto-refresh + ) + self.install_thread.output_received.connect(self.on_installation_output) + self.install_thread.progress_received.connect(self.on_installation_progress) + self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update + self.install_thread.installation_finished.connect(self.on_installation_finished) + self.install_thread.premium_required_detected.connect(self.on_premium_required_detected) + # 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 89dcfe0..1439cf5 100644 --- a/jackify/frontends/gui/screens/install_ttw.py +++ b/jackify/frontends/gui/screens/install_ttw.py @@ -26,6 +26,15 @@ 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 .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_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""" @@ -74,649 +83,10 @@ class ModlistFetchThread(QThread): self.result.emit([], error_msg) -class InstallTTWScreen(QWidget): +class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWInstallerMixin, TTWWorkflowMixin, TTWUIMixin, TTWConfigMixin): steam_restart_finished = Signal(bool, str) resize_request = Signal(str) integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version) - - def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None): - super().__init__() - self.stacked_widget = stacked_widget - self.main_menu_index = main_menu_index - self.system_info = system_info - self.debug = DEBUG_BORDERS - self.online_modlists = {} # {game_type: [modlist_dict, ...]} - self.modlist_details = {} # {modlist_name: modlist_dict} - - # Initialize log path (can be refreshed via refresh_paths method) - self.refresh_paths() - - # Initialize services early - from jackify.backend.services.api_key_service import APIKeyService - from jackify.backend.services.resolution_service import ResolutionService - from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService - from jackify.backend.handlers.config_handler import ConfigHandler - self.api_key_service = APIKeyService() - self.resolution_service = ResolutionService() - self.config_handler = ConfigHandler() - self.protontricks_service = ProtontricksDetectionService() - - # Modlist integration mode tracking - self._integration_mode = False - self._integration_modlist_name = None - self._integration_install_dir = None - - # Somnium guidance tracking - self._show_somnium_guidance = False - self._somnium_install_dir = None - - # Scroll tracking for professional auto-scroll behavior - self._user_manually_scrolled = False - self._was_at_bottom = True - - # Initialize Wabbajack parser for game detection - self.wabbajack_parser = WabbajackParser() - # Remember original main window geometry/min-size to restore on expand - self._saved_geometry = None - self._saved_min_size = None - - main_overall_vbox = QVBoxLayout(self) - main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - # Match other workflow screens - main_overall_vbox.setContentsMargins(50, 50, 50, 0) - main_overall_vbox.setSpacing(12) - if self.debug: - self.setStyleSheet("border: 2px solid magenta;") - - # --- Header (title, description) --- - header_widget = QWidget() - header_layout = QVBoxLayout() - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(2) - - # Title - title = QLabel("Install Tale of Two Wastelands (TTW)") - title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") - title.setAlignment(Qt.AlignHCenter) - header_layout.addWidget(title) - - header_layout.addSpacing(10) - - # Description area with fixed height - desc = QLabel( - "This screen allows you to install Tale of Two Wastelands (TTW) using TTW_Linux_Installer. " - "Configure your options and start the installation." - ) - desc.setWordWrap(True) - desc.setStyleSheet("color: #ccc; font-size: 13px;") - desc.setAlignment(Qt.AlignHCenter) - desc.setMaximumHeight(50) # Fixed height for description zone - header_layout.addWidget(desc) - - header_layout.addSpacing(12) - - header_widget.setLayout(header_layout) - header_widget.setFixedHeight(120) # Fixed total header height to match other screens - if self.debug: - header_widget.setStyleSheet("border: 2px solid pink;") - header_widget.setToolTip("HEADER_SECTION") - main_overall_vbox.addWidget(header_widget) - - # --- Upper section: user-configurables (left) + process monitor (right) --- - upper_hbox = QHBoxLayout() - upper_hbox.setContentsMargins(0, 0, 0, 0) - upper_hbox.setSpacing(16) - # Left: user-configurables (form and controls) - user_config_vbox = QVBoxLayout() - user_config_vbox.setAlignment(Qt.AlignTop) - user_config_vbox.setSpacing(4) # Reduce spacing between major form sections - - # --- Instructions --- - instruction_text = QLabel( - "Tale of Two Wastelands installation requires a .mpi file you can get from: " - 'https://mod.pub/ttw/133/files ' - "(requires a user account for ModPub)" - ) - instruction_text.setWordWrap(True) - instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;") - instruction_text.setOpenExternalLinks(True) - user_config_vbox.addWidget(instruction_text) - - # --- Compact Form Grid for inputs (align with other screens) --- - form_grid = QGridLayout() - form_grid.setHorizontalSpacing(12) - form_grid.setVerticalSpacing(6) - form_grid.setContentsMargins(0, 0, 0, 0) - - # Row 0: TTW .mpi File location - file_label = QLabel("TTW .mpi File location:") - self.file_edit = QLineEdit() - self.file_edit.setMaximumHeight(25) - self.file_edit.textChanged.connect(self._update_start_button_state) - self.file_btn = QPushButton("Browse") - self.file_btn.clicked.connect(self.browse_wabbajack_file) - file_hbox = QHBoxLayout() - file_hbox.addWidget(self.file_edit) - file_hbox.addWidget(self.file_btn) - form_grid.addWidget(file_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(file_hbox, 0, 1) - - # Row 1: Output Directory - install_dir_label = QLabel("Output Directory:") - self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir()) - self.install_dir_edit.setMaximumHeight(25) - self.browse_install_btn = QPushButton("Browse") - self.browse_install_btn.clicked.connect(self.browse_install_dir) - install_dir_hbox = QHBoxLayout() - install_dir_hbox.addWidget(self.install_dir_edit) - install_dir_hbox.addWidget(self.browse_install_btn) - form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(install_dir_hbox, 1, 1) - - # --- TTW_Linux_Installer Status aligned in form grid (row 2) --- - ttw_installer_label = QLabel("TTW_Linux_Installer Status:") - self.ttw_installer_status = QLabel("Checking...") - self.ttw_installer_btn = QPushButton("Install now") - self.ttw_installer_btn.setStyleSheet(""" - QPushButton:hover { opacity: 0.95; } - QPushButton:disabled { opacity: 0.6; } - """) - self.ttw_installer_btn.setVisible(False) - self.ttw_installer_btn.clicked.connect(self.install_ttw_installer) - ttw_installer_hbox = QHBoxLayout() - ttw_installer_hbox.setContentsMargins(0, 0, 0, 0) - ttw_installer_hbox.setSpacing(8) - ttw_installer_hbox.addWidget(self.ttw_installer_status) - ttw_installer_hbox.addWidget(self.ttw_installer_btn) - ttw_installer_hbox.addStretch() - form_grid.addWidget(ttw_installer_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(ttw_installer_hbox, 2, 1) - - # --- Game Requirements aligned in form grid (row 3) --- - game_req_label = QLabel("Game Requirements:") - self.fallout3_status = QLabel("Fallout 3: Checking...") - self.fallout3_status.setStyleSheet("color: #ccc;") - self.fnv_status = QLabel("Fallout New Vegas: Checking...") - self.fnv_status.setStyleSheet("color: #ccc;") - game_req_hbox = QHBoxLayout() - game_req_hbox.setContentsMargins(0, 0, 0, 0) - game_req_hbox.setSpacing(16) - game_req_hbox.addWidget(self.fallout3_status) - game_req_hbox.addWidget(self.fnv_status) - game_req_hbox.addStretch() - form_grid.addWidget(game_req_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(game_req_hbox, 3, 1) - - form_group = QWidget() - form_group.setLayout(form_grid) - user_config_vbox.addWidget(form_group) - - # (TTW_Linux_Installer and Game Requirements now aligned in form_grid above) - - # --- Buttons --- - btn_row = QHBoxLayout() - btn_row.setAlignment(Qt.AlignHCenter) - self.start_btn = QPushButton("Start Installation") - self.start_btn.setEnabled(False) # Disabled until requirements are met - btn_row.addWidget(self.start_btn) - - - - # Cancel button (goes back to menu) - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.clicked.connect(self.cancel_and_cleanup) - btn_row.addWidget(self.cancel_btn) - - # Cancel Installation button (appears during installation) - self.cancel_install_btn = QPushButton("Cancel Installation") - self.cancel_install_btn.clicked.connect(self.cancel_installation) - self.cancel_install_btn.setVisible(False) # Hidden by default - btn_row.addWidget(self.cancel_install_btn) - - # Add stretches to center buttons row - btn_row.insertStretch(0, 1) - btn_row.addStretch(1) - - # Show Details Checkbox (collapsible console) - self.show_details_checkbox = QCheckBox("Show details") - # Start collapsed by default (console hidden until user opts in) - self.show_details_checkbox.setChecked(False) - self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") - # Use toggled(bool) for reliable signal and map to our handler - try: - self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) - except Exception: - # Fallback to stateChanged if toggled is unavailable - self.show_details_checkbox.stateChanged.connect(self._toggle_console_visibility) - # Note: Checkbox will be placed in the status banner row (right-aligned) - - # Wrap button row in widget for debug borders - btn_row_widget = QWidget() - btn_row_widget.setLayout(btn_row) - btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact - if self.debug: - btn_row_widget.setStyleSheet("border: 2px solid red;") - btn_row_widget.setToolTip("BUTTON_ROW") - # Keep a reference for dynamic sizing when collapsing/expanding - self.btn_row_widget = btn_row_widget - user_config_widget = QWidget() - user_config_widget.setLayout(user_config_vbox) - user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) - if self.debug: - user_config_widget.setStyleSheet("border: 2px solid orange;") - user_config_widget.setToolTip("USER_CONFIG_WIDGET") - - # Right: Tabbed interface with Activity and Process Monitor - # Both tabs are always available, user can switch between them - self.file_progress_list = FileProgressList() - self.file_progress_list.setMinimumSize(QSize(300, 20)) - self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - self.process_monitor = QTextEdit() - self.process_monitor.setReadOnly(True) - self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) - self.process_monitor.setMinimumSize(QSize(300, 20)) - self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") - self.process_monitor_heading = QLabel("[Process Monitor]") - self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") - self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - process_vbox = QVBoxLayout() - process_vbox.setContentsMargins(0, 0, 0, 0) - process_vbox.setSpacing(2) - process_vbox.addWidget(self.process_monitor_heading) - process_vbox.addWidget(self.process_monitor) - process_monitor_widget = QWidget() - process_monitor_widget.setLayout(process_vbox) - process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - if self.debug: - process_monitor_widget.setStyleSheet("border: 2px solid purple;") - process_monitor_widget.setToolTip("PROCESS_MONITOR") - self.process_monitor_widget = process_monitor_widget - - # Create tab widget to hold both Activity and Process Monitor - self.activity_tabs = QTabWidget() - self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") - self.activity_tabs.setContentsMargins(0, 0, 0, 0) - self.activity_tabs.setDocumentMode(False) - self.activity_tabs.setTabPosition(QTabWidget.North) - if self.debug: - self.activity_tabs.setStyleSheet("border: 2px solid cyan;") - self.activity_tabs.setToolTip("ACTIVITY_TABS") - - # Add both widgets as tabs - self.activity_tabs.addTab(self.file_progress_list, "Activity") - self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") - - upper_hbox.addWidget(user_config_widget, stretch=11) - upper_hbox.addWidget(self.activity_tabs, stretch=9) - upper_hbox.setAlignment(Qt.AlignTop) - self.upper_section_widget = QWidget() - self.upper_section_widget.setLayout(upper_hbox) - # Use Fixed size policy for consistent height - self.upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.upper_section_widget.setMaximumHeight(280) # Fixed height to match other workflow screens - if self.debug: - self.upper_section_widget.setStyleSheet("border: 2px solid green;") - self.upper_section_widget.setToolTip("UPPER_SECTION") - main_overall_vbox.addWidget(self.upper_section_widget) - - # --- Status Banner (shows high-level progress) --- - self.status_banner = QLabel("Ready to install") - self.status_banner.setAlignment(Qt.AlignCenter) - self.status_banner.setStyleSheet(f""" - background-color: #2a2a2a; - color: {JACKIFY_COLOR_BLUE}; - padding: 6px 8px; - border-radius: 4px; - font-weight: bold; - font-size: 13px; - """) - # Prevent banner from expanding vertically - self.status_banner.setMaximumHeight(34) - self.status_banner.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - # Show the banner by default so users see status even when collapsed - self.status_banner.setVisible(True) - # Create a compact banner row with the checkbox right-aligned - banner_row = QHBoxLayout() - # Minimal padding to avoid visible gaps - banner_row.setContentsMargins(0, 0, 0, 0) - banner_row.setSpacing(8) - banner_row.addWidget(self.status_banner, 1) - banner_row.addStretch() - banner_row.addWidget(self.show_details_checkbox) - banner_row_widget = QWidget() - banner_row_widget.setLayout(banner_row) - banner_row_widget.setMaximumHeight(45) # Compact height - banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - main_overall_vbox.addWidget(banner_row_widget) - - # Remove spacing - console should expand to fill available space - # --- Console output area (full width, placeholder for now) --- - self.console = QTextEdit() - self.console.setReadOnly(True) - self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) - # Console starts hidden; toggled via Show details - self.console.setMinimumHeight(0) - self.console.setMaximumHeight(0) - self.console.setFontFamily('monospace') - if self.debug: - self.console.setStyleSheet("border: 2px solid yellow;") - self.console.setToolTip("CONSOLE") - - # Set up scroll tracking for professional auto-scroll behavior - self._setup_scroll_tracking() - - # Add console directly so we can hide/show without affecting buttons - main_overall_vbox.addWidget(self.console, stretch=1) - # Place the button row after the console so it's always visible and centered - main_overall_vbox.addWidget(btn_row_widget, alignment=Qt.AlignHCenter) - - # Store reference to main layout - self.main_overall_vbox = main_overall_vbox - self.setLayout(main_overall_vbox) - - self.current_modlists = [] - - # --- Process Monitor (right) --- - self.process = None - self.log_timer = None - self.last_log_pos = 0 - # --- Process Monitor Timer --- - self.top_timer = QTimer(self) - self.top_timer.timeout.connect(self.update_top_panel) - 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 - - # Initialize empty controls list - will be populated after UI is built - self._actionable_controls = [] - - 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 pinned/latest - update_available, installed_v, target_v = ttw_installer_handler.is_ttw_installer_update_available() - if update_available: - # Determine if this is a downgrade or upgrade - from jackify.backend.handlers.ttw_installer_handler import TTW_INSTALLER_PINNED_VERSION - if TTW_INSTALLER_PINNED_VERSION and installed_v and target_v: - # If we have a pinned version and installed is newer, it's a downgrade - try: - # Simple version comparison - if installed version string is longer/more complex, likely newer - # For now, just check if they're different and show appropriate message - if installed_v != target_v: - version_text = f"Update to v{target_v} (currently v{installed_v})" - else: - version_text = f"Update available (v{installed_v} → v{target_v})" - except Exception: - version_text = f"Update to v{target_v}" if target_v else "Update available" - else: - # Normal update (newer version available) - version_text = f"Update available (v{installed_v} → v{target_v})" if installed_v and target_v else "Update available" - 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 latest Linux build of TTW_Linux_Installer." - ), - 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") def _collect_actionable_controls(self): """Collect all actionable controls that should be disabled during operations (except Cancel)""" @@ -749,29 +119,6 @@ class InstallTTWScreen(QWidget): self.modlist_log_path = get_jackify_logs_dir() / 'TTW_Install_workflow.log' os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) - def set_modlist_integration_mode(self, modlist_name: str, install_dir: str): - """Set the screen to modlist integration mode - - This mode is activated when TTW needs to be installed and integrated - into an existing modlist. In this mode, after TTW installation completes, - the TTW output will be automatically integrated into the modlist. - - Args: - modlist_name: Name of the modlist to integrate TTW into - install_dir: Installation directory of the modlist - """ - self._integration_mode = True - self._integration_modlist_name = modlist_name - self._integration_install_dir = install_dir - - # 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}") - def _open_url_safe(self, url): """Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime""" import subprocess @@ -780,144 +127,6 @@ class InstallTTWScreen(QWidget): except Exception as e: print(f"Warning: Could not open URL {url}: {e}") - def force_collapsed_state(self): - """Force the screen into its collapsed state regardless of prior layout. - - This is used to resolve timing/race conditions when navigating here from - the end of the Install Modlist workflow, ensuring the UI opens collapsed - just like when launched from Additional Tasks. - """ - try: - from PySide6.QtCore import Qt as _Qt - # Ensure checkbox is unchecked without emitting user-facing signals - if self.show_details_checkbox.isChecked(): - self.show_details_checkbox.blockSignals(True) - self.show_details_checkbox.setChecked(False) - self.show_details_checkbox.blockSignals(False) - # Apply collapsed layout explicitly - self._toggle_console_visibility(_Qt.Unchecked) - # Inform parent window to collapse height - try: - self.resize_request.emit('collapse') - except Exception: - pass - except Exception: - pass - - 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 - # Remove any fixed height constraints to allow natural stretching - self.console.setMaximumHeight(16777215) # Reset to default maximum - # Only enforce a small minimum when details are shown; keep 0 when collapsed - if self.console.isVisible(): - self.console.setMinimumHeight(50) - else: - self.console.setMinimumHeight(0) - - def showEvent(self, event): - """Called when the widget becomes visible""" - super().showEvent(event) - debug_print(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 - QTimer.singleShot(0, self._check_ttw_installer_status) - - # Ensure initial collapsed layout each time this screen is opened - try: - from PySide6.QtCore import Qt as _Qt - # On Steam Deck: keep expanded layout and hide the details toggle - try: - is_steamdeck = False - # Check our own system_info first - if self.system_info and getattr(self.system_info, 'is_steamdeck', False): - is_steamdeck = True - # Fallback to checking parent window's system_info - elif not self.system_info: - parent = self.window() - if parent and hasattr(parent, 'system_info') and getattr(parent.system_info, 'is_steamdeck', False): - is_steamdeck = True - - if is_steamdeck: - debug_print("DEBUG: Steam Deck detected, keeping expanded") - # Force expanded state and hide checkbox - if self.show_details_checkbox.isVisible(): - self.show_details_checkbox.setVisible(False) - # Show console with proper sizing for Steam Deck - self.console.setVisible(True) - self.console.show() - self.console.setMinimumHeight(200) - self.console.setMaximumHeight(16777215) # Remove height limit - return - except Exception as e: - debug_print(f"DEBUG: Steam Deck check exception: {e}") - pass - debug_print(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)") - 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}") - 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}") - 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}") - - # Fixed compact size - same as menu screens - from PySide6.QtCore import QSize - # On Steam Deck, keep fullscreen; on other systems, set normal window state - if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): - main_window.showNormal() - # First, completely unlock the window - main_window.setMinimumSize(QSize(0, 0)) - main_window.setMaximumSize(QSize(16777215, 16777215)) - # Only set minimum size - DO NOT RESIZE - set_responsive_minimum(main_window, min_width=960, min_height=420) - # DO NOT resize - let window stay at current size - # Notify parent to ensure compact - try: - self.resize_request.emit('collapse') - debug_print("DEBUG: Emitted resize_request collapse signal") - except Exception as e: - debug_print(f"DEBUG: Exception emitting signal: {e}") - pass - except Exception as e: - debug_print(f"DEBUG: showEvent exception: {e}") - import traceback - debug_print(f"DEBUG: {traceback.format_exc()}") - pass - - def hideEvent(self, event): - """Called when the widget becomes hidden - restore window size constraints""" - super().hideEvent(event) - try: - main_window = self.window() - if main_window: - from PySide6.QtCore import QSize - # Clear any size constraints that might have been set to prevent affecting other screens - # This is especially important when the 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") - except Exception as e: - debug_print(f"DEBUG: hideEvent exception: {e}") - pass - def _load_saved_parent_directories(self): """No-op: do not pre-populate install/download directories from saved values.""" pass @@ -980,24 +189,6 @@ class InstallTTWScreen(QWidget): self.install_dir_edit.setText(dirs[0]) - def go_back(self): - """Navigate back to main menu and restore window size""" - # Restore window size before navigating away - try: - main_window = self.window() - if main_window: - from PySide6.QtCore import QSize - - # Only set minimum size - DO NOT RESIZE - main_window.setMaximumSize(QSize(16777215, 16777215)) - set_responsive_minimum(main_window, min_width=960, min_height=420) - # DO NOT resize - let window stay at current size - except Exception: - pass - - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(self.main_menu_index) - def update_top_panel(self): try: result = subprocess.run([ @@ -1057,1182 +248,6 @@ class InstallTTWScreen(QWidget): "Continuing anyway, but some features may not work correctly.") return True # Continue anyway - - - def validate_and_start_install(self): - import time - self._install_workflow_start_time = time.time() - debug_print('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') - - # 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") - if missing_fields: - 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 - 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.", - parent=self - ) - 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" - f"This action cannot be undone.", - parent=self - ) - 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(): - if item.is_dir(): - shutil.rmtree(item) - else: - item.unlink() - debug_print(f"DEBUG: Deleted all contents of {install_dir}") - except Exception as e: - MessageService.critical(self, "Error", f"Failed to delete directory contents:\n{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 - - 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 - ) - 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}") - 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}') - 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 - 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") - - def run_ttw_installer(self, mpi_path, install_dir): - debug_print('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER') - - # CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog - # This ensures Proton version and winetricks settings are current - 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}; - padding: 8px; - border-radius: 4px; - font-weight: bold; - font-size: 13px; - """) - - 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 - - # 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 - 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) - # Common emojis: ✅ ❌ ⚠️ 🔍 💾 📁 🚀 🛑 - # Use character-by-character filtering to avoid regex recursion issues - # This is 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 - # Simplified: Only extract progress, don't filter file operations (show in details mode) - # Extract progress from lines like: [44908/58889] or [X/Y] - progress_match = None - try: - progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned) - except (RecursionError, re.error): - pass - - 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 - - # Check if this looks like a file operation line (has file extension) - is_file_operation = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx']) - - if is_file_operation: - # File operation - only show in details mode, but still extract progress for Activity window - self._ttw_file_operation_count += 1 - phase_name = self._ttw_current_phase or "Processing files" - - # Update Activity Window with phase and counters - self._update_ttw_activity(current, total, percent) - - # Only show in details mode - if self.show_details_checkbox.isChecked(): - elapsed = int(time.time() - self.ttw_start_time) - elapsed_min = elapsed // 60 - elapsed_sec = elapsed % 60 - progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - self._update_progress_line(progress_text) - return - - # === COLLAPSE REPETITIVE EXTRACTION PROGRESS === - # Pattern: "Extracted 100/27290 files..." - simplified with error handling - extraction_progress_match = None - try: - extraction_progress_match = re.search(r'Extracted\s+(\d+)/(\d+)\s+files', cleaned, re.IGNORECASE) - except (RecursionError, re.error): - pass - - if extraction_progress_match: - current = int(extraction_progress_match.group(1)) - total = int(extraction_progress_match.group(2)) - percent = int((current / total) * 100) if total > 0 else 0 - - # Update phase with counters (always update Activity window) - phase_name = "Extracting MPI package" - self._ttw_current_phase = phase_name - self._update_ttw_phase(phase_name, current, total, percent) - - # Only show progress line in details mode - if self.show_details_checkbox.isChecked(): - elapsed = int(time.time() - self.ttw_start_time) - elapsed_min = elapsed // 60 - elapsed_sec = elapsed % 60 - progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - self._update_progress_line(progress_text) - - # Update last progress tracking - self._ttw_last_extraction_progress = current - return - - # === IMPORTANT MILESTONES AND SUMMARIES === - # Simplified: Use simple string checks instead of regex - milestone_keywords = ['===', 'complete', 'finished', 'installation summary', 'assets processed', - 'validation complete', 'bsa creation', 'post-commands', 'operation summary', - 'package:', 'variables:', 'locations:', 'assets:', 'loaded', 'successfully parsed'] - is_milestone = any(keyword in lower_cleaned for keyword in milestone_keywords) - - if is_milestone: - self._safe_append_text(cleaned) - return - - # === PROGRESS PATTERNS === - # Pattern 1: "Progress: 50% (1234/5678)" - simplified regex with error handling - progress_pct_match = None - try: - progress_pct_match = re.search(r'(\d+)%\s*\((\d+)/(\d+)\)', cleaned) - except (RecursionError, re.error): - pass - - if progress_pct_match: - percent = int(progress_pct_match.group(1)) - current = int(progress_pct_match.group(2)) - total = int(progress_pct_match.group(3)) - - if not hasattr(self, 'ttw_total_assets'): - self.ttw_total_assets = total - - elapsed = int(time.time() - self.ttw_start_time) - elapsed_min = elapsed // 60 - elapsed_sec = elapsed % 60 - - self.status_banner.setText( - f"Installing TTW: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - ) - - # Update Activity Window with phase and counters - phase_name = self._ttw_current_phase or "Processing" - self._update_ttw_activity(current, total, percent) - - # Only show progress line in details mode - if self.show_details_checkbox.isChecked(): - progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - self._update_progress_line(progress_text) - return - - # Pattern 2: "[X/Y]" with context OR "Loading manifest: X/Y" pattern - simplified with error handling - progress_match = None - loading_manifest_match = None - try: - progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned) - loading_manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned) - except (RecursionError, re.error): - pass - - if loading_manifest_match: - # Special handling for "Loading manifest: X/Y" - always show this progress - current = int(loading_manifest_match.group(1)) - total = int(loading_manifest_match.group(2)) - percent = int((current / total) * 100) if total > 0 else 0 - - # Extract elapsed time if present - simplified with error handling - elapsed_match = None - try: - elapsed_match = re.search(r'elapsed:\s*(\d+)m\s*(\d+)s', lower_cleaned) - except (RecursionError, re.error): - pass - - if elapsed_match: - elapsed_min = int(elapsed_match.group(1)) - elapsed_sec = int(elapsed_match.group(2)) - else: - elapsed = int(time.time() - self.ttw_start_time) - elapsed_min = elapsed // 60 - elapsed_sec = elapsed % 60 - - phase_name = "Loading manifest" - self._ttw_current_phase = phase_name - - # Remove duplicate percentage - status banner already shows it - self.status_banner.setText( - f"Loading manifest: {current:,}/{total:,} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - ) - - # Update single progress line (but show periodic updates to indicate activity) - progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - - # Show periodic updates (every 2% or every 5 seconds) to indicate process is alive - # More frequent updates to prevent appearance of hanging - if not hasattr(self, '_ttw_last_manifest_percent'): - self._ttw_last_manifest_percent = 0 - self._ttw_last_manifest_time = time.time() - - percent_diff = percent - self._ttw_last_manifest_percent - time_diff = time.time() - self._ttw_last_manifest_time - - # Update progress line, but also show new line if significant progress or time elapsed - # More frequent updates (every 2% or 5 seconds) to show activity - if percent_diff >= 2 or time_diff >= 5: - # Significant progress or time elapsed - show as new line to indicate activity - self._safe_append_text(progress_text) - self._ttw_progress_line_text = progress_text - self._ttw_last_manifest_percent = percent - self._ttw_last_manifest_time = time.time() - else: - # Small progress - just update the line - self._update_progress_line(progress_text) - - # Update Activity Window with phase and counters - self._update_ttw_activity(current, total, percent) - - # Process events to keep UI responsive during long operations - QApplication.processEvents() - return - - if progress_match: - current = int(progress_match.group(1)) - total = int(progress_match.group(2)) - - # Check if this is a meaningful progress line (not a file operation we already handled) - if any(keyword in lower_cleaned for keyword in ['writing', 'creating', 'processing', 'installing', 'extracting', 'loading']): - if not hasattr(self, 'ttw_total_assets'): - self.ttw_total_assets = total - - # Detect specific phases from context (simple string checks) - phase_name = self._ttw_current_phase - if 'bsa' in lower_cleaned or 'writing' in lower_cleaned: - phase_name = "Writing BSA archives" - self._ttw_current_phase = phase_name - elif 'loading' in lower_cleaned: - phase_name = "Loading manifest" - self._ttw_current_phase = phase_name - elif not phase_name: - phase_name = "Processing" - - percent = int((current / total) * 100) if total > 0 else 0 - elapsed = int(time.time() - self.ttw_start_time) - elapsed_min = elapsed // 60 - elapsed_sec = elapsed % 60 - - self.status_banner.setText( - f"Installing TTW: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - ) - - # Update Activity Window with phase and counters (always) - self._update_ttw_activity(current, total, percent) - - # Only show progress line in details mode - if self.show_details_checkbox.isChecked(): - progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - self._update_progress_line(progress_text) - return - - # === PHASE DETECTION === - phase_keywords = { - 'extracting': 'Extracting MPI package', - 'downloading': 'Downloading files', - 'loading manifest': 'Loading manifest', - 'parsing assets': 'Parsing assets', - 'validation': 'Running validation', - 'installing': 'Installing TTW', - 'writing bsa': 'Writing BSA archives', - 'post-installation': 'Running post-installation commands', - 'cleaning up': 'Cleaning up' - } - - for keyword, phase_name in phase_keywords.items(): - if keyword in lower_cleaned: - if self._ttw_current_phase != phase_name: - # Start new phase - just update Activity window and show phase message - self._ttw_current_phase = phase_name - self._update_ttw_phase(phase_name) # Start phase without counters initially - if self.show_details_checkbox.isChecked(): - self._safe_append_text(f"{phase_name}...") - self._ttw_progress_line_text = None # Reset progress line - return - - # === CONFIGURATION AND VALIDATION MESSAGES === - # Simplified: Use simple string checks - config_keywords = ['fallout 3:', 'fallout nv:', 'output:', 'mpi package:', 'configuration valid', - 'validating configuration', 'verifying', 'file correctly absent', 'disk space check'] - is_config = any(keyword in lower_cleaned for keyword in config_keywords) - - if is_config: - self._safe_append_text(cleaned) - return - - # === EXECUTION COMMANDS (filter most, show important ones) === - if 'executing:' in lower_cleaned or 'cmd.exe' in lower_cleaned: - # Only show rename operations and failures, not every delete/rename - if 'renamed:' in lower_cleaned or 'ren:' in lower_cleaned: - if self.show_details_checkbox.isChecked(): - self._safe_append_text(cleaned) - return - - # === PATCH/LZ4 DECOMPRESSION MESSAGES === - # Show these to indicate activity during manifest loading - if 'patch' in lower_cleaned and ('lz4' in lower_cleaned or 'decompressing' in lower_cleaned): - # Show patch decompression messages (but not errors - those are handled above) - if 'error' not in lower_cleaned and 'failed' not in lower_cleaned: - # Just a status message - show it briefly or in details mode - if self.show_details_checkbox.isChecked(): - self._safe_append_text(cleaned) - # Don't return - let it fall through to default handling - else: - # Error message - already handled by error detection above - return - - # === DEFAULT: Only show in details mode === - if self.show_details_checkbox.isChecked(): - self._safe_append_text(cleaned) - - 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 - - def _update_progress_line(self, text): - """Update progress - just append, don't try to replace (simpler and safer)""" - # Simplified: Just append progress lines instead of trying to replace - # This avoids Qt cursor manipulation issues that cause SystemError - # Only show in details mode to avoid spam - if self.show_details_checkbox.isChecked(): - self._safe_append_text(text) - # Always track for Activity window updates (handled separately) - self._ttw_progress_line_text = text - - def _update_ttw_elapsed_time(self): - """Update status banner with elapsed time""" - if hasattr(self, 'ttw_start_time'): - elapsed = int(time.time() - self.ttw_start_time) - minutes = elapsed // 60 - seconds = elapsed % 60 - self.status_banner.setText(f"Processing Tale of Two Wastelands installation... Elapsed: {minutes}m {seconds}s") - - def on_installation_finished(self, success, message): - """Handle installation completion""" - debug_print(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""" - background-color: #1a4d1a; - color: #4CAF50; - padding: 8px; - border-radius: 4px; - font-weight: bold; - font-size: 13px; - """) - self._safe_append_text(f"\nSuccess: {message}") - self.process_finished(0, QProcess.NormalExit) - else: - self.status_banner.setText(f"Installation failed: {message}") - self.status_banner.setStyleSheet(f""" - background-color: #4d1a1a; - color: #f44336; - padding: 8px; - border-radius: 4px; - font-weight: bold; - font-size: 13px; - """) - self._safe_append_text(f"\nError: {message}") - 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 - 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") - - - 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 - ) - - if reply == QMessageBox.Yes: - self._create_ttw_mod_archive() - else: - MessageService.information( - self, "Installation Complete", - "TTW installation complete!\n\n" - "You can manually use the TTW files from the output directory.", - 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.") - self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") - self.console.moveCursor(QTextCursor.End) - - def _setup_scroll_tracking(self): - """Set up scroll tracking for professional auto-scroll behavior""" - scrollbar = self.console.verticalScrollBar() - scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) - 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() - # Use tolerance to account for rounding and rapid updates - self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 - - # If user manually scrolls to bottom, reset manual scroll flag - if self._was_at_bottom and self._user_manually_scrolled: - # Small delay to allow user to scroll away if they want - 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: bool): - from PySide6.QtCore import Qt as _Qt - self._toggle_console_visibility(_Qt.Checked if checked else _Qt.Unchecked) - - def _toggle_console_visibility(self, state): - """Toggle console visibility and resize main window""" - is_checked = (state == Qt.Checked) - main_window = self.window() - - if not main_window: - return - - # Check if we're on Steam Deck - is_steamdeck = False - if self.system_info and getattr(self.system_info, 'is_steamdeck', False): - is_steamdeck = True - elif not self.system_info and main_window and hasattr(main_window, 'system_info'): - is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False) - - # Console height when expanded - console_height = 300 - - if is_checked: - # Show console - self.console.setVisible(True) - self.console.show() - self.console.setMinimumHeight(200) - self.console.setMaximumHeight(16777215) - try: - self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - except Exception: - pass - try: - self.main_overall_vbox.setStretchFactor(self.console, 1) - except Exception: - pass - - # 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") - return - - # Restore main window to normal size (clear any compact constraints) - # On Steam Deck, keep fullscreen; on other systems, set normal window state - if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): - main_window.showNormal() - main_window.setMaximumHeight(16777215) - main_window.setMinimumHeight(0) - # Restore original minimum size so the window can expand normally - try: - if self._saved_min_size is not None: - main_window.setMinimumSize(self._saved_min_size) - except Exception: - pass - # Prefer exact original geometry if known - if self._saved_geometry is not None: - main_window.setGeometry(self._saved_geometry) - else: - expanded_min = 900 - current_size = main_window.size() - target_height = max(expanded_min, 900) - main_window.setMinimumHeight(expanded_min) - main_window.resize(current_size.width(), target_height) - try: - # Encourage layouts to recompute sizes - self.main_overall_vbox.invalidate() - self.updateGeometry() - except Exception: - pass - # Notify parent to expand - try: - self.resize_request.emit('expand') - except Exception: - pass - else: - # Hide console fully (removes it from layout sizing) - self.console.setVisible(False) - self.console.hide() - self.console.setMinimumHeight(0) - self.console.setMaximumHeight(0) - try: - # Make the hidden console contribute no expand pressure - self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) - except Exception: - pass - try: - self.main_overall_vbox.setStretchFactor(self.console, 0) - except Exception: - pass - - # 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") - return - - # Use fixed compact height for consistency across all workflow screens - compact_height = 620 - # On Steam Deck, keep fullscreen; on other systems, set normal window state - if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): - main_window.showNormal() - # Set minimum height but no maximum to allow user resizing - try: - from PySide6.QtCore import QSize - set_responsive_minimum(main_window, min_width=960, min_height=compact_height) - main_window.setMaximumSize(QSize(16777215, 16777215)) # No maximum - except Exception: - pass - - # Resize to compact height to avoid leftover space - current_size = main_window.size() - main_window.resize(current_size.width(), compact_height) - # Notify parent to collapse - try: - self.resize_request.emit('collapse') - except Exception: - pass - - def _update_ttw_activity(self, current, total, percent): - """Update Activity window with TTW installation progress""" - try: - # Determine current phase based on progress - if not hasattr(self, '_ttw_current_phase'): - self._ttw_current_phase = None - - # Use current phase name or default - phase_name = self._ttw_current_phase or "Processing" - - # Update or add activity item showing current progress with phase name and counters - # Don't include percentage in label - progress bar shows it - label = f"{phase_name}: {current:,}/{total:,}" - self.file_progress_list.update_or_add_item( - item_id="ttw_progress", - label=label, - progress=percent - ) - except Exception: - pass - - def _update_ttw_phase(self, phase_name, current=None, total=None, percent=0): - """Update Activity window with current TTW installation phase and optional progress""" - try: - self._ttw_current_phase = phase_name - - # Build label with phase name and counters if provided - # Don't include percentage in label - progress bar shows it - if current is not None and total is not None: - label = f"{phase_name}: {current:,}/{total:,}" - else: - label = phase_name - - # Update or add activity item - self.file_progress_list.update_or_add_item( - item_id="ttw_phase", - label=label, - progress=percent - ) - except Exception: - pass - - def _safe_append_text(self, text, color=None): - """Append text with professional auto-scroll behavior - - Args: - text: Text to append - color: Optional HTML color code (e.g., '#f44336' for red) to format the text - """ - # Write all messages to log file (including internal messages) - self._write_to_log_file(text) - - # Filter out internal status messages from user console display - if text.strip().startswith('[Jackify]'): - # Internal messages are logged but not shown in user console - return - - scrollbar = self.console.verticalScrollBar() - # Check if user was at bottom BEFORE adding text - was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance - - # Format text with color if provided - if color: - # Escape HTML special characters - escaped_text = text.replace('&', '&').replace('<', '<').replace('>', '>') - formatted_text = f'{escaped_text}' - # Use insertHtml for colored text (QTextEdit supports HTML in append when using RichText) - cursor = self.console.textCursor() - cursor.movePosition(QTextCursor.End) - self.console.setTextCursor(cursor) - self.console.insertHtml(formatted_text + '
') - else: - # Add plain text - self.console.append(text) - - # Auto-scroll if user was at bottom and hasn't manually scrolled - # Re-check bottom state after text addition for better reliability - if (was_at_bottom and not self._user_manually_scrolled) or \ - (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): - scrollbar.setValue(scrollbar.maximum()) - # Ensure user can still manually scroll up during rapid updates - if scrollbar.value() == scrollbar.maximum(): - self._was_at_bottom = True - def _write_to_log_file(self, message): """Write message to workflow log file with timestamp""" try: @@ -2244,873 +259,6 @@ class InstallTTWScreen(QWidget): # Logging should never break the workflow pass - 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 - shortcut_handler = ShortcutHandler(steamdeck=False) # TODO: Use proper system info - - 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() - - # Note: 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 show_manual_steps_dialog(self, extra_warning=""): - modlist_name = "TTW Installation" - msg = ( - f"Manual Proton Setup Required for {modlist_name}
" - "After Steam restarts, complete the following steps in Steam:
" - f"1. Locate the '{modlist_name}' entry in your Steam Library
" - "2. Right-click and select 'Properties'
" - "3. Switch to the 'Compatibility' tab
" - "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" - "5. Select 'Proton - Experimental' from the dropdown menu
" - "6. Close the Properties window
" - f"7. Launch '{modlist_name}' from your Steam Library
" - "8. Wait for Mod Organizer 2 to fully open
" - "9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here
" - "
Once you have completed ALL the steps above, click OK to continue." - f"{extra_warning}" - ) - reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") - if reply == QMessageBox.Yes: - self.validate_manual_steps_completion() - else: - # User clicked Cancel or closed the dialog - cancel the workflow - self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.") - # Re-enable all controls when workflow is cancelled - self._enable_controls_after_operation() - self.cancel_btn.setVisible(True) - self.cancel_install_btn.setVisible(False) - - def _get_mo2_path(self, install_dir, modlist_name): - """Get ModOrganizer.exe path, handling Somnium's non-standard structure""" - mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe") - if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower(): - somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe") - if os.path.exists(somnium_path): - mo2_exe_path = somnium_path - return mo2_exe_path - - def validate_manual_steps_completion(self): - """Validate that manual steps were actually completed and handle retry logic""" - modlist_name = "TTW Installation" - install_dir = self.install_dir_edit.text().strip() - 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...") - import time - 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...") - from jackify.backend.handlers.shortcut_handler import ShortcutHandler - from jackify.backend.services.platform_detection_service import PlatformDetectionService - - platform_service = PlatformDetectionService.get_instance() - shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck) - current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) - - if not current_appid or not current_appid.isdigit(): - self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") - self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") - self._safe_append_text("Suggestion: Check that Steam is running and shortcuts are visible in library") - self.handle_validation_failure("Could not find Steam shortcut") - 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}") - - # Check 1: Proton version - proton_ok = False - try: - from jackify.backend.handlers.modlist_handler import ModlistHandler - from jackify.backend.handlers.path_handler import PathHandler - - # Initialize ModlistHandler with correct parameters - path_handler = PathHandler() - - # Use centralized Steam Deck detection - from jackify.backend.services.platform_detection_service import PlatformDetectionService - platform_service = PlatformDetectionService.get_instance() - - modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False) - - # Set required properties manually after initialization - modlist_handler.modlist_dir = install_dir - modlist_handler.appid = current_appid - modlist_handler.game_var = "skyrimspecialedition" # Default for now - - # Set compat_data_path for Proton detection - compat_data_path_str = path_handler.find_compat_data(current_appid) - if compat_data_path_str: - from pathlib import Path - 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}...") - if modlist_handler._detect_proton_version(): - self._safe_append_text(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}") - else: - self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") - else: - self._safe_append_text("Error: Could not detect Proton version from any source") - - except Exception as e: - self._safe_append_text(f"Error checking Proton version: {e}") - proton_ok = False - - # Check 2: Compatdata directory exists - compatdata_ok = False - try: - 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...") - prefix_path_str = path_handler.find_compat_data(current_appid) - self._safe_append_text(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}") - else: - if prefix_path_str: - self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") - else: - self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") - self._safe_append_text("Suggestion: Ensure you launched the shortcut from Steam at least once") - self._safe_append_text("Suggestion: Check if Steam is using Flatpak (different file paths)") - - except Exception as e: - self._safe_append_text(f"Error checking compatdata: {e}") - compatdata_ok = False - - # 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...") - - # Continue configuration with the corrected AppID and context - self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) - else: - # Validation failed - handle retry logic - missing_items = [] - if not proton_ok: - missing_items.append("• Proton - Experimental not set") - if not compatdata_ok: - missing_items.append("• Shortcut not launched from Steam (no compatdata)") - - missing_text = "\n".join(missing_items) - self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") - self.handle_validation_failure(missing_text) - - def show_shortcut_conflict_dialog(self, conflicts): - """Show dialog to resolve shortcut name conflicts""" - conflict_names = [c['name'] for c in conflicts] - conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" - - modlist_name = "TTW Installation" - - # Create dialog with Jackify styling - from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout - from PySide6.QtCore import Qt - - dialog = QDialog(self) - dialog.setWindowTitle("Steam Shortcut Conflict") - dialog.setModal(True) - dialog.resize(450, 180) - - # Apply Jackify dark theme styling - dialog.setStyleSheet(""" - QDialog { - background-color: #2b2b2b; - color: #ffffff; - } - QLabel { - color: #ffffff; - font-size: 14px; - padding: 10px 0px; - } - QLineEdit { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px; - font-size: 14px; - selection-background-color: #3fd0ea; - } - QLineEdit:focus { - border-color: #3fd0ea; - } - QPushButton { - background-color: #404040; - color: #ffffff; - border: 2px solid #555555; - border-radius: 4px; - padding: 8px 16px; - font-size: 14px; - min-width: 120px; - } - QPushButton:hover { - background-color: #505050; - border-color: #3fd0ea; - } - QPushButton:pressed { - background-color: #303030; - } - """) - - layout = QVBoxLayout(dialog) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Conflict message - conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") - layout.addWidget(conflict_label) - - # Text input for new name - name_input = QLineEdit(modlist_name) - name_input.selectAll() - layout.addWidget(name_input) - - # Buttons - button_layout = QHBoxLayout() - button_layout.setSpacing(10) - - create_button = QPushButton("Create with New Name") - cancel_button = QPushButton("Cancel") - - button_layout.addStretch() - button_layout.addWidget(cancel_button) - button_layout.addWidget(create_button) - layout.addLayout(button_layout) - - # Connect signals - def on_create(): - new_name = name_input.text().strip() - if new_name and new_name != modlist_name: - dialog.accept() - # Retry workflow with new name - self.retry_automated_workflow_with_new_name(new_name) - elif new_name == modlist_name: - # Same name - show warning - from jackify.frontends.gui.services.message_service import MessageService - MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") - else: - # Empty name - from jackify.frontends.gui.services.message_service import MessageService - MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") - - def on_cancel(): - dialog.reject() - self._safe_append_text("Shortcut creation cancelled by user") - - create_button.clicked.connect(on_create) - cancel_button.clicked.connect(on_cancel) - - # Make Enter key work - name_input.returnPressed.connect(on_create) - - 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 - # TTW doesn't need name editing - - # Restart the automated workflow - self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") - self.start_automated_prefix_workflow() - - 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) - - # 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='skyrim', # Default for now - 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) - - # 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='skyrim', # Default for now - 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): - # This shouldn't happen since manual steps should be done - 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) - def handle_validation_failure(self, missing_text): """Handle failed validation with retry logic""" self._manual_steps_retry_count += 1 @@ -3143,7 +291,6 @@ class InstallTTWScreen(QWidget): self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) def show_next_steps_dialog(self, message): - # EXACT LEGACY show_next_steps_dialog from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication dlg = QDialog(self) dlg.setWindowTitle("Next Steps") @@ -3192,282 +339,6 @@ class InstallTTWScreen(QWidget): thread.terminate() thread.wait(1000) # Wait up to 1 second - def _perform_modlist_integration(self): - """Integrate TTW into the modlist automatically - - This is called when in integration mode. It will: - 1. Copy TTW output to modlist's mods folder - 2. Update modlist.txt for all profiles - 3. Update plugins.txt with TTW ESMs in correct order - 4. Emit integration_complete signal - """ - try: - from pathlib import Path - import re - from PySide6.QtCore import QThread, Signal - - # Get TTW output directory - ttw_output_dir = Path(self.install_dir_edit.text()) - if not ttw_output_dir.exists(): - error_msg = f"TTW output directory not found: {ttw_output_dir}" - self._safe_append_text(f"\nError: {error_msg}") - self.integration_complete.emit(False, "") - return - - # Extract version from .mpi filename - mpi_path = self.file_edit.text().strip() - ttw_version = "" - if mpi_path: - mpi_filename = Path(mpi_path).stem - version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE) - if version_match: - ttw_version = version_match.group(1) - - # 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): - super().__init__() - self.ttw_output_path = ttw_output_path - self.modlist_install_dir = modlist_install_dir - self.ttw_version = ttw_version - - def run(self): - try: - from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler - - self.progress.emit("Integrating TTW into modlist...") - 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 - ) - self.finished.emit(success, self.ttw_version) - except Exception as e: - debug_print(f"ERROR: Integration thread failed: {e}") - import traceback - traceback.print_exc() - self.finished.emit(False, self.ttw_version) - - # Show progress message - self._safe_append_text("\nIntegrating TTW into modlist (this may take a few minutes)...") - - # Update status banner (only in integration mode - visible when collapsed) - if self._integration_mode: - self.status_banner.setText("Integrating TTW into modlist (this may take a few minutes)...") - self.status_banner.setStyleSheet(f""" - QLabel {{ - background-color: #FFA500; - color: white; - font-weight: bold; - padding: 8px; - border-radius: 5px; - }} - """) - - # Create progress dialog for integration - progress_dialog = QProgressDialog( - f"Integrating TTW {ttw_version} into modlist...\n\n" - "This involves copying several GB of files and may take a few minutes.\n" - "Please wait...", - None, # No cancel button - 0, 0, # Indeterminate progress - self - ) - progress_dialog.setWindowTitle("Integrating TTW") - progress_dialog.setMinimumDuration(0) # Show immediately - progress_dialog.setWindowModality(Qt.ApplicationModal) - progress_dialog.setCancelButton(None) - progress_dialog.show() - QApplication.processEvents() - - # Store reference to close later - self._integration_progress_dialog = progress_dialog - - # Create and start integration thread - self.integration_thread = IntegrationThread( - ttw_output_dir, - Path(self._integration_install_dir), - ttw_version - ) - self.integration_thread.progress.connect(self._safe_append_text) - self.integration_thread.finished.connect(self._on_integration_thread_finished) - self.integration_thread.start() - - except Exception as e: - # Close progress dialog if it exists - if hasattr(self, '_integration_progress_dialog'): - self._integration_progress_dialog.close() - delattr(self, '_integration_progress_dialog') - - error_msg = f"Integration error: {str(e)}" - self._safe_append_text(f"\nError: {error_msg}") - debug_print(f"ERROR: {error_msg}") - import traceback - traceback.print_exc() - self.integration_complete.emit(False, "") - - def _on_integration_thread_finished(self, success: bool, ttw_version: str): - """Handle completion of integration thread""" - try: - # Close progress dialog - if hasattr(self, '_integration_progress_dialog'): - self._integration_progress_dialog.close() - delattr(self, '_integration_progress_dialog') - - if success: - self._safe_append_text("\nTTW integration completed successfully!") - - # Update status banner (only in integration mode) - if self._integration_mode: - self.status_banner.setText("TTW integration completed successfully!") - self.status_banner.setStyleSheet(f""" - QLabel {{ - background-color: #28a745; - color: white; - font-weight: bold; - padding: 8px; - border-radius: 5px; - }} - """) - - MessageService.information( - self, "Integration Complete", - f"TTW {ttw_version} has been successfully integrated into {self._integration_modlist_name}!", - safety_level="medium" - ) - self.integration_complete.emit(True, ttw_version) - else: - self._safe_append_text("\nTTW integration failed!") - - # Update status banner (only in integration mode) - if self._integration_mode: - self.status_banner.setText("TTW integration failed!") - self.status_banner.setStyleSheet(f""" - QLabel {{ - background-color: #dc3545; - color: white; - font-weight: bold; - padding: 8px; - border-radius: 5px; - }} - """) - - MessageService.critical( - self, "Integration Failed", - "Failed to integrate TTW into the modlist. Check the log for details." - ) - self.integration_complete.emit(False, ttw_version) - except Exception as e: - debug_print(f"ERROR: Failed to handle integration completion: {e}") - self.integration_complete.emit(False, ttw_version) - - def _create_ttw_mod_archive(self, automated=False): - """Create a zipped mod archive of TTW output for MO2 installation. - - Args: - automated: If True, runs silently without user prompts (for automation) - """ - try: - from pathlib import Path - import re - from PySide6.QtCore import QThread, Signal - - output_dir = Path(self.install_dir_edit.text()) - if not output_dir.exists(): - if not automated: - MessageService.warning(self, "Output Directory Not Found", - f"Output directory does not exist:\n{output_dir}") - return False - - # Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4") - mpi_path = self.file_edit.text().strip() - version_suffix = "" - if mpi_path: - mpi_filename = Path(mpi_path).stem - version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE) - if version_match: - version_suffix = f" {version_match.group(1)}" - - # Create archive filename - archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}" - archive_path = output_dir.parent / archive_name - - # Create background thread for zip creation - class ZipCreationThread(QThread): - finished = Signal(bool, str) # success, result_message - - def __init__(self, output_dir, archive_path): - super().__init__() - self.output_dir = output_dir - self.archive_path = archive_path - - def run(self): - try: - import shutil - final_archive = shutil.make_archive( - str(self.archive_path), - 'zip', - str(self.output_dir) - ) - self.finished.emit(True, str(final_archive)) - except Exception as e: - self.finished.emit(False, str(e)) - - # Create progress dialog (non-modal so UI stays responsive) - progress_dialog = QProgressDialog( - f"Creating mod archive: {archive_name}.zip\n\n" - "This may take several minutes depending on installation size...", - "Cancel", - 0, 0, # 0,0 = indeterminate progress bar - self - ) - progress_dialog.setWindowTitle("Creating Archive") - progress_dialog.setMinimumDuration(0) # Show immediately - progress_dialog.setWindowModality(Qt.ApplicationModal) - progress_dialog.setCancelButton(None) # Cannot cancel zip operation safely - progress_dialog.show() - QApplication.processEvents() - - # Create and start thread - zip_thread = ZipCreationThread(output_dir, archive_path) - - def on_zip_finished(success, result): - progress_dialog.close() - if success: - final_archive = result - if not automated: - self._safe_append_text(f"\nArchive created successfully: {Path(final_archive).name}") - MessageService.information( - self, "Archive Created", - f"TTW mod archive created successfully!\n\n" - f"Location: {final_archive}\n\n" - f"You can now install this archive as a mod in MO2.", - safety_level="medium" - ) - else: - error_msg = f"Failed to create mod archive: {result}" - if not automated: - self._safe_append_text(f"\nError: {error_msg}") - MessageService.critical(self, "Archive Creation Failed", error_msg) - - zip_thread.finished.connect(on_zip_finished) - zip_thread.start() - - # Keep reference to prevent garbage collection - self._zip_thread = zip_thread - - return True - - except Exception as e: - error_msg = f"Failed to create mod archive: {str(e)}" - if not automated: - self._safe_append_text(f"\nError: {error_msg}") - MessageService.critical(self, "Archive Creation Failed", error_msg) - return False - def cancel_installation(self): """Cancel the currently running installation""" reply = MessageService.question( @@ -3527,6 +398,13 @@ class InstallTTWScreen(QWidget): self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) + # Collapse window if "Show Details" is checked + if hasattr(self, 'show_details_checkbox') and self.show_details_checkbox.isChecked(): + self.resize_request.emit('collapse') + self.show_details_checkbox.blockSignals(True) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.blockSignals(False) + self._safe_append_text("Installation cancelled by user.") def _show_somnium_post_install_guidance(self): @@ -3551,29 +429,9 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html""" def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" - self.cleanup_processes() - # Restore main window to standard Jackify size before leaving - try: - main_window = self.window() - if main_window: - from PySide6.QtCore import QSize - - # Only set minimum size - DO NOT RESIZE - main_window.setMaximumSize(QSize(16777215, 16777215)) - set_responsive_minimum(main_window, min_width=960, min_height=420) - # DO NOT resize - let window stay at current size - - # Ensure we exit in collapsed state so next entry starts compact (both Desktop and Deck) - if self.show_details_checkbox.isChecked(): - self.show_details_checkbox.blockSignals(True) - self.show_details_checkbox.setChecked(False) - self.show_details_checkbox.blockSignals(False) - # Only toggle console visibility on Desktop (on Deck it's always visible) - if not is_steamdeck: - self._toggle_console_visibility(_Qt.Unchecked) - except Exception: - pass + self.collapse_show_details_before_leave() self.go_back() + QTimer.singleShot(0, self.cleanup_processes) def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" diff --git a/jackify/frontends/gui/screens/install_ttw_config.py b/jackify/frontends/gui/screens/install_ttw_config.py new file mode 100644 index 0000000..2b000ae --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_config.py @@ -0,0 +1,657 @@ +"""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 new file mode 100644 index 0000000..d14fc58 --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_installer.py @@ -0,0 +1,290 @@ +"""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 new file mode 100644 index 0000000..484b16f --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_integration.py @@ -0,0 +1,322 @@ +"""Modlist integration workflow for InstallTTWScreen (Mixin).""" +from PySide6.QtCore import QThread, Signal, Qt +from PySide6.QtWidgets import QProgressDialog, QApplication +from jackify.frontends.gui.services.message_service import MessageService +from pathlib import Path +import traceback +import os +import json +import shutil +import re + + +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 TTWIntegrationMixin: + """Mixin providing modlist integration workflow for InstallTTWScreen.""" + + def set_modlist_integration_mode(self, modlist_name: str, install_dir: str): + """Set the screen to modlist integration mode + + This mode is activated when TTW needs to be installed and integrated + into an existing modlist. In this mode, after TTW installation completes, + the TTW output will be automatically integrated into the modlist. + + Args: + modlist_name: Name of the modlist to integrate TTW into + install_dir: Installation directory of the modlist + """ + self._integration_mode = True + self._integration_modlist_name = modlist_name + self._integration_install_dir = install_dir + + # 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}") + + def _perform_modlist_integration(self): + """Integrate TTW into the modlist automatically + + This is called when in integration mode. It will: + 1. Copy TTW output to modlist's mods folder + 2. Update modlist.txt for all profiles + 3. Update plugins.txt with TTW ESMs in correct order + 4. Emit integration_complete signal + """ + try: + from pathlib import Path + import re + from PySide6.QtCore import QThread, Signal + + # Get TTW output directory + ttw_output_dir = Path(self.install_dir_edit.text()) + if not ttw_output_dir.exists(): + error_msg = f"TTW output directory not found: {ttw_output_dir}" + self._safe_append_text(f"\nError: {error_msg}") + self.integration_complete.emit(False, "") + return + + # Extract version from .mpi filename + mpi_path = self.file_edit.text().strip() + ttw_version = "" + if mpi_path: + mpi_filename = Path(mpi_path).stem + version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE) + if version_match: + ttw_version = version_match.group(1) + + # 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): + super().__init__() + self.ttw_output_path = ttw_output_path + self.modlist_install_dir = modlist_install_dir + self.ttw_version = ttw_version + + def run(self): + try: + from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler + + self.progress.emit("Integrating TTW into modlist...") + 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 + ) + self.finished.emit(success, self.ttw_version) + except Exception as e: + debug_print(f"ERROR: Integration thread failed: {e}") + import traceback + traceback.print_exc() + self.finished.emit(False, self.ttw_version) + + # Show progress message + self._safe_append_text("\nIntegrating TTW into modlist (this may take a few minutes)...") + + # Update status banner (only in integration mode - visible when collapsed) + if self._integration_mode: + self.status_banner.setText("Integrating TTW into modlist (this may take a few minutes)...") + self.status_banner.setStyleSheet(f""" + QLabel {{ + background-color: #FFA500; + color: white; + font-weight: bold; + padding: 8px; + border-radius: 5px; + }} + """) + + # Create progress dialog for integration + progress_dialog = QProgressDialog( + f"Integrating TTW {ttw_version} into modlist...\n\n" + "This involves copying several GB of files and may take a few minutes.\n" + "Please wait...", + None, # No cancel button + 0, 0, # Indeterminate progress + self + ) + progress_dialog.setWindowTitle("Integrating TTW") + progress_dialog.setMinimumDuration(0) # Show immediately + progress_dialog.setWindowModality(Qt.ApplicationModal) + progress_dialog.setCancelButton(None) + progress_dialog.show() + QApplication.processEvents() + + # Store reference to close later + self._integration_progress_dialog = progress_dialog + + # Create and start integration thread + self.integration_thread = IntegrationThread( + ttw_output_dir, + Path(self._integration_install_dir), + ttw_version + ) + self.integration_thread.progress.connect(self._safe_append_text) + self.integration_thread.finished.connect(self._on_integration_thread_finished) + self.integration_thread.start() + + except Exception as e: + # Close progress dialog if it exists + if hasattr(self, '_integration_progress_dialog'): + self._integration_progress_dialog.close() + delattr(self, '_integration_progress_dialog') + + error_msg = f"Integration error: {str(e)}" + self._safe_append_text(f"\nError: {error_msg}") + debug_print(f"ERROR: {error_msg}") + import traceback + traceback.print_exc() + self.integration_complete.emit(False, "") + + def _on_integration_thread_finished(self, success: bool, ttw_version: str): + """Handle completion of integration thread""" + try: + # Close progress dialog + if hasattr(self, '_integration_progress_dialog'): + self._integration_progress_dialog.close() + delattr(self, '_integration_progress_dialog') + + if success: + self._safe_append_text("\nTTW integration completed successfully!") + + # Update status banner (only in integration mode) + if self._integration_mode: + self.status_banner.setText("TTW integration completed successfully!") + self.status_banner.setStyleSheet(f""" + QLabel {{ + background-color: #28a745; + color: white; + font-weight: bold; + padding: 8px; + border-radius: 5px; + }} + """) + + MessageService.information( + self, "Integration Complete", + f"TTW {ttw_version} has been successfully integrated into {self._integration_modlist_name}!", + safety_level="medium" + ) + self.integration_complete.emit(True, ttw_version) + else: + self._safe_append_text("\nTTW integration failed!") + + # Update status banner (only in integration mode) + if self._integration_mode: + self.status_banner.setText("TTW integration failed!") + self.status_banner.setStyleSheet(f""" + QLabel {{ + background-color: #dc3545; + color: white; + font-weight: bold; + padding: 8px; + border-radius: 5px; + }} + """) + + MessageService.critical( + self, "Integration Failed", + "Failed to integrate TTW into the modlist. Check the log for details." + ) + self.integration_complete.emit(False, ttw_version) + except Exception as e: + debug_print(f"ERROR: Failed to handle integration completion: {e}") + self.integration_complete.emit(False, ttw_version) + + def _create_ttw_mod_archive(self, automated=False): + """Create a zipped mod archive of TTW output for MO2 installation. + + Args: + automated: If True, runs silently without user prompts (for automation) + """ + try: + from pathlib import Path + import re + from PySide6.QtCore import QThread, Signal + + output_dir = Path(self.install_dir_edit.text()) + if not output_dir.exists(): + if not automated: + MessageService.warning(self, "Output Directory Not Found", + f"Output directory does not exist:\n{output_dir}") + return False + + # Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4") + mpi_path = self.file_edit.text().strip() + version_suffix = "" + if mpi_path: + mpi_filename = Path(mpi_path).stem + version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE) + if version_match: + version_suffix = f" {version_match.group(1)}" + + # Create archive filename + archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}" + archive_path = output_dir.parent / archive_name + + # Create background thread for zip creation + class ZipCreationThread(QThread): + finished = Signal(bool, str) # success, result_message + + def __init__(self, output_dir, archive_path): + super().__init__() + self.output_dir = output_dir + self.archive_path = archive_path + + def run(self): + try: + import shutil + final_archive = shutil.make_archive( + str(self.archive_path), + 'zip', + str(self.output_dir) + ) + self.finished.emit(True, str(final_archive)) + except Exception as e: + self.finished.emit(False, str(e)) + + # Create progress dialog (non-modal so UI stays responsive) + progress_dialog = QProgressDialog( + f"Creating mod archive: {archive_name}.zip\n\n" + "This may take several minutes depending on installation size...", + "Cancel", + 0, 0, # 0,0 = indeterminate progress bar + self + ) + progress_dialog.setWindowTitle("Creating Archive") + progress_dialog.setMinimumDuration(0) # Show immediately + progress_dialog.setWindowModality(Qt.ApplicationModal) + progress_dialog.setCancelButton(None) # Cannot cancel zip operation safely + progress_dialog.show() + QApplication.processEvents() + + # Create and start thread + zip_thread = ZipCreationThread(output_dir, archive_path) + + def on_zip_finished(success, result): + progress_dialog.close() + if success: + final_archive = result + if not automated: + self._safe_append_text(f"\nArchive created successfully: {Path(final_archive).name}") + MessageService.information( + self, "Archive Created", + f"TTW mod archive created successfully!\n\n" + f"Location: {final_archive}\n\n" + f"You can now install this archive as a mod in MO2.", + safety_level="medium" + ) + else: + error_msg = f"Failed to create mod archive: {result}" + if not automated: + self._safe_append_text(f"\nError: {error_msg}") + MessageService.critical(self, "Archive Creation Failed", error_msg) + + zip_thread.finished.connect(on_zip_finished) + zip_thread.start() + + # Keep reference to prevent garbage collection + self._zip_thread = zip_thread + + return True + + except Exception as e: + error_msg = f"Failed to create mod archive: {str(e)}" + if not automated: + self._safe_append_text(f"\nError: {error_msg}") + MessageService.critical(self, "Archive Creation Failed", error_msg) + return False + diff --git a/jackify/frontends/gui/screens/install_ttw_lifecycle.py b/jackify/frontends/gui/screens/install_ttw_lifecycle.py new file mode 100644 index 0000000..6cbe3b9 --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_lifecycle.py @@ -0,0 +1,155 @@ +"""Window lifecycle and resize handlers for InstallTTWScreen (Mixin).""" +from PySide6.QtCore import QTimer, QSize, Qt +from PySide6.QtGui import QResizeEvent +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.""" + + def force_collapsed_state(self): + """Force the screen into its collapsed state regardless of prior layout. + + This is used to resolve timing/race conditions when navigating here from + the end of the Install Modlist workflow, ensuring the UI opens collapsed + just like when launched from Additional Tasks. + """ + try: + from PySide6.QtCore import Qt as _Qt + # Ensure checkbox is unchecked without emitting user-facing signals + if self.show_details_checkbox.isChecked(): + self.show_details_checkbox.blockSignals(True) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.blockSignals(False) + # Apply collapsed layout explicitly + self._toggle_console_visibility(_Qt.Unchecked) + # Inform parent window to collapse height + try: + self.resize_request.emit('collapse') + except Exception: + pass + except Exception: + pass + + 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 + # Remove any fixed height constraints to allow natural stretching + self.console.setMaximumHeight(16777215) # Reset to default maximum + # Only enforce a small minimum when details are shown; keep 0 when collapsed + if self.console.isVisible(): + self.console.setMinimumHeight(50) + else: + self.console.setMinimumHeight(0) + + def showEvent(self, event): + """Called when the widget becomes visible""" + super().showEvent(event) + debug_print(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 + QTimer.singleShot(0, self._check_ttw_installer_status) + + # Ensure initial collapsed layout each time this screen is opened + try: + from PySide6.QtCore import Qt as _Qt + # On Steam Deck: keep expanded layout and hide the details toggle + try: + is_steamdeck = False + # Check our own system_info first + if self.system_info and getattr(self.system_info, 'is_steamdeck', False): + is_steamdeck = True + # Fallback to checking parent window's system_info + elif not self.system_info: + parent = self.window() + if parent and hasattr(parent, 'system_info') and getattr(parent.system_info, 'is_steamdeck', False): + is_steamdeck = True + + if is_steamdeck: + debug_print("DEBUG: Steam Deck detected, keeping expanded") + # Force expanded state and hide checkbox + if self.show_details_checkbox.isVisible(): + self.show_details_checkbox.setVisible(False) + # Show console with proper sizing for Steam Deck + self.console.setVisible(True) + self.console.show() + self.console.setMinimumHeight(200) + self.console.setMaximumHeight(16777215) # Remove height limit + return + except Exception as e: + debug_print(f"DEBUG: Steam Deck check exception: {e}") + pass + debug_print(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)") + 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}") + 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}") + 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}") + + # Fixed compact size - same as menu screens + from PySide6.QtCore import QSize + # On Steam Deck, keep fullscreen; on other systems, set normal window state + if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): + main_window.showNormal() + # First, completely unlock the window + main_window.setMinimumSize(QSize(0, 0)) + main_window.setMaximumSize(QSize(16777215, 16777215)) + # Only set minimum size - DO NOT RESIZE + set_responsive_minimum(main_window, min_width=960, min_height=420) + # DO NOT resize - let window stay at current size + # Notify parent to ensure compact + try: + self.resize_request.emit('collapse') + debug_print("DEBUG: Emitted resize_request collapse signal") + except Exception as e: + debug_print(f"DEBUG: Exception emitting signal: {e}") + pass + except Exception as e: + debug_print(f"DEBUG: showEvent exception: {e}") + import traceback + debug_print(f"DEBUG: {traceback.format_exc()}") + pass + + def hideEvent(self, event): + """Called when the widget becomes hidden - restore window size constraints""" + super().hideEvent(event) + try: + main_window = self.window() + if main_window: + from PySide6.QtCore import QSize + # Clear any size constraints that might have been set to prevent affecting other screens + # 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") + except Exception as e: + debug_print(f"DEBUG: hideEvent exception: {e}") + pass + diff --git a/jackify/frontends/gui/screens/install_ttw_requirements.py b/jackify/frontends/gui/screens/install_ttw_requirements.py new file mode 100644 index 0000000..0b534d4 --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_requirements.py @@ -0,0 +1,298 @@ +"""TTW installer requirements and validation for InstallTTWScreen (Mixin).""" +from PySide6.QtCore import QThread, Signal +from PySide6.QtWidgets import QMessageBox +from jackify.frontends.gui.services.message_service import MessageService +from pathlib import Path +import os +import requests +import traceback + + +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 TTWRequirementsMixin: + """Mixin providing TTW installer requirement checking and validation 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 pinned/latest + update_available, installed_v, target_v = ttw_installer_handler.is_ttw_installer_update_available() + if update_available: + # Determine if this is a downgrade or upgrade + from jackify.backend.handlers.ttw_installer_handler import TTW_INSTALLER_PINNED_VERSION + if TTW_INSTALLER_PINNED_VERSION and installed_v and target_v: + # If we have a pinned version and installed is newer, it's a downgrade + try: + # Simple version comparison - if installed version string is longer/more complex, likely newer + # For now, just check if they're different and show appropriate message + if installed_v != target_v: + version_text = f"Update to v{target_v} (currently v{installed_v})" + else: + version_text = f"Update available (v{installed_v} → v{target_v})" + except Exception: + version_text = f"Update to v{target_v}" if target_v else "Update available" + else: + # Normal update (newer version available) + version_text = f"Update available (v{installed_v} → v{target_v})" if installed_v and target_v else "Update available" + 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 latest Linux build of TTW_Linux_Installer." + ), + 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 + + 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_ui.py b/jackify/frontends/gui/screens/install_ttw_ui.py new file mode 100644 index 0000000..e0e0a0b --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_ui.py @@ -0,0 +1,275 @@ +"""UI helper methods for InstallTTWScreen (Mixin).""" +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QTextCursor, QColor +from PySide6.QtWidgets import QSizePolicy +import logging +import time +# Runtime imports to avoid circular dependencies +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.""" + + def _setup_scroll_tracking(self): + """Set up scroll tracking for professional auto-scroll behavior""" + scrollbar = self.console.verticalScrollBar() + scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) + 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() + # Use tolerance to account for rounding and rapid updates + self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 + + # If user manually scrolls to bottom, reset manual scroll flag + if self._was_at_bottom and self._user_manually_scrolled: + # Small delay to allow user to scroll away if they want + 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: bool): + from PySide6.QtCore import Qt as _Qt + self._toggle_console_visibility(_Qt.Checked if checked else _Qt.Unchecked) + + def _toggle_console_visibility(self, state): + """Toggle console visibility and resize main window""" + is_checked = (state == Qt.Checked) + main_window = self.window() + + if not main_window: + return + + # Check if we're on Steam Deck + is_steamdeck = False + if self.system_info and getattr(self.system_info, 'is_steamdeck', False): + is_steamdeck = True + elif not self.system_info and main_window and hasattr(main_window, 'system_info'): + is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False) + + # Console height when expanded + console_height = 300 + + if is_checked: + # Show console + self.console.setVisible(True) + self.console.show() + self.console.setMinimumHeight(200) + self.console.setMaximumHeight(16777215) + try: + self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + except Exception: + pass + try: + self.main_overall_vbox.setStretchFactor(self.console, 1) + except Exception: + pass + + # 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") + return + + # Restore main window to normal size (clear any compact constraints) + # On Steam Deck, keep fullscreen; on other systems, set normal window state + if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): + main_window.showNormal() + main_window.setMaximumHeight(16777215) + main_window.setMinimumHeight(0) + # Restore original minimum size so the window can expand normally + try: + if self._saved_min_size is not None: + main_window.setMinimumSize(self._saved_min_size) + except Exception: + pass + # Prefer exact original geometry if known + if self._saved_geometry is not None: + main_window.setGeometry(self._saved_geometry) + else: + expanded_min = 900 + current_size = main_window.size() + target_height = max(expanded_min, 900) + main_window.setMinimumHeight(expanded_min) + main_window.resize(current_size.width(), target_height) + try: + # Encourage layouts to recompute sizes + self.main_overall_vbox.invalidate() + self.updateGeometry() + except Exception: + pass + # Notify parent to expand + try: + self.resize_request.emit('expand') + except Exception: + pass + else: + # Hide console fully (removes it from layout sizing) + self.console.setVisible(False) + self.console.hide() + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + try: + # Make the hidden console contribute no expand pressure + self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + except Exception: + pass + try: + self.main_overall_vbox.setStretchFactor(self.console, 0) + except Exception: + pass + + # 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") + return + + # Use fixed compact height for consistency across all workflow screens + compact_height = 620 + # On Steam Deck, keep fullscreen; on other systems, set normal window state + if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): + main_window.showNormal() + # Set minimum height but no maximum to allow user resizing + try: + from PySide6.QtCore import QSize + set_responsive_minimum(main_window, min_width=960, min_height=compact_height) + main_window.setMaximumSize(QSize(16777215, 16777215)) # No maximum + except Exception: + pass + + # Resize to compact height to avoid leftover space + current_size = main_window.size() + main_window.resize(current_size.width(), compact_height) + # Notify parent to collapse + try: + self.resize_request.emit('collapse') + except Exception: + pass + + def _update_ttw_activity(self, current, total, percent): + """Update Activity window with TTW installation progress""" + try: + # Determine current phase based on progress + if not hasattr(self, '_ttw_current_phase'): + self._ttw_current_phase = None + + # Use current phase name or default + phase_name = self._ttw_current_phase or "Processing" + + # Update or add activity item showing current progress with phase name and counters + # Don't include percentage in label - progress bar shows it + label = f"{phase_name}: {current:,}/{total:,}" + self.file_progress_list.update_or_add_item( + item_id="ttw_progress", + label=label, + progress=percent + ) + except Exception: + pass + + def _update_ttw_phase(self, phase_name, current=None, total=None, percent=0): + """Update Activity window with current TTW installation phase and optional progress""" + try: + self._ttw_current_phase = phase_name + + # Build label with phase name and counters if provided + # Don't include percentage in label - progress bar shows it + if current is not None and total is not None: + label = f"{phase_name}: {current:,}/{total:,}" + else: + label = phase_name + + # Update or add activity item + self.file_progress_list.update_or_add_item( + item_id="ttw_phase", + label=label, + progress=percent + ) + except Exception: + pass + + def _safe_append_text(self, text, color=None): + """Append text with professional auto-scroll behavior + + Args: + text: Text to append + color: Optional HTML color code (e.g., '#f44336' for red) to format the text + """ + # Write all messages to log file (including internal messages) + self._write_to_log_file(text) + + # Filter out internal status messages from user console display + if text.strip().startswith('[Jackify]'): + # Internal messages are logged but not shown in user console + return + + scrollbar = self.console.verticalScrollBar() + # Check if user was at bottom BEFORE adding text + was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance + + # Format text with color if provided + if color: + # Escape HTML special characters + escaped_text = text.replace('&', '&').replace('<', '<').replace('>', '>') + formatted_text = f'{escaped_text}' + # Use insertHtml for colored text (QTextEdit supports HTML in append when using RichText) + cursor = self.console.textCursor() + cursor.movePosition(QTextCursor.End) + self.console.setTextCursor(cursor) + self.console.insertHtml(formatted_text + '
') + else: + # Add plain text + self.console.append(text) + + # Auto-scroll if user was at bottom and hasn't manually scrolled + # Re-check bottom state after text addition for better reliability + if (was_at_bottom and not self._user_manually_scrolled) or \ + (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): + scrollbar.setValue(scrollbar.maximum()) + # Ensure user can still manually scroll up during rapid updates + if scrollbar.value() == scrollbar.maximum(): + self._was_at_bottom = True + + def _update_progress_line(self, text): + """Update progress - just append, don't try to replace (simpler and safer)""" + # Simplified: Just append progress lines instead of trying to replace + # Avoids Qt cursor SystemError + # Only show in details mode to avoid spam + if self.show_details_checkbox.isChecked(): + self._safe_append_text(text) + # Always track for Activity window updates (handled separately) + self._ttw_progress_line_text = text + + def _update_ttw_elapsed_time(self): + """Update status banner with elapsed time""" + if hasattr(self, 'ttw_start_time'): + elapsed = int(time.time() - self.ttw_start_time) + minutes = elapsed // 60 + seconds = elapsed % 60 + self.status_banner.setText(f"Processing Tale of Two Wastelands installation... Elapsed: {minutes}m {seconds}s") + diff --git a/jackify/frontends/gui/screens/install_ttw_ui_setup.py b/jackify/frontends/gui/screens/install_ttw_ui_setup.py new file mode 100644 index 0000000..66b51d1 --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_ui_setup.py @@ -0,0 +1,368 @@ +"""UI setup methods for InstallTTWScreen (Mixin).""" +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QCheckBox, QFrame, QTabWidget +from PySide6.QtCore import Qt, QTimer, QSize +from PySide6.QtGui import QFont +from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS +from jackify.backend.handlers.wabbajack_parser import WabbajackParser +from jackify.frontends.gui.widgets.file_progress_list import FileProgressList + + +class TTWUISetupMixin: + """Mixin providing UI initialization for InstallTTWScreen.""" + + def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None): + super().__init__() + self.stacked_widget = stacked_widget + self.main_menu_index = main_menu_index + self.system_info = system_info + self.debug = DEBUG_BORDERS + self.online_modlists = {} # {game_type: [modlist_dict, ...]} + self.modlist_details = {} # {modlist_name: modlist_dict} + + # Initialize log path (can be refreshed via refresh_paths method) + self.refresh_paths() + + # Initialize services early + from jackify.backend.services.api_key_service import APIKeyService + from jackify.backend.services.resolution_service import ResolutionService + from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService + from jackify.backend.handlers.config_handler import ConfigHandler + self.api_key_service = APIKeyService() + self.resolution_service = ResolutionService() + self.config_handler = ConfigHandler() + self.protontricks_service = ProtontricksDetectionService() + + # Modlist integration mode tracking + self._integration_mode = False + self._integration_modlist_name = None + self._integration_install_dir = None + + # Somnium guidance tracking + self._show_somnium_guidance = False + self._somnium_install_dir = None + + # Scroll tracking for professional auto-scroll behavior + self._user_manually_scrolled = False + self._was_at_bottom = True + + # Initialize Wabbajack parser for game detection + self.wabbajack_parser = WabbajackParser() + # Remember original main window geometry/min-size to restore on expand + self._saved_geometry = None + self._saved_min_size = None + + main_overall_vbox = QVBoxLayout(self) + main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + # Match other workflow screens + main_overall_vbox.setContentsMargins(50, 50, 50, 0) + main_overall_vbox.setSpacing(12) + if self.debug: + self.setStyleSheet("border: 2px solid magenta;") + + # --- Header (title, description) --- + header_widget = QWidget() + header_layout = QVBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(2) + + # Title + title = QLabel("Install Tale of Two Wastelands (TTW)") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") + title.setAlignment(Qt.AlignHCenter) + header_layout.addWidget(title) + + header_layout.addSpacing(10) + + # Description area with fixed height + desc = QLabel( + "This screen allows you to install Tale of Two Wastelands (TTW) using TTW_Linux_Installer. " + "Configure your options and start the installation." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #ccc; font-size: 13px;") + desc.setAlignment(Qt.AlignHCenter) + desc.setMaximumHeight(50) # Fixed height for description zone + header_layout.addWidget(desc) + + header_layout.addSpacing(12) + + header_widget.setLayout(header_layout) + header_widget.setFixedHeight(120) # Fixed total header height to match other screens + if self.debug: + header_widget.setStyleSheet("border: 2px solid pink;") + header_widget.setToolTip("HEADER_SECTION") + main_overall_vbox.addWidget(header_widget) + + # --- Upper section: user-configurables (left) + process monitor (right) --- + upper_hbox = QHBoxLayout() + upper_hbox.setContentsMargins(0, 0, 0, 0) + upper_hbox.setSpacing(16) + # Left: user-configurables (form and controls) + user_config_vbox = QVBoxLayout() + user_config_vbox.setAlignment(Qt.AlignTop) + user_config_vbox.setSpacing(4) # Reduce spacing between major form sections + + # --- Instructions --- + instruction_text = QLabel( + "Tale of Two Wastelands installation requires a .mpi file you can get from: " + 'https://mod.pub/ttw/133/files ' + "(requires a user account for ModPub)" + ) + instruction_text.setWordWrap(True) + instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;") + instruction_text.setOpenExternalLinks(True) + user_config_vbox.addWidget(instruction_text) + + # --- Compact Form Grid for inputs (align with other screens) --- + form_grid = QGridLayout() + form_grid.setHorizontalSpacing(12) + form_grid.setVerticalSpacing(6) + form_grid.setContentsMargins(0, 0, 0, 0) + + # Row 0: TTW .mpi File location + file_label = QLabel("TTW .mpi File location:") + self.file_edit = QLineEdit() + self.file_edit.setMaximumHeight(25) + self.file_edit.textChanged.connect(self._update_start_button_state) + self.file_btn = QPushButton("Browse") + self.file_btn.clicked.connect(self.browse_wabbajack_file) + file_hbox = QHBoxLayout() + file_hbox.addWidget(self.file_edit) + file_hbox.addWidget(self.file_btn) + form_grid.addWidget(file_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(file_hbox, 0, 1) + + # Row 1: Output Directory + install_dir_label = QLabel("Output Directory:") + self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir()) + self.install_dir_edit.setMaximumHeight(25) + self.browse_install_btn = QPushButton("Browse") + self.browse_install_btn.clicked.connect(self.browse_install_dir) + install_dir_hbox = QHBoxLayout() + install_dir_hbox.addWidget(self.install_dir_edit) + install_dir_hbox.addWidget(self.browse_install_btn) + form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(install_dir_hbox, 1, 1) + + # --- TTW_Linux_Installer Status aligned in form grid (row 2) --- + ttw_installer_label = QLabel("TTW_Linux_Installer Status:") + self.ttw_installer_status = QLabel("Checking...") + self.ttw_installer_btn = QPushButton("Install now") + self.ttw_installer_btn.setStyleSheet(""" + QPushButton:hover { opacity: 0.95; } + QPushButton:disabled { opacity: 0.6; } + """) + self.ttw_installer_btn.setVisible(False) + self.ttw_installer_btn.clicked.connect(self.install_ttw_installer) + ttw_installer_hbox = QHBoxLayout() + ttw_installer_hbox.setContentsMargins(0, 0, 0, 0) + ttw_installer_hbox.setSpacing(8) + ttw_installer_hbox.addWidget(self.ttw_installer_status) + ttw_installer_hbox.addWidget(self.ttw_installer_btn) + ttw_installer_hbox.addStretch() + form_grid.addWidget(ttw_installer_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(ttw_installer_hbox, 2, 1) + + # --- Game Requirements aligned in form grid (row 3) --- + game_req_label = QLabel("Game Requirements:") + self.fallout3_status = QLabel("Fallout 3: Checking...") + self.fallout3_status.setStyleSheet("color: #ccc;") + self.fnv_status = QLabel("Fallout New Vegas: Checking...") + self.fnv_status.setStyleSheet("color: #ccc;") + game_req_hbox = QHBoxLayout() + game_req_hbox.setContentsMargins(0, 0, 0, 0) + game_req_hbox.setSpacing(16) + game_req_hbox.addWidget(self.fallout3_status) + game_req_hbox.addWidget(self.fnv_status) + game_req_hbox.addStretch() + form_grid.addWidget(game_req_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) + form_grid.addLayout(game_req_hbox, 3, 1) + + form_group = QWidget() + form_group.setLayout(form_grid) + user_config_vbox.addWidget(form_group) + + # (TTW_Linux_Installer and Game Requirements now aligned in form_grid above) + + # --- Buttons --- + btn_row = QHBoxLayout() + btn_row.setAlignment(Qt.AlignHCenter) + self.start_btn = QPushButton("Start Installation") + self.start_btn.setEnabled(False) # Disabled until requirements are met + btn_row.addWidget(self.start_btn) + + + + # Cancel button (goes back to menu) + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.cancel_and_cleanup) + btn_row.addWidget(self.cancel_btn) + + # Cancel Installation button (appears during installation) + self.cancel_install_btn = QPushButton("Cancel Installation") + self.cancel_install_btn.clicked.connect(self.cancel_installation) + self.cancel_install_btn.setVisible(False) # Hidden by default + btn_row.addWidget(self.cancel_install_btn) + + # Add stretches to center buttons row + btn_row.insertStretch(0, 1) + btn_row.addStretch(1) + + # Show Details Checkbox (collapsible console) + self.show_details_checkbox = QCheckBox("Show details") + # Start collapsed by default (console hidden until user opts in) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") + # Use toggled(bool) for reliable signal and map to our handler + try: + self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) + except Exception: + # Fallback to stateChanged if toggled is unavailable + self.show_details_checkbox.stateChanged.connect(self._toggle_console_visibility) + # Checkbox placed in status banner row, right-aligned + + # Wrap button row in widget for debug borders + btn_row_widget = QWidget() + btn_row_widget.setLayout(btn_row) + btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact + if self.debug: + btn_row_widget.setStyleSheet("border: 2px solid red;") + btn_row_widget.setToolTip("BUTTON_ROW") + # Keep a reference for dynamic sizing when collapsing/expanding + self.btn_row_widget = btn_row_widget + user_config_widget = QWidget() + user_config_widget.setLayout(user_config_vbox) + user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + if self.debug: + user_config_widget.setStyleSheet("border: 2px solid orange;") + user_config_widget.setToolTip("USER_CONFIG_WIDGET") + + # Right: Tabbed interface with Activity and Process Monitor + # Both tabs are always available, user can switch between them + self.file_progress_list = FileProgressList() + self.file_progress_list.setMinimumSize(QSize(300, 20)) + self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.process_monitor = QTextEdit() + self.process_monitor.setReadOnly(True) + self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + self.process_monitor.setMinimumSize(QSize(300, 20)) + self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") + self.process_monitor_heading = QLabel("[Process Monitor]") + self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") + self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + process_vbox = QVBoxLayout() + process_vbox.setContentsMargins(0, 0, 0, 0) + process_vbox.setSpacing(2) + process_vbox.addWidget(self.process_monitor_heading) + process_vbox.addWidget(self.process_monitor) + process_monitor_widget = QWidget() + process_monitor_widget.setLayout(process_vbox) + process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + if self.debug: + process_monitor_widget.setStyleSheet("border: 2px solid purple;") + process_monitor_widget.setToolTip("PROCESS_MONITOR") + self.process_monitor_widget = process_monitor_widget + + # Create tab widget to hold both Activity and Process Monitor + self.activity_tabs = QTabWidget() + self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") + self.activity_tabs.setContentsMargins(0, 0, 0, 0) + self.activity_tabs.setDocumentMode(False) + self.activity_tabs.setTabPosition(QTabWidget.North) + if self.debug: + self.activity_tabs.setStyleSheet("border: 2px solid cyan;") + self.activity_tabs.setToolTip("ACTIVITY_TABS") + + # Add both widgets as tabs + self.activity_tabs.addTab(self.file_progress_list, "Activity") + self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") + + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(self.activity_tabs, stretch=9) + upper_hbox.setAlignment(Qt.AlignTop) + self.upper_section_widget = QWidget() + self.upper_section_widget.setLayout(upper_hbox) + # Use Fixed size policy for consistent height + self.upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.upper_section_widget.setMaximumHeight(280) # Fixed height to match other workflow screens + if self.debug: + self.upper_section_widget.setStyleSheet("border: 2px solid green;") + self.upper_section_widget.setToolTip("UPPER_SECTION") + main_overall_vbox.addWidget(self.upper_section_widget) + + # --- Status Banner (shows high-level progress) --- + self.status_banner = QLabel("Ready to install") + self.status_banner.setAlignment(Qt.AlignCenter) + self.status_banner.setStyleSheet(f""" + background-color: #2a2a2a; + color: {JACKIFY_COLOR_BLUE}; + padding: 6px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 13px; + """) + # Prevent banner from expanding vertically + self.status_banner.setMaximumHeight(34) + self.status_banner.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + # Show the banner by default so users see status even when collapsed + self.status_banner.setVisible(True) + # Create a compact banner row with the checkbox right-aligned + banner_row = QHBoxLayout() + # Minimal padding to avoid visible gaps + banner_row.setContentsMargins(0, 0, 0, 0) + banner_row.setSpacing(8) + banner_row.addWidget(self.status_banner, 1) + banner_row.addStretch() + banner_row.addWidget(self.show_details_checkbox) + banner_row_widget = QWidget() + banner_row_widget.setLayout(banner_row) + banner_row_widget.setMaximumHeight(45) # Compact height + banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + main_overall_vbox.addWidget(banner_row_widget) + + # Remove spacing - console should expand to fill available space + # --- Console output area (full width, placeholder for now) --- + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) + # Console starts hidden; toggled via Show details + self.console.setMinimumHeight(0) + self.console.setMaximumHeight(0) + self.console.setFontFamily('monospace') + if self.debug: + self.console.setStyleSheet("border: 2px solid yellow;") + self.console.setToolTip("CONSOLE") + + # Set up scroll tracking for professional auto-scroll behavior + self._setup_scroll_tracking() + + # Add console directly so we can hide/show without affecting buttons + main_overall_vbox.addWidget(self.console, stretch=1) + # Place the button row after the console so it's always visible and centered + main_overall_vbox.addWidget(btn_row_widget, alignment=Qt.AlignHCenter) + + # Store reference to main layout + self.main_overall_vbox = main_overall_vbox + self.setLayout(main_overall_vbox) + + self.current_modlists = [] + + # --- Process Monitor (right) --- + self.process = None + self.log_timer = None + self.last_log_pos = 0 + # --- Process Monitor Timer --- + self.top_timer = QTimer(self) + self.top_timer.timeout.connect(self.update_top_panel) + 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 + + # Initialize empty controls list - will be populated after UI is built + self._actionable_controls = [] + diff --git a/jackify/frontends/gui/screens/install_ttw_workflow.py b/jackify/frontends/gui/screens/install_ttw_workflow.py new file mode 100644 index 0000000..dca6782 --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_workflow.py @@ -0,0 +1,681 @@ +"""TTW installation workflow methods for InstallTTWScreen (Mixin).""" +from pathlib import Path +from PySide6.QtCore import QTimer, Qt, QThread, Signal, 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 + +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') + + # Reload config to pick up any settings changes made in Settings dialog + self.config_handler.reload_config() + debug_print('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") + if missing_fields: + 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 + 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.", + parent=self + ) + 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" + f"This action cannot be undone.", + parent=self + ) + 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(): + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink() + debug_print(f"DEBUG: Deleted all contents of {install_dir}") + except Exception as e: + MessageService.critical(self, "Error", f"Failed to delete directory contents:\n{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 + + 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 + ) + 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}") + 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}') + 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 + 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") + + def run_ttw_installer(self, mpi_path, install_dir): + debug_print('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}; + padding: 8px; + border-radius: 4px; + font-weight: bold; + font-size: 13px; + """) + + 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 + + # 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 + 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 + + def on_installation_finished(self, success, message): + """Handle installation completion""" + debug_print(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""" + background-color: #1a4d1a; + color: #4CAF50; + padding: 8px; + border-radius: 4px; + font-weight: bold; + font-size: 13px; + """) + self._safe_append_text(f"\nSuccess: {message}") + self.process_finished(0, QProcess.NormalExit) + else: + self.status_banner.setText(f"Installation failed: {message}") + self.status_banner.setStyleSheet(f""" + background-color: #4d1a1a; + color: #f44336; + padding: 8px; + border-radius: 4px; + font-weight: bold; + font-size: 13px; + """) + self._safe_append_text(f"\nError: {message}") + 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 + 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") + + + 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 + ) + + if reply == QMessageBox.Yes: + self._create_ttw_mod_archive() + else: + MessageService.information( + self, "Installation Complete", + "TTW installation complete!\n\n" + "You can manually use the TTW files from the output directory.", + 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.") + self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") + self.console.moveCursor(QTextCursor.End) + diff --git a/jackify/frontends/gui/screens/main_menu.py b/jackify/frontends/gui/screens/main_menu.py index 59ecc1b..32729db 100644 --- a/jackify/frontends/gui/screens/main_menu.py +++ b/jackify/frontends/gui/screens/main_menu.py @@ -15,8 +15,8 @@ class MainMenu(QWidget): self.dev_mode = dev_mode layout = QVBoxLayout() layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - layout.setContentsMargins(30, 30, 30, 30) # Reduced from 50 - layout.setSpacing(12) # Reduced from 20 + layout.setContentsMargins(30, 30, 30, 30) + layout.setSpacing(12) # Header zone with fixed height for consistent layout across all menu screens header_widget = QWidget() @@ -60,7 +60,7 @@ class MainMenu(QWidget): # Menu buttons button_width = 400 - button_height = 40 # Reduced from 50/60 + button_height = 40 MENU_ITEMS = [ ("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"), ("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"), @@ -94,14 +94,14 @@ class MainMenu(QWidget): btn_container = QWidget() btn_layout = QVBoxLayout() btn_layout.setContentsMargins(0, 0, 0, 0) - btn_layout.setSpacing(3) # Reduced from 4 + btn_layout.setSpacing(3) btn_layout.setAlignment(Qt.AlignHCenter) btn_layout.addWidget(btn) # Description label with proper alignment desc_label = QLabel(description) desc_label.setAlignment(Qt.AlignHCenter) - desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px + desc_label.setStyleSheet("color: #999; font-size: 11px;") desc_label.setWordWrap(True) desc_label.setFixedWidth(button_width) btn_layout.addWidget(desc_label) @@ -110,7 +110,7 @@ class MainMenu(QWidget): layout.addWidget(btn_container) # Disclaimer - layout.addSpacing(12) # Reduced from 20 + layout.addSpacing(12) disclaimer = QLabel(DISCLAIMER_TEXT) disclaimer.setWordWrap(True) disclaimer.setAlignment(Qt.AlignCenter) @@ -151,7 +151,6 @@ class MainMenu(QWidget): elif action_id == "additional_tasks" and self.stacked_widget: self.stacked_widget.setCurrentIndex(3) elif action_id == "return_main_menu": - # This is the main menu, so do nothing pass elif self.stacked_widget: self.stacked_widget.setCurrentIndex(1) # Default to placeholder \ No newline at end of file diff --git a/jackify/frontends/gui/screens/modlist_gallery.py b/jackify/frontends/gui/screens/modlist_gallery.py index 0b74cc9..36d215c 100644 --- a/jackify/frontends/gui/screens/modlist_gallery.py +++ b/jackify/frontends/gui/screens/modlist_gallery.py @@ -21,786 +21,14 @@ from jackify.backend.services.modlist_gallery_service import ModlistGalleryServi from jackify.backend.models.modlist_metadata import ModlistMetadata, ModlistMetadataResponse from ..shared_theme import JACKIFY_COLOR_BLUE from ..utils import get_screen_geometry, set_responsive_minimum +from .modlist_gallery_image_manager import ImageManager +from .modlist_gallery_card import ModlistCard +from .modlist_gallery_detail import ModlistDetailDialog +from .modlist_gallery_filters import ModlistGalleryFiltersMixin +from .modlist_gallery_loading import ModlistGalleryLoadingMixin -class ImageManager(QObject): - """Centralized image loading and caching manager""" - - def __init__(self, gallery_service: ModlistGalleryService): - super().__init__() - self.gallery_service = gallery_service - self.pixmap_cache: Dict[str, QPixmap] = {} - self.network_manager = QNetworkAccessManager() - self.download_queue = deque() - self.downloading: set = set() - self.max_concurrent = 2 # Start with 2 concurrent downloads to reduce UI lag - self.save_queue = deque() # Queue for deferred disk saves - self._save_timer = None - - def get_image(self, metadata: ModlistMetadata, callback, size: str = "small") -> Optional[QPixmap]: - """ - Get image for modlist - returns cached pixmap or None if needs download - - Args: - metadata: Modlist metadata - callback: Callback function when image is loaded - size: Image size to use ("small" for cards, "large" for detail view) - """ - cache_key = f"{metadata.machineURL}_{size}" - - # Check memory cache first (should be preloaded) - if cache_key in self.pixmap_cache: - return self.pixmap_cache[cache_key] - - # Only check disk cache if not in memory (fallback for images that weren't preloaded) - # This should rarely happen if preload worked correctly - cached_path = self.gallery_service.get_cached_image_path(metadata, size) - if cached_path and cached_path.exists(): - try: - pixmap = QPixmap(str(cached_path)) - if not pixmap.isNull(): - self.pixmap_cache[cache_key] = pixmap - return pixmap - except Exception: - pass - - # Queue for download if not cached - if cache_key not in self.downloading: - self.download_queue.append((metadata, callback, size)) - self._process_queue() - - return None - - def _process_queue(self): - """Process download queue up to max_concurrent""" - # Process one at a time with small delays to keep UI responsive - if len(self.downloading) < self.max_concurrent and self.download_queue: - metadata, callback, size = self.download_queue.popleft() - cache_key = f"{metadata.machineURL}_{size}" - - if cache_key not in self.downloading: - self.downloading.add(cache_key) - self._download_image(metadata, callback, size) - - # Schedule next download with small delay to yield to UI - if self.download_queue: - QTimer.singleShot(100, self._process_queue) - - def _download_image(self, metadata: ModlistMetadata, callback, size: str = "small"): - """Download image from network""" - image_url = self.gallery_service.get_image_url(metadata, size) - if not image_url: - cache_key = f"{metadata.machineURL}_{size}" - self.downloading.discard(cache_key) - self._process_queue() - return - - url = QUrl(image_url) - request = QNetworkRequest(url) - request.setRawHeader(b"User-Agent", b"Jackify/0.1.8") - - reply = self.network_manager.get(request) - reply.finished.connect(lambda: self._on_download_finished(reply, metadata, callback, size)) - - def _on_download_finished(self, reply: QNetworkReply, metadata: ModlistMetadata, callback, size: str = "small"): - """Handle download completion""" - from PySide6.QtWidgets import QApplication - - cache_key = f"{metadata.machineURL}_{size}" - self.downloading.discard(cache_key) - - if reply.error() == QNetworkReply.NoError: - image_data = reply.readAll() - pixmap = QPixmap() - if pixmap.loadFromData(image_data) and not pixmap.isNull(): - # Store in memory cache immediately - self.pixmap_cache[cache_key] = pixmap - - # Defer disk save to avoid blocking UI - queue it for later - cached_path = self.gallery_service.get_image_cache_path(metadata, size) - self.save_queue.append((pixmap, cached_path)) - self._start_save_timer() - - # Call callback with pixmap (update UI immediately) - if callback: - callback(pixmap) - - # Process events to keep UI responsive - QApplication.processEvents() - - reply.deleteLater() - - # Process next in queue (with small delay to yield to UI) - QTimer.singleShot(50, self._process_queue) - - def _start_save_timer(self): - """Start timer for deferred disk saves if not already running""" - if self._save_timer is None: - self._save_timer = QTimer() - self._save_timer.timeout.connect(self._save_next_image) - self._save_timer.setSingleShot(False) - self._save_timer.start(200) # Save one image every 200ms - - def _save_next_image(self): - """Save next image from queue to disk (non-blocking)""" - if self.save_queue: - pixmap, cached_path = self.save_queue.popleft() - try: - cached_path.parent.mkdir(parents=True, exist_ok=True) - pixmap.save(str(cached_path), "WEBP") - except Exception: - pass # Save failed - not critical, image is in memory cache - - # Stop timer if queue is empty - if not self.save_queue and self._save_timer: - self._save_timer.stop() - self._save_timer = None - - -class ModlistCard(QFrame): - """Visual card representing a single modlist""" - clicked = Signal(ModlistMetadata) - - def __init__(self, metadata: ModlistMetadata, image_manager: ImageManager, is_steamdeck: bool = False): - super().__init__() - self.metadata = metadata - self.image_manager = image_manager - self.is_steamdeck = is_steamdeck - self._setup_ui() - - def _setup_ui(self): - """Set up the card UI""" - self.setFrameShape(QFrame.StyledPanel) - self.setFrameShadow(QFrame.Raised) - self.setCursor(Qt.PointingHandCursor) - - # Steam Deck-specific sizing (1280x800 screen) - if self.is_steamdeck: - self.setFixedSize(250, 270) # Smaller cards for Steam Deck - image_width, image_height = 230, 130 # Smaller images, maintaining 16:9 ratio - else: - self.setFixedSize(300, 320) # Standard size - image_width, image_height = 280, 158 # Standard image size - - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - - layout = QVBoxLayout() - layout.setContentsMargins(10, 8, 10, 8) # Reduced vertical margins - layout.setSpacing(6) # Reduced spacing between elements - - # Image (widescreen aspect ratio like Wabbajack) - self.image_label = QLabel() - self.image_label.setFixedSize(image_width, image_height) # 16:9 aspect ratio - self.image_label.setStyleSheet("background: #333; border-radius: 4px;") - self.image_label.setAlignment(Qt.AlignCenter) - self.image_label.setScaledContents(True) # Use Qt's automatic scaling - this works best - self.image_label.setText("") - layout.addWidget(self.image_label) - - # Title row with badges (Official, NSFW, UNAVAILABLE) - title_row = QHBoxLayout() - title_row.setSpacing(4) - - title = QLabel(self.metadata.title) - title.setWordWrap(True) - title.setFont(QFont("Sans", 12, QFont.Bold)) - title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};") - title.setMaximumHeight(40) # Reduced from 50 to 40 - title_row.addWidget(title, stretch=1) - - # Store reference to unavailable badge for dynamic updates - self.unavailable_badge = None - if not self.metadata.is_available(): - self.unavailable_badge = QLabel("UNAVAILABLE") - self.unavailable_badge.setStyleSheet("background: #666; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;") - self.unavailable_badge.setFixedHeight(20) - title_row.addWidget(self.unavailable_badge, alignment=Qt.AlignTop | Qt.AlignRight) - - if self.metadata.official: - official_badge = QLabel("OFFICIAL") - official_badge.setStyleSheet("background: #2a5; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;") - official_badge.setFixedHeight(20) - title_row.addWidget(official_badge, alignment=Qt.AlignTop | Qt.AlignRight) - - if self.metadata.nsfw: - nsfw_badge = QLabel("NSFW") - nsfw_badge.setStyleSheet("background: #d44; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;") - nsfw_badge.setFixedHeight(20) - title_row.addWidget(nsfw_badge, alignment=Qt.AlignTop | Qt.AlignRight) - - layout.addLayout(title_row) - - # Author - author = QLabel(f"by {self.metadata.author}") - author.setStyleSheet("color: #aaa; font-size: 11px;") - layout.addWidget(author) - - # Game - game = QLabel(self.metadata.gameHumanFriendly) - game.setStyleSheet("color: #ccc; font-size: 10px;") - layout.addWidget(game) - - # Sizes (Download, Install, Total) - if self.metadata.sizes: - size_info = QLabel( - f"Download: {self.metadata.sizes.downloadSizeFormatted} | " - f"Install: {self.metadata.sizes.installSizeFormatted} | " - f"Total: {self.metadata.sizes.totalSizeFormatted}" - ) - size_info.setStyleSheet("color: #999; font-size: 10px;") - size_info.setWordWrap(True) # Allow wrapping if text is too long - layout.addWidget(size_info) - - # Removed addStretch() to eliminate wasted space - self.setLayout(layout) - - # Load image - self._load_image() - - def _create_placeholder(self): - """Create a placeholder pixmap for cards without images""" - # Create placeholder matching the image label size (Steam Deck or standard) - image_size = self.image_label.size() - placeholder = QPixmap(image_size) - placeholder.fill(QColor("#333")) - - # Draw a simple icon/text on the placeholder - painter = QPainter(placeholder) - painter.setPen(QColor("#666")) - painter.setFont(QFont("Sans", 10)) - painter.drawText(placeholder.rect(), Qt.AlignCenter, "No Image") - painter.end() - - # Show placeholder immediately - self.image_label.setPixmap(placeholder) - - def _load_image(self): - """Load image using centralized image manager - use large images and scale down for quality""" - # Get large image for card - scale down for better quality than small images - pixmap = self.image_manager.get_image(self.metadata, self._on_image_loaded, size="large") - - if pixmap and not pixmap.isNull(): - # Image was in cache - display immediately (should be instant) - self._display_image(pixmap) - else: - # Image needs to be downloaded - show placeholder - self._create_placeholder() - - def _on_image_loaded(self, pixmap: QPixmap): - """Callback when image is loaded from network""" - if pixmap and not pixmap.isNull(): - self._display_image(pixmap) - - def _display_image(self, pixmap: QPixmap): - """Display image - use best method based on aspect ratio""" - if pixmap.isNull(): - return - - label_size = self.image_label.size() - label_aspect = label_size.width() / label_size.height() # 16:9 = ~1.778 - - # Calculate image aspect ratio - image_aspect = pixmap.width() / pixmap.height() if pixmap.height() > 0 else label_aspect - - # If aspect ratios are close (within 5%), use Qt's automatic scaling for best quality - # Otherwise, manually scale with cropping to avoid stretching - aspect_diff = abs(image_aspect - label_aspect) / label_aspect - - if aspect_diff < 0.05: # Within 5% of 16:9 - # Close to correct aspect - use Qt's automatic scaling (best quality) - self.image_label.setScaledContents(True) - self.image_label.setPixmap(pixmap) - else: - # Different aspect - manually scale with cropping (no stretching) - self.image_label.setScaledContents(False) - scaled_pixmap = pixmap.scaled( - label_size.width(), - label_size.height(), - Qt.KeepAspectRatioByExpanding, # Crop instead of stretch - Qt.SmoothTransformation # High quality - ) - self.image_label.setPixmap(scaled_pixmap) - - def _update_availability_badge(self): - """Update unavailable badge visibility based on current availability status""" - is_unavailable = not self.metadata.is_available() - - # Find title row layout (it's the 2nd layout item: image at 0, title_row at 1) - main_layout = self.layout() - if main_layout and main_layout.count() >= 2: - title_row = main_layout.itemAt(1).layout() - if title_row: - if is_unavailable and self.unavailable_badge is None: - # Need to add badge to title row (before Official/NSFW badges) - self.unavailable_badge = QLabel("UNAVAILABLE") - self.unavailable_badge.setStyleSheet("background: #666; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;") - self.unavailable_badge.setFixedHeight(20) - # Insert after title (index 1) but before other badges - # Find first badge position (if any exist) - insert_index = 1 # After title widget - for i in range(title_row.count()): - item = title_row.itemAt(i) - if item and item.widget() and isinstance(item.widget(), QLabel): - widget_text = item.widget().text() - if widget_text in ("OFFICIAL", "NSFW"): - insert_index = i - break - title_row.insertWidget(insert_index, self.unavailable_badge, alignment=Qt.AlignTop | Qt.AlignRight) - elif not is_unavailable and self.unavailable_badge is not None: - # Need to remove badge from title row - title_row.removeWidget(self.unavailable_badge) - self.unavailable_badge.setParent(None) - self.unavailable_badge = None - - def mousePressEvent(self, event): - """Handle click on card""" - if event.button() == Qt.LeftButton: - self.clicked.emit(self.metadata) - super().mousePressEvent(event) - - -class ModlistDetailDialog(QDialog): - """Detailed view of a modlist with install option""" - install_requested = Signal(ModlistMetadata) - - def __init__(self, metadata: ModlistMetadata, image_manager: ImageManager, parent=None): - super().__init__(parent) - self.metadata = metadata - self.image_manager = image_manager - self.setWindowTitle(metadata.title) - set_responsive_minimum(self, min_width=900, min_height=640) - self._apply_initial_size() - self._setup_ui() - - def _apply_initial_size(self): - """Ensure dialog size fits current screen.""" - _, _, screen_width, screen_height = get_screen_geometry(self) - width = 1000 - height = 760 - if screen_width: - width = min(width, max(880, screen_width - 40)) - if screen_height: - height = min(height, max(640, screen_height - 40)) - self.resize(width, height) - - def _setup_ui(self): - """Set up detail dialog UI with modern layout matching Wabbajack style""" - main_layout = QVBoxLayout() - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - # --- Banner area with full-width text overlay --- - # Container so we can place a semi-opaque text panel over the banner image - banner_container = QFrame() - banner_container.setFrameShape(QFrame.NoFrame) - banner_container.setStyleSheet("background: #000; border: none;") - banner_layout = QVBoxLayout() - banner_layout.setContentsMargins(0, 0, 0, 0) - banner_layout.setSpacing(0) - banner_container.setLayout(banner_layout) - - # Banner image at top with 16:9 aspect ratio (like Wabbajack) - self.banner_label = QLabel() - # Height will be calculated based on width to maintain 16:9 ratio - self.banner_label.setMinimumHeight(200) - self.banner_label.setStyleSheet("background: #1a1a1a; border: none;") - self.banner_label.setAlignment(Qt.AlignCenter) - self.banner_label.setText("Loading image...") - banner_layout.addWidget(self.banner_label) - - # Full-width transparent container with opaque card inside (only as wide as text) - overlay_container = QWidget() - overlay_container.setStyleSheet("background: transparent;") - overlay_layout = QHBoxLayout() - overlay_layout.setContentsMargins(24, 0, 24, 24) - overlay_layout.setSpacing(0) - overlay_container.setLayout(overlay_layout) - - # Opaque text card - only as wide as content needs (where red lines are) - self.banner_text_panel = QFrame() - self.banner_text_panel.setFrameShape(QFrame.StyledPanel) - # Opaque background, rounded corners, sized to content only - self.banner_text_panel.setStyleSheet(""" - QFrame { - background-color: rgba(0, 0, 0, 180); - border: 1px solid rgba(255, 255, 255, 30); - border-radius: 8px; - } - """) - self.banner_text_panel.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) - banner_text_layout = QVBoxLayout() - banner_text_layout.setContentsMargins(20, 12, 20, 14) - banner_text_layout.setSpacing(6) - self.banner_text_panel.setLayout(banner_text_layout) - - # Add card to container (left-aligned, rest stays transparent) - overlay_layout.addWidget(self.banner_text_panel, alignment=Qt.AlignBottom | Qt.AlignLeft) - overlay_layout.addStretch() # Push card left, rest transparent - - # Title only (badges moved to tags section below) - title = QLabel(self.metadata.title) - title.setFont(QFont("Sans", 24, QFont.Bold)) - title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};") - title.setWordWrap(True) - banner_text_layout.addWidget(title) - - # Only sizes in overlay (minimal info on image) - if self.metadata.sizes: - sizes_text = ( - f"Download: {self.metadata.sizes.downloadSizeFormatted} • " - f"Install: {self.metadata.sizes.installSizeFormatted} • " - f"Total: {self.metadata.sizes.totalSizeFormatted}" - ) - sizes_label = QLabel(sizes_text) - sizes_label.setStyleSheet("color: #fff; font-size: 13px;") - banner_text_layout.addWidget(sizes_label) - - # Add full-width transparent container at bottom of banner - banner_layout.addWidget(overlay_container, alignment=Qt.AlignBottom) - main_layout.addWidget(banner_container) - - # Content area with padding (tags + description + bottom bar) - content_widget = QWidget() - content_layout = QVBoxLayout() - content_layout.setContentsMargins(24, 20, 24, 20) - content_layout.setSpacing(16) - content_widget.setLayout(content_layout) - - # Metadata line (version, author, game) - moved below image - metadata_line_parts = [] - if self.metadata.version: - metadata_line_parts.append(f"version {self.metadata.version}") - metadata_line_parts.append(f"by {self.metadata.author}") - metadata_line_parts.append(f" {self.metadata.gameHumanFriendly}") - - if self.metadata.maintainers and len(self.metadata.maintainers) > 0: - maintainers_text = ", ".join(self.metadata.maintainers) - if maintainers_text != self.metadata.author: # Only show if different from author - metadata_line_parts.append(f" Maintained by {maintainers_text}") - - metadata_line = QLabel(" ".join(metadata_line_parts)) - metadata_line.setStyleSheet("color: #fff; font-size: 14px;") - metadata_line.setWordWrap(True) - content_layout.addWidget(metadata_line) - - # Tags row (includes status badges moved from overlay) - tags_layout = QHBoxLayout() - tags_layout.setSpacing(6) - tags_layout.setContentsMargins(0, 0, 0, 0) - - # Add status badges first (UNAVAILABLE, Unofficial) - if not self.metadata.is_available(): - unavailable_badge = QLabel("UNAVAILABLE") - unavailable_badge.setStyleSheet("background: #666; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") - tags_layout.addWidget(unavailable_badge) - - if not self.metadata.official: - unofficial_badge = QLabel("Unofficial") - unofficial_badge.setStyleSheet("background: #666; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") - tags_layout.addWidget(unofficial_badge) - - # Add regular tags - tags_to_render = getattr(self.metadata, 'normalized_tags_display', self.metadata.tags or []) - if tags_to_render: - for tag in tags_to_render: - tag_badge = QLabel(tag) - # Match Wabbajack tag styling - if tag.lower() == "nsfw": - tag_badge.setStyleSheet("background: #d44; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") - elif tag.lower() == "official" or tag.lower() == "featured": - tag_badge.setStyleSheet("background: #2a5; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") - else: - tag_badge.setStyleSheet("background: #3a3a3a; color: #ccc; padding: 6px 12px; font-size: 11px; border-radius: 4px;") - tags_layout.addWidget(tag_badge) - - tags_layout.addStretch() - content_layout.addLayout(tags_layout) - - # Description section - desc_label = QLabel("Description:") - content_layout.addWidget(desc_label) - - # Use QTextEdit with explicit line counting to force scrollbar - self.desc_text = QTextEdit() - self.desc_text.setReadOnly(True) - self.desc_text.setPlainText(self.metadata.description or "No description provided.") - # Compact description area; scroll when content is long - self.desc_text.setFixedHeight(120) - self.desc_text.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.desc_text.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.desc_text.setLineWrapMode(QTextEdit.WidgetWidth) - self.desc_text.setStyleSheet(""" - QTextEdit { - background: #2a2a2a; - color: #fff; - border: none; - border-radius: 6px; - padding: 12px; - } - """) - - content_layout.addWidget(self.desc_text) - - main_layout.addWidget(content_widget) - - # Bottom bar with Links (left) and Action buttons (right) - bottom_bar = QHBoxLayout() - bottom_bar.setContentsMargins(24, 16, 24, 24) - bottom_bar.setSpacing(12) - - # Links section on the left - links_layout = QHBoxLayout() - links_layout.setSpacing(10) - - if self.metadata.links and (self.metadata.links.discordURL or self.metadata.links.websiteURL or self.metadata.links.readme): - links_label = QLabel("Links:") - links_layout.addWidget(links_label) - - if self.metadata.links.discordURL: - discord_btn = QPushButton("Discord") - discord_btn.setStyleSheet(""" - QPushButton { - background: #5865F2; - color: white; - padding: 8px 16px; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - QPushButton:hover { - background: #4752C4; - } - QPushButton:pressed { - background: #3C45A5; - } - """) - discord_btn.clicked.connect(lambda: self._open_url(self.metadata.links.discordURL)) - links_layout.addWidget(discord_btn) - - if self.metadata.links.websiteURL: - website_btn = QPushButton("Website") - website_btn.setStyleSheet(""" - QPushButton { - background: #3a3a3a; - color: white; - padding: 8px 16px; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - QPushButton:hover { - background: #4a4a4a; - } - QPushButton:pressed { - background: #2a2a2a; - } - """) - website_btn.clicked.connect(lambda: self._open_url(self.metadata.links.websiteURL)) - links_layout.addWidget(website_btn) - - if self.metadata.links.readme: - readme_btn = QPushButton("Readme") - readme_btn.setStyleSheet(""" - QPushButton { - background: #3a3a3a; - color: white; - padding: 8px 16px; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - QPushButton:hover { - background: #4a4a4a; - } - QPushButton:pressed { - background: #2a2a2a; - } - """) - readme_url = self._convert_raw_github_url(self.metadata.links.readme) - readme_btn.clicked.connect(lambda: self._open_url(readme_url)) - links_layout.addWidget(readme_btn) - - bottom_bar.addLayout(links_layout) - bottom_bar.addStretch() - - # Action buttons on the right - - cancel_btn = QPushButton("Close") - cancel_btn.setStyleSheet(""" - QPushButton { - background: #3a3a3a; - color: white; - padding: 8px 16px; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - QPushButton:hover { - background: #4a4a4a; - } - QPushButton:pressed { - background: #2a2a2a; - } - """) - cancel_btn.clicked.connect(self.reject) - bottom_bar.addWidget(cancel_btn) - - install_btn = QPushButton("Install Modlist") - install_btn.setDefault(True) - if not self.metadata.is_available(): - install_btn.setEnabled(False) - install_btn.setToolTip("This modlist is currently unavailable") - install_btn.setStyleSheet(""" - QPushButton { - background: #555; - color: #999; - padding: 8px 16px; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - """) - else: - install_btn.setStyleSheet(f""" - QPushButton {{ - background: {JACKIFY_COLOR_BLUE}; - color: white; - padding: 8px 16px; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - }} - QPushButton:hover {{ - background: #4a9eff; - }} - QPushButton:pressed {{ - background: #3a8eef; - }} - """) - install_btn.clicked.connect(self._on_install_clicked) - bottom_bar.addWidget(install_btn) - - main_layout.addLayout(bottom_bar) - self.setLayout(main_layout) - - # Load banner image - self._load_banner_image() - - def _load_banner_image(self): - """Load large banner image for detail view""" - if not self.metadata.images or not self.metadata.images.large: - self.banner_label.setText("No image available") - self.banner_label.setStyleSheet("background: #1a1a1a; color: #666; border: none;") - return - - # Try to get large image from cache or download (for detail view banner) - pixmap = self.image_manager.get_image(self.metadata, self._on_banner_loaded, size="large") - - if pixmap and not pixmap.isNull(): - # Image was in cache - display immediately - self._display_banner(pixmap) - else: - # Show placeholder while downloading - placeholder = QPixmap(self.banner_label.size()) - placeholder.fill(QColor("#1a1a1a")) - painter = QPainter(placeholder) - painter.setPen(QColor("#666")) - painter.setFont(QFont("Sans", 12)) - painter.drawText(placeholder.rect(), Qt.AlignCenter, "Loading image...") - painter.end() - self.banner_label.setPixmap(placeholder) - - def _on_banner_loaded(self, pixmap: QPixmap): - """Callback when banner image is loaded""" - if pixmap and not pixmap.isNull(): - self._display_banner(pixmap) - - def resizeEvent(self, event): - """Handle dialog resize to maintain 16:9 aspect ratio for banner""" - super().resizeEvent(event) - # Update banner height to maintain 16:9 aspect ratio - if hasattr(self, 'banner_label'): - width = self.width() - height = int(width / 16 * 9) # 16:9 aspect ratio - self.banner_label.setFixedHeight(height) - # Redisplay image if we have one - if hasattr(self, '_current_banner_pixmap'): - self._display_banner(self._current_banner_pixmap) - - def _display_banner(self, pixmap: QPixmap): - """Display banner image with proper 16:9 aspect ratio (like Wabbajack)""" - # Store pixmap for resize events - self._current_banner_pixmap = pixmap - - # Calculate 16:9 aspect ratio height - width = self.width() if self.width() > 0 else 1000 - target_height = int(width / 16 * 9) - self.banner_label.setFixedHeight(target_height) - - # Scale image to fill width while maintaining aspect ratio (UniformToFill behavior) - # This crops if needed but doesn't stretch - scaled_pixmap = pixmap.scaled( - width, - target_height, - Qt.KeepAspectRatioByExpanding, # Fill the area, cropping if needed - Qt.SmoothTransformation - ) - self.banner_label.setPixmap(scaled_pixmap) - self.banner_label.setText("") - - def _convert_raw_github_url(self, url: str) -> str: - """Convert raw GitHub URLs to rendered blob URLs for better user experience""" - if not url: - return url - - if "raw.githubusercontent.com" in url: - url = url.replace("raw.githubusercontent.com", "github.com") - url = url.replace("/master/", "/blob/master/") - url = url.replace("/main/", "/blob/main/") - - return url - - def _on_install_clicked(self): - """Handle install button click""" - self.install_requested.emit(self.metadata) - self.accept() - - def _open_url(self, url: str): - """Open URL with clean environment to avoid AppImage library conflicts.""" - import subprocess - import os - - env = os.environ.copy() - - # Remove AppImage-specific environment variables - appimage_vars = [ - 'LD_LIBRARY_PATH', - 'PYTHONPATH', - 'PYTHONHOME', - 'QT_PLUGIN_PATH', - 'QML2_IMPORT_PATH', - ] - - if 'APPIMAGE' in env or 'APPDIR' in env: - for var in appimage_vars: - if var in env: - del env[var] - - subprocess.Popen( - ['xdg-open', url], - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True - ) - - -class ModlistGalleryDialog(QDialog): +class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixin, QDialog): """Enhanced modlist gallery dialog with visual browsing""" modlist_selected = Signal(ModlistMetadata) @@ -864,7 +92,7 @@ class ModlistGalleryDialog(QDialog): def _setup_ui(self): """Set up the gallery UI""" main_layout = QHBoxLayout() - main_layout.setContentsMargins(16, 16, 16, 16) # Reduced from 20 to 16 + main_layout.setContentsMargins(16, 16, 16, 16) main_layout.setSpacing(12) # Left sidebar (filters) @@ -877,97 +105,6 @@ class ModlistGalleryDialog(QDialog): self.setLayout(main_layout) - def _create_filter_panel(self) -> QWidget: - """Create filter sidebar""" - panel = QFrame() - panel.setFrameShape(QFrame.StyledPanel) - panel.setFixedWidth(280) # Slightly wider for better readability - - layout = QVBoxLayout() - layout.setSpacing(6) # Reduced from 12 to 6 for tighter spacing - - # Title - title = QLabel("Filters") - title.setStyleSheet(f"font-size: 14px; color: {JACKIFY_COLOR_BLUE};") - layout.addWidget(title) - - # Search box (label removed - placeholder text is clear enough) - self.search_box = QLineEdit() - self.search_box.setPlaceholderText("Search modlists...") - self.search_box.setStyleSheet("QLineEdit { background: #2a2a2a; color: #fff; border: 1px solid #555; padding: 4px; }") - self.search_box.textChanged.connect(self._apply_filters) - layout.addWidget(self.search_box) - - # Game filter (label removed - combo box is self-explanatory) - self.game_combo = QComboBox() - self.game_combo.addItem("All Games", None) - self.game_combo.currentIndexChanged.connect(self._apply_filters) - layout.addWidget(self.game_combo) - - # Status filters - self.show_official_only = QCheckBox("Show Official Only") - self.show_official_only.stateChanged.connect(self._apply_filters) - layout.addWidget(self.show_official_only) - - self.show_nsfw = QCheckBox("Show NSFW") - self.show_nsfw.stateChanged.connect(self._on_nsfw_toggled) - layout.addWidget(self.show_nsfw) - - self.hide_unavailable = QCheckBox("Hide Unavailable") - self.hide_unavailable.setChecked(True) - self.hide_unavailable.stateChanged.connect(self._apply_filters) - layout.addWidget(self.hide_unavailable) - - # Tag filter - tags_label = QLabel("Tags:") - layout.addWidget(tags_label) - - self.tags_list = QListWidget() - self.tags_list.setSelectionMode(QListWidget.MultiSelection) - self.tags_list.setMaximumHeight(150) - self.tags_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Remove horizontal scrollbar - self.tags_list.setStyleSheet("QListWidget { background: #2a2a2a; color: #fff; border: 1px solid #555; }") - self.tags_list.itemSelectionChanged.connect(self._apply_filters) - layout.addWidget(self.tags_list) - - # Add spacing between Tags and Mods sections - layout.addSpacing(8) - - # Mod filter - TEMPORARILY DISABLED (not working correctly in v0.2.0.8) - # TODO: Re-enable once mod search index issue is resolved - # mods_label = QLabel("Mods:") - # layout.addWidget(mods_label) - # - # self.mod_search = QLineEdit() - # self.mod_search.setPlaceholderText("Search mods...") - # self.mod_search.setStyleSheet("QLineEdit { background: #2a2a2a; color: #fff; border: 1px solid #555; padding: 4px; }") - # self.mod_search.textChanged.connect(self._filter_mods_list) - # # Prevent Enter from triggering default button (which would close dialog) - # self.mod_search.returnPressed.connect(lambda: self.mod_search.clearFocus()) - # layout.addWidget(self.mod_search) - # - # self.mods_list = QListWidget() - # self.mods_list.setSelectionMode(QListWidget.MultiSelection) - # self.mods_list.setMaximumHeight(150) - # self.mods_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Remove horizontal scrollbar - # self.mods_list.setStyleSheet("QListWidget { background: #2a2a2a; color: #fff; border: 1px solid #555; }") - # self.mods_list.itemSelectionChanged.connect(self._apply_filters) - # layout.addWidget(self.mods_list) - # - # self.all_mods_list = [] # Store all mods for filtering - - layout.addStretch() - - # Cancel button (not default to prevent Enter from closing) - cancel_btn = QPushButton("Cancel") - cancel_btn.setDefault(False) - cancel_btn.setAutoDefault(False) - cancel_btn.clicked.connect(self.reject) - layout.addWidget(cancel_btn) - - panel.setLayout(layout) - return panel - def _create_content_area(self) -> QWidget: """Create modlist grid content area""" container = QWidget() @@ -991,7 +128,7 @@ class ModlistGalleryDialog(QDialog): self.grid_widget = QWidget() # Don't use WA_StaticContents - we need resize events to recalculate columns self.grid_layout = QGridLayout() - self.grid_layout.setSpacing(8) # Reduced from 12 to 8 for tighter card spacing + self.grid_layout.setSpacing(8) self.grid_layout.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.grid_widget.setLayout(self.grid_layout) @@ -1001,510 +138,6 @@ class ModlistGalleryDialog(QDialog): container.setLayout(layout) return container - def _load_modlists_async(self): - """Load modlists in background thread for instant dialog appearance""" - from PySide6.QtCore import QThread, Signal - from PySide6.QtGui import QFont - - # Hide status label during loading (popup dialog will show instead) - self.status_label.setVisible(False) - - # Show loading overlay directly in content area (simpler than separate dialog) - self._loading_overlay = QWidget(self.content_area) - self._loading_overlay.setStyleSheet(""" - QWidget { - background-color: rgba(35, 35, 35, 240); - border-radius: 8px; - } - """) - overlay_layout = QVBoxLayout() - overlay_layout.setContentsMargins(30, 20, 30, 20) - overlay_layout.setSpacing(12) - - self._loading_label = QLabel("Loading modlists") - self._loading_label.setAlignment(Qt.AlignCenter) - # Set fixed width to prevent text shifting when dots animate - # Width accommodates "Loading modlists..." (longest version) - self._loading_label.setFixedWidth(220) - font = QFont() - font.setPointSize(14) - font.setBold(True) - self._loading_label.setFont(font) - self._loading_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 14px; font-weight: bold;") - overlay_layout.addWidget(self._loading_label) - - self._loading_overlay.setLayout(overlay_layout) - self._loading_overlay.setFixedSize(300, 120) - - # Animate dots in loading message - self._loading_dot_count = 0 - self._loading_dot_timer = QTimer() - self._loading_dot_timer.timeout.connect(self._animate_loading_dots) - self._loading_dot_timer.start(500) # Update every 500ms - - # Position overlay in center of content area - def position_overlay(): - if hasattr(self, 'content_area') and self.content_area.isVisible(): - content_width = self.content_area.width() - content_height = self.content_area.height() - x = (content_width - 300) // 2 - y = (content_height - 120) // 2 - self._loading_overlay.move(x, y) - self._loading_overlay.show() - self._loading_overlay.raise_() - - # Delay slightly to ensure content_area is laid out - QTimer.singleShot(50, position_overlay) - - class ModlistLoaderThread(QThread): - """Background thread to load modlist metadata""" - finished = Signal(object, object) # metadata_response, error_message - - def __init__(self, gallery_service): - super().__init__() - self.gallery_service = gallery_service - - def run(self): - try: - import time - start_time = time.time() - - # Fetch metadata (CPU-intensive work happens here in background) - # Skip search index initially for faster loading - can be loaded later if user searches - metadata_response = self.gallery_service.fetch_modlist_metadata( - include_validation=False, - include_search_index=False, # Skip for faster initial load - sort_by="title" - ) - - elapsed = time.time() - start_time - import logging - logger = logging.getLogger(__name__) - if elapsed < 0.5: - logger.debug(f"Gallery metadata loaded from cache in {elapsed:.2f}s") - else: - logger.info(f"Gallery metadata fetched from engine in {elapsed:.2f}s") - - self.finished.emit(metadata_response, None) - except Exception as e: - self.finished.emit(None, str(e)) - - # Create and start background thread - self._loader_thread = ModlistLoaderThread(self.gallery_service) - self._loader_thread.finished.connect(self._on_modlists_loaded) - self._loader_thread.start() - - def _animate_loading_dots(self): - """Animate dots in loading message""" - if hasattr(self, '_loading_label') and self._loading_label: - self._loading_dot_count = (self._loading_dot_count + 1) % 4 - dots = "." * self._loading_dot_count - # Pad with spaces to keep text width constant (prevents shifting) - padding = " " * (3 - self._loading_dot_count) - self._loading_label.setText(f"Loading modlists{dots}{padding}") - - def _on_modlists_loaded(self, metadata_response, error_message): - """Handle modlist metadata loaded in background thread (runs in GUI thread)""" - import random - from PySide6.QtGui import QFont - - # Stop animation timer and close loading overlay - if hasattr(self, '_loading_dot_timer') and self._loading_dot_timer: - self._loading_dot_timer.stop() - self._loading_dot_timer = None - - if hasattr(self, '_loading_overlay') and self._loading_overlay: - self._loading_overlay.hide() - self._loading_overlay.deleteLater() - self._loading_overlay = None - - self.status_label.setVisible(True) - - if error_message: - self.status_label.setText(f"Error loading modlists: {error_message}") - return - - if not metadata_response: - self.status_label.setText("Failed to load modlists") - return - - try: - # Get all modlists - all_modlists = metadata_response.modlists - - # RANDOMIZE the order each time gallery opens (like Wabbajack) - random.shuffle(all_modlists) - - self.all_modlists = all_modlists - - # Precompute normalized tags for display/filtering - for modlist in self.all_modlists: - normalized_display = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', [])) - modlist.normalized_tags_display = normalized_display - modlist.normalized_tags_keys = [tag.lower() for tag in normalized_display] - - # Temporarily disconnect to prevent triggering during setup - self.game_combo.currentIndexChanged.disconnect(self._apply_filters) - - # Populate game filter - games = sorted(set(m.gameHumanFriendly for m in self.all_modlists)) - for game in games: - self.game_combo.addItem(game, game) - - # If dialog was opened with a game filter, pre-select it - if self.game_filter: - index = self.game_combo.findData(self.game_filter) - if index >= 0: - self.game_combo.setCurrentIndex(index) - - # Populate tag filter (mod filter temporarily disabled) - self._populate_tag_filter() - # self._populate_mod_filter() # TEMPORARILY DISABLED - - # Create cards immediately (will show placeholders for images not in cache) - self._create_all_cards() - - # Preload cached images in background (non-blocking) - self.status_label.setText("Loading images...") - QTimer.singleShot(0, self._preload_cached_images_async) - - # Reconnect filter handler - self.game_combo.currentIndexChanged.connect(self._apply_filters) - - # Enable filter controls now that data is loaded - self._set_filter_controls_enabled(True) - - # Apply filters (will show all modlists for selected game initially) - self._apply_filters() - - # Start background validation update (non-blocking) - self._start_validation_update() - - except Exception as e: - self.status_label.setText(f"Error processing modlists: {str(e)}") - - def _load_modlists(self): - """DEPRECATED: Synchronous loading - replaced by _load_modlists_async()""" - from PySide6.QtWidgets import QApplication - - self.status_label.setText("Loading modlists...") - QApplication.processEvents() # Update UI immediately - - # Fetch metadata (will use cache if valid) - # Skip validation initially for faster loading - can be added later if needed - try: - metadata_response = self.gallery_service.fetch_modlist_metadata( - include_validation=False, # Skip validation for faster initial load - include_search_index=True, # Include mod search index for mod filtering - sort_by="title" - ) - - if metadata_response: - # Get all modlists - all_modlists = metadata_response.modlists - - # RANDOMIZE the order each time gallery opens (like Wabbajack) - # This prevents authors from gaming the system with alphabetical ordering - random.shuffle(all_modlists) - - self.all_modlists = all_modlists - - # Precompute normalized tags for display/filtering (matches upstream Wabbajack) - for modlist in self.all_modlists: - normalized_display = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', [])) - modlist.normalized_tags_display = normalized_display - modlist.normalized_tags_keys = [tag.lower() for tag in normalized_display] - - # Temporarily disconnect to prevent triggering during setup - self.game_combo.currentIndexChanged.disconnect(self._apply_filters) - - # Populate game filter - games = sorted(set(m.gameHumanFriendly for m in self.all_modlists)) - for game in games: - self.game_combo.addItem(game, game) - - # If dialog was opened with a game filter, pre-select it - if self.game_filter: - index = self.game_combo.findData(self.game_filter) - if index >= 0: - self.game_combo.setCurrentIndex(index) - - # Populate tag filter (mod filter temporarily disabled) - self._populate_tag_filter() - # self._populate_mod_filter() # TEMPORARILY DISABLED - - # Create cards immediately (will show placeholders for images not in cache) - self._create_all_cards() - - # Preload cached images in background (non-blocking) - # Images will appear as they're loaded - self.status_label.setText("Loading images...") - QTimer.singleShot(0, self._preload_cached_images_async) - - # Reconnect filter handler - self.game_combo.currentIndexChanged.connect(self._apply_filters) - - # Apply filters (will show all modlists for selected game initially) - self._apply_filters() - - # Start background validation update (non-blocking) - self._start_validation_update() - else: - self.status_label.setText("Failed to load modlists") - except Exception as e: - self.status_label.setText(f"Error loading modlists: {str(e)}") - - def _preload_cached_images_async(self): - """Preload cached images asynchronously - images appear as they load""" - from PySide6.QtWidgets import QApplication - - preloaded = 0 - total = len(self.all_modlists) - - for idx, modlist in enumerate(self.all_modlists): - cache_key = modlist.machineURL - - # Skip if already in cache - if cache_key in self.image_manager.pixmap_cache: - continue - - # Preload large images for cards (scale down for better quality) - cached_path = self.gallery_service.get_cached_image_path(modlist, "large") - if cached_path and cached_path.exists(): - try: - pixmap = QPixmap(str(cached_path)) - if not pixmap.isNull(): - cache_key_large = f"{cache_key}_large" - self.image_manager.pixmap_cache[cache_key_large] = pixmap - preloaded += 1 - - # Update card immediately if it exists - card = self.all_cards.get(cache_key) - if card: - card._display_image(pixmap) - except Exception: - pass - - # Process events every 10 images to keep UI responsive - if idx % 10 == 0 and idx > 0: - QApplication.processEvents() - - # Update status (subtle, user-friendly) - modlist_count = len(self.filtered_modlists) - if modlist_count == 1: - self.status_label.setText("1 modlist") - else: - self.status_label.setText(f"{modlist_count} modlists") - - def _populate_tag_filter(self): - """Populate tag filter with normalized tags (like Wabbajack)""" - normalized_tags = set() - for modlist in self.all_modlists: - display_tags = getattr(modlist, 'normalized_tags_display', None) - if display_tags is None: - display_tags = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', [])) - modlist.normalized_tags_display = display_tags - modlist.normalized_tags_keys = [tag.lower() for tag in display_tags] - normalized_tags.update(display_tags) - - # Add special tags (like Wabbajack) - normalized_tags.add("NSFW") - normalized_tags.add("Featured") # Official - normalized_tags.add("Unavailable") - - self.tags_list.clear() - for tag in sorted(normalized_tags): - self.tags_list.addItem(tag) - - def _get_normalized_tag_display(self, modlist: ModlistMetadata) -> List[str]: - """Return (and cache) normalized tags for display for a modlist.""" - display_tags = getattr(modlist, 'normalized_tags_display', None) - if display_tags is None: - display_tags = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', [])) - modlist.normalized_tags_display = display_tags - modlist.normalized_tags_keys = [tag.lower() for tag in display_tags] - return display_tags - - def _get_normalized_tag_keys(self, modlist: ModlistMetadata) -> List[str]: - """Return (and cache) lowercase normalized tags for filtering.""" - keys = getattr(modlist, 'normalized_tags_keys', None) - if keys is None: - display_tags = self._get_normalized_tag_display(modlist) - keys = [tag.lower() for tag in display_tags] - modlist.normalized_tags_keys = keys - return keys - - def _tag_in_modlist(self, modlist: ModlistMetadata, normalized_tag_key: str) -> bool: - """Check if a normalized (lowercase) tag is present on a modlist.""" - keys = self._get_normalized_tag_keys(modlist) - return any(key == normalized_tag_key for key in keys) - - def _populate_mod_filter(self): - """Populate mod filter with all available mods from search index""" - # TEMPORARILY DISABLED - mod filter feature removed in v0.2.0.8 - return - - # all_mods = set() - # # Track which mods come from NSFW modlists only - # mods_from_nsfw_only = set() - # mods_from_sfw = set() - # modlists_with_mods = 0 - # - # for modlist in self.all_modlists: - # if hasattr(modlist, 'mods') and modlist.mods: - # modlists_with_mods += 1 - # for mod in modlist.mods: - # all_mods.add(mod) - # if modlist.nsfw: - # mods_from_nsfw_only.add(mod) - # else: - # mods_from_sfw.add(mod) - # - # # Mods that are ONLY in NSFW modlists (not in any SFW modlists) - # self.nsfw_only_mods = mods_from_nsfw_only - mods_from_sfw - # - # self.all_mods_list = sorted(all_mods) - # - # self._filter_mods_list("") # Populate with all mods initially - - def _filter_mods_list(self, search_text: str = ""): - """Filter the mods list based on search text and NSFW checkbox""" - # TEMPORARILY DISABLED - mod filter feature removed in v0.2.0.8 - return - - # Get search text from the widget if not provided - # if not search_text and hasattr(self, 'mod_search'): - # search_text = self.mod_search.text() - # - # self.mods_list.clear() - # search_lower = search_text.lower().strip() - # - # # Start with all mods or filtered by search - # if search_lower: - # filtered_mods = [m for m in self.all_mods_list if search_lower in m.lower()] - # else: - # filtered_mods = self.all_mods_list - # - # # Filter out NSFW-only mods if NSFW checkbox is not checked - # if not self.show_nsfw.isChecked(): - # filtered_mods = [m for m in filtered_mods if m not in getattr(self, 'nsfw_only_mods', set())] - # - # # Limit to first 500 results for performance - # for mod in filtered_mods[:500]: - # self.mods_list.addItem(mod) - # - # if len(filtered_mods) > 500: - # self.mods_list.addItem(f"... and {len(filtered_mods) - 500} more (refine search)") - - def _on_nsfw_toggled(self, checked: bool): - """Handle NSFW checkbox toggle - refresh mod list and apply filters""" - # self._filter_mods_list() # TEMPORARILY DISABLED - Refresh mod list based on NSFW state - self._apply_filters() # Apply all filters - - def _set_filter_controls_enabled(self, enabled: bool): - """Enable or disable all filter controls""" - self.search_box.setEnabled(enabled) - self.game_combo.setEnabled(enabled) - self.show_official_only.setEnabled(enabled) - self.show_nsfw.setEnabled(enabled) - self.hide_unavailable.setEnabled(enabled) - self.tags_list.setEnabled(enabled) - # self.mod_search.setEnabled(enabled) # TEMPORARILY DISABLED - # self.mods_list.setEnabled(enabled) # TEMPORARILY DISABLED - - def _apply_filters(self): - """Apply current filters to modlist display""" - # CRITICAL: Guard against race condition - don't filter if modlists aren't loaded yet - if not self.all_modlists: - return - - filtered = self.all_modlists - - # Search filter - search_text = self.search_box.text().strip() - if search_text: - filtered = [m for m in filtered if self._matches_search(m, search_text)] - - # Game filter - game = self.game_combo.currentData() - if game: - filtered = [m for m in filtered if m.gameHumanFriendly == game] - - # Status filters - if self.show_official_only.isChecked(): - filtered = [m for m in filtered if m.official] - - if not self.show_nsfw.isChecked(): - filtered = [m for m in filtered if not m.nsfw] - - if self.hide_unavailable.isChecked(): - filtered = [m for m in filtered if m.is_available()] - - # Tag filter - modlist must have ALL selected tags (normalized like Wabbajack) - selected_tags = [item.text() for item in self.tags_list.selectedItems()] - if selected_tags: - special_selected = {tag for tag in selected_tags if tag in ("NSFW", "Featured", "Unavailable")} - normalized_selected = [ - self.gallery_service.normalize_tag_value(tag).lower() - for tag in selected_tags - if tag not in special_selected - ] - - if "NSFW" in special_selected: - filtered = [m for m in filtered if m.nsfw] - if "Featured" in special_selected: - filtered = [m for m in filtered if m.official] - if "Unavailable" in special_selected: - filtered = [m for m in filtered if not m.is_available()] - - if normalized_selected: - filtered = [ - m for m in filtered - if all( - self._tag_in_modlist(m, normalized_tag) - for normalized_tag in normalized_selected - ) - ] - - # Mod filter - TEMPORARILY DISABLED (not working correctly in v0.2.0.8) - # selected_mods = [item.text() for item in self.mods_list.selectedItems()] - # if selected_mods: - # filtered = [m for m in filtered if m.mods and all(mod in m.mods for mod in selected_mods)] - - self.filtered_modlists = filtered - self._update_grid() - - def _matches_search(self, modlist: ModlistMetadata, query: str) -> bool: - """Check if modlist matches search query""" - query_lower = query.lower() - return ( - query_lower in modlist.title.lower() or - query_lower in modlist.description.lower() or - query_lower in modlist.author.lower() - ) - - def _create_all_cards(self): - """Create cards for all modlists and store in dict""" - # Clear existing cards - self.all_cards.clear() - - # Disable updates during card creation to prevent individual renders - self.grid_widget.setUpdatesEnabled(False) - self.setUpdatesEnabled(False) - - try: - # Create all cards - images should be in memory cache from preload - # so _load_image() will find them instantly - for modlist in self.all_modlists: - card = ModlistCard(modlist, self.image_manager, is_steamdeck=self.is_steamdeck) - card.clicked.connect(self._on_modlist_clicked) - self.all_cards[modlist.machineURL] = card - finally: - # Re-enable updates - single render for all cards - self.setUpdatesEnabled(True) - self.grid_widget.setUpdatesEnabled(True) - self.grid_widget.update() - def _update_grid(self): """Update grid by removing all cards and re-adding only visible ones""" # CRITICAL: Guard against race condition - don't update if cards aren't ready yet @@ -1615,62 +248,6 @@ class ModlistGalleryDialog(QDialog): self.modlist_selected.emit(metadata) self.accept() - def _refresh_metadata(self): - """Force refresh metadata from jackify-engine""" - self.status_label.setText("Refreshing metadata...") - self.gallery_service.clear_cache() - self._load_modlists() - - def _start_validation_update(self): - """Start background validation update to get availability status""" - # Update validation in background thread to avoid blocking UI - class ValidationUpdateThread(QThread): - finished_signal = Signal(object) # Emits updated metadata response - - def __init__(self, gallery_service): - super().__init__() - self.gallery_service = gallery_service - - def run(self): - try: - # Fetch with validation (slower, but in background) - metadata_response = self.gallery_service.fetch_modlist_metadata( - include_validation=True, - include_search_index=False, - sort_by="title" - ) - self.finished_signal.emit(metadata_response) - except Exception: - self.finished_signal.emit(None) - - self._validation_thread = ValidationUpdateThread(self.gallery_service) - self._validation_thread.finished_signal.connect(self._on_validation_updated) - self._validation_thread.start() - - def _on_validation_updated(self, metadata_response): - """Update modlists with validation data when background fetch completes""" - if not metadata_response: - return - - # Create lookup dict for validation data - validation_map = {} - for modlist in metadata_response.modlists: - if modlist.validation: - validation_map[modlist.machineURL] = modlist.validation - - # Update existing modlists with validation data - updated_count = 0 - for modlist in self.all_modlists: - if modlist.machineURL in validation_map: - modlist.validation = validation_map[modlist.machineURL] - updated_count += 1 - - # Update card if it exists - card = self.all_cards.get(modlist.machineURL) - if card: - # Update unavailable badge visibility - card._update_availability_badge() - - # Re-apply filters to update availability filtering - if updated_count > 0: - self._apply_filters() +# Re-export for backward compatibility +__all__ = ['ImageManager', 'ModlistCard', 'ModlistDetailDialog', 'ModlistGalleryDialog'] + diff --git a/jackify/frontends/gui/screens/modlist_gallery_card.py b/jackify/frontends/gui/screens/modlist_gallery_card.py new file mode 100644 index 0000000..6651d6b --- /dev/null +++ b/jackify/frontends/gui/screens/modlist_gallery_card.py @@ -0,0 +1,208 @@ +"""Visual card representing a single modlist.""" +from PySide6.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy +from PySide6.QtCore import Qt, Signal, QSize +from PySide6.QtGui import QPixmap, QPainter, QColor, QFont +from jackify.backend.models.modlist_metadata import ModlistMetadata +from ..shared_theme import JACKIFY_COLOR_BLUE +from .modlist_gallery_image_manager import ImageManager + +class ModlistCard(QFrame): + """Visual card representing a single modlist""" + clicked = Signal(ModlistMetadata) + + def __init__(self, metadata: ModlistMetadata, image_manager: ImageManager, is_steamdeck: bool = False): + super().__init__() + self.metadata = metadata + self.image_manager = image_manager + self.is_steamdeck = is_steamdeck + self._setup_ui() + + def _setup_ui(self): + """Set up the card UI""" + self.setFrameShape(QFrame.StyledPanel) + self.setFrameShadow(QFrame.Raised) + self.setCursor(Qt.PointingHandCursor) + + # Steam Deck-specific sizing (1280x800 screen) + if self.is_steamdeck: + self.setFixedSize(250, 270) # Smaller cards for Steam Deck + image_width, image_height = 230, 130 # Smaller images, maintaining 16:9 ratio + else: + self.setFixedSize(300, 320) # Standard size + image_width, image_height = 280, 158 # Standard image size + + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + layout = QVBoxLayout() + layout.setContentsMargins(10, 8, 10, 8) # Reduced vertical margins + layout.setSpacing(6) # Reduced spacing between elements + + # Image (widescreen aspect ratio like Wabbajack) + self.image_label = QLabel() + self.image_label.setFixedSize(image_width, image_height) # 16:9 aspect ratio + self.image_label.setStyleSheet("background: #333; border-radius: 4px;") + self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setScaledContents(True) # Use Qt's automatic scaling - this works best + self.image_label.setText("") + layout.addWidget(self.image_label) + + # Title row with badges (Official, NSFW, UNAVAILABLE) + title_row = QHBoxLayout() + title_row.setSpacing(4) + + title = QLabel(self.metadata.title) + title.setWordWrap(True) + title.setFont(QFont("Sans", 12, QFont.Bold)) + title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};") + title.setMaximumHeight(40) + title_row.addWidget(title, stretch=1) + + # Store reference to unavailable badge for dynamic updates + self.unavailable_badge = None + if not self.metadata.is_available(): + self.unavailable_badge = QLabel("UNAVAILABLE") + self.unavailable_badge.setStyleSheet("background: #666; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;") + self.unavailable_badge.setFixedHeight(20) + title_row.addWidget(self.unavailable_badge, alignment=Qt.AlignTop | Qt.AlignRight) + + if self.metadata.official: + official_badge = QLabel("OFFICIAL") + official_badge.setStyleSheet("background: #2a5; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;") + official_badge.setFixedHeight(20) + title_row.addWidget(official_badge, alignment=Qt.AlignTop | Qt.AlignRight) + + if self.metadata.nsfw: + nsfw_badge = QLabel("NSFW") + nsfw_badge.setStyleSheet("background: #d44; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;") + nsfw_badge.setFixedHeight(20) + title_row.addWidget(nsfw_badge, alignment=Qt.AlignTop | Qt.AlignRight) + + layout.addLayout(title_row) + + # Author + author = QLabel(f"by {self.metadata.author}") + author.setStyleSheet("color: #aaa; font-size: 11px;") + layout.addWidget(author) + + # Game + game = QLabel(self.metadata.gameHumanFriendly) + game.setStyleSheet("color: #ccc; font-size: 10px;") + layout.addWidget(game) + + # Sizes (Download, Install, Total) + if self.metadata.sizes: + size_info = QLabel( + f"Download: {self.metadata.sizes.downloadSizeFormatted} | " + f"Install: {self.metadata.sizes.installSizeFormatted} | " + f"Total: {self.metadata.sizes.totalSizeFormatted}" + ) + size_info.setStyleSheet("color: #999; font-size: 10px;") + size_info.setWordWrap(True) # Allow wrapping if text is too long + layout.addWidget(size_info) + + # Removed addStretch() to eliminate wasted space + self.setLayout(layout) + + # Load image + self._load_image() + + def _create_placeholder(self): + """Create a placeholder pixmap for cards without images""" + # Create placeholder matching the image label size (Steam Deck or standard) + image_size = self.image_label.size() + placeholder = QPixmap(image_size) + placeholder.fill(QColor("#333")) + + # Draw a simple icon/text on the placeholder + painter = QPainter(placeholder) + painter.setPen(QColor("#666")) + painter.setFont(QFont("Sans", 10)) + painter.drawText(placeholder.rect(), Qt.AlignCenter, "No Image") + painter.end() + + # Show placeholder immediately + self.image_label.setPixmap(placeholder) + + def _load_image(self): + """Load image using centralized image manager - use large images and scale down for quality""" + # Get large image for card - scale down for better quality than small images + pixmap = self.image_manager.get_image(self.metadata, self._on_image_loaded, size="large") + + if pixmap and not pixmap.isNull(): + # Image was in cache - display immediately (should be instant) + self._display_image(pixmap) + else: + # Image needs to be downloaded - show placeholder + self._create_placeholder() + + def _on_image_loaded(self, pixmap: QPixmap): + """Callback when image is loaded from network""" + if pixmap and not pixmap.isNull(): + self._display_image(pixmap) + + def _display_image(self, pixmap: QPixmap): + """Display image - use best method based on aspect ratio""" + if pixmap.isNull(): + return + + label_size = self.image_label.size() + label_aspect = label_size.width() / label_size.height() # 16:9 = ~1.778 + + # Calculate image aspect ratio + image_aspect = pixmap.width() / pixmap.height() if pixmap.height() > 0 else label_aspect + + # If aspect ratios are close (within 5%), use Qt's automatic scaling for best quality + # Otherwise, manually scale with cropping to avoid stretching + aspect_diff = abs(image_aspect - label_aspect) / label_aspect + + if aspect_diff < 0.05: # Within 5% of 16:9 + # Close to correct aspect - use Qt's automatic scaling (best quality) + self.image_label.setScaledContents(True) + self.image_label.setPixmap(pixmap) + else: + # Different aspect - manually scale with cropping (no stretching) + self.image_label.setScaledContents(False) + scaled_pixmap = pixmap.scaled( + label_size.width(), + label_size.height(), + Qt.KeepAspectRatioByExpanding, # Crop instead of stretch + Qt.SmoothTransformation # High quality + ) + self.image_label.setPixmap(scaled_pixmap) + + def _update_availability_badge(self): + """Update unavailable badge visibility based on current availability status""" + is_unavailable = not self.metadata.is_available() + + # Find title row layout (it's the 2nd layout item: image at 0, title_row at 1) + main_layout = self.layout() + if main_layout and main_layout.count() >= 2: + title_row = main_layout.itemAt(1).layout() + if title_row: + if is_unavailable and self.unavailable_badge is None: + # Need to add badge to title row (before Official/NSFW badges) + self.unavailable_badge = QLabel("UNAVAILABLE") + self.unavailable_badge.setStyleSheet("background: #666; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;") + self.unavailable_badge.setFixedHeight(20) + # Insert after title (index 1) but before other badges + # Find first badge position (if any exist) + insert_index = 1 # After title widget + for i in range(title_row.count()): + item = title_row.itemAt(i) + if item and item.widget() and isinstance(item.widget(), QLabel): + widget_text = item.widget().text() + if widget_text in ("OFFICIAL", "NSFW"): + insert_index = i + break + title_row.insertWidget(insert_index, self.unavailable_badge, alignment=Qt.AlignTop | Qt.AlignRight) + elif not is_unavailable and self.unavailable_badge is not None: + # Need to remove badge from title row + title_row.removeWidget(self.unavailable_badge) + self.unavailable_badge.setParent(None) + self.unavailable_badge = None + + def mousePressEvent(self, event): + """Handle click on card""" + if event.button() == Qt.LeftButton: + self.clicked.emit(self.metadata) + super().mousePressEvent(event) diff --git a/jackify/frontends/gui/screens/modlist_gallery_detail.py b/jackify/frontends/gui/screens/modlist_gallery_detail.py new file mode 100644 index 0000000..3792067 --- /dev/null +++ b/jackify/frontends/gui/screens/modlist_gallery_detail.py @@ -0,0 +1,451 @@ +"""Detailed view of a modlist with install option.""" +from PySide6.QtWidgets import ( + QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QFrame, QTextEdit, QSizePolicy +) +from PySide6.QtCore import Qt, Signal, QSize +from PySide6.QtGui import QPixmap, QFont, QPainter, QColor +from jackify.backend.models.modlist_metadata import ModlistMetadata +from ..shared_theme import JACKIFY_COLOR_BLUE +from ..utils import get_screen_geometry, set_responsive_minimum +from .modlist_gallery_image_manager import ImageManager + +class ModlistDetailDialog(QDialog): + """Detailed view of a modlist with install option""" + install_requested = Signal(ModlistMetadata) + + def __init__(self, metadata: ModlistMetadata, image_manager: ImageManager, parent=None): + super().__init__(parent) + self.metadata = metadata + self.image_manager = image_manager + self.setWindowTitle(metadata.title) + set_responsive_minimum(self, min_width=900, min_height=640) + self._apply_initial_size() + self._setup_ui() + + def _apply_initial_size(self): + """Ensure dialog size fits current screen.""" + _, _, screen_width, screen_height = get_screen_geometry(self) + width = 1000 + height = 760 + if screen_width: + width = min(width, max(880, screen_width - 40)) + if screen_height: + height = min(height, max(640, screen_height - 40)) + self.resize(width, height) + + def _setup_ui(self): + """Set up detail dialog UI with modern layout matching Wabbajack style""" + main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + # --- Banner area with full-width text overlay --- + # Container so we can place a semi-opaque text panel over the banner image + banner_container = QFrame() + banner_container.setFrameShape(QFrame.NoFrame) + banner_container.setStyleSheet("background: #000; border: none;") + banner_layout = QVBoxLayout() + banner_layout.setContentsMargins(0, 0, 0, 0) + banner_layout.setSpacing(0) + banner_container.setLayout(banner_layout) + + # Banner image at top with 16:9 aspect ratio (like Wabbajack) + self.banner_label = QLabel() + # Height will be calculated based on width to maintain 16:9 ratio + self.banner_label.setMinimumHeight(200) + self.banner_label.setStyleSheet("background: #1a1a1a; border: none;") + self.banner_label.setAlignment(Qt.AlignCenter) + self.banner_label.setText("Loading image...") + banner_layout.addWidget(self.banner_label) + + # Full-width transparent container with opaque card inside (only as wide as text) + overlay_container = QWidget() + overlay_container.setStyleSheet("background: transparent;") + overlay_layout = QHBoxLayout() + overlay_layout.setContentsMargins(24, 0, 24, 24) + overlay_layout.setSpacing(0) + overlay_container.setLayout(overlay_layout) + + # Opaque text card - only as wide as content needs (where red lines are) + self.banner_text_panel = QFrame() + self.banner_text_panel.setFrameShape(QFrame.StyledPanel) + # Opaque background, rounded corners, sized to content only + self.banner_text_panel.setStyleSheet(""" + QFrame { + background-color: rgba(0, 0, 0, 180); + border: 1px solid rgba(255, 255, 255, 30); + border-radius: 8px; + } + """) + self.banner_text_panel.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) + banner_text_layout = QVBoxLayout() + banner_text_layout.setContentsMargins(20, 12, 20, 14) + banner_text_layout.setSpacing(6) + self.banner_text_panel.setLayout(banner_text_layout) + + # Add card to container (left-aligned, rest stays transparent) + overlay_layout.addWidget(self.banner_text_panel, alignment=Qt.AlignBottom | Qt.AlignLeft) + overlay_layout.addStretch() # Push card left, rest transparent + + # Title only (badges moved to tags section below) + title = QLabel(self.metadata.title) + title.setFont(QFont("Sans", 24, QFont.Bold)) + title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};") + title.setWordWrap(True) + banner_text_layout.addWidget(title) + + # Only sizes in overlay (minimal info on image) + if self.metadata.sizes: + sizes_text = ( + f"Download: {self.metadata.sizes.downloadSizeFormatted} • " + f"Install: {self.metadata.sizes.installSizeFormatted} • " + f"Total: {self.metadata.sizes.totalSizeFormatted}" + ) + sizes_label = QLabel(sizes_text) + sizes_label.setStyleSheet("color: #fff; font-size: 13px;") + banner_text_layout.addWidget(sizes_label) + + # Add full-width transparent container at bottom of banner + banner_layout.addWidget(overlay_container, alignment=Qt.AlignBottom) + main_layout.addWidget(banner_container) + + # Content area with padding (tags + description + bottom bar) + content_widget = QWidget() + content_layout = QVBoxLayout() + content_layout.setContentsMargins(24, 20, 24, 20) + content_layout.setSpacing(16) + content_widget.setLayout(content_layout) + + # Metadata line (version, author, game) - moved below image + metadata_line_parts = [] + if self.metadata.version: + metadata_line_parts.append(f"version {self.metadata.version}") + metadata_line_parts.append(f"by {self.metadata.author}") + metadata_line_parts.append(f" {self.metadata.gameHumanFriendly}") + + if self.metadata.maintainers and len(self.metadata.maintainers) > 0: + maintainers_text = ", ".join(self.metadata.maintainers) + if maintainers_text != self.metadata.author: # Only show if different from author + metadata_line_parts.append(f" Maintained by {maintainers_text}") + + metadata_line = QLabel(" ".join(metadata_line_parts)) + metadata_line.setStyleSheet("color: #fff; font-size: 14px;") + metadata_line.setWordWrap(True) + content_layout.addWidget(metadata_line) + + # Tags row (includes status badges moved from overlay) + tags_layout = QHBoxLayout() + tags_layout.setSpacing(6) + tags_layout.setContentsMargins(0, 0, 0, 0) + + # Add status badges first (UNAVAILABLE, Unofficial) + if not self.metadata.is_available(): + unavailable_badge = QLabel("UNAVAILABLE") + unavailable_badge.setStyleSheet("background: #666; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") + tags_layout.addWidget(unavailable_badge) + + if not self.metadata.official: + unofficial_badge = QLabel("Unofficial") + unofficial_badge.setStyleSheet("background: #666; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") + tags_layout.addWidget(unofficial_badge) + + # Add regular tags + tags_to_render = getattr(self.metadata, 'normalized_tags_display', self.metadata.tags or []) + if tags_to_render: + for tag in tags_to_render: + tag_badge = QLabel(tag) + # Match Wabbajack tag styling + if tag.lower() == "nsfw": + tag_badge.setStyleSheet("background: #d44; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") + elif tag.lower() == "official" or tag.lower() == "featured": + tag_badge.setStyleSheet("background: #2a5; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") + else: + tag_badge.setStyleSheet("background: #3a3a3a; color: #ccc; padding: 6px 12px; font-size: 11px; border-radius: 4px;") + tags_layout.addWidget(tag_badge) + + tags_layout.addStretch() + content_layout.addLayout(tags_layout) + + # Description section + desc_label = QLabel("Description:") + content_layout.addWidget(desc_label) + + # Use QTextEdit with explicit line counting to force scrollbar + self.desc_text = QTextEdit() + self.desc_text.setReadOnly(True) + self.desc_text.setPlainText(self.metadata.description or "No description provided.") + # Compact description area; scroll when content is long + self.desc_text.setFixedHeight(120) + self.desc_text.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.desc_text.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.desc_text.setLineWrapMode(QTextEdit.WidgetWidth) + self.desc_text.setStyleSheet(""" + QTextEdit { + background: #2a2a2a; + color: #fff; + border: none; + border-radius: 6px; + padding: 12px; + } + """) + + content_layout.addWidget(self.desc_text) + + main_layout.addWidget(content_widget) + + # Bottom bar with Links (left) and Action buttons (right) + bottom_bar = QHBoxLayout() + bottom_bar.setContentsMargins(24, 16, 24, 24) + bottom_bar.setSpacing(12) + + # Links section on the left + links_layout = QHBoxLayout() + links_layout.setSpacing(10) + + if self.metadata.links and (self.metadata.links.discordURL or self.metadata.links.websiteURL or self.metadata.links.readme): + links_label = QLabel("Links:") + links_layout.addWidget(links_label) + + if self.metadata.links.discordURL: + discord_btn = QPushButton("Discord") + discord_btn.setStyleSheet(""" + QPushButton { + background: #5865F2; + color: white; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { + background: #4752C4; + } + QPushButton:pressed { + background: #3C45A5; + } + """) + discord_btn.clicked.connect(lambda: self._open_url(self.metadata.links.discordURL)) + links_layout.addWidget(discord_btn) + + if self.metadata.links.websiteURL: + website_btn = QPushButton("Website") + website_btn.setStyleSheet(""" + QPushButton { + background: #3a3a3a; + color: white; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { + background: #4a4a4a; + } + QPushButton:pressed { + background: #2a2a2a; + } + """) + website_btn.clicked.connect(lambda: self._open_url(self.metadata.links.websiteURL)) + links_layout.addWidget(website_btn) + + if self.metadata.links.readme: + readme_btn = QPushButton("Readme") + readme_btn.setStyleSheet(""" + QPushButton { + background: #3a3a3a; + color: white; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { + background: #4a4a4a; + } + QPushButton:pressed { + background: #2a2a2a; + } + """) + readme_url = self._convert_raw_github_url(self.metadata.links.readme) + readme_btn.clicked.connect(lambda: self._open_url(readme_url)) + links_layout.addWidget(readme_btn) + + bottom_bar.addLayout(links_layout) + bottom_bar.addStretch() + + # Action buttons on the right + + cancel_btn = QPushButton("Close") + cancel_btn.setStyleSheet(""" + QPushButton { + background: #3a3a3a; + color: white; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { + background: #4a4a4a; + } + QPushButton:pressed { + background: #2a2a2a; + } + """) + cancel_btn.clicked.connect(self.reject) + bottom_bar.addWidget(cancel_btn) + + install_btn = QPushButton("Install Modlist") + install_btn.setDefault(True) + if not self.metadata.is_available(): + install_btn.setEnabled(False) + install_btn.setToolTip("This modlist is currently unavailable") + install_btn.setStyleSheet(""" + QPushButton { + background: #555; + color: #999; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + """) + else: + install_btn.setStyleSheet(f""" + QPushButton {{ + background: {JACKIFY_COLOR_BLUE}; + color: white; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + }} + QPushButton:hover {{ + background: #4a9eff; + }} + QPushButton:pressed {{ + background: #3a8eef; + }} + """) + install_btn.clicked.connect(self._on_install_clicked) + bottom_bar.addWidget(install_btn) + + main_layout.addLayout(bottom_bar) + self.setLayout(main_layout) + + # Load banner image + self._load_banner_image() + + def _load_banner_image(self): + """Load large banner image for detail view""" + if not self.metadata.images or not self.metadata.images.large: + self.banner_label.setText("No image available") + self.banner_label.setStyleSheet("background: #1a1a1a; color: #666; border: none;") + return + + # Try to get large image from cache or download (for detail view banner) + pixmap = self.image_manager.get_image(self.metadata, self._on_banner_loaded, size="large") + + if pixmap and not pixmap.isNull(): + # Image was in cache - display immediately + self._display_banner(pixmap) + else: + # Show placeholder while downloading + placeholder = QPixmap(self.banner_label.size()) + placeholder.fill(QColor("#1a1a1a")) + painter = QPainter(placeholder) + painter.setPen(QColor("#666")) + painter.setFont(QFont("Sans", 12)) + painter.drawText(placeholder.rect(), Qt.AlignCenter, "Loading image...") + painter.end() + self.banner_label.setPixmap(placeholder) + + def _on_banner_loaded(self, pixmap: QPixmap): + """Callback when banner image is loaded""" + if pixmap and not pixmap.isNull(): + self._display_banner(pixmap) + + def resizeEvent(self, event): + """Handle dialog resize to maintain 16:9 aspect ratio for banner""" + super().resizeEvent(event) + # Update banner height to maintain 16:9 aspect ratio + if hasattr(self, 'banner_label'): + width = self.width() + height = int(width / 16 * 9) # 16:9 aspect ratio + self.banner_label.setFixedHeight(height) + # Redisplay image if we have one + if hasattr(self, '_current_banner_pixmap'): + self._display_banner(self._current_banner_pixmap) + + def _display_banner(self, pixmap: QPixmap): + """Display banner image with proper 16:9 aspect ratio (like Wabbajack)""" + # Store pixmap for resize events + self._current_banner_pixmap = pixmap + + # Calculate 16:9 aspect ratio height + width = self.width() if self.width() > 0 else 1000 + target_height = int(width / 16 * 9) + self.banner_label.setFixedHeight(target_height) + + # Scale image to fill width while maintaining aspect ratio (UniformToFill behavior) + # Crops if needed, no stretch + scaled_pixmap = pixmap.scaled( + width, + target_height, + Qt.KeepAspectRatioByExpanding, # Fill the area, cropping if needed + Qt.SmoothTransformation + ) + self.banner_label.setPixmap(scaled_pixmap) + self.banner_label.setText("") + + def _convert_raw_github_url(self, url: str) -> str: + """Convert raw GitHub URLs to rendered blob URLs for better user experience""" + if not url: + return url + + if "raw.githubusercontent.com" in url: + url = url.replace("raw.githubusercontent.com", "github.com") + url = url.replace("/master/", "/blob/master/") + url = url.replace("/main/", "/blob/main/") + + return url + + def _on_install_clicked(self): + """Handle install button click""" + self.install_requested.emit(self.metadata) + self.accept() + + def _open_url(self, url: str): + """Open URL with clean environment to avoid AppImage library conflicts.""" + import subprocess + import os + + env = os.environ.copy() + + # Remove AppImage-specific environment variables + appimage_vars = [ + 'LD_LIBRARY_PATH', + 'PYTHONPATH', + 'PYTHONHOME', + 'QT_PLUGIN_PATH', + 'QML2_IMPORT_PATH', + ] + + if 'APPIMAGE' in env or 'APPDIR' in env: + for var in appimage_vars: + if var in env: + del env[var] + + subprocess.Popen( + ['xdg-open', url], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) diff --git a/jackify/frontends/gui/screens/modlist_gallery_filters.py b/jackify/frontends/gui/screens/modlist_gallery_filters.py new file mode 100644 index 0000000..a158170 --- /dev/null +++ b/jackify/frontends/gui/screens/modlist_gallery_filters.py @@ -0,0 +1,302 @@ +"""Filter management for ModlistGalleryDialog (Mixin).""" +from PySide6.QtWidgets import QWidget, QFrame, QVBoxLayout, QLabel, QLineEdit, QComboBox, QCheckBox, QListWidget, QPushButton +from PySide6.QtCore import Qt +from typing import List +from jackify.backend.models.modlist_metadata import ModlistMetadata +from ..shared_theme import JACKIFY_COLOR_BLUE + + +class ModlistGalleryFiltersMixin: + """Mixin providing filter management for ModlistGalleryDialog.""" + + def _create_filter_panel(self) -> QWidget: + """Create filter sidebar""" + panel = QFrame() + panel.setFrameShape(QFrame.StyledPanel) + panel.setFixedWidth(280) # Slightly wider for better readability + + layout = QVBoxLayout() + layout.setSpacing(6) + + # Title + title = QLabel("Filters") + title.setStyleSheet(f"font-size: 14px; color: {JACKIFY_COLOR_BLUE};") + layout.addWidget(title) + + # Search box (label removed - placeholder text is clear enough) + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("Search modlists...") + self.search_box.setStyleSheet("QLineEdit { background: #2a2a2a; color: #fff; border: 1px solid #555; padding: 4px; }") + self.search_box.textChanged.connect(self._apply_filters) + layout.addWidget(self.search_box) + + # Game filter (label removed - combo box is self-explanatory) + self.game_combo = QComboBox() + self.game_combo.addItem("All Games", None) + self.game_combo.currentIndexChanged.connect(self._apply_filters) + layout.addWidget(self.game_combo) + + # Status filters + self.show_official_only = QCheckBox("Show Official Only") + self.show_official_only.stateChanged.connect(self._apply_filters) + layout.addWidget(self.show_official_only) + + self.show_nsfw = QCheckBox("Show NSFW") + self.show_nsfw.stateChanged.connect(self._on_nsfw_toggled) + layout.addWidget(self.show_nsfw) + + self.hide_unavailable = QCheckBox("Hide Unavailable") + self.hide_unavailable.setChecked(True) + self.hide_unavailable.stateChanged.connect(self._apply_filters) + layout.addWidget(self.hide_unavailable) + + # Tag filter + tags_label = QLabel("Tags:") + layout.addWidget(tags_label) + + self.tags_list = QListWidget() + self.tags_list.setSelectionMode(QListWidget.MultiSelection) + self.tags_list.setMaximumHeight(150) + self.tags_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Remove horizontal scrollbar + self.tags_list.setStyleSheet("QListWidget { background: #2a2a2a; color: #fff; border: 1px solid #555; }") + self.tags_list.itemSelectionChanged.connect(self._apply_filters) + layout.addWidget(self.tags_list) + + # Add spacing between Tags and Mods sections + layout.addSpacing(8) + + # DISABLED: Mod search feature temporarily disabled due to search index issue + # Re-enable after indexing bug is resolved + # The mod search UI allowed filtering modlists by individual mod names + # Disabled in v0.2.0.8 - planned for re-enabling in future release + # mods_label = QLabel("Mods:") + # layout.addWidget(mods_label) + # + # self.mod_search = QLineEdit() + # self.mod_search.setPlaceholderText("Search mods...") + # self.mod_search.setStyleSheet("QLineEdit { background: #2a2a2a; color: #fff; border: 1px solid #555; padding: 4px; }") + # self.mod_search.textChanged.connect(self._filter_mods_list) + # # Prevent Enter from triggering default button (which would close dialog) + # self.mod_search.returnPressed.connect(lambda: self.mod_search.clearFocus()) + # layout.addWidget(self.mod_search) + # + # self.mods_list = QListWidget() + # self.mods_list.setSelectionMode(QListWidget.MultiSelection) + # self.mods_list.setMaximumHeight(150) + # self.mods_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Remove horizontal scrollbar + # self.mods_list.setStyleSheet("QListWidget { background: #2a2a2a; color: #fff; border: 1px solid #555; }") + # self.mods_list.itemSelectionChanged.connect(self._apply_filters) + # layout.addWidget(self.mods_list) + # + # self.all_mods_list = [] # Store all mods for filtering + + layout.addStretch() + + # Cancel button (not default to prevent Enter from closing) + cancel_btn = QPushButton("Cancel") + cancel_btn.setDefault(False) + cancel_btn.setAutoDefault(False) + cancel_btn.clicked.connect(self.reject) + layout.addWidget(cancel_btn) + + panel.setLayout(layout) + return panel + + + def _populate_tag_filter(self): + """Populate tag filter with normalized tags (like Wabbajack)""" + normalized_tags = set() + for modlist in self.all_modlists: + display_tags = getattr(modlist, 'normalized_tags_display', None) + if display_tags is None: + display_tags = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', [])) + modlist.normalized_tags_display = display_tags + modlist.normalized_tags_keys = [tag.lower() for tag in display_tags] + normalized_tags.update(display_tags) + + # Add special tags (like Wabbajack) + normalized_tags.add("NSFW") + normalized_tags.add("Featured") # Official + normalized_tags.add("Unavailable") + + self.tags_list.clear() + for tag in sorted(normalized_tags): + self.tags_list.addItem(tag) + + + def _get_normalized_tag_display(self, modlist: ModlistMetadata) -> List[str]: + """Return (and cache) normalized tags for display for a modlist.""" + display_tags = getattr(modlist, 'normalized_tags_display', None) + if display_tags is None: + display_tags = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', [])) + modlist.normalized_tags_display = display_tags + modlist.normalized_tags_keys = [tag.lower() for tag in display_tags] + return display_tags + + + def _get_normalized_tag_keys(self, modlist: ModlistMetadata) -> List[str]: + """Return (and cache) lowercase normalized tags for filtering.""" + keys = getattr(modlist, 'normalized_tags_keys', None) + if keys is None: + display_tags = self._get_normalized_tag_display(modlist) + keys = [tag.lower() for tag in display_tags] + modlist.normalized_tags_keys = keys + return keys + + + def _tag_in_modlist(self, modlist: ModlistMetadata, normalized_tag_key: str) -> bool: + """Check if a normalized (lowercase) tag is present on a modlist.""" + keys = self._get_normalized_tag_keys(modlist) + return any(key == normalized_tag_key for key in keys) + + + def _populate_mod_filter(self): + """Populate mod filter with all available mods from search index""" + # TEMPORARILY DISABLED - mod filter feature removed in v0.2.0.8 + return + + # all_mods = set() + # # Track which mods come from NSFW modlists only + # mods_from_nsfw_only = set() + # mods_from_sfw = set() + # modlists_with_mods = 0 + # + # for modlist in self.all_modlists: + # if hasattr(modlist, 'mods') and modlist.mods: + # modlists_with_mods += 1 + # for mod in modlist.mods: + # all_mods.add(mod) + # if modlist.nsfw: + # mods_from_nsfw_only.add(mod) + # else: + # mods_from_sfw.add(mod) + # + # # Mods that are ONLY in NSFW modlists (not in any SFW modlists) + # self.nsfw_only_mods = mods_from_nsfw_only - mods_from_sfw + # + # self.all_mods_list = sorted(all_mods) + # + # self._filter_mods_list("") # Populate with all mods initially + + + def _filter_mods_list(self, search_text: str = ""): + """Filter the mods list based on search text and NSFW checkbox""" + # TEMPORARILY DISABLED - mod filter feature removed in v0.2.0.8 + return + + # Get search text from the widget if not provided + # if not search_text and hasattr(self, 'mod_search'): + # search_text = self.mod_search.text() + # + # self.mods_list.clear() + # search_lower = search_text.lower().strip() + # + # # Start with all mods or filtered by search + # if search_lower: + # filtered_mods = [m for m in self.all_mods_list if search_lower in m.lower()] + # else: + # filtered_mods = self.all_mods_list + # + # # Filter out NSFW-only mods if NSFW checkbox is not checked + # if not self.show_nsfw.isChecked(): + # filtered_mods = [m for m in filtered_mods if m not in getattr(self, 'nsfw_only_mods', set())] + # + # # Limit to first 500 results for performance + # for mod in filtered_mods[:500]: + # self.mods_list.addItem(mod) + # + # if len(filtered_mods) > 500: + # self.mods_list.addItem(f"... and {len(filtered_mods) - 500} more (refine search)") + + + def _on_nsfw_toggled(self, checked: bool): + """Handle NSFW checkbox toggle - refresh mod list and apply filters""" + # self._filter_mods_list() # TEMPORARILY DISABLED - Refresh mod list based on NSFW state + self._apply_filters() # Apply all filters + + + def _set_filter_controls_enabled(self, enabled: bool): + """Enable or disable all filter controls""" + self.search_box.setEnabled(enabled) + self.game_combo.setEnabled(enabled) + self.show_official_only.setEnabled(enabled) + self.show_nsfw.setEnabled(enabled) + self.hide_unavailable.setEnabled(enabled) + self.tags_list.setEnabled(enabled) + # self.mod_search.setEnabled(enabled) # TEMPORARILY DISABLED + # self.mods_list.setEnabled(enabled) # TEMPORARILY DISABLED + + + def _apply_filters(self): + """Apply current filters to modlist display""" + # CRITICAL: Guard against race condition - don't filter if modlists aren't loaded yet + if not self.all_modlists: + return + + filtered = self.all_modlists + + # Search filter + search_text = self.search_box.text().strip() + if search_text: + filtered = [m for m in filtered if self._matches_search(m, search_text)] + + # Game filter + game = self.game_combo.currentData() + if game: + filtered = [m for m in filtered if m.gameHumanFriendly == game] + + # Status filters + if self.show_official_only.isChecked(): + filtered = [m for m in filtered if m.official] + + if not self.show_nsfw.isChecked(): + filtered = [m for m in filtered if not m.nsfw] + + if self.hide_unavailable.isChecked(): + filtered = [m for m in filtered if m.is_available()] + + # Tag filter - modlist must have ALL selected tags (normalized like Wabbajack) + selected_tags = [item.text() for item in self.tags_list.selectedItems()] + if selected_tags: + special_selected = {tag for tag in selected_tags if tag in ("NSFW", "Featured", "Unavailable")} + normalized_selected = [ + self.gallery_service.normalize_tag_value(tag).lower() + for tag in selected_tags + if tag not in special_selected + ] + + if "NSFW" in special_selected: + filtered = [m for m in filtered if m.nsfw] + if "Featured" in special_selected: + filtered = [m for m in filtered if m.official] + if "Unavailable" in special_selected: + filtered = [m for m in filtered if not m.is_available()] + + if normalized_selected: + filtered = [ + m for m in filtered + if all( + self._tag_in_modlist(m, normalized_tag) + for normalized_tag in normalized_selected + ) + ] + + # Mod filter - TEMPORARILY DISABLED (not working correctly in v0.2.0.8) + # selected_mods = [item.text() for item in self.mods_list.selectedItems()] + # if selected_mods: + # filtered = [m for m in filtered if m.mods and all(mod in m.mods for mod in selected_mods)] + + self.filtered_modlists = filtered + self._update_grid() + + + def _matches_search(self, modlist: ModlistMetadata, query: str) -> bool: + """Check if modlist matches search query""" + query_lower = query.lower() + return ( + query_lower in modlist.title.lower() or + query_lower in modlist.description.lower() or + query_lower in modlist.author.lower() + ) + + diff --git a/jackify/frontends/gui/screens/modlist_gallery_image_manager.py b/jackify/frontends/gui/screens/modlist_gallery_image_manager.py new file mode 100644 index 0000000..035826d --- /dev/null +++ b/jackify/frontends/gui/screens/modlist_gallery_image_manager.py @@ -0,0 +1,141 @@ +"""Image loading and caching manager for ModlistGalleryDialog.""" +from PySide6.QtCore import QObject, QTimer, QUrl +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from PySide6.QtGui import QPixmap +from typing import Optional, Dict +from collections import deque +from jackify.backend.models.modlist_metadata import ModlistMetadata +from jackify.backend.services.modlist_gallery_service import ModlistGalleryService + +class ImageManager(QObject): + """Centralized image loading and caching manager""" + + def __init__(self, gallery_service: ModlistGalleryService): + super().__init__() + self.gallery_service = gallery_service + self.pixmap_cache: Dict[str, QPixmap] = {} + self.network_manager = QNetworkAccessManager() + self.download_queue = deque() + self.downloading: set = set() + self.max_concurrent = 2 # Start with 2 concurrent downloads to reduce UI lag + self.save_queue = deque() # Queue for deferred disk saves + self._save_timer = None + + def get_image(self, metadata: ModlistMetadata, callback, size: str = "small") -> Optional[QPixmap]: + """ + Get image for modlist - returns cached pixmap or None if needs download + + Args: + metadata: Modlist metadata + callback: Callback function when image is loaded + size: Image size to use ("small" for cards, "large" for detail view) + """ + cache_key = f"{metadata.machineURL}_{size}" + + # Check memory cache first (should be preloaded) + if cache_key in self.pixmap_cache: + return self.pixmap_cache[cache_key] + + # Only check disk cache if not in memory (fallback for images that weren't preloaded) + # Rarely happens if preload worked + cached_path = self.gallery_service.get_cached_image_path(metadata, size) + if cached_path and cached_path.exists(): + try: + pixmap = QPixmap(str(cached_path)) + if not pixmap.isNull(): + self.pixmap_cache[cache_key] = pixmap + return pixmap + except Exception: + pass + + # Queue for download if not cached + if cache_key not in self.downloading: + self.download_queue.append((metadata, callback, size)) + self._process_queue() + + return None + + def _process_queue(self): + """Process download queue up to max_concurrent""" + # Process one at a time with small delays to keep UI responsive + if len(self.downloading) < self.max_concurrent and self.download_queue: + metadata, callback, size = self.download_queue.popleft() + cache_key = f"{metadata.machineURL}_{size}" + + if cache_key not in self.downloading: + self.downloading.add(cache_key) + self._download_image(metadata, callback, size) + + # Schedule next download with small delay to yield to UI + if self.download_queue: + QTimer.singleShot(100, self._process_queue) + + def _download_image(self, metadata: ModlistMetadata, callback, size: str = "small"): + """Download image from network""" + image_url = self.gallery_service.get_image_url(metadata, size) + if not image_url: + cache_key = f"{metadata.machineURL}_{size}" + self.downloading.discard(cache_key) + self._process_queue() + return + + url = QUrl(image_url) + request = QNetworkRequest(url) + request.setRawHeader(b"User-Agent", b"Jackify/0.1.8") + + reply = self.network_manager.get(request) + reply.finished.connect(lambda: self._on_download_finished(reply, metadata, callback, size)) + + def _on_download_finished(self, reply: QNetworkReply, metadata: ModlistMetadata, callback, size: str = "small"): + """Handle download completion""" + from PySide6.QtWidgets import QApplication + + cache_key = f"{metadata.machineURL}_{size}" + self.downloading.discard(cache_key) + + if reply.error() == QNetworkReply.NoError: + image_data = reply.readAll() + pixmap = QPixmap() + if pixmap.loadFromData(image_data) and not pixmap.isNull(): + # Store in memory cache immediately + self.pixmap_cache[cache_key] = pixmap + + # Defer disk save to avoid blocking UI - queue it for later + cached_path = self.gallery_service.get_image_cache_path(metadata, size) + self.save_queue.append((pixmap, cached_path)) + self._start_save_timer() + + # Call callback with pixmap (update UI immediately) + if callback: + callback(pixmap) + + # Process events to keep UI responsive + QApplication.processEvents() + + reply.deleteLater() + + # Process next in queue (with small delay to yield to UI) + QTimer.singleShot(50, self._process_queue) + + def _start_save_timer(self): + """Start timer for deferred disk saves if not already running""" + if self._save_timer is None: + self._save_timer = QTimer() + self._save_timer.timeout.connect(self._save_next_image) + self._save_timer.setSingleShot(False) + self._save_timer.start(200) # Save one image every 200ms + + def _save_next_image(self): + """Save next image from queue to disk (non-blocking)""" + if self.save_queue: + pixmap, cached_path = self.save_queue.popleft() + try: + cached_path.parent.mkdir(parents=True, exist_ok=True) + pixmap.save(str(cached_path), "WEBP") + except Exception: + pass # Save failed - not critical, image is in memory cache + + # Stop timer if queue is empty + if not self.save_queue and self._save_timer: + self._save_timer.stop() + self._save_timer = None diff --git a/jackify/frontends/gui/screens/modlist_gallery_loading.py b/jackify/frontends/gui/screens/modlist_gallery_loading.py new file mode 100644 index 0000000..86b9b4a --- /dev/null +++ b/jackify/frontends/gui/screens/modlist_gallery_loading.py @@ -0,0 +1,401 @@ +"""Loading and data management for ModlistGalleryDialog (Mixin).""" +from PySide6.QtCore import Qt, QThread, Signal, QTimer +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication +from PySide6.QtGui import QFont +from typing import List, Dict +import random +import logging +from jackify.backend.models.modlist_metadata import ModlistMetadata +from ..shared_theme import JACKIFY_COLOR_BLUE +from .modlist_gallery_card import ModlistCard + +logger = logging.getLogger(__name__) + + +class ModlistGalleryLoadingMixin: + """Mixin providing loading and data management for ModlistGalleryDialog.""" + + def _load_modlists_async(self): + """Load modlists in background thread for instant dialog appearance""" + from PySide6.QtCore import QThread, Signal + from PySide6.QtGui import QFont + + # Hide status label during loading (popup dialog will show instead) + self.status_label.setVisible(False) + + # Show loading overlay directly in content area (simpler than separate dialog) + self._loading_overlay = QWidget(self.content_area) + self._loading_overlay.setStyleSheet(""" + QWidget { + background-color: rgba(35, 35, 35, 240); + border-radius: 8px; + } + """) + overlay_layout = QVBoxLayout() + overlay_layout.setContentsMargins(30, 20, 30, 20) + overlay_layout.setSpacing(12) + + self._loading_label = QLabel("Loading modlists") + self._loading_label.setAlignment(Qt.AlignCenter) + # Set fixed width to prevent text shifting when dots animate + # Width accommodates "Loading modlists..." (longest version) + self._loading_label.setFixedWidth(220) + font = QFont() + font.setPointSize(14) + font.setBold(True) + self._loading_label.setFont(font) + self._loading_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 14px; font-weight: bold;") + overlay_layout.addWidget(self._loading_label) + + self._loading_overlay.setLayout(overlay_layout) + self._loading_overlay.setFixedSize(300, 120) + + # Animate dots in loading message + self._loading_dot_count = 0 + self._loading_dot_timer = QTimer() + self._loading_dot_timer.timeout.connect(self._animate_loading_dots) + self._loading_dot_timer.start(500) # Update every 500ms + + # Position overlay in center of content area + def position_overlay(): + if hasattr(self, 'content_area') and self.content_area.isVisible(): + content_width = self.content_area.width() + content_height = self.content_area.height() + x = (content_width - 300) // 2 + y = (content_height - 120) // 2 + self._loading_overlay.move(x, y) + self._loading_overlay.show() + self._loading_overlay.raise_() + + # Delay slightly to ensure content_area is laid out + QTimer.singleShot(50, position_overlay) + + class ModlistLoaderThread(QThread): + """Background thread to load modlist metadata""" + finished = Signal(object, object) # metadata_response, error_message + + def __init__(self, gallery_service): + super().__init__() + self.gallery_service = gallery_service + + def run(self): + try: + import time + start_time = time.time() + + # Fetch metadata (CPU-intensive work happens here in background) + # Skip search index initially for faster loading - can be loaded later if user searches + metadata_response = self.gallery_service.fetch_modlist_metadata( + include_validation=False, + include_search_index=False, # Skip for faster initial load + sort_by="title" + ) + + elapsed = time.time() - start_time + import logging + logger = logging.getLogger(__name__) + if elapsed < 0.5: + logger.debug(f"Gallery metadata loaded from cache in {elapsed:.2f}s") + else: + logger.info(f"Gallery metadata fetched from engine in {elapsed:.2f}s") + + self.finished.emit(metadata_response, None) + except Exception as e: + self.finished.emit(None, str(e)) + + # Create and start background thread + self._loader_thread = ModlistLoaderThread(self.gallery_service) + self._loader_thread.finished.connect(self._on_modlists_loaded) + self._loader_thread.start() + + + def _animate_loading_dots(self): + """Animate dots in loading message""" + if hasattr(self, '_loading_label') and self._loading_label: + self._loading_dot_count = (self._loading_dot_count + 1) % 4 + dots = "." * self._loading_dot_count + # Pad with spaces to keep text width constant (prevents shifting) + padding = " " * (3 - self._loading_dot_count) + self._loading_label.setText(f"Loading modlists{dots}{padding}") + + + def _on_modlists_loaded(self, metadata_response, error_message): + """Handle modlist metadata loaded in background thread (runs in GUI thread)""" + import random + from PySide6.QtGui import QFont + + # Stop animation timer and close loading overlay + if hasattr(self, '_loading_dot_timer') and self._loading_dot_timer: + self._loading_dot_timer.stop() + self._loading_dot_timer = None + + if hasattr(self, '_loading_overlay') and self._loading_overlay: + self._loading_overlay.hide() + self._loading_overlay.deleteLater() + self._loading_overlay = None + + self.status_label.setVisible(True) + + if error_message: + self.status_label.setText(f"Error loading modlists: {error_message}") + return + + if not metadata_response: + self.status_label.setText("Failed to load modlists") + return + + try: + # Get all modlists + all_modlists = metadata_response.modlists + + # RANDOMIZE the order each time gallery opens (like Wabbajack) + random.shuffle(all_modlists) + + self.all_modlists = all_modlists + + # Precompute normalized tags for display/filtering + for modlist in self.all_modlists: + normalized_display = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', [])) + modlist.normalized_tags_display = normalized_display + modlist.normalized_tags_keys = [tag.lower() for tag in normalized_display] + + # Temporarily disconnect to prevent triggering during setup + self.game_combo.currentIndexChanged.disconnect(self._apply_filters) + + # Populate game filter + games = sorted(set(m.gameHumanFriendly for m in self.all_modlists)) + for game in games: + self.game_combo.addItem(game, game) + + # If dialog was opened with a game filter, pre-select it + if self.game_filter: + index = self.game_combo.findData(self.game_filter) + if index >= 0: + self.game_combo.setCurrentIndex(index) + + # Populate tag filter (mod filter temporarily disabled) + self._populate_tag_filter() + # self._populate_mod_filter() # TEMPORARILY DISABLED + + # Create cards immediately (will show placeholders for images not in cache) + self._create_all_cards() + + # Preload cached images in background (non-blocking) + self.status_label.setText("Loading images...") + QTimer.singleShot(0, self._preload_cached_images_async) + + # Reconnect filter handler + self.game_combo.currentIndexChanged.connect(self._apply_filters) + + # Enable filter controls now that data is loaded + self._set_filter_controls_enabled(True) + + # Apply filters (will show all modlists for selected game initially) + self._apply_filters() + + # Start background validation update (non-blocking) + self._start_validation_update() + + except Exception as e: + self.status_label.setText(f"Error processing modlists: {str(e)}") + + + def _load_modlists(self): + """DEPRECATED: Synchronous loading - replaced by _load_modlists_async()""" + from PySide6.QtWidgets import QApplication + + self.status_label.setText("Loading modlists...") + QApplication.processEvents() # Update UI immediately + + # Fetch metadata (will use cache if valid) + # Skip validation initially for faster loading - can be added later if needed + try: + metadata_response = self.gallery_service.fetch_modlist_metadata( + include_validation=False, # Skip validation for faster initial load + include_search_index=True, # Include mod search index for mod filtering + sort_by="title" + ) + + if metadata_response: + # Get all modlists + all_modlists = metadata_response.modlists + + # RANDOMIZE the order each time gallery opens (like Wabbajack) + # Prevent gaming via alphabetical ordering + random.shuffle(all_modlists) + + self.all_modlists = all_modlists + + # Precompute normalized tags for display/filtering (matches upstream Wabbajack) + for modlist in self.all_modlists: + normalized_display = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', [])) + modlist.normalized_tags_display = normalized_display + modlist.normalized_tags_keys = [tag.lower() for tag in normalized_display] + + # Temporarily disconnect to prevent triggering during setup + self.game_combo.currentIndexChanged.disconnect(self._apply_filters) + + # Populate game filter + games = sorted(set(m.gameHumanFriendly for m in self.all_modlists)) + for game in games: + self.game_combo.addItem(game, game) + + # If dialog was opened with a game filter, pre-select it + if self.game_filter: + index = self.game_combo.findData(self.game_filter) + if index >= 0: + self.game_combo.setCurrentIndex(index) + + # Populate tag filter (mod filter temporarily disabled) + self._populate_tag_filter() + # self._populate_mod_filter() # TEMPORARILY DISABLED + + # Create cards immediately (will show placeholders for images not in cache) + self._create_all_cards() + + # Preload cached images in background (non-blocking) + # Images will appear as they're loaded + self.status_label.setText("Loading images...") + QTimer.singleShot(0, self._preload_cached_images_async) + + # Reconnect filter handler + self.game_combo.currentIndexChanged.connect(self._apply_filters) + + # Apply filters (will show all modlists for selected game initially) + self._apply_filters() + + # Start background validation update (non-blocking) + self._start_validation_update() + else: + self.status_label.setText("Failed to load modlists") + except Exception as e: + self.status_label.setText(f"Error loading modlists: {str(e)}") + + + def _preload_cached_images_async(self): + """Preload cached images asynchronously - images appear as they load""" + from PySide6.QtWidgets import QApplication + + preloaded = 0 + total = len(self.all_modlists) + + for idx, modlist in enumerate(self.all_modlists): + cache_key = modlist.machineURL + + # Skip if already in cache + if cache_key in self.image_manager.pixmap_cache: + continue + + # Preload large images for cards (scale down for better quality) + cached_path = self.gallery_service.get_cached_image_path(modlist, "large") + if cached_path and cached_path.exists(): + try: + pixmap = QPixmap(str(cached_path)) + if not pixmap.isNull(): + cache_key_large = f"{cache_key}_large" + self.image_manager.pixmap_cache[cache_key_large] = pixmap + preloaded += 1 + + # Update card immediately if it exists + card = self.all_cards.get(cache_key) + if card: + card._display_image(pixmap) + except Exception: + pass + + # Process events every 10 images to keep UI responsive + if idx % 10 == 0 and idx > 0: + QApplication.processEvents() + + # Update status (subtle, user-friendly) + modlist_count = len(self.filtered_modlists) + if modlist_count == 1: + self.status_label.setText("1 modlist") + else: + self.status_label.setText(f"{modlist_count} modlists") + + def _create_all_cards(self): + """Create cards for all modlists and store in dict""" + # Clear existing cards + self.all_cards.clear() + + # Disable updates during card creation to prevent individual renders + self.grid_widget.setUpdatesEnabled(False) + self.setUpdatesEnabled(False) + + try: + # Create all cards - images should be in memory cache from preload + # so _load_image() will find them instantly + for modlist in self.all_modlists: + card = ModlistCard(modlist, self.image_manager, is_steamdeck=self.is_steamdeck) + card.clicked.connect(self._on_modlist_clicked) + self.all_cards[modlist.machineURL] = card + finally: + # Re-enable updates - single render for all cards + self.setUpdatesEnabled(True) + self.grid_widget.setUpdatesEnabled(True) + + + def _refresh_metadata(self): + """Force refresh metadata from jackify-engine""" + self.status_label.setText("Refreshing metadata...") + self.gallery_service.clear_cache() + self._load_modlists() + + + def _start_validation_update(self): + """Start background validation update to get availability status""" + # Update validation in background thread to avoid blocking UI + class ValidationUpdateThread(QThread): + finished_signal = Signal(object) # Emits updated metadata response + + def __init__(self, gallery_service): + super().__init__() + self.gallery_service = gallery_service + + def run(self): + try: + # Fetch with validation (slower, but in background) + metadata_response = self.gallery_service.fetch_modlist_metadata( + include_validation=True, + include_search_index=False, + sort_by="title" + ) + self.finished_signal.emit(metadata_response) + except Exception: + self.finished_signal.emit(None) + + self._validation_thread = ValidationUpdateThread(self.gallery_service) + self._validation_thread.finished_signal.connect(self._on_validation_updated) + self._validation_thread.start() + + + def _on_validation_updated(self, metadata_response): + """Update modlists with validation data when background fetch completes""" + if not metadata_response: + return + + # Create lookup dict for validation data + validation_map = {} + for modlist in metadata_response.modlists: + if modlist.validation: + validation_map[modlist.machineURL] = modlist.validation + + # Update existing modlists with validation data + updated_count = 0 + for modlist in self.all_modlists: + if modlist.machineURL in validation_map: + modlist.validation = validation_map[modlist.machineURL] + updated_count += 1 + + # Update card if it exists + card = self.all_cards.get(modlist.machineURL) + if card: + # Update unavailable badge visibility + card._update_availability_badge() + + # Re-apply filters to update availability filtering + if updated_count > 0: + self._apply_filters() + + diff --git a/jackify/frontends/gui/screens/modlist_tasks.py b/jackify/frontends/gui/screens/modlist_tasks.py index ddd7e9b..4d64ead 100644 --- a/jackify/frontends/gui/screens/modlist_tasks.py +++ b/jackify/frontends/gui/screens/modlist_tasks.py @@ -78,7 +78,7 @@ class ModlistTasksScreen(QWidget): """Set up the user interface""" main_layout = QVBoxLayout(self) main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - main_layout.setContentsMargins(30, 30, 30, 30) # Reduced from 50 + main_layout.setContentsMargins(30, 30, 30, 30) main_layout.setSpacing(12) # Match main menu spacing if self.debug: @@ -147,11 +147,11 @@ class ModlistTasksScreen(QWidget): # Create grid layout for buttons button_grid = QGridLayout() - button_grid.setSpacing(12) # Reduced from 16 + button_grid.setSpacing(12) button_grid.setAlignment(Qt.AlignHCenter) button_width = 400 - button_height = 40 # Reduced from 50 + button_height = 40 for i, (label, action_id, description) in enumerate(MENU_ITEMS): # Create button @@ -179,7 +179,7 @@ class ModlistTasksScreen(QWidget): # Create description label desc_label = QLabel(description) desc_label.setAlignment(Qt.AlignHCenter) - desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px + desc_label.setStyleSheet("color: #999; font-size: 11px;") desc_label.setWordWrap(True) desc_label.setFixedWidth(button_width) diff --git a/jackify/frontends/gui/screens/screen_back_mixin.py b/jackify/frontends/gui/screens/screen_back_mixin.py new file mode 100644 index 0000000..ce888c4 --- /dev/null +++ b/jackify/frontends/gui/screens/screen_back_mixin.py @@ -0,0 +1,50 @@ +""" +Shared back/cancel behavior for screens with Show Details. + +All screens that have a Cancel/Back button and optional Show Details checkbox +should use this mixin so the main window consistently collapses when leaving. +""" + +from PySide6.QtCore import QSize, Qt +from ..utils import set_responsive_minimum + + +class ScreenBackMixin: + """ + Mixin providing shared go_back() and collapse_show_details_before_leave(). + + Requires on self: resize_request (Signal(str)), stacked_widget, main_menu_index. + Optional: show_details_checkbox, _toggle_console_visibility (for collapse). + """ + + def go_back(self): + """Navigate back to main menu and request main window collapse.""" + 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 + if getattr(self, "stacked_widget", None) is not None: + self.stacked_widget.setCurrentIndex(self.main_menu_index) + + def collapse_show_details_before_leave(self): + """ + If Show Details is expanded, collapse it so the main window shrinks + before we leave. Call this from cancel_and_cleanup (or any exit path) + before go_back(). + """ + main_window = self.window() + is_steamdeck = bool( + getattr(main_window, "system_info", None) + and getattr(main_window.system_info, "is_steamdeck", False) + ) + if not hasattr(self, "show_details_checkbox") or not self.show_details_checkbox.isChecked(): + return + self.show_details_checkbox.blockSignals(True) + self.show_details_checkbox.setChecked(False) + self.show_details_checkbox.blockSignals(False) + if not is_steamdeck and hasattr(self, "_toggle_console_visibility"): + self._toggle_console_visibility(Qt.Unchecked) diff --git a/jackify/frontends/gui/screens/wabbajack_installer.py b/jackify/frontends/gui/screens/wabbajack_installer.py index 77ef734..ff94332 100644 --- a/jackify/frontends/gui/screens/wabbajack_installer.py +++ b/jackify/frontends/gui/screens/wabbajack_installer.py @@ -6,23 +6,26 @@ Follows standard Jackify screen layout. """ import logging +import os +from datetime import datetime from pathlib import Path from typing import Optional from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QFileDialog, QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox + QFileDialog, QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox, + QMessageBox ) from PySide6.QtCore import Qt, QThread, Signal, QSize from PySide6.QtGui import QTextCursor from jackify.backend.models.configuration import SystemInfo -from jackify.backend.handlers.wabbajack_installer_handler import WabbajackInstallerHandler from ..services.message_service import MessageService from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from ..utils import set_responsive_minimum from ..widgets.file_progress_list import FileProgressList from ..widgets.progress_indicator import OverallProgressIndicator +from .screen_back_mixin import ScreenBackMixin logger = logging.getLogger(__name__) @@ -40,7 +43,6 @@ class WabbajackInstallerWorker(QThread): self.install_folder = install_folder self.shortcut_name = shortcut_name self.enable_gog = enable_gog - self.handler = WabbajackInstallerHandler() self.launch_options = "" # Store launch options for success message self.start_time = None # Track installation start time @@ -50,206 +52,38 @@ class WabbajackInstallerWorker(QThread): logger.info(message) def run(self): - """Run the installation workflow""" + """Run the installation workflow using backend service""" import time self.start_time = time.time() - try: - total_steps = 12 - - # Step 1: Check requirements - self.progress_update.emit("Checking requirements...", 5) - self.activity_update.emit("Checking requirements", 1, total_steps) - self._log("Checking system requirements...") - - proton_path = self.handler.find_proton_experimental() - if not proton_path: - self.installation_complete.emit( - False, - "Proton Experimental not found.\nPlease install it from Steam." - ) - return - self._log(f"Found Proton Experimental: {proton_path}") - - userdata = self.handler.find_steam_userdata_path() - if not userdata: - self.installation_complete.emit( - False, - "Steam userdata not found.\nPlease ensure Steam is installed and you're logged in." - ) - return - self._log(f"Found Steam userdata: {userdata}") - - # Step 2: Download Wabbajack - self.progress_update.emit("Downloading Wabbajack.exe...", 15) - self.activity_update.emit("Downloading Wabbajack.exe", 2, total_steps) - self._log("Downloading Wabbajack.exe from GitHub...") - wabbajack_exe = self.handler.download_wabbajack(self.install_folder) - self._log(f"Downloaded to: {wabbajack_exe}") - - # Step 3: Create dotnet cache - self.progress_update.emit("Creating .NET cache directory...", 20) - self.activity_update.emit("Creating .NET cache", 3, total_steps) - self._log("Creating .NET bundle extract cache...") - self.handler.create_dotnet_cache(self.install_folder) - - # Step 4: Stop Steam before modifying shortcuts.vdf - self.progress_update.emit("Stopping Steam...", 25) - self.activity_update.emit("Stopping Steam", 4, total_steps) - self._log("Stopping Steam (required to safely modify shortcuts.vdf)...") - import subprocess - import time - - # Kill Steam using pkill (simple approach like AuCu) - try: - subprocess.run(['steam', '-shutdown'], timeout=5, capture_output=True) - time.sleep(2) - subprocess.run(['pkill', '-9', 'steam'], timeout=5, capture_output=True) - time.sleep(2) - self._log("Steam stopped successfully") - except Exception as e: - self._log(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...") - - # Step 5: Add to Steam shortcuts (NO Proton - like AuCu, but with STEAM_COMPAT_MOUNTS for libraries) - self.progress_update.emit("Adding to Steam shortcuts...", 30) - self.activity_update.emit("Adding to Steam", 5, total_steps) - self._log("Adding Wabbajack to Steam shortcuts...") - from jackify.backend.services.native_steam_service import NativeSteamService - steam_service = NativeSteamService() - - # Generate launch options with STEAM_COMPAT_MOUNTS for additional Steam libraries (like modlist installs) - # Default to empty string (like AuCu) - only add options if we have additional libraries - # Note: Users may need to manually add other paths (e.g., download directories on different drives) to launch options - launch_options = "" - try: - from jackify.backend.handlers.path_handler import PathHandler - path_handler = PathHandler() - - all_libs = path_handler.get_all_steam_library_paths() - main_steam_lib_path_obj = path_handler.find_steam_library() - if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common": - main_steam_lib_path = main_steam_lib_path_obj.parent.parent - - filtered_libs = [lib for lib in all_libs if str(lib) != str(main_steam_lib_path)] - if filtered_libs: - mount_paths = ":".join(str(lib) for lib in filtered_libs) - launch_options = f'STEAM_COMPAT_MOUNTS="{mount_paths}" %command%' - self._log(f"Added STEAM_COMPAT_MOUNTS for additional Steam libraries: {mount_paths}") - else: - self._log("No additional Steam libraries found - using empty launch options (like AuCu)") - except Exception as e: - self._log(f"Could not generate STEAM_COMPAT_MOUNTS (non-critical): {e}") - # Keep empty string like AuCu - - # Store launch options for success message - self.launch_options = launch_options - - # Create shortcut WITHOUT Proton (AuCu does this separately later) - success, app_id = steam_service.create_shortcut( - app_name=self.shortcut_name, - exe_path=str(wabbajack_exe), - start_dir=str(wabbajack_exe.parent), - launch_options=launch_options, # Empty or with STEAM_COMPAT_MOUNTS - tags=["Jackify"] - ) - if not success or app_id is None: - raise RuntimeError("Failed to create Steam shortcut") - self._log(f"Created Steam shortcut with AppID: {app_id}") - - # Step 6: Initialize Wine prefix - self.progress_update.emit("Initializing Wine prefix...", 45) - self.activity_update.emit("Initializing Wine prefix", 6, total_steps) - self._log("Initializing Wine prefix with Proton...") - prefix_path = self.handler.init_wine_prefix(app_id) - self._log(f"Wine prefix created: {prefix_path}") - - # Step 7: Install WebView2 - self.progress_update.emit("Installing WebView2 runtime...", 60) - self.activity_update.emit("Installing WebView2", 7, total_steps) - self._log("Downloading and installing WebView2...") - try: - self.handler.install_webview2(app_id, self.install_folder) - self._log("WebView2 installed successfully") - except Exception as e: - self._log(f"WARNING: WebView2 installation may have failed: {e}") - self._log("This may prevent Nexus login in Wabbajack. You can manually install WebView2 later.") - # Continue installation - WebView2 is not critical for basic functionality - - # Step 8: Apply Win7 registry - self.progress_update.emit("Applying Windows 7 registry settings...", 75) - self.activity_update.emit("Applying registry settings", 8, total_steps) - self._log("Applying Windows 7 compatibility settings...") - self.handler.apply_win7_registry(app_id) - self._log("Registry settings applied") - - # Step 9: GOG game detection (optional) - gog_count = 0 - if self.enable_gog: - self.progress_update.emit("Detecting GOG games from Heroic...", 80) - self.activity_update.emit("Detecting GOG games", 9, total_steps) - self._log("Searching for GOG games in Heroic...") - try: - gog_count = self.handler.inject_gog_registry(app_id) - if gog_count > 0: - self._log(f"Detected and injected {gog_count} GOG games") - else: - self._log("No GOG games found in Heroic") - except Exception as e: - self._log(f"GOG injection failed (non-critical): {e}") - - # Step 10: Create Steam library symlinks - self.progress_update.emit("Creating Steam library symlinks...", 85) - self.activity_update.emit("Creating library symlinks", 10, total_steps) - self._log("Creating Steam library symlinks for game detection...") - steam_service.create_steam_library_symlinks(app_id) - self._log("Steam library symlinks created") - - # Step 11: Set Proton Experimental (separate step like AuCu) - self.progress_update.emit("Setting Proton compatibility...", 90) - self.activity_update.emit("Setting Proton compatibility", 11, total_steps) - self._log("Setting Proton Experimental as compatibility tool...") - try: - steam_service.set_proton_version(app_id, "proton_experimental") - self._log("Proton Experimental set successfully") - except Exception as e: - self._log(f"Warning: Failed to set Proton version (non-critical): {e}") - self._log("You can set it manually in Steam: Properties → Compatibility → Proton Experimental") - - # Step 12: Start Steam at the end - self.progress_update.emit("Starting Steam...", 95) - self.activity_update.emit("Starting Steam", 12, total_steps) - self._log("Starting Steam...") - from jackify.backend.services.steam_restart_service import start_steam - start_steam() - time.sleep(3) # Give Steam time to start - self._log("Steam started successfully") - - # Done! - self.progress_update.emit("Installation complete!", 100) - self.activity_update.emit("Installation complete", 12, total_steps) - self._log("\n=== Installation Complete ===") - self._log(f"Wabbajack installed to: {self.install_folder}") - self._log(f"Steam AppID: {app_id}") - if gog_count > 0: - self._log(f"GOG games detected: {gog_count}") - self._log("You can now launch Wabbajack from Steam") - - # Calculate time taken - import time - time_taken = int(time.time() - self.start_time) - mins, secs = divmod(time_taken, 60) - time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds" - - # Store data for success dialog (app_id as string to avoid overflow) - self.installation_complete.emit(True, "", self.launch_options, str(app_id), time_str) - - except Exception as e: - error_msg = f"Installation failed: {str(e)}" - self._log(f"\nERROR: {error_msg}") - logger.error(f"Wabbajack installation failed: {e}", exc_info=True) - self.installation_complete.emit(False, error_msg, "", "", "") + + from jackify.backend.services.wabbajack_installer_service import WabbajackInstallerService + + service = WabbajackInstallerService() + + def progress_callback(message: str, percentage: int): + self.progress_update.emit(message, percentage) + step_num = int((percentage / 100) * 12) if percentage < 100 else 12 + self.activity_update.emit(message, step_num, 12) + + def log_callback(message: str): + self._log(message) + + success, app_id, launch_options, gog_count, time_str, error_msg = service.install_wabbajack( + install_folder=self.install_folder, + shortcut_name=self.shortcut_name, + enable_gog=self.enable_gog, + progress_callback=progress_callback, + log_callback=log_callback + ) + + if success: + self.launch_options = launch_options or "" + self.installation_complete.emit(True, "", self.launch_options, str(app_id), time_str or "") + else: + self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "") -class WabbajackInstallerScreen(QWidget): +class WabbajackInstallerScreen(ScreenBackMixin, QWidget): """Wabbajack installer GUI screen following standard Jackify layout""" resize_request = Signal(str) @@ -257,6 +91,7 @@ class WabbajackInstallerScreen(QWidget): def __init__(self, stacked_widget=None, additional_tasks_index=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 @@ -273,6 +108,11 @@ class WabbajackInstallerScreen(QWidget): self._user_manually_scrolled = False self._was_at_bottom = True + # Set up log file path + from jackify.shared.paths import get_jackify_logs_dir + self.log_path = get_jackify_logs_dir() / 'Wabbajack_Installer_workflow.log' + os.makedirs(os.path.dirname(self.log_path), exist_ok=True) + # Initialize progress reporting self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) self.progress_indicator.set_status("Ready", 0) @@ -542,7 +382,7 @@ class WabbajackInstallerScreen(QWidget): # Get shortcut name self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack" - # Confirm with user + # Confirm with user (standard dialog - no safety countdown needed for this operation) confirm = MessageService.question( self, "Confirm Installation", @@ -553,13 +393,25 @@ class WabbajackInstallerScreen(QWidget): "Continue?" ) - if not confirm: + if confirm != QMessageBox.Yes: return # Clear displays self.console.clear() self.file_progress_list.clear() + # 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(self.log_path, backup_count=5) + + # Log session start + self._write_to_log_file("=" * 60) + self._write_to_log_file(f"Wabbajack Installation Started") + self._write_to_log_file(f"Install folder: {self.install_folder}") + self._write_to_log_file(f"Shortcut name: {self.shortcut_name}") + self._write_to_log_file("=" * 60) + # Update UI state self.start_btn.setEnabled(False) self.cancel_btn.setEnabled(False) @@ -585,8 +437,19 @@ class WabbajackInstallerScreen(QWidget): summary_info={"current_step": current, "max_steps": total} ) + def _write_to_log_file(self, message: str): + """Write message to workflow log file with timestamp""" + try: + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(self.log_path, 'a', encoding='utf-8') as f: + f.write(f"[{timestamp}] {message}\n") + except Exception: + pass + def _on_log_output(self, message: str): """Handle log output with professional auto-scroll""" + self._write_to_log_file(message) + scrollbar = self.console.verticalScrollBar() was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) @@ -678,7 +541,7 @@ class WabbajackInstallerScreen(QWidget): # Insert before the Ko-Fi link (which should be near the end) # Find the index of the Ko-Fi label or add at the end - insert_index = card_layout.count() - 2 # Before buttons, after next steps + insert_index = card_layout.count() - 2 card_layout.insertWidget(insert_index, note_frame) success_dialog.show() @@ -698,8 +561,8 @@ class WabbajackInstallerScreen(QWidget): def _go_back(self): """Return to Additional Tasks menu""" - if self.stacked_widget: - self.stacked_widget.setCurrentIndex(self.additional_tasks_index) + self.collapse_show_details_before_leave() + self.go_back() def showEvent(self, event): """Called when widget becomes visible""" diff --git a/jackify/frontends/gui/services/message_service.py b/jackify/frontends/gui/services/message_service.py index 3dbfe41..7987d32 100644 --- a/jackify/frontends/gui/services/message_service.py +++ b/jackify/frontends/gui/services/message_service.py @@ -93,18 +93,12 @@ class SafeMessageBox(NonFocusMessageBox): """High safety: requires typing confirmation code""" # Generate random confirmation code self.confirmation_code = ''.join(random.choices(string.ascii_uppercase, k=6)) - - # Create custom buttons - self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole) - self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole) - - # Make cancel the default (Enter key) + + self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole) + self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole) self.setDefaultButton(self.cancel_btn) - - # Initially disable proceed button self.proceed_btn.setEnabled(False) - - # Add confirmation code input + widget = QWidget() layout = QVBoxLayout(widget) @@ -124,26 +118,16 @@ class SafeMessageBox(NonFocusMessageBox): def _setup_medium_safety(self, danger_action: str, safe_action: str): """Medium safety: requires wait period""" - # Create custom buttons - self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole) - self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole) - - # Make cancel the default (Enter key) + self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole) + self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole) self.setDefaultButton(self.cancel_btn) - - # Initially disable proceed button self.proceed_btn.setEnabled(False) - - # Start countdown self._start_countdown(3) def _setup_low_safety(self, danger_action: str, safe_action: str): """Low safety: no additional features needed""" - # Create standard buttons - self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole) - self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole) - - # Make proceed the default for low safety + self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole) + self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole) self.setDefaultButton(self.proceed_btn) def _start_countdown(self, seconds: int): @@ -274,7 +258,7 @@ class MessageService: default_button: QMessageBox.StandardButton = QMessageBox.No, critical: bool = False, safety_level: str = "low") -> int: - """Show question dialog without stealing focus""" + """Show question dialog without stealing focus. Uses explicit button order for consistency.""" if safety_level in ["medium", "high"]: msg_box = SafeMessageBox(parent, safety_level) msg_box.setup_safety_features(title, message, "Yes", "No", is_question=True) @@ -283,7 +267,21 @@ class MessageService: msg_box.setIcon(QMessageBox.Question) msg_box.setWindowTitle(title) msg_box.setText(message) - msg_box.setStandardButtons(buttons) - msg_box.setDefaultButton(default_button) - - return msg_box.exec() \ No newline at end of file + yes_btn = msg_box.addButton("Yes", QMessageBox.ActionRole) + no_btn = msg_box.addButton("No", QMessageBox.ActionRole) + if default_button == QMessageBox.No: + msg_box.setDefaultButton(no_btn) + else: + msg_box.setDefaultButton(yes_btn) + + result = msg_box.exec() + + # For SafeMessageBox with is_question=True, return value is already set by done() + if safety_level in ["medium", "high"]: + return result + + # For non-SafeMessageBox, map clicked button to QMessageBox.Yes/No for compatibility + clicked = msg_box.clickedButton() + if clicked and clicked.text() == "Yes": + return QMessageBox.Yes + return QMessageBox.No \ No newline at end of file diff --git a/jackify/frontends/gui/widgets/feature_placeholder.py b/jackify/frontends/gui/widgets/feature_placeholder.py new file mode 100644 index 0000000..8075d9a --- /dev/null +++ b/jackify/frontends/gui/widgets/feature_placeholder.py @@ -0,0 +1,22 @@ +""" +Placeholder widget for unimplemented feature screens. +""" + +from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QPushButton +from PySide6.QtCore import Qt + + +class FeaturePlaceholder(QWidget): + """Placeholder widget for features not yet implemented.""" + + def __init__(self, stacked_widget=None): + super().__init__() + layout = QVBoxLayout() + label = QLabel("[Feature screen placeholder]") + label.setAlignment(Qt.AlignCenter) + layout.addWidget(label) + back_btn = QPushButton("Back to Main Menu") + if stacked_widget: + back_btn.clicked.connect(lambda: stacked_widget.setCurrentIndex(0)) + layout.addWidget(back_btn) + self.setLayout(layout) diff --git a/jackify/frontends/gui/widgets/file_progress_item.py b/jackify/frontends/gui/widgets/file_progress_item.py new file mode 100644 index 0000000..9569c69 --- /dev/null +++ b/jackify/frontends/gui/widgets/file_progress_item.py @@ -0,0 +1,195 @@ +""" +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 jackify.shared.progress_models import FileProgress, OperationType +from ..shared_theme import JACKIFY_COLOR_BLUE + + +class FileProgressItem(QWidget): + """Widget representing a single file's progress.""" + + 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._setup_ui() + self._update_display() + + def _setup_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 2, 4, 2) + layout.setSpacing(8) + + operation_label = QLabel(self._get_operation_symbol()) + operation_label.setFixedWidth(20) + operation_label.setAlignment(Qt.AlignCenter) + operation_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-weight: bold;") + layout.addWidget(operation_label) + + filename_label = QLabel(self._truncate_filename(self.file_progress.filename)) + filename_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + filename_label.setToolTip(self.file_progress.filename) + filename_label.setStyleSheet("color: #ccc; font-size: 11px;") + layout.addWidget(filename_label, 1) + self.filename_label = filename_label + + percent_label = QLabel() + percent_label.setFixedWidth(40) + percent_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + percent_label.setStyleSheet("color: #aaa; font-size: 11px;") + layout.addWidget(percent_label) + self.percent_label = percent_label + + progress_bar = QProgressBar() + progress_bar.setFixedHeight(12) + progress_bar.setFixedWidth(80) + progress_bar.setTextVisible(False) + progress_bar.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid #444; + border-radius: 2px; + background-color: #1a1a1a; + }} + QProgressBar::chunk {{ + background-color: {JACKIFY_COLOR_BLUE}; + border-radius: 1px; + }} + """) + layout.addWidget(progress_bar) + self.progress_bar = progress_bar + + def _get_operation_symbol(self) -> str: + symbols = { + OperationType.DOWNLOAD: "↓", + OperationType.EXTRACT: "↻", + OperationType.VALIDATE: "✓", + 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] + "..." + + 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 + + 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() + comp_id = rest.split('|')[0].strip() if '|' in rest else rest + name_display = f"Installing {comp_id}..." + else: + name_display = self._truncate_filename(self.file_progress.filename) + + if not is_summary and not no_progress_bar: + size_display = self.file_progress.size_display + if size_display: + name_display = f"{name_display} ({size_display})" + + self.filename_label.setText(name_display) + self.filename_label.setToolTip(self.file_progress.filename) + + if no_progress_bar: + self._animation_timer.stop() + self.percent_label.setText("") + self.progress_bar.setVisible(False) + return + + self.progress_bar.setVisible(True) + + if is_summary: + summary_step = getattr(self.file_progress, '_summary_step', 0) + summary_max = getattr(self.file_progress, '_summary_max', 0) + + 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) + 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() + 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 + ) + + if is_queued: + 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 + + 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) + ) + + 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) + 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() + + def _animate_progress(self): + 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) + 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("") + + 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() diff --git a/jackify/frontends/gui/widgets/file_progress_list.py b/jackify/frontends/gui/widgets/file_progress_list.py index 02af6fa..2f116b1 100644 --- a/jackify/frontends/gui/widgets/file_progress_list.py +++ b/jackify/frontends/gui/widgets/file_progress_list.py @@ -11,14 +11,18 @@ import shiboken6 import time from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem, + QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem, QProgressBar, QHBoxLayout, QSizePolicy ) from PySide6.QtCore import Qt, QSize, QTimer -from PySide6.QtGui import QFont from jackify.shared.progress_models import FileProgress, OperationType -from ..shared_theme import JACKIFY_COLOR_BLUE + +from .summary_progress_widget import SummaryProgressWidget +from .file_progress_item import FileProgressItem + +__all__ = ['SummaryProgressWidget', 'FileProgressItem', 'FileProgressList'] + def _debug_log(message): """Log message only if debug mode is enabled""" @@ -28,322 +32,6 @@ def _debug_log(message): print(message) -class SummaryProgressWidget(QWidget): - """Widget showing summary progress for phases like Installing.""" - - def __init__(self, phase_name: str, current_step: int, max_steps: int, parent=None): - super().__init__(parent) - self.phase_name = phase_name - self.current_step = current_step - self.max_steps = max_steps - # Smooth interpolation for counter updates - self._target_step = current_step - self._target_max = max_steps - self._display_step = current_step - self._display_max = max_steps - self._interpolation_timer = QTimer(self) - self._interpolation_timer.timeout.connect(self._interpolate_counter) - self._interpolation_timer.setInterval(16) # ~60fps - self._interpolation_timer.start() - self._setup_ui() - self._update_display() - - def _setup_ui(self): - """Set up the UI for summary display.""" - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(6) - - # Text label showing phase and count (no progress bar for cleaner display) - self.text_label = QLabel() - self.text_label.setStyleSheet("color: #ccc; font-size: 12px; font-weight: bold;") - layout.addWidget(self.text_label) - - def _interpolate_counter(self): - """Smoothly interpolate counter display toward target values.""" - # Interpolate step - step_diff = self._target_step - self._display_step - if abs(step_diff) < 0.5: - self._display_step = self._target_step - else: - # Smooth interpolation (20% per frame) - self._display_step += step_diff * 0.2 - - # Interpolate max (usually doesn't change, but handle it) - max_diff = self._target_max - self._display_max - if abs(max_diff) < 0.5: - self._display_max = self._target_max - else: - self._display_max += max_diff * 0.2 - - # Update display with interpolated values - self._update_display() - - def _update_display(self): - """Update the display with current progress.""" - # Use interpolated display values for smooth counter updates - display_step = int(round(self._display_step)) - display_max = int(round(self._display_max)) - - if display_max > 0: - new_text = f"{self.phase_name} ({display_step}/{display_max})" - else: - new_text = f"{self.phase_name}" - - # Only update text if it changed (reduces repaints) - if self.text_label.text() != new_text: - self.text_label.setText(new_text) - - def update_progress(self, current_step: int, max_steps: int): - """Update target values (display will smoothly interpolate).""" - # Update targets (render loop will smoothly interpolate) - self._target_step = current_step - self._target_max = max_steps - # Also update actual values for reference - self.current_step = current_step - self.max_steps = max_steps - - -class FileProgressItem(QWidget): - """Widget representing a single file's progress.""" - - def __init__(self, file_progress: FileProgress, parent=None): - super().__init__(parent) - self.file_progress = file_progress - self._target_percent = file_progress.percent # Target value for smooth animation - self._current_display_percent = file_progress.percent # Currently displayed value - self._spinner_position = 0 # For custom indeterminate spinner animation (0-200 range for smooth wraparound) - self._is_indeterminate = False # Track if we're in indeterminate mode - self._animation_timer = QTimer(self) - self._animation_timer.timeout.connect(self._animate_progress) - self._animation_timer.setInterval(16) # ~60fps for smooth animation - self._setup_ui() - self._update_display() - - def _setup_ui(self): - """Set up the UI for this file item.""" - layout = QHBoxLayout(self) - layout.setContentsMargins(4, 2, 4, 2) - layout.setSpacing(8) - - # Operation icon/indicator (simple text for now) - operation_label = QLabel(self._get_operation_symbol()) - operation_label.setFixedWidth(20) - operation_label.setAlignment(Qt.AlignCenter) - operation_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-weight: bold;") - layout.addWidget(operation_label) - - # Filename (truncated if too long) - filename_label = QLabel(self._truncate_filename(self.file_progress.filename)) - filename_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - filename_label.setToolTip(self.file_progress.filename) # Full name in tooltip - filename_label.setStyleSheet("color: #ccc; font-size: 11px;") - layout.addWidget(filename_label, 1) - self.filename_label = filename_label - - # Progress percentage (only show if we have valid progress data) - percent_label = QLabel() - percent_label.setFixedWidth(40) - percent_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - percent_label.setStyleSheet("color: #aaa; font-size: 11px;") - layout.addWidget(percent_label) - self.percent_label = percent_label - - # Progress indicator: either progress bar (with %) or animated spinner (no %) - progress_bar = QProgressBar() - progress_bar.setFixedHeight(12) - progress_bar.setFixedWidth(80) - progress_bar.setTextVisible(False) # Hide text, we have percent label - - # Apply stylesheet ONCE here instead of on every update - progress_bar.setStyleSheet(f""" - QProgressBar {{ - border: 1px solid #444; - border-radius: 2px; - background-color: #1a1a1a; - }} - QProgressBar::chunk {{ - background-color: {JACKIFY_COLOR_BLUE}; - border-radius: 1px; - }} - """) - - layout.addWidget(progress_bar) - self.progress_bar = progress_bar - - def _get_operation_symbol(self) -> str: - """Get symbol for operation type.""" - symbols = { - OperationType.DOWNLOAD: "↓", - OperationType.EXTRACT: "↻", - OperationType.VALIDATE: "✓", - OperationType.INSTALL: "→", - } - return symbols.get(self.file_progress.operation, "•") - - def _truncate_filename(self, filename: str, max_length: int = 40) -> str: - """Truncate filename if too long.""" - if len(filename) <= max_length: - return filename - return filename[:max_length-3] + "..." - - def _update_display(self): - """Update the display with current progress.""" - # Check if this is a summary item (e.g., "Installing files (1234/5678)") - is_summary = hasattr(self.file_progress, '_is_summary') and self.file_progress._is_summary - - # Check if progress bar should be hidden (e.g., "Installing Files: 234/35346") - no_progress_bar = hasattr(self.file_progress, '_no_progress_bar') and self.file_progress._no_progress_bar - - # Update filename - DON'T truncate for install phase items - # Only truncate for download phase to keep consistency there - 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 # Don't truncate - else: - name_display = self._truncate_filename(self.file_progress.filename) - - if not is_summary and not no_progress_bar: - size_display = self.file_progress.size_display - if size_display: - name_display = f"{name_display} ({size_display})" - - self.filename_label.setText(name_display) - self.filename_label.setToolTip(self.file_progress.filename) - - # For items with _no_progress_bar flag (e.g., "Installing Files: 234/35346") - # Hide the progress bar and percentage - just show the text - if no_progress_bar: - self._animation_timer.stop() # Stop animation for items without progress bars - self.percent_label.setText("") # No percentage - self.progress_bar.setVisible(False) # Hide progress bar - return - - # Ensure progress bar is visible for other items - self.progress_bar.setVisible(True) - - # For summary items, calculate progress from step/max - if is_summary: - summary_step = getattr(self.file_progress, '_summary_step', 0) - summary_max = getattr(self.file_progress, '_summary_max', 0) - - if summary_max > 0: - percent = (summary_step / summary_max) * 100.0 - # Update target for smooth animation - self._target_percent = max(0, min(100, percent)) - - # Start animation timer if not already running - if not self._animation_timer.isActive(): - self._animation_timer.start() - - self.progress_bar.setRange(0, 100) - # Progress bar value will be updated by animation timer - else: - # No max for summary - use custom animated spinner - self._is_indeterminate = True - self.percent_label.setText("") - self.progress_bar.setRange(0, 100) # Use determinate range for custom animation - if not self._animation_timer.isActive(): - self._animation_timer.start() - return - - # Check if this is a queued item (not yet started) - # Queued items have total_size > 0 but percent == 0, current_size == 0, speed <= 0 - 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 - ) - - if is_queued: - # Queued download - show "Queued" text with empty progress bar - 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 - - # Check if we have meaningful progress data - # For operations like BSA building, we may not have percent or size data - 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) - ) - - # Use determinate mode if we have actual progress data, otherwise use custom animated spinner - if has_meaningful_progress: - # Normal progress mode - self._is_indeterminate = False - # Update target for smooth animation - self._target_percent = max(0, self.file_progress.percent) - - # Start animation timer if not already running - if not self._animation_timer.isActive(): - self._animation_timer.start() - - self.progress_bar.setRange(0, 100) - # Progress bar value will be updated by animation timer - else: - # No progress data (e.g., texture conversions, BSA building) - use custom animated spinner - self._is_indeterminate = True - self.percent_label.setText("") # Clear percent label - self.progress_bar.setRange(0, 100) # Use determinate range for custom animation - # Start animation timer for custom spinner - if not self._animation_timer.isActive(): - self._animation_timer.start() - - def _animate_progress(self): - """Smoothly animate progress bar from current to target value, or animate spinner.""" - if self._is_indeterminate: - # Custom indeterminate spinner animation - # Use a bouncing/pulsing effect: position moves 0-100-0 smoothly - # Increment by 4 units per frame for fast animation (full cycle in ~0.8s at 60fps) - self._spinner_position = (self._spinner_position + 4) % 200 - - # Create bouncing effect: 0->100->0 - if self._spinner_position < 100: - display_value = self._spinner_position - else: - display_value = 200 - self._spinner_position - - self.progress_bar.setValue(display_value) - else: - # Normal progress animation - # Calculate difference - diff = self._target_percent - self._current_display_percent - - # If very close, snap to target and stop animation - if abs(diff) < 0.1: - self._current_display_percent = self._target_percent - self._animation_timer.stop() - else: - # Smooth interpolation (ease-out for natural feel) - # Move 20% of remaining distance per frame (~60fps = smooth) - self._current_display_percent += diff * 0.2 - - # Update display - display_percent = max(0, min(100, self._current_display_percent)) - self.progress_bar.setValue(int(display_percent)) - - # Update percentage label - if self.file_progress.percent > 0: - self.percent_label.setText(f"{display_percent:.0f}%") - else: - self.percent_label.setText("") - - def update_progress(self, file_progress: FileProgress): - """Update with new progress data.""" - self.file_progress = file_progress - self._update_display() - - def cleanup(self): - """Clean up resources when widget is no longer needed.""" - if self._animation_timer.isActive(): - self._animation_timer.stop() - - class FileProgressList(QWidget): """ Widget displaying a list of files currently being processed. @@ -584,6 +272,10 @@ class FileProgressList(QWidget): 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) @@ -608,15 +300,16 @@ class FileProgressList(QWidget): if 'Installing Files:' in file_progress.filename: item_key = "__installing_files__" elif 'Converting Texture:' in file_progress.filename: - # Extract base filename for stable key base_name = file_progress.filename.split('(')[0].strip() item_key = f"__texture_{base_name}__" elif file_progress.filename.startswith('BSA:'): - # Extract BSA filename for stable key 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: - # Use filename as key for regular files item_key = file_progress.filename if item_key in self._file_items: @@ -686,6 +379,19 @@ class FileProgressList(QWidget): # 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 @@ -787,7 +493,7 @@ class FileProgressList(QWidget): total_cpu = main_cpu # Add CPU usage from ALL child processes recursively - # This includes jackify-engine, texconv.exe, wine processes, etc. + # Includes jackify-engine, texconv.exe, wine processes, etc. child_count = 0 child_cpu_sum = 0.0 try: @@ -825,8 +531,8 @@ class FileProgressList(QWidget): pass # Also search for ALL Jackify-related processes by name/cmdline - # This catches processes that may not be direct children (shell launches, Proton/wine wrappers, etc.) - # NOTE: Since children() is recursive, this typically only finds Proton spawn cases. + # 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) @@ -868,7 +574,7 @@ class FileProgressList(QWidget): # 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.) - # This includes: texconv, texconv.exe, texdiag, 7z, 7zz, bsarch, jackify-engine + # 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) diff --git a/jackify/frontends/gui/widgets/progress_indicator.py b/jackify/frontends/gui/widgets/progress_indicator.py index 2ffcc74..91a129c 100644 --- a/jackify/frontends/gui/widgets/progress_indicator.py +++ b/jackify/frontends/gui/widgets/progress_indicator.py @@ -136,7 +136,7 @@ class OverallProgressIndicator(QWidget): eta_seconds = -1.0 if using_aggregated: # For concurrent downloads: sum all active download speeds (not average) - # This gives us the combined throughput + # Combined throughput active_speeds = [f.speed for f in progress.active_files if f.speed > 0] if active_speeds: combined_speed = sum(active_speeds) # Sum speeds for concurrent downloads @@ -189,7 +189,7 @@ class OverallProgressIndicator(QWidget): is_bsa_building = progress.get_phase_label() == "Building BSAs" # For install/extract/download/BSA building phases, prefer step-based progress (more accurate) - # This prevents carrying over 100% from previous phases (e.g., .wabbajack download) + # Prevent carrying over 100% from previous phases if progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT, InstallationPhase.DOWNLOAD) or is_bsa_building: if progress.phase_max_steps > 0: display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0 diff --git a/jackify/frontends/gui/widgets/summary_progress_widget.py b/jackify/frontends/gui/widgets/summary_progress_widget.py new file mode 100644 index 0000000..8887792 --- /dev/null +++ b/jackify/frontends/gui/widgets/summary_progress_widget.py @@ -0,0 +1,67 @@ +""" +Summary progress widget for phase display (e.g. Installing 123/456). +""" + +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PySide6.QtCore import QTimer + + +class SummaryProgressWidget(QWidget): + """Widget showing summary progress for phases like Installing.""" + + def __init__(self, phase_name: str, current_step: int, max_steps: int, parent=None): + super().__init__(parent) + self.phase_name = phase_name + self.current_step = current_step + self.max_steps = max_steps + self._target_step = current_step + self._target_max = max_steps + self._display_step = current_step + self._display_max = max_steps + self._interpolation_timer = QTimer(self) + self._interpolation_timer.timeout.connect(self._interpolate_counter) + self._interpolation_timer.setInterval(16) + self._interpolation_timer.start() + self._setup_ui() + self._update_display() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + self.text_label = QLabel() + self.text_label.setStyleSheet("color: #ccc; font-size: 12px; font-weight: bold;") + layout.addWidget(self.text_label) + + def _interpolate_counter(self): + step_diff = self._target_step - self._display_step + if abs(step_diff) < 0.5: + self._display_step = self._target_step + else: + self._display_step += step_diff * 0.2 + + max_diff = self._target_max - self._display_max + if abs(max_diff) < 0.5: + self._display_max = self._target_max + else: + self._display_max += max_diff * 0.2 + + self._update_display() + + def _update_display(self): + display_step = int(round(self._display_step)) + display_max = int(round(self._display_max)) + + if display_max > 0: + new_text = f"{self.phase_name} ({display_step}/{display_max})" + else: + new_text = f"{self.phase_name}" + + if self.text_label.text() != new_text: + self.text_label.setText(new_text) + + def update_progress(self, current_step: int, max_steps: int): + self._target_step = current_step + self._target_max = max_steps + self.current_step = current_step + self.max_steps = max_steps diff --git a/jackify/shared/progress_models.py b/jackify/shared/progress_models.py index 3d17632..48fdb7b 100644 --- a/jackify/shared/progress_models.py +++ b/jackify/shared/progress_models.py @@ -248,7 +248,7 @@ class InstallationProgress: # Only update if we have a previous value and the change isn't too extreme if use_smoothing and self._smoothed_eta_seconds > 0: # If new ETA is wildly different (>50% change), use weighted average - # This prevents temporary speed drops from causing huge ETA jumps + # Prevent temporary speed drops from causing huge ETA jumps change_ratio = abs(eta_seconds - self._smoothed_eta_seconds) / max(self._smoothed_eta_seconds, 1.0) if change_ratio > 0.5: # Large change - use 70% old, 30% new (smooth transition) @@ -359,7 +359,7 @@ class InstallationProgress: return "Building BSAs" # For FINALIZE phase, always prefer phase_name over generic "Finalising" label - # This allows post-install steps to show specific labels (e.g., "Installing Wine components") + # Post-install steps can show specific labels if self.phase == InstallationPhase.FINALIZE and self.phase_name: return self.phase_name @@ -427,7 +427,7 @@ class InstallationProgress: def add_file(self, file_progress: FileProgress): """Add or update a file in active files list.""" # Don't re-add files that are already at 100% unless they're being actively updated - # This prevents completed files from cluttering the list + # Prevent completed files from cluttering the list if file_progress.percent >= 100.0: # Check if this file already exists at 100% existing = None @@ -438,7 +438,7 @@ class InstallationProgress: if existing and existing.percent >= 100.0: # File is already at 100% - only update if it's very recent (within 0.5s) - # This allows the completion notification to refresh the timestamp + # Completion notification refreshes the timestamp if time.time() - existing.last_update < 0.5: existing.last_update = time.time() # Otherwise, don't re-add it - let remove_completed_files handle cleanup diff --git a/jackify/tools/winetricks b/jackify/tools/winetricks index 1688406..5acdd8d 100755 --- a/jackify/tools/winetricks +++ b/jackify/tools/winetricks @@ -16398,14 +16398,14 @@ load_nook() w_metadata npp apps \ title="Notepad++" \ publisher="Don Ho" \ - year="2019" \ + year="2026" \ media="download" \ - file1="npp.7.7.1.Installer.exe" \ - installed_exe1="${W_PROGRAMS_X86_WIN}/Notepad++/notepad++.exe" + file1="npp.8.9.1.Installer.x64.exe" \ + installed_exe1="${W_PROGRAMS_WIN}/Notepad++/notepad++.exe" load_npp() { - w_download https://notepad-plus-plus.org/repository/7.x/7.7.1/npp.7.7.1.Installer.exe 6787c524b0ac30a698237ffb035f932d7132343671b8fe8f0388ed380d19a51c + w_download https://github.com/notepad-plus-plus/notepad-plus-plus/releases/download/v8.9.1/npp.8.9.1.Installer.x64.exe 06c42ea6edbbc2c1ffa74d5c3355ced51616896f41aee66372bfb55eb54ae68f w_try_cd "${W_CACHE}/${W_PACKAGE}" w_try "${WINE}" "${file1}" ${W_OPT_UNATTENDED:+/S} } @@ -16514,83 +16514,6 @@ load_openwatcom() #---------------------------------------------------------------- -w_metadata origin apps \ - title="EA Origin" \ - publisher="EA" \ - year="2011" \ - media="download" \ - file1="OriginSetup.exe" \ - file2="version_v3.dll" \ - installed_file1="${W_PROGRAMS_X86_WIN}/Origin/Origin.exe" \ - homepage="https://www.origin.com/" - -load_origin() -{ - w_download_to origin https://taskinoz.com/downloads/OriginSetup-10.5.119.52718.exe ed6ee5174f697744ac7c5783ff9021da603bbac42ae9836cd468d432cadc9779 OriginSetup.exe - w_download_to origin https://github.com/p0358/Fuck_off_EA_App/releases/download/v3/version.dll 6c2df238a5cbff3475527aa7adf1d8b76d4d2d1a33a6d62edd4749408305c2be version_v3.dll - - w_try_mkdir "${W_DRIVE_C}/ProgramData/Origin" - - w_warn "Stopping Origin from finding updates" - cat > "${W_DRIVE_C}/ProgramData/Origin/local.xml" <<_EOF_ - - - - - - - -_EOF_ - - w_try_cd "${W_CACHE}/${W_PACKAGE}" - w_try "${WINE}" "${file1}" /NoLaunch ${W_OPT_UNATTENDED:+/SILENT} - - if w_workaround_wine_bug 32342 "QtWebEngineProcess.exe crashes when updating or launching Origin (missing fonts)"; then - w_call corefonts - fi - - if w_workaround_wine_bug 36863 "Disabling Origin In-game overlay."; then - w_override_dlls disabled igoproxy.exe - w_override_dlls disabled igoproxy64.exe - fi - - if w_workaround_wine_bug 44985 "Disabling libglesv2 to make Store and Library function correctly."; then - w_override_app_dlls Origin.exe disabled libglesv2 - fi - - # Avoids "An unexpected error has occurred. Please try again in a few moments. Error: 327684:3" - # Games won't register correctly unless disabled - if w_workaround_wine_bug 52781 "Origin does not notice games exiting, does not allow them to be relaunched."; then - w_override_app_dlls Origin.exe disabled gameux - fi - - if [ "$(uname -s)" = "Darwin" ]; then - w_override_app_dlls EALink.exe disabled d3d10 - w_override_app_dlls EALink.exe disabled d3d10core - w_override_app_dlls EALink.exe disabled d3d12 - w_override_app_dlls EALink.exe disabled d3d11 - w_override_app_dlls EALink.exe disabled dxgi - w_override_app_dlls Origin.exe disabled dxgi - fi - - w_warn "Workaround Forced EA app upgrade." - w_try cp -f "${W_CACHE}/${W_PACKAGE}/version_v3.dll" "${W_PROGRAMS_X86_UNIX}/Origin/version.dll" - w_override_app_dlls Origin.exe native version - - w_warn "Pretend EA app is installed" - cat > "${W_TMP}"/ea-app.reg <<_EOF_ -REGEDIT4 - -[HKEY_LOCAL_MACHINE\\Software\\Electronic Arts\\EA Desktop] -"InstallSuccessful"="true" - -_EOF_ - w_try_regedit "${W_TMP}"/ea-app.reg - -} - -#---------------------------------------------------------------- - w_metadata procexp apps \ title="Process Explorer" \ publisher="Steve P. Miller" \