diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7bede..1a059d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,95 @@ # Jackify Changelog +## v0.2.0 - Modlist Gallery, OAuth Authentication & Performance Improvements +**Release Date:** 2025-12-06 + +### Major Features + +#### Modlist Selection Gallery +Complete overhaul of modlist selection (First pass): + +**Core Features:** +- Card-based Modlist Selection browser with modlist images, titles, authors and metadata +- Game-specific filtering automatically applied based on selected game type +- Details per card: download/install/total sizes, tags, version, badges +- Async image loading from GitHub with local 7-day caching +- Detail view with full descriptions, banner images, and external links +- Selected modlist automatically populates Install Modlist workflow + +**Search and Filtering:** +- Text search across modlist names and descriptions +- Multi-select tag filtering with normalized tags +- Show Official Only, Show NSFW, Hide Unavailable toggles +- Mod search capability - find modlists containing specific Nexus mods +- Randomised card ordering + +**Performance:** +- Gallery images loading from cache +- Background metadata and image preloading when Install Modlist screen opens +- Efficient rendering - cards created once, filters toggle visibility +- Non-blocking UI with concurrent image downloads + +**Steam Deck Optimized:** +- Dynamic card sizing (e.g 250x270 on Steam Deck, larger on desktop) +- Responsive grid layout (up to 4 columns on large screens, 3 on Steam Deck) +- Optimized spacing and padding for 1280x800 displays + +#### OAuth 2.0 Authentication +Modern authentication for Nexus Mods with secure token management: + +- One-click browser-based authorization with PKCE security +- Automatic token refresh with encrypted storage +- Authorisation status indicator on Install Modlist screen +- Works in both GUI and CLI workflows + +#### Compact Mode UI Redesign +Streamlined interface with dynamic window management: + +- Default compact mode with optional Details view +- Activity window tab (default), across all workflow screens +- Process Monitor tab still available +- Show Details toggle for console output when needed + +### Critical Fixes + +### Replaced TTW Installer +- Replaced the previous TTW Installer due to complexities with its config file + +#### GPU Texture Conversion (jackify-engine 0.4.0) +- Fixed GPU not being used for BC7/BC6H texture conversions +- Previous versions fell back to CPU-only despite GPU availability +- Added GPU toggle in Settings (enabled by default) + +#### Winetricks Compatibility & Protontricks +- Fixed bundled winetricks path incompatibility +- Hopefully fixed winetricks in cases where it failed to download components +- For now, Jackify still defaults to bundled winetricks (Protontricks toggle in settings) + +#### Steam Restart Reliability +- Enhanced Steam Restart so that is now hopefully works more reliably on all distros +- Fixed Flatpak detection blocking normal Steam start methods + +### Technical Improvements + +- Proton version usage clarified: Install Proton for installation/texture processing, Game Proton for shortcuts +- Centralised Steam detection in SystemInfo +- ConfigHandler refactored to always read fresh from disk +- Removed obsolete dotnet4.x code +- Enhanced Flatpak Steam compatdata detection with proper VDF parsing + +### Bug Fixes + +- TTW installation UI performance (batched output processing, non-blocking operations) +- Activity window animations (removed custom timers, Qt native rendering) +- Timer reset when returning from TTW screen +- Fixed bandwidth limit KB/s to bytes conversion +- Fixed AttributeError in AutomatedPrefixService.restart_steam() + +### Engine Updates +- jackify-engine 0.4.0 with GPU texture conversion fixes and refactored file progress reporting + +--- + ## v0.1.7.1 - Wine Component Verification & Flatpak Steam Fixes **Release Date:** November 11, 2025 @@ -479,6 +569,23 @@ laf - TTW Installation function using Hoolamike application - https://github.co - **Clean Architecture**: Removed obsolete service imports, initializations, and cleanup methods - **Code Quality**: Eliminated "tombstone comments" and unused service references +### Deferred Features (Available in Future Release) + +#### OAuth 2.0 Authentication for Nexus Mods +**Status:** Fully implemented but disabled pending Nexus Mods approval + +The OAuth 2.0 authentication system has been fully developed and tested, but is temporarily disabled in v0.1.8 as we await approval from Nexus Mods for our OAuth application. The backend code remains intact and will be re-enabled immediately upon approval. + +**Features (ready for deployment):** +- **Secure OAuth 2.0 + PKCE Flow**: Modern authentication to replace API key dependency +- **Encrypted Token Storage**: Tokens stored using Fernet encryption with automatic refresh +- **GUI Integration**: Clean status display on Install Modlist screen with authorize/revoke functionality +- **CLI Integration**: OAuth menu in Additional Tasks for command-line users +- **API Key Fallback**: Optional legacy API key support (configurable in Settings) +- **Unified Auth Service**: Single authentication layer supporting both OAuth and API key methods + +**Current Limitation:** Awaiting Nexus approval for `jackify://oauth/callback` custom URI. Once approved, OAuth will be enabled as the primary authentication method with API key as optional fallback. + ### Technical Details - **Single Shortcut Creation Path**: All workflows now use `run_working_workflow()` → `create_shortcut_with_native_service()` - **Service Layer Cleanup**: Removed dual codepath architecture in favor of proven automated workflows diff --git a/jackify/__init__.py b/jackify/__init__.py index 130752b..a01e7cb 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.1.7.1" +__version__ = "0.2.0" diff --git a/jackify/backend/core/modlist_operations.py b/jackify/backend/core/modlist_operations.py index 6d8b13f..4537407 100644 --- a/jackify/backend/core/modlist_operations.py +++ b/jackify/backend/core/modlist_operations.py @@ -30,6 +30,8 @@ def _get_user_proton_version(): from jackify.backend.handlers.wine_utils import WineUtils config_handler = ConfigHandler() + # Use Install Proton (not Game Proton) for installation/texture processing + # get_proton_path() returns the Install Proton path user_proton_path = config_handler.get_proton_path() if user_proton_path == 'auto': @@ -90,15 +92,15 @@ def get_jackify_engine_path(): logger.debug(f"Using engine from environment variable: {env_engine_path}") return env_engine_path - # Priority 2: PyInstaller bundle (most specific detection) + # Priority 2: Frozen bundle (most specific detection) if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): - # Running in a PyInstaller bundle + # Running inside a frozen bundle # Engine is expected at /jackify/engine/jackify-engine engine_path = os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine') if os.path.exists(engine_path): return engine_path # Fallback: log warning but continue to other detection methods - logger.warning(f"PyInstaller engine not found at expected path: {engine_path}") + logger.warning(f"Frozen-bundle engine not found at expected path: {engine_path}") # Priority 3: Check if THIS process is actually running from Jackify AppImage # (not just inheriting APPDIR from another AppImage like Cursor) @@ -123,7 +125,7 @@ def get_jackify_engine_path(): # If all else fails, log error and return the source path anyway logger.error(f"jackify-engine not found in any expected location. Tried:") - logger.error(f" PyInstaller: {getattr(sys, '_MEIPASS', 'N/A')}/jackify/engine/jackify-engine") + logger.error(f" Frozen bundle: {getattr(sys, '_MEIPASS', 'N/A')}/jackify/engine/jackify-engine") logger.error(f" AppImage: {appdir or 'N/A'}/opt/jackify/engine/jackify-engine") logger.error(f" Source: {engine_path}") logger.error("This will likely cause installation failures.") @@ -481,53 +483,76 @@ class ModlistInstallCLI: self.context['download_dir'] = download_dir_path self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") - # 5. Prompt for Nexus API key (skip if in context and valid) + # 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.api_key_service import APIKeyService - api_key_service = APIKeyService() - saved_key = api_key_service.get_saved_api_key() - api_key = None - if saved_key: - print("\n" + "-" * 28) - print(f"{COLOR_INFO}A Nexus API Key is already saved.{COLOR_RESET}") - use_saved = input(f"{COLOR_PROMPT}Use the saved API key? [Y/n]: {COLOR_RESET}").strip().lower() - if use_saved in ('', 'y', 'yes'): - api_key = saved_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 + api_key = auth_service.ensure_valid_auth() + if api_key: + self.context['nexus_api_key'] = api_key else: - new_key = input(f"{COLOR_PROMPT}Enter a new Nexus API Key (or press Enter to keep the saved one): {COLOR_RESET}").strip() - if new_key: - api_key = new_key - replace = input(f"{COLOR_PROMPT}Replace the saved key with this one? [y/N]: {COLOR_RESET}").strip().lower() - if replace == 'y': - if api_key_service.save_api_key(api_key): - print(f"{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}") - else: - print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}") + # 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 = auth_service.ensure_valid_auth() + if api_key: + self.context['nexus_api_key'] = api_key else: - print(f"{COLOR_INFO}Using new key for this session only. Saved key unchanged.{COLOR_RESET}") + print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}") + return None else: - api_key = saved_key - else: - print("\n" + "-" * 28) - print(f"{COLOR_INFO}A Nexus Mods API key is required for downloading mods.{COLOR_RESET}") - print(f"{COLOR_INFO}You can get your personal key at: {COLOR_SELECTION}https://www.nexusmods.com/users/myaccount?tab=api{COLOR_RESET}") - print(f"{COLOR_WARNING}Your API Key is NOT saved locally. It is used only for this session unless you choose to save it.{COLOR_RESET}") - api_key = input(f"{COLOR_PROMPT}Enter Nexus API Key (or 'q' to cancel): {COLOR_RESET}").strip() - if not api_key or api_key.lower() == 'q': - self.logger.info("User cancelled or provided no API key.") - return None - save = input(f"{COLOR_PROMPT}Would you like to save this API key for future use? [y/N]: {COLOR_RESET}").strip().lower() - if save == 'y': - if api_key_service.save_api_key(api_key): - print(f"{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}") - else: - print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}") + print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}") + return None else: - print(f"{COLOR_INFO}Using API key for this session only. It will not be saved.{COLOR_RESET}") - - # Set the API key in context regardless of which path was taken - self.context['nexus_api_key'] = api_key - self.logger.debug(f"NEXUS_API_KEY is set in environment for engine (presence check).") + # 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 @@ -622,11 +647,23 @@ class ModlistInstallCLI: 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}") - - if self.context.get('nexus_api_key'): - print(f"Nexus API Key: [SET]") + + # 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: - print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") + # 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}") def configuration_phase(self): @@ -719,7 +756,7 @@ class ModlistInstallCLI: # --- End Patch --- # Build command - cmd = [engine_path, 'install'] + 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): @@ -738,6 +775,12 @@ class ModlistInstallCLI: cmd.append('--debug') self.logger.info("Adding --debug flag to jackify-engine") + # Check GPU setting and add --no-gpu flag if disabled + gpu_enabled = config_handler.get('enable_gpu_texture_conversion', True) + if not gpu_enabled: + cmd.append('--no-gpu') + self.logger.info("GPU texture conversion disabled - adding --no-gpu flag to jackify-engine") + # Store original environment values to restore later original_env_values = { 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), @@ -771,9 +814,11 @@ class ModlistInstallCLI: else: self.logger.warning(f"File descriptor limit: {message}") - # Popen now inherits the modified os.environ because env=None + # 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=None, cwd=engine_dir) + 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 @@ -1512,9 +1557,21 @@ class ModlistInstallCLI: 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}") - - if self.context.get('nexus_api_key'): - print(f"Nexus API Key: [SET]") + + # 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: - print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") + # 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/handlers/config_handler.py b/jackify/backend/handlers/config_handler.py index fba8633..88909bd 100644 --- a/jackify/backend/handlers/config_handler.py +++ b/jackify/backend/handlers/config_handler.py @@ -11,7 +11,9 @@ import logging import shutil import re import base64 +import hashlib from pathlib import Path +from typing import Optional # Initialize logger logger = logging.getLogger(__name__) @@ -40,7 +42,7 @@ class ConfigHandler: self.config_dir = os.path.expanduser("~/.config/jackify") self.config_file = os.path.join(self.config_dir, "config.json") self.settings = { - "version": "0.0.5", + "version": "0.2.0", "last_selected_modlist": None, "steam_libraries": [], "resolution": None, @@ -52,13 +54,20 @@ class ConfigHandler: "modlist_install_base_dir": os.path.expanduser("~/Games"), # Configurable base directory for modlist installations "modlist_downloads_base_dir": os.path.expanduser("~/Games/Modlist_Downloads"), # Configurable base directory for downloads "jackify_data_dir": None, # Configurable Jackify data directory (default: ~/Jackify) - "use_winetricks_for_components": True, # True = use winetricks (faster), False = use protontricks for all (legacy) - "game_proton_path": None # Proton version for game shortcuts (can be any Proton 9+), separate from install proton + "use_winetricks_for_components": True, # DEPRECATED: Migrated to component_installation_method. Kept for backward compatibility. + "component_installation_method": "winetricks", # "winetricks" (default) or "system_protontricks" + "game_proton_path": None, # Proton version for game shortcuts (can be any Proton 9+), separate from install proton + "steam_restart_strategy": "jackify", # "jackify" (default) or "nak_simple" + "window_width": None, # Saved window width (None = use dynamic sizing) + "window_height": None # Saved window height (None = use dynamic sizing) } # Load configuration if exists self._load_config() + # Perform version migrations + self._migrate_config() + # If steam_path is not set, detect it if not self.settings["steam_path"]: self.settings["steam_path"] = self._detect_steam_path() @@ -115,7 +124,10 @@ class ConfigHandler: return None def _load_config(self): - """Load configuration from file""" + """ + Load configuration from file and update in-memory cache. + For legacy compatibility with initialization code. + """ try: if os.path.exists(self.config_file): with open(self.config_file, 'r') as f: @@ -129,6 +141,78 @@ class ConfigHandler: except Exception as e: logger.error(f"Error loading configuration: {e}") + def _migrate_config(self): + """ + Migrate configuration between versions + Handles breaking changes and data format updates + """ + current_version = self.settings.get("version", "0.0.0") + target_version = "0.2.0" + + if current_version == target_version: + return + + logger.info(f"Migrating config from {current_version} to {target_version}") + + # Migration: v0.0.x -> v0.2.0 + # Encryption changed from cryptography (Fernet) to pycryptodome (AES-GCM) + # Old encrypted API keys cannot be decrypted, must be re-entered + if current_version < "0.2.0": + # Clear old encrypted credentials + if self.settings.get("nexus_api_key"): + logger.warning("Clearing saved API key due to encryption format change") + logger.warning("Please re-enter your Nexus API key in Settings") + self.settings["nexus_api_key"] = None + + # Clear OAuth token file (different encryption format) + oauth_token_file = Path(self.config_dir) / "nexus-oauth.json" + if oauth_token_file.exists(): + logger.warning("Clearing saved OAuth token due to encryption format change") + logger.warning("Please re-authorize with Nexus Mods") + try: + oauth_token_file.unlink() + except Exception as e: + logger.error(f"Failed to remove old OAuth token: {e}") + + # Remove obsolete keys + obsolete_keys = [ + "hoolamike_install_path", + "hoolamike_version", + "api_key_fallback_enabled", + "proton_version", # Display string only, path stored in proton_path + "game_proton_version" # Display string only, path stored in game_proton_path + ] + + removed_count = 0 + for key in obsolete_keys: + if key in self.settings: + del self.settings[key] + removed_count += 1 + + if removed_count > 0: + logger.info(f"Removed {removed_count} obsolete config keys") + + # Update version + self.settings["version"] = target_version + self.save_config() + logger.info("Config migration completed") + + def _read_config_from_disk(self): + """ + Read configuration directly from disk without caching. + Returns merged config (defaults + saved values). + """ + try: + config = self.settings.copy() # Start with defaults + if os.path.exists(self.config_file): + with open(self.config_file, 'r') as f: + saved_config = json.load(f) + config.update(saved_config) + return config + except Exception as e: + logger.error(f"Error reading configuration from disk: {e}") + return self.settings.copy() + def reload_config(self): """Reload configuration from disk to pick up external changes""" self._load_config() @@ -154,8 +238,12 @@ class ConfigHandler: return False def get(self, key, default=None): - """Get a configuration value by key""" - return self.settings.get(key, default) + """ + Get a configuration value by key. + Always reads fresh from disk to avoid stale data. + """ + config = self._read_config_from_disk() + return config.get(key, default) def set(self, key, value): """Set a configuration value""" @@ -214,48 +302,178 @@ class ConfigHandler: """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 + + 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 + + # 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 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 base64 encoding - + 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: - # Encode the API key using base64 - encoded_key = base64.b64encode(api_key.encode('utf-8')).decode('utf-8') - self.settings["nexus_api_key"] = encoded_key - logger.debug("API key saved successfully") + # 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") - - return self.save_config() + + 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 decode the saved Nexus API key - Always reads fresh from disk to pick up changes from other instances - + Retrieve and decrypt the saved Nexus API key. + Always reads fresh from disk. + Returns: - str: Decoded API key or None if not saved + str: Decrypted API key or None if not saved """ try: - # Reload config from disk to pick up changes from Settings dialog - self._load_config() - encoded_key = self.settings.get("nexus_api_key") - if encoded_key: - # Decode the base64 encoded key - decoded_key = base64.b64decode(encoded_key.encode('utf-8')).decode('utf-8') - return decoded_key + 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}") @@ -263,15 +481,14 @@ class ConfigHandler: def has_saved_api_key(self): """ - Check if an API key is saved in configuration - Always reads fresh from disk to pick up changes from other instances - + Check if an API key is saved in configuration. + Always reads fresh from disk. + Returns: bool: True if API key exists, False otherwise """ - # Reload config from disk to pick up changes from Settings dialog - self._load_config() - return self.settings.get("nexus_api_key") is not None + config = self._read_config_from_disk() + return config.get("nexus_api_key") is not None def clear_api_key(self): """ @@ -519,16 +736,15 @@ class ConfigHandler: def get_proton_path(self): """ - Retrieve the saved Install Proton path from configuration (for jackify-engine) - Always reads fresh from disk to pick up changes from Settings dialog + Retrieve the saved Install Proton path from configuration (for jackify-engine). + Always reads fresh from disk. Returns: str: Saved Install Proton path or 'auto' if not saved """ try: - # Reload config from disk to pick up changes from Settings dialog - self._load_config() - proton_path = self.settings.get("proton_path", "auto") + config = self._read_config_from_disk() + proton_path = config.get("proton_path", "auto") logger.debug(f"Retrieved fresh install proton_path from config: {proton_path}") return proton_path except Exception as e: @@ -537,21 +753,20 @@ class ConfigHandler: 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 to pick up changes from Settings dialog + 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 'auto' if not saved """ try: - # Reload config from disk to pick up changes from Settings dialog - self._load_config() - game_proton_path = self.settings.get("game_proton_path") + 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 = self.settings.get("proton_path", "auto") + game_proton_path = config.get("proton_path", "auto") logger.debug(f"Retrieved fresh game proton_path from config: {game_proton_path}") return game_proton_path @@ -561,16 +776,15 @@ class ConfigHandler: def get_proton_version(self): """ - Retrieve the saved Proton version from configuration - Always reads fresh from disk to pick up changes from Settings dialog + Retrieve the saved Proton version from configuration. + Always reads fresh from disk. Returns: str: Saved Proton version or 'auto' if not saved """ try: - # Reload config from disk to pick up changes from Settings dialog - self._load_config() - proton_version = self.settings.get("proton_version", "auto") + 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: diff --git a/jackify/backend/handlers/hoolamike_handler.py b/jackify/backend/handlers/hoolamike_handler.py deleted file mode 100644 index 12962c3..0000000 --- a/jackify/backend/handlers/hoolamike_handler.py +++ /dev/null @@ -1,1415 +0,0 @@ -import logging -import os -import subprocess -import zipfile -import tarfile -from pathlib import Path -import yaml # Assuming PyYAML is installed -from typing import Dict, Optional, List -import requests - -# Import necessary handlers from the current Jackify structure -from .path_handler import PathHandler -from .vdf_handler import VDFHandler # Keeping just in case -from .filesystem_handler import FileSystemHandler -from .config_handler import ConfigHandler -# Import color constants needed for print statements in this module -from .ui_colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_SELECTION -from .logging_handler import LoggingHandler -from .status_utils import show_status, clear_status -from .subprocess_utils import get_clean_subprocess_env - -logger = logging.getLogger(__name__) - -# Define default Hoolamike AppIDs for relevant games -TARGET_GAME_APPIDS = { - 'Fallout 3': '22370', # GOTY Edition - 'Fallout New Vegas': '22380', # Base game - 'Skyrim Special Edition': '489830', - 'Oblivion': '22330', # GOTY Edition - 'Fallout 4': '377160' -} - -# Define the expected name of the native Hoolamike executable -HOOLAMIKE_EXECUTABLE_NAME = "hoolamike" # Assuming this is the binary name -# Keep consistent with logs directory - use ~/Jackify/ for user-visible managed components -JACKIFY_BASE_DIR = Path.home() / "Jackify" -# Use Jackify base directory for ALL Hoolamike-related files to centralize management -DEFAULT_HOOLAMIKE_APP_INSTALL_DIR = JACKIFY_BASE_DIR / "Hoolamike" -HOOLAMIKE_CONFIG_DIR = DEFAULT_HOOLAMIKE_APP_INSTALL_DIR -HOOLAMIKE_CONFIG_FILENAME = "hoolamike.yaml" -# Default dirs for other components -DEFAULT_HOOLAMIKE_DOWNLOADS_DIR = JACKIFY_BASE_DIR / "Mod_Downloads" -DEFAULT_MODLIST_INSTALL_BASE_DIR = Path.home() / "ModdedGames" - -class HoolamikeHandler: - """Handles discovery, configuration, and execution of Hoolamike tasks. - Assumes Hoolamike is a native Linux CLI application. - """ - - def __init__(self, steamdeck: bool, verbose: bool, filesystem_handler: FileSystemHandler, config_handler: ConfigHandler, menu_handler=None): - """Initialize the handler and perform initial discovery.""" - self.steamdeck = steamdeck - self.verbose = verbose - self.path_handler = PathHandler() - self.filesystem_handler = filesystem_handler - self.config_handler = config_handler - self.menu_handler = menu_handler - # Set up dedicated log file for TTW operations - logging_handler = LoggingHandler() - logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log') - self.logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log') - - # --- Discovered/Managed State --- - self.game_install_paths: Dict[str, Path] = {} - # Allow user override for Hoolamike app install path later - self.hoolamike_app_install_path: Path = DEFAULT_HOOLAMIKE_APP_INSTALL_DIR - self.hoolamike_executable_path: Optional[Path] = None # Path to the binary - self.hoolamike_installed: bool = False - self.hoolamike_config_path: Path = HOOLAMIKE_CONFIG_DIR / HOOLAMIKE_CONFIG_FILENAME - self.hoolamike_config: Optional[Dict] = None - - # Load Hoolamike install path from Jackify config if it exists - saved_path_str = self.config_handler.get('hoolamike_install_path') - if saved_path_str and Path(saved_path_str).is_dir(): # Basic check if path exists - self.hoolamike_app_install_path = Path(saved_path_str) - self.logger.info(f"Loaded Hoolamike install path from Jackify config: {self.hoolamike_app_install_path}") - - self._load_hoolamike_config() - self._run_discovery() - - def _ensure_hoolamike_dirs_exist(self): - """Ensure base directories for Hoolamike exist.""" - try: - HOOLAMIKE_CONFIG_DIR.mkdir(parents=True, exist_ok=True) # Separate Hoolamike config - self.hoolamike_app_install_path.mkdir(parents=True, exist_ok=True) # Install dir (~/Jackify/Hoolamike) - # Default downloads dir also needs to exist if we reference it - DEFAULT_HOOLAMIKE_DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) - except OSError as e: - self.logger.error(f"Error creating Hoolamike directories: {e}", exc_info=True) - # Decide how to handle this - maybe raise an exception? - - def _check_hoolamike_installation(self): - """Check if Hoolamike executable exists at the expected location. - Prioritizes path stored in config if available. - """ - potential_exe_path = self.hoolamike_app_install_path / HOOLAMIKE_EXECUTABLE_NAME - check_path = None - if potential_exe_path.is_file() and os.access(potential_exe_path, os.X_OK): - check_path = potential_exe_path - self.logger.info(f"Found Hoolamike at current path: {check_path}") - else: - self.logger.info(f"Hoolamike executable ({HOOLAMIKE_EXECUTABLE_NAME}) not found or not executable at current path {self.hoolamike_app_install_path}.") - - # Update state based on whether we found a valid path - if check_path: - self.hoolamike_installed = True - self.hoolamike_executable_path = check_path - else: - self.hoolamike_installed = False - self.hoolamike_executable_path = None - - def _generate_default_config(self) -> Dict: - """Generates the default configuration dictionary.""" - self.logger.info("Generating default Hoolamike config structure.") - # Detection is now handled separately after loading config - detected_paths = self.path_handler.find_game_install_paths(TARGET_GAME_APPIDS) - - config = { - "downloaders": { - "downloads_directory": str(DEFAULT_HOOLAMIKE_DOWNLOADS_DIR), - "nexus": {"api_key": "YOUR_API_KEY_HERE"} - }, - "installation": { - "wabbajack_file_path": "", # Placeholder, set per-run - "installation_path": "" # Placeholder, set per-run - }, - "games": { # Only include detected games with consistent formatting (no spaces) - self._format_game_name(game_name): {"root_directory": str(path)} - for game_name, path in detected_paths.items() - }, - "fixup": { - "game_resolution": "1920x1080" - }, - "extras": { - "tale_of_two_wastelands": { - "path_to_ttw_mpi_file": "", # Placeholder - "variables": { - "DESTINATION": "" # Placeholder - } - } - } - } - # Add comment if no games detected - if not detected_paths: - # This won't appear in YAML, logic adjusted below - pass - return config - - def _format_game_name(self, game_name: str) -> str: - """Formats game name for Hoolamike configuration (removes spaces). - - Hoolamike expects game names without spaces like: Fallout3, FalloutNewVegas, SkyrimSpecialEdition - """ - # Handle specific game name formats that Hoolamike expects - game_name_map = { - "Fallout 3": "Fallout3", - "Fallout New Vegas": "FalloutNewVegas", - "Skyrim Special Edition": "SkyrimSpecialEdition", - "Fallout 4": "Fallout4", - "Oblivion": "Oblivion" # No change needed - } - - # Use predefined mapping if available - if game_name in game_name_map: - return game_name_map[game_name] - - # Otherwise, just remove spaces as fallback - return game_name.replace(" ", "") - - def _load_hoolamike_config(self): - """Load hoolamike.yaml if it exists, or generate a default one.""" - self._ensure_hoolamike_dirs_exist() # Ensure parent dir exists - - if self.hoolamike_config_path.is_file(): - self.logger.info(f"Found existing hoolamike.yaml at {self.hoolamike_config_path}. Loading...") - try: - with open(self.hoolamike_config_path, 'r', encoding='utf-8') as f: - self.hoolamike_config = yaml.safe_load(f) - if not isinstance(self.hoolamike_config, dict): - self.logger.warning(f"Failed to parse hoolamike.yaml as a dictionary. Generating default.") - self.hoolamike_config = self._generate_default_config() - self.save_hoolamike_config() # Save the newly generated default - else: - self.logger.info("Successfully loaded hoolamike.yaml configuration.") - # Game path merging is handled in _run_discovery now - except yaml.YAMLError as e: - self.logger.error(f"Error parsing hoolamike.yaml: {e}. The file may be corrupted.") - # Don't automatically overwrite - let user decide - self.hoolamike_config = None - return False - except Exception as e: - self.logger.error(f"Error reading hoolamike.yaml: {e}.", exc_info=True) - # Don't automatically overwrite - let user decide - self.hoolamike_config = None - return False - else: - self.logger.info(f"hoolamike.yaml not found at {self.hoolamike_config_path}. Generating default configuration.") - self.hoolamike_config = self._generate_default_config() - self.save_hoolamike_config() - - return True - - def save_hoolamike_config(self): - """Saves the current configuration dictionary to hoolamike.yaml.""" - if self.hoolamike_config is None: - self.logger.error("Cannot save config, internal config dictionary is None.") - return False - - self._ensure_hoolamike_dirs_exist() # Ensure parent dir exists - self.logger.info(f"Saving configuration to {self.hoolamike_config_path}") - try: - with open(self.hoolamike_config_path, 'w', encoding='utf-8') as f: - # Add comments conditionally - f.write("# Configuration file created or updated by Jackify\n") - if not self.hoolamike_config.get("games"): - f.write("# No games were detected by Jackify. Add game paths manually if needed.\n") - # Dump the actual YAML - yaml.dump(self.hoolamike_config, f, default_flow_style=False, sort_keys=False, width=float('inf')) - self.logger.info("Configuration saved successfully.") - return True - except Exception as e: - self.logger.error(f"Error saving hoolamike.yaml: {e}", exc_info=True) - return False - - def _run_discovery(self): - """Execute all discovery steps.""" - self.logger.info("Starting Hoolamike feature discovery phase...") - - # Check if Hoolamike is installed - self._check_hoolamike_installation() - - # Detect game paths and update internal state + config - self._detect_and_update_game_paths() - - self.logger.info("Hoolamike discovery phase complete.") - - def _detect_and_update_game_paths(self): - """Detect game install paths and update state and config.""" - self.logger.info("Detecting game install paths...") - # Always run detection - detected_paths = self.path_handler.find_game_install_paths(TARGET_GAME_APPIDS) - self.game_install_paths = detected_paths # Update internal state - self.logger.info(f"Detected game paths: {detected_paths}") - - # Update the loaded config if it exists - if self.hoolamike_config is not None: - self.logger.debug("Updating loaded hoolamike.yaml with detected game paths.") - if "games" not in self.hoolamike_config or not isinstance(self.hoolamike_config.get("games"), dict): - self.hoolamike_config["games"] = {} # Ensure games section exists - - # Define a unified format for game names in config - no spaces - # Clear existing entries first to avoid duplicates - self.hoolamike_config["games"] = {} - - # Add detected paths with proper formatting - no spaces - for game_name, detected_path in detected_paths.items(): - formatted_name = self._format_game_name(game_name) - self.hoolamike_config["games"][formatted_name] = {"root_directory": str(detected_path)} - - self.logger.info(f"Updated config with {len(detected_paths)} game paths using correct naming format (no spaces)") - - # Save the updated config to disk so Hoolamike can read it - if detected_paths: - self.logger.info("Saving updated game paths to hoolamike.yaml") - self.save_hoolamike_config() - else: - self.logger.warning("Cannot update game paths in config because config is not loaded.") - - # --- Methods for Hoolamike Tasks --- - # GUI-safe, non-interactive installer used by Install TTW screen - def install_hoolamike(self, install_dir: Optional[Path] = None) -> tuple[bool, str]: - """Non-interactive install/update of Hoolamike for GUI usage. - - Downloads the latest Linux x86_64 release from GitHub, extracts it to the - Jackify-managed directory (~/Jackify/Hoolamike by default or provided install_dir), - sets executable permissions, and saves the install path to Jackify config. - - Returns: - (success, message) - """ - try: - self._ensure_hoolamike_dirs_exist() - # Determine target install directory - target_dir = Path(install_dir) if install_dir else self.hoolamike_app_install_path - target_dir.mkdir(parents=True, exist_ok=True) - - # Fetch latest release info - release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest" - self.logger.info(f"Fetching latest Hoolamike release info 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") - - linux_asset = None - for asset in data.get("assets", []): - name = asset.get("name", "").lower() - if "linux" in name and (name.endswith(".tar.gz") or name.endswith(".tgz") or name.endswith(".zip")) and ("x86_64" in name or "amd64" in name): - linux_asset = asset - break - - if not linux_asset: - return False, "No suitable Linux x86_64 Hoolamike asset found in latest release" - - 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" - - # Download to target directory - temp_path = target_dir / asset_name - if not self.filesystem_handler.download_file(download_url, temp_path, overwrite=True, quiet=True): - return False, "Failed to download Hoolamike asset" - - # Extract - try: - if asset_name.lower().endswith((".tar.gz", ".tgz")): - with tarfile.open(temp_path, "r:*") as tar: - tar.extractall(path=target_dir) - elif asset_name.lower().endswith(".zip"): - with zipfile.ZipFile(temp_path, "r") as zf: - zf.extractall(target_dir) - else: - return False, f"Unknown archive format: {asset_name}" - finally: - try: - temp_path.unlink(missing_ok=True) # cleanup - except Exception: - pass - - # Ensure executable bit on binary - exe_path = target_dir / HOOLAMIKE_EXECUTABLE_NAME - if not exe_path.is_file(): - # Some archives may include a subfolder; try to locate the binary - for p in target_dir.rglob(HOOLAMIKE_EXECUTABLE_NAME): - if p.is_file(): - exe_path = p - break - if not exe_path.is_file(): - return False, "Hoolamike binary not found after extraction" - try: - os.chmod(exe_path, 0o755) - except Exception as e: - self.logger.warning(f"Failed to chmod +x on {exe_path}: {e}") - - # Mark installed and persist path - self.hoolamike_app_install_path = target_dir - self.hoolamike_executable_path = exe_path - self.hoolamike_installed = True - self.config_handler.set('hoolamike_install_path', str(target_dir)) - if release_tag: - self.config_handler.set('hoolamike_version', str(release_tag)) - self.config_handler.save_config() - - return True, f"Hoolamike installed at {target_dir}" - except Exception as e: - self.logger.error("Hoolamike installation failed", exc_info=True) - return False, f"Error installing Hoolamike: {e}" - - def get_installed_hoolamike_version(self) -> Optional[str]: - """Return the installed Hoolamike version stored in Jackify config, if any.""" - try: - v = self.config_handler.get('hoolamike_version') - return str(v) if v else None - except Exception: - return None - - def is_hoolamike_update_available(self) -> tuple[bool, Optional[str], Optional[str]]: - """ - Check GitHub for the latest Hoolamike release and compare with installed version. - Returns (update_available, installed_version, latest_version). - """ - installed = self.get_installed_hoolamike_version() - try: - release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest" - resp = requests.get(release_url, timeout=10, verify=True) - resp.raise_for_status() - latest = resp.json().get('tag_name') or resp.json().get('name') - if not latest: - return (False, installed, None) - if not installed: - # No version recorded but installed may exist; treat as update available - return (True, None, latest) - return (installed != str(latest), installed, str(latest)) - except Exception: - return (False, installed, None) - - def install_update_hoolamike(self, context=None) -> bool: - """Install or update Hoolamike application. - - Returns: - bool: True if installation/update was successful or process was properly cancelled, - False if a critical error occurred. - """ - self.logger.info("Starting Hoolamike Installation/Update...") - print("\nStarting Hoolamike Installation/Update...") - - # 1. Prompt user to install/reinstall/update - try: - # Check if Hoolamike is already installed at the expected path - self._check_hoolamike_installation() - if self.hoolamike_installed: - self.logger.info(f"Hoolamike appears to be installed at: {self.hoolamike_executable_path}") - print(f"{COLOR_INFO}Hoolamike is already installed at:{COLOR_RESET}") - print(f" {self.hoolamike_executable_path}") - # Use a menu-style prompt for reinstall/update - print(f"\n{COLOR_PROMPT}Choose an action for Hoolamike:{COLOR_RESET}") - print(f" 1. Reinstall/Update Hoolamike") - print(f" 2. Keep existing installation (return to menu)") - while True: - choice = input(f"Select an option [1-2]: ").strip() - if choice == '1': - self.logger.info("User chose to reinstall/update Hoolamike.") - break - elif choice == '2' or choice.lower() == 'q': - self.logger.info("User chose to keep existing Hoolamike installation.") - print("Skipping Hoolamike installation/update.") - return True - else: - print(f"{COLOR_WARNING}Invalid choice. Please enter 1 or 2.{COLOR_RESET}") - # 2. Get installation directory from user (allow override) - self.logger.info(f"Default install path: {self.hoolamike_app_install_path}") - print("\nHoolamike Installation Directory:") - print(f"Default: {self.hoolamike_app_install_path}") - install_dir = self.menu_handler.get_directory_path( - prompt_message=f"Specify where to install Hoolamike (or press Enter for default)", - default_path=self.hoolamike_app_install_path, - create_if_missing=True, - no_header=True - ) - if install_dir is None: - self.logger.warning("User cancelled Hoolamike installation path selection.") - print("Installation cancelled.") - return True - # Check if hoolamike already exists at this specific path - potential_existing_exe = install_dir / HOOLAMIKE_EXECUTABLE_NAME - if potential_existing_exe.is_file() and os.access(potential_existing_exe, os.X_OK): - self.logger.info(f"Hoolamike executable found at the chosen path: {potential_existing_exe}") - print(f"{COLOR_INFO}Hoolamike appears to already be installed at:{COLOR_RESET}") - print(f" {install_dir}") - # Use menu-style prompt for overwrite - print(f"{COLOR_PROMPT}Choose an action for the existing installation:{COLOR_RESET}") - print(f" 1. Download and overwrite (update)") - print(f" 2. Keep existing installation (return to menu)") - while True: - overwrite_choice = input(f"Select an option [1-2]: ").strip() - if overwrite_choice == '1': - self.logger.info("User chose to update (overwrite) existing Hoolamike installation.") - break - elif overwrite_choice == '2' or overwrite_choice.lower() == 'q': - self.logger.info("User chose to keep existing Hoolamike installation at chosen path.") - print("Update cancelled. Using existing installation for this session.") - self.hoolamike_app_install_path = install_dir - self.hoolamike_executable_path = potential_existing_exe - self.hoolamike_installed = True - return True - else: - print(f"{COLOR_WARNING}Invalid choice. Please enter 1 or 2.{COLOR_RESET}") - # Proceed with install/update - self.logger.info(f"Proceeding with installation to directory: {install_dir}") - self.hoolamike_app_install_path = install_dir - # Get latest release info from GitHub - release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest" - download_url = None - asset_name = None - try: - self.logger.info(f"Fetching latest release info from {release_url}") - show_status("Fetching latest Hoolamike release info...") - response = requests.get(release_url, timeout=15, verify=True) - response.raise_for_status() - release_data = response.json() - self.logger.debug(f"GitHub Release Data: {release_data}") - linux_tar_asset = None - linux_zip_asset = None - for asset in release_data.get('assets', []): - name = asset.get('name', '').lower() - self.logger.debug(f"Checking asset: {name}") - is_linux = 'linux' in name - is_x64 = 'x86_64' in name or 'amd64' in name - is_incompatible_arch = 'arm' in name or 'aarch64' in name or 'darwin' in name - if is_linux and is_x64 and not is_incompatible_arch: - if name.endswith(('.tar.gz', '.tgz')): - linux_tar_asset = asset - self.logger.debug(f"Found potential tar asset: {name}") - break - elif name.endswith('.zip') and not linux_tar_asset: - linux_zip_asset = asset - self.logger.debug(f"Found potential zip asset: {name}") - chosen_asset = linux_tar_asset or linux_zip_asset - if not chosen_asset: - clear_status() - self.logger.error("Could not find a suitable Linux x86_64 download asset (tar.gz/zip) in the latest release.") - print(f"{COLOR_ERROR}Error: Could not find a linux x86_64 download asset in the latest Hoolamike release.{COLOR_RESET}") - return False - download_url = chosen_asset.get('browser_download_url') - asset_name = chosen_asset.get('name') - if not download_url or not asset_name: - clear_status() - self.logger.error(f"Chosen asset is missing URL or name: {chosen_asset}") - print(f"{COLOR_ERROR}Error: Found asset but could not get download details.{COLOR_RESET}") - return False - self.logger.info(f"Found asset '{asset_name}' for download: {download_url}") - clear_status() - except requests.exceptions.RequestException as e: - clear_status() - self.logger.error(f"Failed to fetch release info from GitHub: {e}") - print(f"Error: Failed to contact GitHub to check for Hoolamike updates: {e}") - return False - except Exception as e: - clear_status() - self.logger.error(f"Error parsing release info: {e}", exc_info=True) - print("Error: Failed to understand release information from GitHub.") - return False - # Download the asset - show_status(f"Downloading {asset_name}...") - temp_download_path = self.hoolamike_app_install_path / asset_name - if not self.filesystem_handler.download_file(download_url, temp_download_path, overwrite=True, quiet=True): - clear_status() - self.logger.error(f"Failed to download {asset_name} from {download_url}") - print(f"{COLOR_ERROR}Error: Failed to download Hoolamike asset.{COLOR_RESET}") - return False - clear_status() - self.logger.info(f"Downloaded {asset_name} successfully to {temp_download_path}") - show_status("Extracting Hoolamike archive...") - # Extract the asset - try: - if asset_name.lower().endswith(('.tar.gz', '.tgz')): - self.logger.debug(f"Extracting tar file: {temp_download_path}") - with tarfile.open(temp_download_path, 'r:*') as tar: - tar.extractall(path=self.hoolamike_app_install_path) - self.logger.info("Extracted tar file successfully.") - elif asset_name.lower().endswith('.zip'): - self.logger.debug(f"Extracting zip file: {temp_download_path}") - with zipfile.ZipFile(temp_download_path, 'r') as zip_ref: - zip_ref.extractall(self.hoolamike_app_install_path) - self.logger.info("Extracted zip file successfully.") - else: - clear_status() - self.logger.error(f"Unknown archive format for asset: {asset_name}") - print(f"{COLOR_ERROR}Error: Unknown file type '{asset_name}'. Cannot extract.{COLOR_RESET}") - return False - clear_status() - print("Extraction complete. Setting permissions...") - except (tarfile.TarError, zipfile.BadZipFile, EOFError) as e: - clear_status() - self.logger.error(f"Failed to extract archive {temp_download_path}: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error: Failed to extract downloaded file: {e}{COLOR_RESET}") - return False - except Exception as e: - clear_status() - self.logger.error(f"An unexpected error occurred during extraction: {e}", exc_info=True) - print(f"{COLOR_ERROR}An unexpected error occurred during extraction.{COLOR_RESET}") - return False - finally: - # Clean up downloaded archive - if temp_download_path.exists(): - try: - temp_download_path.unlink() - self.logger.debug(f"Removed temporary download file: {temp_download_path}") - except OSError as e: - self.logger.warning(f"Could not remove temporary download file {temp_download_path}: {e}") - # Set execute permissions on the binary - executable_path = self.hoolamike_app_install_path / HOOLAMIKE_EXECUTABLE_NAME - if executable_path.is_file(): - try: - show_status("Setting permissions on Hoolamike executable...") - os.chmod(executable_path, 0o755) - self.logger.info(f"Set execute permissions (+x) on {executable_path}") - clear_status() - print("Permissions set successfully.") - except OSError as e: - clear_status() - self.logger.error(f"Failed to set execute permission on {executable_path}: {e}") - print(f"{COLOR_ERROR}Error: Could not set execute permission on Hoolamike executable.{COLOR_RESET}") - else: - clear_status() - self.logger.error(f"Hoolamike executable not found after extraction at {executable_path}") - print(f"{COLOR_ERROR}Error: Hoolamike executable missing after extraction!{COLOR_RESET}") - return False - # Update self.hoolamike_installed and self.hoolamike_executable_path state - self.logger.info("Refreshing Hoolamike installation status...") - self._check_hoolamike_installation() - if not self.hoolamike_installed: - self.logger.error("Hoolamike check failed after apparent successful install/extract.") - print(f"{COLOR_ERROR}Error: Installation completed, but failed final verification check.{COLOR_RESET}") - return False - # Save install path to Jackify config - self.logger.info(f"Saving Hoolamike install path to Jackify config: {self.hoolamike_app_install_path}") - self.config_handler.set('hoolamike_install_path', str(self.hoolamike_app_install_path)) - if not self.config_handler.save_config(): - self.logger.warning("Failed to save Jackify config file after updating Hoolamike path.") - # Non-fatal, but warn user? - print(f"{COLOR_WARNING}Warning: Could not save installation path to main Jackify config file.{COLOR_RESET}") - print(f"{COLOR_SUCCESS}Hoolamike installation/update successful!{COLOR_RESET}") - self.logger.info("Hoolamike install/update process completed successfully.") - return True - except Exception as e: - self.logger.error(f"Error during Hoolamike installation/update: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error: An unexpected error occurred during Hoolamike installation/update: {e}{COLOR_RESET}") - return False - - def install_modlist(self, wabbajack_path=None, install_path=None, downloads_path=None, premium=False, api_key=None, game_resolution=None, context=None): - """ - Install a Wabbajack modlist using Hoolamike, following Jackify's Discovery/Configuration/Confirmation pattern. - """ - self.logger.info("Starting Hoolamike modlist install (Discovery Phase)") - self._check_hoolamike_installation() - menu = self.menu_handler - print(f"\n{'='*60}") - print(f"{COLOR_INFO}Hoolamike Modlist Installation{COLOR_RESET}") - print(f"{'='*60}\n") - - # --- Discovery Phase --- - # 1. Auto-detect games (robust, multi-library) - detected_games = self.path_handler.find_vanilla_game_paths() - # 2. Prompt for .wabbajack file (custom prompt, only accept .wabbajack, q to exit, with tab-completion) - print() - while not wabbajack_path: - print(f"{COLOR_WARNING}This option requires a Nexus Mods Premium account for automatic downloads.{COLOR_RESET}") - print(f"If you don't have a premium account, please use the '{COLOR_SELECTION}Non-Premium Installation{COLOR_RESET}' option from the previous menu instead.\n") - print(f"Before continuing, you'll need a .wabbajack file. You can usually find these at:") - print(f" 1. {COLOR_INFO}https://build.wabbajack.org/authored_files{COLOR_RESET} - Official Wabbajack modlist repository") - print(f" 2. {COLOR_INFO}https://www.nexusmods.com/{COLOR_RESET} - Some modlist authors publish on Nexus Mods") - print(f" 3. Various Discord communities for specific modlists\n") - print(f"{COLOR_WARNING}NOTE: Download the .wabbajack file first, then continue. Enter 'q' to exit.{COLOR_RESET}\n") - # Use menu.get_existing_file_path for tab-completion - candidate = menu.get_existing_file_path( - prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", - extension_filter=".wabbajack", - no_header=True - ) - if candidate is None: - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - # If user literally typed 'q', treat as cancel - if str(candidate).strip().lower() == 'q': - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - wabbajack_path = candidate - # 3. Prompt for install directory - print() - while True: - install_path_result = menu.get_directory_path( - prompt_message="Select the directory where the modlist should be installed:", - default_path=DEFAULT_MODLIST_INSTALL_BASE_DIR / wabbajack_path.stem, - create_if_missing=True, - no_header=False - ) - if not install_path_result: - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - # Handle tuple (path, should_create) - if isinstance(install_path_result, tuple): - install_path, install_should_create = install_path_result - else: - install_path, install_should_create = install_path_result, False - # Check if directory exists and is not empty - if install_path.exists() and any(install_path.iterdir()): - print(f"{COLOR_WARNING}Warning: The selected directory '{install_path}' already exists and is not empty. Its contents may be overwritten!{COLOR_RESET}") - confirm = input(f"{COLOR_PROMPT}This directory is not empty and may be overwritten. Proceed? (y/N): {COLOR_RESET}").strip().lower() - if not confirm.startswith('y'): - print(f"{COLOR_INFO}Please select a different directory.\n{COLOR_RESET}") - continue - break - # 4. Prompt for downloads directory - print() - if not downloads_path: - downloads_path_result = menu.get_directory_path( - prompt_message="Select the directory for mod downloads:", - default_path=DEFAULT_HOOLAMIKE_DOWNLOADS_DIR, - create_if_missing=True, - no_header=False - ) - if not downloads_path_result: - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - # Handle tuple (path, should_create) - if isinstance(downloads_path_result, tuple): - downloads_path, downloads_should_create = downloads_path_result - else: - downloads_path, downloads_should_create = downloads_path_result, False - else: - downloads_should_create = False - # 5. Nexus API key - print() - current_api_key = self.hoolamike_config.get('downloaders', {}).get('nexus', {}).get('api_key') if self.hoolamike_config else None - if not current_api_key or current_api_key == 'YOUR_API_KEY_HERE': - api_key = menu.get_nexus_api_key(current_api_key) - if not api_key: - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - else: - api_key = current_api_key - - # --- Summary & Confirmation --- - print(f"\n{'-'*60}") - print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}") - print(f"- Wabbajack file: {wabbajack_path}") - print(f"- Install directory: {install_path}") - print(f"- Downloads directory: {downloads_path}") - print(f"- Nexus API key: [{'Set' if api_key else 'Not Set'}]") - print("- Games:") - for game in ["Fallout 3", "Fallout New Vegas", "Skyrim Special Edition", "Oblivion", "Fallout 4"]: - found = detected_games.get(game) - print(f" {game}: {found if found else 'Not Found'}") - print(f"{'-'*60}") - print(f"{COLOR_WARNING}Proceed with these settings and start Hoolamike install? (Warning: This can take MANY HOURS){COLOR_RESET}") - confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower() - if confirm and not confirm.startswith('y'): - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - # --- Actually create directories if needed --- - if install_should_create and not install_path.exists(): - try: - install_path.mkdir(parents=True, exist_ok=True) - print(f"{COLOR_SUCCESS}Install directory created: {install_path}{COLOR_RESET}") - except Exception as e: - print(f"{COLOR_ERROR}Failed to create install directory: {e}{COLOR_RESET}") - return False - if downloads_should_create and not downloads_path.exists(): - try: - downloads_path.mkdir(parents=True, exist_ok=True) - print(f"{COLOR_SUCCESS}Downloads directory created: {downloads_path}{COLOR_RESET}") - except Exception as e: - print(f"{COLOR_ERROR}Failed to create downloads directory: {e}{COLOR_RESET}") - return False - - # --- Configuration Phase --- - # Prepare config dict - config = { - "downloaders": { - "downloads_directory": str(downloads_path), - "nexus": {"api_key": api_key} - }, - "installation": { - "wabbajack_file_path": str(wabbajack_path), - "installation_path": str(install_path) - }, - "games": { - self._format_game_name(game): {"root_directory": str(path)} - for game, path in detected_games.items() - }, - "fixup": { - "game_resolution": "1920x1080" - }, - # Resolution intentionally omitted - # "extras": {}, - # No 'jackify_managed' key here - } - self.hoolamike_config = config - if not self.save_hoolamike_config(): - print(f"{COLOR_ERROR}Failed to save hoolamike.yaml. Aborting.{COLOR_RESET}") - return False - - # --- Run Hoolamike --- - print(f"\n{COLOR_INFO}Starting Hoolamike...{COLOR_RESET}") - print(f"{COLOR_INFO}Streaming output below. Press Ctrl+C to cancel and return to Jackify menu.{COLOR_RESET}\n") - # Defensive: Ensure executable path is set and valid - if not self.hoolamike_executable_path or not Path(self.hoolamike_executable_path).is_file(): - print(f"{COLOR_ERROR}Error: Hoolamike executable not found or not set. Please (re)install Hoolamike from the menu before continuing.{COLOR_RESET}") - input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") - return False - try: - cmd = [str(self.hoolamike_executable_path), "install"] - ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env()) - if ret == 0: - print(f"\n{COLOR_SUCCESS}Hoolamike completed successfully!{COLOR_RESET}") - input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") - return True - else: - print(f"\n{COLOR_ERROR}Hoolamike process failed with exit code {ret}.{COLOR_RESET}") - input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") - return False - except KeyboardInterrupt: - print(f"\n{COLOR_WARNING}Hoolamike install interrupted by user. Returning to menu.{COLOR_RESET}") - input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") - return False - except Exception as e: - print(f"\n{COLOR_ERROR}Error running Hoolamike: {e}{COLOR_RESET}") - input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") - return False - - def install_ttw_backend(self, ttw_mpi_path, ttw_output_path): - """Clean backend function for TTW installation - no user interaction. - - Args: - ttw_mpi_path: Path to the TTW installer .mpi file (required) - ttw_output_path: Target installation directory for TTW (required) - - Returns: - tuple: (success: bool, message: str) - """ - self.logger.info(f"Starting Tale of Two Wastelands installation via Hoolamike") - - # Validate required 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" - - # Convert to Path objects - ttw_mpi_path = Path(ttw_mpi_path) - ttw_output_path = Path(ttw_output_path) - - # Validate paths exist - if not ttw_mpi_path.exists(): - return False, f"TTW .mpi file not found: {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 Hoolamike installation - self._check_hoolamike_installation() - - # Ensure config is loaded - if self.hoolamike_config is None: - loaded = self._load_hoolamike_config() - if not loaded or self.hoolamike_config is None: - self.logger.error("Failed to load or generate hoolamike.yaml configuration.") - return False, "Failed to load or generate Hoolamike configuration" - - # Verify required games are detected - 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: - self.logger.error(f"Missing required games for TTW installation: {', '.join(missing_games)}") - return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas." - - # Update TTW configuration - self._update_hoolamike_config_for_ttw(ttw_mpi_path, ttw_output_path) - if not self.save_hoolamike_config(): - self.logger.error("Failed to save hoolamike.yaml configuration.") - return False, "Failed to save Hoolamike configuration" - - # Construct and execute command - cmd = [ - str(self.hoolamike_executable_path), - "tale-of-two-wastelands" - ] - self.logger.info(f"Executing Hoolamike command: {' '.join(cmd)}") - - try: - ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env()) - 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 Hoolamike TTW installation: {e}", exc_info=True) - return False, f"Error executing Hoolamike TTW installation: {e}" - - def install_ttw(self, ttw_mpi_path=None, ttw_output_path=None, context=None): - """CLI interface for TTW installation - handles user interaction and calls backend. - - Args: - ttw_mpi_path: Path to the TTW installer .mpi file (optional for CLI) - ttw_output_path: Target installation directory for TTW (optional for CLI) - - Returns: - bool: True if successful, False otherwise - """ - menu = self.menu_handler - print(f"\n{'='*60}") - print(f"{COLOR_INFO}Hoolamike: Tale of Two Wastelands Installation{COLOR_RESET}") - print(f"{'='*60}\n") - print(f"This feature will install Tale of Two Wastelands (TTW) using Hoolamike.") - print(f"Requirements:") - print(f" • Fallout 3 and Fallout New Vegas must be installed and detected.") - print(f" • You must provide the path to your TTW .mpi installer file.") - print(f" • You must select an output directory for the TTW install.\n") - - # If parameters provided, use them directly - if ttw_mpi_path and ttw_output_path: - print(f"{COLOR_INFO}Using provided parameters:{COLOR_RESET}") - print(f"- TTW .mpi file: {ttw_mpi_path}") - print(f"- Output directory: {ttw_output_path}") - print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}") - confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower() - if confirm and not confirm.startswith('y'): - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - else: - # Interactive mode - collect user input - print(f"{COLOR_INFO}Please provide the path to your TTW .mpi installer file.{COLOR_RESET}") - print(f"You can download this from: {COLOR_INFO}https://mod.pub/ttw/133/files{COLOR_RESET}") - print(f"(Extract the .mpi file from the downloaded archive.)\n") - while not ttw_mpi_path: - candidate = menu.get_existing_file_path( - prompt_message="Enter the path to your TTW .mpi file (or 'q' to cancel):", - extension_filter=".mpi", - no_header=True - ) - if candidate is None: - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - if str(candidate).strip().lower() == 'q': - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - ttw_mpi_path = candidate - - # Prompt for output directory - print(f"\n{COLOR_INFO}Please select the output directory where TTW will be installed.{COLOR_RESET}") - print(f"(This should be an empty or new directory.)\n") - while not ttw_output_path: - ttw_output_path = menu.get_directory_path( - prompt_message="Select the TTW output directory:", - default_path=self.hoolamike_app_install_path / "TTW_Output", - create_if_missing=True, - no_header=False - ) - if not ttw_output_path: - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - if ttw_output_path.exists() and any(ttw_output_path.iterdir()): - print(f"{COLOR_WARNING}Warning: The selected directory '{ttw_output_path}' already exists and is not empty. Its contents may be overwritten!{COLOR_RESET}") - confirm = input(f"{COLOR_PROMPT}This directory is not empty and may be overwritten. Proceed? (y/N): {COLOR_RESET}").strip().lower() - if not confirm.startswith('y'): - print(f"{COLOR_INFO}Please select a different directory.\n{COLOR_RESET}") - ttw_output_path = None - continue - - # Summary & Confirmation - print(f"\n{'-'*60}") - print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}") - print(f"- TTW .mpi file: {ttw_mpi_path}") - print(f"- Output directory: {ttw_output_path}") - print(f"{'-'*60}") - print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}") - confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower() - if confirm and not confirm.startswith('y'): - print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") - return False - - # Call the clean backend function - success, message = self.install_ttw_backend(ttw_mpi_path, ttw_output_path) - - if success: - print(f"\n{COLOR_SUCCESS}{message}{COLOR_RESET}") - - # Offer to create MO2 zip archive - print(f"\n{COLOR_INFO}Would you like to create a zipped mod archive for MO2?{COLOR_RESET}") - print(f"This will package the TTW files for easy installation into Mod Organizer 2.") - create_zip = input(f"{COLOR_PROMPT}Create zip archive? [Y/n]: {COLOR_RESET}").strip().lower() - - if not create_zip or create_zip.startswith('y'): - zip_success = self._create_ttw_mod_archive_cli(ttw_mpi_path, ttw_output_path) - if not zip_success: - print(f"\n{COLOR_WARNING}Archive creation failed, but TTW installation completed successfully.{COLOR_RESET}") - else: - print(f"\n{COLOR_INFO}Skipping archive creation. You can manually use the TTW files from the output directory.{COLOR_RESET}") - - input(f"\n{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") - return True - else: - print(f"\n{COLOR_ERROR}{message}{COLOR_RESET}") - input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") - return False - - def _update_hoolamike_config_for_ttw(self, ttw_mpi_path: Path, ttw_output_path: Path): - """Update the Hoolamike configuration with settings for TTW installation.""" - # Ensure extras and TTW sections exist - if "extras" not in self.hoolamike_config: - self.hoolamike_config["extras"] = {} - - if "tale_of_two_wastelands" not in self.hoolamike_config["extras"]: - self.hoolamike_config["extras"]["tale_of_two_wastelands"] = { - "variables": {} - } - - # Update TTW configuration - ttw_config = self.hoolamike_config["extras"]["tale_of_two_wastelands"] - ttw_config["path_to_ttw_mpi_file"] = str(ttw_mpi_path) - - # Ensure variables section exists - if "variables" not in ttw_config: - ttw_config["variables"] = {} - - # Set destination variable - ttw_config["variables"]["DESTINATION"] = str(ttw_output_path) - - # Set USERPROFILE to Fallout New Vegas Wine prefix Documents folder - userprofile_path = self._detect_fallout_nv_userprofile() - if "variables" not in self.hoolamike_config["extras"]["tale_of_two_wastelands"]: - self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"] = {} - self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"]["USERPROFILE"] = userprofile_path - - # Make sure game paths are set correctly using proper Hoolamike naming format - for game in ['Fallout 3', 'Fallout New Vegas']: - if game in self.game_install_paths: - # Use _format_game_name to ensure correct naming (removes spaces) - formatted_game_name = self._format_game_name(game) - - if "games" not in self.hoolamike_config: - self.hoolamike_config["games"] = {} - - if formatted_game_name not in self.hoolamike_config["games"]: - self.hoolamike_config["games"][formatted_game_name] = {} - - self.hoolamike_config["games"][formatted_game_name]["root_directory"] = str(self.game_install_paths[game]) - - self.logger.info("Updated Hoolamike configuration with TTW settings.") - - def _create_ttw_mod_archive_cli(self, ttw_mpi_path: Path, ttw_output_path: Path) -> bool: - """Create a zipped mod archive of TTW output for MO2 installation (CLI version). - - Args: - ttw_mpi_path: Path to the TTW .mpi file (used for version extraction) - ttw_output_path: Path to the TTW output directory to archive - - Returns: - bool: True if successful, False otherwise - """ - try: - import shutil - import re - - if not ttw_output_path.exists(): - print(f"{COLOR_ERROR}Output directory does not exist: {ttw_output_path}{COLOR_RESET}") - return False - - # Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4") - version_suffix = "" - if ttw_mpi_path: - mpi_filename = ttw_mpi_path.stem # Get filename without extension - # Look for version pattern like "3.4", "v3.4", etc. - 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 - [NoDelete] prefix is used by MO2 workflows - archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}" - - # Place archive in parent directory of output - archive_path = ttw_output_path.parent / archive_name - - print(f"\n{COLOR_INFO}Creating mod archive: {archive_name}.zip{COLOR_RESET}") - print(f"{COLOR_INFO}This may take several minutes...{COLOR_RESET}") - - # Create the zip archive - # shutil.make_archive returns the path without .zip extension - final_archive = shutil.make_archive( - str(archive_path), # base name (without extension) - 'zip', # format - str(ttw_output_path) # directory to archive - ) - - print(f"\n{COLOR_SUCCESS}Archive created successfully: {Path(final_archive).name}{COLOR_RESET}") - print(f"{COLOR_INFO}Location: {final_archive}{COLOR_RESET}") - print(f"{COLOR_INFO}You can now install this archive as a mod in MO2.{COLOR_RESET}") - - self.logger.info(f"Created TTW mod archive: {final_archive}") - return True - - except Exception as e: - print(f"\n{COLOR_ERROR}Failed to create mod archive: {e}{COLOR_RESET}") - self.logger.error(f"Failed to create TTW mod archive: {e}", exc_info=True) - return False - - def _detect_fallout_nv_userprofile(self) -> str: - """ - Detect the Fallout New Vegas Wine prefix Documents folder for USERPROFILE. - - Returns: - str: Path to the Fallout New Vegas Wine prefix Documents folder, - or fallback to Jackify-managed directory if not found. - """ - try: - # Fallout New Vegas AppID - fnv_appid = "22380" - - # Find the compatdata directory for Fallout New Vegas - compatdata_path = self.path_handler.find_compat_data(fnv_appid) - if not compatdata_path: - self.logger.warning(f"Could not find compatdata directory for Fallout New Vegas (AppID: {fnv_appid})") - # Fallback to Jackify-managed directory - fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE") - self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}") - return fallback_path - - # Construct the Wine prefix Documents path - wine_documents_path = compatdata_path / "pfx" / "drive_c" / "users" / "steamuser" / "Documents" / "My Games" / "FalloutNV" - - if wine_documents_path.exists(): - self.logger.info(f"Found Fallout New Vegas Wine prefix Documents folder: {wine_documents_path}") - return str(wine_documents_path) - else: - self.logger.warning(f"Fallout New Vegas Wine prefix Documents folder not found at: {wine_documents_path}") - # Fallback to Jackify-managed directory - fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE") - self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}") - return fallback_path - - except Exception as e: - self.logger.error(f"Error detecting Fallout New Vegas USERPROFILE: {e}", exc_info=True) - # Fallback to Jackify-managed directory - fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE") - self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}") - return fallback_path - - def reset_config(self): - """Resets the hoolamike.yaml to default settings, backing up any existing file.""" - if self.hoolamike_config_path.is_file(): - # Create a backup with timestamp - import datetime - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - backup_path = self.hoolamike_config_path.with_suffix(f".{timestamp}.bak") - try: - import shutil - shutil.copy2(self.hoolamike_config_path, backup_path) - self.logger.info(f"Created backup of existing config at {backup_path}") - print(f"{COLOR_INFO}Created backup of existing config at {backup_path}{COLOR_RESET}") - except Exception as e: - self.logger.error(f"Failed to create backup of config: {e}") - print(f"{COLOR_WARNING}Warning: Failed to create backup of config: {e}{COLOR_RESET}") - - # Generate and save a fresh default config - self.logger.info("Generating new default configuration") - self.hoolamike_config = self._generate_default_config() - if self.save_hoolamike_config(): - self.logger.info("Successfully reset config to defaults") - print(f"{COLOR_SUCCESS}Successfully reset configuration to defaults.{COLOR_RESET}") - return True - else: - self.logger.error("Failed to save new default config") - print(f"{COLOR_ERROR}Failed to save new default configuration.{COLOR_RESET}") - return False - - def edit_hoolamike_config(self): - """Opens the hoolamike.yaml file in a chosen editor, with a 0 option to return to menu.""" - self.logger.info("Task: Edit Hoolamike Config started...") - self._check_hoolamike_installation() - if not self.hoolamike_installed: - self.logger.warning("Cannot edit config - Hoolamike not installed") - print(f"\n{COLOR_WARNING}Hoolamike is not installed through Jackify yet.{COLOR_RESET}") - print(f"Please use option 1 from the Hoolamike menu to install Hoolamike first.") - print(f"This will ensure that Jackify can properly manage the Hoolamike configuration.") - return False - if self.hoolamike_config is None: - self.logger.warning("Config is not loaded properly. Will attempt to fix or create.") - print(f"\n{COLOR_WARNING}Configuration file may be corrupted or not accessible.{COLOR_RESET}") - print("Options:") - print("1. Reset to default configuration (backup will be created)") - print("2. Try to edit the file anyway (may be corrupted)") - print("0. Cancel and return to menu") - choice = input("\nEnter your choice (0-2): ").strip() - if choice == "1": - if not self.reset_config(): - self.logger.error("Failed to reset configuration") - print(f"{COLOR_ERROR}Failed to reset configuration. See logs for details.{COLOR_RESET}") - return - elif choice == "2": - self.logger.warning("User chose to edit potentially corrupted config") - # Continue to editing - elif choice == "0": - self.logger.info("User cancelled editing corrupted config") - print("Edit cancelled.") - return - else: - self.logger.info("User cancelled editing corrupted config") - print("Edit cancelled.") - return - if not self.hoolamike_config_path.exists(): - self.logger.warning(f"Hoolamike config file does not exist at {self.hoolamike_config_path}. Generating default before editing.") - self.hoolamike_config = self._generate_default_config() - self.save_hoolamike_config() - if not self.hoolamike_config_path.exists(): - self.logger.error("Failed to create config file for editing.") - print("Error: Could not create configuration file.") - return - available_editors = ["nano", "vim", "vi", "gedit", "kate", "micro"] - preferred_editor = os.environ.get("EDITOR") - found_editors = {} - import shutil - for editor_name in available_editors: - editor_path = shutil.which(editor_name) - if editor_path and editor_path not in found_editors.values(): - found_editors[editor_name] = editor_path - if preferred_editor: - preferred_editor_path = shutil.which(preferred_editor) - if preferred_editor_path and preferred_editor_path not in found_editors.values(): - display_name = os.path.basename(preferred_editor) if '/' in preferred_editor else preferred_editor - if display_name not in found_editors: - found_editors[display_name] = preferred_editor_path - if not found_editors: - self.logger.error("No suitable text editors found on the system.") - print(f"{COLOR_ERROR}Error: No common text editors (nano, vim, gedit, kate, micro) found.{COLOR_RESET}") - return - sorted_editor_names = sorted(found_editors.keys()) - print("\nSelect an editor to open the configuration file:") - print(f"(System default EDITOR is: {preferred_editor if preferred_editor else 'Not set'})") - for i, name in enumerate(sorted_editor_names): - print(f" {i + 1}. {name}") - print(f" 0. Return to Hoolamike Menu") - while True: - try: - choice = input(f"Enter choice (0-{len(sorted_editor_names)}): ").strip() - if choice == "0": - print("Edit cancelled.") - return - choice_index = int(choice) - 1 - if 0 <= choice_index < len(sorted_editor_names): - chosen_name = sorted_editor_names[choice_index] - editor_to_use_path = found_editors[chosen_name] - break - else: - print("Invalid choice.") - except ValueError: - print("Invalid input. Please enter a number.") - except KeyboardInterrupt: - print("\nEdit cancelled.") - return - if editor_to_use_path: - self.logger.info(f"Launching editor '{editor_to_use_path}' for {self.hoolamike_config_path}") - try: - process = subprocess.Popen([editor_to_use_path, str(self.hoolamike_config_path)]) - process.wait() - self.logger.info(f"Editor '{editor_to_use_path}' closed. Reloading config...") - if not self._load_hoolamike_config(): - self.logger.error("Failed to load config after editing. It may still be corrupted.") - print(f"{COLOR_ERROR}Warning: The configuration file could not be parsed after editing.{COLOR_RESET}") - print("You may need to fix it manually or reset it to defaults.") - return False - else: - self.logger.info("Successfully reloaded config after editing.") - print(f"{COLOR_SUCCESS}Configuration file successfully updated.{COLOR_RESET}") - return True - except FileNotFoundError: - self.logger.error(f"Editor '{editor_to_use_path}' not found unexpectedly.") - print(f"{COLOR_ERROR}Error: Editor command '{editor_to_use_path}' not found.{COLOR_RESET}") - except Exception as e: - self.logger.error(f"Error launching or waiting for editor: {e}") - print(f"{COLOR_ERROR}An error occurred while launching the editor: {e}{COLOR_RESET}") - - @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 - import re - - # 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"Failed to integrate TTW into modlist: {e}") - import traceback - logger.error(traceback.format_exc()) - return False - -# Example usage (for testing, remove later) -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') - print("Running HoolamikeHandler discovery...") - handler = HoolamikeHandler(steamdeck=False, verbose=True) - print("\n--- Discovery Results ---") - print(f"Game Paths: {handler.game_install_paths}") - print(f"Hoolamike App Install Path: {handler.hoolamike_app_install_path}") - print(f"Hoolamike Executable: {handler.hoolamike_executable_path}") - print(f"Hoolamike Installed: {handler.hoolamike_installed}") - print(f"Hoolamike Config Path: {handler.hoolamike_config_path}") - config_loaded = isinstance(handler.hoolamike_config, dict) - print(f"Hoolamike Config Loaded: {config_loaded}") - if config_loaded: - print(f" Downloads Dir: {handler.hoolamike_config.get('downloaders', {}).get('downloads_directory')}") - print(f" API Key Set: {'Yes' if handler.hoolamike_config.get('downloaders', {}).get('nexus', {}).get('api_key') != 'YOUR_API_KEY_HERE' else 'No'}") - print("-------------------------") - # Test edit config (example) - # handler.edit_hoolamike_config() \ No newline at end of file diff --git a/jackify/backend/handlers/menu_handler.py b/jackify/backend/handlers/menu_handler.py index 0039f8c..10c16fe 100644 --- a/jackify/backend/handlers/menu_handler.py +++ b/jackify/backend/handlers/menu_handler.py @@ -863,60 +863,6 @@ class MenuHandler: self.logger.debug("_clear_screen: Clearing screen for POSIX by printing 100 newlines.") print("\n" * 100, flush=True) - def show_hoolamike_menu(self, cli_instance): - """Show the Hoolamike Modlist Management menu""" - if not hasattr(cli_instance, 'hoolamike_handler') or cli_instance.hoolamike_handler is None: - try: - from .hoolamike_handler import HoolamikeHandler - cli_instance.hoolamike_handler = HoolamikeHandler( - steamdeck=getattr(cli_instance, 'steamdeck', False), - verbose=getattr(cli_instance, 'verbose', False), - filesystem_handler=getattr(cli_instance, 'filesystem_handler', None), - config_handler=getattr(cli_instance, 'config_handler', None), - menu_handler=self - ) - except Exception as e: - self.logger.error(f"Failed to initialize Hoolamike features: {e}", exc_info=True) - print(f"{COLOR_ERROR}Error: Failed to initialize Hoolamike features. Check logs.{COLOR_RESET}") - input("\nPress Enter to return to the main menu...") - return # Exit this menu if handler fails - - while True: - self._clear_screen() - # Banner display handled by frontend - # Use print_section_header for consistency if available, otherwise manual with COLOR_SELECTION - if hasattr(self, 'print_section_header'): # Check if method exists (it's from ui_utils) - print_section_header("Hoolamike Modlist Management") - else: # Fallback if not imported or available directly on self - print(f"{COLOR_SELECTION}Hoolamike Modlist Management{COLOR_RESET}") - print(f"{COLOR_SELECTION}{'-'*30}{COLOR_RESET}") - - print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install or Update Hoolamike App") - print(f"{COLOR_SELECTION}2.{COLOR_RESET} Install Modlist (Nexus Premium)") - print(f"{COLOR_SELECTION}3.{COLOR_RESET} Install Modlist (Non-Premium) {COLOR_DISABLED}(Not Implemented){COLOR_RESET}") - print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Tale of Two Wastelands (TTW)") - print(f"{COLOR_SELECTION}5.{COLOR_RESET} Edit Hoolamike Configuration") - print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu") - selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip() - - if selection.lower() == 'q': # Allow 'q' to re-display menu - continue - if selection == "1": - cli_instance.hoolamike_handler.install_update_hoolamike() - elif selection == "2": - cli_instance.hoolamike_handler.install_modlist(premium=True) - elif selection == "3": - print(f"{COLOR_INFO}Install Modlist (Non-Premium) is not yet implemented.{COLOR_RESET}") - input("\nPress Enter to return to the Hoolamike menu...") - elif selection == "4": - cli_instance.hoolamike_handler.install_ttw() - elif selection == "5": - cli_instance.hoolamike_handler.edit_hoolamike_config() - elif selection == "0": - break - else: - print("Invalid selection. Please try again.") - time.sleep(1) diff --git a/jackify/backend/handlers/modlist_handler.py b/jackify/backend/handlers/modlist_handler.py index e5bc448..7e6e7d9 100644 --- a/jackify/backend/handlers/modlist_handler.py +++ b/jackify/backend/handlers/modlist_handler.py @@ -571,15 +571,19 @@ class ModlistHandler: 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). """ - # 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.") + 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 @@ -706,15 +710,18 @@ class ModlistHandler: 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) if success: self.logger.info("Wine component installation completed successfully") @@ -920,16 +927,25 @@ class ModlistHandler: 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 self.path_handler.create_dxvk_conf( + 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 - ): - self.logger.warning("Failed to create dxvk.conf file.") - print("Warning: Failed to create dxvk.conf file.") + 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 diff --git a/jackify/backend/handlers/modlist_install_cli.py b/jackify/backend/handlers/modlist_install_cli.py index 5e947a2..753273f 100644 --- a/jackify/backend/handlers/modlist_install_cli.py +++ b/jackify/backend/handlers/modlist_install_cli.py @@ -49,7 +49,7 @@ logger = logging.getLogger(__name__) # Standard logger init # Helper function to get path to jackify-install-engine def get_jackify_engine_path(): if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): - # Running in a PyInstaller bundle + # Running inside the bundled AppImage (frozen) # Engine is expected at /jackify/engine/jackify-engine return os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine') else: @@ -408,51 +408,76 @@ class ModlistInstallCLI: self.context['download_dir'] = download_dir_path self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") - # 5. Prompt for Nexus API key (skip if in context) + # 5. Get Nexus authentication (OAuth or API key) if 'nexus_api_key' not in self.context: - from jackify.backend.services.api_key_service import APIKeyService - api_key_service = APIKeyService() - saved_key = api_key_service.get_saved_api_key() - api_key = None - if saved_key: - print("\n" + "-" * 28) - print(f"{COLOR_INFO}A Nexus API Key is already saved.{COLOR_RESET}") - use_saved = input(f"{COLOR_PROMPT}Use the saved API key? [Y/n]: {COLOR_RESET}").strip().lower() - if use_saved in ('', 'y', 'yes'): - api_key = saved_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 + api_key = auth_service.ensure_valid_auth() + if api_key: + self.context['nexus_api_key'] = api_key else: - new_key = input(f"{COLOR_PROMPT}Enter a new Nexus API Key (or press Enter to keep the saved one): {COLOR_RESET}").strip() - if new_key: - api_key = new_key - replace = input(f"{COLOR_PROMPT}Replace the saved key with this one? [y/N]: {COLOR_RESET}").strip().lower() - if replace == 'y': - if api_key_service.save_api_key(api_key): - print(f"{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}") - else: - print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}") + # 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 = auth_service.ensure_valid_auth() + if api_key: + self.context['nexus_api_key'] = api_key else: - print(f"{COLOR_INFO}Using new key for this session only. Saved key unchanged.{COLOR_RESET}") + print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}") + return None else: - api_key = saved_key - else: - print("\n" + "-" * 28) - print(f"{COLOR_INFO}A Nexus Mods API key is required for downloading mods.{COLOR_RESET}") - print(f"{COLOR_INFO}You can get your personal key at: {COLOR_SELECTION}https://www.nexusmods.com/users/myaccount?tab=api{COLOR_RESET}") - print(f"{COLOR_WARNING}Your API Key is NOT saved locally. It is used only for this session unless you choose to save it.{COLOR_RESET}") - api_key = input(f"{COLOR_PROMPT}Enter Nexus API Key (or 'q' to cancel): {COLOR_RESET}").strip() - if not api_key or api_key.lower() == 'q': - self.logger.info("User cancelled or provided no API key.") - return None - save = input(f"{COLOR_PROMPT}Would you like to save this API key for future use? [y/N]: {COLOR_RESET}").strip().lower() - if save == 'y': - if api_key_service.save_api_key(api_key): - print(f"{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}") - else: - print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}") + print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}") + return None else: - print(f"{COLOR_INFO}Using API key for this session only. It will not be saved.{COLOR_RESET}") - self.context['nexus_api_key'] = api_key - self.logger.debug(f"NEXUS_API_KEY is set in environment for engine (presence check).") + # 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 @@ -501,11 +526,23 @@ class ModlistInstallCLI: 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}") - - if self.context.get('nexus_api_key'): - print(f"Nexus API Key: [SET]") + + # 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: - print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") + # 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}") def configuration_phase(self): @@ -597,8 +634,8 @@ class ModlistInstallCLI: # --- End Patch --- # Build command - cmd = [engine_path, 'install'] - + 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() @@ -606,7 +643,13 @@ class ModlistInstallCLI: if debug_mode: cmd.append('--debug') self.logger.info("Debug mode enabled in config - passing --debug flag to jackify-engine") - + + # Check GPU setting and add --no-gpu flag if disabled + gpu_enabled = config_handler.get('enable_gpu_texture_conversion', True) + if not gpu_enabled: + cmd.append('--no-gpu') + self.logger.info("GPU texture conversion disabled - passing --no-gpu 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') @@ -667,8 +710,10 @@ class ModlistInstallCLI: else: self.logger.warning(f"File descriptor limit: {message}") - # Popen now inherits the modified os.environ because env=None - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=None, cwd=engine_dir) + # 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 @@ -1101,11 +1146,23 @@ class ModlistInstallCLI: 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}") - - if self.context.get('nexus_api_key'): - print(f"Nexus API Key: [SET]") + + # 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: - print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") + # 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}") def _enhance_nexus_error(self, line: str) -> str: @@ -1234,52 +1291,65 @@ class ModlistInstallCLI: print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}") # Import TTW installation handler - from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler + from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler from jackify.backend.models.configuration import SystemInfo + from pathlib import Path system_info = SystemInfo() - hoolamike_handler = HoolamikeHandler(system_info) + 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 Hoolamike is installed - is_installed, installed_version = hoolamike_handler.check_installation_status() + # Check if TTW_Linux_Installer is installed + ttw_installer_handler._check_installation() - if not is_installed: - print(f"{COLOR_INFO}Hoolamike (TTW installer) is not installed.{COLOR_RESET}") - user_input = input(f"{COLOR_PROMPT}Install Hoolamike? (yes/no): {COLOR_RESET}").strip().lower() + 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 Hoolamike - print(f"{COLOR_INFO}Installing Hoolamike...{COLOR_RESET}") - success, message = hoolamike_handler.install_hoolamike() + # 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 Hoolamike: {message}{COLOR_RESET}") + print(f"{COLOR_ERROR}Failed to install TTW_Linux_Installer: {message}{COLOR_RESET}") return - print(f"{COLOR_INFO}Hoolamike installed successfully.{COLOR_RESET}") + print(f"{COLOR_INFO}TTW_Linux_Installer installed successfully.{COLOR_RESET}") - # Get Hoolamike MPI path - mpi_path = hoolamike_handler.get_mpi_path() - if not mpi_path or not os.path.exists(mpi_path): - print(f"{COLOR_ERROR}Hoolamike MPI file not found at: {mpi_path}{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}") - print(f"Default: {os.path.join(install_dir, 'TTW')}") + 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 = os.path.join(install_dir, "TTW") + ttw_install_dir = default_ttw_dir - # Run Hoolamike installation - print(f"\n{COLOR_INFO}Installing TTW using Hoolamike...{COLOR_RESET}") + # 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 = hoolamike_handler.run_hoolamike_install(mpi_path, ttw_install_dir) + success, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir)) if success: print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}") @@ -1289,6 +1359,7 @@ class ModlistInstallCLI: 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) diff --git a/jackify/backend/handlers/oauth_token_handler.py b/jackify/backend/handlers/oauth_token_handler.py new file mode 100644 index 0000000..9d27d31 --- /dev/null +++ b/jackify/backend/handlers/oauth_token_handler.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +OAuth Token Handler +Handles encrypted storage and retrieval of OAuth tokens +""" + +import os +import json +import base64 +import hashlib +import logging +import time +from typing import Optional, Dict +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class OAuthTokenHandler: + """ + Handles OAuth token storage with simple encryption + Stores tokens in ~/.config/jackify/nexus-oauth.json + """ + + def __init__(self, config_dir: Optional[str] = None): + """ + Initialize token handler + + Args: + config_dir: Optional custom config directory (defaults to ~/.config/jackify) + """ + if config_dir: + self.config_dir = Path(config_dir) + else: + self.config_dir = Path.home() / ".config" / "jackify" + + self.token_file = self.config_dir / "nexus-oauth.json" + + # Ensure config directory exists + self.config_dir.mkdir(parents=True, exist_ok=True) + + # Generate encryption key based on machine-specific data + self._encryption_key = self._generate_encryption_key() + + def _generate_encryption_key(self) -> bytes: + """ + Generate encryption key based on machine-specific data using Fernet + + Uses hostname + username + machine ID as key material, similar to DPAPI approach. + This provides proper symmetric encryption while remaining machine-specific. + + Returns: + Fernet-compatible 32-byte encryption key + """ + import socket + import getpass + + try: + hostname = socket.gethostname() + username = getpass.getuser() + + # Try to get machine ID for additional entropy + machine_id = None + try: + # Linux machine-id + with open('/etc/machine-id', 'r') as f: + machine_id = f.read().strip() + except: + try: + # Alternative locations + with open('/var/lib/dbus/machine-id', 'r') as f: + machine_id = f.read().strip() + except: + pass + + # Combine multiple sources of machine-specific data + 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 32-byte key using SHA256 for Fernet + # Fernet requires base64-encoded 32-byte key + key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest() + return base64.urlsafe_b64encode(key_bytes) + + def _encrypt_data(self, data: str) -> str: + """ + Encrypt data using AES-GCM (authenticated encryption) + + Uses pycryptodome for cross-platform compatibility. + AES-GCM provides authenticated encryption similar to Fernet. + + Args: + data: Plain text data + + Returns: + Encrypted data as base64 string (nonce:ciphertext:tag format) + """ + try: + from Crypto.Cipher import AES + from Crypto.Random import get_random_bytes + + # Derive 32-byte AES key from encryption_key (which is base64-encoded) + key = base64.urlsafe_b64decode(self._encryption_key) + + # Generate random nonce (12 bytes for GCM) + nonce = get_random_bytes(12) + + # Create AES-GCM cipher + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + + # Encrypt and get authentication tag + data_bytes = data.encode('utf-8') + ciphertext, tag = cipher.encrypt_and_digest(data_bytes) + + # Combine nonce:ciphertext:tag and base64 encode + combined = nonce + ciphertext + tag + return base64.b64encode(combined).decode('utf-8') + + except ImportError: + logger.error("pycryptodome package not available for token encryption") + return "" + except Exception as e: + logger.error(f"Failed to encrypt data: {e}") + return "" + + def _decrypt_data(self, encrypted_data: str) -> Optional[str]: + """ + Decrypt data using AES-GCM (authenticated encryption) + + Args: + encrypted_data: Encrypted data string (base64-encoded nonce:ciphertext:tag) + + Returns: + Decrypted plain text or None on failure + """ + try: + from Crypto.Cipher import AES + + # Derive 32-byte AES key from encryption_key + key = base64.urlsafe_b64decode(self._encryption_key) + + # Decode base64 and split nonce:ciphertext:tag + combined = base64.b64decode(encrypted_data.encode('utf-8')) + nonce = combined[:12] + tag = combined[-16:] + ciphertext = combined[12:-16] + + # Create AES-GCM cipher + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + + # Decrypt and verify authentication tag + plaintext = cipher.decrypt_and_verify(ciphertext, tag) + + return plaintext.decode('utf-8') + + except ImportError: + logger.error("pycryptodome package not available for token decryption") + return None + except Exception as e: + logger.error(f"Failed to decrypt data: {e}") + return None + + def save_token(self, token_data: Dict) -> bool: + """ + Save OAuth token to encrypted file with proper permissions + + Args: + token_data: Token data dict from OAuth response + + Returns: + True if saved successfully + """ + try: + # Add timestamp for tracking + token_data['_saved_at'] = int(time.time()) + + # Convert to JSON + json_data = json.dumps(token_data, indent=2) + + # Encrypt using Fernet + encrypted = self._encrypt_data(json_data) + + if not encrypted: + logger.error("Encryption failed, cannot save token") + return False + + # Save to file with restricted permissions + # Write to temp file first, then move (atomic operation) + import tempfile + fd, temp_path = tempfile.mkstemp(dir=self.config_dir, prefix='.oauth_tmp_') + + try: + with os.fdopen(fd, 'w') as f: + json.dump({'encrypted_data': encrypted}, f, indent=2) + + # Set restrictive permissions (owner read/write only) + os.chmod(temp_path, 0o600) + + # Atomic move + os.replace(temp_path, self.token_file) + + logger.info(f"Saved encrypted OAuth token to {self.token_file}") + return True + + except Exception as e: + # Clean up temp file on error + try: + os.unlink(temp_path) + except: + pass + raise e + + except Exception as e: + logger.error(f"Failed to save OAuth token: {e}") + return False + + def load_token(self) -> Optional[Dict]: + """ + Load OAuth token from encrypted file + + Returns: + Token data dict or None if not found or invalid + """ + if not self.token_file.exists(): + logger.debug("No OAuth token file found") + return None + + try: + # Load encrypted data + with open(self.token_file, 'r') as f: + data = json.load(f) + + encrypted = data.get('encrypted_data') + if not encrypted: + logger.error("Token file missing encrypted_data field") + return None + + # Decrypt + decrypted = self._decrypt_data(encrypted) + if not decrypted: + logger.error("Failed to decrypt token data") + return None + + # Parse JSON + token_data = json.loads(decrypted) + + logger.debug("Successfully loaded OAuth token") + return token_data + + except json.JSONDecodeError as e: + logger.error(f"Token file contains invalid JSON: {e}") + return None + except Exception as e: + logger.error(f"Failed to load OAuth token: {e}") + return None + + def delete_token(self) -> bool: + """ + Delete OAuth token file + + Returns: + True if deleted successfully + """ + try: + if self.token_file.exists(): + self.token_file.unlink() + logger.info("Deleted OAuth token file") + return True + else: + logger.debug("No OAuth token file to delete") + return False + + except Exception as e: + logger.error(f"Failed to delete OAuth token: {e}") + return False + + def has_token(self) -> bool: + """ + Check if OAuth token file exists + + Returns: + True if token file exists + """ + return self.token_file.exists() + + def is_token_expired(self, token_data: Optional[Dict] = None, buffer_minutes: int = 5) -> bool: + """ + Check if token is expired or close to expiring + + Args: + token_data: Optional token data dict (loads from file if not provided) + buffer_minutes: Minutes before expiry to consider token expired (default 5) + + Returns: + True if token is expired or will expire within buffer_minutes + """ + if token_data is None: + token_data = self.load_token() + + if not token_data: + return True + + # Extract OAuth data if nested + oauth_data = token_data.get('oauth', token_data) + + # Get expiry information + expires_in = oauth_data.get('expires_in') + saved_at = token_data.get('_saved_at') + + if not expires_in or not saved_at: + logger.debug("Token missing expiry information, assuming valid") + return False # Assume token is valid if no expiry info + + # Calculate expiry time + expires_at = saved_at + expires_in + buffer_seconds = buffer_minutes * 60 + now = int(time.time()) + + # Check if expired or within buffer + is_expired = (expires_at - buffer_seconds) < now + + if is_expired: + remaining = expires_at - now + if remaining < 0: + logger.debug(f"Token expired {-remaining} seconds ago") + else: + logger.debug(f"Token expires in {remaining} seconds (within buffer)") + + return is_expired + + def get_access_token(self) -> Optional[str]: + """ + Get access token from storage + + Returns: + Access token string or None if not found or expired + """ + token_data = self.load_token() + + if not token_data: + return None + + # Check if expired + if self.is_token_expired(token_data): + logger.debug("Stored token is expired") + return None + + # Extract access token from OAuth structure + oauth_data = token_data.get('oauth', token_data) + access_token = oauth_data.get('access_token') + + if not access_token: + logger.error("Token data missing access_token field") + return None + + return access_token + + def get_refresh_token(self) -> Optional[str]: + """ + Get refresh token from storage + + Returns: + Refresh token string or None if not found + """ + token_data = self.load_token() + + if not token_data: + return None + + # Extract refresh token from OAuth structure + oauth_data = token_data.get('oauth', token_data) + refresh_token = oauth_data.get('refresh_token') + + return refresh_token + + def get_token_info(self) -> Dict: + """ + Get diagnostic information about current token + + Returns: + Dict with token status information + """ + token_data = self.load_token() + + if not token_data: + return { + 'has_token': False, + 'error': 'No token file found' + } + + oauth_data = token_data.get('oauth', token_data) + expires_in = oauth_data.get('expires_in') + saved_at = token_data.get('_saved_at') + + # Check if refresh token is likely expired (30 days since last auth) + # Nexus doesn't provide refresh token expiry, so we estimate conservatively + REFRESH_TOKEN_LIFETIME_DAYS = 30 + now = int(time.time()) + refresh_token_age_days = (now - saved_at) / 86400 if saved_at else 0 + refresh_token_likely_expired = refresh_token_age_days > REFRESH_TOKEN_LIFETIME_DAYS + + if expires_in and saved_at: + expires_at = saved_at + expires_in + remaining_seconds = expires_at - now + + return { + 'has_token': True, + 'has_refresh_token': bool(oauth_data.get('refresh_token')), + 'expires_in_seconds': remaining_seconds, + 'expires_in_minutes': remaining_seconds / 60, + 'expires_in_hours': remaining_seconds / 3600, + 'is_expired': remaining_seconds < 0, + 'expires_soon_5min': remaining_seconds < 300, + 'expires_soon_15min': remaining_seconds < 900, + 'saved_at': saved_at, + 'expires_at': expires_at, + 'refresh_token_age_days': refresh_token_age_days, + 'refresh_token_likely_expired': refresh_token_likely_expired, + } + else: + return { + 'has_token': True, + 'has_refresh_token': bool(oauth_data.get('refresh_token')), + 'refresh_token_age_days': refresh_token_age_days, + 'refresh_token_likely_expired': refresh_token_likely_expired, + 'error': 'Token missing expiry information' + } diff --git a/jackify/backend/handlers/path_handler.py b/jackify/backend/handlers/path_handler.py index 8d35b35..33aa3b1 100644 --- a/jackify/backend/handlers/path_handler.py +++ b/jackify/backend/handlers/path_handler.py @@ -12,6 +12,7 @@ import shutil from pathlib import Path from typing import Optional, Union, Dict, Any, List, Tuple from datetime import datetime +import vdf # Initialize logger logger = logging.getLogger(__name__) @@ -258,7 +259,7 @@ class PathHandler: return False @staticmethod - def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full, vanilla_game_dir=None): + 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 @@ -269,6 +270,7 @@ class PathHandler: 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 @@ -276,49 +278,45 @@ class PathHandler: try: logger.info("Creating dxvk.conf file...") - # Determine the location for dxvk.conf - dxvk_conf_path = None + 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 + ) - # Check for common stock game directories first, then vanilla as fallback - stock_game_paths = [ - os.path.join(modlist_dir, "Stock Game"), - os.path.join(modlist_dir, "Game Root"), - os.path.join(modlist_dir, "STOCK GAME"), - os.path.join(modlist_dir, "Stock Game Folder"), - os.path.join(modlist_dir, "Stock Folder"), - os.path.join(modlist_dir, "Skyrim Stock"), - os.path.join(modlist_dir, "root", "Skyrim Special Edition") - ] + if not candidate_dirs: + logger.error("Could not determine location for dxvk.conf (no candidate directories found)") + return False - # Add vanilla game directory as fallback if steam_library and game_var_full are provided - if steam_library and game_var_full: - stock_game_paths.append(os.path.join(steam_library, "steamapps", "common", game_var_full)) - - for path in stock_game_paths: - if os.path.exists(path): - dxvk_conf_path = os.path.join(path, "dxvk.conf") + target_dir = None + for directory in candidate_dirs: + if directory.is_dir(): + target_dir = directory break - if not dxvk_conf_path: - # Fallback: Try vanilla game directory if provided - if vanilla_game_dir and os.path.exists(vanilla_game_dir): - logger.info(f"Attempting fallback to vanilla game directory: {vanilla_game_dir}") - dxvk_conf_path = os.path.join(vanilla_game_dir, "dxvk.conf") - logger.info(f"Using vanilla game directory for dxvk.conf: {dxvk_conf_path}") + 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("Could not determine location for dxvk.conf") + 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 os.path.exists(dxvk_conf_path): + 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') as f: + with open(dxvk_conf_path, 'r', encoding='utf-8') as f: existing_content = f.read().strip() # Check if our required line is already present @@ -339,7 +337,7 @@ class PathHandler: updated_content = required_line + '\n' logger.info("Adding required DXVK setting to empty file") - with open(dxvk_conf_path, 'w') as f: + 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}") @@ -353,7 +351,8 @@ class PathHandler: # Create new dxvk.conf file (original behavior) dxvk_conf_content = required_line + '\n' - with open(dxvk_conf_path, 'w') as f: + 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}") @@ -362,6 +361,99 @@ class PathHandler: 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]: @@ -491,40 +583,53 @@ class PathHandler: logger.debug(f"Searching for compatdata directory for AppID: {appid}") - # Use libraryfolders.vdf to find all Steam library paths + # Use libraryfolders.vdf to find all Steam library paths, when available library_paths = PathHandler.get_all_steam_library_paths() - if not library_paths: - logger.error("Could not find any Steam library paths from libraryfolders.vdf") - return None + 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}") - logger.debug(f"Checking compatdata in {len(library_paths)} Steam libraries") + # 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 - # 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 + 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: - logger.debug(f"Compatdata for AppID {appid} not found in {compatdata_base}") - - # Fallback: Broad search (can be slow, consider if needed) - # try: - # logger.debug(f"Compatdata not found in standard locations, attempting wider search...") - # # This can be very slow and resource-intensive - # # find_output = subprocess.check_output(['find', '/', '-type', 'd', '-name', appid, '-path', '*/compatdata/*', '-print', '-quit', '2>/dev/null'], text=True).strip() - # # if find_output: - # # logger.info(f"Found compatdata via find command: {find_output}") - # # return Path(find_output) - # except Exception as e: - # logger.warning(f"Error during 'find' command for compatdata: {e}") - - logger.warning(f"Compatdata directory for AppID {appid} not found.") + # 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 @@ -617,14 +722,22 @@ class PathHandler: if vdf_path.is_file(): logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}") try: - with open(vdf_path) as f: - for line in f: - m = re.search(r'"path"\s*"([^"]+)"', line) - if m: - lib_path = Path(m.group(1)) + 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) - resolved_path = lib_path.resolve() - library_paths.add(resolved_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: + # 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}") diff --git a/jackify/backend/handlers/progress_parser.py b/jackify/backend/handlers/progress_parser.py new file mode 100644 index 0000000..f053409 --- /dev/null +++ b/jackify/backend/handlers/progress_parser.py @@ -0,0 +1,1167 @@ +""" +Progress Parser + +Parses jackify-engine text output to extract structured progress information. +This is an R&D implementation - experimental and subject to change. +""" + +import os +import re +import time +from typing import Optional, List, Tuple +from dataclasses import dataclass + +from jackify.shared.progress_models import ( + InstallationProgress, + InstallationPhase, + FileProgress, + OperationType +) + + +@dataclass +class ParsedLine: + """Result of parsing a single line of output.""" + has_progress: bool = False + phase: Optional[InstallationPhase] = None + phase_name: Optional[str] = None + file_progress: Optional[FileProgress] = None + completed_filename: Optional[str] = None # Filename that just completed + overall_percent: Optional[float] = None + step_info: Optional[Tuple[int, int]] = None # (current, total) + data_info: Optional[Tuple[int, int]] = None # (current_bytes, total_bytes) + speed_info: Optional[Tuple[str, float]] = None # (operation, speed_bytes_per_sec) + file_counter: Optional[Tuple[int, int]] = None # (current_file, total_files) for Extracting phase + message: str = "" + + +class ProgressParser: + """ + Parses jackify-engine output to extract progress information. + + This parser uses pattern matching to extract: + - Installation phases + - File-level progress + - Overall progress percentages + - Step counts + - Data sizes + - Operation speeds + """ + + def __init__(self): + """Initialize parser with pattern definitions.""" + # Phase detection patterns + self.phase_patterns = [ + (r'===?\s*(.+?)\s*===?', self._extract_phase_from_section), + (r'\[.*?\]\s*(?:Installing|Downloading|Extracting|Validating|Processing)', self._extract_phase_from_action), + (r'(?:Starting|Beginning)\s+(.+?)(?:\s+phase|\.|$)', re.IGNORECASE), + ] + + # File progress patterns + self.file_patterns = [ + # Pattern: "Installing: filename.7z (42%)" + (r'(?:Installing|Downloading|Extracting|Validating):\s*(.+?)\s*\((\d+(?:\.\d+)?)%\)', self._parse_file_with_percent), + # Pattern: "filename.7z: 42%" + (r'(.+?\.(?:7z|zip|rar|bsa|dds)):\s*(\d+(?:\.\d+)?)%', self._parse_file_with_percent), + # Pattern: "filename.7z [45.2MB/s]" + (r'(.+?\.(?:7z|zip|rar|bsa|dds))\s*\[([^\]]+)\]', self._parse_file_with_speed), + ] + + # Overall progress patterns (stored as regex patterns, not tuples with callbacks) + # Wabbajack format: "[12/14] Installing files (1.1GB/56.3GB)" + self.overall_patterns = [ + # Pattern: "Progress: 85%" or "85%" + (r'(?:Progress|Overall):\s*(\d+(?:\.\d+)?)%', re.IGNORECASE), + (r'^(\d+(?:\.\d+)?)%\s*(?:complete|done|progress)', re.IGNORECASE), + ] + + # Wabbajack status update format: "[12/14] StatusText (current/total)" + # This is the primary format we should match + self.wabbajack_status_pattern = re.compile( + r'\[(\d+)/(\d+)\]\s+(.+?)\s+\(([^)]+)\)', + re.IGNORECASE + ) + + # Alternative format: "[timestamp] StatusText (current/total) - speed" + # Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s" + self.timestamp_status_pattern = re.compile( + r'\[[^\]]+\]\s+(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)', + re.IGNORECASE + ) + + # Data size patterns + self.data_patterns = [ + # Pattern: "1.1GB/56.3GB" or "(1.1GB/56.3GB)" + (r'\(?(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\)?', re.IGNORECASE), + # Pattern: "Processing 1.1GB of 56.3GB" + (r'Processing\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s+of\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', re.IGNORECASE), + ] + + # Speed patterns + self.speed_patterns = [ + # Pattern: "267.3MB/s" or "45.2 MB/s" + (r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', re.IGNORECASE), + # Pattern: "at 267.3MB/s" or "speed: 45.2 MB/s" + (r'(?:at|speed:?)\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', re.IGNORECASE), + ] + + # File filter - only display meaningful artifacts in the UI + self.allowed_extensions = { + '.7z', '.zip', '.rar', '.bsa', '.ba2', '.dds', '.wabbajack', + '.exe', '.esp', '.esm', '.esl', '.bin', '.dll', '.pak', + '.tar', '.gz', '.xz', '.bz2', '.z01', '.z02', '.cab', '.msi' + } + + def should_display_file(self, filename: str) -> bool: + """Public helper so other components can reuse the filter.""" + return self._should_display_file(filename) + + def _should_display_file(self, filename: str) -> bool: + """Determine whether a filename is worth showing in the UI.""" + if not filename: + return False + base = os.path.basename(filename.strip()) + if not base: + return False + # Special case: allow ".wabbajack" and "Downloading .wabbajack file" + if base == ".wabbajack" or base == "Downloading .wabbajack file": + return True + # Skip temporary/generated files (e.g., #zcbe$123.txt) + if base.startswith('#'): + return False + name, ext = os.path.splitext(base) + if not ext: + return False + if ext.lower() not in self.allowed_extensions: + return False + # Also skip generic filenames that are clearly tooling artifacts + if name.lower() in {'empty', 'script', 'one', 'two', 'three'}: + return False + return True + + def parse_line(self, line: str) -> ParsedLine: + """ + Parse a single line of output and extract progress information. + + Args: + line: Raw line from jackify-engine output + + Returns: + ParsedLine with extracted information + """ + result = ParsedLine(message=line.strip()) + + if not line.strip(): + return result + + # Try to extract phase information + phase_info = self._extract_phase(line) + if phase_info: + result.phase, result.phase_name = phase_info + result.has_progress = True + + # Try to extract file progress + file_prog = self._extract_file_progress(line) + if file_prog: + result.file_progress = file_prog + result.has_progress = True + # Check if file counter was attached (for extraction or install phases) + if hasattr(file_prog, '_file_counter'): + result.file_counter = file_prog._file_counter + delattr(file_prog, '_file_counter') # Clean up temp attribute + + # Try to extract overall progress + overall = self._extract_overall_progress(line) + if overall is not None: + result.overall_percent = overall + result.has_progress = True + + # Try to extract Wabbajack status format first: "[12/14] StatusText (1.1GB/56.3GB)" + # BUT skip if this is a .wabbajack download line (handled by specific pattern below) + wabbajack_match = self.wabbajack_status_pattern.search(line) + if wabbajack_match: + status_text = wabbajack_match.group(3).strip().lower() + # Skip if this is a .wabbajack download - let the specific pattern handle it + if '.wabbajack' in status_text or 'downloading .wabbajack' in status_text: + # Don't process this as generic status - let .wabbajack pattern handle it + pass + else: + # Extract step info + current_step = int(wabbajack_match.group(1)) + max_steps = int(wabbajack_match.group(2)) + result.step_info = (current_step, max_steps) + + # Extract status text (phase name) + phase_info = self._extract_phase_from_text(status_text) + if phase_info: + result.phase, result.phase_name = phase_info + + # Extract data info from parentheses + data_str = wabbajack_match.group(4).strip() + data_info = self._parse_data_string(data_str) + if data_info: + result.data_info = data_info + + result.has_progress = True + + # Try alternative format: "[timestamp] StatusText (current/total) - speed" + # Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s" + timestamp_match = self.timestamp_status_pattern.search(line) + if timestamp_match: + # Extract status text (phase name) + status_text = timestamp_match.group(1).strip() + phase_info = self._extract_phase_from_text(status_text) + if phase_info: + result.phase, result.phase_name = phase_info + + # Extract step info (current/total in parentheses) + current_step = int(timestamp_match.group(2)) + max_steps = int(timestamp_match.group(3)) + result.step_info = (current_step, max_steps) + + # Extract speed + speed_str = timestamp_match.group(4).strip() + speed_info = self._parse_speed_from_string(speed_str) + if speed_info: + operation = self._detect_operation_from_line(status_text) + result.speed_info = (operation, speed_info) + + # Calculate overall percentage from step progress + if max_steps > 0: + result.overall_percent = (current_step / max_steps) * 100.0 + + result.has_progress = True + + # Try .wabbajack download format: "[timestamp] Downloading .wabbajack (size/size) - speed" + # Example: "[00:02:08] Downloading .wabbajack (739.2/1947.2MB) - 6.0MB/s" + # Also handles: "[00:02:08] Downloading modlist.wabbajack (739.2/1947.2MB) - 6.0MB/s" + wabbajack_download_pattern = re.compile( + r'\[[^\]]+\]\s+Downloading\s+([^\s]+\.wabbajack|\.wabbajack)\s+\(([^)]+)\)\s*-\s*([^\s]+)', + re.IGNORECASE + ) + wabbajack_match = wabbajack_download_pattern.search(line) + if wabbajack_match: + # Extract filename (group 1) + filename = wabbajack_match.group(1).strip() + if filename == ".wabbajack": + # Try to extract actual filename from message if available + filename_match = re.search(r'([A-Za-z0-9_\-\.]+\.wabbajack)', line, re.IGNORECASE) + if filename_match: + filename = filename_match.group(1) + else: + # Use display message as filename + filename = "Downloading .wabbajack file" + + # Extract data info from parentheses (e.g., "49.7/1947.2MB" or "739.2MB/1947.2MB") + # Format can be: "current/totalUnit" or "currentUnit/totalUnit" + data_str = wabbajack_match.group(2).strip() + data_info = None + + # Try standard format first (both have units) + data_info = self._extract_data_info(f"({data_str})") + + # If that fails, try format where only second number has unit: "49.7/1947.2MB" + if not data_info: + pattern = r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)' + match = re.search(pattern, data_str, re.IGNORECASE) + if match: + current_val = float(match.group(1)) + current_unit = match.group(2) if match.group(2) else match.group(4) # Use second unit if first missing + total_val = float(match.group(3)) + total_unit = match.group(4) + + current_bytes = self._convert_to_bytes(current_val, current_unit) + total_bytes = self._convert_to_bytes(total_val, total_unit) + data_info = (current_bytes, total_bytes) + + if data_info: + result.data_info = data_info + # Calculate percent from data + current_bytes, total_bytes = data_info + if total_bytes > 0: + result.overall_percent = (current_bytes / total_bytes) * 100.0 + + # Extract speed (group 3) + speed_str = wabbajack_match.group(3).strip() + speed_info = self._parse_speed_from_string(speed_str) + if speed_info: + result.speed_info = ("download", speed_info) + + # Set phase + result.phase = InstallationPhase.DOWNLOAD + result.phase_name = f"Downloading {filename}" + + # Create FileProgress entry for .wabbajack file + if data_info: + current_bytes, total_bytes = data_info + percent = (current_bytes / total_bytes) * 100.0 if total_bytes > 0 else 0.0 + from jackify.shared.progress_models import FileProgress, OperationType + file_progress = FileProgress( + filename=filename, + operation=OperationType.DOWNLOAD, + percent=percent, + current_size=current_bytes, + total_size=total_bytes, + speed=speed_info if speed_info else -1.0 + ) + result.file_progress = file_progress + + result.has_progress = True + + # Try to extract step information (fallback) + if not result.step_info: + step_info = self._extract_step_info(line) + if step_info: + result.step_info = step_info + result.has_progress = True + + # Try to extract data size information (fallback) + if not result.data_info: + data_info = self._extract_data_info(line) + if data_info: + result.data_info = data_info + result.has_progress = True + + # Try to extract speed information + speed_info = self._extract_speed_info(line) + if speed_info: + result.speed_info = speed_info + result.has_progress = True + + # Try to detect file completion + completed_file = self._extract_completed_file(line) + if completed_file: + result.completed_filename = completed_file + 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.""" + # 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: + """ + Manages installation progress state by accumulating parsed information. + + This class maintains the current state of installation progress and + updates it as new lines are parsed. + """ + + def __init__(self): + """Initialize state manager.""" + self.state = InstallationProgress() + self.parser = ProgressParser() + self._file_history = {} + self._wabbajack_entry_name = None + self._synthetic_flag = "_synthetic_wabbajack" + + 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 + if parsed.phase: + self.state.phase = parsed.phase + updated = True + if parsed.phase_name: + self.state.phase_name = parsed.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 + + # 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 + 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: + # 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: + # 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() + self._file_history = {} + 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 + diff --git a/jackify/backend/handlers/progress_parser_example.py b/jackify/backend/handlers/progress_parser_example.py new file mode 100644 index 0000000..02b6e0b --- /dev/null +++ b/jackify/backend/handlers/progress_parser_example.py @@ -0,0 +1,51 @@ +""" +Example usage of ProgressParser + +This file demonstrates how to use the progress parser to extract +structured information from jackify-engine output. + +R&D NOTE: This is experimental code for investigation purposes. +""" + +from jackify.backend.handlers.progress_parser import ProgressStateManager + + +def example_usage(): + """Example of how to use the progress parser.""" + + # Create state manager + state_manager = ProgressStateManager() + + # Simulate processing lines from jackify-engine output + sample_lines = [ + "[00:00:00] === Installing files ===", + "[00:00:05] [12/14] Installing files (1.1GB/56.3GB)", + "[00:00:10] Installing: Enderal Remastered Armory.7z (42%)", + "[00:00:15] Extracting: Mandragora Sprouts.7z (96%)", + "[00:00:20] Downloading at 45.2MB/s", + "[00:00:25] Extracting at 267.3MB/s", + "[00:00:30] Progress: 85%", + ] + + print("Processing sample output lines...\n") + + for line in sample_lines: + updated = state_manager.process_line(line) + if updated: + state = state_manager.get_state() + print(f"Line: {line}") + print(f" Phase: {state.phase.value} - {state.phase_name}") + print(f" Progress: {state.overall_percent:.1f}%") + print(f" Step: {state.phase_progress_text}") + print(f" Data: {state.data_progress_text}") + print(f" Active Files: {len(state.active_files)}") + for file_prog in state.active_files: + print(f" - {file_prog.filename}: {file_prog.percent:.1f}%") + print(f" Speeds: {state.speeds}") + print(f" Display: {state.display_text}") + print() + + +if __name__ == "__main__": + example_usage() + diff --git a/jackify/backend/handlers/protontricks_handler.py b/jackify/backend/handlers/protontricks_handler.py index 8725ba5..4f03440 100644 --- a/jackify/backend/handlers/protontricks_handler.py +++ b/jackify/backend/handlers/protontricks_handler.py @@ -34,36 +34,134 @@ class ProtontricksHandler: self.steamdeck = steamdeck # Store steamdeck status self._native_steam_service = None self.use_native_operations = True # Enable native Steam operations by default + + 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 PyInstaller-specific + 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 """ - env = os.environ.copy() - - # Remove PyInstaller-specific environment variables - env.pop('_MEIPASS', None) - env.pop('_MEIPASS2', None) - - # Clean library path variables that PyInstaller modifies (Linux/Unix) + # 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 PyInstaller + # Restore original LD_LIBRARY_PATH if it was backed up by the bundler env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG'] else: - # Remove PyInstaller-modified LD_LIBRARY_PATH + # Remove bundle-modified LD_LIBRARY_PATH env.pop('LD_LIBRARY_PATH', None) - # Clean PATH of PyInstaller-specific entries - if 'PATH' in env and hasattr(sys, '_MEIPASS'): - path_entries = env['PATH'].split(os.pathsep) - # Remove any PATH entries that point to PyInstaller temp directory - cleaned_path = [p for p in path_entries if not p.startswith(sys._MEIPASS)] - env['PATH'] = os.pathsep.join(cleaned_path) - # 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) @@ -84,17 +182,17 @@ class ProtontricksHandler: def detect_protontricks(self): """ - Detect if protontricks is installed and whether it's flatpak or native. - If not found, prompts the user to install the Flatpak version. + Detect if protontricks is installed (silent detection for GUI/automated use). - Returns True if protontricks is found or successfully installed, False otherwise + 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") # Store for later use - + self.flatpak_path = shutil.which("flatpak") + if protontricks_path_which: # Check if it's a flatpak wrapper try: @@ -103,7 +201,6 @@ class ProtontricksHandler: if "flatpak run" in content: logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}") self.which_protontricks = 'flatpak' - # Continue to check flatpak list just to be sure else: logger.info(f"Native Protontricks found at {protontricks_path_which}") self.which_protontricks = 'native' @@ -111,103 +208,27 @@ class ProtontricksHandler: return True except Exception as e: logger.error(f"Error reading protontricks executable: {e}") - - # Check if flatpak protontricks is installed (or if wrapper check indicated flatpak) - flatpak_installed = False - try: - # PyInstaller fix: Comprehensive environment cleaning for subprocess calls - env = self._get_clean_subprocess_env() + # Check if flatpak protontricks is installed + try: + env = self._get_clean_subprocess_env() result = subprocess.run( ["flatpak", "list"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, # Suppress stderr to avoid error messages when flatpak not installed + capture_output=True, text=True, - env=env # Use comprehensively cleaned environment + env=env ) if result.returncode == 0 and "com.github.Matoking.protontricks" in result.stdout: logger.info("Flatpak Protontricks is installed") self.which_protontricks = 'flatpak' - flatpak_installed = True return True except FileNotFoundError: - logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.") + logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.") except Exception as e: logger.error(f"Unexpected error checking flatpak: {e}") - # If neither native nor flatpak found, prompt for installation - if not self.which_protontricks: - logger.warning("Protontricks not found (native or flatpak).") - - should_install = False - if self.steamdeck: - logger.info("Running on Steam Deck, attempting automatic Flatpak installation.") - # Maybe add a brief pause or message? - print("Protontricks not found. Attempting automatic installation via Flatpak...") - should_install = True - else: - try: - print("\nProtontricks not found. Choose installation method:") - print("1. Install via Flatpak (automatic)") - print("2. Install via native package manager (manual)") - print("3. Skip (Use bundled winetricks instead)") - choice = input("Enter choice (1/2/3): ").strip() - - if choice == '1' or choice == '': - should_install = True - elif choice == '2': - print("\nTo install protontricks via your system package manager:") - print("• Ubuntu/Debian: sudo apt install protontricks") - print("• Fedora: sudo dnf install protontricks") - print("• Arch Linux: sudo pacman -S protontricks") - print("• openSUSE: sudo zypper install protontricks") - print("\nAfter installation, please rerun Jackify.") - return False - elif choice == '3': - print("Skipping protontricks installation. Will use bundled winetricks for component installation.") - logger.info("User chose to skip protontricks and use winetricks fallback") - return False - else: - print("Invalid choice. Installation cancelled.") - return False - except KeyboardInterrupt: - print("\nInstallation cancelled.") - return False - - if should_install: - try: - logger.info("Attempting to install Flatpak Protontricks...") - # Use --noninteractive for automatic install where applicable - install_cmd = ["flatpak", "install", "-u", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"] - - # PyInstaller fix: Comprehensive environment cleaning for subprocess calls - env = self._get_clean_subprocess_env() - - # Run with output visible to user - process = subprocess.run(install_cmd, check=True, text=True, env=env) - logger.info("Flatpak Protontricks installation successful.") - print("Flatpak Protontricks installed successfully.") - self.which_protontricks = 'flatpak' - return True - except FileNotFoundError: - logger.error("'flatpak' command not found. Cannot install.") - print("Error: 'flatpak' command not found. Please install Flatpak first.") - return False - except subprocess.CalledProcessError as e: - logger.error(f"Flatpak installation failed: {e}") - print(f"Error: Flatpak installation failed (Command: {' '.join(e.cmd)}). Please try installing manually.") - return False - except Exception as e: - logger.error(f"Unexpected error during Flatpak installation: {e}") - print("An unexpected error occurred during installation.") - return False - else: - logger.error("User chose not to install Protontricks or installation skipped.") - print("Protontricks installation skipped. Cannot continue without Protontricks.") - return False - - # Should not reach here if logic is correct, but acts as a fallback - logger.error("Protontricks detection failed unexpectedly.") + # Not found + logger.warning("Protontricks not found (native or flatpak).") return False def check_protontricks_version(self): @@ -257,13 +278,30 @@ class ProtontricksHandler: logger.error("Could not detect protontricks installation") return None - if self.which_protontricks == 'flatpak': + # 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 = ["flatpak", "run", "com.github.Matoking.protontricks"] - else: + cmd.extend(args) + else: # native cmd = ["protontricks"] - - cmd.extend(args) - + cmd.extend(args) + # Default to capturing stdout/stderr unless specified otherwise in kwargs run_kwargs = { 'stdout': subprocess.PIPE, @@ -271,18 +309,73 @@ class ProtontricksHandler: 'text': True, **kwargs # Allow overriding defaults (like stderr=DEVNULL) } - # PyInstaller fix: Use cleaned environment for all protontricks calls - env = self._get_clean_subprocess_env() + + # 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}") - # Consider returning a mock CompletedProcess with an error code? return None - + def set_protontricks_permissions(self, modlist_dir, steamdeck=False): """ Set permissions for Steam operations to access the modlist directory. @@ -304,7 +397,7 @@ class ProtontricksHandler: logger.info("Setting Protontricks permissions...") try: - # PyInstaller fix: Use cleaned environment + # Bundled-runtime fix: Use cleaned environment env = self._get_clean_subprocess_env() subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", @@ -414,7 +507,7 @@ class ProtontricksHandler: logger.error("Protontricks path not determined, cannot list shortcuts.") return {} self.logger.debug(f"Running command: {' '.join(cmd)}") - # PyInstaller fix: Use cleaned environment + # 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 @@ -566,7 +659,7 @@ class ProtontricksHandler: Returns True on success, False on failure """ try: - # PyInstaller fix: Use cleaned environment + # Bundled-runtime fix: Use cleaned environment env = self._get_clean_subprocess_env() env["WINEDEBUG"] = "-all" @@ -652,16 +745,22 @@ class ProtontricksHandler: def run_protontricks_launch(self, appid, installer_path, *extra_args): """ - Run protontricks-launch (for WebView or similar installers) using the correct method for flatpak or native. + 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 == 'flatpak': + 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 = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)] - else: + else: # native launch_path = shutil.which("protontricks-launch") if not launch_path: self.logger.error("protontricks-launch command not found in PATH.") @@ -671,7 +770,7 @@ class ProtontricksHandler: cmd.extend(extra_args) self.logger.debug(f"Running protontricks-launch: {' '.join(map(str, cmd))}") try: - # PyInstaller fix: Use cleaned environment + # 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: @@ -685,6 +784,44 @@ class ProtontricksHandler: """ 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) + 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}") @@ -716,8 +853,25 @@ class ProtontricksHandler: # Continue to retry else: self.logger.error(f"Protontricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode if result else 'N/A'}") - self.logger.error(f"Stdout: {result.stdout.strip() if result else ''}") - self.logger.error(f"Stderr: {result.stderr.strip() if result else ''}") + # 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.") diff --git a/jackify/backend/handlers/shortcut_handler.py b/jackify/backend/handlers/shortcut_handler.py index d0262fc..f3f3819 100644 --- a/jackify/backend/handlers/shortcut_handler.py +++ b/jackify/backend/handlers/shortcut_handler.py @@ -198,7 +198,10 @@ class ShortcutHandler: if steam_vdf_spec is None: # Try to install steam-vdf using pip print("Installing required dependency (steam-vdf)...") - subprocess.check_call([sys.executable, "-m", "pip", "install", "steam-vdf", "--user"]) + # 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 diff --git a/jackify/backend/handlers/subprocess_utils.py b/jackify/backend/handlers/subprocess_utils.py index 1253c60..99ea78a 100644 --- a/jackify/backend/handlers/subprocess_utils.py +++ b/jackify/backend/handlers/subprocess_utils.py @@ -3,17 +3,119 @@ import signal import subprocess import time import resource +import sys +import shutil + +def get_safe_python_executable(): + """ + Get a safe Python executable for subprocess calls. + When running as AppImage, returns system Python instead of AppImage path + to prevent recursive AppImage spawning. + + Returns: + str: Path to Python executable safe for subprocess calls + """ + # Check if we're running as AppImage + is_appimage = ( + 'APPIMAGE' in os.environ or + 'APPDIR' in os.environ or + (hasattr(sys, 'frozen') and sys.frozen) or + (sys.argv[0] and sys.argv[0].endswith('.AppImage')) + ) + + if is_appimage: + # Running as AppImage - use system Python to avoid recursive spawning + # Try to find system Python (same logic as AppRun) + for cmd in ['python3', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3.8']: + python_path = shutil.which(cmd) + if python_path: + return python_path + # Fallback: if we can't find system Python, this is a problem + # But we'll still return sys.executable as last resort + return sys.executable + else: + # Not AppImage - sys.executable is safe + return sys.executable def get_clean_subprocess_env(extra_env=None): """ - Returns a copy of os.environ with PyInstaller and other problematic variables removed. + Returns a copy of os.environ with bundled-runtime variables and other problematic entries removed. Optionally merges in extra_env dict. + Also ensures bundled tools (lz4, unzip, etc.) are in PATH when running as AppImage. + CRITICAL: Preserves system PATH to ensure system tools (like lz4) are available. """ + from pathlib import Path + env = os.environ.copy() - # Remove PyInstaller-specific variables + + # Remove AppImage-specific variables that can confuse subprocess calls + # These variables cause subprocesses to be interpreted as new AppImage launches + for key in ['APPIMAGE', 'APPDIR', 'ARGV0', 'OWD']: + env.pop(key, None) + + # Remove bundle-specific variables for k in list(env): if k.startswith('_MEIPASS'): del env[k] + + # Get current PATH - ensure we preserve system paths + 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. + 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: + if sys_path not in path_parts and os.path.isdir(sys_path): + path_parts.append(sys_path) + + # Add bundled tools directory to PATH if running as AppImage + # This ensures lz4, unzip, xz, etc. are available to subprocesses + appdir = env.get('APPDIR') + tools_dir = None + + if appdir: + # Running as AppImage - use APPDIR + tools_dir = os.path.join(appdir, 'opt', 'jackify', 'tools') + # Verify the tools directory exists and contains lz4 + if not os.path.isdir(tools_dir): + tools_dir = None + elif not os.path.exists(os.path.join(tools_dir, 'lz4')): + # Tools dir exists but lz4 not there - might be a different layout + tools_dir = None + elif getattr(sys, 'frozen', False): + # PyInstaller frozen - try to find tools relative to executable + exe_path = Path(sys.executable) + # In PyInstaller, sys.executable is the bundled executable + # Tools should be in the same directory or a tools subdirectory + possible_tools_dirs = [ + exe_path.parent / 'tools', + exe_path.parent / 'opt' / 'jackify' / 'tools', + ] + for possible_dir in possible_tools_dirs: + if possible_dir.is_dir() and (possible_dir / 'lz4').exists(): + tools_dir = str(possible_dir) + break + + # Build final PATH: bundled tools first (if any), then original PATH with system paths + final_path_parts = [] + if tools_dir and os.path.isdir(tools_dir): + # Prepend tools directory so bundled tools take precedence + # This is critical - bundled lz4 must come before system lz4 + final_path_parts.append(tools_dir) + + # Add all other paths (preserving order, removing duplicates) + # Note: AppRun already sets PATH with tools directory, but we ensure it's first + seen = set() + if tools_dir: + seen.add(tools_dir) # Already added, don't add again + for path_part in path_parts: + if path_part and path_part not in seen: + final_path_parts.append(path_part) + seen.add(path_part) + + env['PATH'] = ':'.join(final_path_parts) + # Optionally restore LD_LIBRARY_PATH to system default if needed # (You can add more logic here if you know your system's default) if extra_env: @@ -59,7 +161,11 @@ class ProcessManager: """ def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0): self.cmd = cmd - self.env = env + # Default to cleaned environment if None to prevent AppImage variable inheritance + if env is None: + self.env = get_clean_subprocess_env() + else: + self.env = env self.cwd = cwd self.text = text self.bufsize = bufsize diff --git a/jackify/backend/handlers/ttw_installer_handler.py b/jackify/backend/handlers/ttw_installer_handler.py new file mode 100644 index 0000000..fe2fefe --- /dev/null +++ b/jackify/backend/handlers/ttw_installer_handler.py @@ -0,0 +1,742 @@ +""" +TTW_Linux_Installer Handler + +Handles downloading, installation, and execution of TTW_Linux_Installer for TTW installations. +Replaces hoolamike for TTW-specific functionality. +""" + +import logging +import os +import subprocess +import tarfile +import zipfile +from pathlib import Path +from typing import Optional, Tuple +import requests + +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 + +logger = logging.getLogger(__name__) + +# Define default TTW_Linux_Installer paths +JACKIFY_BASE_DIR = Path.home() / "Jackify" +DEFAULT_TTW_INSTALLER_DIR = JACKIFY_BASE_DIR / "TTW_Linux_Installer" +TTW_INSTALLER_EXECUTABLE_NAME = "ttw_linux_gui" # Same executable, runs in CLI mode with args + +# GitHub release info +TTW_INSTALLER_REPO = "SulfurNitride/TTW_Linux_Installer" +TTW_INSTALLER_RELEASE_URL = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/releases/latest" + + +class TTWInstallerHandler: + """Handles TTW installation using TTW_Linux_Installer (replaces hoolamike for TTW).""" + + def __init__(self, steamdeck: bool, verbose: bool, filesystem_handler: FileSystemHandler, + config_handler: ConfigHandler, menu_handler=None): + """Initialize the handler.""" + self.steamdeck = steamdeck + self.verbose = verbose + self.path_handler = PathHandler() + self.filesystem_handler = filesystem_handler + self.config_handler = config_handler + self.menu_handler = menu_handler + + # Set up logging + logging_handler = LoggingHandler() + logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log') + self.logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log') + + # Installation paths + self.ttw_installer_dir: Path = DEFAULT_TTW_INSTALLER_DIR + self.ttw_installer_executable_path: Optional[Path] = None + self.ttw_installer_installed: bool = False + + # Load saved install path from config + saved_path_str = self.config_handler.get('ttw_installer_install_path') + if saved_path_str and Path(saved_path_str).is_dir(): + self.ttw_installer_dir = Path(saved_path_str) + self.logger.info(f"Loaded TTW_Linux_Installer path from config: {self.ttw_installer_dir}") + + # Check if already installed + self._check_installation() + + def _ensure_dirs_exist(self): + """Ensure base directories exist.""" + self.ttw_installer_dir.mkdir(parents=True, exist_ok=True) + + def _check_installation(self): + """Check if TTW_Linux_Installer is installed at expected location.""" + self._ensure_dirs_exist() + + potential_exe_path = self.ttw_installer_dir / TTW_INSTALLER_EXECUTABLE_NAME + if potential_exe_path.is_file() and os.access(potential_exe_path, os.X_OK): + self.ttw_installer_executable_path = potential_exe_path + self.ttw_installer_installed = True + self.logger.info(f"Found TTW_Linux_Installer at: {self.ttw_installer_executable_path}") + else: + self.ttw_installer_installed = False + self.ttw_installer_executable_path = None + self.logger.info(f"TTW_Linux_Installer not found at {potential_exe_path}") + + def install_ttw_installer(self, install_dir: Optional[Path] = None) -> Tuple[bool, str]: + """Download and install TTW_Linux_Installer from GitHub releases. + + Args: + install_dir: Optional directory to install to (defaults to ~/Jackify/TTW_Linux_Installer) + + Returns: + (success: bool, message: str) + """ + try: + self._ensure_dirs_exist() + target_dir = Path(install_dir) if install_dir else self.ttw_installer_dir + target_dir.mkdir(parents=True, exist_ok=True) + + # Fetch latest release info + self.logger.info(f"Fetching latest TTW_Linux_Installer release from {TTW_INSTALLER_RELEASE_URL}") + resp = requests.get(TTW_INSTALLER_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 + asset_names = [asset.get("name", "") for asset in data.get("assets", [])] + self.logger.info(f"Available release assets: {asset_names}") + + for asset in data.get("assets", []): + name = asset.get("name", "").lower() + # Look for universal-mpi-installer pattern + if "universal-mpi-installer" in name and name.endswith((".zip", ".tar.gz")): + linux_asset = asset + self.logger.info(f"Found Linux asset: {asset.get('name')}") + 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)}" + + 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" + + # Download to target directory + temp_path = target_dir / asset_name + self.logger.info(f"Downloading {asset_name} from {download_url}") + if not self.filesystem_handler.download_file(download_url, temp_path, overwrite=True, quiet=True): + return False, "Failed to download TTW_Linux_Installer asset" + + # Extract archive (zip or tar.gz) + try: + self.logger.info(f"Extracting {asset_name} to {target_dir}") + if asset_name.lower().endswith('.tar.gz'): + with tarfile.open(temp_path, "r:gz") as tf: + tf.extractall(path=target_dir) + elif asset_name.lower().endswith('.zip'): + with zipfile.ZipFile(temp_path, "r") as zf: + zf.extractall(path=target_dir) + else: + return False, f"Unsupported archive format: {asset_name}" + finally: + try: + temp_path.unlink(missing_ok=True) # cleanup + except Exception: + pass + + # Find executable (may be in subdirectory or root) + exe_path = target_dir / TTW_INSTALLER_EXECUTABLE_NAME + if not exe_path.is_file(): + # Search for it + for p in target_dir.rglob(TTW_INSTALLER_EXECUTABLE_NAME): + if p.is_file(): + exe_path = p + break + + if not exe_path.is_file(): + return False, "TTW_Linux_Installer executable not found after extraction" + + # Set executable permissions + try: + os.chmod(exe_path, 0o755) + except Exception as e: + self.logger.warning(f"Failed to chmod +x on {exe_path}: {e}") + + # Update state + self.ttw_installer_dir = target_dir + self.ttw_installer_executable_path = exe_path + self.ttw_installer_installed = True + self.config_handler.set('ttw_installer_install_path', str(target_dir)) + if release_tag: + self.config_handler.set('ttw_installer_version', release_tag) + + self.logger.info(f"TTW_Linux_Installer installed successfully at {exe_path}") + return True, f"TTW_Linux_Installer installed at {target_dir}" + + except Exception as e: + self.logger.error(f"Error installing TTW_Linux_Installer: {e}", exc_info=True) + return False, f"Error installing TTW_Linux_Installer: {e}" + + def get_installed_ttw_installer_version(self) -> Optional[str]: + """Return the installed TTW_Linux_Installer version stored in Jackify config, if any.""" + try: + v = self.config_handler.get('ttw_installer_version') + return str(v) if v else None + except Exception: + return None + + def is_ttw_installer_update_available(self) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Check GitHub for the latest TTW_Linux_Installer release and compare with installed version. + Returns (update_available, installed_version, latest_version). + """ + installed = self.get_installed_ttw_installer_version() + + # If executable exists but no version is recorded, don't show as "out of date" + # This can happen if the executable was installed before version tracking was added + if not installed and self.ttw_installer_installed: + self.logger.info("TTW_Linux_Installer executable found but no version recorded in config") + # Don't treat as update available - just show as "Ready" (unknown version) + return (False, None, None) + + try: + resp = requests.get(TTW_INSTALLER_RELEASE_URL, timeout=10, verify=True) + resp.raise_for_status() + latest = resp.json().get('tag_name') or resp.json().get('name') + if not latest: + return (False, installed, None) + if not installed: + # No version recorded and executable doesn't exist; treat as not installed + return (False, None, str(latest)) + return (installed != str(latest), installed, str(latest)) + except Exception as e: + 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() + process = subprocess.Popen( + cmd, + cwd=str(self.ttw_installer_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() + + # Ensure lz4 is in PATH (critical for TTW_Linux_Installer) + import shutil + appdir = env.get('APPDIR') + if appdir: + tools_dir = os.path.join(appdir, 'opt', 'jackify', 'tools') + bundled_lz4 = os.path.join(tools_dir, 'lz4') + if os.path.exists(bundled_lz4) and os.access(bundled_lz4, os.X_OK): + current_path = env.get('PATH', '') + path_parts = [p for p in current_path.split(':') if p and p != tools_dir] + env['PATH'] = f"{tools_dir}:{':'.join(path_parts)}" + self.logger.info(f"Added bundled lz4 to PATH: {tools_dir}") + + # Verify lz4 is available + lz4_path = shutil.which('lz4', path=env.get('PATH', '')) + if not lz4_path: + system_lz4 = shutil.which('lz4') + if system_lz4: + lz4_dir = os.path.dirname(system_lz4) + env['PATH'] = f"{lz4_dir}:{env.get('PATH', '')}" + self.logger.info(f"Added system lz4 to PATH: {lz4_dir}") + else: + return None, "lz4 is required but not found in PATH" + + # Open output file for writing + output_fh = open(output_file, 'w', encoding='utf-8', buffering=1) + + # Start process with output redirected to file + process = subprocess.Popen( + cmd, + cwd=str(self.ttw_installer_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() + process = subprocess.Popen( + cmd, + cwd=str(self.ttw_installer_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/wine_utils.py b/jackify/backend/handlers/wine_utils.py index 3cd883c..44ffde1 100644 --- a/jackify/backend/handlers/wine_utils.py +++ b/jackify/backend/handlers/wine_utils.py @@ -1016,8 +1016,8 @@ class WineUtils: seen_names.add(version['name']) if unique_versions: - logger.info(f"Found {len(unique_versions)} total Proton version(s)") - logger.info(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})") + 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") diff --git a/jackify/backend/handlers/winetricks_handler.py b/jackify/backend/handlers/winetricks_handler.py index d0500bd..2c35c89 100644 --- a/jackify/backend/handlers/winetricks_handler.py +++ b/jackify/backend/handlers/winetricks_handler.py @@ -137,6 +137,8 @@ class WinetricksHandler: 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 @@ -162,21 +164,27 @@ class WinetricksHandler: else: self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}") - # Fall back to auto-detection if user selection failed or is 'auto' + # Only auto-detect if user explicitly chose 'auto' if not wine_binary: - self.logger.info("Falling back to automatic Proton detection") - 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]}") + 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("No Proton versions detected in standard Steam locations") + # 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") @@ -269,27 +277,23 @@ class WinetricksHandler: # Check user preference for component installation method from ..handlers.config_handler import ConfigHandler config_handler = ConfigHandler() - use_winetricks = config_handler.get('use_winetricks_for_components', True) + + # Get component installation method with migration + method = config_handler.get('component_installation_method', 'winetricks') - # Legacy .NET Framework versions that are problematic in Wine/Proton - # DISABLED in v0.1.6.2: Universal registry fixes replace dotnet4.x installation - # legacy_dotnet_versions = ['dotnet40', 'dotnet472', 'dotnet48'] - legacy_dotnet_versions = [] # ALL dotnet4.x versions disabled - universal registry fixes handle compatibility + # Migrate bundled_protontricks to system_protontricks (no longer supported) + if method == 'bundled_protontricks': + self.logger.warning("Bundled protontricks no longer supported, migrating to system_protontricks") + method = 'system_protontricks' + config_handler.set('component_installation_method', 'system_protontricks') - # Check if any legacy .NET Framework versions are present - has_legacy_dotnet = any(comp in components_to_install for comp in legacy_dotnet_versions) - - # Choose installation method based on user preference and components - # HYBRID APPROACH MOSTLY DISABLED: dotnet40/dotnet472 replaced with universal registry fixes - if has_legacy_dotnet: - legacy_found = [comp for comp in legacy_dotnet_versions if comp in components_to_install] - self.logger.info(f"Using hybrid approach: protontricks for legacy .NET versions {legacy_found} (reliable), {'winetricks' if use_winetricks else 'protontricks'} for other components") - return self._install_components_hybrid_approach(components_to_install, wineprefix, game_var, use_winetricks) - elif not use_winetricks: - self.logger.info("Using legacy approach: protontricks for all components") + # Choose installation method based on user preference + if method == 'system_protontricks': + self.logger.info("Using system protontricks for all components") return self._install_components_protontricks_only(components_to_install, wineprefix, game_var) + # else: method == 'winetricks' (default behavior continues below) - # For non-dotnet40 installations, install all components together (faster) + # Install all components together with winetricks (faster) max_attempts = 3 winetricks_failed = False last_error_details = None @@ -361,23 +365,6 @@ class WinetricksHandler: self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})") # Continue to retry else: - # Special handling for dotnet40 verification issue (mimics protontricks behavior) - if "dotnet40" in components_to_install and "ngen.exe not found" in result.stderr: - self.logger.warning("dotnet40 verification warning (common in Steam Proton prefixes)") - self.logger.info("Checking if dotnet40 was actually installed...") - - # Check if dotnet40 appears in winetricks.log (indicates successful installation) - log_path = os.path.join(wineprefix, 'winetricks.log') - if os.path.exists(log_path): - try: - with open(log_path, 'r') as f: - log_content = f.read() - if 'dotnet40' in log_content: - self.logger.info("dotnet40 found in winetricks.log - installation succeeded despite verification warning") - return True - except Exception as e: - self.logger.warning(f"Could not read winetricks.log: {e}") - # Store detailed error information for fallback diagnostics last_error_details = { 'returncode': result.returncode, @@ -463,7 +450,8 @@ class WinetricksHandler: # Check if protontricks is available for fallback using centralized handler try: from .protontricks_handler import ProtontricksHandler - protontricks_handler = ProtontricksHandler() + steamdeck = os.path.exists('/home/deck') + protontricks_handler = ProtontricksHandler(steamdeck) protontricks_available = protontricks_handler.is_available() if protontricks_available: @@ -493,103 +481,24 @@ class WinetricksHandler: def _reorder_components_for_installation(self, components: list) -> list: """ - Reorder components for proper installation sequence. - Critical: dotnet40 must be installed before dotnet6/dotnet7 to avoid conflicts. + Reorder components for proper installation sequence if needed. + Currently returns components in original order. """ - # Simple reordering: dotnet40 first, then everything else - reordered = [] - - # Add dotnet40 first if it exists - if "dotnet40" in components: - reordered.append("dotnet40") - - # Add all other components in original order - for component in components: - if component != "dotnet40": - reordered.append(component) - - if reordered != components: - self.logger.info(f"Reordered for dotnet40 compatibility: {reordered}") - - return reordered - - def _prepare_prefix_for_dotnet(self, wineprefix: str, wine_binary: str) -> bool: - """ - Prepare the Wine prefix for .NET installation by mimicking protontricks preprocessing. - This removes mono components and specific symlinks that interfere with .NET installation. - """ - try: - env = os.environ.copy() - env['WINEDEBUG'] = '-all' - env['WINEPREFIX'] = wineprefix - - # Step 1: Remove mono components (mimics protontricks behavior) - self.logger.info("Preparing prefix for .NET installation: removing mono") - mono_result = subprocess.run([ - self.winetricks_path, - '-q', - 'remove_mono' - ], env=env, capture_output=True, text=True, timeout=300) - - if mono_result.returncode != 0: - self.logger.warning(f"Mono removal warning (non-critical): {mono_result.stderr}") - - # Step 2: Set Windows version to XP (protontricks uses winxp for dotnet40) - self.logger.info("Setting Windows version to XP for .NET compatibility") - winxp_result = subprocess.run([ - self.winetricks_path, - '-q', - 'winxp' - ], env=env, capture_output=True, text=True, timeout=300) - - if winxp_result.returncode != 0: - self.logger.warning(f"Windows XP setting warning: {winxp_result.stderr}") - - # Step 3: Remove mscoree.dll symlinks (critical for .NET installation) - self.logger.info("Removing problematic mscoree.dll symlinks") - dosdevices_path = os.path.join(wineprefix, 'dosdevices', 'c:') - mscoree_paths = [ - os.path.join(dosdevices_path, 'windows', 'syswow64', 'mscoree.dll'), - os.path.join(dosdevices_path, 'windows', 'system32', 'mscoree.dll') - ] - - for dll_path in mscoree_paths: - if os.path.exists(dll_path) or os.path.islink(dll_path): - try: - os.remove(dll_path) - self.logger.debug(f"Removed symlink: {dll_path}") - except Exception as e: - self.logger.warning(f"Could not remove {dll_path}: {e}") - - self.logger.info("Prefix preparation complete for .NET installation") - return True - - except Exception as e: - self.logger.error(f"Error preparing prefix for .NET: {e}") - return False + return components def _install_components_separately(self, components: list, wineprefix: str, wine_binary: str, base_env: dict) -> bool: """ - Install components separately like protontricks does. - This is necessary when dotnet40 is present to avoid component conflicts. + Install components separately for maximum compatibility. """ - self.logger.info(f"Installing {len(components)} components separately (protontricks style)") + 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() - - # Special preprocessing for dotnet40 only - if component == "dotnet40": - self.logger.info("Applying dotnet40 preprocessing") - if not self._prepare_prefix_for_dotnet(wineprefix, wine_binary): - self.logger.error("Failed to prepare prefix for dotnet40") - return False - else: - # For non-dotnet40 components, install in standard mode (Windows 10 will be set after all components) - self.logger.debug(f"Installing {component} in standard mode") + env['WINEPREFIX'] = wineprefix + env['WINE'] = wine_binary # Install this component max_attempts = 3 @@ -602,9 +511,6 @@ class WinetricksHandler: try: cmd = [self.winetricks_path, '--unattended', component] - env['WINEPREFIX'] = wineprefix - env['WINE'] = wine_binary - self.logger.debug(f"Running: {' '.join(cmd)}") result = subprocess.run( @@ -620,22 +526,6 @@ class WinetricksHandler: component_success = True break else: - # Special handling for dotnet40 verification issue - if component == "dotnet40" and "ngen.exe not found" in result.stderr: - self.logger.warning("dotnet40 verification warning (expected in Steam Proton)") - - # Check winetricks.log for actual success - log_path = os.path.join(wineprefix, 'winetricks.log') - if os.path.exists(log_path): - try: - with open(log_path, 'r') as f: - if 'dotnet40' in f.read(): - self.logger.info("dotnet40 confirmed in winetricks.log") - component_success = True - break - except Exception as e: - self.logger.warning(f"Could not read winetricks.log: {e}") - self.logger.error(f"{component} failed (attempt {attempt}): {result.stderr.strip()}") self.logger.debug(f"Full stdout for {component}: {result.stdout.strip()}") @@ -647,121 +537,10 @@ class WinetricksHandler: return False self.logger.info("All components installed successfully using separate sessions") - # Set Windows 10 mode after all component installation (matches legacy script timing) + # Set Windows 10 mode after all component installation self._set_windows_10_mode(wineprefix, env.get('WINE', '')) return True - def _install_components_hybrid_approach(self, components: list, wineprefix: str, game_var: str, use_winetricks: bool = True) -> bool: - """ - Hybrid approach: Install legacy .NET Framework versions with protontricks (reliable), - then install remaining components with winetricks OR protontricks based on user preference. - - Args: - components: List of all components to install - wineprefix: Wine prefix path - game_var: Game variable for AppID detection - use_winetricks: Whether to use winetricks for non-legacy components - - Returns: - bool: True if all installations succeeded, False otherwise - """ - self.logger.info("Starting hybrid installation approach") - - # Legacy .NET Framework versions that need protontricks - legacy_dotnet_versions = ['dotnet40', 'dotnet472', 'dotnet48'] - - # Separate legacy .NET (protontricks) from other components (winetricks) - protontricks_components = [comp for comp in components if comp in legacy_dotnet_versions] - other_components = [comp for comp in components if comp not in legacy_dotnet_versions] - - self.logger.info(f"Protontricks components: {protontricks_components}") - self.logger.info(f"Other components: {other_components}") - - # Step 1: Install legacy .NET Framework versions with protontricks if present - if protontricks_components: - self.logger.info(f"Installing legacy .NET versions {protontricks_components} using protontricks...") - if not self._install_legacy_dotnet_with_protontricks(protontricks_components, wineprefix, game_var): - self.logger.error(f"Failed to install {protontricks_components} with protontricks") - return False - self.logger.info(f"{protontricks_components} installation completed successfully with protontricks") - - # Step 2: Install remaining components if any - if other_components: - if use_winetricks: - self.logger.info(f"Installing remaining components with winetricks: {other_components}") - # Use existing winetricks logic for other components - env = self._prepare_winetricks_environment(wineprefix) - if not env: - return False - return self._install_components_with_winetricks(other_components, wineprefix, env) - else: - self.logger.info(f"Installing remaining components with protontricks: {other_components}") - return self._install_components_protontricks_only(other_components, wineprefix, game_var) - - self.logger.info("Hybrid component installation completed successfully") - # Set Windows 10 mode after all component installation (matches legacy script timing) - wine_binary = self._get_wine_binary_for_prefix(wineprefix) - self._set_windows_10_mode(wineprefix, wine_binary) - return True - - def _install_legacy_dotnet_with_protontricks(self, legacy_components: list, wineprefix: str, game_var: str) -> bool: - """ - Install legacy .NET Framework versions using protontricks (known to work more reliably). - - Args: - legacy_components: List of legacy .NET components to install (dotnet40, dotnet472, dotnet48) - wineprefix: Wine prefix path - game_var: Game variable for AppID detection - - Returns: - bool: True if installation succeeded, False otherwise - """ - try: - # Extract AppID from wineprefix path (e.g., /path/to/compatdata/123456789/pfx -> 123456789) - appid = None - 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(): - appid = potential_appid - break - - if not appid: - self.logger.error(f"Could not extract AppID from wineprefix path: {wineprefix}") - return False - - self.logger.info(f"Using AppID {appid} for protontricks dotnet40 installation") - - # Import and use protontricks handler - from .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) - - # Detect protontricks availability - if not protontricks_handler.detect_protontricks(): - self.logger.error(f"Protontricks not available for legacy .NET installation: {legacy_components}") - return False - - # Install legacy .NET components using protontricks - success = protontricks_handler.install_wine_components(appid, game_var, legacy_components) - - if success: - self.logger.info(f"Legacy .NET components {legacy_components} installed successfully with protontricks") - return True - else: - self.logger.error(f"Legacy .NET components {legacy_components} installation failed with protontricks") - return False - - except Exception as e: - self.logger.error(f"Error installing legacy .NET components {legacy_components} with protontricks: {e}", exc_info=True) - return False - def _prepare_winetricks_environment(self, wineprefix: str) -> Optional[dict]: """ Prepare the environment for winetricks installation. @@ -799,9 +578,15 @@ class WinetricksHandler: wine_binary = ge_proton_wine if not wine_binary: - best_proton = WineUtils.select_best_proton() - if best_proton: - wine_binary = WineUtils.find_proton_binary(best_proton['name']) + 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']) + 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") @@ -915,11 +700,16 @@ class WinetricksHandler: def _install_components_protontricks_only(self, components: list, wineprefix: str, game_var: str) -> bool: """ - Legacy approach: Install all components using protontricks only. + 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 protontricks (legacy method): {components}") + self.logger.info(f"Installing all components with system protontricks: {components}") # Import protontricks handler from ..handlers.protontricks_handler import ProtontricksHandler @@ -1013,11 +803,17 @@ class WinetricksHandler: elif os.path.exists(ge_proton_wine): wine_binary = ge_proton_wine - # Fall back to auto-detection if user selection failed or is 'auto' + # Only auto-detect if user explicitly chose 'auto' if not wine_binary: - best_proton = WineUtils.select_best_proton() - if best_proton: - wine_binary = WineUtils.find_proton_binary(best_proton['name']) + 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']) + 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: diff --git a/jackify/backend/models/configuration.py b/jackify/backend/models/configuration.py index fcacb0b..405f10c 100644 --- a/jackify/backend/models/configuration.py +++ b/jackify/backend/models/configuration.py @@ -68,7 +68,9 @@ class SystemInfo: steam_root: Optional[Path] = None steam_user_id: Optional[str] = None proton_version: Optional[str] = None - + is_flatpak_steam: bool = False + is_native_steam: bool = False + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary.""" return { @@ -76,4 +78,6 @@ class SystemInfo: 'steam_root': str(self.steam_root) if self.steam_root else None, 'steam_user_id': self.steam_user_id, 'proton_version': self.proton_version, + 'is_flatpak_steam': self.is_flatpak_steam, + 'is_native_steam': self.is_native_steam, } \ No newline at end of file diff --git a/jackify/backend/models/modlist_metadata.py b/jackify/backend/models/modlist_metadata.py new file mode 100644 index 0000000..5204782 --- /dev/null +++ b/jackify/backend/models/modlist_metadata.py @@ -0,0 +1,216 @@ +""" +Data models for modlist metadata from jackify-engine JSON output. + +These models match the JSON schema documented in MODLIST_METADATA_IMPLEMENTATION.md +""" +from dataclasses import dataclass, field +from typing import List, Optional +from datetime import datetime + + +@dataclass +class ModlistImages: + """Image URLs for modlist (small thumbnail and large banner)""" + small: str + large: str + + +@dataclass +class ModlistLinks: + """External links associated with the modlist""" + image: Optional[str] = None + readme: Optional[str] = None + download: Optional[str] = None + discordURL: Optional[str] = None + websiteURL: Optional[str] = None + + +@dataclass +class ModlistSizes: + """Size information for modlist downloads and installation""" + downloadSize: int + downloadSizeFormatted: str + installSize: int + installSizeFormatted: str + totalSize: int + totalSizeFormatted: str + numberOfArchives: int + numberOfInstalledFiles: int + + +@dataclass +class ModlistValidation: + """Validation status from Wabbajack build server (optional)""" + failed: int = 0 + passed: int = 0 + updating: int = 0 + mirrored: int = 0 + modListIsMissing: bool = False + hasFailures: bool = False + + +@dataclass +class ModlistMetadata: + """Complete modlist metadata from jackify-engine""" + # Basic information + title: str + description: str + author: str + maintainers: List[str] + namespacedName: str + repositoryName: str + machineURL: str + + # Game information + game: str + gameHumanFriendly: str + + # Status flags + official: bool + nsfw: bool + utilityList: bool + forceDown: bool + imageContainsTitle: bool + + # Version information + version: Optional[str] = None + displayVersionOnlyInInstallerView: bool = False + + # Dates + dateCreated: Optional[str] = None # ISO8601 format + dateUpdated: Optional[str] = None # ISO8601 format + + # Categorization + tags: List[str] = field(default_factory=list) + + # Nested objects + links: Optional[ModlistLinks] = None + sizes: Optional[ModlistSizes] = None + images: Optional[ModlistImages] = None + + # Optional data (only if flags specified) + validation: Optional[ModlistValidation] = None + mods: List[str] = field(default_factory=list) + + def is_available(self) -> bool: + """Check if modlist is available for installation""" + if self.forceDown: + return False + if self.validation and self.validation.hasFailures: + return False + return True + + def is_broken(self) -> bool: + """Check if modlist has validation failures""" + return self.validation.hasFailures if self.validation else False + + def get_date_updated_datetime(self) -> Optional[datetime]: + """Parse dateUpdated string to datetime object""" + if not self.dateUpdated: + return None + try: + return datetime.fromisoformat(self.dateUpdated.replace('Z', '+00:00')) + except (ValueError, AttributeError): + return None + + def get_date_created_datetime(self) -> Optional[datetime]: + """Parse dateCreated string to datetime object""" + if not self.dateCreated: + return None + try: + return datetime.fromisoformat(self.dateCreated.replace('Z', '+00:00')) + except (ValueError, AttributeError): + return None + + +@dataclass +class ModlistMetadataResponse: + """Root response object from jackify-engine list-modlists --json""" + metadataVersion: str + timestamp: str # ISO8601 format + count: int + modlists: List[ModlistMetadata] + + def get_timestamp_datetime(self) -> Optional[datetime]: + """Parse timestamp string to datetime object""" + try: + return datetime.fromisoformat(self.timestamp.replace('Z', '+00:00')) + except (ValueError, AttributeError): + return None + + def filter_by_game(self, game: str) -> List[ModlistMetadata]: + """Filter modlists by game name""" + return [m for m in self.modlists if m.game.lower() == game.lower()] + + def filter_available_only(self) -> List[ModlistMetadata]: + """Filter to only available (non-broken, non-forced-down) modlists""" + return [m for m in self.modlists if m.is_available()] + + def filter_by_tag(self, tag: str) -> List[ModlistMetadata]: + """Filter modlists by tag""" + return [m for m in self.modlists if tag.lower() in [t.lower() for t in m.tags]] + + def filter_official_only(self) -> List[ModlistMetadata]: + """Filter to only official modlists""" + return [m for m in self.modlists if m.official] + + def search(self, query: str) -> List[ModlistMetadata]: + """Search modlists by title, description, or author""" + query_lower = query.lower() + return [ + m for m in self.modlists + if query_lower in m.title.lower() + or query_lower in m.description.lower() + or query_lower in m.author.lower() + ] + + +def parse_modlist_metadata_from_dict(data: dict) -> ModlistMetadata: + """Parse a modlist metadata dictionary into ModlistMetadata object""" + # Parse nested objects + images = ModlistImages(**data['images']) if 'images' in data and data['images'] else None + links = ModlistLinks(**data['links']) if 'links' in data and data['links'] else None + sizes = ModlistSizes(**data['sizes']) if 'sizes' in data and data['sizes'] else None + validation = ModlistValidation(**data['validation']) if 'validation' in data and data['validation'] else None + + # Create ModlistMetadata with nested objects + metadata = ModlistMetadata( + title=data['title'], + description=data['description'], + author=data['author'], + maintainers=data.get('maintainers', []), + namespacedName=data['namespacedName'], + repositoryName=data['repositoryName'], + machineURL=data['machineURL'], + game=data['game'], + gameHumanFriendly=data['gameHumanFriendly'], + official=data['official'], + nsfw=data['nsfw'], + utilityList=data['utilityList'], + forceDown=data['forceDown'], + imageContainsTitle=data['imageContainsTitle'], + version=data.get('version'), + displayVersionOnlyInInstallerView=data.get('displayVersionOnlyInInstallerView', False), + dateCreated=data.get('dateCreated'), + dateUpdated=data.get('dateUpdated'), + tags=data.get('tags', []), + links=links, + sizes=sizes, + images=images, + validation=validation, + mods=data.get('mods', []) + ) + + return metadata + + +def parse_modlist_metadata_response(data: dict) -> ModlistMetadataResponse: + """Parse the full JSON response from jackify-engine into ModlistMetadataResponse""" + modlists = [parse_modlist_metadata_from_dict(m) for m in data.get('modlists', [])] + + return ModlistMetadataResponse( + metadataVersion=data['metadataVersion'], + timestamp=data['timestamp'], + count=data['count'], + modlists=modlists + ) diff --git a/jackify/backend/services/automated_prefix_service.py b/jackify/backend/services/automated_prefix_service.py index 13c79de..d2b0071 100644 --- a/jackify/backend/services/automated_prefix_service.py +++ b/jackify/backend/services/automated_prefix_service.py @@ -29,9 +29,10 @@ class AutomatedPrefixService: and direct Proton wrapper integration. """ - def __init__(self): + def __init__(self, system_info=None): self.scripts_dir = Path.home() / "Jackify/scripts" self.scripts_dir.mkdir(parents=True, exist_ok=True) + self.system_info = system_info # Use shared timing for consistency across services def _get_progress_timestamp(self): @@ -546,13 +547,15 @@ exit""" def restart_steam(self) -> bool: """ Restart Steam using the robust service method. - + Returns: True if successful, False otherwise """ try: from .steam_restart_service import robust_steam_restart - return robust_steam_restart(progress_callback=None, timeout=60) + # Use system_info if available (backward compatibility) + system_info = getattr(self, 'system_info', None) + return robust_steam_restart(progress_callback=None, timeout=60, system_info=system_info) except Exception as e: logger.error(f"Error restarting Steam: {e}") return False @@ -929,22 +932,35 @@ echo Prefix creation complete. # 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 - config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = proton_version + + # 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) - actual_value = verify_data.get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid)) - debug_print(f"[DEBUG] Verification: AppID {appid} -> {actual_value}") + 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 @@ -1045,7 +1061,18 @@ echo Prefix creation complete. 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 - env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(Path.home() / ".local/share/Steam") + + # 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 = [ @@ -1109,7 +1136,10 @@ echo Prefix creation complete. def _get_compatdata_path_for_appid(self, appid: int) -> Optional[Path]: """ - Get the compatdata path for a given AppID using existing Jackify functions. + 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 @@ -1117,22 +1147,32 @@ echo Prefix creation complete. Returns: Path to the compatdata directory, or None if not found """ - # Use existing Jackify path detection 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 - # Fallback: construct the path manually - possible_bases = [ + # 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", - Path.home() / ".var/app/com.valvesoftware.Steam/home/.steam/steam/steamapps/compatdata", - Path.home() / ".var/app/com.valvesoftware.Steam/home/.local/share/Steam/steamapps/compatdata", ] - for base_path in possible_bases: + for base_path in fallback_bases: if base_path.is_dir(): return base_path / str(appid) @@ -2666,9 +2706,40 @@ echo Prefix creation complete. True if successful, False otherwise """ try: - steam_root = Path.home() / ".steam/steam" - compatdata_dir = steam_root / "steamapps/compatdata" - proton_common_dir = steam_root / "steamapps/common" + # 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) @@ -2686,9 +2757,9 @@ echo Prefix creation complete. env['WINEDEBUG'] = '-all' env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d' - # Create the compatdata directory + # Create the compatdata directory for this AppID (but never the whole tree) compat_dir = compatdata_dir / str(abs(appid)) - compat_dir.mkdir(parents=True, exist_ok=True) + 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']}") diff --git a/jackify/backend/services/modlist_gallery_service.py b/jackify/backend/services/modlist_gallery_service.py new file mode 100644 index 0000000..8e5336f --- /dev/null +++ b/jackify/backend/services/modlist_gallery_service.py @@ -0,0 +1,474 @@ +""" +Service for fetching and managing modlist metadata for the gallery view. + +Handles jackify-engine integration, caching, and image management. +""" +import json +import subprocess +import time +import threading +from pathlib import Path +from typing import Optional, List, Dict +from datetime import datetime, timedelta +import urllib.request + +from jackify.backend.models.modlist_metadata import ( + ModlistMetadataResponse, + ModlistMetadata, + parse_modlist_metadata_response +) +from jackify.backend.core.modlist_operations import get_jackify_engine_path +from jackify.backend.handlers.config_handler import ConfigHandler +from jackify.shared.paths import get_jackify_data_dir + + +class ModlistGalleryService: + """Service for fetching and caching modlist metadata from jackify-engine""" + + CACHE_VALIDITY_DAYS = 7 # Refresh cache after 7 days + # CRITICAL: Thread lock to prevent concurrent engine calls that could cause recursive spawning + _engine_call_lock = threading.Lock() + + def __init__(self): + """Initialize the gallery service""" + self.config_handler = ConfigHandler() + # Cache directories in Jackify Data Directory + jackify_data_dir = get_jackify_data_dir() + self.CACHE_DIR = jackify_data_dir / "modlist-cache" / "metadata" + self.IMAGE_CACHE_DIR = jackify_data_dir / "modlist-cache" / "images" + self.METADATA_CACHE_FILE = self.CACHE_DIR / "modlist_metadata.json" + self._ensure_cache_dirs() + # Tag metadata caches (avoid refetching per render) + self._tag_mappings_cache: Optional[Dict[str, str]] = None + self._tag_mapping_lookup: Optional[Dict[str, str]] = None + self._allowed_tags_cache: Optional[set] = None + self._allowed_tags_lookup: Optional[Dict[str, str]] = None + + def _ensure_cache_dirs(self): + """Create cache directories if they don't exist""" + self.CACHE_DIR.mkdir(parents=True, exist_ok=True) + self.IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + def fetch_modlist_metadata( + self, + include_validation: bool = True, + include_search_index: bool = False, + sort_by: str = "title", + force_refresh: bool = False + ) -> Optional[ModlistMetadataResponse]: + """ + Fetch modlist metadata from jackify-engine. + + Args: + include_validation: Include validation status (slower) + include_search_index: Include mod search index (slower) + sort_by: Sort order (title, size, date) + force_refresh: Force refresh even if cache is valid + + Returns: + ModlistMetadataResponse or None if fetch fails + """ + # Check cache first unless force refresh + # If include_search_index is True, check if cache has mods before using it + if not force_refresh: + cached = self._load_from_cache() + if cached and self._is_cache_valid(): + # If we need search index, check if cached data has mods + if include_search_index: + # Check if at least one modlist has mods (indicates cache was built with search index) + has_mods = any(hasattr(m, 'mods') and m.mods for m in cached.modlists) + if has_mods: + return cached # Cache has mods, use it + # Cache doesn't have mods, need to fetch fresh + else: + return cached # Don't need search index, use cache + + # Fetch fresh data from jackify-engine + try: + metadata = self._fetch_from_engine( + include_validation=include_validation, + include_search_index=include_search_index, + sort_by=sort_by + ) + + if metadata: + self._save_to_cache(metadata) + + return metadata + + except Exception as e: + print(f"Error fetching modlist metadata: {e}") + # Fall back to cache if available + return self._load_from_cache() + + def _fetch_from_engine( + self, + include_validation: bool, + include_search_index: bool, + sort_by: str + ) -> Optional[ModlistMetadataResponse]: + """Call jackify-engine to fetch modlist metadata""" + # CRITICAL: Use thread lock to prevent concurrent engine calls + # Multiple simultaneous calls could cause recursive spawning issues + with self._engine_call_lock: + # CRITICAL: Get engine path BEFORE cleaning environment + # get_jackify_engine_path() may need APPDIR to locate the engine + engine_path = get_jackify_engine_path() + if not engine_path: + raise FileNotFoundError("jackify-engine not found") + + # Build command + cmd = [str(engine_path), "list-modlists", "--json", "--sort-by", sort_by] + + if include_validation: + cmd.append("--include-validation-status") + + if include_search_index: + cmd.append("--include-search-index") + + # Execute command + # CRITICAL: Use centralized clean environment to prevent AppImage recursive spawning + # This must happen AFTER engine path resolution + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + clean_env = get_clean_subprocess_env() + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout for large data + env=clean_env + ) + + if result.returncode != 0: + raise RuntimeError(f"jackify-engine failed: {result.stderr}") + + # Parse JSON response - skip progress messages and extract JSON + # jackify-engine prints progress to stdout before the JSON + stdout = result.stdout.strip() + + # Find the start of JSON (first '{' on its own line) + lines = stdout.split('\n') + json_start = 0 + for i, line in enumerate(lines): + if line.strip().startswith('{'): + json_start = i + break + + json_text = '\n'.join(lines[json_start:]) + data = json.loads(json_text) + return parse_modlist_metadata_response(data) + + def _load_from_cache(self) -> Optional[ModlistMetadataResponse]: + """Load metadata from cache file""" + if not self.METADATA_CACHE_FILE.exists(): + return None + + try: + with open(self.METADATA_CACHE_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return parse_modlist_metadata_response(data) + except Exception as e: + print(f"Error loading cache: {e}") + return None + + def _save_to_cache(self, metadata: ModlistMetadataResponse): + """Save metadata to cache file""" + try: + # Convert to dict for JSON serialization + data = { + 'metadataVersion': metadata.metadataVersion, + 'timestamp': metadata.timestamp, + 'count': metadata.count, + 'modlists': [self._metadata_to_dict(m) for m in metadata.modlists] + } + + with open(self.METADATA_CACHE_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + + except Exception as e: + print(f"Error saving cache: {e}") + + def _metadata_to_dict(self, metadata: ModlistMetadata) -> dict: + """Convert ModlistMetadata to dict for JSON serialization""" + result = { + 'title': metadata.title, + 'description': metadata.description, + 'author': metadata.author, + 'maintainers': metadata.maintainers, + 'namespacedName': metadata.namespacedName, + 'repositoryName': metadata.repositoryName, + 'machineURL': metadata.machineURL, + 'game': metadata.game, + 'gameHumanFriendly': metadata.gameHumanFriendly, + 'official': metadata.official, + 'nsfw': metadata.nsfw, + 'utilityList': metadata.utilityList, + 'forceDown': metadata.forceDown, + 'imageContainsTitle': metadata.imageContainsTitle, + 'version': metadata.version, + 'displayVersionOnlyInInstallerView': metadata.displayVersionOnlyInInstallerView, + 'dateCreated': metadata.dateCreated, + 'dateUpdated': metadata.dateUpdated, + 'tags': metadata.tags, + 'mods': metadata.mods + } + + if metadata.images: + result['images'] = { + 'small': metadata.images.small, + 'large': metadata.images.large + } + + if metadata.links: + result['links'] = { + 'image': metadata.links.image, + 'readme': metadata.links.readme, + 'download': metadata.links.download, + 'discordURL': metadata.links.discordURL, + 'websiteURL': metadata.links.websiteURL + } + + if metadata.sizes: + result['sizes'] = { + 'downloadSize': metadata.sizes.downloadSize, + 'downloadSizeFormatted': metadata.sizes.downloadSizeFormatted, + 'installSize': metadata.sizes.installSize, + 'installSizeFormatted': metadata.sizes.installSizeFormatted, + 'totalSize': metadata.sizes.totalSize, + 'totalSizeFormatted': metadata.sizes.totalSizeFormatted, + 'numberOfArchives': metadata.sizes.numberOfArchives, + 'numberOfInstalledFiles': metadata.sizes.numberOfInstalledFiles + } + + if metadata.validation: + result['validation'] = { + 'failed': metadata.validation.failed, + 'passed': metadata.validation.passed, + 'updating': metadata.validation.updating, + 'mirrored': metadata.validation.mirrored, + 'modListIsMissing': metadata.validation.modListIsMissing, + 'hasFailures': metadata.validation.hasFailures + } + + return result + + def _is_cache_valid(self) -> bool: + """Check if cache is still valid based on age""" + if not self.METADATA_CACHE_FILE.exists(): + return False + + # Check file modification time + mtime = datetime.fromtimestamp(self.METADATA_CACHE_FILE.stat().st_mtime) + age = datetime.now() - mtime + + return age < timedelta(days=self.CACHE_VALIDITY_DAYS) + + def download_images( + self, + game_filter: Optional[str] = None, + size: str = "both", + overwrite: bool = False + ) -> bool: + """ + Download modlist images to cache using jackify-engine. + + Args: + game_filter: Filter by game name (None = all games) + size: Image size to download (small, large, both) + overwrite: Overwrite existing images + + Returns: + True if successful, False otherwise + """ + # Build command (engine path will be resolved inside lock) + cmd = [ + "placeholder", # Will be replaced with actual engine path + "download-modlist-images", + "--output", str(self.IMAGE_CACHE_DIR), + "--size", size + ] + + if game_filter: + cmd.extend(["--game", game_filter]) + + if overwrite: + cmd.append("--overwrite") + + # Execute command + try: + # CRITICAL: Use thread lock to prevent concurrent engine calls + with self._engine_call_lock: + # CRITICAL: Get engine path BEFORE cleaning environment + # get_jackify_engine_path() may need APPDIR to locate the engine + engine_path = get_jackify_engine_path() + if not engine_path: + return False + + # Update cmd with resolved engine path + cmd[0] = str(engine_path) + + # CRITICAL: Use centralized clean environment to prevent AppImage recursive spawning + # This must happen AFTER engine path resolution + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + clean_env = get_clean_subprocess_env() + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=3600, # 1 hour timeout for downloads + env=clean_env + ) + return result.returncode == 0 + except Exception as e: + print(f"Error downloading images: {e}") + return False + + def get_cached_image_path(self, metadata: ModlistMetadata, size: str = "large") -> Optional[Path]: + """ + Get path to cached image for a modlist (only if it exists). + + Args: + metadata: Modlist metadata + size: Image size (small or large) + + Returns: + Path to cached image or None if not cached + """ + filename = f"{metadata.machineURL}_{size}.webp" + image_path = self.IMAGE_CACHE_DIR / metadata.repositoryName / filename + + if image_path.exists(): + return image_path + return None + + def get_image_cache_path(self, metadata: ModlistMetadata, size: str = "large") -> Path: + """ + Get path where image should be cached (always returns path, even if file doesn't exist). + + Args: + metadata: Modlist metadata + size: Image size (small or large) + + Returns: + Path where image should be cached + """ + filename = f"{metadata.machineURL}_{size}.webp" + return self.IMAGE_CACHE_DIR / metadata.repositoryName / filename + + def get_image_url(self, metadata: ModlistMetadata, size: str = "large") -> Optional[str]: + """ + Get image URL for a modlist. + + Args: + metadata: Modlist metadata + size: Image size (small or large) + + Returns: + Image URL or None if images not available + """ + if not metadata.images: + return None + + return metadata.images.large if size == "large" else metadata.images.small + + def clear_cache(self): + """Clear all cached metadata and images""" + if self.METADATA_CACHE_FILE.exists(): + self.METADATA_CACHE_FILE.unlink() + + # Clear image cache + if self.IMAGE_CACHE_DIR.exists(): + import shutil + shutil.rmtree(self.IMAGE_CACHE_DIR) + self.IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + def get_installed_modlists(self) -> List[str]: + """ + Get list of installed modlist machine URLs. + + Returns: + List of machine URLs for installed modlists + """ + # TODO: Integrate with existing modlist database/config + # For now, return empty list - will be implemented when integrated with existing modlist tracking + return [] + + def is_modlist_installed(self, machine_url: str) -> bool: + """Check if a modlist is installed""" + return machine_url in self.get_installed_modlists() + + def load_tag_mappings(self) -> Dict[str, str]: + """ + Load tag mappings from Wabbajack GitHub repository. + Maps variant tag names to canonical tag names. + + Returns: + Dictionary mapping variant tags to canonical tags + """ + url = "https://raw.githubusercontent.com/wabbajack-tools/mod-lists/master/tag_mappings.json" + try: + with urllib.request.urlopen(url, timeout=10) as response: + data = json.loads(response.read().decode('utf-8')) + return data + except Exception as e: + print(f"Warning: Could not load tag mappings: {e}") + return {} + + def load_allowed_tags(self) -> set: + """ + Load allowed tags from Wabbajack GitHub repository. + + Returns: + Set of allowed tag names (preserving original case) + """ + url = "https://raw.githubusercontent.com/wabbajack-tools/mod-lists/master/allowed_tags.json" + try: + with urllib.request.urlopen(url, timeout=10) as response: + 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}") + return set() + + def _ensure_tag_metadata(self): + """Ensure tag mappings/allowed tags (and lookups) are cached.""" + if self._tag_mappings_cache is None: + self._tag_mappings_cache = self.load_tag_mappings() + if self._tag_mapping_lookup is None: + self._tag_mapping_lookup = {k.lower(): v for k, v in self._tag_mappings_cache.items()} + if self._allowed_tags_cache is None: + self._allowed_tags_cache = self.load_allowed_tags() + if self._allowed_tags_lookup is None: + self._allowed_tags_lookup = {tag.lower(): tag for tag in self._allowed_tags_cache} + + def normalize_tag_value(self, tag: str) -> str: + """ + Normalize a tag to its canonical display form using Wabbajack mappings. + Returns the normalized tag (original casing preserved when possible). + """ + if not tag: + return "" + self._ensure_tag_metadata() + tag_key = tag.strip().lower() + if not tag_key: + return "" + canonical = self._tag_mapping_lookup.get(tag_key, tag.strip()) + # Prefer allowed tag casing if available + return self._allowed_tags_lookup.get(canonical.lower(), canonical) + + def normalize_tags_for_display(self, tags: Optional[List[str]]) -> List[str]: + """Normalize a list of tags for UI display (deduped, canonical casing).""" + if not tags: + return [] + self._ensure_tag_metadata() + normalized = [] + seen = set() + for tag in tags: + normalized_tag = self.normalize_tag_value(tag) + key = normalized_tag.lower() + if key and key not in seen: + normalized.append(normalized_tag) + seen.add(key) + return normalized diff --git a/jackify/backend/services/modlist_service.py b/jackify/backend/services/modlist_service.py index cf038b5..7c2035c 100644 --- a/jackify/backend/services/modlist_service.py +++ b/jackify/backend/services/modlist_service.py @@ -285,8 +285,18 @@ class ModlistService: 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'] + # Build command (copied from working code) + cmd = [engine_path, 'install', '--show-file-progress'] + + # Check GPU setting + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + gpu_enabled = config_handler.get('enable_gpu_texture_conversion', True) + logger.info(f"GPU texture conversion setting: {gpu_enabled}") + if not gpu_enabled: + cmd.append('--no-gpu') + logger.info("Added --no-gpu flag to jackify-engine command") + modlist_value = context.get('modlist_value') if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): cmd += ['-w', modlist_value] @@ -326,8 +336,10 @@ class ModlistService: else: output_callback(f"File descriptor limit warning: {message}") - # Subprocess call (copied from working code) - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=None, cwd=engine_dir) + # 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'' diff --git a/jackify/backend/services/native_steam_service.py b/jackify/backend/services/native_steam_service.py index 65cd084..bc3d51d 100644 --- a/jackify/backend/services/native_steam_service.py +++ b/jackify/backend/services/native_steam_service.py @@ -481,14 +481,34 @@ class NativeSteamService: Returns: (success, app_id) - Success status and the AppID """ - # Auto-detect best Proton version if none provided + # Use Game Proton from settings for shortcut creation (not Install Proton) if proton_version is None: try: - from jackify.backend.core.modlist_operations import _get_user_proton_version - proton_version = _get_user_proton_version() - logger.info(f"Auto-detected Proton version: {proton_version}") + from jackify.backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + game_proton_path = config_handler.get_game_proton_path() + + if game_proton_path and game_proton_path != 'auto': + # User has selected Game Proton - use it + proton_version = os.path.basename(game_proton_path) + # Convert to Steam format + if not proton_version.startswith('GE-Proton'): + proton_version = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_') + if not proton_version.startswith('proton'): + proton_version = f"proton_{proton_version}" + logger.info(f"Using Game Proton from settings: {proton_version}") + else: + # Fallback to auto-detect if Game Proton not set + from jackify.backend.handlers.wine_utils import WineUtils + best_proton = WineUtils.select_best_proton() + if best_proton: + proton_version = best_proton['name'] + logger.info(f"Auto-detected Game Proton: {proton_version}") + else: + proton_version = "proton_experimental" + logger.warning("Failed to auto-detect Game Proton, falling back to experimental") except Exception as e: - logger.warning(f"Failed to auto-detect Proton, falling back to experimental: {e}") + logger.warning(f"Failed to get Game Proton, falling back to experimental: {e}") proton_version = "proton_experimental" logger.info(f"Creating shortcut with Proton: '{app_name}' -> '{proton_version}'") diff --git a/jackify/backend/services/nexus_auth_service.py b/jackify/backend/services/nexus_auth_service.py new file mode 100644 index 0000000..b53529d --- /dev/null +++ b/jackify/backend/services/nexus_auth_service.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Nexus Authentication Service +Unified service for Nexus authentication using OAuth or API key fallback +""" + +import logging +from typing import Optional, Tuple +from .nexus_oauth_service import NexusOAuthService +from ..handlers.oauth_token_handler import OAuthTokenHandler +from .api_key_service import APIKeyService + +logger = logging.getLogger(__name__) + + +class NexusAuthService: + """ + Unified authentication service for Nexus Mods + Handles OAuth 2.0 (preferred) with API key fallback (legacy) + """ + + def __init__(self): + """Initialize authentication service""" + self.oauth_service = NexusOAuthService() + self.token_handler = OAuthTokenHandler() + self.api_key_service = APIKeyService() + logger.debug("NexusAuthService initialized") + + def get_auth_token(self) -> Optional[str]: + """ + Get authentication token, preferring OAuth over API key + + Returns: + Access token or API key, or None if no authentication available + """ + # Try OAuth first + oauth_token = self._get_oauth_token() + if oauth_token: + logger.debug("Using OAuth token for authentication") + return oauth_token + + # Fall back to API key + api_key = self.api_key_service.get_saved_api_key() + if api_key: + logger.debug("Using API key for authentication (OAuth not available)") + return api_key + + logger.warning("No authentication available (neither OAuth nor API key)") + return None + + def _get_oauth_token(self) -> Optional[str]: + """ + Get OAuth access token, refreshing if needed + + Returns: + Valid access token or None + """ + # Check if we have a stored token + if not self.token_handler.has_token(): + logger.debug("No OAuth token stored") + return None + + # Check if token is expired (15 minute buffer for long installs) + if self.token_handler.is_token_expired(buffer_minutes=15): + logger.info("OAuth token expiring soon, attempting refresh") + + # Try to refresh + refresh_token = self.token_handler.get_refresh_token() + if refresh_token: + new_token_data = self.oauth_service.refresh_token(refresh_token) + + if new_token_data: + # Save refreshed token + self.token_handler.save_token({'oauth': new_token_data}) + logger.info("OAuth token refreshed successfully") + return new_token_data.get('access_token') + else: + logger.warning("Token refresh failed, OAuth token invalid") + # Delete invalid token + self.token_handler.delete_token() + return None + else: + logger.warning("No refresh token available") + return None + + # Token is valid, return it + return self.token_handler.get_access_token() + + def is_authenticated(self) -> bool: + """ + Check if user is authenticated via OAuth or API key + + Returns: + True if authenticated + """ + return self.get_auth_token() is not None + + def get_auth_method(self) -> Optional[str]: + """ + Get current authentication method + + Returns: + 'oauth', 'api_key', or None + """ + # Check OAuth first + oauth_token = self._get_oauth_token() + if oauth_token: + return 'oauth' + + # Check API key + api_key = self.api_key_service.get_saved_api_key() + if api_key: + return 'api_key' + + return None + + def get_auth_status(self) -> Tuple[bool, str, Optional[str]]: + """ + Get detailed authentication status + + Returns: + Tuple of (authenticated, method, username) + - authenticated: True if authenticated + - method: 'oauth', 'oauth_expired', 'api_key', or 'none' + - username: Username if available (OAuth only), or None + """ + # Check if OAuth token exists + if self.token_handler.has_token(): + # Check if refresh token is likely expired (hasn't been refreshed in 30+ days) + token_info = self.token_handler.get_token_info() + if token_info.get('refresh_token_likely_expired'): + logger.warning("Refresh token likely expired (30+ days old), user should re-authorize") + return False, 'oauth_expired', None + + # Try OAuth + oauth_token = self._get_oauth_token() + if oauth_token: + # Try to get username from userinfo + user_info = self.oauth_service.get_user_info(oauth_token) + username = user_info.get('name') if user_info else None + return True, 'oauth', username + elif self.token_handler.has_token(): + # Had token but couldn't get valid access token (refresh failed) + logger.warning("OAuth token refresh failed, token may be invalid") + return False, 'oauth_expired', None + + # Try API key + api_key = self.api_key_service.get_saved_api_key() + if api_key: + return True, 'api_key', None + + return False, 'none', None + + def authorize_oauth(self, show_browser_message_callback=None) -> bool: + """ + Perform OAuth authorization flow + + Args: + show_browser_message_callback: Optional callback for browser messages + + Returns: + True if authorization successful + """ + logger.info("Starting OAuth authorization") + + token_data = self.oauth_service.authorize(show_browser_message_callback) + + if token_data: + # Save token + success = self.token_handler.save_token({'oauth': token_data}) + if success: + logger.info("OAuth authorization completed successfully") + return True + else: + logger.error("Failed to save OAuth token") + return False + else: + logger.error("OAuth authorization failed") + return False + + def revoke_oauth(self) -> bool: + """ + Revoke OAuth authorization by deleting stored token + + Returns: + True if revoked successfully + """ + logger.info("Revoking OAuth authorization") + return self.token_handler.delete_token() + + def save_api_key(self, api_key: str) -> bool: + """ + Save API key (legacy fallback) + + Args: + api_key: Nexus API key + + Returns: + True if saved successfully + """ + return self.api_key_service.save_api_key(api_key) + + def validate_api_key(self, api_key: Optional[str] = None) -> Tuple[bool, Optional[str]]: + """ + Validate API key against Nexus API + + Args: + api_key: Optional API key to validate (uses stored if not provided) + + Returns: + Tuple of (valid, username_or_error) + """ + return self.api_key_service.validate_api_key(api_key) + + def ensure_valid_auth(self) -> Optional[str]: + """ + Ensure we have valid authentication, refreshing if needed + This should be called before any Nexus operation + + Returns: + Valid auth token (OAuth access token or API key), or None + """ + auth_token = self.get_auth_token() + + if not auth_token: + logger.warning("No authentication available for Nexus operation") + + return auth_token + + def get_auth_for_engine(self) -> Optional[str]: + """ + Get authentication token for jackify-engine + Same as ensure_valid_auth() - engine uses NEXUS_API_KEY env var for both OAuth and API keys + (This matches upstream Wabbajack behavior) + + Returns: + Valid auth token to pass via NEXUS_API_KEY environment variable, or None + """ + return self.ensure_valid_auth() + + def clear_all_auth(self) -> bool: + """ + Clear all authentication (both OAuth and API key) + Useful for testing or switching accounts + + Returns: + True if any auth was cleared + """ + oauth_cleared = self.token_handler.delete_token() + api_key_cleared = self.api_key_service.clear_api_key() + + if oauth_cleared or api_key_cleared: + logger.info("Cleared all Nexus authentication") + return True + else: + logger.debug("No authentication to clear") + return False diff --git a/jackify/backend/services/nexus_oauth_service.py b/jackify/backend/services/nexus_oauth_service.py new file mode 100644 index 0000000..9932d99 --- /dev/null +++ b/jackify/backend/services/nexus_oauth_service.py @@ -0,0 +1,759 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Nexus OAuth Service +Handles OAuth 2.0 authentication flow with Nexus Mods using PKCE +""" + +import os +import base64 +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 + +logger = logging.getLogger(__name__) + + +class NexusOAuthService: + """ + Handles OAuth 2.0 authentication with Nexus Mods + Uses PKCE flow with system browser and localhost callback + """ + + # OAuth Configuration + CLIENT_ID = "jackify" + AUTH_URL = "https://users.nexusmods.com/oauth/authorize" + TOKEN_URL = "https://users.nexusmods.com/oauth/token" + USERINFO_URL = "https://users.nexusmods.com/oauth/userinfo" + SCOPES = "public openid profile" + + # Redirect configuration (custom protocol scheme - no SSL cert needed!) + # Requires jackify:// protocol handler to be registered with OS + REDIRECT_URI = "jackify://oauth/callback" + + # Callback timeout (5 minutes) + CALLBACK_TIMEOUT = 300 + + def __init__(self): + """Initialize OAuth service""" + self._auth_code = None + self._auth_state = None + self._auth_error = None + self._server_done = threading.Event() + + # Ensure jackify:// protocol is registered on first use + self._ensure_protocol_registered() + + def _generate_pkce_params(self) -> Tuple[str, str, str]: + """ + Generate PKCE code verifier, challenge, and state + + Returns: + Tuple of (code_verifier, code_challenge, state) + """ + # Generate code verifier (43-128 characters, base64url encoded) + code_verifier = base64.urlsafe_b64encode( + os.urandom(32) + ).decode('utf-8').rstrip('=') + + # Generate code challenge (SHA256 hash of verifier, base64url encoded) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode('utf-8')).digest() + ).decode('utf-8').rstrip('=') + + # Generate state for CSRF protection + state = secrets.token_urlsafe(32) + + 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 = ( + getattr(sys, 'frozen', False) or # PyInstaller frozen + '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/ + exec_path = f"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() + if f"Exec={exec_path} %u" not in current_content: + needs_update = True + logger.info(f"Updating desktop file with new Exec path: {exec_path}") + + if needs_update: + desktop_file.parent.mkdir(parents=True, exist_ok=True) + + # Build desktop file content with proper working directory + if is_appimage: + # AppImage doesn't need working directory + 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 needs working directory set to src/ + # exec_path already contains the correct format: "cd {src_dir} && {sys.executable} -m jackify.frontends.gui" + 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 + """ + 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 + } + + 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 + """ + try: + # Try notify-send (Linux) + subprocess.run( + ['notify-send', title, message], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2 + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + def _exchange_code_for_token( + self, + auth_code: str, + code_verifier: str + ) -> Optional[Dict]: + """ + Exchange authorization code for access token + + Args: + auth_code: Authorization code from callback + code_verifier: PKCE code verifier + + Returns: + Token response dict or None on failure + """ + data = { + 'grant_type': 'authorization_code', + 'client_id': self.CLIENT_ID, + 'redirect_uri': self.REDIRECT_URI, + 'code': auth_code, + 'code_verifier': code_verifier + } + + try: + response = requests.post(self.TOKEN_URL, data=data, timeout=10) + + if response.status_code == 200: + token_data = response.json() + logger.info("Successfully exchanged authorization code for token") + return token_data + else: + logger.error(f"Token exchange failed: {response.status_code} - {response.text}") + return None + + except requests.RequestException as e: + logger.error(f"Token exchange request failed: {e}") + return None + + def refresh_token(self, refresh_token: str) -> Optional[Dict]: + """ + Refresh an access token using refresh token + + Args: + refresh_token: Refresh token from previous authentication + + Returns: + New token response dict or None on failure + """ + data = { + 'grant_type': 'refresh_token', + 'client_id': self.CLIENT_ID, + 'refresh_token': refresh_token + } + + try: + response = requests.post(self.TOKEN_URL, data=data, timeout=10) + + if response.status_code == 200: + token_data = response.json() + logger.info("Successfully refreshed access token") + return token_data + else: + logger.error(f"Token refresh failed: {response.status_code} - {response.text}") + return None + + except requests.RequestException as e: + logger.error(f"Token refresh request failed: {e}") + return None + + def get_user_info(self, access_token: str) -> Optional[Dict]: + """ + Get user information using access token + + Args: + access_token: OAuth access token + + Returns: + User info dict or None on failure + """ + headers = { + 'Authorization': f'Bearer {access_token}' + } + + try: + response = requests.get(self.USERINFO_URL, headers=headers, timeout=10) + + if response.status_code == 200: + user_info = response.json() + logger.info(f"Retrieved user info for: {user_info.get('name', 'unknown')}") + return user_info + else: + logger.error(f"User info request failed: {response.status_code}") + return None + + except requests.RequestException as e: + logger.error(f"User info request failed: {e}") + return None + + def authorize(self, show_browser_message_callback=None) -> Optional[Dict]: + """ + Perform full OAuth authorization flow + + Args: + show_browser_message_callback: Optional callback to display message about browser opening + + Returns: + Token response dict or None on failure + """ + logger.info("Starting Nexus OAuth authorization flow") + + # Reset state + self._auth_code = None + self._auth_state = None + self._auth_error = None + self._server_done.clear() + + # Generate PKCE parameters + code_verifier, code_challenge, state = self._generate_pkce_params() + logger.debug(f"Generated PKCE parameters (state: {state[:10]}...)") + + # Build authorization URL + auth_url = self._build_authorization_url(code_challenge, state) + + # Open browser + logger.info("Opening browser for authorisation") + + try: + # When running from AppImage, we need to clean the environment to avoid + # library conflicts with system tools (xdg-open, kde-open, etc.) + import os + import subprocess + + env = os.environ.copy() + + # Remove AppImage-specific environment variables that can cause conflicts + # These variables inject AppImage's bundled libraries into child processes + appimage_vars = [ + 'LD_LIBRARY_PATH', + 'PYTHONPATH', + 'PYTHONHOME', + 'QT_PLUGIN_PATH', + 'QML2_IMPORT_PATH', + ] + + # Check if we're running from AppImage + if 'APPIMAGE' in env or 'APPDIR' in env: + logger.debug("Running from AppImage - cleaning environment for browser launch") + for var in appimage_vars: + if var in env: + del env[var] + logger.debug(f"Removed {var} from browser environment") + + # Use Popen instead of run to avoid waiting for browser to close + # xdg-open may not return until the browser closes, which could be never + try: + process = subprocess.Popen( + ['xdg-open', auth_url], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True # Detach from parent process + ) + # Give it a moment to fail if it's going to fail + import time + time.sleep(0.5) + + # Check if process is still running or has exited successfully + poll_result = process.poll() + if poll_result is None: + # Process still running - browser is opening/open + logger.info("Browser opened successfully via xdg-open (process running)") + browser_opened = True + elif poll_result == 0: + # Process exited successfully + logger.info("Browser opened successfully via xdg-open (exit code 0)") + browser_opened = True + else: + # Process exited with error + logger.warning(f"xdg-open exited with code {poll_result}, trying webbrowser module") + if webbrowser.open(auth_url): + logger.info("Browser opened successfully via webbrowser module") + browser_opened = True + else: + logger.warning("webbrowser.open returned False") + browser_opened = False + except FileNotFoundError: + # xdg-open not found - try webbrowser module + logger.warning("xdg-open not found, trying webbrowser module") + if webbrowser.open(auth_url): + logger.info("Browser opened successfully via webbrowser module") + browser_opened = True + else: + logger.warning("webbrowser.open returned False") + browser_opened = False + except Exception as e: + logger.error(f"Error opening browser: {e}") + browser_opened = False + + # Send desktop notification + self._send_desktop_notification( + "Jackify - Nexus Authorisation", + "Please check your browser to authorise Jackify" + ) + + # Show message via callback if provided (AFTER browser opens) + if show_browser_message_callback: + if browser_opened: + show_browser_message_callback( + "Browser opened for Nexus authorisation.\n\n" + "After clicking 'Authorize', your browser may ask to\n" + "open Jackify or show a popup blocker notification.\n\n" + "Please click 'Open' or 'Allow' to complete authorization." + ) + else: + show_browser_message_callback( + f"Could not open browser automatically.\n\n" + f"Please open this URL manually:\n{auth_url}" + ) + + # 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 + + 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 + + logger.info("Authorization code received, exchanging for token") + + # 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") + + return token_data diff --git a/jackify/backend/services/protontricks_detection_service.py b/jackify/backend/services/protontricks_detection_service.py index c659630..95e6471 100644 --- a/jackify/backend/services/protontricks_detection_service.py +++ b/jackify/backend/services/protontricks_detection_service.py @@ -6,8 +6,11 @@ Centralized service for detecting and managing protontricks installation across """ import logging +import os import shutil import subprocess +import sys +import importlib.util from typing import Optional, Tuple from ..handlers.protontricks_handler import ProtontricksHandler from ..handlers.config_handler import ConfigHandler @@ -44,11 +47,11 @@ class ProtontricksDetectionService: def detect_protontricks(self, use_cache: bool = True) -> Tuple[bool, str, str]: """ - Detect if protontricks is installed and get installation details - + Detect if system protontricks is installed and get installation details + Args: use_cache (bool): Whether to use cached detection result - + Returns: Tuple[bool, str, str]: (is_installed, installation_type, details_message) - is_installed: True if protontricks is available @@ -82,7 +85,7 @@ class ProtontricksDetectionService: details_message = "Protontricks is installed (unknown type)" else: installation_type = 'none' - details_message = "Protontricks not found - required for Jackify functionality" + details_message = "Protontricks not found - install via flatpak or package manager" # Cache the result self._last_detection_result = (is_installed, installation_type, details_message) @@ -93,55 +96,22 @@ class ProtontricksDetectionService: def _detect_without_prompts(self, handler: ProtontricksHandler) -> bool: """ - Detect protontricks without user prompts or installation attempts - + Detect system protontricks (flatpak or native) without user prompts. + Args: handler (ProtontricksHandler): Handler instance to use - + Returns: - bool: True if protontricks is found + bool: True if system protontricks is found + """ + # Use the handler's silent detection method + return handler.detect_protontricks() + + def is_bundled_mode(self) -> bool: + """ + DEPRECATED: Bundled protontricks no longer supported. + Always returns False for backwards compatibility. """ - import shutil - - # Check if protontricks exists as a command - protontricks_path_which = shutil.which("protontricks") - - 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}") - handler.which_protontricks = 'flatpak' - # Continue to check flatpak list just to be sure - else: - logger.info(f"Native Protontricks found at {protontricks_path_which}") - handler.which_protontricks = 'native' - handler.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 - try: - env = handler._get_clean_subprocess_env() - result = subprocess.run( - ["flatpak", "list"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, # Suppress stderr to avoid error messages - text=True, - env=env - ) - if result.returncode == 0 and "com.github.Matoking.protontricks" in result.stdout: - logger.info("Flatpak Protontricks is installed") - handler.which_protontricks = 'flatpak' - 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}") - return False def install_flatpak_protontricks(self) -> Tuple[bool, str]: diff --git a/jackify/backend/services/steam_restart_service.py b/jackify/backend/services/steam_restart_service.py index 2815cc8..1a83692 100644 --- a/jackify/backend/services/steam_restart_service.py +++ b/jackify/backend/services/steam_restart_service.py @@ -10,42 +10,82 @@ from typing import Callable, Optional logger = logging.getLogger(__name__) +STRATEGY_JACKIFY = "jackify" +STRATEGY_NAK_SIMPLE = "nak_simple" + + +def _get_restart_strategy() -> str: + """Read restart strategy from config with safe fallback.""" + try: + 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): + return STRATEGY_JACKIFY + return strategy + except Exception as exc: # pragma: no cover - defensive logging only + logger.debug(f"Steam restart: Unable to read strategy from config: {exc}") + return STRATEGY_JACKIFY + + +def _strategy_label(strategy: str) -> str: + if strategy == STRATEGY_NAK_SIMPLE: + return "NaK simple restart" + return "Jackify hardened restart" + def _get_clean_subprocess_env(): """ - Create a clean environment for subprocess calls by removing PyInstaller-specific - environment variables that can interfere with Steam execution. + Create a clean environment for subprocess calls by stripping bundle-specific + environment variables (e.g., frozen AppImage remnants) that can interfere with Steam. + + CRITICAL: Preserves all display/session variables that Steam needs for GUI: + - DISPLAY, WAYLAND_DISPLAY, XDG_SESSION_TYPE, DBUS_SESSION_BUS_ADDRESS, + XDG_RUNTIME_DIR, XAUTHORITY, etc. Returns: - dict: Cleaned environment dictionary + dict: Cleaned environment dictionary with GUI variables preserved """ env = os.environ.copy() - pyinstaller_vars_removed = [] + bundle_vars_removed = [] - # Remove PyInstaller-specific environment variables + # CRITICAL: Preserve display/session variables that Steam GUI needs + # These MUST be kept for Steam to open its GUI window + gui_vars_to_preserve = [ + 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS', + 'XDG_RUNTIME_DIR', 'XAUTHORITY', 'XDG_CURRENT_DESKTOP', 'XDG_SESSION_DESKTOP', + 'QT_QPA_PLATFORM', 'GDK_BACKEND', 'XDG_DATA_DIRS', 'XDG_CONFIG_DIRS' + ] + preserved_gui_vars = {} + for var in gui_vars_to_preserve: + if var in env: + preserved_gui_vars[var] = env[var] + logger.debug(f"Steam restart: Preserving GUI variable {var}={env[var][:50] if len(str(env[var])) > 50 else env[var]}") + + # Remove bundle-specific environment variables if env.pop('_MEIPASS', None): - pyinstaller_vars_removed.append('_MEIPASS') + bundle_vars_removed.append('_MEIPASS') if env.pop('_MEIPASS2', None): - pyinstaller_vars_removed.append('_MEIPASS2') + bundle_vars_removed.append('_MEIPASS2') - # Clean library path variables that PyInstaller modifies (Linux/Unix) + # 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 PyInstaller + # Restore original LD_LIBRARY_PATH if it was backed up by the bundler env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG'] - pyinstaller_vars_removed.append('LD_LIBRARY_PATH (restored from _ORIG)') + bundle_vars_removed.append('LD_LIBRARY_PATH (restored from _ORIG)') else: - # Remove PyInstaller-modified LD_LIBRARY_PATH + # Remove modified LD_LIBRARY_PATH entries if env.pop('LD_LIBRARY_PATH', None): - pyinstaller_vars_removed.append('LD_LIBRARY_PATH (removed)') + bundle_vars_removed.append('LD_LIBRARY_PATH (removed)') - # Clean PATH of PyInstaller-specific entries + # Clean PATH of bundle-specific entries if 'PATH' in env and hasattr(sys, '_MEIPASS'): path_entries = env['PATH'].split(os.pathsep) original_count = len(path_entries) - # Remove any PATH entries that point to PyInstaller temp directory + # Remove any PATH entries that point to the bundle's temp directory cleaned_path = [p for p in path_entries if not p.startswith(sys._MEIPASS)] env['PATH'] = os.pathsep.join(cleaned_path) if len(cleaned_path) < original_count: - pyinstaller_vars_removed.append(f'PATH (removed {original_count - len(cleaned_path)} PyInstaller entries)') + bundle_vars_removed.append(f'PATH (removed {original_count - len(cleaned_path)} bundle entries)') # Clean macOS library path (if present) if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'): @@ -53,16 +93,26 @@ def _get_clean_subprocess_env(): 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) - pyinstaller_vars_removed.append('DYLD_LIBRARY_PATH (cleaned)') + bundle_vars_removed.append('DYLD_LIBRARY_PATH (cleaned)') else: env.pop('DYLD_LIBRARY_PATH', None) - pyinstaller_vars_removed.append('DYLD_LIBRARY_PATH (removed)') + bundle_vars_removed.append('DYLD_LIBRARY_PATH (removed)') + + # Ensure GUI variables are still present (they should be, but double-check) + for var, value in preserved_gui_vars.items(): + if var not in env: + env[var] = value + logger.warning(f"Steam restart: Restored GUI variable {var} that was accidentally removed") # Log what was cleaned for debugging - if pyinstaller_vars_removed: - logger.debug(f"Steam restart: Cleaned PyInstaller environment variables: {', '.join(pyinstaller_vars_removed)}") + if bundle_vars_removed: + logger.debug(f"Steam restart: Cleaned bundled environment variables: {', '.join(bundle_vars_removed)}") else: - logger.debug("Steam restart: No PyInstaller environment variables detected (likely DEV mode)") + logger.debug("Steam restart: No bundled environment variables detected (likely DEV mode)") + + # Log preserved GUI variables for debugging + if preserved_gui_vars: + logger.debug(f"Steam restart: Preserved {len(preserved_gui_vars)} GUI environment variables") return env @@ -138,22 +188,99 @@ def wait_for_steam_exit(timeout: int = 60, check_interval: float = 0.5) -> bool: time.sleep(check_interval) return False -def start_steam() -> bool: - """Attempt to start Steam using the exact methods from existing working logic.""" - env = _get_clean_subprocess_env() +def _start_steam_nak_style(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. + """ + 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]}") + else: + logger.warning(f"NaK-style 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.") + 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) + 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 + subprocess.Popen("steam", shell=True, env=env) + + time.sleep(5) + check_result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env) + if check_result.returncode == 0: + logger.info("NaK-style restart detected running Steam process.") + return True + + logger.warning("NaK-style restart did not detect Steam process after launch.") + return False + except FileNotFoundError as exc: + logger.error(f"NaK-style restart command not found: {exc}") + return False + except Exception as exc: + logger.error(f"NaK-style restart encountered an error: {exc}") + return False + + +def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None, strategy: str = STRATEGY_JACKIFY) -> bool: + """ + Attempt to start Steam using the exact methods from existing working logic. + + Args: + is_steamdeck_flag: Optional pre-detected Steam Deck status + is_flatpak_flag: Optional pre-detected Flatpak Steam status + env_override: Optional environment dictionary for subprocess calls + strategy: Restart strategy identifier + """ + if strategy == STRATEGY_NAK_SIMPLE: + return _start_steam_nak_style( + is_steamdeck_flag=is_steamdeck_flag, + is_flatpak_flag=is_flatpak_flag, + env_override=env_override or os.environ.copy(), + ) + + env = env_override if env_override is not None else _get_clean_subprocess_env() + + # Use provided flags or detect + _is_steam_deck = is_steamdeck_flag if is_steamdeck_flag is not None else is_steam_deck() + _is_flatpak = is_flatpak_flag if is_flatpak_flag is not None else is_flatpak_steam() + logger.info( + "Starting Steam (strategy=%s, steam_deck=%s, flatpak=%s)", + strategy, + _is_steam_deck, + _is_flatpak, + ) + try: # Try systemd user service (Steam Deck) - HIGHEST PRIORITY - if is_steam_deck(): + if _is_steam_deck: + logger.debug("Using systemctl restart for Steam Deck.") subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env) return True # Check if Flatpak Steam (only if not Steam Deck) - if is_flatpak_steam(): + if _is_flatpak: logger.info("Flatpak Steam detected - using flatpak run command") try: - # Redirect flatpak's stderr to suppress "app not installed" errors on systems without flatpak Steam - # Steam's own stdout/stderr will still go through (flatpak forwards them) - subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam", "-silent"], + # Use -foreground to ensure GUI opens (not -silent) + # CRITICAL: Do NOT use start_new_session - Steam needs to inherit the session + logger.debug("Executing: flatpak run com.valvesoftware.Steam -foreground (inheriting session for GUI)") + subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam", "-foreground"], env=env, stderr=subprocess.DEVNULL) time.sleep(5) check_result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env) @@ -161,18 +288,15 @@ def start_steam() -> bool: logger.info("Flatpak Steam process detected after start.") return True else: - logger.warning("Flatpak Steam process not detected after start attempt.") - return False + logger.warning("Flatpak Steam start failed, falling back to normal Steam start methods") except Exception as e: - logger.error(f"Error starting Flatpak Steam: {e}") - return False + logger.warning(f"Flatpak Steam start failed ({e}), falling back to normal Steam start methods") - # Use startup methods with only -silent flag (no -minimized or -no-browser) - # Don't redirect stdout/stderr or use start_new_session to allow Steam to connect to display/tray + # Use startup methods with -foreground flag to ensure GUI opens start_methods = [ - {"name": "Popen", "cmd": ["steam", "-silent"], "kwargs": {"env": env}}, - {"name": "setsid", "cmd": ["setsid", "steam", "-silent"], "kwargs": {"env": env}}, - {"name": "nohup", "cmd": ["nohup", "steam", "-silent"], "kwargs": {"preexec_fn": os.setpgrp, "env": env}} + {"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}} ] for method in start_methods: @@ -201,36 +325,48 @@ def start_steam() -> bool: logger.error(f"Error starting Steam: {e}") return False -def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = None, timeout: int = 60) -> bool: +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. Optionally accepts a progress_callback(message: str) for UI feedback. Uses aggressive pkill approach for maximum reliability. + + Args: + progress_callback: Optional callback for progress updates + timeout: Timeout in seconds for restart operation + system_info: Optional SystemInfo object with pre-detected Steam installation types """ - env = _get_clean_subprocess_env() - + shutdown_env = _get_clean_subprocess_env() + 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() + def report(msg): logger.info(msg) if progress_callback: progress_callback(msg) report("Shutting down Steam...") + report(f"Steam restart strategy: {_strategy_label(strategy)}") # Steam Deck: Use systemctl for shutdown (special handling) - HIGHEST PRIORITY - if is_steam_deck(): + 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=env) + 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 (only if not Steam Deck) - elif is_flatpak_steam(): + elif _is_flatpak: try: report("Flatpak Steam detected - stopping via flatpak...") subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'], - timeout=15, check=False, capture_output=True, stderr=subprocess.DEVNULL, env=env) + timeout=15, check=False, capture_output=True, stderr=subprocess.DEVNULL, env=shutdown_env) time.sleep(2) except Exception as e: logger.debug(f"flatpak kill failed: {e}") @@ -238,21 +374,21 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No # All systems: Use pkill approach (proven 15/16 test success rate) try: # Skip unreliable steam -shutdown, go straight to pkill - pkill_result = subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=env) + 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=env) + 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=env) + 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=env) + 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: @@ -269,19 +405,24 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No # Start Steam using platform-specific logic report("Starting Steam...") - + # Steam Deck: Use systemctl restart (keep existing working approach) - if is_steam_deck(): + if _is_steam_deck: try: - subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env) + subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=start_env) logger.info("Steam Deck: Initiated systemctl restart") except Exception as e: logger.error(f"Steam Deck systemctl restart failed: {e}") report("Failed to restart Steam on Steam Deck.") return False else: - # All other distros: Use proven steam -silent method - if not start_steam(): + # All other distros: Use start_steam() which now uses -foreground to ensure GUI opens + if not start_steam( + is_steamdeck_flag=_is_steam_deck, + is_flatpak_flag=_is_flatpak, + env_override=start_env, + strategy=strategy, + ): report("Failed to start Steam.") return False @@ -294,7 +435,7 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No while elapsed_wait < max_startup_wait: try: - result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env) + result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=start_env) if result.returncode == 0: if not initial_wait_done: logger.info("Steam process detected. Waiting additional time for full initialization...") @@ -302,7 +443,7 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No time.sleep(5) elapsed_wait += 5 if initial_wait_done and elapsed_wait >= 15: - final_check = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env) + final_check = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=start_env) if final_check.returncode == 0: report("Steam started successfully.") logger.info("Steam confirmed running after wait.") diff --git a/jackify/backend/utils/__init__.py b/jackify/backend/utils/__init__.py new file mode 100644 index 0000000..a5b51f9 --- /dev/null +++ b/jackify/backend/utils/__init__.py @@ -0,0 +1,3 @@ +"""Helper utilities for backend services.""" + + diff --git a/jackify/backend/utils/nexus_premium_detector.py b/jackify/backend/utils/nexus_premium_detector.py new file mode 100644 index 0000000..f9501c9 --- /dev/null +++ b/jackify/backend/utils/nexus_premium_detector.py @@ -0,0 +1,46 @@ +""" +Utilities for detecting Nexus Premium requirement messages in engine output. +""" + +from __future__ import annotations + +_KEYWORD_PHRASES = ( + "buy nexus premium", + "requires nexus premium", + "requires a nexus premium", + "nexus premium is required", + "nexus premium required", + "nexus mods premium is required", + "manual download", # Evaluated with additional context +) + + +def is_non_premium_indicator(line: str) -> bool: + """ + Return True if the engine output line indicates a Nexus non-premium scenario. + + Args: + line: Raw line emitted from the jackify-engine process. + """ + if not line: + return False + + normalized = line.strip().lower() + if not normalized: + return False + + # Direct phrase detection + for phrase in _KEYWORD_PHRASES[:6]: + if phrase in normalized: + return True + + if "nexus" in normalized and "premium" in normalized: + return True + + # Manual download + Nexus URL implies premium requirement in current workflows. + if "manual download" in normalized and ("nexusmods.com" in normalized or "nexus mods" in normalized): + return True + + return False + + diff --git a/jackify/engine/Microsoft.CSharp.dll b/jackify/engine/Microsoft.CSharp.dll old mode 100644 new mode 100755 index 24d16df..366b42b Binary files a/jackify/engine/Microsoft.CSharp.dll and b/jackify/engine/Microsoft.CSharp.dll differ diff --git a/jackify/engine/Microsoft.VisualBasic.Core.dll b/jackify/engine/Microsoft.VisualBasic.Core.dll index cac4ffe..43a70bd 100755 Binary files a/jackify/engine/Microsoft.VisualBasic.Core.dll and b/jackify/engine/Microsoft.VisualBasic.Core.dll differ diff --git a/jackify/engine/Microsoft.VisualBasic.dll b/jackify/engine/Microsoft.VisualBasic.dll index 0e606c5..e06e0ad 100755 Binary files a/jackify/engine/Microsoft.VisualBasic.dll and b/jackify/engine/Microsoft.VisualBasic.dll differ diff --git a/jackify/engine/Microsoft.Win32.Primitives.dll b/jackify/engine/Microsoft.Win32.Primitives.dll index 8feaa22..068d5ef 100755 Binary files a/jackify/engine/Microsoft.Win32.Primitives.dll and b/jackify/engine/Microsoft.Win32.Primitives.dll differ diff --git a/jackify/engine/Microsoft.Win32.Registry.dll b/jackify/engine/Microsoft.Win32.Registry.dll index f306d2d..6eb5cc1 100755 Binary files a/jackify/engine/Microsoft.Win32.Registry.dll and b/jackify/engine/Microsoft.Win32.Registry.dll differ diff --git a/jackify/engine/System.AppContext.dll b/jackify/engine/System.AppContext.dll index 1d44a7b..70bd59f 100755 Binary files a/jackify/engine/System.AppContext.dll and b/jackify/engine/System.AppContext.dll differ diff --git a/jackify/engine/System.Buffers.dll b/jackify/engine/System.Buffers.dll index 44b42a7..5a0dfa6 100755 Binary files a/jackify/engine/System.Buffers.dll and b/jackify/engine/System.Buffers.dll differ diff --git a/jackify/engine/System.Collections.Concurrent.dll b/jackify/engine/System.Collections.Concurrent.dll old mode 100644 new mode 100755 index 131282d..846e6b9 Binary files a/jackify/engine/System.Collections.Concurrent.dll and b/jackify/engine/System.Collections.Concurrent.dll differ diff --git a/jackify/engine/System.Collections.Immutable.dll b/jackify/engine/System.Collections.Immutable.dll old mode 100644 new mode 100755 index e5959af..3f6a57e Binary files a/jackify/engine/System.Collections.Immutable.dll and b/jackify/engine/System.Collections.Immutable.dll differ diff --git a/jackify/engine/System.Collections.NonGeneric.dll b/jackify/engine/System.Collections.NonGeneric.dll old mode 100644 new mode 100755 index 0f4ea51..7d1a6ab Binary files a/jackify/engine/System.Collections.NonGeneric.dll and b/jackify/engine/System.Collections.NonGeneric.dll differ diff --git a/jackify/engine/System.Collections.Specialized.dll b/jackify/engine/System.Collections.Specialized.dll old mode 100644 new mode 100755 index 77760d0..749f2ec Binary files a/jackify/engine/System.Collections.Specialized.dll and b/jackify/engine/System.Collections.Specialized.dll differ diff --git a/jackify/engine/System.Collections.dll b/jackify/engine/System.Collections.dll old mode 100644 new mode 100755 index 8f98b94..1d1d055 Binary files a/jackify/engine/System.Collections.dll and b/jackify/engine/System.Collections.dll differ diff --git a/jackify/engine/System.ComponentModel.Annotations.dll b/jackify/engine/System.ComponentModel.Annotations.dll index 91f4157..8ef70cd 100755 Binary files a/jackify/engine/System.ComponentModel.Annotations.dll and b/jackify/engine/System.ComponentModel.Annotations.dll differ diff --git a/jackify/engine/System.ComponentModel.DataAnnotations.dll b/jackify/engine/System.ComponentModel.DataAnnotations.dll index d5b63a4..7331c7d 100755 Binary files a/jackify/engine/System.ComponentModel.DataAnnotations.dll and b/jackify/engine/System.ComponentModel.DataAnnotations.dll differ diff --git a/jackify/engine/System.ComponentModel.EventBasedAsync.dll b/jackify/engine/System.ComponentModel.EventBasedAsync.dll old mode 100644 new mode 100755 index cf9bb89..f7ab97b Binary files a/jackify/engine/System.ComponentModel.EventBasedAsync.dll and b/jackify/engine/System.ComponentModel.EventBasedAsync.dll differ diff --git a/jackify/engine/System.ComponentModel.Primitives.dll b/jackify/engine/System.ComponentModel.Primitives.dll old mode 100644 new mode 100755 index da2b9e2..09af950 Binary files a/jackify/engine/System.ComponentModel.Primitives.dll and b/jackify/engine/System.ComponentModel.Primitives.dll differ diff --git a/jackify/engine/System.ComponentModel.TypeConverter.dll b/jackify/engine/System.ComponentModel.TypeConverter.dll old mode 100644 new mode 100755 index 2145f55..0293fb6 Binary files a/jackify/engine/System.ComponentModel.TypeConverter.dll and b/jackify/engine/System.ComponentModel.TypeConverter.dll differ diff --git a/jackify/engine/System.ComponentModel.dll b/jackify/engine/System.ComponentModel.dll old mode 100644 new mode 100755 index 70b3fe6..fd9a2ef Binary files a/jackify/engine/System.ComponentModel.dll and b/jackify/engine/System.ComponentModel.dll differ diff --git a/jackify/engine/System.Configuration.dll b/jackify/engine/System.Configuration.dll index 64ad31e..40a8387 100755 Binary files a/jackify/engine/System.Configuration.dll and b/jackify/engine/System.Configuration.dll differ diff --git a/jackify/engine/System.Console.dll b/jackify/engine/System.Console.dll old mode 100644 new mode 100755 index 31143df..e0179cd Binary files a/jackify/engine/System.Console.dll and b/jackify/engine/System.Console.dll differ diff --git a/jackify/engine/System.Core.dll b/jackify/engine/System.Core.dll index 40c7865..d6db4ab 100755 Binary files a/jackify/engine/System.Core.dll and b/jackify/engine/System.Core.dll differ diff --git a/jackify/engine/System.Data.Common.dll b/jackify/engine/System.Data.Common.dll old mode 100644 new mode 100755 index ad9052a..6f77660 Binary files a/jackify/engine/System.Data.Common.dll and b/jackify/engine/System.Data.Common.dll differ diff --git a/jackify/engine/System.Data.DataSetExtensions.dll b/jackify/engine/System.Data.DataSetExtensions.dll index de65dfa..44e206d 100755 Binary files a/jackify/engine/System.Data.DataSetExtensions.dll and b/jackify/engine/System.Data.DataSetExtensions.dll differ diff --git a/jackify/engine/System.Data.dll b/jackify/engine/System.Data.dll index afdea17..15fd8f5 100755 Binary files a/jackify/engine/System.Data.dll and b/jackify/engine/System.Data.dll differ diff --git a/jackify/engine/System.Diagnostics.Contracts.dll b/jackify/engine/System.Diagnostics.Contracts.dll index 84d21e5..d80aafa 100755 Binary files a/jackify/engine/System.Diagnostics.Contracts.dll and b/jackify/engine/System.Diagnostics.Contracts.dll differ diff --git a/jackify/engine/System.Diagnostics.Debug.dll b/jackify/engine/System.Diagnostics.Debug.dll index 0c1cd28..3812f8e 100755 Binary files a/jackify/engine/System.Diagnostics.Debug.dll and b/jackify/engine/System.Diagnostics.Debug.dll differ diff --git a/jackify/engine/System.Diagnostics.FileVersionInfo.dll b/jackify/engine/System.Diagnostics.FileVersionInfo.dll old mode 100644 new mode 100755 index 53db889..e92b1d3 Binary files a/jackify/engine/System.Diagnostics.FileVersionInfo.dll and b/jackify/engine/System.Diagnostics.FileVersionInfo.dll differ diff --git a/jackify/engine/System.Diagnostics.Process.dll b/jackify/engine/System.Diagnostics.Process.dll old mode 100644 new mode 100755 index 3a85c55..ffa90f0 Binary files a/jackify/engine/System.Diagnostics.Process.dll and b/jackify/engine/System.Diagnostics.Process.dll differ diff --git a/jackify/engine/System.Diagnostics.StackTrace.dll b/jackify/engine/System.Diagnostics.StackTrace.dll old mode 100644 new mode 100755 index 77217bf..4c2a97c Binary files a/jackify/engine/System.Diagnostics.StackTrace.dll and b/jackify/engine/System.Diagnostics.StackTrace.dll differ diff --git a/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll b/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll index f865d28..2ed1a4b 100755 Binary files a/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll and b/jackify/engine/System.Diagnostics.TextWriterTraceListener.dll differ diff --git a/jackify/engine/System.Diagnostics.Tools.dll b/jackify/engine/System.Diagnostics.Tools.dll index 0b72c7b..498f5a4 100755 Binary files a/jackify/engine/System.Diagnostics.Tools.dll and b/jackify/engine/System.Diagnostics.Tools.dll differ diff --git a/jackify/engine/System.Diagnostics.TraceSource.dll b/jackify/engine/System.Diagnostics.TraceSource.dll old mode 100644 new mode 100755 index cb3fb76..a349118 Binary files a/jackify/engine/System.Diagnostics.TraceSource.dll and b/jackify/engine/System.Diagnostics.TraceSource.dll differ diff --git a/jackify/engine/System.Diagnostics.Tracing.dll b/jackify/engine/System.Diagnostics.Tracing.dll index 143ca11..a029b12 100755 Binary files a/jackify/engine/System.Diagnostics.Tracing.dll and b/jackify/engine/System.Diagnostics.Tracing.dll differ diff --git a/jackify/engine/System.Drawing.Primitives.dll b/jackify/engine/System.Drawing.Primitives.dll old mode 100644 new mode 100755 index e7e88e1..e47e8e1 Binary files a/jackify/engine/System.Drawing.Primitives.dll and b/jackify/engine/System.Drawing.Primitives.dll differ diff --git a/jackify/engine/System.Drawing.dll b/jackify/engine/System.Drawing.dll old mode 100644 new mode 100755 index c2e5e65..4e57a96 Binary files a/jackify/engine/System.Drawing.dll and b/jackify/engine/System.Drawing.dll differ diff --git a/jackify/engine/System.Dynamic.Runtime.dll b/jackify/engine/System.Dynamic.Runtime.dll index a4c1595..15dc1d2 100755 Binary files a/jackify/engine/System.Dynamic.Runtime.dll and b/jackify/engine/System.Dynamic.Runtime.dll differ diff --git a/jackify/engine/System.Formats.Asn1.dll b/jackify/engine/System.Formats.Asn1.dll old mode 100644 new mode 100755 index 35d9e40..3b64434 Binary files a/jackify/engine/System.Formats.Asn1.dll and b/jackify/engine/System.Formats.Asn1.dll differ diff --git a/jackify/engine/System.Formats.Tar.dll b/jackify/engine/System.Formats.Tar.dll index 051253b..406f6e9 100755 Binary files a/jackify/engine/System.Formats.Tar.dll and b/jackify/engine/System.Formats.Tar.dll differ diff --git a/jackify/engine/System.Globalization.Calendars.dll b/jackify/engine/System.Globalization.Calendars.dll index bdab2aa..d2f78c2 100755 Binary files a/jackify/engine/System.Globalization.Calendars.dll and b/jackify/engine/System.Globalization.Calendars.dll differ diff --git a/jackify/engine/System.Globalization.Extensions.dll b/jackify/engine/System.Globalization.Extensions.dll index fc51823..b72515b 100755 Binary files a/jackify/engine/System.Globalization.Extensions.dll and b/jackify/engine/System.Globalization.Extensions.dll differ diff --git a/jackify/engine/System.Globalization.dll b/jackify/engine/System.Globalization.dll index e6292db..58f80b2 100755 Binary files a/jackify/engine/System.Globalization.dll and b/jackify/engine/System.Globalization.dll differ diff --git a/jackify/engine/System.IO.Compression.Brotli.dll b/jackify/engine/System.IO.Compression.Brotli.dll old mode 100644 new mode 100755 index f062a88..1f261d2 Binary files a/jackify/engine/System.IO.Compression.Brotli.dll and b/jackify/engine/System.IO.Compression.Brotli.dll differ diff --git a/jackify/engine/System.IO.Compression.FileSystem.dll b/jackify/engine/System.IO.Compression.FileSystem.dll index 6de208d..91ee411 100755 Binary files a/jackify/engine/System.IO.Compression.FileSystem.dll and b/jackify/engine/System.IO.Compression.FileSystem.dll differ diff --git a/jackify/engine/System.IO.Compression.ZipFile.dll b/jackify/engine/System.IO.Compression.ZipFile.dll old mode 100644 new mode 100755 index 7a47b78..a81cf3a Binary files a/jackify/engine/System.IO.Compression.ZipFile.dll and b/jackify/engine/System.IO.Compression.ZipFile.dll differ diff --git a/jackify/engine/System.IO.Compression.dll b/jackify/engine/System.IO.Compression.dll old mode 100644 new mode 100755 index 965d05b..e5e6eb4 Binary files a/jackify/engine/System.IO.Compression.dll and b/jackify/engine/System.IO.Compression.dll differ diff --git a/jackify/engine/System.IO.FileSystem.AccessControl.dll b/jackify/engine/System.IO.FileSystem.AccessControl.dll index d4dfcf8..53c38d4 100755 Binary files a/jackify/engine/System.IO.FileSystem.AccessControl.dll and b/jackify/engine/System.IO.FileSystem.AccessControl.dll differ diff --git a/jackify/engine/System.IO.FileSystem.DriveInfo.dll b/jackify/engine/System.IO.FileSystem.DriveInfo.dll old mode 100644 new mode 100755 index 51f189e..0258a03 Binary files a/jackify/engine/System.IO.FileSystem.DriveInfo.dll and b/jackify/engine/System.IO.FileSystem.DriveInfo.dll differ diff --git a/jackify/engine/System.IO.FileSystem.Primitives.dll b/jackify/engine/System.IO.FileSystem.Primitives.dll index af57f82..9bf2dc9 100755 Binary files a/jackify/engine/System.IO.FileSystem.Primitives.dll and b/jackify/engine/System.IO.FileSystem.Primitives.dll differ diff --git a/jackify/engine/System.IO.FileSystem.Watcher.dll b/jackify/engine/System.IO.FileSystem.Watcher.dll old mode 100644 new mode 100755 index 7817832..5c3e375 Binary files a/jackify/engine/System.IO.FileSystem.Watcher.dll and b/jackify/engine/System.IO.FileSystem.Watcher.dll differ diff --git a/jackify/engine/System.IO.FileSystem.dll b/jackify/engine/System.IO.FileSystem.dll index 8117602..400b10c 100755 Binary files a/jackify/engine/System.IO.FileSystem.dll and b/jackify/engine/System.IO.FileSystem.dll differ diff --git a/jackify/engine/System.IO.IsolatedStorage.dll b/jackify/engine/System.IO.IsolatedStorage.dll index 607c448..6bcf19e 100755 Binary files a/jackify/engine/System.IO.IsolatedStorage.dll and b/jackify/engine/System.IO.IsolatedStorage.dll differ diff --git a/jackify/engine/System.IO.MemoryMappedFiles.dll b/jackify/engine/System.IO.MemoryMappedFiles.dll old mode 100644 new mode 100755 index 4273291..6b65103 Binary files a/jackify/engine/System.IO.MemoryMappedFiles.dll and b/jackify/engine/System.IO.MemoryMappedFiles.dll differ diff --git a/jackify/engine/System.IO.Pipes.AccessControl.dll b/jackify/engine/System.IO.Pipes.AccessControl.dll index f858379..1f64b25 100755 Binary files a/jackify/engine/System.IO.Pipes.AccessControl.dll and b/jackify/engine/System.IO.Pipes.AccessControl.dll differ diff --git a/jackify/engine/System.IO.Pipes.dll b/jackify/engine/System.IO.Pipes.dll old mode 100644 new mode 100755 index dbf6583..614652f Binary files a/jackify/engine/System.IO.Pipes.dll and b/jackify/engine/System.IO.Pipes.dll differ diff --git a/jackify/engine/System.IO.UnmanagedMemoryStream.dll b/jackify/engine/System.IO.UnmanagedMemoryStream.dll index d76f481..7a8d31c 100755 Binary files a/jackify/engine/System.IO.UnmanagedMemoryStream.dll and b/jackify/engine/System.IO.UnmanagedMemoryStream.dll differ diff --git a/jackify/engine/System.IO.dll b/jackify/engine/System.IO.dll index 3fdb611..24c7f11 100755 Binary files a/jackify/engine/System.IO.dll and b/jackify/engine/System.IO.dll differ diff --git a/jackify/engine/System.Linq.Expressions.dll b/jackify/engine/System.Linq.Expressions.dll old mode 100644 new mode 100755 index a41cd33..a33a8b1 Binary files a/jackify/engine/System.Linq.Expressions.dll and b/jackify/engine/System.Linq.Expressions.dll differ diff --git a/jackify/engine/System.Linq.Parallel.dll b/jackify/engine/System.Linq.Parallel.dll old mode 100644 new mode 100755 index ec227a0..f251ca4 Binary files a/jackify/engine/System.Linq.Parallel.dll and b/jackify/engine/System.Linq.Parallel.dll differ diff --git a/jackify/engine/System.Linq.Queryable.dll b/jackify/engine/System.Linq.Queryable.dll index f7b71e7..8444b39 100755 Binary files a/jackify/engine/System.Linq.Queryable.dll and b/jackify/engine/System.Linq.Queryable.dll differ diff --git a/jackify/engine/System.Linq.dll b/jackify/engine/System.Linq.dll old mode 100644 new mode 100755 index fc17c85..30802b2 Binary files a/jackify/engine/System.Linq.dll and b/jackify/engine/System.Linq.dll differ diff --git a/jackify/engine/System.Memory.dll b/jackify/engine/System.Memory.dll old mode 100644 new mode 100755 index b771144..6b34986 Binary files a/jackify/engine/System.Memory.dll and b/jackify/engine/System.Memory.dll differ diff --git a/jackify/engine/System.Net.Http.Json.dll b/jackify/engine/System.Net.Http.Json.dll old mode 100644 new mode 100755 index bf9845a..c5bff82 Binary files a/jackify/engine/System.Net.Http.Json.dll and b/jackify/engine/System.Net.Http.Json.dll differ diff --git a/jackify/engine/System.Net.Http.dll b/jackify/engine/System.Net.Http.dll old mode 100644 new mode 100755 index 57051b1..c49e29e Binary files a/jackify/engine/System.Net.Http.dll and b/jackify/engine/System.Net.Http.dll differ diff --git a/jackify/engine/System.Net.HttpListener.dll b/jackify/engine/System.Net.HttpListener.dll index d05ff20..2e67f44 100755 Binary files a/jackify/engine/System.Net.HttpListener.dll and b/jackify/engine/System.Net.HttpListener.dll differ diff --git a/jackify/engine/System.Net.Mail.dll b/jackify/engine/System.Net.Mail.dll old mode 100644 new mode 100755 index a603ef9..01d55da Binary files a/jackify/engine/System.Net.Mail.dll and b/jackify/engine/System.Net.Mail.dll differ diff --git a/jackify/engine/System.Net.NameResolution.dll b/jackify/engine/System.Net.NameResolution.dll old mode 100644 new mode 100755 index a4441e6..144c8cd Binary files a/jackify/engine/System.Net.NameResolution.dll and b/jackify/engine/System.Net.NameResolution.dll differ diff --git a/jackify/engine/System.Net.NetworkInformation.dll b/jackify/engine/System.Net.NetworkInformation.dll old mode 100644 new mode 100755 index 60c8237..a6b91a9 Binary files a/jackify/engine/System.Net.NetworkInformation.dll and b/jackify/engine/System.Net.NetworkInformation.dll differ diff --git a/jackify/engine/System.Net.Ping.dll b/jackify/engine/System.Net.Ping.dll index a6f5592..c1bdb25 100755 Binary files a/jackify/engine/System.Net.Ping.dll and b/jackify/engine/System.Net.Ping.dll differ diff --git a/jackify/engine/System.Net.Primitives.dll b/jackify/engine/System.Net.Primitives.dll old mode 100644 new mode 100755 index a149b83..659165f Binary files a/jackify/engine/System.Net.Primitives.dll and b/jackify/engine/System.Net.Primitives.dll differ diff --git a/jackify/engine/System.Net.Quic.dll b/jackify/engine/System.Net.Quic.dll old mode 100644 new mode 100755 index 385db03..f1b80a1 Binary files a/jackify/engine/System.Net.Quic.dll and b/jackify/engine/System.Net.Quic.dll differ diff --git a/jackify/engine/System.Net.Requests.dll b/jackify/engine/System.Net.Requests.dll old mode 100644 new mode 100755 index c09c472..4490b9d Binary files a/jackify/engine/System.Net.Requests.dll and b/jackify/engine/System.Net.Requests.dll differ diff --git a/jackify/engine/System.Net.Security.dll b/jackify/engine/System.Net.Security.dll old mode 100644 new mode 100755 index 6a345ca..7b09cb7 Binary files a/jackify/engine/System.Net.Security.dll and b/jackify/engine/System.Net.Security.dll differ diff --git a/jackify/engine/System.Net.ServicePoint.dll b/jackify/engine/System.Net.ServicePoint.dll old mode 100644 new mode 100755 index b385f86..c1f34d5 Binary files a/jackify/engine/System.Net.ServicePoint.dll and b/jackify/engine/System.Net.ServicePoint.dll differ diff --git a/jackify/engine/System.Net.Sockets.dll b/jackify/engine/System.Net.Sockets.dll old mode 100644 new mode 100755 index fb10e47..56c5686 Binary files a/jackify/engine/System.Net.Sockets.dll and b/jackify/engine/System.Net.Sockets.dll differ diff --git a/jackify/engine/System.Net.WebClient.dll b/jackify/engine/System.Net.WebClient.dll index 2ecf3ef..8032847 100755 Binary files a/jackify/engine/System.Net.WebClient.dll and b/jackify/engine/System.Net.WebClient.dll differ diff --git a/jackify/engine/System.Net.WebHeaderCollection.dll b/jackify/engine/System.Net.WebHeaderCollection.dll old mode 100644 new mode 100755 index 0fe13b7..217d61d Binary files a/jackify/engine/System.Net.WebHeaderCollection.dll and b/jackify/engine/System.Net.WebHeaderCollection.dll differ diff --git a/jackify/engine/System.Net.WebProxy.dll b/jackify/engine/System.Net.WebProxy.dll old mode 100644 new mode 100755 index 2c9a2e3..233d34c Binary files a/jackify/engine/System.Net.WebProxy.dll and b/jackify/engine/System.Net.WebProxy.dll differ diff --git a/jackify/engine/System.Net.WebSockets.Client.dll b/jackify/engine/System.Net.WebSockets.Client.dll index 0e789a8..b139f09 100755 Binary files a/jackify/engine/System.Net.WebSockets.Client.dll and b/jackify/engine/System.Net.WebSockets.Client.dll differ diff --git a/jackify/engine/System.Net.WebSockets.dll b/jackify/engine/System.Net.WebSockets.dll index 3cc38f6..9fc3b83 100755 Binary files a/jackify/engine/System.Net.WebSockets.dll and b/jackify/engine/System.Net.WebSockets.dll differ diff --git a/jackify/engine/System.Net.dll b/jackify/engine/System.Net.dll index 0fc2106..686d5d4 100755 Binary files a/jackify/engine/System.Net.dll and b/jackify/engine/System.Net.dll differ diff --git a/jackify/engine/System.Numerics.Vectors.dll b/jackify/engine/System.Numerics.Vectors.dll index 212325d..53856fc 100755 Binary files a/jackify/engine/System.Numerics.Vectors.dll and b/jackify/engine/System.Numerics.Vectors.dll differ diff --git a/jackify/engine/System.Numerics.dll b/jackify/engine/System.Numerics.dll index db9839c..94eb8f4 100755 Binary files a/jackify/engine/System.Numerics.dll and b/jackify/engine/System.Numerics.dll differ diff --git a/jackify/engine/System.ObjectModel.dll b/jackify/engine/System.ObjectModel.dll old mode 100644 new mode 100755 index d6f23eb..ed8e1c9 Binary files a/jackify/engine/System.ObjectModel.dll and b/jackify/engine/System.ObjectModel.dll differ diff --git a/jackify/engine/System.Private.CoreLib.dll b/jackify/engine/System.Private.CoreLib.dll old mode 100644 new mode 100755 index 2e5270a..cf1720a Binary files a/jackify/engine/System.Private.CoreLib.dll and b/jackify/engine/System.Private.CoreLib.dll differ diff --git a/jackify/engine/System.Private.DataContractSerialization.dll b/jackify/engine/System.Private.DataContractSerialization.dll index 312ae47..69d2f9c 100755 Binary files a/jackify/engine/System.Private.DataContractSerialization.dll and b/jackify/engine/System.Private.DataContractSerialization.dll differ diff --git a/jackify/engine/System.Private.Uri.dll b/jackify/engine/System.Private.Uri.dll old mode 100644 new mode 100755 index 3676227..32095f5 Binary files a/jackify/engine/System.Private.Uri.dll and b/jackify/engine/System.Private.Uri.dll differ diff --git a/jackify/engine/System.Private.Xml.Linq.dll b/jackify/engine/System.Private.Xml.Linq.dll old mode 100644 new mode 100755 index 9a07627..d4973d3 Binary files a/jackify/engine/System.Private.Xml.Linq.dll and b/jackify/engine/System.Private.Xml.Linq.dll differ diff --git a/jackify/engine/System.Private.Xml.dll b/jackify/engine/System.Private.Xml.dll old mode 100644 new mode 100755 index 4b7d1ff..695c7b5 Binary files a/jackify/engine/System.Private.Xml.dll and b/jackify/engine/System.Private.Xml.dll differ diff --git a/jackify/engine/System.Reflection.DispatchProxy.dll b/jackify/engine/System.Reflection.DispatchProxy.dll index b55b0f5..68be402 100755 Binary files a/jackify/engine/System.Reflection.DispatchProxy.dll and b/jackify/engine/System.Reflection.DispatchProxy.dll differ diff --git a/jackify/engine/System.Reflection.Emit.ILGeneration.dll b/jackify/engine/System.Reflection.Emit.ILGeneration.dll index aa77156..329c154 100755 Binary files a/jackify/engine/System.Reflection.Emit.ILGeneration.dll and b/jackify/engine/System.Reflection.Emit.ILGeneration.dll differ diff --git a/jackify/engine/System.Reflection.Emit.Lightweight.dll b/jackify/engine/System.Reflection.Emit.Lightweight.dll index c1623f8..435e0e0 100755 Binary files a/jackify/engine/System.Reflection.Emit.Lightweight.dll and b/jackify/engine/System.Reflection.Emit.Lightweight.dll differ diff --git a/jackify/engine/System.Reflection.Emit.dll b/jackify/engine/System.Reflection.Emit.dll index da51862..4b5a653 100755 Binary files a/jackify/engine/System.Reflection.Emit.dll and b/jackify/engine/System.Reflection.Emit.dll differ diff --git a/jackify/engine/System.Reflection.Extensions.dll b/jackify/engine/System.Reflection.Extensions.dll index 6e62dc1..5feee33 100755 Binary files a/jackify/engine/System.Reflection.Extensions.dll and b/jackify/engine/System.Reflection.Extensions.dll differ diff --git a/jackify/engine/System.Reflection.Metadata.dll b/jackify/engine/System.Reflection.Metadata.dll old mode 100644 new mode 100755 index 2689851..bd8e2f9 Binary files a/jackify/engine/System.Reflection.Metadata.dll and b/jackify/engine/System.Reflection.Metadata.dll differ diff --git a/jackify/engine/System.Reflection.Primitives.dll b/jackify/engine/System.Reflection.Primitives.dll index 3bbada8..9c612b0 100755 Binary files a/jackify/engine/System.Reflection.Primitives.dll and b/jackify/engine/System.Reflection.Primitives.dll differ diff --git a/jackify/engine/System.Reflection.TypeExtensions.dll b/jackify/engine/System.Reflection.TypeExtensions.dll index 6efce2f..fe3bf5f 100755 Binary files a/jackify/engine/System.Reflection.TypeExtensions.dll and b/jackify/engine/System.Reflection.TypeExtensions.dll differ diff --git a/jackify/engine/System.Reflection.dll b/jackify/engine/System.Reflection.dll index 3bfc7d8..56c067b 100755 Binary files a/jackify/engine/System.Reflection.dll and b/jackify/engine/System.Reflection.dll differ diff --git a/jackify/engine/System.Resources.Reader.dll b/jackify/engine/System.Resources.Reader.dll index 4590d46..5e8a8a2 100755 Binary files a/jackify/engine/System.Resources.Reader.dll and b/jackify/engine/System.Resources.Reader.dll differ diff --git a/jackify/engine/System.Resources.ResourceManager.dll b/jackify/engine/System.Resources.ResourceManager.dll index 7d315be..1e8dcfe 100755 Binary files a/jackify/engine/System.Resources.ResourceManager.dll and b/jackify/engine/System.Resources.ResourceManager.dll differ diff --git a/jackify/engine/System.Resources.Writer.dll b/jackify/engine/System.Resources.Writer.dll index 1601422..a1402a7 100755 Binary files a/jackify/engine/System.Resources.Writer.dll and b/jackify/engine/System.Resources.Writer.dll differ diff --git a/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll b/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll index 8fa1b03..aa52d3e 100755 Binary files a/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll and b/jackify/engine/System.Runtime.CompilerServices.Unsafe.dll differ diff --git a/jackify/engine/System.Runtime.CompilerServices.VisualC.dll b/jackify/engine/System.Runtime.CompilerServices.VisualC.dll index ad6c740..ada4ebb 100755 Binary files a/jackify/engine/System.Runtime.CompilerServices.VisualC.dll and b/jackify/engine/System.Runtime.CompilerServices.VisualC.dll differ diff --git a/jackify/engine/System.Runtime.Extensions.dll b/jackify/engine/System.Runtime.Extensions.dll index aa27e9b..6c1a945 100755 Binary files a/jackify/engine/System.Runtime.Extensions.dll and b/jackify/engine/System.Runtime.Extensions.dll differ diff --git a/jackify/engine/System.Runtime.Handles.dll b/jackify/engine/System.Runtime.Handles.dll index 166ca6b..951a8f7 100755 Binary files a/jackify/engine/System.Runtime.Handles.dll and b/jackify/engine/System.Runtime.Handles.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.JavaScript.dll b/jackify/engine/System.Runtime.InteropServices.JavaScript.dll index 6830d38..6c97a23 100755 Binary files a/jackify/engine/System.Runtime.InteropServices.JavaScript.dll and b/jackify/engine/System.Runtime.InteropServices.JavaScript.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll b/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll index e8a3c69..e59f071 100755 Binary files a/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll and b/jackify/engine/System.Runtime.InteropServices.RuntimeInformation.dll differ diff --git a/jackify/engine/System.Runtime.InteropServices.dll b/jackify/engine/System.Runtime.InteropServices.dll index c044074..2f485a2 100755 Binary files a/jackify/engine/System.Runtime.InteropServices.dll and b/jackify/engine/System.Runtime.InteropServices.dll differ diff --git a/jackify/engine/System.Runtime.Intrinsics.dll b/jackify/engine/System.Runtime.Intrinsics.dll index 9150906..1a97d92 100755 Binary files a/jackify/engine/System.Runtime.Intrinsics.dll and b/jackify/engine/System.Runtime.Intrinsics.dll differ diff --git a/jackify/engine/System.Runtime.Loader.dll b/jackify/engine/System.Runtime.Loader.dll index d95abc1..53dc681 100755 Binary files a/jackify/engine/System.Runtime.Loader.dll and b/jackify/engine/System.Runtime.Loader.dll differ diff --git a/jackify/engine/System.Runtime.Numerics.dll b/jackify/engine/System.Runtime.Numerics.dll old mode 100644 new mode 100755 index 7770937..a4acf5a Binary files a/jackify/engine/System.Runtime.Numerics.dll and b/jackify/engine/System.Runtime.Numerics.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Formatters.dll b/jackify/engine/System.Runtime.Serialization.Formatters.dll old mode 100644 new mode 100755 index 3812d7c..2a51d29 Binary files a/jackify/engine/System.Runtime.Serialization.Formatters.dll and b/jackify/engine/System.Runtime.Serialization.Formatters.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Json.dll b/jackify/engine/System.Runtime.Serialization.Json.dll index cf9b28f..72b18fd 100755 Binary files a/jackify/engine/System.Runtime.Serialization.Json.dll and b/jackify/engine/System.Runtime.Serialization.Json.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Primitives.dll b/jackify/engine/System.Runtime.Serialization.Primitives.dll old mode 100644 new mode 100755 index 3195524..4335fbb Binary files a/jackify/engine/System.Runtime.Serialization.Primitives.dll and b/jackify/engine/System.Runtime.Serialization.Primitives.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.Xml.dll b/jackify/engine/System.Runtime.Serialization.Xml.dll index 00be54c..c381552 100755 Binary files a/jackify/engine/System.Runtime.Serialization.Xml.dll and b/jackify/engine/System.Runtime.Serialization.Xml.dll differ diff --git a/jackify/engine/System.Runtime.Serialization.dll b/jackify/engine/System.Runtime.Serialization.dll index bfa3cc2..16425ed 100755 Binary files a/jackify/engine/System.Runtime.Serialization.dll and b/jackify/engine/System.Runtime.Serialization.dll differ diff --git a/jackify/engine/System.Runtime.dll b/jackify/engine/System.Runtime.dll old mode 100644 new mode 100755 index 4759b4e..bedb2b5 Binary files a/jackify/engine/System.Runtime.dll and b/jackify/engine/System.Runtime.dll differ diff --git a/jackify/engine/System.Security.AccessControl.dll b/jackify/engine/System.Security.AccessControl.dll index 323d184..10887dc 100755 Binary files a/jackify/engine/System.Security.AccessControl.dll and b/jackify/engine/System.Security.AccessControl.dll differ diff --git a/jackify/engine/System.Security.Claims.dll b/jackify/engine/System.Security.Claims.dll old mode 100644 new mode 100755 index 8bd6275..0e3521c Binary files a/jackify/engine/System.Security.Claims.dll and b/jackify/engine/System.Security.Claims.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Algorithms.dll b/jackify/engine/System.Security.Cryptography.Algorithms.dll index ba1a829..6c60370 100755 Binary files a/jackify/engine/System.Security.Cryptography.Algorithms.dll and b/jackify/engine/System.Security.Cryptography.Algorithms.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Cng.dll b/jackify/engine/System.Security.Cryptography.Cng.dll index fe3039b..7fbf1b8 100755 Binary files a/jackify/engine/System.Security.Cryptography.Cng.dll and b/jackify/engine/System.Security.Cryptography.Cng.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Csp.dll b/jackify/engine/System.Security.Cryptography.Csp.dll index aa06329..5bb2b4f 100755 Binary files a/jackify/engine/System.Security.Cryptography.Csp.dll and b/jackify/engine/System.Security.Cryptography.Csp.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Encoding.dll b/jackify/engine/System.Security.Cryptography.Encoding.dll index b0ed8a6..159dd2d 100755 Binary files a/jackify/engine/System.Security.Cryptography.Encoding.dll and b/jackify/engine/System.Security.Cryptography.Encoding.dll differ diff --git a/jackify/engine/System.Security.Cryptography.OpenSsl.dll b/jackify/engine/System.Security.Cryptography.OpenSsl.dll index 38bb632..1d3121f 100755 Binary files a/jackify/engine/System.Security.Cryptography.OpenSsl.dll and b/jackify/engine/System.Security.Cryptography.OpenSsl.dll differ diff --git a/jackify/engine/System.Security.Cryptography.Primitives.dll b/jackify/engine/System.Security.Cryptography.Primitives.dll index 992c5a5..bda275b 100755 Binary files a/jackify/engine/System.Security.Cryptography.Primitives.dll and b/jackify/engine/System.Security.Cryptography.Primitives.dll differ diff --git a/jackify/engine/System.Security.Cryptography.X509Certificates.dll b/jackify/engine/System.Security.Cryptography.X509Certificates.dll index 4c2921e..ff858c8 100755 Binary files a/jackify/engine/System.Security.Cryptography.X509Certificates.dll and b/jackify/engine/System.Security.Cryptography.X509Certificates.dll differ diff --git a/jackify/engine/System.Security.Cryptography.dll b/jackify/engine/System.Security.Cryptography.dll old mode 100644 new mode 100755 index b07a3fc..7b23d0e Binary files a/jackify/engine/System.Security.Cryptography.dll and b/jackify/engine/System.Security.Cryptography.dll differ diff --git a/jackify/engine/System.Security.Principal.Windows.dll b/jackify/engine/System.Security.Principal.Windows.dll old mode 100644 new mode 100755 index 2ce552c..c3d71e4 Binary files a/jackify/engine/System.Security.Principal.Windows.dll and b/jackify/engine/System.Security.Principal.Windows.dll differ diff --git a/jackify/engine/System.Security.Principal.dll b/jackify/engine/System.Security.Principal.dll index ae34b1b..c6ebdc2 100755 Binary files a/jackify/engine/System.Security.Principal.dll and b/jackify/engine/System.Security.Principal.dll differ diff --git a/jackify/engine/System.Security.SecureString.dll b/jackify/engine/System.Security.SecureString.dll index 610ec22..45fc8d7 100755 Binary files a/jackify/engine/System.Security.SecureString.dll and b/jackify/engine/System.Security.SecureString.dll differ diff --git a/jackify/engine/System.Security.dll b/jackify/engine/System.Security.dll index 0b2be17..dad8d44 100755 Binary files a/jackify/engine/System.Security.dll and b/jackify/engine/System.Security.dll differ diff --git a/jackify/engine/System.ServiceModel.Web.dll b/jackify/engine/System.ServiceModel.Web.dll index bd74d5d..7920910 100755 Binary files a/jackify/engine/System.ServiceModel.Web.dll and b/jackify/engine/System.ServiceModel.Web.dll differ diff --git a/jackify/engine/System.ServiceProcess.dll b/jackify/engine/System.ServiceProcess.dll index a48f9a9..9834f92 100755 Binary files a/jackify/engine/System.ServiceProcess.dll and b/jackify/engine/System.ServiceProcess.dll differ diff --git a/jackify/engine/System.Text.Encoding.CodePages.dll b/jackify/engine/System.Text.Encoding.CodePages.dll old mode 100644 new mode 100755 index a5ed4e3..71c12b6 Binary files a/jackify/engine/System.Text.Encoding.CodePages.dll and b/jackify/engine/System.Text.Encoding.CodePages.dll differ diff --git a/jackify/engine/System.Text.Encoding.Extensions.dll b/jackify/engine/System.Text.Encoding.Extensions.dll index 4773b0f..b0a3d53 100755 Binary files a/jackify/engine/System.Text.Encoding.Extensions.dll and b/jackify/engine/System.Text.Encoding.Extensions.dll differ diff --git a/jackify/engine/System.Text.Encoding.dll b/jackify/engine/System.Text.Encoding.dll index e2dfb8b..86e7bf2 100755 Binary files a/jackify/engine/System.Text.Encoding.dll and b/jackify/engine/System.Text.Encoding.dll differ diff --git a/jackify/engine/System.Text.RegularExpressions.dll b/jackify/engine/System.Text.RegularExpressions.dll old mode 100644 new mode 100755 index 1ab0715..9853ab5 Binary files a/jackify/engine/System.Text.RegularExpressions.dll and b/jackify/engine/System.Text.RegularExpressions.dll differ diff --git a/jackify/engine/System.Threading.Channels.dll b/jackify/engine/System.Threading.Channels.dll old mode 100644 new mode 100755 index 6d5b284..a99c9a2 Binary files a/jackify/engine/System.Threading.Channels.dll and b/jackify/engine/System.Threading.Channels.dll differ diff --git a/jackify/engine/System.Threading.Overlapped.dll b/jackify/engine/System.Threading.Overlapped.dll index 7c56bd3..fc8dd14 100755 Binary files a/jackify/engine/System.Threading.Overlapped.dll and b/jackify/engine/System.Threading.Overlapped.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Dataflow.dll b/jackify/engine/System.Threading.Tasks.Dataflow.dll index 81e5504..98ab57e 100755 Binary files a/jackify/engine/System.Threading.Tasks.Dataflow.dll and b/jackify/engine/System.Threading.Tasks.Dataflow.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Extensions.dll b/jackify/engine/System.Threading.Tasks.Extensions.dll index 26a9b3c..6827eda 100755 Binary files a/jackify/engine/System.Threading.Tasks.Extensions.dll and b/jackify/engine/System.Threading.Tasks.Extensions.dll differ diff --git a/jackify/engine/System.Threading.Tasks.Parallel.dll b/jackify/engine/System.Threading.Tasks.Parallel.dll old mode 100644 new mode 100755 index 2131444..6b22d51 Binary files a/jackify/engine/System.Threading.Tasks.Parallel.dll and b/jackify/engine/System.Threading.Tasks.Parallel.dll differ diff --git a/jackify/engine/System.Threading.Tasks.dll b/jackify/engine/System.Threading.Tasks.dll index 2cf8204..d8349dc 100755 Binary files a/jackify/engine/System.Threading.Tasks.dll and b/jackify/engine/System.Threading.Tasks.dll differ diff --git a/jackify/engine/System.Threading.Thread.dll b/jackify/engine/System.Threading.Thread.dll index 4db6b29..fcca564 100755 Binary files a/jackify/engine/System.Threading.Thread.dll and b/jackify/engine/System.Threading.Thread.dll differ diff --git a/jackify/engine/System.Threading.ThreadPool.dll b/jackify/engine/System.Threading.ThreadPool.dll index 20e2fa1..9a9fcf5 100755 Binary files a/jackify/engine/System.Threading.ThreadPool.dll and b/jackify/engine/System.Threading.ThreadPool.dll differ diff --git a/jackify/engine/System.Threading.Timer.dll b/jackify/engine/System.Threading.Timer.dll index 257db6c..d430676 100755 Binary files a/jackify/engine/System.Threading.Timer.dll and b/jackify/engine/System.Threading.Timer.dll differ diff --git a/jackify/engine/System.Threading.dll b/jackify/engine/System.Threading.dll old mode 100644 new mode 100755 index d425b98..9190e58 Binary files a/jackify/engine/System.Threading.dll and b/jackify/engine/System.Threading.dll differ diff --git a/jackify/engine/System.Transactions.Local.dll b/jackify/engine/System.Transactions.Local.dll old mode 100644 new mode 100755 index 2a8ad7c..e1ef9cd Binary files a/jackify/engine/System.Transactions.Local.dll and b/jackify/engine/System.Transactions.Local.dll differ diff --git a/jackify/engine/System.Transactions.dll b/jackify/engine/System.Transactions.dll index af18248..086a382 100755 Binary files a/jackify/engine/System.Transactions.dll and b/jackify/engine/System.Transactions.dll differ diff --git a/jackify/engine/System.ValueTuple.dll b/jackify/engine/System.ValueTuple.dll index ae19f6f..9ddd20f 100755 Binary files a/jackify/engine/System.ValueTuple.dll and b/jackify/engine/System.ValueTuple.dll differ diff --git a/jackify/engine/System.Web.HttpUtility.dll b/jackify/engine/System.Web.HttpUtility.dll old mode 100644 new mode 100755 index c798cad..aec089f Binary files a/jackify/engine/System.Web.HttpUtility.dll and b/jackify/engine/System.Web.HttpUtility.dll differ diff --git a/jackify/engine/System.Web.dll b/jackify/engine/System.Web.dll index 576ffb9..af091cf 100755 Binary files a/jackify/engine/System.Web.dll and b/jackify/engine/System.Web.dll differ diff --git a/jackify/engine/System.Windows.dll b/jackify/engine/System.Windows.dll index c2ddce8..22350d4 100755 Binary files a/jackify/engine/System.Windows.dll and b/jackify/engine/System.Windows.dll differ diff --git a/jackify/engine/System.Xml.Linq.dll b/jackify/engine/System.Xml.Linq.dll old mode 100644 new mode 100755 index c1ffd10..fd9ef46 Binary files a/jackify/engine/System.Xml.Linq.dll and b/jackify/engine/System.Xml.Linq.dll differ diff --git a/jackify/engine/System.Xml.ReaderWriter.dll b/jackify/engine/System.Xml.ReaderWriter.dll index 614b6b7..6d4d735 100755 Binary files a/jackify/engine/System.Xml.ReaderWriter.dll and b/jackify/engine/System.Xml.ReaderWriter.dll differ diff --git a/jackify/engine/System.Xml.Serialization.dll b/jackify/engine/System.Xml.Serialization.dll index cf1cddc..c60cb9c 100755 Binary files a/jackify/engine/System.Xml.Serialization.dll and b/jackify/engine/System.Xml.Serialization.dll differ diff --git a/jackify/engine/System.Xml.XDocument.dll b/jackify/engine/System.Xml.XDocument.dll index c65061c..7c0ccf3 100755 Binary files a/jackify/engine/System.Xml.XDocument.dll and b/jackify/engine/System.Xml.XDocument.dll differ diff --git a/jackify/engine/System.Xml.XPath.XDocument.dll b/jackify/engine/System.Xml.XPath.XDocument.dll index b0737c5..18360f0 100755 Binary files a/jackify/engine/System.Xml.XPath.XDocument.dll and b/jackify/engine/System.Xml.XPath.XDocument.dll differ diff --git a/jackify/engine/System.Xml.XPath.dll b/jackify/engine/System.Xml.XPath.dll index 19f1b5c..9ed47c8 100755 Binary files a/jackify/engine/System.Xml.XPath.dll and b/jackify/engine/System.Xml.XPath.dll differ diff --git a/jackify/engine/System.Xml.XmlDocument.dll b/jackify/engine/System.Xml.XmlDocument.dll index 414a849..6b9dcba 100755 Binary files a/jackify/engine/System.Xml.XmlDocument.dll and b/jackify/engine/System.Xml.XmlDocument.dll differ diff --git a/jackify/engine/System.Xml.XmlSerializer.dll b/jackify/engine/System.Xml.XmlSerializer.dll index 5d751ea..c485ea1 100755 Binary files a/jackify/engine/System.Xml.XmlSerializer.dll and b/jackify/engine/System.Xml.XmlSerializer.dll differ diff --git a/jackify/engine/System.Xml.dll b/jackify/engine/System.Xml.dll index fd21f3c..2c22ee6 100755 Binary files a/jackify/engine/System.Xml.dll and b/jackify/engine/System.Xml.dll differ diff --git a/jackify/engine/System.dll b/jackify/engine/System.dll old mode 100644 new mode 100755 index b82f502..d914881 Binary files a/jackify/engine/System.dll and b/jackify/engine/System.dll differ diff --git a/jackify/engine/Wabbajack.CLI.Builder.dll b/jackify/engine/Wabbajack.CLI.Builder.dll index d7435f3..051a52b 100644 Binary files a/jackify/engine/Wabbajack.CLI.Builder.dll and b/jackify/engine/Wabbajack.CLI.Builder.dll differ diff --git a/jackify/engine/Wabbajack.Common.dll b/jackify/engine/Wabbajack.Common.dll index c2f4464..a49259e 100644 Binary files a/jackify/engine/Wabbajack.Common.dll and b/jackify/engine/Wabbajack.Common.dll differ diff --git a/jackify/engine/Wabbajack.Compiler.dll b/jackify/engine/Wabbajack.Compiler.dll index f9dbfed..d47fed5 100644 Binary files a/jackify/engine/Wabbajack.Compiler.dll and b/jackify/engine/Wabbajack.Compiler.dll differ diff --git a/jackify/engine/Wabbajack.Compression.BSA.dll b/jackify/engine/Wabbajack.Compression.BSA.dll index 112f499..25ff1c5 100644 Binary files a/jackify/engine/Wabbajack.Compression.BSA.dll and b/jackify/engine/Wabbajack.Compression.BSA.dll differ diff --git a/jackify/engine/Wabbajack.Compression.Zip.dll b/jackify/engine/Wabbajack.Compression.Zip.dll index 859dbb4..601184a 100644 Binary files a/jackify/engine/Wabbajack.Compression.Zip.dll and b/jackify/engine/Wabbajack.Compression.Zip.dll differ diff --git a/jackify/engine/Wabbajack.Configuration.dll b/jackify/engine/Wabbajack.Configuration.dll index afcea24..31327ad 100644 Binary files a/jackify/engine/Wabbajack.Configuration.dll and b/jackify/engine/Wabbajack.Configuration.dll differ diff --git a/jackify/engine/Wabbajack.DTOs.dll b/jackify/engine/Wabbajack.DTOs.dll index 5d4ae75..7a4864b 100644 Binary files a/jackify/engine/Wabbajack.DTOs.dll and b/jackify/engine/Wabbajack.DTOs.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll index dce1061..fb8732b 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Bethesda.dll and b/jackify/engine/Wabbajack.Downloaders.Bethesda.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll index f65c6eb..ebd85f2 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll and b/jackify/engine/Wabbajack.Downloaders.Dispatcher.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.GameFile.dll b/jackify/engine/Wabbajack.Downloaders.GameFile.dll index 4b3c879..762f600 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.GameFile.dll and b/jackify/engine/Wabbajack.Downloaders.GameFile.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll index 7ea486b..2fabfbf 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll and b/jackify/engine/Wabbajack.Downloaders.GoogleDrive.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Http.dll b/jackify/engine/Wabbajack.Downloaders.Http.dll index 2906f3b..f13612a 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Http.dll and b/jackify/engine/Wabbajack.Downloaders.Http.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll index f044496..0b78e4a 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll and b/jackify/engine/Wabbajack.Downloaders.IPS4OAuth2Downloader.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll index e8385e9..1fd0d74 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Interfaces.dll and b/jackify/engine/Wabbajack.Downloaders.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Manual.dll b/jackify/engine/Wabbajack.Downloaders.Manual.dll index 372337b..9858b78 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Manual.dll and b/jackify/engine/Wabbajack.Downloaders.Manual.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll index f3a8263..2a14e88 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.MediaFire.dll and b/jackify/engine/Wabbajack.Downloaders.MediaFire.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Mega.dll b/jackify/engine/Wabbajack.Downloaders.Mega.dll index afb890a..2eea4c0 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Mega.dll and b/jackify/engine/Wabbajack.Downloaders.Mega.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.ModDB.dll b/jackify/engine/Wabbajack.Downloaders.ModDB.dll index 3f8f66a..2110715 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.ModDB.dll and b/jackify/engine/Wabbajack.Downloaders.ModDB.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.Nexus.dll b/jackify/engine/Wabbajack.Downloaders.Nexus.dll index bad258c..949fde1 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.Nexus.dll and b/jackify/engine/Wabbajack.Downloaders.Nexus.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll index 6ee199b..f23b873 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll and b/jackify/engine/Wabbajack.Downloaders.VerificationCache.dll differ diff --git a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll index e19beeb..50ad81f 100644 Binary files a/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll and b/jackify/engine/Wabbajack.Downloaders.WabbajackCDN.dll differ diff --git a/jackify/engine/Wabbajack.FileExtractor.dll b/jackify/engine/Wabbajack.FileExtractor.dll index fbe3fc6..5b10df3 100644 Binary files a/jackify/engine/Wabbajack.FileExtractor.dll and b/jackify/engine/Wabbajack.FileExtractor.dll differ diff --git a/jackify/engine/Wabbajack.Hashing.PHash.dll b/jackify/engine/Wabbajack.Hashing.PHash.dll index 4ebff1a..fc663e9 100644 Binary files a/jackify/engine/Wabbajack.Hashing.PHash.dll and b/jackify/engine/Wabbajack.Hashing.PHash.dll differ diff --git a/jackify/engine/Wabbajack.Hashing.xxHash64.dll b/jackify/engine/Wabbajack.Hashing.xxHash64.dll index e8c965d..7df6d1f 100644 Binary files a/jackify/engine/Wabbajack.Hashing.xxHash64.dll and b/jackify/engine/Wabbajack.Hashing.xxHash64.dll differ diff --git a/jackify/engine/Wabbajack.IO.Async.dll b/jackify/engine/Wabbajack.IO.Async.dll index 19cb8ee..83df54c 100644 Binary files a/jackify/engine/Wabbajack.IO.Async.dll and b/jackify/engine/Wabbajack.IO.Async.dll differ diff --git a/jackify/engine/Wabbajack.Installer.dll b/jackify/engine/Wabbajack.Installer.dll index 8b15a9d..0abac60 100644 Binary files a/jackify/engine/Wabbajack.Installer.dll and b/jackify/engine/Wabbajack.Installer.dll differ diff --git a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll index bc25baa..5fab10b 100644 Binary files a/jackify/engine/Wabbajack.Networking.BethesdaNet.dll and b/jackify/engine/Wabbajack.Networking.BethesdaNet.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Discord.dll b/jackify/engine/Wabbajack.Networking.Discord.dll index 4087477..ba04336 100644 Binary files a/jackify/engine/Wabbajack.Networking.Discord.dll and b/jackify/engine/Wabbajack.Networking.Discord.dll differ diff --git a/jackify/engine/Wabbajack.Networking.GitHub.dll b/jackify/engine/Wabbajack.Networking.GitHub.dll index 4f4390f..d702dd0 100644 Binary files a/jackify/engine/Wabbajack.Networking.GitHub.dll and b/jackify/engine/Wabbajack.Networking.GitHub.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll index 400a085..cb5574b 100644 Binary files a/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll and b/jackify/engine/Wabbajack.Networking.Http.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.Networking.Http.dll b/jackify/engine/Wabbajack.Networking.Http.dll index d6af05b..f3d2050 100644 Binary files a/jackify/engine/Wabbajack.Networking.Http.dll and b/jackify/engine/Wabbajack.Networking.Http.dll differ diff --git a/jackify/engine/Wabbajack.Networking.NexusApi.dll b/jackify/engine/Wabbajack.Networking.NexusApi.dll index bffb25f..5b5de37 100644 Binary files a/jackify/engine/Wabbajack.Networking.NexusApi.dll and b/jackify/engine/Wabbajack.Networking.NexusApi.dll differ diff --git a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll index 8430b9a..512f33a 100644 Binary files a/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll and b/jackify/engine/Wabbajack.Networking.WabbajackClientApi.dll differ diff --git a/jackify/engine/Wabbajack.Paths.IO.dll b/jackify/engine/Wabbajack.Paths.IO.dll index 81e5017..a5fe781 100644 Binary files a/jackify/engine/Wabbajack.Paths.IO.dll and b/jackify/engine/Wabbajack.Paths.IO.dll differ diff --git a/jackify/engine/Wabbajack.Paths.dll b/jackify/engine/Wabbajack.Paths.dll index 3cfbb3c..b55c435 100644 Binary files a/jackify/engine/Wabbajack.Paths.dll and b/jackify/engine/Wabbajack.Paths.dll differ diff --git a/jackify/engine/Wabbajack.RateLimiter.dll b/jackify/engine/Wabbajack.RateLimiter.dll index dd7089d..eb8773e 100644 Binary files a/jackify/engine/Wabbajack.RateLimiter.dll and b/jackify/engine/Wabbajack.RateLimiter.dll differ diff --git a/jackify/engine/Wabbajack.Server.Lib.dll b/jackify/engine/Wabbajack.Server.Lib.dll index e3de313..2d191fe 100644 Binary files a/jackify/engine/Wabbajack.Server.Lib.dll and b/jackify/engine/Wabbajack.Server.Lib.dll differ diff --git a/jackify/engine/Wabbajack.Services.OSIntegrated.dll b/jackify/engine/Wabbajack.Services.OSIntegrated.dll index bac8bd8..cf23f74 100644 Binary files a/jackify/engine/Wabbajack.Services.OSIntegrated.dll and b/jackify/engine/Wabbajack.Services.OSIntegrated.dll differ diff --git a/jackify/engine/Wabbajack.VFS.Interfaces.dll b/jackify/engine/Wabbajack.VFS.Interfaces.dll index b8dc353..479defa 100644 Binary files a/jackify/engine/Wabbajack.VFS.Interfaces.dll and b/jackify/engine/Wabbajack.VFS.Interfaces.dll differ diff --git a/jackify/engine/Wabbajack.VFS.dll b/jackify/engine/Wabbajack.VFS.dll index 7143f05..8e943a7 100644 Binary files a/jackify/engine/Wabbajack.VFS.dll and b/jackify/engine/Wabbajack.VFS.dll differ diff --git a/jackify/engine/WindowsBase.dll b/jackify/engine/WindowsBase.dll index d5bcaf6..dd8099c 100755 Binary files a/jackify/engine/WindowsBase.dll and b/jackify/engine/WindowsBase.dll differ diff --git a/jackify/engine/createdump b/jackify/engine/createdump index 532ec4b..6246200 100755 Binary files a/jackify/engine/createdump and b/jackify/engine/createdump differ diff --git a/jackify/engine/jackify-engine b/jackify/engine/jackify-engine index 6c345bd..0cd7777 100755 Binary files a/jackify/engine/jackify-engine and b/jackify/engine/jackify-engine differ diff --git a/jackify/engine/jackify-engine.deps.json b/jackify/engine/jackify-engine.deps.json index ea0f177..18dde1e 100644 --- a/jackify/engine/jackify-engine.deps.json +++ b/jackify/engine/jackify-engine.deps.json @@ -7,7 +7,7 @@ "targets": { ".NETCoreApp,Version=v8.0": {}, ".NETCoreApp,Version=v8.0/linux-x64": { - "jackify-engine/0.3.18": { + "jackify-engine/0.4.0": { "dependencies": { "Markdig": "0.40.0", "Microsoft.Extensions.Configuration.Json": "9.0.1", @@ -22,684 +22,684 @@ "SixLabors.ImageSharp": "3.1.6", "System.CommandLine": "2.0.0-beta4.22272.1", "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", - "Wabbajack.CLI.Builder": "0.3.18", - "Wabbajack.Downloaders.Bethesda": "0.3.18", - "Wabbajack.Downloaders.Dispatcher": "0.3.18", - "Wabbajack.Hashing.xxHash64": "0.3.18", - "Wabbajack.Networking.Discord": "0.3.18", - "Wabbajack.Networking.GitHub": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18", - "Wabbajack.Server.Lib": "0.3.18", - "Wabbajack.Services.OSIntegrated": "0.3.18", - "Wabbajack.VFS": "0.3.18", + "Wabbajack.CLI.Builder": "0.4.0", + "Wabbajack.Downloaders.Bethesda": "0.4.0", + "Wabbajack.Downloaders.Dispatcher": "0.4.0", + "Wabbajack.Hashing.xxHash64": "0.4.0", + "Wabbajack.Networking.Discord": "0.4.0", + "Wabbajack.Networking.GitHub": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0", + "Wabbajack.Server.Lib": "0.4.0", + "Wabbajack.Services.OSIntegrated": "0.4.0", + "Wabbajack.VFS": "0.4.0", "MegaApiClient": "1.0.0.0", - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.19" + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.21" }, "runtime": { "jackify-engine.dll": {} } }, - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.19": { + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.21": { "runtime": { "Microsoft.CSharp.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "Microsoft.VisualBasic.Core.dll": { "assemblyVersion": "13.0.0.0", - "fileVersion": "13.0.1925.36514" + "fileVersion": "13.0.2125.47513" }, "Microsoft.VisualBasic.dll": { "assemblyVersion": "10.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "Microsoft.Win32.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "Microsoft.Win32.Registry.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.AppContext.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Buffers.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Collections.Concurrent.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Collections.Immutable.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Collections.NonGeneric.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Collections.Specialized.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Collections.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ComponentModel.Annotations.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ComponentModel.DataAnnotations.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ComponentModel.EventBasedAsync.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ComponentModel.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ComponentModel.TypeConverter.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ComponentModel.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Configuration.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Console.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Core.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Data.Common.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Data.DataSetExtensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Data.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.Contracts.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.Debug.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.FileVersionInfo.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.Process.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.StackTrace.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.TextWriterTraceListener.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.Tools.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.TraceSource.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Diagnostics.Tracing.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Drawing.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Drawing.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Dynamic.Runtime.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Formats.Asn1.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Formats.Tar.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Globalization.Calendars.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Globalization.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Globalization.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.Compression.Brotli.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.Compression.FileSystem.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.Compression.ZipFile.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.Compression.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.FileSystem.AccessControl.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.FileSystem.DriveInfo.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.FileSystem.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.FileSystem.Watcher.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.FileSystem.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.IsolatedStorage.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.MemoryMappedFiles.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.Pipes.AccessControl.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.Pipes.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.UnmanagedMemoryStream.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.IO.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Linq.Expressions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Linq.Parallel.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Linq.Queryable.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Linq.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Memory.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Http.Json.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Http.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.HttpListener.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Mail.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.NameResolution.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.NetworkInformation.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Ping.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Quic.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Requests.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Security.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.ServicePoint.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.Sockets.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.WebClient.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.WebHeaderCollection.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.WebProxy.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.WebSockets.Client.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.WebSockets.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Net.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Numerics.Vectors.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Numerics.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ObjectModel.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Private.CoreLib.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Private.DataContractSerialization.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Private.Uri.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Private.Xml.Linq.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Private.Xml.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.DispatchProxy.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.Emit.ILGeneration.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.Emit.Lightweight.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.Emit.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.Metadata.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.TypeExtensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Reflection.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Resources.Reader.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Resources.ResourceManager.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Resources.Writer.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.CompilerServices.Unsafe.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.CompilerServices.VisualC.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Handles.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.InteropServices.JavaScript.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.InteropServices.RuntimeInformation.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.InteropServices.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Intrinsics.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Loader.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Numerics.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Serialization.Formatters.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Serialization.Json.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Serialization.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Serialization.Xml.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.Serialization.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Runtime.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.AccessControl.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Claims.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Cryptography.Algorithms.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Cryptography.Cng.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Cryptography.Csp.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Cryptography.Encoding.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Cryptography.OpenSsl.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Cryptography.Primitives.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Cryptography.X509Certificates.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Cryptography.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Principal.Windows.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.Principal.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.SecureString.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Security.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ServiceModel.Web.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ServiceProcess.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Text.Encoding.CodePages.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Text.Encoding.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Text.Encoding.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Text.RegularExpressions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.Channels.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.Overlapped.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.Tasks.Dataflow.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.Tasks.Extensions.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.Tasks.Parallel.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.Tasks.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.Thread.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.ThreadPool.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.Timer.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Threading.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Transactions.Local.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Transactions.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.ValueTuple.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Web.HttpUtility.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Web.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Windows.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.Linq.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.ReaderWriter.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.Serialization.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.XDocument.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.XPath.XDocument.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.XPath.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.XmlDocument.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.XmlSerializer.dll": { "assemblyVersion": "8.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.Xml.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "System.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "WindowsBase.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "mscorlib.dll": { "assemblyVersion": "4.0.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" }, "netstandard.dll": { "assemblyVersion": "2.1.0.0", - "fileVersion": "8.0.1925.36514" + "fileVersion": "8.0.2125.47513" } }, "native": { @@ -1781,7 +1781,7 @@ } } }, - "Wabbajack.CLI.Builder/0.3.18": { + "Wabbajack.CLI.Builder/0.4.0": { "dependencies": { "Microsoft.Extensions.Configuration.Json": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1", @@ -1791,109 +1791,109 @@ "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "System.CommandLine": "2.0.0-beta4.22272.1", "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", - "Wabbajack.Paths": "0.3.18" + "Wabbajack.Paths": "0.4.0" }, "runtime": { "Wabbajack.CLI.Builder.dll": {} } }, - "Wabbajack.Common/0.3.18": { + "Wabbajack.Common/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "System.Reactive": "6.0.1", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Networking.Http": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18" + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Networking.Http": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0" }, "runtime": { "Wabbajack.Common.dll": {} } }, - "Wabbajack.Compiler/0.3.18": { + "Wabbajack.Compiler/0.4.0": { "dependencies": { "F23.StringSimilarity": "6.0.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Dispatcher": "0.3.18", - "Wabbajack.Installer": "0.3.18", - "Wabbajack.VFS": "0.3.18", + "Wabbajack.Downloaders.Dispatcher": "0.4.0", + "Wabbajack.Installer": "0.4.0", + "Wabbajack.VFS": "0.4.0", "ini-parser-netstandard": "2.5.2" }, "runtime": { "Wabbajack.Compiler.dll": {} } }, - "Wabbajack.Compression.BSA/0.3.18": { + "Wabbajack.Compression.BSA/0.4.0": { "dependencies": { "K4os.Compression.LZ4.Streams": "1.3.8", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "SharpZipLib": "1.4.2", - "Wabbajack.Common": "0.3.18", - "Wabbajack.DTOs": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.DTOs": "0.4.0" }, "runtime": { "Wabbajack.Compression.BSA.dll": {} } }, - "Wabbajack.Compression.Zip/0.3.18": { + "Wabbajack.Compression.Zip/0.4.0": { "dependencies": { - "Wabbajack.IO.Async": "0.3.18" + "Wabbajack.IO.Async": "0.4.0" }, "runtime": { "Wabbajack.Compression.Zip.dll": {} } }, - "Wabbajack.Configuration/0.3.18": { + "Wabbajack.Configuration/0.4.0": { "runtime": { "Wabbajack.Configuration.dll": {} } }, - "Wabbajack.Downloaders.Bethesda/0.3.18": { + "Wabbajack.Downloaders.Bethesda/0.4.0": { "dependencies": { "LibAES-CTR": "1.1.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "SharpZipLib": "1.4.2", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Networking.BethesdaNet": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Networking.BethesdaNet": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.Bethesda.dll": {} } }, - "Wabbajack.Downloaders.Dispatcher/0.3.18": { + "Wabbajack.Downloaders.Dispatcher/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Bethesda": "0.3.18", - "Wabbajack.Downloaders.GameFile": "0.3.18", - "Wabbajack.Downloaders.GoogleDrive": "0.3.18", - "Wabbajack.Downloaders.Http": "0.3.18", - "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Downloaders.Manual": "0.3.18", - "Wabbajack.Downloaders.MediaFire": "0.3.18", - "Wabbajack.Downloaders.Mega": "0.3.18", - "Wabbajack.Downloaders.ModDB": "0.3.18", - "Wabbajack.Downloaders.Nexus": "0.3.18", - "Wabbajack.Downloaders.VerificationCache": "0.3.18", - "Wabbajack.Downloaders.WabbajackCDN": "0.3.18", - "Wabbajack.Networking.WabbajackClientApi": "0.3.18" + "Wabbajack.Downloaders.Bethesda": "0.4.0", + "Wabbajack.Downloaders.GameFile": "0.4.0", + "Wabbajack.Downloaders.GoogleDrive": "0.4.0", + "Wabbajack.Downloaders.Http": "0.4.0", + "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Downloaders.Manual": "0.4.0", + "Wabbajack.Downloaders.MediaFire": "0.4.0", + "Wabbajack.Downloaders.Mega": "0.4.0", + "Wabbajack.Downloaders.ModDB": "0.4.0", + "Wabbajack.Downloaders.Nexus": "0.4.0", + "Wabbajack.Downloaders.VerificationCache": "0.4.0", + "Wabbajack.Downloaders.WabbajackCDN": "0.4.0", + "Wabbajack.Networking.WabbajackClientApi": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.Dispatcher.dll": {} } }, - "Wabbajack.Downloaders.GameFile/0.3.18": { + "Wabbajack.Downloaders.GameFile/0.4.0": { "dependencies": { "GameFinder.StoreHandlers.EADesktop": "4.5.0", "GameFinder.StoreHandlers.EGS": "4.5.0", @@ -1903,360 +1903,360 @@ "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.VFS": "0.3.18" + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.VFS": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.GameFile.dll": {} } }, - "Wabbajack.Downloaders.GoogleDrive/0.3.18": { + "Wabbajack.Downloaders.GoogleDrive/0.4.0": { "dependencies": { "HtmlAgilityPack": "1.11.72", "Microsoft.AspNetCore.Http.Extensions": "2.3.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.3.18", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Networking.Http": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Networking.Http": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.GoogleDrive.dll": {} } }, - "Wabbajack.Downloaders.Http/0.3.18": { + "Wabbajack.Downloaders.Http/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.3.18", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Networking.BethesdaNet": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Networking.BethesdaNet": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.Http.dll": {} } }, - "Wabbajack.Downloaders.Interfaces/0.3.18": { + "Wabbajack.Downloaders.Interfaces/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Compression.Zip": "0.3.18", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18" + "Wabbajack.Compression.Zip": "0.4.0", + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.Interfaces.dll": {} } }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.18": { + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.0": { "dependencies": { "F23.StringSimilarity": "6.0.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Networking.Http": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Networking.Http": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {} } }, - "Wabbajack.Downloaders.Manual/0.3.18": { + "Wabbajack.Downloaders.Manual/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.Manual.dll": {} } }, - "Wabbajack.Downloaders.MediaFire/0.3.18": { + "Wabbajack.Downloaders.MediaFire/0.4.0": { "dependencies": { "HtmlAgilityPack": "1.11.72", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.MediaFire.dll": {} } }, - "Wabbajack.Downloaders.Mega/0.3.18": { + "Wabbajack.Downloaders.Mega/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.Mega.dll": {} } }, - "Wabbajack.Downloaders.ModDB/0.3.18": { + "Wabbajack.Downloaders.ModDB/0.4.0": { "dependencies": { "HtmlAgilityPack": "1.11.72", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Networking.Http": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Networking.Http": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.ModDB.dll": {} } }, - "Wabbajack.Downloaders.Nexus/0.3.18": { + "Wabbajack.Downloaders.Nexus/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Hashing.xxHash64": "0.3.18", - "Wabbajack.Networking.Http": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18", - "Wabbajack.Networking.NexusApi": "0.3.18", - "Wabbajack.Paths": "0.3.18" + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Hashing.xxHash64": "0.4.0", + "Wabbajack.Networking.Http": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0", + "Wabbajack.Networking.NexusApi": "0.4.0", + "Wabbajack.Paths": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.Nexus.dll": {} } }, - "Wabbajack.Downloaders.VerificationCache/0.3.18": { + "Wabbajack.Downloaders.VerificationCache/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Stub.System.Data.SQLite.Core.NetStandard": "1.0.119", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18" + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.VerificationCache.dll": {} } }, - "Wabbajack.Downloaders.WabbajackCDN/0.3.18": { + "Wabbajack.Downloaders.WabbajackCDN/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Toolkit.HighPerformance": "7.1.2", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Networking.Http": "0.3.18", - "Wabbajack.RateLimiter": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Networking.Http": "0.4.0", + "Wabbajack.RateLimiter": "0.4.0" }, "runtime": { "Wabbajack.Downloaders.WabbajackCDN.dll": {} } }, - "Wabbajack.DTOs/0.3.18": { + "Wabbajack.DTOs/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.Hashing.xxHash64": "0.3.18", - "Wabbajack.Paths": "0.3.18" + "Wabbajack.Hashing.xxHash64": "0.4.0", + "Wabbajack.Paths": "0.4.0" }, "runtime": { "Wabbajack.DTOs.dll": {} } }, - "Wabbajack.FileExtractor/0.3.18": { + "Wabbajack.FileExtractor/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "OMODFramework": "3.0.1", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Compression.BSA": "0.3.18", - "Wabbajack.Hashing.PHash": "0.3.18", - "Wabbajack.Paths": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Compression.BSA": "0.4.0", + "Wabbajack.Hashing.PHash": "0.4.0", + "Wabbajack.Paths": "0.4.0" }, "runtime": { "Wabbajack.FileExtractor.dll": {} } }, - "Wabbajack.Hashing.PHash/0.3.18": { + "Wabbajack.Hashing.PHash/0.4.0": { "dependencies": { "BCnEncoder.Net.ImageSharp": "1.1.1", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Shipwreck.Phash": "0.5.0", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Common": "0.3.18", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Paths": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Paths": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0" }, "runtime": { "Wabbajack.Hashing.PHash.dll": {} } }, - "Wabbajack.Hashing.xxHash64/0.3.18": { + "Wabbajack.Hashing.xxHash64/0.4.0": { "dependencies": { - "Wabbajack.Paths": "0.3.18", - "Wabbajack.RateLimiter": "0.3.18" + "Wabbajack.Paths": "0.4.0", + "Wabbajack.RateLimiter": "0.4.0" }, "runtime": { "Wabbajack.Hashing.xxHash64.dll": {} } }, - "Wabbajack.Installer/0.3.18": { + "Wabbajack.Installer/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", "Octopus.Octodiff": "2.0.548", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Downloaders.Dispatcher": "0.3.18", - "Wabbajack.Downloaders.GameFile": "0.3.18", - "Wabbajack.FileExtractor": "0.3.18", - "Wabbajack.Networking.WabbajackClientApi": "0.3.18", - "Wabbajack.Paths": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18", - "Wabbajack.VFS": "0.3.18", + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Downloaders.Dispatcher": "0.4.0", + "Wabbajack.Downloaders.GameFile": "0.4.0", + "Wabbajack.FileExtractor": "0.4.0", + "Wabbajack.Networking.WabbajackClientApi": "0.4.0", + "Wabbajack.Paths": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0", + "Wabbajack.VFS": "0.4.0", "ini-parser-netstandard": "2.5.2" }, "runtime": { "Wabbajack.Installer.dll": {} } }, - "Wabbajack.IO.Async/0.3.18": { + "Wabbajack.IO.Async/0.4.0": { "runtime": { "Wabbajack.IO.Async.dll": {} } }, - "Wabbajack.Networking.BethesdaNet/0.3.18": { + "Wabbajack.Networking.BethesdaNet/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Networking.Http": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18" + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Networking.Http": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.Networking.BethesdaNet.dll": {} } }, - "Wabbajack.Networking.Discord/0.3.18": { + "Wabbajack.Networking.Discord/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.Networking.Http.Interfaces": "0.3.18" + "Wabbajack.Networking.Http.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.Networking.Discord.dll": {} } }, - "Wabbajack.Networking.GitHub/0.3.18": { + "Wabbajack.Networking.GitHub/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Octokit": "14.0.0", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18" + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.Networking.GitHub.dll": {} } }, - "Wabbajack.Networking.Http/0.3.18": { + "Wabbajack.Networking.Http/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Http": "9.0.1", "Microsoft.Extensions.Logging": "9.0.1", - "Wabbajack.Configuration": "0.3.18", - "Wabbajack.Downloaders.Interfaces": "0.3.18", - "Wabbajack.Hashing.xxHash64": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18", - "Wabbajack.Paths": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18" + "Wabbajack.Configuration": "0.4.0", + "Wabbajack.Downloaders.Interfaces": "0.4.0", + "Wabbajack.Hashing.xxHash64": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0", + "Wabbajack.Paths": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0" }, "runtime": { "Wabbajack.Networking.Http.dll": {} } }, - "Wabbajack.Networking.Http.Interfaces/0.3.18": { + "Wabbajack.Networking.Http.Interfaces/0.4.0": { "dependencies": { - "Wabbajack.Hashing.xxHash64": "0.3.18" + "Wabbajack.Hashing.xxHash64": "0.4.0" }, "runtime": { "Wabbajack.Networking.Http.Interfaces.dll": {} } }, - "Wabbajack.Networking.NexusApi/0.3.18": { + "Wabbajack.Networking.NexusApi/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Networking.Http": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18", - "Wabbajack.Networking.WabbajackClientApi": "0.3.18" + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Networking.Http": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0", + "Wabbajack.Networking.WabbajackClientApi": "0.4.0" }, "runtime": { "Wabbajack.Networking.NexusApi.dll": {} } }, - "Wabbajack.Networking.WabbajackClientApi/0.3.18": { + "Wabbajack.Networking.WabbajackClientApi/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Octokit": "14.0.0", - "Wabbajack.Common": "0.3.18", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18", - "Wabbajack.VFS.Interfaces": "0.3.18", + "Wabbajack.Common": "0.4.0", + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0", + "Wabbajack.VFS.Interfaces": "0.4.0", "YamlDotNet": "16.3.0" }, "runtime": { "Wabbajack.Networking.WabbajackClientApi.dll": {} } }, - "Wabbajack.Paths/0.3.18": { + "Wabbajack.Paths/0.4.0": { "runtime": { "Wabbajack.Paths.dll": {} } }, - "Wabbajack.Paths.IO/0.3.18": { + "Wabbajack.Paths.IO/0.4.0": { "dependencies": { - "Wabbajack.Paths": "0.3.18", + "Wabbajack.Paths": "0.4.0", "shortid": "4.0.0" }, "runtime": { "Wabbajack.Paths.IO.dll": {} } }, - "Wabbajack.RateLimiter/0.3.18": { + "Wabbajack.RateLimiter/0.4.0": { "runtime": { "Wabbajack.RateLimiter.dll": {} } }, - "Wabbajack.Server.Lib/0.3.18": { + "Wabbajack.Server.Lib/0.4.0": { "dependencies": { "FluentFTP": "52.0.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", @@ -2264,58 +2264,58 @@ "Nettle": "3.0.0", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Common": "0.3.18", - "Wabbajack.Networking.Http.Interfaces": "0.3.18", - "Wabbajack.Services.OSIntegrated": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.Networking.Http.Interfaces": "0.4.0", + "Wabbajack.Services.OSIntegrated": "0.4.0" }, "runtime": { "Wabbajack.Server.Lib.dll": {} } }, - "Wabbajack.Services.OSIntegrated/0.3.18": { + "Wabbajack.Services.OSIntegrated/0.4.0": { "dependencies": { "DeviceId": "6.8.0", "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Newtonsoft.Json": "13.0.3", "SixLabors.ImageSharp": "3.1.6", - "Wabbajack.Compiler": "0.3.18", - "Wabbajack.Downloaders.Dispatcher": "0.3.18", - "Wabbajack.Installer": "0.3.18", - "Wabbajack.Networking.BethesdaNet": "0.3.18", - "Wabbajack.Networking.Discord": "0.3.18", - "Wabbajack.VFS": "0.3.18" + "Wabbajack.Compiler": "0.4.0", + "Wabbajack.Downloaders.Dispatcher": "0.4.0", + "Wabbajack.Installer": "0.4.0", + "Wabbajack.Networking.BethesdaNet": "0.4.0", + "Wabbajack.Networking.Discord": "0.4.0", + "Wabbajack.VFS": "0.4.0" }, "runtime": { "Wabbajack.Services.OSIntegrated.dll": {} } }, - "Wabbajack.VFS/0.3.18": { + "Wabbajack.VFS/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1", "SixLabors.ImageSharp": "3.1.6", "System.Data.SQLite.Core": "1.0.119", - "Wabbajack.Common": "0.3.18", - "Wabbajack.FileExtractor": "0.3.18", - "Wabbajack.Hashing.PHash": "0.3.18", - "Wabbajack.Hashing.xxHash64": "0.3.18", - "Wabbajack.Paths": "0.3.18", - "Wabbajack.Paths.IO": "0.3.18", - "Wabbajack.VFS.Interfaces": "0.3.18" + "Wabbajack.Common": "0.4.0", + "Wabbajack.FileExtractor": "0.4.0", + "Wabbajack.Hashing.PHash": "0.4.0", + "Wabbajack.Hashing.xxHash64": "0.4.0", + "Wabbajack.Paths": "0.4.0", + "Wabbajack.Paths.IO": "0.4.0", + "Wabbajack.VFS.Interfaces": "0.4.0" }, "runtime": { "Wabbajack.VFS.dll": {} } }, - "Wabbajack.VFS.Interfaces/0.3.18": { + "Wabbajack.VFS.Interfaces/0.4.0": { "dependencies": { "Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Wabbajack.DTOs": "0.3.18", - "Wabbajack.Hashing.xxHash64": "0.3.18", - "Wabbajack.Paths": "0.3.18" + "Wabbajack.DTOs": "0.4.0", + "Wabbajack.Hashing.xxHash64": "0.4.0", + "Wabbajack.Paths": "0.4.0" }, "runtime": { "Wabbajack.VFS.Interfaces.dll": {} @@ -2332,12 +2332,12 @@ } }, "libraries": { - "jackify-engine/0.3.18": { + "jackify-engine/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.19": { + "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64/8.0.21": { "type": "runtimepack", "serviceable": false, "sha512": "" @@ -3021,202 +3021,202 @@ "path": "yamldotnet/16.3.0", "hashPath": "yamldotnet.16.3.0.nupkg.sha512" }, - "Wabbajack.CLI.Builder/0.3.18": { + "Wabbajack.CLI.Builder/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Common/0.3.18": { + "Wabbajack.Common/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compiler/0.3.18": { + "Wabbajack.Compiler/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compression.BSA/0.3.18": { + "Wabbajack.Compression.BSA/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Compression.Zip/0.3.18": { + "Wabbajack.Compression.Zip/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Configuration/0.3.18": { + "Wabbajack.Configuration/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Bethesda/0.3.18": { + "Wabbajack.Downloaders.Bethesda/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Dispatcher/0.3.18": { + "Wabbajack.Downloaders.Dispatcher/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.GameFile/0.3.18": { + "Wabbajack.Downloaders.GameFile/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.GoogleDrive/0.3.18": { + "Wabbajack.Downloaders.GoogleDrive/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Http/0.3.18": { + "Wabbajack.Downloaders.Http/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Interfaces/0.3.18": { + "Wabbajack.Downloaders.Interfaces/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.18": { + "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Manual/0.3.18": { + "Wabbajack.Downloaders.Manual/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.MediaFire/0.3.18": { + "Wabbajack.Downloaders.MediaFire/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Mega/0.3.18": { + "Wabbajack.Downloaders.Mega/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.ModDB/0.3.18": { + "Wabbajack.Downloaders.ModDB/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.Nexus/0.3.18": { + "Wabbajack.Downloaders.Nexus/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.VerificationCache/0.3.18": { + "Wabbajack.Downloaders.VerificationCache/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Downloaders.WabbajackCDN/0.3.18": { + "Wabbajack.Downloaders.WabbajackCDN/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.DTOs/0.3.18": { + "Wabbajack.DTOs/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.FileExtractor/0.3.18": { + "Wabbajack.FileExtractor/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Hashing.PHash/0.3.18": { + "Wabbajack.Hashing.PHash/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Hashing.xxHash64/0.3.18": { + "Wabbajack.Hashing.xxHash64/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Installer/0.3.18": { + "Wabbajack.Installer/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.IO.Async/0.3.18": { + "Wabbajack.IO.Async/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.BethesdaNet/0.3.18": { + "Wabbajack.Networking.BethesdaNet/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Discord/0.3.18": { + "Wabbajack.Networking.Discord/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.GitHub/0.3.18": { + "Wabbajack.Networking.GitHub/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Http/0.3.18": { + "Wabbajack.Networking.Http/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.Http.Interfaces/0.3.18": { + "Wabbajack.Networking.Http.Interfaces/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.NexusApi/0.3.18": { + "Wabbajack.Networking.NexusApi/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Networking.WabbajackClientApi/0.3.18": { + "Wabbajack.Networking.WabbajackClientApi/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Paths/0.3.18": { + "Wabbajack.Paths/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Paths.IO/0.3.18": { + "Wabbajack.Paths.IO/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.RateLimiter/0.3.18": { + "Wabbajack.RateLimiter/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Server.Lib/0.3.18": { + "Wabbajack.Server.Lib/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.Services.OSIntegrated/0.3.18": { + "Wabbajack.Services.OSIntegrated/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.VFS/0.3.18": { + "Wabbajack.VFS/0.4.0": { "type": "project", "serviceable": false, "sha512": "" }, - "Wabbajack.VFS.Interfaces/0.3.18": { + "Wabbajack.VFS.Interfaces/0.4.0": { "type": "project", "serviceable": false, "sha512": "" diff --git a/jackify/engine/jackify-engine.dll b/jackify/engine/jackify-engine.dll index f43b08d..95fb394 100644 Binary files a/jackify/engine/jackify-engine.dll and b/jackify/engine/jackify-engine.dll differ diff --git a/jackify/engine/jackify-engine.runtimeconfig.json b/jackify/engine/jackify-engine.runtimeconfig.json index 0110e65..85b249a 100644 --- a/jackify/engine/jackify-engine.runtimeconfig.json +++ b/jackify/engine/jackify-engine.runtimeconfig.json @@ -4,7 +4,7 @@ "includedFrameworks": [ { "name": "Microsoft.NETCore.App", - "version": "8.0.19" + "version": "8.0.21" } ], "configProperties": { diff --git a/jackify/engine/libSystem.Globalization.Native.so b/jackify/engine/libSystem.Globalization.Native.so index f0b2e2b..49c9322 100755 Binary files a/jackify/engine/libSystem.Globalization.Native.so and b/jackify/engine/libSystem.Globalization.Native.so differ diff --git a/jackify/engine/libSystem.IO.Compression.Native.so b/jackify/engine/libSystem.IO.Compression.Native.so index 0fdb27c..36ab0e8 100755 Binary files a/jackify/engine/libSystem.IO.Compression.Native.so and b/jackify/engine/libSystem.IO.Compression.Native.so differ diff --git a/jackify/engine/libSystem.Native.so b/jackify/engine/libSystem.Native.so index 190bf83..e5d0e87 100755 Binary files a/jackify/engine/libSystem.Native.so and b/jackify/engine/libSystem.Native.so differ diff --git a/jackify/engine/libSystem.Net.Security.Native.so b/jackify/engine/libSystem.Net.Security.Native.so index ae56834..678f305 100755 Binary files a/jackify/engine/libSystem.Net.Security.Native.so and b/jackify/engine/libSystem.Net.Security.Native.so differ diff --git a/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so b/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so index 362d66a..83a7cff 100755 Binary files a/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so and b/jackify/engine/libSystem.Security.Cryptography.Native.OpenSsl.so differ diff --git a/jackify/engine/libclrgc.so b/jackify/engine/libclrgc.so index 94fc2d7..2579a67 100755 Binary files a/jackify/engine/libclrgc.so and b/jackify/engine/libclrgc.so differ diff --git a/jackify/engine/libclrjit.so b/jackify/engine/libclrjit.so index 4fcf810..4ab7ec6 100755 Binary files a/jackify/engine/libclrjit.so and b/jackify/engine/libclrjit.so differ diff --git a/jackify/engine/libcoreclr.so b/jackify/engine/libcoreclr.so index 40f2c65..67079a9 100755 Binary files a/jackify/engine/libcoreclr.so and b/jackify/engine/libcoreclr.so differ diff --git a/jackify/engine/libcoreclrtraceptprovider.so b/jackify/engine/libcoreclrtraceptprovider.so index 001b79d..466b0b9 100755 Binary files a/jackify/engine/libcoreclrtraceptprovider.so and b/jackify/engine/libcoreclrtraceptprovider.so differ diff --git a/jackify/engine/libhostfxr.so b/jackify/engine/libhostfxr.so index 339827e..b99b541 100755 Binary files a/jackify/engine/libhostfxr.so and b/jackify/engine/libhostfxr.so differ diff --git a/jackify/engine/libhostpolicy.so b/jackify/engine/libhostpolicy.so index e3e1f7b..2ca7942 100755 Binary files a/jackify/engine/libhostpolicy.so and b/jackify/engine/libhostpolicy.so differ diff --git a/jackify/engine/libmscordaccore.so b/jackify/engine/libmscordaccore.so index b20c15a..9688b8e 100755 Binary files a/jackify/engine/libmscordaccore.so and b/jackify/engine/libmscordaccore.so differ diff --git a/jackify/engine/libmscordbi.so b/jackify/engine/libmscordbi.so index 7b200d3..5c2af7c 100755 Binary files a/jackify/engine/libmscordbi.so and b/jackify/engine/libmscordbi.so differ diff --git a/jackify/engine/mscorlib.dll b/jackify/engine/mscorlib.dll index af3b4a4..61f9ce6 100755 Binary files a/jackify/engine/mscorlib.dll and b/jackify/engine/mscorlib.dll differ diff --git a/jackify/engine/netstandard.dll b/jackify/engine/netstandard.dll index bd0b985..7cf503a 100755 Binary files a/jackify/engine/netstandard.dll and b/jackify/engine/netstandard.dll differ diff --git a/jackify/frontends/cli/main.py b/jackify/frontends/cli/main.py index 337e31b..eb1889e 100755 --- a/jackify/frontends/cli/main.py +++ b/jackify/frontends/cli/main.py @@ -25,7 +25,6 @@ from .commands.install_modlist import InstallModlistCommand # Import our menu handlers from .menus.main_menu import MainMenuHandler from .menus.wabbajack_menu import WabbajackMenuHandler -from .menus.hoolamike_menu import HoolamikeMenuHandler from .menus.additional_menu import AdditionalMenuHandler # Import backend handlers for legacy compatibility @@ -59,10 +58,18 @@ class JackifyCLI: # Configure logging to be quiet by default - will be adjusted after arg parsing self._configure_logging_early() - - # Determine system info - self.system_info = SystemInfo(is_steamdeck=self._is_steamdeck()) - + + # 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() @@ -290,7 +297,6 @@ class JackifyCLI: menus = { 'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)), 'wabbajack': WabbajackMenuHandler(), - 'hoolamike': HoolamikeMenuHandler(), 'additional': AdditionalMenuHandler() } @@ -419,9 +425,6 @@ class JackifyCLI: elif command == "install-wabbajack": # Legacy functionality - TODO: extract to command handler return self._handle_legacy_install_wabbajack() - elif command == "hoolamike": - # Legacy functionality - TODO: extract to command handler - return self._handle_legacy_hoolamike() elif command == "install-mo2": print("MO2 installation not yet implemented") print("This functionality is coming soon!") @@ -495,9 +498,6 @@ class JackifyCLI: print("Install Wabbajack functionality not yet migrated to new structure") return 1 - def _handle_legacy_hoolamike(self): - """Handle hoolamike command (legacy functionality)""" - print("Hoolamike functionality not yet migrated to new structure") return 1 def _handle_legacy_recovery(self, args): diff --git a/jackify/frontends/cli/menus/__init__.py b/jackify/frontends/cli/menus/__init__.py index 90de6e9..a859155 100644 --- a/jackify/frontends/cli/menus/__init__.py +++ b/jackify/frontends/cli/menus/__init__.py @@ -5,14 +5,12 @@ Extracted from the legacy monolithic CLI system from .main_menu import MainMenuHandler from .wabbajack_menu import WabbajackMenuHandler -from .hoolamike_menu import HoolamikeMenuHandler from .additional_menu import AdditionalMenuHandler from .recovery_menu import RecoveryMenuHandler __all__ = [ 'MainMenuHandler', 'WabbajackMenuHandler', - 'HoolamikeMenuHandler', 'AdditionalMenuHandler', 'RecoveryMenuHandler' ] \ No newline at end of file diff --git a/jackify/frontends/cli/menus/additional_menu.py b/jackify/frontends/cli/menus/additional_menu.py index 058523e..1f6a1a8 100644 --- a/jackify/frontends/cli/menus/additional_menu.py +++ b/jackify/frontends/cli/menus/additional_menu.py @@ -29,19 +29,23 @@ class AdditionalMenuHandler: self._clear_screen() print_jackify_banner() print_section_header("Additional Tasks & Tools") - print(f"{COLOR_INFO}Additional Tasks & Tools, such as TTW Installation{COLOR_RESET}\n") + print(f"{COLOR_INFO}Nexus Authentication, TTW Install & more{COLOR_RESET}\n") - print(f"{COLOR_SELECTION}1.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation") - print(f" {COLOR_ACTION}→ Install TTW using Hoolamike native automation{COLOR_RESET}") - print(f"{COLOR_SELECTION}2.{COLOR_RESET} Coming Soon...") + print(f"{COLOR_SELECTION}1.{COLOR_RESET} Nexus Mods Authorization") + 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}0.{COLOR_RESET} Return to Main Menu") - selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() - + selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip() + if selection.lower() == 'q': # Allow 'q' to re-display menu continue if selection == "1": - self._execute_hoolamike_ttw_install(cli_instance) + self._execute_nexus_authorization(cli_instance) 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...") elif selection == "0": @@ -68,57 +72,211 @@ class AdditionalMenuHandler: recovery_handler.logger = self.logger recovery_handler.show_recovery_menu(cli_instance) - def _execute_hoolamike_ttw_install(self, cli_instance): - """Execute TTW installation using Hoolamike handler""" - from ....backend.handlers.hoolamike_handler import HoolamikeHandler + def _execute_ttw_install(self, cli_instance): + """Execute TTW installation using TTW_Linux_Installer handler""" + from ....backend.handlers.ttw_installer_handler import TTWInstallerHandler from ....backend.models.configuration import SystemInfo - from ....shared.colors import COLOR_ERROR + from ....shared.colors import COLOR_ERROR, COLOR_WARNING, COLOR_SUCCESS, COLOR_INFO, COLOR_PROMPT + from pathlib import Path system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck) - hoolamike_handler = HoolamikeHandler( + ttw_installer_handler = TTWInstallerHandler( steamdeck=system_info.is_steamdeck, verbose=cli_instance.verbose, filesystem_handler=cli_instance.filesystem_handler, - config_handler=cli_instance.config_handler, - menu_handler=cli_instance.menu_handler + config_handler=cli_instance.config_handler ) - # First check if Hoolamike is installed - if not hoolamike_handler.hoolamike_installed: - print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}") - if not hoolamike_handler.install_update_hoolamike(): - print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with TTW installation.{COLOR_RESET}") + # First check if TTW_Linux_Installer is installed + if not ttw_installer_handler.ttw_installer_installed: + print(f"\n{COLOR_WARNING}TTW_Linux_Installer is not installed. Installing TTW_Linux_Installer first...{COLOR_RESET}") + success, message = ttw_installer_handler.install_ttw_installer() + if not success: + print(f"{COLOR_ERROR}Failed to install TTW_Linux_Installer. Cannot proceed with TTW installation.{COLOR_RESET}") + print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}") input("Press Enter to return to menu...") return - # Run TTW installation workflow + # Check for required games + detected_games = ttw_installer_handler.path_handler.find_vanilla_game_paths() + required_games = ['Fallout 3', 'Fallout New Vegas'] + missing_games = [game for game in required_games if game not in detected_games] + if missing_games: + print(f"\n{COLOR_ERROR}Missing required games: {', '.join(missing_games)}") + print(f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.{COLOR_RESET}") + input("Press Enter to return to menu...") + return + + # 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}") + input("Press Enter to return to menu...") + 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}") + input("Press Enter to return to menu...") + return + + # Prompt for output directory + 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 not output_path: + output_path = default_output + else: + output_path = Path(output_path).expanduser() + + # Run TTW installation print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}") - result = hoolamike_handler.install_ttw() - if result is None: - print(f"\n{COLOR_WARNING}TTW installation returned without result.{COLOR_RESET}") + success, message = ttw_installer_handler.install_ttw_backend(mpi_path, output_path) + + if success: + print(f"\n{COLOR_SUCCESS}TTW installation completed successfully!{COLOR_RESET}") + print(f"{COLOR_INFO}TTW installed to: {output_path}{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}TTW installation failed.{COLOR_RESET}") + print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}") input("Press Enter to return to menu...") - def _execute_hoolamike_modlist_install(self, cli_instance): - """Execute modlist installation using Hoolamike handler""" - from ....backend.handlers.hoolamike_handler import HoolamikeHandler - from ....backend.models.configuration import SystemInfo + def _execute_nexus_authorization(self, cli_instance): + """Execute Nexus authorization menu (OAuth or API key)""" + from ....backend.services.nexus_auth_service import NexusAuthService + from ....backend.services.api_key_service import APIKeyService + from ....shared.colors import COLOR_ERROR, COLOR_SUCCESS - system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck) - hoolamike_handler = HoolamikeHandler( - steamdeck=system_info.is_steamdeck, - verbose=cli_instance.verbose, - filesystem_handler=cli_instance.filesystem_handler, - config_handler=cli_instance.config_handler, - menu_handler=cli_instance.menu_handler - ) + auth_service = NexusAuthService() - # First check if Hoolamike is installed - if not hoolamike_handler.hoolamike_installed: - print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}") - if not hoolamike_handler.install_update_hoolamike(): - print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with modlist installation.{COLOR_RESET}") - input("Press Enter to return to menu...") - return + while True: + self._clear_screen() + print_jackify_banner() + print_section_header("Nexus Mods Authorization") - # Run modlist installation - hoolamike_handler.install_modlist() \ No newline at end of file + # Get current auth status + authenticated, method, username = auth_service.get_auth_status() + + if authenticated: + if method == 'oauth': + print(f"\n{COLOR_SUCCESS}Status: Authorized via OAuth{COLOR_RESET}") + if username: + print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}") + elif method == 'api_key': + print(f"\n{COLOR_WARNING}Status: Using API Key (Legacy){COLOR_RESET}") + print(f"{COLOR_INFO}Consider switching to OAuth for better security{COLOR_RESET}") + else: + print(f"\n{COLOR_WARNING}Status: Not Authorized{COLOR_RESET}") + print(f"{COLOR_INFO}You need to authorize to download mods from Nexus{COLOR_RESET}") + + print(f"\n{COLOR_SELECTION}1.{COLOR_RESET} Authorize with Nexus (OAuth)") + print(f" {COLOR_ACTION}→ Opens browser for secure authorization{COLOR_RESET}") + + if method == 'oauth': + print(f"{COLOR_SELECTION}2.{COLOR_RESET} Revoke OAuth Authorization") + print(f" {COLOR_ACTION}→ Remove OAuth token{COLOR_RESET}") + + print(f"{COLOR_SELECTION}3.{COLOR_RESET} Set API Key (Legacy Fallback)") + print(f" {COLOR_ACTION}→ Manually enter Nexus API key{COLOR_RESET}") + + if authenticated: + print(f"{COLOR_SELECTION}4.{COLOR_RESET} Clear All Authentication") + print(f" {COLOR_ACTION}→ Remove both OAuth and API key{COLOR_RESET}") + + print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Additional Tasks Menu") + + selection = input(f"\n{COLOR_PROMPT}Enter your selection: {COLOR_RESET}").strip() + + if selection == "1": + # 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_WARNING}Please check your browser and authorize Jackify.{COLOR_RESET}") + print(f"\n{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}") + + input(f"\n{COLOR_PROMPT}Press Enter to open browser...{COLOR_RESET}") + + # Perform OAuth authorization + 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}") + # Get username + _, _, username = auth_service.get_auth_status() + if username: + print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}") + print(f"{COLOR_INFO}You can try again or use API key as fallback.{COLOR_RESET}") + + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + + elif selection == "2" and method == 'oauth': + # Revoke OAuth + print(f"\n{COLOR_WARNING}Are you sure you want to revoke OAuth authorization?{COLOR_RESET}") + confirm = input(f"{COLOR_PROMPT}Type 'yes' to confirm: {COLOR_RESET}").strip().lower() + + if confirm == 'yes': + if auth_service.revoke_oauth(): + print(f"\n{COLOR_SUCCESS}OAuth authorization revoked.{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}Failed to revoke OAuth authorization.{COLOR_RESET}") + else: + print(f"\n{COLOR_INFO}Cancelled.{COLOR_RESET}") + + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + + elif selection == "3": + # Set API key + print(f"\n{COLOR_INFO}Enter your Nexus API Key{COLOR_RESET}") + print(f"{COLOR_INFO}(Get it from: https://www.nexusmods.com/users/myaccount?tab=api){COLOR_RESET}") + + api_key = input(f"\n{COLOR_PROMPT}API Key: {COLOR_RESET}").strip() + + if api_key: + if auth_service.save_api_key(api_key): + print(f"\n{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}") + + # Optionally validate + print(f"\n{COLOR_INFO}Validating API key...{COLOR_RESET}") + valid, result = auth_service.validate_api_key(api_key) + + if valid: + print(f"{COLOR_SUCCESS}API key validated successfully!{COLOR_RESET}") + print(f"{COLOR_INFO}Username: {result}{COLOR_RESET}") + else: + print(f"{COLOR_WARNING}Warning: API key validation failed: {result}{COLOR_RESET}") + print(f"{COLOR_INFO}Key saved, but may not work correctly.{COLOR_RESET}") + else: + print(f"\n{COLOR_ERROR}Failed to save API key.{COLOR_RESET}") + else: + print(f"\n{COLOR_INFO}Cancelled.{COLOR_RESET}") + + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + + elif selection == "4" and authenticated: + # Clear all authentication + print(f"\n{COLOR_WARNING}Are you sure you want to clear ALL authentication?{COLOR_RESET}") + print(f"{COLOR_WARNING}This will remove both OAuth token and API key.{COLOR_RESET}") + confirm = input(f"{COLOR_PROMPT}Type 'yes' to confirm: {COLOR_RESET}").strip().lower() + + if confirm == 'yes': + if auth_service.clear_all_auth(): + print(f"\n{COLOR_SUCCESS}All authentication cleared.{COLOR_RESET}") + else: + print(f"\n{COLOR_INFO}No authentication to clear.{COLOR_RESET}") + else: + print(f"\n{COLOR_INFO}Cancelled.{COLOR_RESET}") + + input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") + + elif selection == "0": + break + else: + print(f"\n{COLOR_ERROR}Invalid selection.{COLOR_RESET}") + time.sleep(1) diff --git a/jackify/frontends/cli/menus/hoolamike_menu.py b/jackify/frontends/cli/menus/hoolamike_menu.py deleted file mode 100644 index f5446bf..0000000 --- a/jackify/frontends/cli/menus/hoolamike_menu.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Hoolamike Menu Handler for Jackify CLI Frontend -Extracted from src.modules.menu_handler.MenuHandler.show_hoolamike_menu() -""" - -from jackify.shared.colors import COLOR_INFO, COLOR_PROMPT, COLOR_RESET - -class HoolamikeMenuHandler: - """ - Handles the Hoolamike Tasks menu - Extracted from legacy MenuHandler class - """ - - def __init__(self): - self.logger = None # Will be set by CLI when needed - - def show_hoolamike_menu(self, cli_instance): - """ - LEGACY BRIDGE: Delegate to legacy menu handler until full backend migration - - Args: - cli_instance: Reference to main CLI instance for access to handlers - """ - print(f"{COLOR_INFO}Hoolamike menu functionality has been extracted but needs migration to backend services.{COLOR_RESET}") - print(f"{COLOR_INFO}This will be implemented in Phase 2.3 (Menu Backend Integration).{COLOR_RESET}") - - # LEGACY BRIDGE: Use the original menu handler's method - if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'show_hoolamike_menu'): - cli_instance.menu.show_hoolamike_menu(cli_instance) - else: - print(f"{COLOR_INFO}Legacy menu handler not available - returning to main menu.{COLOR_RESET}") - input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}") \ No newline at end of file diff --git a/jackify/frontends/gui/__main__.py b/jackify/frontends/gui/__main__.py index 04cd755..15dbfa3 100644 --- a/jackify/frontends/gui/__main__.py +++ b/jackify/frontends/gui/__main__.py @@ -5,7 +5,123 @@ Entry point for Jackify GUI Frontend Usage: python -m jackify.frontends.gui """ -from jackify.frontends.gui.main import main +import sys +from pathlib import Path + +def main(): + # Check if launched with jackify:// protocol URL (OAuth callback) + if len(sys.argv) > 1 and sys.argv[1].startswith('jackify://'): + handle_protocol_url(sys.argv[1]) + return + + # Normal GUI launch + from jackify.frontends.gui.main import main as gui_main + gui_main() + +def handle_protocol_url(url: str): + """Handle jackify:// protocol URL (OAuth callback)""" + import os + import sys + + # Enhanced logging with system information + try: + from jackify.shared.paths import get_jackify_logs_dir + log_dir = get_jackify_logs_dir() + except Exception as e: + # Fallback if config system fails + log_dir = Path.home() / ".config" / "jackify" / "logs" + + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "protocol_handler.log" + + def log(msg): + with open(log_file, 'a') as f: + import datetime + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + f.write(f"[{timestamp}] {msg}\n") + f.flush() # Ensure immediate write + + try: + # Log system information for debugging + log(f"=== Protocol Handler Invoked ===") + log(f"URL: {url}") + log(f"Python executable: {sys.executable}") + log(f"Script path: {sys.argv[0]}") + log(f"Working directory: {os.getcwd()}") + log(f"APPIMAGE env: {os.environ.get('APPIMAGE', 'Not set')}") + log(f"APPDIR env: {os.environ.get('APPDIR', 'Not set')}") + + from urllib.parse import urlparse, parse_qs + + parsed = urlparse(url) + log(f"Parsed URL - scheme: {parsed.scheme}, netloc: {parsed.netloc}, path: {parsed.path}, query: {parsed.query}") + + # URL format: jackify://oauth/callback?code=XXX&state=YYY + # urlparse treats "oauth" as netloc, so reconstruct full path + full_path = f"/{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path + log(f"Reconstructed path: {full_path}") + + if full_path == '/oauth/callback': + params = parse_qs(parsed.query) + code = params.get('code', [None])[0] + state = params.get('state', [None])[0] + error = params.get('error', [None])[0] + + log(f"OAuth parameters - Code: {'Present' if code else 'Missing'}, State: {'Present' if state else 'Missing'}, Error: {error}") + + if error: + log(f"ERROR: OAuth error received: {error}") + error_description = params.get('error_description', ['No description'])[0] + log(f"ERROR: OAuth error description: {error_description}") + print(f"OAuth authorization failed: {error} - {error_description}") + elif code and state: + # Write to callback file for OAuth service to pick up + callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp" + log(f"Creating callback file: {callback_file}") + + try: + callback_file.parent.mkdir(parents=True, exist_ok=True) + callback_content = f"{code}\n{state}" + callback_file.write_text(callback_content) + + # Verify file was written + if callback_file.exists(): + written_content = callback_file.read_text() + log(f"Callback file created successfully, size: {len(written_content)} bytes") + print("OAuth callback received and saved successfully") + else: + log("ERROR: Callback file was not created") + print("Error: Failed to create callback file") + + except Exception as callback_error: + log(f"ERROR: Failed to write callback file: {callback_error}") + print(f"Error writing callback file: {callback_error}") + else: + log("ERROR: Missing required OAuth parameters (code or state)") + print("Invalid OAuth callback - missing required parameters") + else: + log(f"ERROR: Unknown protocol path: {full_path}") + print(f"Unknown protocol path: {full_path}") + + log("=== Protocol Handler Completed ===") + + except Exception as e: + log(f"CRITICAL EXCEPTION: {e}") + import traceback + log(f"TRACEBACK:\n{traceback.format_exc()}") + print(f"Critical error handling protocol URL: {e}") + + # Try to log to a fallback location if main logging fails + try: + fallback_log = Path.home() / "jackify_protocol_error.log" + with open(fallback_log, 'a') as f: + import datetime + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + f.write(f"[{timestamp}] CRITICAL ERROR: {e}\n") + f.write(f"URL: {url}\n") + f.write(f"Traceback:\n{traceback.format_exc()}\n\n") + except: + pass # If even fallback logging fails, just continue if __name__ == "__main__": main() \ No newline at end of file diff --git a/jackify/frontends/gui/dialogs/about_dialog.py b/jackify/frontends/gui/dialogs/about_dialog.py index 5d8a0a2..5012c70 100644 --- a/jackify/frontends/gui/dialogs/about_dialog.py +++ b/jackify/frontends/gui/dialogs/about_dialog.py @@ -304,11 +304,12 @@ class AboutDialog(QDialog): def _get_engine_version(self) -> str: """Get jackify-engine version.""" try: - # Try to execute jackify-engine --version + # Try to execute jackify-engine --version engine_path = Path(__file__).parent.parent.parent.parent / "engine" / "jackify-engine" if engine_path.exists(): - result = subprocess.run([str(engine_path), "--version"], - capture_output=True, text=True, timeout=5) + from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env + result = subprocess.run([str(engine_path), "--version"], + capture_output=True, text=True, timeout=5, env=get_clean_subprocess_env()) if result.returncode == 0: version = result.stdout.strip() # Extract just the version number (before the +commit hash) diff --git a/jackify/frontends/gui/dialogs/warning_dialog.py b/jackify/frontends/gui/dialogs/warning_dialog.py index a91f261..f2dba32 100644 --- a/jackify/frontends/gui/dialogs/warning_dialog.py +++ b/jackify/frontends/gui/dialogs/warning_dialog.py @@ -23,8 +23,10 @@ class WarningDialog(QDialog): self.setWindowTitle("Warning!") self.setModal(True) # Increased height for better text display, scalable for 800p screens - self.setFixedSize(500, 440) + self.setFixedSize(500, 460) self.confirmed = False + self._failed_attempts = 0 + self._max_attempts = 3 self._setup_ui(warning_message) def _setup_ui(self, warning_message): @@ -99,21 +101,21 @@ class WarningDialog(QDialog): card_layout.addWidget(message_text) # Confirmation entry - confirm_label = QLabel("Type 'DELETE' to confirm:") - confirm_label.setAlignment(Qt.AlignCenter) - confirm_label.setStyleSheet( + self.confirm_label = QLabel("Type 'DELETE' to confirm (all caps):") + self.confirm_label.setAlignment(Qt.AlignCenter) + self.confirm_label.setStyleSheet( "QLabel { " " font-size: 13px; " " color: #e67e22; " " margin-bottom: 2px; " "}" ) - card_layout.addWidget(confirm_label) + card_layout.addWidget(self.confirm_label) self.confirm_edit = QLineEdit() self.confirm_edit.setAlignment(Qt.AlignCenter) self.confirm_edit.setPlaceholderText("DELETE") - self.confirm_edit.setStyleSheet( + self._default_lineedit_style = ( "QLineEdit { " " font-size: 15px; " " border: 1px solid #e67e22; " @@ -123,6 +125,9 @@ class WarningDialog(QDialog): " color: #e67e22; " "}" ) + self.confirm_edit.setStyleSheet(self._default_lineedit_style) + self.confirm_edit.textChanged.connect(self._on_text_changed) + self.confirm_edit.returnPressed.connect(self._on_confirm) # Handle Enter key card_layout.addWidget(self.confirm_edit) # Action buttons @@ -178,11 +183,69 @@ class WarningDialog(QDialog): layout.addWidget(card, alignment=Qt.AlignCenter) layout.addStretch() + def _on_text_changed(self): + """Reset error styling when user starts typing again.""" + # Only reset if currently showing error state (darker background) + if "#3b2323" in self.confirm_edit.styleSheet(): + self.confirm_edit.setStyleSheet(self._default_lineedit_style) + self.confirm_edit.setPlaceholderText("DELETE") + + # Reset label but keep attempt counter if attempts were made + if self._failed_attempts > 0: + remaining = self._max_attempts - self._failed_attempts + self.confirm_label.setText(f"Type 'DELETE' to confirm (all caps) - {remaining} attempt(s) remaining:") + else: + self.confirm_label.setText("Type 'DELETE' to confirm (all caps):") + + self.confirm_label.setStyleSheet( + "QLabel { " + " font-size: 13px; " + " color: #e67e22; " + " margin-bottom: 2px; " + "}" + ) + def _on_confirm(self): - if self.confirm_edit.text().strip().upper() == "DELETE": + entered_text = self.confirm_edit.text().strip() + + if entered_text == "DELETE": + # Correct - proceed self.confirmed = True self.accept() else: - self.confirm_edit.setText("") - self.confirm_edit.setPlaceholderText("Type DELETE to confirm") - self.confirm_edit.setStyleSheet(self.confirm_edit.styleSheet() + "QLineEdit { background: #3b2323; }") \ No newline at end of file + # Wrong text entered + self._failed_attempts += 1 + + if self._failed_attempts >= self._max_attempts: + # Too many failed attempts - cancel automatically + self.confirmed = False + self.reject() + return + + # Still have attempts remaining - clear field and show error feedback + self.confirm_edit.clear() + + # Update label to show remaining attempts + remaining = self._max_attempts - self._failed_attempts + self.confirm_label.setText(f"Wrong! Type 'DELETE' exactly (all caps) - {remaining} attempt(s) remaining:") + self.confirm_label.setStyleSheet( + "QLabel { " + " font-size: 13px; " + " color: #c0392b; " # Red for error + " margin-bottom: 2px; " + " font-weight: bold; " + "}" + ) + + # Show error state in text field + self.confirm_edit.setPlaceholderText(f"Type DELETE ({remaining} attempts left)") + self.confirm_edit.setStyleSheet( + "QLineEdit { " + " font-size: 15px; " + " border: 2px solid #c0392b; " # Red border for error + " border-radius: 6px; " + " padding: 6px; " + " background: #3b2323; " # Darker red background + " color: #e67e22; " + "}" + ) \ No newline at end of file diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py index d42c39f..146cd8f 100644 --- a/jackify/frontends/gui/main.py +++ b/jackify/frontends/gui/main.py @@ -14,15 +14,15 @@ from pathlib import Path os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.qpa.*=false;*.warning=false' os.environ['QT_ENABLE_GLYPH_CACHE_WORKAROUND'] = '1' -# Hidden diagnostic flag for debugging PyInstaller environment issues - must be first +# Hidden diagnostic flag for debugging AppImage/bundled environment issues - must be first if '--env-diagnostic' in sys.argv: import json from datetime import datetime - print("PyInstaller Environment Diagnostic") + print("Bundled Environment Diagnostic") print("=" * 50) - # Check if we're in PyInstaller + # Check if we're running from a frozen bundle is_frozen = getattr(sys, 'frozen', False) meipass = getattr(sys, '_MEIPASS', None) @@ -32,7 +32,7 @@ if '--env-diagnostic' in sys.argv: # Capture environment data env_data = { 'timestamp': datetime.now().isoformat(), - 'context': 'pyinstaller_internal', + 'context': 'appimage_runtime', 'frozen': is_frozen, 'meipass': meipass, 'python_executable': sys.executable, @@ -40,13 +40,13 @@ if '--env-diagnostic' in sys.argv: 'sys_path': sys.path, } - # PyInstaller-specific environment variables - pyinstaller_vars = {} + # Bundle-specific environment variables + bundle_vars = {} for key, value in os.environ.items(): - if any(term in key.lower() for term in ['mei', 'pyinstaller', 'tmp']): - pyinstaller_vars[key] = value + if any(term in key.lower() for term in ['mei', 'appimage', 'tmp']): + bundle_vars[key] = value - env_data['pyinstaller_vars'] = pyinstaller_vars + env_data['bundle_vars'] = bundle_vars # Check LD_LIBRARY_PATH ld_path = os.environ.get('LD_LIBRARY_PATH', '') @@ -55,7 +55,7 @@ if '--env-diagnostic' in sys.argv: env_data['ld_library_path'] = ld_path env_data['ld_library_path_suspicious'] = suspicious - # Try to find jackify-engine from PyInstaller context + # Try to find jackify-engine from bundled context engine_paths = [] if meipass: meipass_path = Path(meipass) @@ -71,7 +71,7 @@ if '--env-diagnostic' in sys.argv: # Save to file try: - output_file = Path.cwd() / "pyinstaller_env_capture.json" + output_file = Path.cwd() / "bundle_env_capture.json" with open(output_file, 'w') as f: json.dump(env_data, f, indent=2) print(f"\nData saved to: {output_file}") @@ -101,10 +101,11 @@ src_dir = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(src_dir)) from PySide6.QtWidgets import ( + QSizePolicy, QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QPushButton, - QStackedWidget, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QCheckBox, QSpinBox, QMessageBox, QGroupBox, QGridLayout, QFileDialog, QToolButton, QStyle, QComboBox, QTabWidget + QStackedWidget, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QCheckBox, QSpinBox, QMessageBox, QGroupBox, QGridLayout, QFileDialog, QToolButton, QStyle, QComboBox, QTabWidget, QRadioButton, QButtonGroup ) -from PySide6.QtCore import Qt, QEvent +from PySide6.QtCore import Qt, QEvent, QTimer from PySide6.QtGui import QIcon import json @@ -113,6 +114,9 @@ from jackify.backend.models.configuration import SystemInfo 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 + +ENABLE_WINDOW_HEIGHT_ANIMATION = False def debug_print(message): """Print debug message only if debug mode is enabled""" @@ -273,36 +277,6 @@ class SettingsDialog(QDialog): general_layout.addWidget(dir_group) general_layout.addSpacing(12) - # --- Nexus API Key Section --- - api_group = QGroupBox("Nexus API Key") - api_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; }") - api_layout = QHBoxLayout() - api_group.setLayout(api_layout) - 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 (obfuscated by default, click Show to reveal)") - # Connect for immediate saving when text changes - 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) - self.api_show_btn.setStyleSheet("") - clear_api_btn = QPushButton("Clear API Key") - clear_api_btn.clicked.connect(self._clear_api_key) - api_layout.addWidget(QLabel("Nexus API Key:")) - api_layout.addWidget(self.api_key_edit) - api_layout.addWidget(self.api_show_btn) - api_layout.addWidget(clear_api_btn) - general_layout.addWidget(api_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; }") @@ -351,6 +325,34 @@ class SettingsDialog(QDialog): 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; }") @@ -372,6 +374,50 @@ class SettingsDialog(QDialog): 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_layout = QGridLayout() @@ -414,12 +460,14 @@ class SettingsDialog(QDialog): # Bandwidth limiter row (only show if Downloads resource exists) if "Downloads" in self.resource_settings: - downloads_throughput = self.resource_settings["Downloads"].get("MaxThroughput", 0) + downloads_throughput_bytes = self.resource_settings["Downloads"].get("MaxThroughput", 0) + # Convert bytes/s to KB/s for display + downloads_throughput_kb = downloads_throughput_bytes // 1024 if downloads_throughput_bytes > 0 else 0 self.bandwidth_spin = QSpinBox() self.bandwidth_spin.setMinimum(0) self.bandwidth_spin.setMaximum(1000000) - self.bandwidth_spin.setValue(downloads_throughput) + self.bandwidth_spin.setValue(downloads_throughput_kb) self.bandwidth_spin.setSuffix(" KB/s") self.bandwidth_spin.setFixedWidth(160) self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.") @@ -438,37 +486,80 @@ class SettingsDialog(QDialog): advanced_layout.addWidget(resource_group) + # --- GPU Texture Conversion Section --- + gpu_group = QGroupBox("Texture Conversion") + gpu_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; }") + gpu_layout = QVBoxLayout() + gpu_group.setLayout(gpu_layout) + + gpu_description = QLabel( + "Control whether jackify-engine uses GPU acceleration during texture conversion. " + "GPU acceleration significantly speeds up modlist installation but requires compatible hardware." + ) + gpu_description.setWordWrap(True) + gpu_description.setStyleSheet("color: #ccc; font-size: 10pt;") + gpu_layout.addWidget(gpu_description) + + self.gpu_button_group = QButtonGroup() + current_gpu_enabled = self.config_handler.get("enable_gpu_texture_conversion", True) + + self.gpu_enabled_radio = QRadioButton("Enable GPU for Texture Conversion (default)") + self.gpu_enabled_radio.setToolTip("Use GPU acceleration for faster texture processing during installation.") + self.gpu_enabled_radio.setChecked(current_gpu_enabled) + self.gpu_button_group.addButton(self.gpu_enabled_radio, 0) + gpu_layout.addWidget(self.gpu_enabled_radio) + + self.gpu_disabled_radio = QRadioButton("Disable GPU (CPU only)") + self.gpu_disabled_radio.setToolTip("Use CPU-only texture processing (slower but more compatible).") + self.gpu_disabled_radio.setChecked(not current_gpu_enabled) + self.gpu_button_group.addButton(self.gpu_disabled_radio, 1) + gpu_layout.addWidget(self.gpu_disabled_radio) + + gpu_note = QLabel("Note: Disabling GPU may significantly increase installation time for large modlists.") + gpu_note.setStyleSheet("color: #aaa; font-size: 9pt;") + gpu_layout.addWidget(gpu_note) + + advanced_layout.addWidget(gpu_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 toggle button + # Label for the radio buttons method_label = QLabel("Wine Components Installation:") component_layout.addWidget(method_label) - # Toggle button for winetricks/protontricks selection - self.component_toggle = QPushButton("Winetricks") - self.component_toggle.setCheckable(True) - use_winetricks = self.config_handler.get('use_winetricks_for_components', True) - self.component_toggle.setChecked(use_winetricks) + # Radio button group for component installation method + self.component_method_group = QButtonGroup() + component_method_layout = QVBoxLayout() + + # Get current setting + current_method = self.config_handler.get('component_installation_method', 'system_protontricks') + # Migrate old bundled_protontricks users to system_protontricks + if current_method == 'bundled_protontricks': + current_method = 'system_protontricks' - # Function to update button text based on state - def update_button_text(): - if self.component_toggle.isChecked(): - self.component_toggle.setText("Winetricks") - else: - self.component_toggle.setText("Protontricks") - - self.component_toggle.toggled.connect(update_button_text) - update_button_text() # Set initial text - - self.component_toggle.setToolTip( - "Winetricks: Faster, uses bundled tools (Default)\n" - "Protontricks: Legacy mode, slower but system-compatible" + # Protontricks (default) + self.protontricks_radio = QRadioButton("Protontricks (Default)") + self.protontricks_radio.setChecked(current_method == 'system_protontricks') + self.protontricks_radio.setToolTip( + "Use system-installed protontricks (flatpak or native). Required for component installation." ) - component_layout.addWidget(self.component_toggle) + self.component_method_group.addButton(self.protontricks_radio, 0) + component_method_layout.addWidget(self.protontricks_radio) + + # Winetricks (alternative) + self.winetricks_radio = QRadioButton("Winetricks (Alternative)") + self.winetricks_radio.setChecked(current_method == 'winetricks') + self.winetricks_radio.setToolTip( + "Use bundled winetricks instead. May work when protontricks unavailable." + ) + self.component_method_group.addButton(self.winetricks_radio, 1) + component_method_layout.addWidget(self.winetricks_radio) + + component_layout.addLayout(component_method_layout) advanced_layout.addWidget(component_group) advanced_layout.addStretch() # Add stretch to push content to top @@ -527,6 +618,86 @@ class SettingsDialog(QDialog): 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: @@ -686,13 +857,17 @@ class SettingsDialog(QDialog): if self.bandwidth_spin: if "Downloads" not in self.resource_settings: self.resource_settings["Downloads"] = {"MaxTasks": 16} # Provide default MaxTasks - self.resource_settings["Downloads"]["MaxThroughput"] = self.bandwidth_spin.value() + # 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) @@ -744,7 +919,21 @@ class SettingsDialog(QDialog): self.config_handler.set("game_proton_version", resolved_game_version) # Save component installation method preference - self.config_handler.set("use_winetricks_for_components", self.component_toggle.isChecked()) + if self.winetricks_radio.isChecked(): + method = 'winetricks' + else: # protontricks_radio (default) + 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') + + # Save GPU texture conversion preference + if hasattr(self, "gpu_enabled_radio") and hasattr(self, "gpu_disabled_radio"): + gpu_enabled = self.gpu_enabled_radio.isChecked() + self.config_handler.set("enable_gpu_texture_conversion", gpu_enabled) # Force immediate save and verify save_result = self.config_handler.save_config() @@ -781,6 +970,16 @@ class SettingsDialog(QDialog): 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() @@ -822,38 +1021,205 @@ class JackifyMainWindow(QMainWindow): def __init__(self, dev_mode=False): super().__init__() self.setWindowTitle("Jackify") - self.setMinimumSize(1400, 950) - self.resize(1400, 900) + self._window_margin = 32 + self._base_min_width = 900 + self._base_min_height = 520 + self._compact_height = 640 + self._details_extra_height = 360 + self._initial_show_adjusted = False + + # Ensure GNOME/Ubuntu exposes full set of window controls (avoid hidden buttons) + self._apply_standard_window_flags() + try: + self.setSizeGripEnabled(True) + except AttributeError: + pass + + # Set default responsive minimum constraints before restoring geometry + self.apply_responsive_minimum(self._base_min_width, self._base_min_height) + + # Restore window geometry from QSettings (standard Qt approach) + self._restore_geometry() + self.apply_responsive_minimum(self._base_min_width, self._base_min_height) # Initialize backend services self._initialize_backend() - + # Set up UI self._setup_ui(dev_mode=dev_mode) + + # 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 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) - # Set up cleanup - QApplication.instance().aboutToQuit.connect(self.cleanup_processes) + 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)""" - # Determine system info - self.system_info = SystemInfo(is_steamdeck=self._is_steamdeck()) - + # 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__) @@ -913,7 +1279,7 @@ class JackifyMainWindow(QMainWindow): InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen ) from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen - + self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode) self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget) @@ -944,11 +1310,17 @@ class JackifyMainWindow(QMainWindow): main_menu_index=0, 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 # Add screens to stacked widget self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu @@ -959,9 +1331,11 @@ class JackifyMainWindow(QMainWindow): 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.configure_existing_modlist_screen) # Index 7: 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() @@ -1015,8 +1389,11 @@ class JackifyMainWindow(QMainWindow): main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) - main_layout.addWidget(self.stacked_widget, stretch=1) # Screen takes all available space + # 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) @@ -1026,6 +1403,13 @@ class JackifyMainWindow(QMainWindow): # 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 @@ -1047,7 +1431,7 @@ class JackifyMainWindow(QMainWindow): 4: "Install Modlist Screen", 5: "Install TTW Screen", 6: "Configure New Modlist", - 7: "Configure Existing Modlist" + 7: "Configure Existing Modlist", } screen_name = screen_names.get(index, f"Unknown Screen (Index {index})") widget = self.stacked_widget.widget(index) @@ -1069,9 +1453,54 @@ class JackifyMainWindow(QMainWindow): 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: @@ -1201,6 +1630,10 @@ class JackifyMainWindow(QMainWindow): traceback.print_exc() 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: @@ -1216,38 +1649,46 @@ class JackifyMainWindow(QMainWindow): except Exception: pass - # Ensure we can actually resize - self.showNormal() - self.setMaximumHeight(16777215) - debug_print(f"DEBUG: Set max height to unlimited, current_size={self.size()}") - - if mode == 'expand': - # Restore a sensible minimum and expand height - min_width = max(1200, self.minimumWidth()) - min_height = 900 - debug_print(f"DEBUG: Expand mode - min_width={min_width}, min_height={min_height}") - try: - from PySide6.QtCore import QSize - self.setMinimumSize(QSize(min_width, min_height)) - except Exception: - self.setMinimumSize(min_width, min_height) - # Animate to target height - target_height = max(self.size().height(), min_height) - self._animate_height(target_height) + # 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: - # Collapse to compact height computed from the TTW screen's sizeHint - try: - content_hint = self.install_ttw_screen.sizeHint().height() - except Exception: - content_hint = 460 - compact_height = max(440, min(560, content_hint + 20)) - debug_print(f"DEBUG: Collapse mode - content_hint={content_hint}, compact_height={compact_height}") - from PySide6.QtCore import QSize - self.setMaximumHeight(compact_height) - self.setMinimumSize(QSize(max(1200, self.minimumWidth()), compact_height)) - # Animate to compact height - self._animate_height(compact_height) + # 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. @@ -1258,13 +1699,29 @@ class JackifyMainWindow(QMainWindow): 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(), target_height) + 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") @@ -1272,13 +1729,34 @@ class JackifyMainWindow(QMainWindow): 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() + def resource_path(relative_path): + """Get path to resource file, handling both AppImage and dev modes.""" + # PyInstaller frozen mode if hasattr(sys, '_MEIPASS'): return os.path.join(sys._MEIPASS, relative_path) - return os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) + + # AppImage mode - use APPDIR if available + appdir = os.environ.get('APPDIR') + if appdir: + # In AppImage, resources are in opt/jackify/ relative to APPDIR + # __file__ is at opt/jackify/frontends/gui/main.py, so go up to opt/jackify/ + appimage_path = os.path.join(appdir, 'opt', 'jackify', relative_path) + if os.path.exists(appimage_path): + return appimage_path + + # Dev mode or fallback - go up from frontends/gui to jackify, then to assets + # __file__ is at src/jackify/frontends/gui/main.py, so go up to src/jackify/ + current_dir = os.path.abspath(os.path.dirname(__file__)) + # Go up from frontends/gui to jackify + jackify_dir = os.path.dirname(os.path.dirname(current_dir)) + return os.path.join(jackify_dir, relative_path) def main(): @@ -1322,9 +1800,12 @@ def main(): dev_mode = '--dev' in sys.argv # Launch GUI application - from PySide6.QtGui import QIcon app = QApplication(sys.argv) - + # CRITICAL: Set application name before desktop file name to ensure proper window title/icon on PopOS/Ubuntu + app.setApplicationName("Jackify") + app.setApplicationDisplayName("Jackify") + app.setDesktopFileName("jackify.desktop") + # Global cleanup function for signal handling def emergency_cleanup(): debug_print("Cleanup: terminating jackify-engine processes") @@ -1345,11 +1826,64 @@ def main(): signal.signal(signal.SIGTERM, signal_handler) # System shutdown # Set the application icon - icon_path = resource_path('assets/JackifyLogo_256.png') - app.setWindowIcon(QIcon(icon_path)) + # Try multiple locations - AppImage build script places icon in standard locations + icon_path = None + icon = QIcon() + + # Priority 1: Try resource_path (works in dev mode and if assets are in AppImage) + try_path = resource_path('assets/JackifyLogo_256.png') + if os.path.exists(try_path): + icon_path = try_path + icon = QIcon(try_path) + + # Priority 2: Try standard AppImage icon locations (where build script actually places it) + if icon.isNull(): + appdir = os.environ.get('APPDIR') + if appdir: + appimage_icon_paths = [ + os.path.join(appdir, 'com.jackify.app.png'), # Root of AppDir + os.path.join(appdir, 'usr', 'share', 'icons', 'hicolor', '256x256', 'apps', 'com.jackify.app.png'), # Standard location + os.path.join(appdir, 'opt', 'jackify', 'assets', 'JackifyLogo_256.png'), # If assets are copied + ] + for path in appimage_icon_paths: + if os.path.exists(path): + icon_path = path + icon = QIcon(path) + if not icon.isNull(): + if debug_mode: + print(f"[DEBUG] Using AppImage icon: {path}") + break + + # Priority 3: Fallback to any PNG in assets directory + if icon.isNull(): + try_path = resource_path('assets/JackifyLogo_256.png') + if os.path.exists(try_path): + icon_path = try_path + icon = QIcon(try_path) + + if debug_mode: + print(f"[DEBUG] Final icon path: {icon_path}") + print(f"[DEBUG] Icon is null: {icon.isNull()}") + + app.setWindowIcon(icon) window = JackifyMainWindow(dev_mode=dev_mode) + window.setWindowIcon(icon) window.show() + # On Steam Deck, set window to maximized to prevent button overlap with Show Details console + if hasattr(window, 'system_info') and window.system_info.is_steamdeck: + window.showMaximized() + else: + # Position window after showing (so size is finalized) + # Center horizontally, position near top (10% from top) to leave room for expansion + screen = QApplication.primaryScreen() + if screen: + screen_geometry = screen.availableGeometry() + window_size = window.size() + x = (screen_geometry.width() - window_size.width()) // 2 + y = int(screen_geometry.top() + (screen_geometry.height() * 0.1)) # 10% from top + window.move(x, y) + # Start background update check after window is shown window._check_for_updates_on_startup() diff --git a/jackify/frontends/gui/screens/additional_tasks.py b/jackify/frontends/gui/screens/additional_tasks.py index 32e3e52..5c6e603 100644 --- a/jackify/frontends/gui/screens/additional_tasks.py +++ b/jackify/frontends/gui/screens/additional_tasks.py @@ -17,6 +17,7 @@ from PySide6.QtGui import QFont from jackify.backend.models.configuration import SystemInfo from ..shared_theme import JACKIFY_COLOR_BLUE +from ..utils import set_responsive_minimum logger = logging.getLogger(__name__) @@ -35,8 +36,8 @@ class AdditionalTasksScreen(QWidget): def _setup_ui(self): """Set up the user interface following ModlistTasksScreen pattern""" layout = QVBoxLayout() - layout.setContentsMargins(40, 40, 40, 40) - layout.setSpacing(0) + layout.setContentsMargins(30, 30, 30, 30) # Reduced from 40 + layout.setSpacing(12) # Match main menu spacing # Header section self._setup_header(layout) @@ -50,55 +51,58 @@ class AdditionalTasksScreen(QWidget): def _setup_header(self, layout): """Set up the header section""" + header_widget = QWidget() header_layout = QVBoxLayout() - header_layout.setSpacing(0) - + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(2) + # Title title = QLabel("Additional Tasks & Tools") - title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") title.setAlignment(Qt.AlignHCenter) header_layout.addWidget(title) - # Add a spacer to match main menu vertical spacing - header_layout.addSpacing(16) - - # Description - desc = QLabel( - "TTW automation and additional tools.
 " - ) + header_layout.addSpacing(10) + + # Description area with fixed height + desc = QLabel("TTW automation and additional tools.") desc.setWordWrap(True) - desc.setStyleSheet("color: #ccc;") + 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(24) - - # Separator (shorter like main menu) + + header_layout.addSpacing(12) + + # Separator sep = QLabel() sep.setFixedHeight(2) sep.setFixedWidth(400) # Match button width sep.setStyleSheet("background: #fff;") header_layout.addWidget(sep, alignment=Qt.AlignHCenter) - - header_layout.addSpacing(16) - layout.addLayout(header_layout) + + header_layout.addSpacing(10) + + header_widget.setLayout(header_layout) + header_widget.setFixedHeight(120) # Fixed total header height + layout.addWidget(header_widget) def _setup_menu_buttons(self, layout): """Set up the menu buttons section""" # Menu options - ONLY TTW and placeholder MENU_ITEMS = [ - ("Install TTW", "ttw_install", "Install Tale of Two Wastelands using Hoolamike automation"), + ("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"), ("Coming Soon...", "coming_soon", "Additional tools will be added in future updates"), ("Return to Main Menu", "return_main_menu", "Go back to the main menu"), ] # Create grid layout for buttons (mirror ModlistTasksScreen pattern) button_grid = QGridLayout() - button_grid.setSpacing(16) + button_grid.setSpacing(12) # Reduced from 16 button_grid.setAlignment(Qt.AlignHCenter) button_width = 400 - button_height = 50 + button_height = 40 # Reduced from 50 for i, (label, action_id, description) in enumerate(MENU_ITEMS): # Create button @@ -109,8 +113,8 @@ class AdditionalTasksScreen(QWidget): background-color: #4a5568; color: white; border: none; - border-radius: 8px; - font-size: 14px; + border-radius: 6px; + font-size: 13px; font-weight: bold; text-align: center; }} @@ -126,7 +130,7 @@ class AdditionalTasksScreen(QWidget): # Description label desc_label = QLabel(description) desc_label.setAlignment(Qt.AlignHCenter) - desc_label.setStyleSheet("color: #999; font-size: 12px;") + desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px desc_label.setWordWrap(True) desc_label.setFixedWidth(button_width) @@ -166,4 +170,18 @@ class AdditionalTasksScreen(QWidget): def _return_to_main_menu(self): """Return to main menu""" if self.stacked_widget: - self.stacked_widget.setCurrentIndex(self.main_menu_index) \ No newline at end of file + self.stacked_widget.setCurrentIndex(self.main_menu_index) + + def showEvent(self, event): + """Called when the widget becomes visible - resize to compact size""" + super().showEvent(event) + 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 \ No newline at end of file diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py index 11456cf..1a31b4e 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -3,7 +3,11 @@ from PySide6.QtWidgets import * from PySide6.QtCore import * from PySide6.QtGui import * from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS -from ..utils import ansi_to_html +from ..utils import ansi_to_html, set_responsive_minimum +# Progress reporting components +from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator +from jackify.frontends.gui.widgets.file_progress_list import FileProgressList +from jackify.shared.progress_models import InstallationPhase, InstallationProgress import os import subprocess import sys @@ -50,25 +54,23 @@ class ConfigureExistingModlistScreen(QWidget): self.resolution_service = ResolutionService() self.config_handler = ConfigHandler() - # --- Fetch shortcuts for ModOrganizer.exe using existing backend functionality --- - # Use existing discover_executable_shortcuts which already filters by protontricks availability - 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 + # --- 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 = [] - 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" - } - self.mo2_shortcuts.append(shortcut) + 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) @@ -211,77 +213,104 @@ class ConfigureExistingModlistScreen(QWidget): self.start_btn = QPushButton("Start Configuration") btn_row.addWidget(self.start_btn) cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.go_back) + 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: Activity window (FileProgressList widget) + # Fixed size policy to prevent shrinking when window expands + self.file_progress_list.setMinimumSize(QSize(300, 20)) + self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + activity_widget = QWidget() + activity_layout = QVBoxLayout() + activity_layout.setContentsMargins(0, 0, 0, 0) + activity_layout.setSpacing(0) + activity_layout.addWidget(self.file_progress_list) + activity_widget.setLayout(activity_layout) + if self.debug: + activity_widget.setStyleSheet("border: 2px solid purple;") + activity_widget.setToolTip("ACTIVITY_WINDOW") + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(activity_widget, stretch=9) + + # Keep legacy process monitor hidden (for compatibility with existing code) 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) - if self.debug: - process_monitor_widget.setStyleSheet("border: 2px solid purple;") - process_monitor_widget.setToolTip("PROCESS_MONITOR") - upper_hbox.addWidget(user_config_widget, stretch=11) - upper_hbox.addWidget(process_monitor_widget, stretch=9) + self.process_monitor.setVisible(False) # Hidden in compact mode 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) - # Remove spacing - console should expand to fill available space + + # 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) # Very small minimum - can shrink to almost nothing - self.console.setMaximumHeight(1000) # Allow growth when space available + 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) # Limit height to make it more compact + 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) # Small gap between console and buttons - - console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space - console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container - + 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") - main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space + # 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 @@ -379,6 +408,88 @@ class ConfigureExistingModlistScreen(QWidget): 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 _safe_append_text(self, text): """Append text with professional auto-scroll behavior""" # Write all messages to log file @@ -420,7 +531,13 @@ class ConfigureExistingModlistScreen(QWidget): 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() @@ -560,7 +677,10 @@ class ConfigureExistingModlistScreen(QWidget): if success: # Calculate time taken time_taken = self._calculate_time_taken() - + + # Clear Activity window before showing success dialog + self.file_progress_list.clear() + # Show success dialog with celebration success_dialog = SuccessDialog( modlist_name=modlist_name, @@ -644,9 +764,188 @@ class ConfigureExistingModlistScreen(QWidget): 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([ @@ -693,43 +992,10 @@ class ConfigureExistingModlistScreen(QWidget): pass def refresh_modlist_list(self): - """Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts""" - try: - # Re-detect shortcuts 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 - self.mo2_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" - } - self.mo2_shortcuts.append(shortcut) - - # Clear and repopulate the combo box - self.shortcut_combo.clear() - 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) - - # Show feedback to user in UI only (don't write to log before workflow starts) - # Feedback is shown by the updated dropdown items - - except Exception as e: - # Don't write to log file before workflow starts - just show error in UI - MessageService.warning(self, "Refresh Error", f"Failed to refresh modlist list: {e}", safety_level="low") + """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""" diff --git a/jackify/frontends/gui/screens/configure_new_modlist.py b/jackify/frontends/gui/screens/configure_new_modlist.py index ea4a8d4..b317603 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist.py +++ b/jackify/frontends/gui/screens/configure_new_modlist.py @@ -1,11 +1,15 @@ """ ConfigureNewModlistScreen for Jackify GUI """ -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject from PySide6.QtGui import QPixmap, QTextCursor from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS -from ..utils import ansi_to_html +from ..utils import ansi_to_html, set_responsive_minimum +# Progress reporting components +from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator +from jackify.frontends.gui.widgets.file_progress_list import FileProgressList +from jackify.shared.progress_models import InstallationPhase, InstallationProgress import os import subprocess import sys @@ -44,17 +48,24 @@ class ModlistFetchThread(QThread): 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 = [sys.executable, self.cli_path, '--install-modlist', '--list-modlists', '--game-type', self.game_type] + cmd = [python_exe, self.cli_path, '--install-modlist', '--list-modlists', '--game-type', self.game_type] elif self.mode == 'install': - cmd = [sys.executable, 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] + 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") - proc = subprocess.Popen(cmd, cwd=self.project_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + # 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: @@ -112,10 +123,21 @@ class ConfigureNewModlistScreen(QWidget): # 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 @@ -271,79 +293,104 @@ class ConfigureNewModlistScreen(QWidget): self.start_btn = QPushButton("Start Configuration") btn_row.addWidget(self.start_btn) cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.go_back) + 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: process monitor (as before) + # Right: Activity window (FileProgressList widget) + # Fixed size policy to prevent shrinking when window expands + self.file_progress_list.setMinimumSize(QSize(300, 20)) + self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + activity_widget = QWidget() + activity_layout = QVBoxLayout() + activity_layout.setContentsMargins(0, 0, 0, 0) + activity_layout.setSpacing(0) + activity_layout.addWidget(self.file_progress_list) + activity_widget.setLayout(activity_layout) + if self.debug: + activity_widget.setStyleSheet("border: 2px solid purple;") + activity_widget.setToolTip("ACTIVITY_WINDOW") + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(activity_widget, stretch=9) + + # Keep legacy process monitor hidden (for compatibility with existing code) 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) - if self.debug: - process_monitor_widget.setStyleSheet("border: 2px solid purple;") - process_monitor_widget.setToolTip("PROCESS_MONITOR") - upper_hbox.addWidget(user_config_widget, stretch=11) - upper_hbox.addWidget(process_monitor_widget, stretch=9) + self.process_monitor.setVisible(False) # Hidden in compact mode 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) - # Remove spacing - console should expand to fill available space - # --- Console output area (full width, placeholder for now) --- + + # 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) # Very small minimum - can shrink to almost nothing - self.console.setMaximumHeight(1000) # Allow growth when space available + 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) # Limit height to make it more compact + 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) # Small gap between console and buttons - - console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space - console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container - + 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") - main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space + # 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) --- @@ -442,6 +489,87 @@ class ConfigureNewModlistScreen(QWidget): 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 _safe_append_text(self, text): """Append text with professional auto-scroll behavior""" # Write all messages to log file @@ -480,9 +608,62 @@ class ConfigureNewModlistScreen(QWidget): 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 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) + + # 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) + 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([ @@ -528,6 +709,9 @@ class ConfigureNewModlistScreen(QWidget): 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: @@ -582,7 +766,13 @@ class ConfigureNewModlistScreen(QWidget): # 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() @@ -1251,7 +1441,10 @@ class ConfigureNewModlistScreen(QWidget): if success: # Calculate time taken time_taken = self._calculate_time_taken() - + + # Clear Activity window before showing success dialog + self.file_progress_list.clear() + # Show success dialog with celebration success_dialog = SuccessDialog( modlist_name=modlist_name, diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py index 67547dc..5e0360d 100644 --- a/jackify/frontends/gui/screens/install_modlist.py +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -5,12 +5,13 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayo 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 +from ..utils import ansi_to_html, set_responsive_minimum from ..widgets.unsupported_game_dialog import UnsupportedGameDialog import os import subprocess import sys import threading +from typing import Optional from jackify.backend.handlers.shortcut_handler import ShortcutHandler from jackify.backend.handlers.wabbajack_parser import WabbajackParser import traceback @@ -24,6 +25,14 @@ from ..dialogs import SuccessDialog from jackify.backend.handlers.validation_handler import ValidationHandler from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog from jackify.frontends.gui.services.message_service import MessageService +from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator +# R&D: Progress reporting components +from jackify.backend.handlers.progress_parser import ProgressStateManager +from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator +from jackify.frontends.gui.widgets.file_progress_list import FileProgressList +from jackify.shared.progress_models import InstallationPhase, InstallationProgress +# Modlist gallery (imported at module level to avoid import delay when opening dialog) +from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog def debug_print(message): """Print debug message only if debug mode is enabled""" @@ -347,11 +356,17 @@ class SelectionDialog(QDialog): class InstallModlistScreen(QWidget): 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} @@ -360,10 +375,12 @@ class InstallModlistScreen(QWidget): # 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() @@ -372,16 +389,46 @@ class InstallModlistScreen(QWidget): 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._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;") @@ -416,13 +463,18 @@ class InstallModlistScreen(QWidget): 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; }") + 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") @@ -505,56 +557,33 @@ class InstallModlistScreen(QWidget): 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 API Key - api_key_label = QLabel("Nexus API Key:") - self.api_key_edit = QLineEdit() - self.api_key_edit.setMaximumHeight(25) # Force compact height - # Services already initialized above - # Set up obfuscation timer and state - self.api_key_obfuscation_timer = QTimer(self) - self.api_key_obfuscation_timer.setSingleShot(True) - self.api_key_obfuscation_timer.timeout.connect(self._obfuscate_api_key) - self.api_key_original_text = "" - self.api_key_is_obfuscated = False - # Connect events for obfuscation - self.api_key_edit.textChanged.connect(self._on_api_key_text_changed) - self.api_key_edit.focusInEvent = self._on_api_key_focus_in - self.api_key_edit.focusOutEvent = self._on_api_key_focus_out - # Load saved API key if available - saved_key = self.api_key_service.get_saved_api_key() - if saved_key: - self.api_key_original_text = saved_key # Set original text first - self.api_key_edit.setText(saved_key) - self._obfuscate_api_key() # Immediately obfuscate saved keys - form_grid.addWidget(api_key_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addWidget(self.api_key_edit, 3, 1) - # API Key save checkbox and info (row 4) - api_save_layout = QHBoxLayout() - api_save_layout.setContentsMargins(0, 0, 0, 0) - api_save_layout.setSpacing(8) - self.save_api_key_checkbox = QCheckBox("Save API Key") - self.save_api_key_checkbox.setChecked(self.api_key_service.has_saved_api_key()) - self.save_api_key_checkbox.toggled.connect(self._on_api_key_save_toggled) - api_save_layout.addWidget(self.save_api_key_checkbox, alignment=Qt.AlignTop) - - # Validate button removed - validation now happens silently on save checkbox toggle - api_info = QLabel( - 'Storing your API Key locally is done so at your own risk.
' - 'You can get your API key at: ' - 'https://www.nexusmods.com/users/myaccount?tab=api
' - ) - api_info.setOpenExternalLinks(False) - api_info.linkActivated.connect(self._open_url_safe) - api_info.setWordWrap(True) - api_info.setAlignment(Qt.AlignLeft) - api_save_layout.addWidget(api_info, stretch=1) - api_save_widget = QWidget() - api_save_widget.setLayout(api_save_layout) - # Remove height constraint to prevent text truncation - if self.debug: - api_save_widget.setStyleSheet("border: 2px solid blue;") - api_save_widget.setToolTip("API_KEY_SECTION") - form_grid.addWidget(api_save_widget, 4, 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() @@ -626,10 +655,10 @@ class InstallModlistScreen(QWidget): form_grid.addLayout(resolution_and_restart_layout, 5, 1) form_section_widget = QWidget() - form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) form_section_widget.setLayout(form_grid) - form_section_widget.setMinimumHeight(220) # Increased to allow RED API key box proper height - form_section_widget.setMaximumHeight(240) # Increased to allow RED API key box proper height + # 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") @@ -661,12 +690,14 @@ class InstallModlistScreen(QWidget): 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.Expanding) # Allow vertical expansion to fill space + 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: process monitor (as before) + # 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) @@ -682,26 +713,84 @@ class InstallModlistScreen(QWidget): 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") - upper_hbox.addWidget(user_config_widget, stretch=1) - upper_hbox.addWidget(process_monitor_widget, stretch=3) - upper_hbox.setAlignment(Qt.AlignTop) + # 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) - upper_section_widget.setMaximumHeight(320) # Increased to ensure resolution dropdown is visible + # 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) - self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing - self.console.setMaximumHeight(1000) # Allow growth when space available + # 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;") @@ -714,16 +803,25 @@ class InstallModlistScreen(QWidget): 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) # Small gap between console and buttons + console_and_buttons_layout.setSpacing(0) # No spacing - console is hidden initially - console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space + # 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") - main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space + # 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 = [] @@ -765,7 +863,6 @@ class InstallModlistScreen(QWidget): self.modlist_name_edit, self.install_dir_edit, self.downloads_dir_edit, - self.api_key_edit, self.file_edit, # Browse buttons self.browse_install_btn, @@ -773,8 +870,9 @@ class InstallModlistScreen(QWidget): self.file_btn, # Resolution controls self.resolution_combo, + # Nexus login button + self.nexus_login_btn, # Checkboxes - self.save_api_key_checkbox, self.auto_restart_checkbox, ] @@ -790,6 +888,20 @@ class InstallModlistScreen(QWidget): if control: control.setEnabled(True) + def _abort_install_validation(self): + """Reset UI state when validation is aborted early.""" + self._enable_controls_after_operation() + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + self.progress_indicator.reset() + self.process_monitor.clear() + + def _abort_with_message(self, level: str, title: str, message: str, **kwargs): + """Show a message and abort the validation workflow.""" + messenger = getattr(MessageService, level, MessageService.warning) + messenger(self, title, message, **kwargs) + self._abort_install_validation() + def refresh_paths(self): """Refresh cached paths when config changes.""" from jackify.shared.paths import get_jackify_logs_dir @@ -797,7 +909,7 @@ class InstallModlistScreen(QWidget): os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) def _open_url_safe(self, url): - """Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller""" + """Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime""" import subprocess try: subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) @@ -817,24 +929,160 @@ class InstallModlistScreen(QWidget): self.console.setMinimumHeight(50) # Keep minimum height for usability def showEvent(self, event): - """Called when the widget becomes visible - always reload saved API key""" + """Called when the widget becomes visible - ensure collapsed state""" super().showEvent(event) - # Always reload saved API key to pick up changes from Settings dialog - saved_key = self.api_key_service.get_saved_api_key() - if saved_key: - self.api_key_original_text = saved_key - self.api_key_edit.setText(saved_key) - self.api_key_is_obfuscated = False # Start unobfuscated - # Set checkbox state - self.save_api_key_checkbox.setChecked(True) - # Immediately obfuscate saved keys (don't wait 3 seconds) - self._obfuscate_api_key() - elif not self.api_key_edit.text().strip(): - # Only clear if no saved key and field is empty - self.api_key_original_text = "" - self.save_api_key_checkbox.setChecked(False) + + # Refresh Nexus auth status when screen becomes visible + # This ensures auth status is updated after user completes OAuth from Settings menu + 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 + + # Ensure initial collapsed layout each time this screen is opened (like TTW screen) + 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(_Qt.Unchecked) + # Force the window to compact height + main_window = self.window() + if main_window: + # Save original geometry once + if self._saved_geometry is None: + self._saved_geometry = main_window.geometry() + 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 + from PySide6.QtCore import QTimer, QSize + from PySide6.QtWidgets import QApplication + + def calculate_and_set_upper_section_height(): + """Calculate and lock the upper section height based on left side only""" + try: + if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: + # Only calculate if we haven't stored it yet + if not hasattr(self, '_upper_section_fixed_height') or self._upper_section_fixed_height is None: + # Calculate height based on LEFT side (user_config_widget) only + if hasattr(self, 'user_config_widget') and self.user_config_widget is not None: + # Force layout updates to ensure everything is calculated + self.user_config_widget.updateGeometry() + self.user_config_widget.layout().update() + self.updateGeometry() + self.layout().update() + QApplication.processEvents() + # Get the natural height of the left side + left_height = self.user_config_widget.sizeHint().height() + # Add a small margin for spacing + self._upper_section_fixed_height = left_height + 20 + else: + # Fallback: use sizeHint of upper section + self.upper_section_widget.updateGeometry() + self._upper_section_fixed_height = self.upper_section_widget.sizeHint().height() + # Lock the height - same in both modes + self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height) + self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) + except Exception as e: + if self.debug: + print(f"DEBUG: Error calculating upper section height: {e}") + pass + + # Calculate heights immediately after forcing layout update + # This prevents visible layout shift + self.updateGeometry() + self.layout().update() + QApplication.processEvents() + + # Calculate upper section height immediately + calculate_and_set_upper_section_height() + + # Only set minimum size - DO NOT RESIZE + 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.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: + debug_print(f"DEBUG: showEvent exception: {e}") + + def _start_gallery_cache_preload(self): + """DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow""" + # Only start once per session + if self._gallery_cache_preload_started: + return + + self._gallery_cache_preload_started = True + + # Create background thread to preload gallery cache + class GalleryCachePreloadThread(QThread): + finished_signal = Signal(bool, str) # success, message + + def run(self): + try: + from jackify.backend.services.modlist_gallery_service import ModlistGalleryService + service = ModlistGalleryService() + + # Fetch with search index to build cache (this will take time but is invisible) + # Use force_refresh=False to allow using existing cache if it has mods + 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 it has mods, otherwise fetch fresh + ) + + if metadata: + # Check if we got mods + modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods) + if modlists_with_mods > 0: + debug_print(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)") + else: + # Cache didn't have mods, but we fetched fresh - should have mods now + debug_print("DEBUG: Gallery cache updated") + else: + debug_print("DEBUG: Failed to load gallery cache") + + except Exception as e: + debug_print(f"DEBUG: Gallery cache preload error: {str(e)}") + + # Start thread (non-blocking, invisible to user) + self._gallery_cache_preload_thread = GalleryCachePreloadThread() + # Don't connect finished signal - we don't need to do anything, just let it run + self._gallery_cache_preload_thread.start() + + debug_print("DEBUG: Started background gallery cache preload") + + def hideEvent(self, event): + """Called when the widget is hidden - restore window size constraints""" + 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.""" pass @@ -866,62 +1114,267 @@ class InstallModlistScreen(QWidget): """Removed automatic saving - user should set defaults in settings""" pass - def _on_api_key_text_changed(self, text): - """Handle API key text changes for obfuscation timing""" - if not self.api_key_is_obfuscated: - self.api_key_original_text = text - # Restart the obfuscation timer (3 seconds after last change) - self.api_key_obfuscation_timer.stop() - if text.strip(): # Only start timer if there's actual text - self.api_key_obfuscation_timer.start(3000) # 3 seconds - else: - # If currently obfuscated and user is typing/pasting, un-obfuscate - if text != self.api_key_service.get_api_key_display(self.api_key_original_text): - self.api_key_is_obfuscated = False - self.api_key_original_text = text - if text.strip(): - self.api_key_obfuscation_timer.start(3000) - - def _on_api_key_focus_in(self, event): - """Handle API key field gaining focus - de-obfuscate if needed""" - # Call the original focusInEvent first - QLineEdit.focusInEvent(self.api_key_edit, event) - if self.api_key_is_obfuscated: - self.api_key_edit.blockSignals(True) - self.api_key_edit.setText(self.api_key_original_text) - self.api_key_is_obfuscated = False - self.api_key_edit.blockSignals(False) - self.api_key_obfuscation_timer.stop() + def _update_nexus_status(self): + """Update the Nexus login status display""" + authenticated, method, username = self.auth_service.get_auth_status() - def _on_api_key_focus_out(self, event): - """Handle API key field losing focus - immediately obfuscate""" - QLineEdit.focusOutEvent(self.api_key_edit, event) - self._obfuscate_api_key() - - def _obfuscate_api_key(self): - """Obfuscate the API key text field""" - if not self.api_key_is_obfuscated and self.api_key_original_text.strip(): - # Block signals to prevent recursion - self.api_key_edit.blockSignals(True) - # Show masked version - masked_text = self.api_key_service.get_api_key_display(self.api_key_original_text) - self.api_key_edit.setText(masked_text) - self.api_key_is_obfuscated = True - # Re-enable signals - self.api_key_edit.blockSignals(False) - - def _get_actual_api_key(self): - """Get the actual API key value (not the obfuscated version)""" - if self.api_key_is_obfuscated: - return self.api_key_original_text + 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: - return self.api_key_edit.text() + # 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) - self.fetch_modlists_for_game_type(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 @@ -1013,32 +1466,61 @@ class InstallModlistScreen(QWidget): self.modlist_btn.setEnabled(False) def open_modlist_dialog(self): - if not self.current_modlist_display: + # 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 - dlg = SelectionDialog("Select Modlist", self.current_modlist_display, self, show_search=True, placeholder_text="Search modlists...", show_legend=True) - if dlg.exec() == QDialog.Accepted and dlg.selected_item: - modlist_id = self._modlist_id_map.get(dlg.selected_item, dlg.selected_item) - self.modlist_btn.setText(modlist_id) - # Fetch and store the full ModlistInfo for unsupported game detection - try: - from jackify.backend.services.modlist_service import ModlistService - from jackify.backend.models.configuration import SystemInfo - 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) - all_modlists = modlist_service.list_modlists() - selected_info = next((m for m in all_modlists if m.id == modlist_id), None) - self.selected_modlist_info = selected_info.to_dict() if selected_info else None - - # Auto-populate the Modlist Name field with the display name (user can still modify) - if selected_info and selected_info.name: - self.modlist_name_edit.setText(selected_info.name) - except Exception as e: - self.selected_modlist_info = None + + 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) + 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)") @@ -1056,6 +1538,24 @@ class InstallModlistScreen(QWidget): 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) @@ -1091,6 +1591,9 @@ class InstallModlistScreen(QWidget): 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: @@ -1256,11 +1759,22 @@ class InstallModlistScreen(QWidget): 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') - time_str = getattr(self, '_elapsed_time_str', '0m 0s') game_name = "Fallout New Vegas" + # Clear Activity window before showing success dialog + self.file_progress_list.clear() + # Show enhanced success dialog success_dialog = SuccessDialog( modlist_name=modlist_name, @@ -1284,86 +1798,23 @@ class InstallModlistScreen(QWidget): f"TTW integration completed but failed to show success dialog: {str(e)}" ) - def _on_api_key_save_toggled(self, checked): - """Handle immediate API key saving with silent validation when checkbox is toggled""" - try: - if checked: - # Save API key if one is entered - api_key = self._get_actual_api_key().strip() - if api_key: - # Silently validate API key first - is_valid, validation_message = self.api_key_service.validate_api_key_works(api_key) - if not is_valid: - # Show error dialog for invalid API key - from jackify.frontends.gui.services.message_service import MessageService - MessageService.critical( - self, - "Invalid API Key", - f"The API key is invalid and cannot be saved.\n\nError: {validation_message}", - safety_level="low" - ) - self.save_api_key_checkbox.setChecked(False) # Uncheck on validation failure - return - - # API key is valid, proceed with saving - success = self.api_key_service.save_api_key(api_key) - if success: - self._show_api_key_feedback("✓ API key saved successfully", is_success=True) - debug_print("DEBUG: API key validated and saved immediately on checkbox toggle") - else: - self._show_api_key_feedback("✗ Failed to save API key - check permissions", is_success=False) - # Uncheck the checkbox since save failed - self.save_api_key_checkbox.setChecked(False) - debug_print("DEBUG: Failed to save API key immediately") - else: - self._show_api_key_feedback("Enter an API key first", is_success=False) - # Uncheck the checkbox since no key to save - self.save_api_key_checkbox.setChecked(False) - else: - # Clear saved API key when unchecked - if self.api_key_service.has_saved_api_key(): - success = self.api_key_service.clear_saved_api_key() - if success: - self._show_api_key_feedback("✓ API key cleared", is_success=True) - debug_print("DEBUG: Saved API key cleared immediately on checkbox toggle") - else: - self._show_api_key_feedback("✗ Failed to clear API key", is_success=False) - debug_print("DEBUG: Failed to clear API key") - except Exception as e: - self._show_api_key_feedback(f"✗ Error: {str(e)}", is_success=False) - self.save_api_key_checkbox.setChecked(False) - debug_print(f"DEBUG: Error in _on_api_key_save_toggled: {e}") - - def _show_api_key_feedback(self, message, is_success=True): - """Show temporary feedback message for API key operations""" - # Use tooltip for immediate feedback - color = "#22c55e" if is_success else "#ef4444" # Green for success, red for error - self.save_api_key_checkbox.setToolTip(message) - - # Temporarily change checkbox style to show feedback - original_style = self.save_api_key_checkbox.styleSheet() - feedback_style = f"QCheckBox {{ color: {color}; font-weight: bold; }}" - self.save_api_key_checkbox.setStyleSheet(feedback_style) - - # Reset style and tooltip after 3 seconds - from PySide6.QtCore import QTimer - def reset_feedback(): - self.save_api_key_checkbox.setStyleSheet(original_style) - self.save_api_key_checkbox.setToolTip("") - - QTimer.singleShot(3000, reset_feedback) - + 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) @@ -1375,28 +1826,51 @@ class InstallModlistScreen(QWidget): 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'): - MessageService.warning(self, "Invalid Modlist", "Please select a valid .wabbajack file.") - self._enable_controls_after_operation() + self._abort_with_message( + "warning", + "Invalid Modlist", + "Please select a valid .wabbajack file." + ) return install_mode = 'file' else: - modlist = self.modlist_btn.text().strip() - if not modlist or modlist in ("Select Modlist", "Fetching modlists...", "No modlists found.", "Error fetching modlists."): - MessageService.warning(self, "Invalid Modlist", "Please select a valid modlist.") - self._enable_controls_after_operation() + # 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 - # For online modlists, use machine_url instead of display name - if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: - machine_url = self.selected_modlist_info.get('machine_url') - if machine_url: - modlist = machine_url # Use machine URL for installation - debug_print(f"DEBUG: Using machine_url for installation: {machine_url}") - else: - debug_print("DEBUG: No machine_url found in selected_modlist_info, using display name") + 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() - api_key = self._get_actual_api_key().strip() + + # Get authentication token (OAuth or API key) with automatic refresh + api_key = self.auth_service.ensure_valid_auth() + 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 + modlist_name = self.modlist_name_edit.text().strip() missing_fields = [] if not modlist_name: @@ -1405,18 +1879,21 @@ class InstallModlistScreen(QWidget): missing_fields.append("Install Directory") if not downloads_dir: missing_fields.append("Downloads Directory") - if not api_key: - missing_fields.append("Nexus API Key") 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() + 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) - if not dlg.exec() or not dlg.confirmed: + 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?", @@ -1428,8 +1905,10 @@ class InstallModlistScreen(QWidget): 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?", @@ -1441,28 +1920,12 @@ class InstallModlistScreen(QWidget): 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 API key saving BEFORE validation (to match settings dialog behavior) - if self.save_api_key_checkbox.isChecked(): - if api_key: - success = self.api_key_service.save_api_key(api_key) - if success: - debug_print("DEBUG: API key saved successfully") - else: - debug_print("DEBUG: Failed to save API key") - else: - # If checkbox is unchecked, clear any saved API key - if self.api_key_service.has_saved_api_key(): - self.api_key_service.clear_saved_api_key() - debug_print("DEBUG: Saved API key cleared") - - # Validate API key for installation purposes - if not api_key or not self.api_key_service._validate_api_key_format(api_key): - MessageService.warning(self, "Invalid API Key", "Please enter a valid Nexus API Key.") - return - + # Handle resolution saving resolution = self.resolution_combo.currentText() if resolution and resolution != "Leave unchanged": @@ -1560,18 +2023,50 @@ class InstallModlistScreen(QWidget): # Show unsupported game dialog dialog = UnsupportedGameDialog(self, game_name) if not dialog.show_dialog(self, game_name): - # User cancelled + 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._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) - debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, api_key={api_key[:6]}..., install_mode={install_mode}') + # 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) except Exception as e: debug_print(f"DEBUG: Exception in validate_and_start_install: {e}") @@ -1607,9 +2102,11 @@ class InstallModlistScreen(QWidget): 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'): + def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name, install_mode='online', progress_state_manager=None): super().__init__() self.modlist = modlist self.install_dir = install_dir @@ -1619,6 +2116,9 @@ class InstallModlistScreen(QWidget): 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._premium_signal_sent = False def cancel(self): self.cancelled = True @@ -1628,10 +2128,26 @@ class InstallModlistScreen(QWidget): 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", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] + cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] else: - cmd = [engine_path, "install", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] + 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 @@ -1640,8 +2156,20 @@ class InstallModlistScreen(QWidget): if debug_mode: cmd.append('--debug') debug_print("DEBUG: Added --debug flag to jackify-engine command") - env = os.environ.copy() - env['NEXUS_API_KEY'] = self.api_key + + # Check GPU setting and add --no-gpu flag if disabled + gpu_enabled = config_handler.get('enable_gpu_texture_conversion', True) + if not gpu_enabled: + cmd.append('--no-gpu') + debug_print("DEBUG: Added --no-gpu flag (GPU texture conversion disabled)") + + # 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 = get_clean_subprocess_env({'NEXUS_API_KEY': self.api_key}) self.process_manager = ProcessManager(cmd, env=env, text=False) ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') buffer = b'' @@ -1659,30 +2187,114 @@ class InstallModlistScreen(QWidget): line, buffer = buffer.split(b'\r', 1) line = ansi_escape.sub(b'', line) decoded = line.decode('utf-8', errors='replace') - self.progress_received.emit(decoded) + + # 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) + if not self._premium_signal_sent and is_non_premium_indicator(decoded): + self._premium_signal_sent = True + self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") + + # 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 + if not self._premium_signal_sent and is_non_premium_indicator(decoded): + self._premium_signal_sent = True + self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") + + # 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('') + self.output_received.emit('\n') last_was_blank = True else: - self.output_received.emit(decoded) + # 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') - self.output_received.emit(decoded) - self.process_manager.wait() + # 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 self.process_manager.proc.returncode == 0: + elif returncode == 0: self.installation_finished.emit(True, "Installation completed successfully") else: - self.installation_finished.emit(False, "Installation failed") + 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: @@ -1691,11 +2303,16 @@ class InstallModlistScreen(QWidget): # 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 + 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 ) 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): @@ -1705,25 +2322,401 @@ class InstallModlistScreen(QWidget): # Log internal messages to file but don't show in console self._write_to_log_file(message) return + + # Detect known engine bugs and provide helpful guidance + msg_lower = message.lower() + 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): - """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 + """ + 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 + + # 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 + from jackify.shared.progress_models import FileProgress, OperationType + + 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 + self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None) + else: + # Show empty state so widget stays visible even when no files are active + self.file_progress_list.update_files([], current_phase=phase_label) + + 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: - self._safe_append_text(f"\nSuccess: {message}") + # 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: - self._safe_append_text(f"\nError: {message}") + # 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): @@ -1784,13 +2777,29 @@ class InstallModlistScreen(QWidget): # Re-enable controls since operation is complete self._enable_controls_after_operation() else: - # Check for user cancellation first - last_output = self.console.toPlainText() - if "cancelled by user" in last_output.lower(): + # 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: - 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}).") + # 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): @@ -1820,6 +2829,234 @@ class InstallModlistScreen(QWidget): 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': 'config_finalize', + 'label': "Finalising Jackify configuration", + 'keywords': [ + "configuration completed successfully", + "configuration complete", + "manual steps validation failed", + "configuration failed" + ], + }, + ] + + 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 + for idx, step in enumerate(self._post_install_sequence, start=1): + if any(keyword in normalized for keyword in step['keywords']): + matched = True + if idx >= self._post_install_current_step: + self._post_install_current_step = idx + self._post_install_last_label = step['label'] + self._update_post_install_ui(step['label'], idx, total, detail=text) + else: + self._update_post_install_ui(step['label'], idx, total, detail=text) + break + + if not matched and self._post_install_current_step > 0: + label = self._post_install_last_label or "Post-installation" + 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.""" + display_label = label + if detail: + # Remove timestamp prefix from detail messages + clean_detail = self._strip_timestamp_prefix(detail.strip()) + if clean_detail: + 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 + progress_state = InstallationProgress( + phase=InstallationPhase.FINALIZE, + phase_name=display_label, + phase_step=step_clamped, + phase_max_steps=total, + overall_percent=overall_percent + ) + self.progress_indicator.update_progress(progress_state) + summary_info = { + 'current_step': step_clamped, + 'max_steps': total, + } + 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() @@ -1827,22 +3064,36 @@ class InstallModlistScreen(QWidget): self._user_manually_scrolled = False def _safe_append_text(self, text): - """Append text with professional auto-scroll behavior""" + """ + 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(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 \ @@ -1852,6 +3103,79 @@ class InstallModlistScreen(QWidget): 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: @@ -1912,11 +3236,38 @@ class InstallModlistScreen(QWidget): # 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}") + # 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)") @@ -1927,7 +3278,7 @@ class InstallModlistScreen(QWidget): 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...") @@ -1983,6 +3334,8 @@ class InstallModlistScreen(QWidget): 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 @@ -2107,6 +3460,7 @@ class InstallModlistScreen(QWidget): "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 @@ -2118,14 +3472,17 @@ class InstallModlistScreen(QWidget): 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""" @@ -2154,8 +3511,11 @@ class InstallModlistScreen(QWidget): def on_configuration_complete(self, success, message, modlist_name): """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() + self._end_post_install_feedback(success) if success: # Check if we need to show Somnium guidance @@ -2189,12 +3549,15 @@ class InstallModlistScreen(QWidget): self, "Install TTW?", f"{modlist_name} requires Tale of Two Wastelands!\n\n" - "Would you like to install and configure TTW automatically now?\n\n" + "Would you like to install TTW now?\n\n" "This will:\n" "• Guide you through TTW installation\n" - "• Automatically integrate TTW into your modlist\n" - "• Configure load order correctly\n\n" - "Note: TTW installation can take a while. You can also install TTW later from Additional Tasks & Tools.", + "• 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" ) @@ -2204,6 +3567,9 @@ class InstallModlistScreen(QWidget): self._initiate_ttw_workflow(modlist_name, install_dir) return # Don't show success dialog yet, will show after TTW completes + # Clear Activity window before showing success dialog + self.file_progress_list.clear() + # Show normal success dialog success_dialog = SuccessDialog( modlist_name=modlist_name, @@ -2860,40 +4226,57 @@ class InstallModlistScreen(QWidget): if reply == QMessageBox.Yes: self._safe_append_text("\n🛑 Cancelling installation...") - - # Cancel the installation thread if it exists - if hasattr(self, 'install_thread') and self.install_thread.isRunning(): - self.install_thread.cancel() - self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown - if self.install_thread.isRunning(): - self.install_thread.terminate() # Force terminate if needed - self.install_thread.wait(1000) - - # Cancel the automated prefix thread if it exists - if hasattr(self, 'prefix_thread') and self.prefix_thread.isRunning(): - self.prefix_thread.terminate() - self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown - if self.prefix_thread.isRunning(): - self.prefix_thread.terminate() # Force terminate if needed - self.prefix_thread.wait(1000) - - # Cancel the configuration thread if it exists - if hasattr(self, 'config_thread') and self.config_thread.isRunning(): - self.config_thread.terminate() - self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown - if self.config_thread.isRunning(): - self.config_thread.terminate() # Force terminate if needed - self.config_thread.wait(1000) - - # Cleanup any remaining processes - self.cleanup_processes() - - # Reset button states and re-enable all controls - self._enable_controls_after_operation() - self.cancel_btn.setVisible(True) - self.cancel_install_btn.setVisible(False) - - self._safe_append_text("Installation cancelled by user.") + + # Set flag so we can detect cancellation reliably + self._cancellation_requested = True + + try: + # Clear Active Files window and reset progress indicator + if hasattr(self, 'file_progress_list'): + self.file_progress_list.clear() + if hasattr(self, 'progress_indicator'): + self.progress_indicator.reset() + + # Cancel the installation thread if it exists + if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning(): + self.install_thread.cancel() + self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown + if self.install_thread.isRunning(): + self.install_thread.terminate() # Force terminate if needed + self.install_thread.wait(1000) + + # Cancel the automated prefix thread if it exists + if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning(): + self.prefix_thread.terminate() + self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown + if self.prefix_thread.isRunning(): + self.prefix_thread.terminate() # Force terminate if needed + self.prefix_thread.wait(1000) + + # Cancel the configuration thread if it exists + if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): + self.config_thread.terminate() + self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown + if self.config_thread.isRunning(): + self.config_thread.terminate() # Force terminate if needed + self.config_thread.wait(1000) + + # Cleanup any remaining processes + self.cleanup_processes() + + # Reset button states and re-enable all controls + self._enable_controls_after_operation() + self.cancel_btn.setVisible(True) + self.cancel_install_btn.setVisible(False) + + except Exception as e: + debug_print(f"ERROR: Exception during cancellation cleanup: {e}") + import traceback + traceback.print_exc() + + finally: + # Always write cancellation message to console so detection works + self._safe_append_text("Installation cancelled by user.") def _show_somnium_post_install_guidance(self): """Show guidance popup for Somnium post-installation steps""" diff --git a/jackify/frontends/gui/screens/install_ttw.py b/jackify/frontends/gui/screens/install_ttw.py index dd6404d..b1f09d9 100644 --- a/jackify/frontends/gui/screens/install_ttw.py +++ b/jackify/frontends/gui/screens/install_ttw.py @@ -6,8 +6,9 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayo from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl from PySide6.QtGui import QPixmap, QTextCursor, QPainter, QFont from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS -from ..utils import ansi_to_html, strip_ansi_control_codes +from ..utils import ansi_to_html, strip_ansi_control_codes, set_responsive_minimum from ..widgets.unsupported_game_dialog import UnsupportedGameDialog +from jackify.frontends.gui.widgets.file_progress_list import FileProgressList import os import subprocess import sys @@ -121,38 +122,41 @@ class InstallTTWScreen(QWidget): main_overall_vbox = QVBoxLayout(self) main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - # Tighter outer margins and reduced inter-section spacing - main_overall_vbox.setContentsMargins(20, 12, 20, 0) - main_overall_vbox.setSpacing(6) + # 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.setSpacing(1) # Reduce spacing between title and description - # Title (no logo) + 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}; margin: 0px; padding: 0px;") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") title.setAlignment(Qt.AlignHCenter) - title.setMaximumHeight(30) # Force compact height header_layout.addWidget(title) - - - # Description + + header_layout.addSpacing(10) + + # Description area with fixed height desc = QLabel( - "This screen allows you to install Tale of Two Wastelands (TTW) using the Hoolamike tool. " + "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; margin: 0px; padding: 0px; line-height: 1.2;") + desc.setStyleSheet("color: #ccc; font-size: 13px;") desc.setAlignment(Qt.AlignHCenter) - desc.setMaximumHeight(40) # Force compact height for description + desc.setMaximumHeight(50) # Fixed height for description zone header_layout.addWidget(desc) - header_widget = QWidget() + + header_layout.addSpacing(12) + header_widget.setLayout(header_layout) - # Keep header compact - header_widget.setMaximumHeight(90) - # Remove height constraint to allow status banner to show + 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") @@ -209,24 +213,24 @@ class InstallTTWScreen(QWidget): form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(install_dir_hbox, 1, 1) - # --- Hoolamike Status aligned in form grid (row 2) --- - hoolamike_label = QLabel("Hoolamike Status:") - self.hoolamike_status = QLabel("Checking...") - self.hoolamike_btn = QPushButton("Install now") - self.hoolamike_btn.setStyleSheet(""" + # --- 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.hoolamike_btn.setVisible(False) - self.hoolamike_btn.clicked.connect(self.install_hoolamike) - hoolamike_hbox = QHBoxLayout() - hoolamike_hbox.setContentsMargins(0, 0, 0, 0) - hoolamike_hbox.setSpacing(8) - hoolamike_hbox.addWidget(self.hoolamike_status) - hoolamike_hbox.addWidget(self.hoolamike_btn) - hoolamike_hbox.addStretch() - form_grid.addWidget(hoolamike_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - form_grid.addLayout(hoolamike_hbox, 2, 1) + 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:") @@ -247,7 +251,7 @@ class InstallTTWScreen(QWidget): form_group.setLayout(form_grid) user_config_vbox.addWidget(form_group) - # (Hoolamike and Game Requirements now aligned in form_grid above) + # (TTW_Linux_Installer and Game Requirements now aligned in form_grid above) # --- Buttons --- btn_row = QHBoxLayout() @@ -277,6 +281,7 @@ class InstallTTWScreen(QWidget): 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) @@ -296,40 +301,40 @@ class InstallTTWScreen(QWidget): 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) # Allow vertical expansion to fill space + 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: process monitor (as before) + + # Right: Activity window (FileProgressList widget) + # Fixed size policy to prevent shrinking when window expands + self.file_progress_list = FileProgressList() + self.file_progress_list.setMinimumSize(QSize(300, 20)) + self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + activity_widget = QWidget() + activity_layout = QVBoxLayout() + activity_layout.setContentsMargins(0, 0, 0, 0) + activity_layout.setSpacing(0) + activity_layout.addWidget(self.file_progress_list) + activity_widget.setLayout(activity_layout) + if self.debug: + activity_widget.setStyleSheet("border: 2px solid purple;") + activity_widget.setToolTip("ACTIVITY_WINDOW") + + upper_hbox.addWidget(user_config_widget, stretch=11) + upper_hbox.addWidget(activity_widget, stretch=9) + + # Keep legacy process monitor hidden (for compatibility with existing code) 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) - if self.debug: - process_monitor_widget.setStyleSheet("border: 2px solid purple;") - process_monitor_widget.setToolTip("PROCESS_MONITOR") - upper_hbox.addWidget(user_config_widget, stretch=1) - upper_hbox.addWidget(process_monitor_widget, stretch=3) + self.process_monitor.setVisible(False) upper_hbox.setAlignment(Qt.AlignTop) self.upper_section_widget = QWidget() self.upper_section_widget.setLayout(upper_hbox) - # Keep the top section tightly wrapped to its content height - try: - self.upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.upper_section_widget.setMaximumHeight(self.upper_section_widget.sizeHint().height()) - except Exception: - pass + # 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") @@ -361,6 +366,8 @@ class InstallTTWScreen(QWidget): 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 @@ -411,7 +418,6 @@ class InstallTTWScreen(QWidget): def check_requirements(self): """Check and display requirements status""" from jackify.backend.handlers.path_handler import PathHandler - from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.models.configuration import SystemInfo @@ -440,10 +446,10 @@ class InstallTTWScreen(QWidget): # Update Start button state after checking requirements self._update_start_button_state() - def _check_hoolamike_status(self): - """Check Hoolamike installation status and update UI""" + def _check_ttw_installer_status(self): + """Check TTW_Linux_Installer installation status and update UI""" try: - from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler + 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 @@ -452,124 +458,172 @@ class InstallTTWScreen(QWidget): filesystem_handler = FileSystemHandler() config_handler = ConfigHandler() system_info = SystemInfo(is_steamdeck=False) - hoolamike_handler = HoolamikeHandler( + ttw_installer_handler = TTWInstallerHandler( steamdeck=False, verbose=False, filesystem_handler=filesystem_handler, config_handler=config_handler ) - # Check if Hoolamike is installed - hoolamike_handler._check_hoolamike_installation() + # Check if TTW_Linux_Installer is installed + ttw_installer_handler._check_installation() - if hoolamike_handler.hoolamike_installed: + if ttw_installer_handler.ttw_installer_installed: # Check version against latest - update_available, installed_v, latest_v = hoolamike_handler.is_hoolamike_update_available() + update_available, installed_v, latest_v = ttw_installer_handler.is_ttw_installer_update_available() if update_available: - self.hoolamike_status.setText("Out of date") - self.hoolamike_status.setStyleSheet("color: #f44336;") - self.hoolamike_btn.setText("Update now") - self.hoolamike_btn.setEnabled(True) - self.hoolamike_btn.setVisible(True) + self.ttw_installer_status.setText("Out of date") + 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: - self.hoolamike_status.setText("Ready") - self.hoolamike_status.setStyleSheet("color: #3fd0ea;") - self.hoolamike_btn.setText("Update now") - self.hoolamike_btn.setEnabled(False) # Greyed out when ready - self.hoolamike_btn.setVisible(True) + self.ttw_installer_status.setText("Ready") + 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.hoolamike_status.setText("Not Found") - self.hoolamike_status.setStyleSheet("color: #f44336;") - self.hoolamike_btn.setText("Install now") - self.hoolamike_btn.setEnabled(True) - self.hoolamike_btn.setVisible(True) + 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.hoolamike_status.setText("Check Failed") - self.hoolamike_status.setStyleSheet("color: #f44336;") - self.hoolamike_btn.setText("Install now") - self.hoolamike_btn.setEnabled(True) - self.hoolamike_btn.setVisible(True) - debug_print(f"DEBUG: Hoolamike status check failed: {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_hoolamike(self): - """Install or update Hoolamike""" - # If not detected, show an appreciation/info dialog about Hoolamike first + def install_ttw_installer(self): + """Install or update TTW_Linux_Installer""" + # If not detected, show info dialog try: - current_status = self.hoolamike_status.text().strip() + current_status = self.ttw_installer_status.text().strip() except Exception: current_status = "" if current_status == "Not Found": MessageService.information( self, - "Hoolamike Installation", + "TTW_Linux_Installer Installation", ( - "Hoolamike is a community-made installer that enables the installation of modlists and TTW on Linux.

" - "Project: github.com/Niedzwiedzw/hoolamike
" + "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 Hoolamike." + "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.hoolamike_btn.setText("Installing...") - self.hoolamike_btn.setEnabled(False) + self.ttw_installer_btn.setText("Installing...") + self.ttw_installer_btn.setEnabled(False) - self.console.append("Installing/updating Hoolamike...") + self.console.append("Installing/updating TTW_Linux_Installer...") - try: - from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler - from jackify.backend.models.configuration import SystemInfo + # Create background thread for installation + from PySide6.QtCore import QThread, Signal - # Create handler instances - filesystem_handler = FileSystemHandler() - config_handler = ConfigHandler() - system_info = SystemInfo(is_steamdeck=False) - hoolamike_handler = HoolamikeHandler( - steamdeck=False, - verbose=False, - filesystem_handler=filesystem_handler, - config_handler=config_handler + 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 ) - # Install Hoolamike - success, message = hoolamike_handler.install_hoolamike() - - if success: - # Extract path from message if available, or show config path - install_path = hoolamike_handler.hoolamike_app_install_path - self.console.append("Hoolamike installed successfully") - self.console.append(f"Installation location: {install_path}") - self.console.append("Re-checking Hoolamike status...") - # Re-check Hoolamike status after installation - self._check_hoolamike_status() - self._update_start_button_state() - - # Update button to show successful installation - self.hoolamike_btn.setText("Installed") - # Keep button disabled - no need to reinstall - else: - self.console.append(f"Installation failed: {message}") - # Re-enable button on failure so user can retry - self.hoolamike_btn.setText("Install now") - self.hoolamike_btn.setEnabled(True) - - except Exception as e: - self.console.append(f"Error installing Hoolamike: {str(e)}") - debug_print(f"DEBUG: Hoolamike installation error: {e}") - # Re-enable button on exception so user can retry - self.hoolamike_btn.setText("Install now") - self.hoolamike_btn.setEnabled(True) + 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 - from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler - from jackify.backend.handlers.filesystem_handler import FileSystemHandler - from jackify.backend.handlers.config_handler import ConfigHandler path_handler = PathHandler() @@ -590,13 +644,13 @@ class InstallTTWScreen(QWidget): ) return False - # Check Hoolamike using the status we already checked - status_text = self.hoolamike_status.text() + # 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, - "Hoolamike Required", - "Hoolamike is required for TTW installation but is not installed.\n\nPlease install Hoolamike using the 'Install now' button." + "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 @@ -682,7 +736,7 @@ class InstallTTWScreen(QWidget): debug_print(f"Installation directory: {install_dir}") def _open_url_safe(self, url): - """Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller""" + """Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime""" import subprocess try: subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) @@ -733,8 +787,10 @@ class InstallTTWScreen(QWidget): """Called when the widget becomes visible""" super().showEvent(event) debug_print(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}") - # Check Hoolamike status only when TTW screen is opened - self._check_hoolamike_status() + + # 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: @@ -770,6 +826,7 @@ class InstallTTWScreen(QWidget): 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 @@ -783,33 +840,18 @@ class InstallTTWScreen(QWidget): 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}") - # Recompute and pin upper section to its content size to avoid slack - try: - if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: - self.upper_section_widget.setMaximumHeight(self.upper_section_widget.sizeHint().height()) - except Exception: - pass - # Derive compact height from current content (tighter) - compact_height = max(440, min(540, self.sizeHint().height() + 20)) - debug_print(f"DEBUG: Calculated compact_height={compact_height}, sizeHint={self.sizeHint().height()}") - # COMPLETE RESET: Clear ALL size constraints from previous screen + # Fixed compact size - same as menu screens from PySide6.QtCore import QSize - main_window.showNormal() + # 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)) - debug_print("DEBUG: Cleared all size constraints") - - # Now set our compact constraints - main_window.setMinimumSize(QSize(1200, compact_height)) - main_window.setMaximumHeight(compact_height) - debug_print(f"DEBUG: Set compact constraints: min=1200x{compact_height}, max_height={compact_height}") - - # Force resize - before_size = main_window.size() - main_window.resize(1400, compact_height) - debug_print(f"DEBUG: Resized from {before_size} to {main_window.size()}") + # 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') @@ -824,28 +866,17 @@ class InstallTTWScreen(QWidget): pass def hideEvent(self, event): - """Called when the widget becomes hidden - ensure window constraints are cleared on Steam Deck""" + """Called when the widget becomes hidden - restore window size constraints""" super().hideEvent(event) try: - # 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 - else: - main_window = self.window() - if main_window and hasattr(main_window, 'system_info'): - is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False) - - # On Steam Deck, clear any size constraints that might have been set - # This prevents window size issues affecting other screens after exiting TTW screen - if is_steamdeck: - debug_print("DEBUG: Steam Deck detected in hideEvent, clearing window constraints") - main_window = self.window() - if main_window: - from PySide6.QtCore import QSize - # Clear any size constraints that might have been set - main_window.setMaximumSize(QSize(16777215, 16777215)) - main_window.setMinimumSize(QSize(0, 0)) + 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 @@ -886,17 +917,47 @@ class InstallTTWScreen(QWidget): def browse_wabbajack_file(self): - file, _ = QFileDialog.getOpenFileName(self, "Select TTW .mpi File", os.path.expanduser("~"), "MPI Files (*.mpi);;All Files (*)") - if file: - self.file_edit.setText(file) + # Use QFileDialog instance to ensure consistent dialog style + start_path = self.file_edit.text() if self.file_edit.text() else os.path.expanduser("~") + dialog = QFileDialog(self, "Select TTW .mpi File") + dialog.setFileMode(QFileDialog.ExistingFile) + dialog.setNameFilter("MPI Files (*.mpi);;All Files (*)") + dialog.setDirectory(start_path) + dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog for consistency + if dialog.exec() == QDialog.Accepted: + files = dialog.selectedFiles() + if files: + self.file_edit.setText(files[0]) 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) + # Use QFileDialog instance to match file browser style exactly + dialog = QFileDialog(self, "Select Install Directory") + dialog.setFileMode(QFileDialog.Directory) + dialog.setOption(QFileDialog.ShowDirsOnly, True) + dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog to match file browser + if self.install_dir_edit.text(): + dialog.setDirectory(self.install_dir_edit.text()) + if dialog.exec() == QDialog.Accepted: + dirs = dialog.selectedFiles() + if dirs: + 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) @@ -914,7 +975,7 @@ class InstallTTWScreen(QWidget): if ( ("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 or - "hoolamike" in line_lower) + "ttw_linux" in line_lower) and "jackify-gui.py" not in line_lower ): cols = line.strip().split(None, 3) @@ -932,6 +993,9 @@ class InstallTTWScreen(QWidget): 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: @@ -999,11 +1063,52 @@ class InstallTTWScreen(QWidget): # Validate install directory 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) + 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?", @@ -1014,8 +1119,10 @@ class InstallTTWScreen(QWidget): 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 @@ -1056,6 +1163,12 @@ class InstallTTWScreen(QWidget): 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...") @@ -1087,17 +1200,19 @@ class InstallTTWScreen(QWidget): from PySide6.QtCore import QThread, Signal class TTWInstallationThread(QThread): - output_received = Signal(str) + 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: @@ -1105,67 +1220,134 @@ class InstallTTWScreen(QWidget): 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.hoolamike_handler import HoolamikeHandler + 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 - from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env - import subprocess, sys + from pathlib import Path + import tempfile - # Prepare backend config (do not run process here) + # Emit startup message + self.process_and_buffer_line("Initializing TTW installation...") + self.flush_output_buffer() + + # Create backend handler filesystem_handler = FileSystemHandler() config_handler = ConfigHandler() - system_info = SystemInfo(is_steamdeck=False) - hoolamike_handler = HoolamikeHandler( + ttw_handler = TTWInstallerHandler( steamdeck=False, verbose=False, filesystem_handler=filesystem_handler, config_handler=config_handler ) - # Update config for TTW and save - hoolamike_handler._update_hoolamike_config_for_ttw( - Path(self.mpi_path), Path(self.install_dir) - ) - if not hoolamike_handler.save_hoolamike_config(): - self.installation_finished.emit(False, "Failed to save hoolamike.yaml") - return + # 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() - hoolamike_handler._check_hoolamike_installation() - if not hoolamike_handler.hoolamike_executable_path: - self.installation_finished.emit(False, "Hoolamike executable not found. Please install Hoolamike.") - return + # Start installation via backend (non-blocking) + self.process_and_buffer_line("Starting TTW installation...") + self.flush_output_buffer() - cmd = [str(hoolamike_handler.hoolamike_executable_path), "tale-of-two-wastelands"] - env = get_clean_subprocess_env() - - # Use info level to get progress bar updates from indicatif - # Our output filtering will parse the progress indicators - env['RUST_LOG'] = 'info' - - cwd = str(hoolamike_handler.hoolamike_app_install_path) - - # Stream output live to GUI - self.proc = subprocess.Popen( - cmd, - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - universal_newlines=True, + self.proc, error_msg = ttw_handler.start_ttw_installation( + Path(self.mpi_path), + Path(self.install_dir), + output_file_path ) - assert self.proc.stdout is not None - for line in self.proc.stdout: + 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 - self.output_received.emit(line.rstrip()) - returncode = self.proc.wait() + 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: @@ -1174,170 +1356,493 @@ class InstallTTWScreen(QWidget): 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) - self.install_thread.output_received.connect(self.on_installation_output) - self.install_thread.progress_received.connect(self.on_installation_progress) - self.install_thread.installation_finished.connect(self.on_installation_finished) + # 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']) + + should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op) + + 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 installation thread with smart progress parsing""" + """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 (cursor movement, line clearing, etc.) + # Strip ANSI terminal control codes cleaned = strip_ansi_control_codes(message).strip() - # Filter out empty lines after stripping control codes + # 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 - # If user asked to see details, show the raw cleaned line first (INFO-level verbosity) - try: - if self.show_details_checkbox.isChecked(): - self._safe_append_text(cleaned) - except Exception: - pass - - import re - - # Try to extract total asset count from the completion message - success_match = re.search(r'succesfully installed \[(\d+)\] assets', cleaned) - if success_match: - total = int(success_match.group(1)) - if not hasattr(self, 'ttw_asset_count'): - self.ttw_asset_count = 0 - - # Cache this total for future installs in config - from jackify.backend.handlers.config_handler import ConfigHandler - config_handler = ConfigHandler() - config_handler.set('ttw_asset_count_cache', total) - - self._safe_append_text(f"\nInstallation complete: {total} assets processed successfully!") - return - - # Parse progress bar lines: "▕bar▏(123/456 ETA 10m ELAPSED 5m) handling_assets" - progress_match = re.search(r'\((\d+)/(\d+)\s+ETA\s+([^\)]+)\)\s*(.*)', cleaned) - if progress_match: - current = int(progress_match.group(1)) - total = int(progress_match.group(2)) - - # Store total for later use - if not hasattr(self, 'ttw_total_assets'): - self.ttw_total_assets = total - - task = progress_match.group(4).strip() or "Processing" - percent = int((current / total) * 100) if total > 0 else 0 - elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0 - elapsed_min = elapsed // 60 - elapsed_sec = elapsed % 60 - - self.status_banner.setText( - f"{task}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - ) - - # Show progress updates every 100 assets in console (keep it minimal) - if current % 100 == 0: - self._safe_append_text(f"Progress: {current}/{total} assets ({percent}%)") - return + # Initialize start time if not set + if not hasattr(self, 'ttw_start_time'): + self.ttw_start_time = time.time() lower_cleaned = cleaned.lower() - # Detect phases and extract useful information - if 'extracting_manifest' in cleaned: - self._safe_append_text("Extracting TTW manifest from .mpi file...") - return + # === 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']) + + should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op) + + 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 - if 'handling_assets_for_location' in cleaned: - # Parse location being processed - location_match = re.search(r'location=([^}]+)', cleaned) - if location_match: - location = location_match.group(1).strip() - self._safe_append_text(f"Processing location: {location}") - return - - if 'building_archive' in cleaned: - self._safe_append_text("Building BSA archives...") - return - - # Filter out variable resolution spam (MAGICALLY messages) - if 'magically' in lower_cleaned or 'variable_name=' in cleaned or 'resolve_variable' in cleaned: - # Extract total from manifest if present - if 'got manifest file' in lower_cleaned: - self._safe_append_text("Loading TTW manifest...") - return - - # Use known asset count for TTW 3.4 - # Actual count: 215,396 assets (measured from complete installation of TTW 3.4) - # This will need updating if TTW releases a new version - if 'got manifest file' in lower_cleaned and not hasattr(self, 'ttw_total_assets'): - self.ttw_total_assets = 215396 - self._safe_append_text(f"Loading TTW manifest ({self.ttw_total_assets:,} assets)...") - return - - # Filter out ALL per-asset processing messages - if 'handling_asset{kind=' in cleaned: - # Track progress by counting these messages - if not hasattr(self, 'ttw_asset_count'): - self.ttw_asset_count = 0 - self.ttw_asset_count += 1 - - # Update banner every 10 assets processed - if self.ttw_asset_count % 10 == 0: - elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0 + # === 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 - - # Show with total if we have it - if hasattr(self, 'ttw_total_assets'): - percent = int((self.ttw_asset_count / self.ttw_total_assets) * 100) - self.status_banner.setText( - f"Processing assets... {self.ttw_asset_count}/{self.ttw_total_assets} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" - ) - else: - self.status_banner.setText( - f"Processing assets... ({self.ttw_asset_count} completed) | Elapsed: {elapsed_min}m {elapsed_sec}s" - ) - - return # Don't show per-asset messages in console - - # Filter out per-file verbose messages - if 'wrote [' in cleaned and 'bytes]' in cleaned: + 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 - if '[ok]' in lower_cleaned: - return # Skip all [OK] messages - - # Filter out version/metadata spam at start - if any(x in lower_cleaned for x in ['install:installing_ttw', 'title=', 'version=', 'author=', 'description=']): - if 'installing_ttw{' in cleaned: - # Extract just the version/title cleanly - version_match = re.search(r'version=([\d.]+)\s+title=([^}]+)', cleaned) - if version_match: - self._safe_append_text(f"Installing {version_match.group(2)} v{version_match.group(1)}") + # === 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 - # Keep important messages: errors, warnings, completions - important_keywords = [ - 'error', 'warning', 'failed', 'patch applied' - ] + # === 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 - # Show only important messages - if any(kw in lower_cleaned for kw in important_keywords): - # Strip out emojis if present - cleaned_no_emoji = re.sub(r'[⭐☢️🩹]', '', cleaned) - self._safe_append_text(cleaned_no_emoji.strip()) + # 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 - # Auto-expand console on errors/warnings - if any(kw in lower_cleaned for kw in ['error', 'warning', 'failed']): - if not self.show_details_checkbox.isChecked(): - self.show_details_checkbox.setChecked(True) + # === 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""" @@ -1348,6 +1853,16 @@ class InstallTTWScreen(QWidget): 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'): @@ -1517,7 +2032,9 @@ class InstallTTWScreen(QWidget): return # Restore main window to normal size (clear any compact constraints) - main_window.showNormal() + # 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 @@ -1567,38 +2084,77 @@ class InstallTTWScreen(QWidget): debug_print("DEBUG: Steam Deck detected, skipping window resize in collapse branch") return - # Shrink main window to a compact height so no extra space remains - # Use the screen's sizeHint to choose a minimal-but-safe height (tighter) - size_hint = self.sizeHint().height() - new_min_height = max(440, min(540, size_hint + 20)) - main_window.showNormal() - # Temporarily clamp max to enforce the smaller collapsed size; parent clears on expand - main_window.setMaximumHeight(new_min_height) - main_window.setMinimumHeight(new_min_height) - # Lower the main window minimum size vertically so it can collapse + # 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 - current_min = self._saved_min_size or main_window.minimumSize() - main_window.setMinimumSize(QSize(current_min.width(), new_min_height)) + 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(), new_min_height) - try: - self.main_overall_vbox.invalidate() - self.updateGeometry() - except Exception: - pass + main_window.resize(current_size.width(), compact_height) # Notify parent to collapse try: self.resize_request.emit('collapse') except Exception: pass - def _safe_append_text(self, text): - """Append text with professional auto-scroll behavior""" + 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) @@ -1611,8 +2167,19 @@ class InstallTTWScreen(QWidget): # 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) + # 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 @@ -2624,10 +3191,10 @@ class InstallTTWScreen(QWidget): def run(self): try: - from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler + from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler self.progress.emit("Integrating TTW into modlist...") - success = HoolamikeHandler.integrate_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 @@ -2655,6 +3222,25 @@ class InstallTTWScreen(QWidget): }} """) + # 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, @@ -2666,6 +3252,11 @@ class InstallTTWScreen(QWidget): 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}") @@ -2676,6 +3267,11 @@ class InstallTTWScreen(QWidget): 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!") @@ -2731,8 +3327,8 @@ class InstallTTWScreen(QWidget): """ try: from pathlib import Path - import shutil import re + from PySide6.QtCore import QThread, Signal output_dir = Path(self.install_dir_edit.text()) if not output_dir.exists(): @@ -2745,39 +3341,78 @@ class InstallTTWScreen(QWidget): mpi_path = self.file_edit.text().strip() version_suffix = "" if mpi_path: - mpi_filename = Path(mpi_path).stem # Get filename without extension - # Look for version pattern like "3.4", "v3.4", etc. + 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 - [NoDelete] prefix is used by MO2 workflows + # Create archive filename archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}" - - # Place archive in parent directory of output archive_path = output_dir.parent / archive_name - if not automated: - self._safe_append_text(f"\nCreating mod archive: {archive_name}.zip") - self._safe_append_text("This may take several minutes...") + # Create background thread for zip creation + class ZipCreationThread(QThread): + finished = Signal(bool, str) # success, result_message - # Create the zip archive - # shutil.make_archive returns the path without .zip extension - final_archive = shutil.make_archive( - str(archive_path), # base name (without extension) - 'zip', # format - str(output_dir) # directory to archive + 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() - 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" - ) + # 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 @@ -2874,32 +3509,14 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html""" self.cleanup_processes() # Restore main window to standard Jackify size before leaving try: - from PySide6.QtCore import Qt as _Qt main_window = self.window() - - # Check if we're on Steam Deck - if so, skip all window size modifications - 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) - - if main_window and not is_steamdeck: - # Desktop: Restore main window to standard Jackify size - main_window.setMaximumHeight(16777215) - main_window.setMinimumHeight(900) - # Prefer a sane default height; keep current width - current_width = max(1200, main_window.size().width()) - main_window.resize(current_width, 900) - elif is_steamdeck: - # Steam Deck: Only clear any constraints that might exist, don't set new ones - # This prevents window size issues when navigating away - debug_print("DEBUG: Steam Deck detected in cancel_and_cleanup, skipping window resize") - if main_window: - # Clear any size constraints that might have been set - from PySide6.QtCore import QSize - main_window.setMaximumSize(QSize(16777215, 16777215)) - main_window.setMinimumSize(QSize(0, 0)) + 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(): diff --git a/jackify/frontends/gui/screens/install_ttw_simple_output.py b/jackify/frontends/gui/screens/install_ttw_simple_output.py new file mode 100644 index 0000000..5473f84 --- /dev/null +++ b/jackify/frontends/gui/screens/install_ttw_simple_output.py @@ -0,0 +1,63 @@ +""" +Simplified output handler for TTW installation - minimal filtering, maximum stability +This is a reference implementation showing the absolute minimum needed. +""" + +def on_installation_output_simple(self, message): + """ + Ultra-simplified output handler: + - Strip emojis (required) + - Show all output (no filtering) + - Extract progress numbers for Activity window only + - No regex except for simple number extraction + """ + # Strip ANSI codes + cleaned = strip_ansi_control_codes(message).strip() + + # Strip emojis - character by character (no regex) + filtered_chars = [] + for char in cleaned: + code = ord(char) + is_emoji = ( + (0x1F300 <= code <= 0x1F9FF) or + (0x1F600 <= code <= 0x1F64F) or + (0x2600 <= code <= 0x26FF) or + (0x2700 <= code <= 0x27BF) + ) + if not is_emoji: + filtered_chars.append(char) + cleaned = ''.join(filtered_chars).strip() + + if not cleaned: + return + + # Log everything + self._write_to_log_file(message) + + # Show everything in console (no filtering) + self._safe_append_text(cleaned) + + # Extract progress for Activity window ONLY - minimal regex with error handling + # Pattern: [X/Y] or "Loading manifest: X/Y" + try: + # Try to extract [X/Y] pattern + import re + match = re.search(r'\[(\d+)/(\d+)\]', cleaned) + if match: + current = int(match.group(1)) + total = int(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" + match = re.search(r'loading manifest:\s*(\d+)/(\d+)', cleaned.lower()) + if match: + current = int(match.group(1)) + total = int(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 (RecursionError, re.error, Exception): + # If regex fails, just skip progress extraction - show output anyway + pass diff --git a/jackify/frontends/gui/screens/main_menu.py b/jackify/frontends/gui/screens/main_menu.py index 35a59bf..59ecc1b 100644 --- a/jackify/frontends/gui/screens/main_menu.py +++ b/jackify/frontends/gui/screens/main_menu.py @@ -6,6 +6,7 @@ from PySide6.QtGui import QPixmap, QFont from PySide6.QtCore import Qt import os from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT +from ..utils import set_responsive_minimum class MainMenu(QWidget): def __init__(self, stacked_widget=None, dev_mode=False): @@ -14,37 +15,52 @@ class MainMenu(QWidget): self.dev_mode = dev_mode layout = QVBoxLayout() layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - layout.setContentsMargins(50, 50, 50, 50) - layout.setSpacing(20) + layout.setContentsMargins(30, 30, 30, 30) # Reduced from 50 + layout.setSpacing(12) # Reduced from 20 + + # Header zone with fixed height for consistent layout across all menu screens + header_widget = QWidget() + header_layout = QVBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(2) # Title title = QLabel("Jackify") - title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") title.setAlignment(Qt.AlignHCenter) - layout.addWidget(title) + header_layout.addWidget(title) - # Description + header_layout.addSpacing(10) + + # Description area with fixed height desc = QLabel( "Manage your modlists with native Linux tools. " "Choose from the options below to install, " "configure, or manage modlists." ) desc.setWordWrap(True) - desc.setStyleSheet("color: #ccc;") + desc.setStyleSheet("color: #ccc; font-size: 13px;") desc.setAlignment(Qt.AlignHCenter) - layout.addWidget(desc) + desc.setMaximumHeight(50) # Fixed height for description zone + header_layout.addWidget(desc) + + header_layout.addSpacing(12) # Separator - layout.addSpacing(16) sep = QLabel() sep.setFixedHeight(2) sep.setStyleSheet("background: #fff;") - layout.addWidget(sep) - layout.addSpacing(16) + header_layout.addWidget(sep) + + header_layout.addSpacing(10) + + header_widget.setLayout(header_layout) + header_widget.setFixedHeight(120) # Fixed total header height + layout.addWidget(header_widget) # Menu buttons button_width = 400 - button_height = 60 + button_height = 40 # Reduced from 50/60 MENU_ITEMS = [ ("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"), ("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"), @@ -54,14 +70,14 @@ class MainMenu(QWidget): for label, action_id, description in MENU_ITEMS: # Main button btn = QPushButton(label) - btn.setFixedSize(button_width, 50) + btn.setFixedSize(button_width, button_height) # Use variable height btn.setStyleSheet(f""" QPushButton {{ background-color: #4a5568; color: white; border: none; - border-radius: 8px; - font-size: 14px; + border-radius: 6px; + font-size: 13px; font-weight: bold; text-align: center; }} @@ -73,28 +89,28 @@ class MainMenu(QWidget): }} """) btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a)) - + # Button container with proper alignment btn_container = QWidget() btn_layout = QVBoxLayout() btn_layout.setContentsMargins(0, 0, 0, 0) - btn_layout.setSpacing(4) + btn_layout.setSpacing(3) # Reduced from 4 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: 12px;") + desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px desc_label.setWordWrap(True) - desc_label.setFixedWidth(button_width) # Match button width for proper alignment + desc_label.setFixedWidth(button_width) btn_layout.addWidget(desc_label) - + btn_container.setLayout(btn_layout) layout.addWidget(btn_container) # Disclaimer - layout.addSpacing(20) + layout.addSpacing(12) # Reduced from 20 disclaimer = QLabel(DISCLAIMER_TEXT) disclaimer.setWordWrap(True) disclaimer.setAlignment(Qt.AlignCenter) @@ -104,6 +120,20 @@ class MainMenu(QWidget): self.setLayout(layout) + def showEvent(self, event): + """Called when the widget becomes visible - ensure minimum size only""" + super().showEvent(event) + 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 + def menu_action(self, action_id): if action_id == "exit_jackify": from PySide6.QtWidgets import QApplication diff --git a/jackify/frontends/gui/screens/modlist_gallery.py b/jackify/frontends/gui/screens/modlist_gallery.py new file mode 100644 index 0000000..7f9312e --- /dev/null +++ b/jackify/frontends/gui/screens/modlist_gallery.py @@ -0,0 +1,1500 @@ +""" +Enhanced Modlist Gallery Screen for Jackify GUI. + +Provides visual browsing, filtering, and selection of modlists using +rich metadata from jackify-engine. +""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QLineEdit, QComboBox, QCheckBox, QScrollArea, QGridLayout, + QFrame, QSizePolicy, QDialog, QTextEdit, QTextBrowser, QMessageBox, QListWidget +) +from PySide6.QtCore import Qt, Signal, QSize, QThread, QUrl, QTimer, QObject +from PySide6.QtGui import QPixmap, QFont, QDesktopServices, QPainter, QColor, QTextOption, QPalette +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from pathlib import Path +from typing import List, Optional, Dict +from collections import deque +import random + +from jackify.backend.services.modlist_gallery_service import ModlistGalleryService +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 + + +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 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...") + main_layout.addWidget(self.banner_label) + + # Content area with padding + content_widget = QWidget() + content_layout = QVBoxLayout() + content_layout.setContentsMargins(24, 20, 24, 20) + content_layout.setSpacing(16) + content_widget.setLayout(content_layout) + + # Title row with status badges (UNAVAILABLE, Unofficial - Official and NSFW shown in tags) + title_row = QHBoxLayout() + title_row.setSpacing(12) + + title = QLabel(self.metadata.title) + title.setFont(QFont("Sans", 24, QFont.Bold)) + title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};") + title.setWordWrap(True) + title_row.addWidget(title, stretch=1) + + # Status badges in title row + if not self.metadata.is_available(): + unavailable_badge = QLabel("UNAVAILABLE") + unavailable_badge.setStyleSheet("background: #666; color: white; padding: 4px 10px; font-size: 10px; font-weight: bold; border-radius: 4px;") + title_row.addWidget(unavailable_badge) + + # Show "Unofficial" badge if not official (Official is shown in tags) + if not self.metadata.official: + unofficial_badge = QLabel("Unofficial") + unofficial_badge.setStyleSheet("background: #666; color: white; padding: 4px 10px; font-size: 10px; font-weight: bold; border-radius: 4px;") + title_row.addWidget(unofficial_badge) + + content_layout.addLayout(title_row) + + # Metadata line (version, author, game) - inline like Wabbajack + 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 (like Wabbajack) + tags_to_render = getattr(self.metadata, 'normalized_tags_display', self.metadata.tags or []) + if tags_to_render: + tags_layout = QHBoxLayout() + tags_layout.setSpacing(6) + tags_layout.setContentsMargins(0, 0, 0, 0) + + 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) + + # Sizes (if available) + 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;") + content_layout.addWidget(sizes_label) + + # 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.") + self.desc_text.setFixedHeight(300) + 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: QDesktopServices.openUrl(QUrl(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: QDesktopServices.openUrl(QUrl(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: QDesktopServices.openUrl(QUrl(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() + + +class ModlistGalleryDialog(QDialog): + """Enhanced modlist gallery dialog with visual browsing""" + modlist_selected = Signal(ModlistMetadata) + + def __init__(self, game_filter: Optional[str] = None, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Modlist") + self.setModal(True) + self.setAutoFillBackground(True) + palette = self.palette() + palette.setColor(QPalette.Window, QColor("#111111")) + self.setPalette(palette) + + # Detect Steam Deck + from jackify.backend.services.platform_detection_service import PlatformDetectionService + platform_service = PlatformDetectionService.get_instance() + self.is_steamdeck = platform_service.is_steamdeck + + # Responsive sizing for different screen sizes (especially Steam Deck 1280x800) + min_height = 650 if self.is_steamdeck else 700 + set_responsive_minimum(self, min_width=1100 if self.is_steamdeck else 1200, min_height=min_height) + self._apply_initial_size() + + self.gallery_service = ModlistGalleryService() + self.image_manager = ImageManager(self.gallery_service) + self.all_modlists: List[ModlistMetadata] = [] + self.filtered_modlists: List[ModlistMetadata] = [] + self.game_filter = game_filter + self.selected_metadata: Optional[ModlistMetadata] = None + self.all_cards: Dict[str, ModlistCard] = {} # Dict keyed by machineURL for quick lookup + self._validation_update_timer = None # Timer for background validation updates + + self._setup_ui() + # Lazy load - fetch modlists when dialog is shown + + def _apply_initial_size(self): + """Ensure dialog fits on screen while maximizing usable space.""" + _, _, screen_width, screen_height = get_screen_geometry(self) + width = 1400 + height = 800 + + if self.is_steamdeck or (screen_width and screen_width <= 1280): + width = min(width, 1200) + height = min(height, 750) + + if screen_width: + width = min(width, max(1000, screen_width - 40)) + if screen_height: + height = min(height, max(640, screen_height - 40)) + + self.resize(width, height) + + def showEvent(self, event): + """Fetch modlists when dialog is first shown""" + super().showEvent(event) + if not self.all_modlists: + # Start loading in background thread for instant dialog appearance + self._load_modlists_async() + + 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.setSpacing(12) + + # Left sidebar (filters) + filter_panel = self._create_filter_panel() + main_layout.addWidget(filter_panel) + + # Right content area (modlist grid) + content_area = self._create_content_area() + main_layout.addWidget(content_area, stretch=1) + + 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 + 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() + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(12) + + # Status label (subtle, top-right) + self.status_label = QLabel("Loading modlists...") + self.status_label.setStyleSheet("color: #888; font-size: 10px;") + self.status_label.setAlignment(Qt.AlignRight | Qt.AlignTop) + layout.addWidget(self.status_label) + + # Scroll area for modlist cards + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + # Grid container for cards + 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.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.grid_widget.setLayout(self.grid_layout) + + self.scroll_area.setWidget(self.grid_widget) + layout.addWidget(self.scroll_area) + + 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 + + # Make status label more prominent during loading + self.status_label.setText("Loading modlists...") + font = QFont() + font.setPointSize(14) + font.setBold(True) + self.status_label.setFont(font) + self.status_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 14px; font-weight: bold;") + + 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) + metadata_response = self.gallery_service.fetch_modlist_metadata( + include_validation=False, + include_search_index=True, + 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 _on_modlists_loaded(self, metadata_response, error_message): + """Handle modlist metadata loaded in background thread (runs in GUI thread)""" + import random + from PySide6.QtCore import QTimer + from PySide6.QtGui import QFont + + # Restore normal status label styling + font = QFont() + font.setPointSize(10) + self.status_label.setFont(font) + self.status_label.setStyleSheet("color: #888; font-size: 10px;") + + 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 and mod filters + self._populate_tag_filter() + self._populate_mod_filter() + + # 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) + + # 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 and mod filters + self._populate_tag_filter() + self._populate_mod_filter() + + # 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""" + 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""" + # 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() # Refresh mod list based on NSFW state + self._apply_filters() # Apply all filters + + def _apply_filters(self): + """Apply current filters to modlist display""" + 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 - modlist must contain ALL selected mods + 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""" + # Disable updates during grid update + self.grid_widget.setUpdatesEnabled(False) + + try: + # Remove all cards from layout + while self.grid_layout.count(): + item = self.grid_layout.takeAt(0) + if item.widget(): + item.widget().setParent(None) + del item + + # Calculate number of columns based on available width + # Get the scroll area width (accounting for filter panel ~280px + margins) + scroll_area = self.grid_widget.parent() + if scroll_area and hasattr(scroll_area, 'viewport'): + available_width = scroll_area.viewport().width() + else: + # Fallback: estimate based on dialog width minus filter panel + available_width = self.width() - 280 - 32 # Filter panel + margins + + if available_width <= 0: + # Fallback if width not yet calculated + available_width = 900 if not self.is_steamdeck else 700 + + # Card width + spacing between cards + if self.is_steamdeck: + card_width = 250 + else: + card_width = 300 + + card_spacing = 8 + # Calculate how many columns fit + columns = max(1, int((available_width + card_spacing) / (card_width + card_spacing))) + + # Limit to reasonable max (4 columns on large screens, 3 on Steam Deck) + if not self.is_steamdeck: + columns = min(columns, 4) + else: + columns = min(columns, 3) + + # Preserve randomized order (already shuffled in _load_modlists) + # Add visible cards to grid in order + for idx, modlist in enumerate(self.filtered_modlists): + row = idx // columns + col = idx % columns + + card = self.all_cards.get(modlist.machineURL) + if card: + self.grid_layout.addWidget(card, row, col) + + # Set column stretch - don't stretch card columns, but add a spacer column + for col in range(columns): + self.grid_layout.setColumnStretch(col, 0) # Cards are fixed width + # Add a stretch column after cards to fill remaining space (centers the grid) + if columns < 4: + self.grid_layout.setColumnStretch(columns, 1) + finally: + # Re-enable updates + self.grid_widget.setUpdatesEnabled(True) + self.grid_widget.update() + + # Update status + self.status_label.setText(f"Showing {len(self.filtered_modlists)} modlists") + + def resizeEvent(self, event): + """Handle dialog resize to recalculate grid columns""" + super().resizeEvent(event) + # Recalculate columns when dialog is resized + if hasattr(self, 'filtered_modlists') and self.filtered_modlists: + self._update_grid() + + def _on_modlist_clicked(self, metadata: ModlistMetadata): + """Handle modlist card click - show detail dialog""" + dialog = ModlistDetailDialog(metadata, self.image_manager, self) + dialog.install_requested.connect(self._on_install_requested) + dialog.exec() + + def _on_install_requested(self, metadata: ModlistMetadata): + """Handle install request from detail dialog""" + self.selected_metadata = metadata + 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() diff --git a/jackify/frontends/gui/screens/modlist_tasks.py b/jackify/frontends/gui/screens/modlist_tasks.py index 9a432a3..3126706 100644 --- a/jackify/frontends/gui/screens/modlist_tasks.py +++ b/jackify/frontends/gui/screens/modlist_tasks.py @@ -27,6 +27,7 @@ from PySide6.QtGui import QFont, QPalette, QColor, QPixmap # Import our GUI services from jackify.backend.models.configuration import SystemInfo from ..shared_theme import JACKIFY_COLOR_BLUE +from ..utils import set_responsive_minimum # Constants DEBUG_BORDERS = False @@ -77,8 +78,9 @@ class ModlistTasksScreen(QWidget): """Set up the user interface""" main_layout = QVBoxLayout(self) main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - main_layout.setContentsMargins(50, 50, 50, 50) - + main_layout.setContentsMargins(30, 30, 30, 30) # Reduced from 50 + main_layout.setSpacing(12) # Match main menu spacing + if self.debug: self.setStyleSheet("border: 2px solid green;") @@ -93,38 +95,43 @@ class ModlistTasksScreen(QWidget): def _setup_header(self, layout): """Set up the header section""" + header_widget = QWidget() header_layout = QVBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(2) - + # Title title = QLabel("Modlist Tasks") - title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};") + title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") title.setAlignment(Qt.AlignHCenter) header_layout.addWidget(title) - # Add a spacer to match main menu vertical spacing - header_layout.addSpacing(16) - - # Description + header_layout.addSpacing(10) + + # Description area with fixed height desc = QLabel( "Manage your modlists with native Linux tools. Choose " - "from the options below to install or configure modlists.
 " + "from the options below to install or configure modlists." ) desc.setWordWrap(True) - desc.setStyleSheet("color: #ccc;") + 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(24) - + + header_layout.addSpacing(12) + # Separator sep = QLabel() sep.setFixedHeight(2) sep.setStyleSheet("background: #fff;") header_layout.addWidget(sep) - - header_layout.addSpacing(16) - layout.addLayout(header_layout) + + header_layout.addSpacing(10) + + header_widget.setLayout(header_layout) + header_widget.setFixedHeight(120) # Fixed total header height + layout.addWidget(header_widget) def _setup_menu_buttons(self, layout): """Set up the menu buttons section""" @@ -140,12 +147,12 @@ class ModlistTasksScreen(QWidget): # Create grid layout for buttons button_grid = QGridLayout() - button_grid.setSpacing(16) + button_grid.setSpacing(12) # Reduced from 16 button_grid.setAlignment(Qt.AlignHCenter) - + button_width = 400 - button_height = 50 - + button_height = 40 # Reduced from 50 + for i, (label, action_id, description) in enumerate(MENU_ITEMS): # Create button btn = QPushButton(label) @@ -155,8 +162,8 @@ class ModlistTasksScreen(QWidget): background-color: #4a5568; color: white; border: none; - border-radius: 8px; - font-size: 14px; + border-radius: 6px; + font-size: 13px; font-weight: bold; text-align: center; }} @@ -168,11 +175,11 @@ class ModlistTasksScreen(QWidget): }} """) btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a)) - + # Create description label desc_label = QLabel(description) desc_label.setAlignment(Qt.AlignHCenter) - desc_label.setStyleSheet("color: #999; font-size: 12px;") + desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px desc_label.setWordWrap(True) desc_label.setFixedWidth(button_width) @@ -208,7 +215,21 @@ class ModlistTasksScreen(QWidget): """Return to main menu""" if self.stacked_widget: self.stacked_widget.setCurrentIndex(self.main_menu_index) - + + def showEvent(self, event): + """Called when the widget becomes visible - resize to compact size""" + super().showEvent(event) + 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 + def cleanup(self): """Clean up resources when the screen is closed""" pass \ No newline at end of file diff --git a/jackify/frontends/gui/shared_theme.py b/jackify/frontends/gui/shared_theme.py index 86456d2..3c7788d 100644 --- a/jackify/frontends/gui/shared_theme.py +++ b/jackify/frontends/gui/shared_theme.py @@ -4,7 +4,7 @@ Jackify GUI theme and shared constants import os JACKIFY_COLOR_BLUE = "#3fd0ea" # Official Jackify blue -DEBUG_BORDERS = False +DEBUG_BORDERS = False # Enable debug borders to visualize widget boundaries ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'assets') LOGO_PATH = os.path.join(ASSETS_DIR, 'jackify_logo.png') DISCLAIMER_TEXT = ( diff --git a/jackify/frontends/gui/utils.py b/jackify/frontends/gui/utils.py index b35a830..75c3402 100644 --- a/jackify/frontends/gui/utils.py +++ b/jackify/frontends/gui/utils.py @@ -2,6 +2,9 @@ GUI Utilities for Jackify Frontend """ import re +from typing import Tuple, Optional +from PySide6.QtWidgets import QApplication, QWidget +from PySide6.QtCore import QSize, QPoint ANSI_COLOR_MAP = { '30': 'black', '31': 'red', '32': 'green', '33': 'yellow', '34': 'blue', '35': 'magenta', '36': 'cyan', '37': 'white', @@ -50,4 +53,272 @@ def ansi_to_html(text): else: result += chunk result = result.replace('\n', '
') - return result \ No newline at end of file + return result + + +def get_screen_geometry(widget: Optional[QWidget] = None) -> Tuple[int, int, int, int]: + """ + Get available screen geometry for a widget. + + Args: + widget: Widget to get screen for (uses primary screen if None) + + Returns: + Tuple of (x, y, width, height) for available screen geometry + """ + app = QApplication.instance() + if not app: + return (0, 0, 1920, 1080) # Fallback + + if widget: + screen = None + window_handle = widget.windowHandle() + if window_handle and window_handle.screen(): + screen = window_handle.screen() + else: + try: + global_pos = widget.mapToGlobal(widget.rect().center()) + except Exception: + global_pos = QPoint(0, 0) + if app: + screen = app.screenAt(global_pos) + if not screen and app: + screen = app.primaryScreen() + else: + screen = app.primaryScreen() + + if screen: + geometry = screen.availableGeometry() + return (geometry.x(), geometry.y(), geometry.width(), geometry.height()) + + return (0, 0, 1920, 1080) # Fallback + + +def calculate_window_size( + widget: Optional[QWidget] = None, + width_ratio: float = 0.7, + height_ratio: float = 0.6, + min_width: int = 900, + min_height: int = 500, + max_width: Optional[int] = None, + max_height: Optional[int] = None +) -> Tuple[int, int]: + """ + Calculate appropriate window size based on screen geometry. + + Args: + widget: Widget to calculate size for (uses primary screen if None) + width_ratio: Fraction of screen width to use (0.0-1.0) + height_ratio: Fraction of screen height to use (0.0-1.0) + min_width: Minimum window width + min_height: Minimum window height + max_width: Maximum window width (None = no limit) + max_height: Maximum window height (None = no limit) + + Returns: + Tuple of (width, height) + """ + _, _, screen_width, screen_height = get_screen_geometry(widget) + + # Calculate size based on ratios + width = int(screen_width * width_ratio) + height = int(screen_height * height_ratio) + + # Apply minimums + width = max(width, min_width) + height = max(height, min_height) + + # Apply maximums + if max_width: + width = min(width, max_width) + if max_height: + height = min(height, max_height) + + # Ensure we don't exceed screen bounds + width = min(width, screen_width) + height = min(height, screen_height) + + return (width, height) + + +def calculate_window_position( + widget: QWidget, + window_width: int, + window_height: int, + parent: Optional[QWidget] = None +) -> QPoint: + """ + Calculate appropriate window position (centered on parent or screen). + + Args: + widget: Widget to position + window_width: Width of window to position + window_height: Height of window to position + parent: Parent widget to center on (centers on screen if None) + + Returns: + QPoint with x, y coordinates + """ + _, _, screen_width, screen_height = get_screen_geometry(widget) + + if parent: + parent_geometry = parent.geometry() + x = parent_geometry.x() + (parent_geometry.width() - window_width) // 2 + y = parent_geometry.y() + (parent_geometry.height() - window_height) // 2 + else: + # Center on screen + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + + # Ensure window stays on screen + x = max(0, min(x, screen_width - window_width)) + y = max(0, min(y, screen_height - window_height)) + + return QPoint(x, y) + + +def set_responsive_minimum(window: Optional[QWidget], min_width: int = 960, + min_height: int = 520, margin: int = 32): + """ + Apply minimum size constraints that respect the current screen bounds. + + Args: + window: Target window + min_width: Desired minimum width + min_height: Desired minimum height + margin: Pixels to subtract from available size to avoid full-screen overlap + """ + if window is None: + return + + _, _, screen_width, screen_height = get_screen_geometry(window) + + width_cap = min_width + height_cap = min_height + + if screen_width: + available_width = max(640, screen_width - margin) + available_width = min(available_width, screen_width) + width_cap = min(min_width, available_width) + if screen_height: + available_height = max(520, screen_height - margin) + available_height = min(available_height, screen_height) + height_cap = min(min_height, available_height) + + window.setMinimumSize(QSize(width_cap, height_cap)) + +def load_saved_window_size(window: QWidget) -> Optional[Tuple[int, int]]: + """ + Load saved window size from config if available. + Only returns sizes that are reasonable (compact menu size, not expanded). + + Args: + window: Window widget (used to validate size against screen) + + Returns: + Tuple of (width, height) if saved size exists and is valid, None otherwise + """ + try: + from ...backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + + saved_width = config_handler.get('window_width') + saved_height = config_handler.get('window_height') + + if saved_width and saved_height: + # Validate saved size is reasonable (not too small, fits on screen) + _, _, screen_width, screen_height = get_screen_geometry(window) + min_width = 1200 + min_height = 500 + max_height = int(screen_height * 0.6) # Reject sizes larger than 60% of screen (expanded state) + + # Ensure saved size is within reasonable bounds (compact menu size) + # Reject expanded sizes that are too tall + if (min_width <= saved_width <= screen_width and + min_height <= saved_height <= max_height): + return (saved_width, saved_height) + except Exception: + pass + + return None + + +def save_window_size(window: QWidget): + """ + Save current window size to config. + + Args: + window: Window widget to save size for + """ + try: + from ...backend.handlers.config_handler import ConfigHandler + config_handler = ConfigHandler() + + size = window.size() + config_handler.set('window_width', size.width()) + config_handler.set('window_height', size.height()) + config_handler.save_config() + except Exception: + pass + + +def apply_window_size_and_position( + window: QWidget, + width_ratio: float = 0.7, + height_ratio: float = 0.6, + min_width: int = 900, + min_height: int = 500, + max_width: Optional[int] = None, + max_height: Optional[int] = None, + parent: Optional[QWidget] = None, + preserve_position: bool = False, + use_saved_size: bool = True +): + """ + Apply dynamic window sizing and positioning based on screen geometry. + Optionally uses saved window size if user has manually resized before. + + Args: + window: Window widget to size/position + width_ratio: Fraction of screen width to use (if no saved size) + height_ratio: Fraction of screen height to use (if no saved size) + min_width: Minimum window width + min_height: Minimum window height + max_width: Maximum window width (None = no limit) + max_height: Maximum window height (None = no limit) + parent: Parent widget to center on (centers on screen if None) + preserve_position: If True, preserve current size and position (only set minimums) + use_saved_size: If True, check for saved window size first + """ + # Set minimum size first + window.setMinimumSize(QSize(min_width, min_height)) + + # If preserve_position is True, don't resize - just ensure minimums are set + if preserve_position: + # Only ensure current size meets minimums, don't change size + current_size = window.size() + if current_size.width() < min_width: + window.resize(min_width, current_size.height()) + if current_size.height() < min_height: + window.resize(window.size().width(), min_height) + return + + # Check for saved window size first + width = None + height = None + + if use_saved_size: + saved_size = load_saved_window_size(window) + if saved_size: + width, height = saved_size + + # If no saved size, calculate dynamically + if width is None or height is None: + width, height = calculate_window_size( + window, width_ratio, height_ratio, min_width, min_height, max_width, max_height + ) + + # Calculate and set position + pos = calculate_window_position(window, width, height, parent) + window.resize(width, height) + window.move(pos) diff --git a/jackify/frontends/gui/widgets/file_progress_list.py b/jackify/frontends/gui/widgets/file_progress_list.py new file mode 100644 index 0000000..8d4da3b --- /dev/null +++ b/jackify/frontends/gui/widgets/file_progress_list.py @@ -0,0 +1,848 @@ +""" +File Progress List Widget + +Displays a list of files currently being processed (downloaded, extracted, etc.) +with individual progress indicators. +R&D NOTE: This is experimental code for investigation purposes. +""" + +from typing import Optional +import shiboken6 +import time + +from PySide6.QtWidgets import ( + 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 + + +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._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 + + # Speed display (if available) + speed_label = QLabel() + speed_label.setFixedWidth(60) + speed_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + speed_label.setStyleSheet("color: #888; font-size: 10px;") + layout.addWidget(speed_label) + self.speed_label = speed_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.speed_label.setText("") # No speed + 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.speed_label.setText("") # No speed for summary + self.progress_bar.setRange(0, 100) + # Progress bar value will be updated by animation timer + else: + # Indeterminate if no max - use Qt's built-in smooth animation + self.percent_label.setText("") + self.speed_label.setText("") + self.progress_bar.setRange(0, 0) # Qt handles animation smoothly + 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 Qt's indeterminate mode + if has_meaningful_progress: + # 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() + + # Update speed label immediately (doesn't need animation) + self.speed_label.setText(self.file_progress.speed_display) + self.progress_bar.setRange(0, 100) + # Progress bar value will be updated by animation timer + else: + # No progress data (e.g., texture conversions) - Qt's indeterminate mode + self._animation_timer.stop() # Stop animation for indeterminate items + self.percent_label.setText("") # No percentage + self.speed_label.setText("") # No speed + self.progress_bar.setRange(0, 0) # Qt handles smooth indeterminate animation + + def _animate_progress(self): + """Smoothly animate progress bar from current to target value.""" + # 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. + Shows individual progress for each file. + """ + + def __init__(self, parent=None): + """ + Initialize file progress list. + + Args: + parent: Parent widget + """ + super().__init__(parent) + self._file_items: dict[str, FileProgressItem] = {} + self._summary_widget: Optional[SummaryProgressWidget] = None + self._last_phase: Optional[str] = None # Track phase changes for transition messages + self._transition_label: Optional[QLabel] = None # Label for "Preparing..." message + self._last_summary_time: float = 0.0 # Track when summary widget was last shown + self._summary_hold_duration: float = 0.5 # Hold summary for minimum 0.5s to prevent flicker + self._last_summary_update: float = 0.0 # Track last summary update for throttling + self._summary_update_interval: float = 0.1 # Update summary every 100ms (simple throttling) + + self._setup_ui() + # Set size policy to match Process Monitor - expand to fill available space + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + def _setup_ui(self): + """Set up the UI - match Process Monitor layout structure exactly.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) # Match Process Monitor spacing (was 4, now 2) + + # Header row with CPU usage only (tab label replaces "[Activity]" header) + header_layout = QHBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(8) + + # CPU usage indicator (right-aligned) + self.cpu_label = QLabel("") + self.cpu_label.setStyleSheet("color: #888; font-size: 11px; margin-bottom: 2px;") + self.cpu_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + header_layout.addStretch() # Push CPU label to the right + header_layout.addWidget(self.cpu_label, 0) + + layout.addLayout(header_layout) + + # List widget for file items - match Process Monitor size constraints + self.list_widget = QListWidget() + self.list_widget.setStyleSheet(""" + QListWidget { + background-color: #222; + border: 1px solid #444; + border-radius: 4px; + } + QListWidget::item { + border-bottom: 1px solid #2a2a2a; + padding: 2px; + } + QListWidget::item:selected { + background-color: #2a2a2a; + } + """) + # Match Process Monitor minimum size: QSize(300, 20) + self.list_widget.setMinimumSize(QSize(300, 20)) + # Match Process Monitor - no maximum height constraint, expand to fill available space + # The list will scroll if there are more items than can fit + self.list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + # Match Process Monitor size policy - expand to fill available space + self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + layout.addWidget(self.list_widget, stretch=1) # Match Process Monitor stretch + + # Throttle timer for updates when there are many files + import time + self._last_update_time = 0.0 + + # CPU usage tracking + self._cpu_timer = QTimer(self) + self._cpu_timer.timeout.connect(self._update_cpu_usage) + self._cpu_timer.setInterval(2000) # Update every 2 seconds + self._last_cpu_percent = 0.0 + self._cpu_process_cache = None # Cache the process object for better performance + self._child_process_cache = {} # Cache child Process objects by PID for persistent CPU tracking + + def update_files(self, file_progresses: list[FileProgress], current_phase: str = None, summary_info: dict = None): + """ + Update the list with current file progresses. + + Args: + file_progresses: List of FileProgress objects for active files + current_phase: Optional phase name to display in header (e.g., "Downloading", "Extracting") + summary_info: Optional dict with 'current_step' and 'max_steps' for summary display (e.g., Installing phase) + """ + # Throttle updates to prevent UI freezing with many files + # If we have many files (>50), throttle updates to every 100ms + import time + current_time = time.time() + if len(file_progresses) > 50: + if current_time - self._last_update_time < 0.1: # 100ms throttle + return # Skip this update + self._last_update_time = current_time + + # If we have summary info (e.g., Installing phase), show summary widget instead of file list + if summary_info and not file_progresses: + current_time = time.time() + + # Get new values + current_step = summary_info.get('current_step', 0) + max_steps = summary_info.get('max_steps', 0) + phase_name = current_phase or "Installing files" + + # Check if summary widget already exists and is valid + summary_widget_valid = self._summary_widget and shiboken6.isValid(self._summary_widget) + if not summary_widget_valid: + self._summary_widget = None + + # If widget exists, check if we should throttle the update + if self._summary_widget: + # Throttle updates to prevent flickering with rapidly changing counters + if current_time - self._last_summary_update < self._summary_update_interval: + return # Skip update, too soon + + # Update existing summary widget (no clearing needed) + self._summary_widget.update_progress(current_step, max_steps) + # Update phase name if it changed + if self._summary_widget.phase_name != phase_name: + self._summary_widget.phase_name = phase_name + self._summary_widget._update_display() + self._last_summary_update = current_time + return + + # Widget doesn't exist - create it (only clear when creating new widget) + self.list_widget.clear() + self._file_items.clear() + + # Create new summary widget + self._summary_widget = SummaryProgressWidget(phase_name, current_step, max_steps) + summary_item = QListWidgetItem() + summary_item.setSizeHint(self._summary_widget.sizeHint()) + summary_item.setData(Qt.UserRole, "__summary__") + self.list_widget.addItem(summary_item) + self.list_widget.setItemWidget(summary_item, self._summary_widget) + self._last_summary_time = current_time + self._last_summary_update = current_time + + return + + # Clear summary widget and transition label when showing file list + # But only if enough time has passed to prevent flickering + current_time = time.time() + + if self._summary_widget: + # Hold summary widget for minimum duration to prevent rapid flickering + if current_time - self._last_summary_time >= self._summary_hold_duration: + # Remove summary widget from list + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item and item.data(Qt.UserRole) == "__summary__": + self.list_widget.takeItem(i) + break + self._summary_widget = None + else: + # Too soon to clear summary, keep it visible + return + + # Clear transition label if it exists + if self._transition_label: + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item and item.data(Qt.UserRole) == "__transition__": + self.list_widget.takeItem(i) + break + self._transition_label = None + + if not file_progresses: + # No files - check if this is a phase transition + if current_phase and self._last_phase and current_phase != self._last_phase: + # Phase changed - show transition message briefly + self._show_transition_message(current_phase) + else: + # Show empty state but keep header stable + self.list_widget.clear() + self._file_items.clear() + + # Update last phase tracker + if current_phase: + self._last_phase = current_phase + return + + # Determine phase from file operations if not provided + if not current_phase and file_progresses: + # Get the most common operation type + operations = [fp.operation for fp in file_progresses if fp.operation != OperationType.UNKNOWN] + if operations: + operation_counts = {} + for op in operations: + operation_counts[op] = operation_counts.get(op, 0) + 1 + most_common = max(operation_counts.items(), key=lambda x: x[1])[0] + phase_map = { + OperationType.DOWNLOAD: "Downloading", + OperationType.EXTRACT: "Extracting", + OperationType.VALIDATE: "Validating", + OperationType.INSTALL: "Installing", + } + current_phase = phase_map.get(most_common, "") + + # Remove completed files + # Build set of current item keys (using stable keys for counters) + current_keys = set() + for fp in file_progresses: + if 'Installing Files:' in fp.filename: + current_keys.add("__installing_files__") + elif 'Converting Texture:' in fp.filename: + base_name = fp.filename.split('(')[0].strip() + current_keys.add(f"__texture_{base_name}__") + elif fp.filename.startswith('BSA:'): + bsa_name = fp.filename.split('(')[0].strip() + current_keys.add(f"__bsa_{bsa_name}__") + else: + current_keys.add(fp.filename) + + for item_key in list(self._file_items.keys()): + if item_key not in current_keys: + # Find and remove the item + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item and item.data(Qt.UserRole) == item_key: + self.list_widget.takeItem(i) + break + del self._file_items[item_key] + + # Update or add files - maintain specific ordering + # Use stable identifiers for special items (like "Installing Files: X/Y") + for idx, file_progress in enumerate(file_progresses): + # For items with changing counters in filename, use a stable key + if 'Installing Files:' in file_progress.filename: + item_key = "__installing_files__" + elif 'Converting Texture:' in file_progress.filename: + # 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}__" + else: + # Use filename as key for regular files + item_key = file_progress.filename + + if item_key in self._file_items: + # Update existing - ensure it's in the right position + item_widget = self._file_items[item_key] + item_widget.update_progress(file_progress) + + # Find the item in the list and move it if needed + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item and item.data(Qt.UserRole) == item_key: + # Item is at position i, should be at position idx + if i != idx: + # Take item from current position and insert at correct position + taken_item = self.list_widget.takeItem(i) + self.list_widget.insertItem(idx, taken_item) + self.list_widget.setItemWidget(taken_item, item_widget) + break + else: + # Add new - insert at specific position (idx) to maintain order + item_widget = FileProgressItem(file_progress) + list_item = QListWidgetItem() + list_item.setSizeHint(item_widget.sizeHint()) + list_item.setData(Qt.UserRole, item_key) # Use stable key + self.list_widget.insertItem(idx, list_item) # Insert at specific position + self.list_widget.setItemWidget(list_item, item_widget) + self._file_items[item_key] = item_widget + + # Update last phase tracker + if current_phase: + self._last_phase = current_phase + + def _show_transition_message(self, new_phase: str): + """Show a brief 'Preparing...' message during phase transitions.""" + self.list_widget.clear() + self._file_items.clear() + + # Header removed - tab label provides context + + # Create or update transition label + if self._transition_label is None or not shiboken6.isValid(self._transition_label): + self._transition_label = QLabel() + self._transition_label.setAlignment(Qt.AlignCenter) + self._transition_label.setStyleSheet("color: #888; font-style: italic; padding: 20px;") + + self._transition_label.setText(f"Preparing {new_phase.lower()}...") + + # Add to list widget + transition_item = QListWidgetItem() + transition_item.setSizeHint(self._transition_label.sizeHint()) + transition_item.setData(Qt.UserRole, "__transition__") + self.list_widget.addItem(transition_item) + self.list_widget.setItemWidget(transition_item, self._transition_label) + + # Remove transition message after brief delay (will be replaced by actual content) + # The next update_files call with actual content will clear this automatically + + def clear(self): + """Clear all file items.""" + self.list_widget.clear() + self._file_items.clear() + self._summary_widget = None + self._transition_label = None + self._last_phase = None + # Header removed - tab label provides context + # Stop CPU timer and clear CPU label + self.stop_cpu_tracking() + self.cpu_label.setText("") + + def start_cpu_tracking(self): + """Start tracking CPU usage.""" + if not self._cpu_timer.isActive(): + # Initialize process and take first measurement to establish baseline + try: + import psutil + import os + self._cpu_process_cache = psutil.Process(os.getpid()) + # First call with interval to establish baseline + self._cpu_process_cache.cpu_percent(interval=0.1) + # Cache child processes + self._child_process_cache = {} + for child in self._cpu_process_cache.children(recursive=True): + try: + child.cpu_percent(interval=0.1) + self._child_process_cache[child.pid] = child + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + except Exception: + pass + + self._cpu_timer.start() + self._update_cpu_usage() # Update immediately after baseline + + def stop_cpu_tracking(self): + """Stop tracking CPU usage.""" + if self._cpu_timer.isActive(): + self._cpu_timer.stop() + + def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0): + """ + Add or update a single status item in the Activity window. + Useful for simple status messages like "Downloading...", "Extracting...", etc. + + Args: + item_id: Unique identifier for this item + label: Display label for the item + progress: Progress percentage (0-100), or 0 for indeterminate + """ + from jackify.shared.progress_models import FileProgress, OperationType + + # Create a FileProgress object for this status item + file_progress = FileProgress( + filename=label, + operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN, + percent=progress, + current_size=0, + total_size=0 + ) + + # Use update_files with a single-item list + self.update_files([file_progress], current_phase=None) + + def _update_cpu_usage(self): + """ + Update CPU usage display with Jackify-related processes. + + Shows total CPU usage across all cores as a percentage of system capacity. + E.g., on an 8-core system: + - 100% = using all 8 cores fully + - 50% = using 4 cores fully (or 8 cores at half capacity) + - 12.5% = using 1 core fully + """ + try: + import psutil + import os + import sys + + # Get or create process cache + if self._cpu_process_cache is None: + self._cpu_process_cache = psutil.Process(os.getpid()) + + # Get current process CPU (Jackify GUI) + # cpu_percent() returns percentage relative to one core + # We need to divide by num_cpus to get system-wide percentage + num_cpus = psutil.cpu_count() + + main_cpu_raw = self._cpu_process_cache.cpu_percent(interval=None) + main_cpu = main_cpu_raw / num_cpus + total_cpu = main_cpu + + # Add CPU usage from ALL child processes recursively + # This includes jackify-engine, texconv.exe, wine processes, etc. + child_count = 0 + child_cpu_sum = 0.0 + try: + children = self._cpu_process_cache.children(recursive=True) + current_child_pids = set() + + for child in children: + try: + current_child_pids.add(child.pid) + + # Check if this is a new process we haven't cached + if child.pid not in self._child_process_cache: + # Cache new process and establish baseline + child.cpu_percent(interval=0.1) + self._child_process_cache[child.pid] = child + # Skip this iteration since baseline was just set + continue + + # Use cached process object for consistent cpu_percent tracking + cached_child = self._child_process_cache[child.pid] + child_cpu_raw = cached_child.cpu_percent(interval=None) + child_cpu = child_cpu_raw / num_cpus + total_cpu += child_cpu + child_count += 1 + child_cpu_sum += child_cpu_raw + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + # Clean up cache for processes that no longer exist + dead_pids = set(self._child_process_cache.keys()) - current_child_pids + for pid in dead_pids: + del self._child_process_cache[pid] + + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + # Also search for ALL Jackify-related processes by name/cmdline + # 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. + tracked_pids = {self._cpu_process_cache.pid} # Avoid double-counting + tracked_pids.update(current_child_pids) + + extra_count = 0 + extra_cpu_sum = 0.0 + try: + for proc in psutil.process_iter(['name', 'pid', 'cmdline']): + try: + if proc.pid in tracked_pids: + continue + + proc_name = proc.info.get('name', '').lower() + cmdline = proc.info.get('cmdline', []) + cmdline_str = ' '.join(cmdline).lower() if cmdline else '' + + # Match Jackify-related process names (include Proton/wine wrappers) + # Include all tools that jackify-engine uses during installation + jackify_names = [ + 'jackify-engine', # Main engine + 'texconv', # Texture conversion + 'texdiag', # Texture diagnostics + 'directxtex', # DirectXTex helper binaries + 'texconv_jackify', # Bundled texconv build + 'texdiag_jackify', # Bundled texdiag build + 'directxtex_jackify', # Bundled DirectXTex build + '7z', # Archive extraction (7z) + '7zz', # Archive extraction (7zz) + 'bsarch', # BSA archive tool + 'wine', # Proton/wine launcher + 'wine64', # Proton/wine 64-bit launcher + 'wine64-preloader', # Proton/wine preloader + 'steam-run', # Steam runtime wrapper + 'proton', # Proton launcher scripts + ] + + # Check process name + is_jackify = any(name in proc_name for name in jackify_names) + + # Check command line (e.g., wine running jackify tools, or paths containing jackify) + if not is_jackify and cmdline_str: + # Check for jackify tool names in command line (catches wine running texconv.exe, etc.) + # This includes: texconv, texconv.exe, texdiag, 7z, 7zz, bsarch, jackify-engine + is_jackify = any(name in cmdline_str for name in jackify_names) + + # Also check for .exe variants (wine runs .exe files) + if not is_jackify: + exe_names = [f'{name}.exe' for name in jackify_names] + is_jackify = any(exe_name in cmdline_str for exe_name in exe_names) + + # Also check if command line contains jackify paths + if not is_jackify: + is_jackify = 'jackify' in cmdline_str and any( + tool in cmdline_str for tool in ['engine', 'tools', 'binaries'] + ) + + if is_jackify: + # Check if this is a new process we haven't cached + if proc.pid not in self._child_process_cache: + # Establish baseline for new process and cache it + proc.cpu_percent(interval=0.1) + self._child_process_cache[proc.pid] = proc + # Skip this iteration since baseline was just set + continue + + # Use cached process object + cached_proc = self._child_process_cache[proc.pid] + proc_cpu_raw = cached_proc.cpu_percent(interval=None) + proc_cpu = proc_cpu_raw / num_cpus + total_cpu += proc_cpu + tracked_pids.add(proc.pid) + extra_count += 1 + extra_cpu_sum += proc_cpu_raw + + except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError): + pass + except Exception: + pass + + # Smooth the value slightly to reduce jitter (less aggressive than before) + if self._last_cpu_percent > 0: + total_cpu = (self._last_cpu_percent * 0.3) + (total_cpu * 0.7) + self._last_cpu_percent = total_cpu + + # Always show CPU percentage when tracking is active + # Cap at 100% for display (shouldn't exceed but just in case) + display_percent = min(100.0, total_cpu) + + if display_percent >= 0.1: + self.cpu_label.setText(f"CPU: {display_percent:.0f}%") + else: + # Show 0% instead of hiding to indicate tracking is active + self.cpu_label.setText("CPU: 0%") + + except Exception as e: + # Show error indicator if tracking fails + import sys + print(f"CPU tracking error: {e}", file=sys.stderr) + self.cpu_label.setText("") + + diff --git a/jackify/frontends/gui/widgets/progress_indicator.py b/jackify/frontends/gui/widgets/progress_indicator.py new file mode 100644 index 0000000..e4477e9 --- /dev/null +++ b/jackify/frontends/gui/widgets/progress_indicator.py @@ -0,0 +1,178 @@ +""" +Progress Indicator Widget + +Enhanced status banner widget that displays overall installation progress. +R&D NOTE: This is experimental code for investigation purposes. +""" + +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QProgressBar, QSizePolicy +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +from jackify.shared.progress_models import InstallationProgress +from ..shared_theme import JACKIFY_COLOR_BLUE + + +class OverallProgressIndicator(QWidget): + """ + Enhanced progress indicator widget showing: + - Phase name + - Step progress [12/14] + - Data progress (1.1GB/56.3GB) + - Overall percentage + - Optional progress bar + """ + + def __init__(self, parent=None, show_progress_bar=True): + """ + Initialize progress indicator. + + Args: + parent: Parent widget + show_progress_bar: If True, show visual progress bar in addition to text + """ + super().__init__(parent) + self.show_progress_bar = show_progress_bar + self._setup_ui() + + def _setup_ui(self): + """Set up the UI components.""" + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + # Status text label (similar to TTW status banner) + self.status_label = QLabel("Ready to install") + self.status_label.setAlignment(Qt.AlignCenter) + self.status_label.setStyleSheet(f""" + background-color: #2a2a2a; + color: {JACKIFY_COLOR_BLUE}; + padding: 6px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 13px; + """) + self.status_label.setMaximumHeight(34) + self.status_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + + # Progress bar (optional, shown below or integrated) + if self.show_progress_bar: + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setFormat("%p%") + # Use white text with shadow/outline effect for readability on both dark and blue backgrounds + self.progress_bar.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid #444; + border-radius: 4px; + text-align: center; + background-color: #1a1a1a; + color: #fff; + font-weight: bold; + height: 20px; + }} + QProgressBar::chunk {{ + background-color: {JACKIFY_COLOR_BLUE}; + border-radius: 3px; + }} + """) + self.progress_bar.setMaximumHeight(20) + self.progress_bar.setVisible(True) + + # Layout: text on left, progress bar on right (or stacked) + if self.show_progress_bar: + # Horizontal layout: status text takes available space, progress bar fixed width + layout.addWidget(self.status_label, 1) + layout.addWidget(self.progress_bar, 0) # Fixed width + self.progress_bar.setFixedWidth(100) # Fixed width for progress bar + else: + # Just the status label, full width + layout.addWidget(self.status_label, 1) + + # Constrain widget height to prevent unwanted vertical expansion + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setMaximumHeight(34) # Match status label height + + def update_progress(self, progress: InstallationProgress): + """ + Update the progress indicator with new progress state. + + Args: + progress: InstallationProgress object with current state + """ + # Update status text + display_text = progress.display_text + if not display_text or display_text == "Processing...": + display_text = progress.phase_name or progress.phase.value.title() or "Processing..." + + self.status_label.setText(display_text) + + # Update progress bar if enabled + if self.show_progress_bar and hasattr(self, 'progress_bar'): + # Calculate progress - prioritize data progress, then step progress, then overall_percent + display_percent = 0.0 + + # Check if we're in BSA building phase (detected by phase label) + from jackify.shared.progress_models import InstallationPhase + is_bsa_building = progress.get_phase_label() == "Building BSAs" + + # For install/extract/BSA building phases, prefer step-based progress (more accurate) + if progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT) or is_bsa_building: + if progress.phase_max_steps > 0: + display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0 + elif progress.data_total > 0 and progress.data_processed > 0: + display_percent = (progress.data_processed / progress.data_total) * 100.0 + else: + # If no step/data info, use overall_percent but only if it's reasonable + # Don't carry over 100% from previous phase + if progress.overall_percent > 0 and progress.overall_percent < 100.0: + display_percent = progress.overall_percent + else: + display_percent = 0.0 # Reset if we don't have valid progress + else: + # For other phases, prefer data progress, then overall_percent, then step progress + if progress.data_total > 0 and progress.data_processed > 0: + display_percent = (progress.data_processed / progress.data_total) * 100.0 + elif progress.overall_percent > 0: + display_percent = progress.overall_percent + elif progress.phase_max_steps > 0: + display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0 + + self.progress_bar.setValue(int(display_percent)) + + # Update tooltip with detailed information + tooltip_parts = [] + if progress.phase_name: + tooltip_parts.append(f"Phase: {progress.phase_name}") + if progress.phase_progress_text: + tooltip_parts.append(f"Step: {progress.phase_progress_text}") + if progress.data_progress_text: + tooltip_parts.append(f"Data: {progress.data_progress_text}") + if progress.overall_percent > 0: + tooltip_parts.append(f"Overall: {progress.overall_percent:.1f}%") + + if tooltip_parts: + self.progress_bar.setToolTip("\n".join(tooltip_parts)) + self.status_label.setToolTip("\n".join(tooltip_parts)) + + def set_status(self, text: str, percent: int = None): + """ + Set status text directly without full progress update. + + Args: + text: Status text to display + percent: Optional progress percentage (0-100) + """ + self.status_label.setText(text) + if percent is not None and self.show_progress_bar and hasattr(self, 'progress_bar'): + self.progress_bar.setValue(int(percent)) + + def reset(self): + """Reset the progress indicator to initial state.""" + self.status_label.setText("Ready to install") + if self.show_progress_bar and hasattr(self, 'progress_bar'): + self.progress_bar.setValue(0) + self.progress_bar.setToolTip("") + self.status_label.setToolTip("") + diff --git a/jackify/shared/progress_models.py b/jackify/shared/progress_models.py new file mode 100644 index 0000000..2c0c5ce --- /dev/null +++ b/jackify/shared/progress_models.py @@ -0,0 +1,318 @@ +""" +Progress Data Models + +Shared data models for representing installation progress state. +Used by both parser and GUI components. +""" + +from dataclasses import dataclass, field +from typing import List, Dict, Optional +from enum import Enum +import time + + +class InstallationPhase(Enum): + """Installation phases that can be detected.""" + UNKNOWN = "unknown" + INITIALIZATION = "initialization" + DOWNLOAD = "download" + EXTRACT = "extract" + VALIDATE = "validate" + INSTALL = "install" + FINALIZE = "finalize" + + +class OperationType(Enum): + """Types of operations being performed on files.""" + DOWNLOAD = "download" + EXTRACT = "extract" + VALIDATE = "validate" + INSTALL = "install" + UNKNOWN = "unknown" + + +@dataclass +class FileProgress: + """Represents progress for a single file operation.""" + filename: str + operation: OperationType + percent: float = 0.0 # 0-100 + current_size: int = 0 # Bytes processed + total_size: int = 0 # Total bytes (0 if unknown) + speed: float = -1.0 # Bytes per second (-1 = not provided by engine) + last_update: float = field(default_factory=time.time) + + def __post_init__(self): + """Ensure percent is in valid range.""" + self.percent = max(0.0, min(100.0, self.percent)) + + @property + def is_complete(self) -> bool: + """Check if file operation is complete.""" + return self.percent >= 100.0 or (self.total_size > 0 and self.current_size >= self.total_size) + + @property + def size_display(self) -> str: + """Get human-readable size display.""" + if self.total_size > 0: + return f"{self._format_bytes(self.current_size)}/{self._format_bytes(self.total_size)}" + elif self.current_size > 0: + return f"{self._format_bytes(self.current_size)}" + else: + return "" + + @property + def speed_display(self) -> str: + """Get human-readable speed display.""" + if self.speed <= 0: + return "" + return f"{self._format_bytes(int(self.speed))}/s" + + @staticmethod + def _format_bytes(bytes_val: int) -> str: + """Format bytes to human-readable format.""" + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_val < 1024.0: + return f"{bytes_val:.1f}{unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.1f}PB" + + +@dataclass +class InstallationProgress: + """Complete installation progress state.""" + phase: InstallationPhase = InstallationPhase.UNKNOWN + phase_name: str = "" # Human-readable phase name + phase_step: int = 0 # Current step in phase + phase_max_steps: int = 0 # Total steps in phase (0 if unknown) + overall_percent: float = 0.0 # 0-100 overall progress + data_processed: int = 0 # Bytes processed + data_total: int = 0 # Total bytes (0 if unknown) + active_files: List[FileProgress] = field(default_factory=list) + speeds: Dict[str, float] = field(default_factory=dict) # Speed by operation type + speed_timestamps: Dict[str, float] = field(default_factory=dict) # Last time each speed updated + timestamp: float = field(default_factory=time.time) + message: str = "" # Current status message + texture_conversion_current: int = 0 # Current texture being converted + texture_conversion_total: int = 0 # Total textures to convert + bsa_building_current: int = 0 # Current BSA being built + bsa_building_total: int = 0 # Total BSAs to build + + def __post_init__(self): + """Ensure percent is in valid range.""" + self.overall_percent = max(0.0, min(100.0, self.overall_percent)) + + @property + def phase_progress_text(self) -> str: + """Get phase progress text like '[12/14]'.""" + if self.phase_max_steps > 0: + return f"[{self.phase_step}/{self.phase_max_steps}]" + elif self.phase_step > 0: + return f"[{self.phase_step}]" + else: + return "" + + @property + def data_progress_text(self) -> str: + """Get data progress text like '1.1GB/56.3GB'.""" + if self.data_total > 0: + return f"{FileProgress._format_bytes(self.data_processed)}/{FileProgress._format_bytes(self.data_total)}" + elif self.data_processed > 0: + return f"{FileProgress._format_bytes(self.data_processed)}" + else: + return "" + + def get_overall_speed_display(self) -> str: + """Get overall speed display from speeds dict or sum of active files.""" + def _fresh_speed(op_key: str) -> float: + """Return speed if recently updated, else 0.""" + if op_key not in self.speeds: + return 0.0 + updated_at = self.speed_timestamps.get(op_key, 0.0) + if updated_at == 0.0: + return 0.0 + if time.time() - updated_at > 2.0: + return 0.0 + return max(0.0, self.speeds.get(op_key, 0.0)) + + # Prefer aggregate speeds that match the current phase + phase_operation_map = { + InstallationPhase.DOWNLOAD: 'download', + InstallationPhase.EXTRACT: 'extract', + InstallationPhase.VALIDATE: 'validate', + InstallationPhase.INSTALL: 'install', + } + active_op = phase_operation_map.get(self.phase) + if active_op: + op_speed = _fresh_speed(active_op) + if op_speed > 0: + return FileProgress._format_bytes(int(op_speed)) + "/s" + + # Otherwise check other operations in priority order + for op_key in ['download', 'extract', 'validate', 'install']: + op_speed = _fresh_speed(op_key) + if op_speed > 0: + return FileProgress._format_bytes(int(op_speed)) + "/s" + + # If engine didn't report aggregate speed, fall back to summing active file speeds + if self.active_files: + total_speed = sum( + fp.speed + for fp in self.active_files + if fp.speed > 0 and not fp.is_complete + ) + if total_speed > 0: + return FileProgress._format_bytes(int(total_speed)) + "/s" + return "" + + def get_phase_label(self) -> str: + """Return a short, stable label for the current phase.""" + # Check for specific operations first (more specific than generic phase labels) + if self.phase_name: + phase_lower = self.phase_name.lower() + # Check for texture conversion (very specific) + if 'converting' in phase_lower and 'texture' in phase_lower: + return "Converting Textures" + # Check for BSA building + if 'bsa' in phase_lower or ('building' in phase_lower and self.phase == InstallationPhase.INSTALL): + return "Building BSAs" + + # For FINALIZE phase, always prefer phase_name over generic "Finalising" label + # This allows post-install steps to show specific labels (e.g., "Installing Wine components") + if self.phase == InstallationPhase.FINALIZE and self.phase_name: + return self.phase_name + + phase_labels = { + InstallationPhase.DOWNLOAD: "Downloading", + InstallationPhase.EXTRACT: "Extracting", + InstallationPhase.VALIDATE: "Validating", + InstallationPhase.INSTALL: "Installing", + InstallationPhase.FINALIZE: "Finalising", + InstallationPhase.INITIALIZATION: "Preparing", + } + if self.phase in phase_labels: + return phase_labels[self.phase] + if self.phase_name: + return self.phase_name + if self.phase != InstallationPhase.UNKNOWN: + return self.phase.value.title() + return "" + + @property + def display_text(self) -> str: + """Get formatted display text for progress indicator.""" + parts = [] + + # Phase name + phase_label = self.get_phase_label() + if phase_label: + parts.append(phase_label) + + # For BSA building, show BSA count instead of generic phase progress or data progress + if self.bsa_building_total > 0: + # BSA building in progress - show BSA count + parts.append(f"[{self.bsa_building_current}/{self.bsa_building_total}]") + # Don't show data progress during BSA building (it's usually complete at 100%) + else: + # Normal phase - show phase progress + phase_prog = self.phase_progress_text + if phase_prog: + parts.append(phase_prog) + + # Data progress (but not during BSA building) + data_prog = self.data_progress_text + if data_prog: + # Don't show if it's 100% complete (adds no value) + if self.data_total > 0 and self.data_processed < self.data_total: + parts.append(f"({data_prog})") + elif self.data_total == 0 and self.data_processed > 0: + # Show partial progress even without total + parts.append(f"({data_prog})") + + # Overall speed (if available, but not during BSA building) + if self.bsa_building_total == 0: + speed_display = self.get_overall_speed_display() + if speed_display: + parts.append(f"- {speed_display}") + + # Overall percentage removed - redundant with progress bar display + + return " ".join(parts) if parts else "Processing..." + + def get_speed(self, operation: str) -> float: + """Get speed for a specific operation type.""" + return self.speeds.get(operation.lower(), 0.0) + + 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 + if file_progress.percent >= 100.0: + # Check if this file already exists at 100% + existing = None + for f in self.active_files: + if f.filename == file_progress.filename: + existing = f + break + + 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 + 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 + return + + # Remove existing entry for same filename if present + existing = None + for f in self.active_files: + if f.filename == file_progress.filename: + existing = f + break + + if existing: + # Update existing entry (preserve original add time for minimum display) + existing.operation = file_progress.operation + existing.percent = file_progress.percent + existing.current_size = file_progress.current_size + existing.total_size = file_progress.total_size + existing.speed = file_progress.speed + existing.last_update = time.time() + # If file just reached 100%, ensure we keep it visible for minimum time + if file_progress.percent >= 100.0 and existing.percent < 100.0: + # File just completed - ensure it stays visible + existing.last_update = time.time() + else: + # Add new entry - set initial timestamp + file_progress.last_update = time.time() + self.active_files.append(file_progress) + + # Update timestamp + self.timestamp = time.time() + + def remove_completed_files(self, stale_seconds: float = 0.5, stale_incomplete_seconds: float = 30.0): + """ + Remove files that are marked as complete, or files that haven't been updated in a while. + + Args: + stale_seconds: Keep completed files for this many seconds before removing (allows brief display at 100%) + Reduced to 0.5s so tiny files that complete instantly still appear briefly + stale_incomplete_seconds: Remove incomplete files that haven't been updated in this many seconds (handles stuck files) + """ + current_time = time.time() + self.active_files = [ + f for f in self.active_files + # Keep files that are: + # 1. Not complete AND updated recently (active files) + # 2. Complete AND updated very recently (show at 100% briefly so users can see all files, even tiny ones) + if (not f.is_complete and (current_time - f.last_update) < stale_incomplete_seconds) or \ + (f.is_complete and (current_time - f.last_update) < stale_seconds) + ] + + def update_speed(self, operation: str, speed: float): + """Update speed for an operation type.""" + op_key = operation.lower() + self.speeds[op_key] = max(0.0, speed) + self.speed_timestamps[op_key] = time.time() + self.timestamp = time.time() + diff --git a/jackify/shared/steam_utils.py b/jackify/shared/steam_utils.py new file mode 100644 index 0000000..3b45ba6 --- /dev/null +++ b/jackify/shared/steam_utils.py @@ -0,0 +1,77 @@ +""" +Steam Utilities Module + +Centralized Steam installation type detection to avoid redundant subprocess calls. +""" + +import logging +import subprocess +import shutil +from typing import Tuple + +logger = logging.getLogger(__name__) + + +def detect_steam_installation_types() -> Tuple[bool, bool]: + """ + Detect Steam installation types at startup. + + Performs detection ONCE and returns results to be cached in SystemInfo. + + Returns: + Tuple[bool, bool]: (is_flatpak_steam, is_native_steam) + """ + is_flatpak = _detect_flatpak_steam() + is_native = _detect_native_steam() + + logger.info(f"Steam installation detection: Flatpak={is_flatpak}, Native={is_native}") + + return is_flatpak, is_native + + +def _detect_flatpak_steam() -> bool: + """Detect if Steam is installed as a Flatpak.""" + try: + # First check if flatpak command exists + if not shutil.which('flatpak'): + 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 + text=True, + timeout=5 + ) + + if result.returncode == 0 and 'com.valvesoftware.Steam' in result.stdout: + logger.debug("Flatpak Steam detected") + return True + + except Exception as e: + logger.debug(f"Error detecting Flatpak Steam: {e}") + + return False + + +def _detect_native_steam() -> bool: + """Detect if native Steam installation exists.""" + try: + # Check for common Steam paths + import os + steam_paths = [ + os.path.expanduser("~/.steam/steam"), + os.path.expanduser("~/.local/share/Steam"), + os.path.expanduser("~/.steam/root") + ] + + for path in steam_paths: + if os.path.exists(path): + logger.debug(f"Native Steam detected at: {path}") + return True + + except Exception as e: + logger.debug(f"Error detecting native Steam: {e}") + + return False diff --git a/requirements.txt b/requirements.txt index 6afa70d..12c1573 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,9 @@ psutil>=5.8.0 requests>=2.25.0 tqdm>=4.65.0 +# OAuth token encryption (pure-Python compatible) +pycryptodome>=3.19.0 + # Configuration file handling PyYAML>=6.0