mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:17:58 +02:00
Sync from development - prepare for v0.4.0
This commit is contained in:
@@ -91,12 +91,17 @@ class ConfigureModlistCommand:
|
||||
try:
|
||||
# Build configuration context from args
|
||||
context = self._build_context_from_args(args)
|
||||
|
||||
|
||||
# Use legacy implementation for now - will migrate to backend services later
|
||||
result = self._execute_legacy_configuration(context)
|
||||
|
||||
|
||||
logger.info("Finished non-interactive modlist configuration")
|
||||
return 0 if result is not True else 1
|
||||
|
||||
if not getattr(args, 'skip_confirmation', False) and context.get('install_dir'):
|
||||
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
|
||||
prompt_ttw_if_eligible(context['install_dir'], context.get('modlist_name') or '')
|
||||
|
||||
return 0 if result is True else 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure modlist: {e}")
|
||||
@@ -156,4 +161,4 @@ class ConfigureModlistCommand:
|
||||
# The _configure_new_modlist method already calls run_modlist_configuration_phase internally
|
||||
# So we don't need to call it again here
|
||||
|
||||
return result
|
||||
return result
|
||||
|
||||
89
jackify/frontends/cli/commands/setup_mo2.py
Normal file
89
jackify/frontends/cli/commands/setup_mo2.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Setup Mod Organizer 2 Command
|
||||
|
||||
CLI interface for downloading and configuring a standalone MO2 instance.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from jackify.backend.services.mo2_setup_service import MO2SetupService, _is_dangerous_path
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SUCCESS, COLOR_ERROR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SetupMO2Command:
|
||||
"""CLI command for standalone MO2 setup"""
|
||||
|
||||
def run(self):
|
||||
"""Execute the MO2 setup workflow"""
|
||||
print(f"\n{COLOR_INFO}=== Setup Mod Organizer 2 ==={COLOR_RESET}\n")
|
||||
print("Downloads the latest MO2 release, adds it to Steam, and configures a Proton prefix.")
|
||||
print("Steam will be restarted during this process.\n")
|
||||
|
||||
# Install directory
|
||||
default_dir = str(Path.home() / "ModOrganizer2")
|
||||
dir_input = input(
|
||||
f"{COLOR_PROMPT}Installation directory [{default_dir}]: {COLOR_RESET}"
|
||||
).strip()
|
||||
install_dir = Path(dir_input) if dir_input else Path(default_dir)
|
||||
|
||||
# Danger check
|
||||
if _is_dangerous_path(install_dir):
|
||||
print(f"{COLOR_ERROR}Refusing to install to a dangerous directory: {install_dir}{COLOR_RESET}")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
return
|
||||
|
||||
# Non-empty directory warning
|
||||
if install_dir.exists() and any(install_dir.iterdir()):
|
||||
print(f"\n{COLOR_ERROR}[WARN] Directory is not empty: {install_dir}{COLOR_RESET}")
|
||||
confirm = input(
|
||||
f"{COLOR_PROMPT}Files may be overwritten. Continue anyway? (y/N): {COLOR_RESET}"
|
||||
).strip().lower()
|
||||
if confirm != 'y':
|
||||
print("Cancelled.")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
return
|
||||
|
||||
# Shortcut name
|
||||
default_name = "Mod Organizer 2"
|
||||
name_input = input(
|
||||
f"{COLOR_PROMPT}Steam shortcut name [{default_name}]: {COLOR_RESET}"
|
||||
).strip()
|
||||
shortcut_name = name_input if name_input else default_name
|
||||
|
||||
# Confirm
|
||||
print(f"\n{COLOR_INFO}Install directory: {install_dir}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Shortcut name: {shortcut_name}{COLOR_RESET}")
|
||||
confirm = input(
|
||||
f"\n{COLOR_PROMPT}Proceed? (Y/n): {COLOR_RESET}"
|
||||
).strip().lower()
|
||||
if confirm == 'n':
|
||||
print("Cancelled.")
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
return
|
||||
|
||||
print(f"\n{COLOR_INFO}Starting MO2 setup...{COLOR_RESET}\n")
|
||||
|
||||
def _progress(msg: str):
|
||||
print(f"{COLOR_INFO} {msg}{COLOR_RESET}")
|
||||
|
||||
service = MO2SetupService()
|
||||
success, app_id, error_msg = service.setup_mo2(
|
||||
install_dir=install_dir,
|
||||
shortcut_name=shortcut_name,
|
||||
progress_callback=_progress,
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"\n{COLOR_SUCCESS}{'='*60}{COLOR_RESET}")
|
||||
print(f"{COLOR_SUCCESS}MO2 setup complete!{COLOR_RESET}")
|
||||
print(f"{COLOR_SUCCESS}{'='*60}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Launch Mod Organizer 2 from your Steam library.{COLOR_RESET}")
|
||||
else:
|
||||
print(f"\n{COLOR_ERROR}MO2 setup failed: {error_msg}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}Check logs for details.{COLOR_RESET}")
|
||||
|
||||
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
|
||||
@@ -33,11 +33,9 @@ from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
from jackify.backend.handlers.menu_handler import MenuHandler
|
||||
from jackify.backend.handlers.mo2_handler import MO2Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JackifyCLI:
|
||||
"""Main application class for Jackify CLI Frontend"""
|
||||
|
||||
@@ -92,10 +90,6 @@ class JackifyCLI:
|
||||
self.selected_modlist = None
|
||||
self.setup_complete = False
|
||||
|
||||
def _debug_print(self, message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
if hasattr(self, '_debug_mode') and self._debug_mode:
|
||||
logger.debug(message)
|
||||
|
||||
def _configure_logging_early(self):
|
||||
"""Configure logging to be quiet during initialization, will be adjusted after arg parsing"""
|
||||
@@ -113,22 +107,40 @@ class JackifyCLI:
|
||||
"""Configure final logging level based on parsed arguments"""
|
||||
# Use the existing LoggingHandler for proper log rotation
|
||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
|
||||
# Set up CLI-specific logging with rotation
|
||||
logging_handler = LoggingHandler()
|
||||
logging_handler.rotate_log_for_logger('jackify-cli', 'Modlist_Install_workflow_cli.log')
|
||||
cli_logger = logging_handler.setup_logger('jackify-cli', 'Modlist_Install_workflow_cli.log')
|
||||
# Keep CLI logging in the canonical modlist workflow log file.
|
||||
logging_handler.rotate_log_for_logger('jackify-cli', 'Modlist_Install_workflow.log')
|
||||
cli_logger = logging_handler.setup_logger('jackify-cli', 'Modlist_Install_workflow.log')
|
||||
|
||||
# Remove legacy CLI log artifact if present (old naming path no longer used).
|
||||
try:
|
||||
legacy_cli_log = get_jackify_logs_dir() / "Modlist_Install_workflow_cli.log"
|
||||
if legacy_cli_log.exists():
|
||||
legacy_cli_log.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Configure logging level
|
||||
if self.args.debug:
|
||||
cli_logger.setLevel(logging.DEBUG)
|
||||
root_level = logging.DEBUG
|
||||
print("Debug logging enabled for console and file")
|
||||
elif self.args.verbose:
|
||||
cli_logger.setLevel(logging.INFO)
|
||||
root_level = logging.INFO
|
||||
print("Verbose logging enabled for console and file")
|
||||
else:
|
||||
# Keep it at WARNING level for clean startup
|
||||
# Keep console clean in normal mode; details remain in workflow log.
|
||||
cli_logger.setLevel(logging.WARNING)
|
||||
root_level = logging.ERROR
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(root_level)
|
||||
for handler in root_logger.handlers:
|
||||
handler.setLevel(root_level)
|
||||
|
||||
def _is_steamdeck(self):
|
||||
"""Check if running on Steam Deck"""
|
||||
@@ -192,7 +204,7 @@ class JackifyCLI:
|
||||
def _check_for_updates_on_startup(self):
|
||||
"""Check for updates on startup in background thread"""
|
||||
try:
|
||||
self._debug_print("Checking for updates on startup...")
|
||||
logger.debug("Checking for updates on startup...")
|
||||
|
||||
def update_check_callback(update_info):
|
||||
"""Handle update check results"""
|
||||
@@ -207,15 +219,15 @@ class JackifyCLI:
|
||||
print(f"\nTo update, run: jackify --update")
|
||||
print("Or visit: https://github.com/Omni-guides/Jackify/releases")
|
||||
else:
|
||||
self._debug_print("No updates available")
|
||||
logger.debug("No updates available")
|
||||
except Exception as e:
|
||||
self._debug_print(f"Error showing update info: {e}")
|
||||
logger.debug(f"Error showing update info: {e}")
|
||||
|
||||
# Check for updates in background
|
||||
self.backend_services['update_service'].check_for_updates_async(update_check_callback)
|
||||
|
||||
except Exception as e:
|
||||
self._debug_print(f"Error checking for updates on startup: {e}")
|
||||
logger.debug(f"Error checking for updates on startup: {e}")
|
||||
# Continue anyway - don't block startup on update check errors
|
||||
|
||||
def _handle_update(self):
|
||||
@@ -326,7 +338,6 @@ class JackifyCLI:
|
||||
self.menu_handler = self.menu # Alias for backend compatibility
|
||||
|
||||
# Add MO2 handler to the menu handler for additional tasks menu
|
||||
self.menu.mo2_handler = MO2Handler(self.menu)
|
||||
|
||||
# Set steamdeck attribute that menus expect
|
||||
self.steamdeck = self.system_info.is_steamdeck
|
||||
@@ -359,24 +370,24 @@ class JackifyCLI:
|
||||
# Now that we have args, configure logging properly
|
||||
self._configure_logging_final()
|
||||
|
||||
self._debug_print('Initializing Jackify CLI Frontend')
|
||||
self._debug_print('JackifyCLI.run() called')
|
||||
self._debug_print(f'Parsed args: {self.args}')
|
||||
logger.debug('Initializing Jackify CLI Frontend')
|
||||
logger.debug('JackifyCLI.run() called')
|
||||
logger.debug(f'Parsed args: {self.args}')
|
||||
|
||||
# Handle update functionality
|
||||
if getattr(self.args, 'update', False):
|
||||
self._debug_print('Entering update workflow')
|
||||
logger.debug('Entering update workflow')
|
||||
return self._handle_update()
|
||||
|
||||
# Handle legacy restart-steam functionality (temporary)
|
||||
if getattr(self.args, 'restart_steam', False):
|
||||
self._debug_print('Entering restart_steam workflow')
|
||||
logger.debug('Entering restart_steam workflow')
|
||||
return self._handle_restart_steam()
|
||||
|
||||
|
||||
# Handle install-modlist top-level functionality
|
||||
if getattr(self.args, 'install_modlist', False):
|
||||
self._debug_print('Entering install_modlist workflow')
|
||||
logger.debug('Entering install_modlist workflow')
|
||||
return self.commands['install_modlist'].execute_top_level(self.args)
|
||||
|
||||
# Handle subcommands
|
||||
@@ -514,12 +525,10 @@ class JackifyCLI:
|
||||
command_instance.run()
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
"""Legacy main function (not used in new structure)"""
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Do not call directly -- use __main__.py
|
||||
print("Please use: python -m jackify.frontends.cli")
|
||||
|
||||
@@ -37,8 +37,10 @@ class AdditionalMenuHandler:
|
||||
print(f" {COLOR_ACTION}→ Install TTW using TTW_Linux_Installer{COLOR_RESET}")
|
||||
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Install Wabbajack Application")
|
||||
print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via Proton){COLOR_RESET}")
|
||||
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Setup Mod Organizer 2")
|
||||
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
|
||||
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
|
||||
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
|
||||
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
|
||||
|
||||
if selection.lower() == 'q': # Allow 'q' to re-display menu
|
||||
continue
|
||||
@@ -48,21 +50,14 @@ class AdditionalMenuHandler:
|
||||
self._execute_ttw_install(cli_instance)
|
||||
elif selection == "3":
|
||||
self._execute_install_wabbajack(cli_instance)
|
||||
elif selection == "4":
|
||||
self._execute_setup_mo2(cli_instance)
|
||||
elif selection == "0":
|
||||
break
|
||||
else:
|
||||
print("Invalid selection. Please try again.")
|
||||
time.sleep(1)
|
||||
|
||||
def _execute_legacy_install_mo2(self, cli_instance):
|
||||
"""LEGACY BRIDGE: Execute MO2 installation"""
|
||||
# LEGACY BRIDGE: Use legacy imports until backend migration complete
|
||||
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'mo2_handler'):
|
||||
cli_instance.menu.mo2_handler.install_mo2()
|
||||
else:
|
||||
print(f"{COLOR_INFO}MO2 handler not available - this will be implemented in Phase 2.3{COLOR_RESET}")
|
||||
input("\nPress Enter to continue...")
|
||||
|
||||
def _execute_legacy_recovery_menu(self, cli_instance):
|
||||
"""LEGACY BRIDGE: Execute recovery menu"""
|
||||
# Handled by RecoveryMenuHandler
|
||||
@@ -314,3 +309,12 @@ class AdditionalMenuHandler:
|
||||
if self.logger:
|
||||
self.logger.debug("AdditionalMenuHandler: Executing Install Wabbajack command")
|
||||
command.run()
|
||||
|
||||
def _execute_setup_mo2(self, cli_instance):
|
||||
"""Execute standalone MO2 setup"""
|
||||
from jackify.frontends.cli.commands.setup_mo2 import SetupMO2Command
|
||||
|
||||
command = SetupMO2Command()
|
||||
if self.logger:
|
||||
self.logger.debug("AdditionalMenuHandler: Executing Setup MO2 command")
|
||||
command.run()
|
||||
|
||||
@@ -8,6 +8,7 @@ Usage: python -m jackify.frontends.gui
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
# Check if launched with jackify:// protocol URL (OAuth callback)
|
||||
if len(sys.argv) > 1 and sys.argv[1].startswith('jackify://'):
|
||||
@@ -18,110 +19,57 @@ def main():
|
||||
from jackify.frontends.gui.main import main as gui_main
|
||||
gui_main()
|
||||
|
||||
|
||||
def handle_protocol_url(url: str):
|
||||
"""Handle jackify:// protocol URL (OAuth callback)"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Enhanced logging with system information
|
||||
"""Handle jackify:// protocol URL (OAuth callback)."""
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
parsed = urlparse(url)
|
||||
full_path = f"/{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path
|
||||
|
||||
if full_path != '/oauth/callback':
|
||||
_log_error(f"Unknown protocol path: {full_path}")
|
||||
return
|
||||
|
||||
params = parse_qs(parsed.query)
|
||||
code = params.get('code', [None])[0]
|
||||
state = params.get('state', [None])[0]
|
||||
error = params.get('error', [None])[0]
|
||||
|
||||
if error:
|
||||
error_description = params.get('error_description', ['No description'])[0]
|
||||
_log_error(f"OAuth error: {error} — {error_description}")
|
||||
return
|
||||
|
||||
if not code or not state:
|
||||
_log_error("OAuth callback missing required parameters (code or state)")
|
||||
return
|
||||
|
||||
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
|
||||
try:
|
||||
callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
callback_file.write_text(f"{code}\n{state}")
|
||||
except Exception as e:
|
||||
_log_error(f"Failed to write OAuth callback file: {e}")
|
||||
|
||||
|
||||
def _log_error(message: str):
|
||||
"""Write an error entry to protocol_handler.log. Only called on failure."""
|
||||
import datetime
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
log_dir = get_jackify_logs_dir()
|
||||
except Exception as e:
|
||||
# Fallback if config system fails
|
||||
except Exception:
|
||||
log_dir = Path.home() / ".config" / "jackify" / "logs"
|
||||
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "protocol_handler.log"
|
||||
|
||||
def log(msg):
|
||||
with open(log_file, 'a') as f:
|
||||
import datetime
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
f.write(f"[{timestamp}] {msg}\n")
|
||||
f.flush() # Ensure immediate write
|
||||
|
||||
try:
|
||||
# Log system information for debugging
|
||||
log(f"=== Protocol Handler Invoked ===")
|
||||
log(f"URL: {url}")
|
||||
log(f"Python executable: {sys.executable}")
|
||||
log(f"Script path: {sys.argv[0]}")
|
||||
log(f"Working directory: {os.getcwd()}")
|
||||
log(f"APPIMAGE env: {os.environ.get('APPIMAGE', 'Not set')}")
|
||||
log(f"APPDIR env: {os.environ.get('APPDIR', 'Not set')}")
|
||||
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "protocol_handler.log"
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(log_file, 'a') as f:
|
||||
f.write(f"[{timestamp}] ERROR: {message}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parsed = urlparse(url)
|
||||
log(f"Parsed URL - scheme: {parsed.scheme}, netloc: {parsed.netloc}, path: {parsed.path}, query: {parsed.query}")
|
||||
|
||||
# URL format: jackify://oauth/callback?code=XXX&state=YYY
|
||||
# urlparse treats "oauth" as netloc, so reconstruct full path
|
||||
full_path = f"/{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path
|
||||
log(f"Reconstructed path: {full_path}")
|
||||
|
||||
if full_path == '/oauth/callback':
|
||||
params = parse_qs(parsed.query)
|
||||
code = params.get('code', [None])[0]
|
||||
state = params.get('state', [None])[0]
|
||||
error = params.get('error', [None])[0]
|
||||
|
||||
log(f"OAuth parameters - Code: {'Present' if code else 'Missing'}, State: {'Present' if state else 'Missing'}, Error: {error}")
|
||||
|
||||
if error:
|
||||
log(f"ERROR: OAuth error received: {error}")
|
||||
error_description = params.get('error_description', ['No description'])[0]
|
||||
log(f"ERROR: OAuth error description: {error_description}")
|
||||
print(f"OAuth authorization failed: {error} - {error_description}")
|
||||
elif code and state:
|
||||
# Write to callback file for OAuth service to pick up
|
||||
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
|
||||
log(f"Creating callback file: {callback_file}")
|
||||
|
||||
try:
|
||||
callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
callback_content = f"{code}\n{state}"
|
||||
callback_file.write_text(callback_content)
|
||||
|
||||
# Verify file was written
|
||||
if callback_file.exists():
|
||||
written_content = callback_file.read_text()
|
||||
log(f"Callback file created successfully, size: {len(written_content)} bytes")
|
||||
print("OAuth callback received and saved successfully")
|
||||
else:
|
||||
log("ERROR: Callback file was not created")
|
||||
print("Error: Failed to create callback file")
|
||||
|
||||
except Exception as callback_error:
|
||||
log(f"ERROR: Failed to write callback file: {callback_error}")
|
||||
print(f"Error writing callback file: {callback_error}")
|
||||
else:
|
||||
log("ERROR: Missing required OAuth parameters (code or state)")
|
||||
print("Invalid OAuth callback - missing required parameters")
|
||||
else:
|
||||
log(f"ERROR: Unknown protocol path: {full_path}")
|
||||
print(f"Unknown protocol path: {full_path}")
|
||||
|
||||
log("=== Protocol Handler Completed ===")
|
||||
|
||||
except Exception as e:
|
||||
log(f"CRITICAL EXCEPTION: {e}")
|
||||
import traceback
|
||||
log(f"TRACEBACK:\n{traceback.format_exc()}")
|
||||
print(f"Critical error handling protocol URL: {e}")
|
||||
|
||||
# Try to log to a fallback location if main logging fails
|
||||
try:
|
||||
fallback_log = Path.home() / "jackify_protocol_error.log"
|
||||
with open(fallback_log, 'a') as f:
|
||||
import datetime
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
f.write(f"[{timestamp}] CRITICAL ERROR: {e}\n")
|
||||
f.write(f"URL: {url}\n")
|
||||
f.write(f"Traceback:\n{traceback.format_exc()}\n\n")
|
||||
except:
|
||||
pass # If even fallback logging fails, just continue
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -28,7 +28,7 @@ class NextStepsDialog(QDialog):
|
||||
Displays the same information as the CLI completion message but in a proper GUI format.
|
||||
"""
|
||||
|
||||
def __init__(self, modlist_name: str, parent=None):
|
||||
def __init__(self, modlist_name: str, workflow_type: str = "configure_new", parent=None):
|
||||
"""
|
||||
Initialize the Next Steps dialog.
|
||||
|
||||
@@ -38,6 +38,7 @@ class NextStepsDialog(QDialog):
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.modlist_name = modlist_name
|
||||
self.workflow_type = workflow_type
|
||||
self.setWindowTitle("Next Steps")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(600, 400)
|
||||
@@ -189,10 +190,13 @@ class NextStepsDialog(QDialog):
|
||||
Returns:
|
||||
Formatted completion text string
|
||||
"""
|
||||
# Match the CLI completion text from menu_handler.py lines 627-631
|
||||
is_existing = self.workflow_type == "configure_existing"
|
||||
completion_title = "Modlist Configuration complete!" if is_existing else "Modlist Install and Configuration complete!"
|
||||
completion_log = "Configure_Existing_Modlist_workflow.log" if is_existing else "Configure_New_Modlist_workflow.log"
|
||||
|
||||
completion_text = f"""✓ Configuration completed successfully!
|
||||
|
||||
Modlist Install and Configuration complete!:
|
||||
{completion_title}
|
||||
|
||||
• You should now be able to Launch '{self.modlist_name}' through Steam.
|
||||
• Congratulations and enjoy the game!
|
||||
@@ -200,6 +204,6 @@ Modlist Install and Configuration complete!:
|
||||
NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of
|
||||
Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).
|
||||
|
||||
Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log"""
|
||||
Detailed log available at: {get_jackify_logs_dir()}/{completion_log}"""
|
||||
|
||||
return completion_text
|
||||
return completion_text
|
||||
|
||||
@@ -94,7 +94,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
def _pick_directory(self, line_edit):
|
||||
dir_path = QFileDialog.getExistingDirectory(self, "Select Directory", line_edit.text() or os.path.expanduser("~"))
|
||||
if dir_path:
|
||||
line_edit.setText(dir_path)
|
||||
line_edit.setText(os.path.realpath(dir_path))
|
||||
|
||||
def _show_help(self):
|
||||
MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low")
|
||||
@@ -130,7 +130,17 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
auth_service = NexusAuthService()
|
||||
authenticated, method, username = auth_service.get_auth_status()
|
||||
if authenticated and method == 'oauth':
|
||||
self.oauth_status_label.setText(f"Authorised as {username}" if username else "Authorised")
|
||||
tier_label = ""
|
||||
try:
|
||||
token = auth_service.get_auth_token()
|
||||
if token:
|
||||
from jackify.backend.services.nexus_premium_service import NexusPremiumService
|
||||
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=True)
|
||||
tier_label = " [Premium]" if is_premium else " [Free]"
|
||||
except Exception:
|
||||
pass
|
||||
display = f"Authorised as {username}{tier_label}" if username else "Authorised"
|
||||
self.oauth_status_label.setText(display)
|
||||
self.oauth_status_label.setStyleSheet("color: #3fd0ea;")
|
||||
self.oauth_btn.setText("Revoke")
|
||||
elif method == 'oauth_expired':
|
||||
@@ -323,7 +333,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
# Check if debug mode changed and prompt for restart
|
||||
new_debug_mode = self.debug_checkbox.isChecked()
|
||||
if new_debug_mode != self._original_debug_mode:
|
||||
reply = MessageService.question(self, "Restart Required", "Debug mode change requires a restart. Restart Jackify now?", safety_level="low")
|
||||
reply = MessageService.question(self, "Restart Required", "Debug mode change requires a restart. Restart Jackify now?", safety_level="medium")
|
||||
if reply == QMessageBox.Yes:
|
||||
import os, sys
|
||||
# User requested restart - do it regardless of execution environment
|
||||
@@ -383,4 +393,3 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
label.setStyleSheet("font-weight: bold; color: #fff;")
|
||||
return label
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class SettingsDialogProtonMixin:
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
available_protons = WineUtils.scan_valve_proton_versions()
|
||||
for proton in available_protons:
|
||||
if proton['version'].startswith('10.'):
|
||||
if proton['name'].startswith('Proton 10.'):
|
||||
return proton['path']
|
||||
return 'auto'
|
||||
except Exception:
|
||||
|
||||
@@ -93,7 +93,6 @@ if '-v' in sys.argv or '--version' in sys.argv or '-V' in sys.argv:
|
||||
print(f"Jackify version {jackify_version}")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
from jackify import __version__
|
||||
|
||||
# Add src directory to Python path
|
||||
@@ -125,13 +124,6 @@ from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder
|
||||
|
||||
ENABLE_WINDOW_HEIGHT_ANIMATION = False
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
# Constants for styling and disclaimer
|
||||
DISCLAIMER_TEXT = (
|
||||
"Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, "
|
||||
@@ -147,7 +139,6 @@ MENU_ITEMS = [
|
||||
("Exit Jackify", "exit_jackify"),
|
||||
]
|
||||
|
||||
|
||||
class JackifyMainWindow(
|
||||
MainWindowGeometryMixin,
|
||||
MainWindowBackendMixin,
|
||||
@@ -201,8 +192,6 @@ class JackifyMainWindow(
|
||||
def showEvent(self, event):
|
||||
self._geometry_show_event(event)
|
||||
|
||||
|
||||
|
||||
def resource_path(relative_path):
|
||||
"""Get path to resource file, handling both AppImage and dev modes."""
|
||||
# AppImage mode - use APPDIR if available
|
||||
@@ -221,7 +210,6 @@ def resource_path(relative_path):
|
||||
jackify_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
return os.path.join(jackify_dir, relative_path)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the GUI application"""
|
||||
# CRITICAL: Enable faulthandler for segfault debugging
|
||||
@@ -265,8 +253,8 @@ def main():
|
||||
logging_handler = LoggingHandler()
|
||||
# Only rotate log file when debug mode is enabled
|
||||
if debug_mode:
|
||||
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log')
|
||||
root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True, debug_mode=debug_mode) # Empty name = root logger
|
||||
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-debug.log')
|
||||
root_logger = logging_handler.setup_logger('', 'jackify-debug.log', is_general=True, debug_mode=debug_mode) # Empty name = root logger
|
||||
|
||||
# CRITICAL: Set root logger level BEFORE any child loggers are used
|
||||
# DEBUG messages from child loggers must propagate
|
||||
@@ -294,7 +282,7 @@ def main():
|
||||
|
||||
# Global cleanup function for signal handling
|
||||
def emergency_cleanup():
|
||||
debug_print("Cleanup: terminating jackify-engine processes")
|
||||
logger.debug("Cleanup: terminating jackify-engine processes")
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
|
||||
@@ -379,6 +367,5 @@ def main():
|
||||
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -7,14 +7,9 @@ import os
|
||||
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.services.modlist_service import ModlistService
|
||||
import logging
|
||||
|
||||
|
||||
def _debug_print(message):
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
ch = ConfigHandler()
|
||||
if ch.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MainWindowBackendMixin:
|
||||
"""Mixin for backend service initialization."""
|
||||
@@ -37,7 +32,7 @@ class MainWindowBackendMixin:
|
||||
from jackify.backend.services.update_service import UpdateService
|
||||
from jackify import __version__
|
||||
self.update_service = UpdateService(__version__)
|
||||
_debug_print(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}")
|
||||
logger.debug(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}")
|
||||
|
||||
def _is_steamdeck(self):
|
||||
try:
|
||||
@@ -58,7 +53,7 @@ class MainWindowBackendMixin:
|
||||
if success:
|
||||
status = resource_manager.get_limit_status()
|
||||
if status['target_achieved']:
|
||||
_debug_print(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
|
||||
logger.debug(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
|
||||
else:
|
||||
print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
|
||||
else:
|
||||
|
||||
@@ -11,6 +11,43 @@ from jackify.frontends.gui.dialogs.settings_dialog import SettingsDialog
|
||||
|
||||
class MainWindowDialogsMixin:
|
||||
"""Mixin for settings/about dialogs, open URL, and cleanup."""
|
||||
def _stop_qthread(self, thread, thread_name: str, cooperative_timeout_ms: int = 5000):
|
||||
"""Stop a QThread robustly to avoid teardown crashes on app exit."""
|
||||
if thread is None:
|
||||
return None
|
||||
try:
|
||||
if not thread.isRunning():
|
||||
return None
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
try:
|
||||
thread.requestInterruption()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
thread.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if thread.wait(cooperative_timeout_ms):
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
thread.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if not thread.wait(10000):
|
||||
print(f"WARNING: {thread_name} still running during shutdown")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def open_settings_dialog(self):
|
||||
try:
|
||||
@@ -83,27 +120,35 @@ class MainWindowDialogsMixin:
|
||||
def cleanup_processes(self):
|
||||
try:
|
||||
if hasattr(self, '_update_thread') and self._update_thread is not None:
|
||||
if self._update_thread.isRunning():
|
||||
self._update_thread.quit()
|
||||
self._update_thread.wait(2000)
|
||||
self._update_thread = None
|
||||
self._update_thread = self._stop_qthread(self._update_thread, "_update_thread")
|
||||
if hasattr(self, '_gallery_cache_preload_thread') and self._gallery_cache_preload_thread is not None:
|
||||
if self._gallery_cache_preload_thread.isRunning():
|
||||
self._gallery_cache_preload_thread.quit()
|
||||
self._gallery_cache_preload_thread.wait(2000)
|
||||
self._gallery_cache_preload_thread = None
|
||||
self._gallery_cache_preload_thread = self._stop_qthread(
|
||||
self._gallery_cache_preload_thread,
|
||||
"_gallery_cache_preload_thread",
|
||||
)
|
||||
for service in self.gui_services.values():
|
||||
if hasattr(service, 'cleanup'):
|
||||
service.cleanup()
|
||||
screens = [
|
||||
self.modlist_tasks_screen, self.install_modlist_screen,
|
||||
self.configure_new_modlist_screen, self.configure_existing_modlist_screen,
|
||||
getattr(self, 'modlist_tasks_screen', None),
|
||||
getattr(self, 'additional_tasks_screen', None),
|
||||
getattr(self, 'install_modlist_screen', None),
|
||||
getattr(self, 'install_ttw_screen', None),
|
||||
getattr(self, 'configure_new_modlist_screen', None),
|
||||
getattr(self, 'wabbajack_installer_screen', None),
|
||||
getattr(self, 'configure_existing_modlist_screen', None),
|
||||
getattr(self, 'install_mo2_screen', None),
|
||||
]
|
||||
for screen in screens:
|
||||
if screen is None:
|
||||
continue
|
||||
if hasattr(screen, 'cleanup_processes'):
|
||||
screen.cleanup_processes()
|
||||
elif hasattr(screen, 'cleanup'):
|
||||
screen.cleanup()
|
||||
elif hasattr(screen, 'worker'):
|
||||
worker = getattr(screen, 'worker', None)
|
||||
setattr(screen, 'worker', self._stop_qthread(worker, f"{screen.__class__.__name__}.worker"))
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
|
||||
except Exception:
|
||||
|
||||
@@ -7,17 +7,11 @@ from PySide6.QtWidgets import QMainWindow, QApplication
|
||||
from PySide6.QtCore import Qt, QTimer, QRect
|
||||
|
||||
from jackify.frontends.gui.utils import get_screen_geometry, set_responsive_minimum
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ENABLE_WINDOW_HEIGHT_ANIMATION = False
|
||||
|
||||
|
||||
def _debug_print(message):
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
ch = ConfigHandler()
|
||||
if ch.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class MainWindowGeometryMixin:
|
||||
"""Mixin for window geometry, save/restore, compact mode, and resize behavior."""
|
||||
|
||||
@@ -135,10 +129,10 @@ class MainWindowGeometryMixin:
|
||||
self.showMaximized()
|
||||
|
||||
def _on_child_resize_request(self, mode: str):
|
||||
_debug_print(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}")
|
||||
logger.debug(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}")
|
||||
try:
|
||||
if self.system_info and self.system_info.is_steamdeck:
|
||||
_debug_print("DEBUG: Steam Deck detected, ignoring resize request")
|
||||
logger.debug("DEBUG: Steam Deck detected, ignoring resize request")
|
||||
try:
|
||||
if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox:
|
||||
self.install_ttw_screen.show_details_checkbox.setVisible(False)
|
||||
@@ -183,7 +177,7 @@ class MainWindowGeometryMixin:
|
||||
before = self.size()
|
||||
self._programmatic_resize = True
|
||||
self.resize(self.size().width(), target_height)
|
||||
_debug_print(f"DEBUG: Animated fallback resize from {before} to {self.size()}")
|
||||
logger.debug(f"DEBUG: Animated fallback resize from {before} to {self.size()}")
|
||||
QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False))
|
||||
return
|
||||
start_rect = self.geometry()
|
||||
|
||||
@@ -7,14 +7,9 @@ import sys
|
||||
|
||||
from PySide6.QtCore import QThread, Signal, QTimer
|
||||
from PySide6.QtWidgets import QDialog
|
||||
import logging
|
||||
|
||||
|
||||
def _debug_print(message):
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
ch = ConfigHandler()
|
||||
if ch.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MainWindowStartupMixin:
|
||||
"""Mixin for startup and background tasks."""
|
||||
@@ -38,23 +33,23 @@ class MainWindowStartupMixin:
|
||||
if metadata:
|
||||
modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods)
|
||||
if modlists_with_mods > 0:
|
||||
_debug_print(f"Gallery cache ready ({modlists_with_mods} modlists with mods)")
|
||||
logger.debug(f"Gallery cache ready ({modlists_with_mods} modlists with mods)")
|
||||
else:
|
||||
_debug_print("Gallery cache updated")
|
||||
logger.debug("Gallery cache updated")
|
||||
else:
|
||||
_debug_print("Failed to load gallery cache")
|
||||
logger.debug("Failed to load gallery cache")
|
||||
except Exception as e:
|
||||
_debug_print(f"Gallery cache preload error: {str(e)}")
|
||||
logger.debug(f"Gallery cache preload error: {str(e)}")
|
||||
|
||||
self._gallery_cache_preload_thread = GalleryCachePreloadThread()
|
||||
self._gallery_cache_preload_thread.start()
|
||||
_debug_print("Started background gallery cache preload")
|
||||
logger.debug("Started background gallery cache preload")
|
||||
|
||||
def _check_protontricks_on_startup(self):
|
||||
try:
|
||||
method = self.config_handler.get('component_installation_method', 'winetricks')
|
||||
if method != 'system_protontricks':
|
||||
_debug_print(f"Skipping protontricks check (current method: {method}).")
|
||||
logger.debug(f"Skipping protontricks check (current method: {method}).")
|
||||
return
|
||||
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
|
||||
if not is_installed:
|
||||
@@ -66,13 +61,13 @@ class MainWindowStartupMixin:
|
||||
print("User chose to exit due to missing protontricks")
|
||||
sys.exit(1)
|
||||
else:
|
||||
_debug_print(f"Protontricks detected: {details}")
|
||||
logger.debug(f"Protontricks detected: {details}")
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
|
||||
def _check_for_updates_on_startup(self):
|
||||
try:
|
||||
_debug_print("Checking for updates on startup...")
|
||||
logger.debug("Checking for updates on startup...")
|
||||
|
||||
class UpdateCheckThread(QThread):
|
||||
update_available = Signal(object)
|
||||
@@ -87,7 +82,7 @@ class MainWindowStartupMixin:
|
||||
self.update_available.emit(update_info)
|
||||
|
||||
def on_update_available(update_info):
|
||||
_debug_print(f"Update available: v{update_info.version}")
|
||||
logger.debug(f"Update available: v{update_info.version}")
|
||||
|
||||
def show_update_dialog():
|
||||
from jackify.frontends.gui.dialogs.update_dialog import UpdateDialog
|
||||
@@ -99,4 +94,4 @@ class MainWindowStartupMixin:
|
||||
self._update_thread.update_available.connect(on_update_available)
|
||||
self._update_thread.start()
|
||||
except Exception as e:
|
||||
_debug_print(f"Error setting up update check: {e}")
|
||||
logger.debug(f"Error setting up update check: {e}")
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""
|
||||
Main window UI setup mixin.
|
||||
Stacked widget, screens, bottom bar, screen change handling.
|
||||
|
||||
Screens 1-9 are lazy-initialised: placeholder QWidgets are inserted at startup
|
||||
and swapped for real screens on first navigation. Only index 0 (MainMenu) is
|
||||
created eagerly because it is always visible first.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QLabel, QVBoxLayout, QHBoxLayout,
|
||||
@@ -15,81 +19,43 @@ from jackify import __version__
|
||||
from jackify.frontends.gui.shared_theme import DEBUG_BORDERS
|
||||
from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _debug_print(message):
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
ch = ConfigHandler()
|
||||
if ch.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
class _LazyPlaceholder(QWidget):
|
||||
"""Sentinel widget used in place of a not-yet-initialised screen."""
|
||||
|
||||
|
||||
class MainWindowUIMixin:
|
||||
"""Mixin for main window UI: stacked widget, screens, bottom bar."""
|
||||
|
||||
def _setup_ui(self, dev_mode=False):
|
||||
self._dev_mode = dev_mode
|
||||
self.stacked_widget = QStackedWidget()
|
||||
from jackify.frontends.gui.screens import (
|
||||
MainMenu, ModlistTasksScreen, AdditionalTasksScreen,
|
||||
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen,
|
||||
)
|
||||
from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen
|
||||
from jackify.frontends.gui.screens.wabbajack_installer import WabbajackInstallerScreen
|
||||
|
||||
# Only MainMenu is created eagerly (always shown first).
|
||||
from jackify.frontends.gui.screens import MainMenu
|
||||
self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode)
|
||||
self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget)
|
||||
self.modlist_tasks_screen = ModlistTasksScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0, dev_mode=dev_mode
|
||||
)
|
||||
self.additional_tasks_screen = AdditionalTasksScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
|
||||
)
|
||||
self.install_modlist_screen = InstallModlistScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
|
||||
)
|
||||
self.configure_new_modlist_screen = ConfigureNewModlistScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
|
||||
)
|
||||
self.configure_existing_modlist_screen = ConfigureExistingModlistScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
|
||||
)
|
||||
self.install_ttw_screen = InstallTTWScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
|
||||
)
|
||||
self.wabbajack_installer_screen = WabbajackInstallerScreen(
|
||||
stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info
|
||||
)
|
||||
self.stacked_widget.addWidget(self.main_menu) # index 0
|
||||
|
||||
try:
|
||||
self.install_ttw_screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.install_modlist_screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.configure_new_modlist_screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.configure_existing_modlist_screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.wabbajack_installer_screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
# Indexes 1-9: insert lightweight placeholders now; real screens on demand.
|
||||
for _ in range(9):
|
||||
self.stacked_widget.addWidget(_LazyPlaceholder())
|
||||
|
||||
self.stacked_widget.addWidget(self.main_menu)
|
||||
self.stacked_widget.addWidget(self.feature_placeholder)
|
||||
self.stacked_widget.addWidget(self.modlist_tasks_screen)
|
||||
self.stacked_widget.addWidget(self.additional_tasks_screen)
|
||||
self.stacked_widget.addWidget(self.install_modlist_screen)
|
||||
self.stacked_widget.addWidget(self.install_ttw_screen)
|
||||
self.stacked_widget.addWidget(self.configure_new_modlist_screen)
|
||||
self.stacked_widget.addWidget(self.wabbajack_installer_screen)
|
||||
self.stacked_widget.addWidget(self.configure_existing_modlist_screen)
|
||||
# Factory map: index -> callable that creates and caches the real screen.
|
||||
self._screen_factories = {
|
||||
1: self._make_feature_placeholder,
|
||||
2: self._make_modlist_tasks_screen,
|
||||
3: self._make_additional_tasks_screen,
|
||||
4: self._make_install_modlist_screen,
|
||||
5: self._make_install_ttw_screen,
|
||||
6: self._make_configure_new_modlist_screen,
|
||||
7: self._make_wabbajack_installer_screen,
|
||||
8: self._make_configure_existing_modlist_screen,
|
||||
9: self._make_install_mo2_screen,
|
||||
}
|
||||
|
||||
self.stacked_widget.currentChanged.connect(self._lazy_init_screen)
|
||||
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
|
||||
self.stacked_widget.currentChanged.connect(self._maintain_fullscreen_on_deck)
|
||||
|
||||
@@ -141,6 +107,121 @@ class MainWindowUIMixin:
|
||||
self.stacked_widget.setCurrentIndex(0)
|
||||
self._check_protontricks_on_startup()
|
||||
|
||||
def _lazy_init_screen(self, index: int) -> None:
|
||||
"""Swap placeholder at *index* for the real screen on first visit."""
|
||||
if index == 0:
|
||||
return
|
||||
widget = self.stacked_widget.widget(index)
|
||||
if not isinstance(widget, _LazyPlaceholder):
|
||||
return
|
||||
factory = self._screen_factories.get(index)
|
||||
if factory is None:
|
||||
return
|
||||
real_screen = factory()
|
||||
# Block signals for the entire swap including setCurrentWidget so that:
|
||||
# (a) Qt's auto-current-change on removeWidget doesn't cascade into the
|
||||
# other placeholders via a re-entrant _lazy_init_screen call, and
|
||||
# (b) setCurrentWidget does not fire a second currentChanged — the outer
|
||||
# currentChanged (which triggered this lazy init) is still being
|
||||
# dispatched and will reach _debug_screen_change with the real screen
|
||||
# already in place, so reset_screen_to_defaults runs exactly once.
|
||||
self.stacked_widget.blockSignals(True)
|
||||
self.stacked_widget.removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
self.stacked_widget.insertWidget(index, real_screen)
|
||||
self.stacked_widget.setCurrentWidget(real_screen)
|
||||
self.stacked_widget.blockSignals(False)
|
||||
|
||||
def _make_feature_placeholder(self):
|
||||
screen = FeaturePlaceholder(stacked_widget=self.stacked_widget)
|
||||
self.feature_placeholder = screen
|
||||
return screen
|
||||
|
||||
def _make_modlist_tasks_screen(self):
|
||||
from jackify.frontends.gui.screens import ModlistTasksScreen
|
||||
screen = ModlistTasksScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0, dev_mode=self._dev_mode
|
||||
)
|
||||
self.modlist_tasks_screen = screen
|
||||
return screen
|
||||
|
||||
def _make_additional_tasks_screen(self):
|
||||
from jackify.frontends.gui.screens import AdditionalTasksScreen
|
||||
screen = AdditionalTasksScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0,
|
||||
system_info=self.system_info, install_mo2_screen_index=9,
|
||||
)
|
||||
self.additional_tasks_screen = screen
|
||||
return screen
|
||||
|
||||
def _make_install_modlist_screen(self):
|
||||
from jackify.frontends.gui.screens import InstallModlistScreen
|
||||
screen = InstallModlistScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=2, system_info=self.system_info
|
||||
)
|
||||
self.install_modlist_screen = screen
|
||||
try:
|
||||
screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
return screen
|
||||
|
||||
def _make_install_ttw_screen(self):
|
||||
from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen
|
||||
screen = InstallTTWScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=3, system_info=self.system_info
|
||||
)
|
||||
self.install_ttw_screen = screen
|
||||
try:
|
||||
screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
return screen
|
||||
|
||||
def _make_configure_new_modlist_screen(self):
|
||||
from jackify.frontends.gui.screens import ConfigureNewModlistScreen
|
||||
screen = ConfigureNewModlistScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=2, system_info=self.system_info
|
||||
)
|
||||
self.configure_new_modlist_screen = screen
|
||||
try:
|
||||
screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
return screen
|
||||
|
||||
def _make_wabbajack_installer_screen(self):
|
||||
from jackify.frontends.gui.screens.wabbajack_installer import WabbajackInstallerScreen
|
||||
screen = WabbajackInstallerScreen(
|
||||
stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info
|
||||
)
|
||||
self.wabbajack_installer_screen = screen
|
||||
try:
|
||||
screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
return screen
|
||||
|
||||
def _make_configure_existing_modlist_screen(self):
|
||||
from jackify.frontends.gui.screens import ConfigureExistingModlistScreen
|
||||
screen = ConfigureExistingModlistScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=2, system_info=self.system_info
|
||||
)
|
||||
self.configure_existing_modlist_screen = screen
|
||||
try:
|
||||
screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
return screen
|
||||
|
||||
def _make_install_mo2_screen(self):
|
||||
from jackify.frontends.gui.screens.install_mo2_screen import InstallMO2Screen
|
||||
screen = InstallMO2Screen(
|
||||
stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info
|
||||
)
|
||||
self.install_mo2_screen = screen
|
||||
return screen
|
||||
|
||||
def _debug_screen_change(self, index):
|
||||
try:
|
||||
idx = int(index) if index is not None else 0
|
||||
@@ -167,21 +248,22 @@ class MainWindowUIMixin:
|
||||
6: "Configure New Modlist",
|
||||
7: "Wabbajack Installer",
|
||||
8: "Configure Existing Modlist",
|
||||
9: "Install MO2 Screen",
|
||||
}
|
||||
screen_name = screen_names.get(idx, f"Unknown Screen (Index {idx})")
|
||||
widget = self.stacked_widget.widget(idx)
|
||||
except (OverflowError, TypeError, ValueError):
|
||||
return
|
||||
widget_class = widget.__class__.__name__ if widget else "None"
|
||||
print(f"[DEBUG] Screen changed to Index {idx}: {screen_name} (Widget: {widget_class})", file=sys.stderr)
|
||||
logger.debug(f"Screen changed to Index {idx}: {screen_name} (Widget: {widget_class})")
|
||||
if idx == 4:
|
||||
print(" Install Modlist Screen details:", file=sys.stderr)
|
||||
print(f" - Widget type: {type(widget)}", file=sys.stderr)
|
||||
print(f" - Widget file: {widget.__class__.__module__}", file=sys.stderr)
|
||||
logger.debug("Install Modlist Screen details:")
|
||||
logger.debug(f" Widget type: {type(widget)}")
|
||||
logger.debug(f" Widget file: {widget.__class__.__module__}")
|
||||
if hasattr(widget, 'windowTitle'):
|
||||
print(f" - Window title: {widget.windowTitle()}", file=sys.stderr)
|
||||
logger.debug(f" Window title: {widget.windowTitle()}")
|
||||
if hasattr(widget, 'layout'):
|
||||
layout = widget.layout()
|
||||
if layout:
|
||||
print(f" - Layout type: {type(layout)}", file=sys.stderr)
|
||||
print(f" - Layout children count: {layout.count()}", file=sys.stderr)
|
||||
logger.debug(f" Layout type: {type(layout)}")
|
||||
logger.debug(f" Layout children count: {layout.count()}")
|
||||
|
||||
@@ -25,11 +25,13 @@ logger = logging.getLogger(__name__)
|
||||
class AdditionalTasksScreen(QWidget):
|
||||
"""Simple Additional Tasks screen for TTW only"""
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None):
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
|
||||
install_mo2_screen_index: int = 9):
|
||||
super().__init__()
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
self.system_info = system_info or SystemInfo(is_steamdeck=False)
|
||||
self.install_mo2_screen_index = install_mo2_screen_index
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
@@ -93,6 +95,7 @@ class AdditionalTasksScreen(QWidget):
|
||||
MENU_ITEMS = [
|
||||
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"),
|
||||
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
|
||||
("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"),
|
||||
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
|
||||
]
|
||||
|
||||
@@ -148,6 +151,8 @@ class AdditionalTasksScreen(QWidget):
|
||||
self._show_ttw_info()
|
||||
elif action_id == "wabbajack_install":
|
||||
self._show_wabbajack_installer()
|
||||
elif action_id == "setup_mo2":
|
||||
self._show_mo2_setup()
|
||||
elif action_id == "coming_soon":
|
||||
self._show_coming_soon_info()
|
||||
elif action_id == "return_main_menu":
|
||||
@@ -165,6 +170,11 @@ class AdditionalTasksScreen(QWidget):
|
||||
# Navigate to Wabbajack installer screen (index 7)
|
||||
self.stacked_widget.setCurrentIndex(7)
|
||||
|
||||
def _show_mo2_setup(self):
|
||||
"""Navigate to standalone MO2 setup screen"""
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.install_mo2_screen_index)
|
||||
|
||||
def _show_coming_soon_info(self):
|
||||
"""Show coming soon info"""
|
||||
from ..services.message_service import MessageService
|
||||
|
||||
@@ -8,6 +8,7 @@ from ..utils import ansi_to_html, set_responsive_minimum
|
||||
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
|
||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||
from jackify.shared.progress_models import InstallationPhase, InstallationProgress
|
||||
from jackify.shared.errors import configuration_failed
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -23,28 +24,24 @@ from jackify.backend.services.resolution_service import ResolutionService
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from ..dialogs import SuccessDialog
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from .configure_existing_modlist_ui import ConfigureExistingModlistUIMixin
|
||||
from .configure_existing_modlist_workflow import ConfigureExistingModlistWorkflowMixin
|
||||
from .configure_existing_modlist_shortcuts import ConfigureExistingModlistShortcutsMixin
|
||||
from .configure_existing_modlist_console import ConfigureExistingModlistConsoleMixin
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
from .install_modlist_ttw import TTWIntegrationMixin
|
||||
|
||||
class ConfigureExistingModlistScreen(
|
||||
ScreenBackMixin,
|
||||
TTWIntegrationMixin,
|
||||
ConfigureExistingModlistUIMixin,
|
||||
ConfigureExistingModlistWorkflowMixin,
|
||||
ConfigureExistingModlistShortcutsMixin,
|
||||
ConfigureExistingModlistConsoleMixin,
|
||||
QWidget,
|
||||
):
|
||||
steam_restart_finished = Signal(bool, str)
|
||||
resize_request = Signal(str)
|
||||
|
||||
def cleanup_processes(self):
|
||||
@@ -86,14 +83,11 @@ class ConfigureExistingModlistScreen(
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to set initial collapsed state: {e}")
|
||||
|
||||
# Load shortcuts after layout is done so we don't block or re-enter during showEvent
|
||||
if not self._shortcuts_loaded:
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(150, self._load_shortcuts_async)
|
||||
self._shortcuts_loaded = True
|
||||
# Shortcut loading is handled by reset_screen_to_defaults() → refresh_modlist_list()
|
||||
# which fires via _debug_screen_change on every navigation to this screen.
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Clean up thread when screen is hidden (terminate without blocking main thread)"""
|
||||
"""Clean up thread when screen is hidden."""
|
||||
super().hideEvent(event)
|
||||
if self._shortcut_loader is not None:
|
||||
if self._shortcut_loader.isRunning():
|
||||
@@ -102,6 +96,7 @@ class ConfigureExistingModlistScreen(
|
||||
except Exception:
|
||||
pass
|
||||
self._shortcut_loader.terminate()
|
||||
self._shortcut_loader.wait(2000)
|
||||
self._shortcut_loader = None
|
||||
|
||||
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
|
||||
@@ -110,8 +105,19 @@ class ConfigureExistingModlistScreen(
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
# Check for VNV post-install automation after configuration
|
||||
install_dir = getattr(self, '_current_install_dir', None)
|
||||
|
||||
if install_dir:
|
||||
game_type = self._detect_game_type_from_mo2_ini(install_dir)
|
||||
if game_type in ('falloutnv', 'fallout_new_vegas'):
|
||||
from jackify.backend.utils.modlist_meta import get_modlist_name
|
||||
identified_name = get_modlist_name(install_dir)
|
||||
if identified_name and self._check_ttw_eligibility(identified_name, game_type, install_dir):
|
||||
self._cleanup_config_thread()
|
||||
self._initiate_ttw_workflow(identified_name, install_dir)
|
||||
return
|
||||
|
||||
# Check for VNV post-install automation after configuration
|
||||
if install_dir:
|
||||
self._check_and_run_vnv_automation(modlist_name, install_dir)
|
||||
|
||||
@@ -142,8 +148,8 @@ class ConfigureExistingModlistScreen(
|
||||
logging.getLogger(__name__).warning("Failed to show ENB dialog: %s", e)
|
||||
else:
|
||||
self._safe_append_text(f"Configuration failed: {message}")
|
||||
MessageService.critical(self, "Configuration Failed",
|
||||
f"Configuration failed: {message}", safety_level="medium")
|
||||
MessageService.show_error(self, configuration_failed(str(message)))
|
||||
self._cleanup_config_thread()
|
||||
|
||||
def on_configuration_error(self, error_message):
|
||||
"""Handle configuration error"""
|
||||
@@ -151,7 +157,27 @@ class ConfigureExistingModlistScreen(
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
self._safe_append_text(f"Configuration error: {error_message}")
|
||||
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")
|
||||
MessageService.show_error(self, configuration_failed(str(error_message)))
|
||||
self._cleanup_config_thread()
|
||||
|
||||
def _cleanup_config_thread(self):
|
||||
"""Safely stop and release configuration thread."""
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000)
|
||||
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
"""Reset the screen to default state when navigating back from main menu"""
|
||||
@@ -179,16 +205,16 @@ class ConfigureExistingModlistScreen(
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running threads when the screen is closed"""
|
||||
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")
|
||||
logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread")
|
||||
|
||||
# Clean up config thread if running
|
||||
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
|
||||
debug_print("DEBUG: Terminating ConfigurationThread")
|
||||
logger.debug("DEBUG: Terminating ConfigurationThread")
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except:
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
self.config_thread.terminate()
|
||||
self.config_thread.wait(2000) # Wait up to 2 seconds
|
||||
self.config_thread.wait(2000) # Wait up to 2 seconds
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
"""Shortcut loading for ConfigureExistingModlistScreen (Mixin)."""
|
||||
from PySide6.QtCore import QThread, Signal, QObject
|
||||
import logging
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ConfigureExistingModlistShortcutsMixin:
|
||||
"""Mixin providing shortcut loading for ConfigureExistingModlistScreen."""
|
||||
|
||||
@@ -73,20 +67,31 @@ class ConfigureExistingModlistShortcutsMixin:
|
||||
self.shortcut_combo.addItem("Loading modlists...")
|
||||
self.shortcut_combo.setEnabled(False)
|
||||
|
||||
# Clean up any existing thread first (defer so we don't block main thread)
|
||||
# Clean up any existing thread: disconnect its signal so results are ignored,
|
||||
# terminate it, and park it in a holding list so the QThread object is not
|
||||
# GC'd while still running (which would cause Qt to abort).
|
||||
if self._shortcut_loader is not None:
|
||||
if self._shortcut_loader.isRunning():
|
||||
self._shortcut_loader.finished_signal.disconnect()
|
||||
try:
|
||||
self._shortcut_loader.finished_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._shortcut_loader.terminate()
|
||||
if not hasattr(self, '_old_loaders'):
|
||||
self._old_loaders = []
|
||||
self._old_loaders.append(self._shortcut_loader)
|
||||
self._shortcut_loader = None
|
||||
|
||||
# Purge finished threads from the holding list
|
||||
if hasattr(self, '_old_loaders'):
|
||||
self._old_loaders = [t for t in self._old_loaders if t.isRunning()]
|
||||
|
||||
# Start background thread
|
||||
self._shortcut_loader = ShortcutLoaderThread()
|
||||
self._shortcut_loader.finished_signal.connect(self._on_shortcuts_loaded)
|
||||
self._shortcut_loader.error_signal.connect(self._on_shortcuts_error)
|
||||
self._shortcut_loader.start()
|
||||
|
||||
|
||||
def _on_shortcuts_loaded(self, shortcuts):
|
||||
"""Update UI when shortcuts are loaded"""
|
||||
self.mo2_shortcuts = shortcuts
|
||||
@@ -103,15 +108,13 @@ class ConfigureExistingModlistShortcutsMixin:
|
||||
self.shortcut_combo.addItem(display)
|
||||
self.shortcut_map.append(shortcut)
|
||||
|
||||
|
||||
def _on_shortcuts_error(self, error_msg):
|
||||
"""Handle errors from shortcut loading thread"""
|
||||
# Log error from main thread (safe to write to stderr here)
|
||||
debug_print(f"Warning: Failed to load shortcuts: {error_msg}")
|
||||
logger.debug(f"Warning: Failed to load shortcuts: {error_msg}")
|
||||
# Update UI to show error state
|
||||
if hasattr(self, 'shortcut_combo'):
|
||||
self.shortcut_combo.clear()
|
||||
self.shortcut_combo.setEnabled(True)
|
||||
self.shortcut_combo.addItem("Error loading modlists - please try again")
|
||||
|
||||
|
||||
|
||||
@@ -11,22 +11,15 @@ from ..utils import set_responsive_minimum
|
||||
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
|
||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
import logging
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ConfigureExistingModlistUIMixin:
|
||||
"""Mixin providing UI setup and control management for ConfigureExistingModlistScreen."""
|
||||
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None):
|
||||
super().__init__()
|
||||
debug_print("DEBUG: ConfigureExistingModlistScreen __init__ called")
|
||||
logger.debug("DEBUG: ConfigureExistingModlistScreen __init__ called")
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
@@ -184,7 +177,7 @@ class ConfigureExistingModlistUIMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
@@ -335,7 +328,6 @@ class ConfigureExistingModlistUIMixin:
|
||||
self.top_timer.timeout.connect(self.update_top_panel)
|
||||
self.top_timer.start(2000)
|
||||
self.start_btn.clicked.connect(self.validate_and_start_configure)
|
||||
self.steam_restart_finished.connect(self._on_steam_restart_finished)
|
||||
|
||||
# Scroll tracking for professional auto-scroll behavior
|
||||
self._user_manually_scrolled = False
|
||||
@@ -361,34 +353,29 @@ class ConfigureExistingModlistUIMixin:
|
||||
self.resolution_combo,
|
||||
]
|
||||
|
||||
|
||||
def _disable_controls_during_operation(self):
|
||||
"""Disable all actionable controls during configure operations (except Cancel)"""
|
||||
for control in self._actionable_controls:
|
||||
if control:
|
||||
control.setEnabled(False)
|
||||
|
||||
|
||||
def _enable_controls_after_operation(self):
|
||||
"""Re-enable all actionable controls after configure operations complete"""
|
||||
for control in self._actionable_controls:
|
||||
if control:
|
||||
control.setEnabled(True)
|
||||
|
||||
|
||||
def refresh_paths(self):
|
||||
"""Refresh cached paths when config changes."""
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
self.modlist_log_path = get_jackify_logs_dir() / 'Configure_Existing_Modlist_workflow.log'
|
||||
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
|
||||
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle window resize to prioritize form over console"""
|
||||
super().resizeEvent(event)
|
||||
self._adjust_console_for_form_priority()
|
||||
|
||||
|
||||
def _adjust_console_for_form_priority(self):
|
||||
"""Console now dynamically fills available space with stretch=1, no manual calculation needed"""
|
||||
# The console automatically fills remaining space due to stretch=1 in the layout
|
||||
@@ -396,7 +383,6 @@ class ConfigureExistingModlistUIMixin:
|
||||
self.console.setMaximumHeight(16777215) # Reset to default maximum
|
||||
self.console.setMinimumHeight(50) # Keep minimum height for usability
|
||||
|
||||
|
||||
def _setup_scroll_tracking(self):
|
||||
"""Set up scroll tracking for professional auto-scroll behavior"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
@@ -404,17 +390,14 @@ class ConfigureExistingModlistUIMixin:
|
||||
scrollbar.sliderReleased.connect(self._on_scrollbar_released)
|
||||
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
|
||||
|
||||
|
||||
def _on_scrollbar_pressed(self):
|
||||
"""User started manually scrolling"""
|
||||
self._user_manually_scrolled = True
|
||||
|
||||
|
||||
def _on_scrollbar_released(self):
|
||||
"""User finished manually scrolling"""
|
||||
self._user_manually_scrolled = False
|
||||
|
||||
|
||||
def _on_scrollbar_value_changed(self):
|
||||
"""Track if user is at bottom of scroll area"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
@@ -427,19 +410,16 @@ class ConfigureExistingModlistUIMixin:
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom)
|
||||
|
||||
|
||||
def _reset_manual_scroll_if_at_bottom(self):
|
||||
"""Reset manual scroll flag if user is still at bottom after delay"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
if scrollbar.value() >= scrollbar.maximum() - 1:
|
||||
self._user_manually_scrolled = False
|
||||
|
||||
|
||||
def _on_show_details_toggled(self, checked):
|
||||
"""Handle Show Details checkbox toggle"""
|
||||
self._toggle_console_visibility(checked)
|
||||
|
||||
|
||||
def _toggle_console_visibility(self, is_checked):
|
||||
"""Toggle console visibility and window size"""
|
||||
main_window = None
|
||||
@@ -518,7 +498,6 @@ class ConfigureExistingModlistUIMixin:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def update_top_panel(self):
|
||||
try:
|
||||
result = subprocess.run([
|
||||
@@ -561,4 +540,3 @@ class ConfigureExistingModlistUIMixin:
|
||||
except Exception as e:
|
||||
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
|
||||
|
||||
|
||||
|
||||
@@ -6,17 +6,10 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
from jackify.shared.errors import configuration_failed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class ConfigureExistingModlistWorkflowMixin:
|
||||
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
|
||||
|
||||
@@ -37,6 +30,8 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
return 'fallout4'
|
||||
elif 'nvse_loader.exe' in content or 'fallout new vegas' in content:
|
||||
return 'falloutnv'
|
||||
elif 'fose_loader.exe' in content or 'fallout 3' in content:
|
||||
return 'fallout3'
|
||||
elif 'obse_loader.exe' in content or 'oblivion' in content:
|
||||
return 'oblivion'
|
||||
elif 'starfield' in content:
|
||||
@@ -49,7 +44,6 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
|
||||
return 'skyrim'
|
||||
|
||||
|
||||
def validate_and_start_configure(self):
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
self.config_handler.reload_config()
|
||||
@@ -88,18 +82,17 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
debug_print(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
debug_print("DEBUG: Failed to save resolution")
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
debug_print("DEBUG: Saved resolution cleared")
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
# Start the workflow (no shortcut creation needed)
|
||||
self.start_workflow(modlist_name, install_dir, resolution)
|
||||
|
||||
|
||||
def start_workflow(self, modlist_name, install_dir, resolution):
|
||||
"""Start the configuration workflow using backend service directly"""
|
||||
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
|
||||
@@ -211,8 +204,7 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
|
||||
except Exception as e:
|
||||
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
|
||||
MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium")
|
||||
|
||||
MessageService.show_error(self, configuration_failed(str(e)))
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
|
||||
"""Check if VNV automation should run and execute if applicable
|
||||
@@ -237,7 +229,7 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
|
||||
if not game_root:
|
||||
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return
|
||||
|
||||
# Confirmation callback - show dialog to user
|
||||
@@ -269,7 +261,7 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return Path(file_path)
|
||||
return Path(file_path).resolve()
|
||||
return None
|
||||
|
||||
# Run automation
|
||||
@@ -294,10 +286,9 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"ERROR: Failed to run VNV automation: {e}")
|
||||
logger.debug(f"ERROR: Failed to run VNV automation: {e}")
|
||||
import traceback
|
||||
debug_print(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
logger.debug(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
def show_manual_steps_dialog(self, extra_warning=""):
|
||||
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
|
||||
@@ -334,7 +325,6 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication
|
||||
dlg = QDialog(self)
|
||||
@@ -360,18 +350,12 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
btn_exit.clicked.connect(on_exit)
|
||||
dlg.exec()
|
||||
|
||||
|
||||
def _on_steam_restart_finished(self, success, message):
|
||||
pass
|
||||
|
||||
|
||||
def refresh_modlist_list(self):
|
||||
"""Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts (async)"""
|
||||
# Use async loading to avoid blocking UI
|
||||
self._shortcuts_loaded = False # Allow reload
|
||||
self._load_shortcuts_async()
|
||||
|
||||
|
||||
def _calculate_time_taken(self) -> str:
|
||||
"""Calculate and format the time taken for the workflow"""
|
||||
if self._workflow_start_time is None:
|
||||
@@ -389,4 +373,3 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
else:
|
||||
return f"{elapsed_seconds_remainder} seconds"
|
||||
|
||||
|
||||
|
||||
@@ -28,22 +28,17 @@ from ..dialogs import SuccessDialog
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
from jackify.shared.errors import configuration_failed
|
||||
from .configure_new_modlist_ui_setup import ConfigureNewModlistUISetupMixin
|
||||
from .configure_new_modlist_console import ConfigureNewModlistConsoleMixin
|
||||
from .configure_new_modlist_workflow import ConfigureNewModlistWorkflowMixin
|
||||
from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, ModlistFetchThread, SelectionDialog
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from .install_modlist_ttw import TTWIntegrationMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, QWidget):
|
||||
class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, QWidget):
|
||||
resize_request = Signal(str)
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
@@ -79,8 +74,20 @@ class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
raw = self.install_dir_edit.text().strip()
|
||||
install_dir = os.path.dirname(raw) if raw.endswith('ModOrganizer.exe') else raw
|
||||
|
||||
if install_dir:
|
||||
game_type = self._detect_game_type_from_mo2_ini(install_dir)
|
||||
if game_type in ('falloutnv', 'fallout_new_vegas'):
|
||||
from jackify.backend.utils.modlist_meta import get_modlist_name
|
||||
identified_name = get_modlist_name(install_dir)
|
||||
if identified_name and self._check_ttw_eligibility(identified_name, game_type, install_dir):
|
||||
self._cleanup_config_thread()
|
||||
self._initiate_ttw_workflow(identified_name, install_dir)
|
||||
return
|
||||
|
||||
# Check for VNV post-install automation after configuration
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
if install_dir:
|
||||
self._check_and_run_vnv_automation(modlist_name, install_dir)
|
||||
|
||||
@@ -111,8 +118,8 @@ class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin
|
||||
logger.warning(f"Failed to show ENB dialog: {e}")
|
||||
else:
|
||||
self._safe_append_text(f"Configuration failed: {message}")
|
||||
MessageService.critical(self, "Configuration Failed",
|
||||
f"Configuration failed: {message}", safety_level="medium")
|
||||
MessageService.show_error(self, configuration_failed(str(message)))
|
||||
self._cleanup_config_thread()
|
||||
|
||||
def on_configuration_error(self, error_message):
|
||||
"""Handle configuration error"""
|
||||
@@ -120,11 +127,27 @@ class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
self._safe_append_text(f"Configuration error: {error_message}")
|
||||
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")
|
||||
MessageService.show_error(self, configuration_failed(str(error_message)))
|
||||
self._cleanup_config_thread()
|
||||
|
||||
def _cleanup_config_thread(self):
|
||||
"""Safely stop and release configuration thread."""
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000)
|
||||
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
"""Reset the screen to default state when navigating back from main menu"""
|
||||
@@ -149,28 +172,28 @@ class ConfigureNewModlistScreen(ScreenBackMixin, ConfigureNewModlistUISetupMixin
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running threads when the screen is closed"""
|
||||
debug_print("DEBUG: cleanup called - cleaning up threads")
|
||||
logger.debug("DEBUG: cleanup called - cleaning up threads")
|
||||
|
||||
# Clean up automated prefix thread if running
|
||||
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread and self.automated_prefix_thread.isRunning():
|
||||
debug_print("DEBUG: Terminating AutomatedPrefixThread")
|
||||
logger.debug("DEBUG: Terminating AutomatedPrefixThread")
|
||||
try:
|
||||
self.automated_prefix_thread.progress_update.disconnect()
|
||||
self.automated_prefix_thread.workflow_complete.disconnect()
|
||||
self.automated_prefix_thread.error_occurred.disconnect()
|
||||
except:
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
self.automated_prefix_thread.terminate()
|
||||
self.automated_prefix_thread.wait(2000) # Wait up to 2 seconds
|
||||
|
||||
# Clean up config thread if running
|
||||
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
|
||||
debug_print("DEBUG: Terminating ConfigThread")
|
||||
logger.debug("DEBUG: Terminating ConfigThread")
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except:
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
self.config_thread.terminate()
|
||||
self.config_thread.wait(2000) # Wait up to 2 seconds
|
||||
self.config_thread.wait(2000) # Wait up to 2 seconds
|
||||
|
||||
@@ -167,6 +167,6 @@ class ConfigureNewModlistConsoleMixin:
|
||||
def browse_install_dir(self):
|
||||
file, _ = QFileDialog.getOpenFileName(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)")
|
||||
if file:
|
||||
self.install_dir_edit.setText(file)
|
||||
self.install_dir_edit.setText(os.path.realpath(file))
|
||||
|
||||
|
||||
|
||||
@@ -6,15 +6,10 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
import subprocess
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import manual_steps_incomplete
|
||||
import logging
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ModlistFetchThread(QThread):
|
||||
result = Signal(list, str)
|
||||
def __init__(self, cli_path, game_type, project_root, log_path, mode='list-modlists', modlist_name=None, install_dir=None, download_dir=None):
|
||||
@@ -56,7 +51,6 @@ class ModlistFetchThread(QThread):
|
||||
except Exception as e:
|
||||
self.result.emit([], str(e))
|
||||
|
||||
|
||||
class SelectionDialog(QDialog):
|
||||
def __init__(self, title, items, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -78,7 +72,6 @@ class SelectionDialog(QDialog):
|
||||
self.selected_item = item.text()
|
||||
self.accept()
|
||||
|
||||
|
||||
class ConfigureNewModlistDialogsMixin:
|
||||
"""Mixin providing dialog management for ConfigureNewModlistScreen."""
|
||||
|
||||
@@ -98,7 +91,6 @@ class ConfigureNewModlistDialogsMixin:
|
||||
self.config_thread.terminate()
|
||||
self.config_thread.wait(1000)
|
||||
|
||||
|
||||
def show_shortcut_conflict_dialog(self, conflicts):
|
||||
"""Show dialog to resolve shortcut name conflicts"""
|
||||
conflict_names = [c['name'] for c in conflicts]
|
||||
@@ -209,7 +201,6 @@ class ConfigureNewModlistDialogsMixin:
|
||||
|
||||
dialog.exec()
|
||||
|
||||
|
||||
def retry_automated_workflow_with_new_name(self, new_name):
|
||||
"""Retry the automated workflow with a new shortcut name"""
|
||||
# Update the modlist name field temporarily
|
||||
@@ -220,16 +211,13 @@ class ConfigureNewModlistDialogsMixin:
|
||||
self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'")
|
||||
self._start_automated_prefix_workflow(new_name, os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip(), self.install_dir_edit.text().strip(), self.resolution_combo.currentText())
|
||||
|
||||
|
||||
def handle_validation_failure(self, missing_text):
|
||||
"""Handle manual steps validation failure with retry logic"""
|
||||
self._manual_steps_retry_count += 1
|
||||
|
||||
if self._manual_steps_retry_count < 3:
|
||||
# Show retry dialog
|
||||
MessageService.critical(self, "Manual Steps Incomplete",
|
||||
f"Manual steps validation failed:\n\n{missing_text}\n\n"
|
||||
"Please complete the manual steps and try again.", safety_level="medium")
|
||||
MessageService.show_error(self, manual_steps_incomplete())
|
||||
# Show manual steps dialog again
|
||||
extra_warning = ""
|
||||
if self._manual_steps_retry_count >= 2:
|
||||
@@ -237,11 +225,9 @@ class ConfigureNewModlistDialogsMixin:
|
||||
self.show_manual_steps_dialog(extra_warning)
|
||||
else:
|
||||
# Max retries reached
|
||||
MessageService.critical(self, "Manual Steps Failed",
|
||||
"Manual steps validation failed after multiple attempts.", safety_level="medium")
|
||||
MessageService.show_error(self, manual_steps_incomplete())
|
||||
self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self.modlist_name_edit.text().strip())
|
||||
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
|
||||
"""Check if VNV automation should run and execute if applicable
|
||||
|
||||
@@ -265,7 +251,7 @@ class ConfigureNewModlistDialogsMixin:
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
|
||||
if not game_root:
|
||||
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return
|
||||
|
||||
# Confirmation callback - show dialog to user
|
||||
@@ -297,7 +283,7 @@ class ConfigureNewModlistDialogsMixin:
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return Path(file_path)
|
||||
return Path(file_path).resolve()
|
||||
return None
|
||||
|
||||
# Run automation
|
||||
@@ -322,10 +308,9 @@ class ConfigureNewModlistDialogsMixin:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"ERROR: Failed to run VNV automation: {e}")
|
||||
logger.debug(f"ERROR: Failed to run VNV automation: {e}")
|
||||
import traceback
|
||||
debug_print(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
logger.debug(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
dlg = QDialog(self)
|
||||
@@ -351,4 +336,3 @@ class ConfigureNewModlistDialogsMixin:
|
||||
btn_exit.clicked.connect(on_exit)
|
||||
dlg.exec()
|
||||
|
||||
|
||||
|
||||
@@ -10,22 +10,15 @@ from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
||||
from ..utils import set_responsive_minimum
|
||||
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
|
||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||
import logging
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ConfigureNewModlistUISetupMixin:
|
||||
"""Mixin providing UI setup and control management for ConfigureNewModlistScreen."""
|
||||
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, dev_mode=False, system_info=None):
|
||||
super().__init__()
|
||||
debug_print("DEBUG: ConfigureNewModlistScreen __init__ called")
|
||||
logger.debug("DEBUG: ConfigureNewModlistScreen __init__ called")
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
self.dev_mode = dev_mode
|
||||
@@ -179,7 +172,7 @@ class ConfigureNewModlistUISetupMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
@@ -363,11 +356,6 @@ class ConfigureNewModlistUISetupMixin:
|
||||
# Now collect all actionable controls after UI is fully built
|
||||
self._collect_actionable_controls()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _collect_actionable_controls(self):
|
||||
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
|
||||
self._actionable_controls = [
|
||||
@@ -382,34 +370,29 @@ class ConfigureNewModlistUISetupMixin:
|
||||
self.auto_restart_checkbox,
|
||||
]
|
||||
|
||||
|
||||
def _disable_controls_during_operation(self):
|
||||
"""Disable all actionable controls during configure operations (except Cancel)"""
|
||||
for control in self._actionable_controls:
|
||||
if control:
|
||||
control.setEnabled(False)
|
||||
|
||||
|
||||
def _enable_controls_after_operation(self):
|
||||
"""Re-enable all actionable controls after configure operations complete"""
|
||||
for control in self._actionable_controls:
|
||||
if control:
|
||||
control.setEnabled(True)
|
||||
|
||||
|
||||
def refresh_paths(self):
|
||||
"""Refresh cached paths when config changes."""
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
self.modlist_log_path = get_jackify_logs_dir() / 'Configure_New_Modlist_workflow.log'
|
||||
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
|
||||
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle window resize to prioritize form over console"""
|
||||
super().resizeEvent(event)
|
||||
self._adjust_console_for_form_priority()
|
||||
|
||||
|
||||
def _adjust_console_for_form_priority(self):
|
||||
"""Console now dynamically fills available space with stretch=1, no manual calculation needed"""
|
||||
# The console automatically fills remaining space due to stretch=1 in the layout
|
||||
@@ -417,7 +400,6 @@ class ConfigureNewModlistUISetupMixin:
|
||||
self.console.setMaximumHeight(16777215) # Reset to default maximum
|
||||
self.console.setMinimumHeight(50) # Keep minimum height for usability
|
||||
|
||||
|
||||
def _setup_scroll_tracking(self):
|
||||
"""Set up scroll tracking for professional auto-scroll behavior"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
@@ -425,17 +407,14 @@ class ConfigureNewModlistUISetupMixin:
|
||||
scrollbar.sliderReleased.connect(self._on_scrollbar_released)
|
||||
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
|
||||
|
||||
|
||||
def _on_scrollbar_pressed(self):
|
||||
"""User started manually scrolling"""
|
||||
self._user_manually_scrolled = True
|
||||
|
||||
|
||||
def _on_scrollbar_released(self):
|
||||
"""User finished manually scrolling"""
|
||||
self._user_manually_scrolled = False
|
||||
|
||||
|
||||
def _on_scrollbar_value_changed(self):
|
||||
"""Track if user is at bottom of scroll area"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
@@ -448,19 +427,16 @@ class ConfigureNewModlistUISetupMixin:
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom)
|
||||
|
||||
|
||||
def _reset_manual_scroll_if_at_bottom(self):
|
||||
"""Reset manual scroll flag if user is still at bottom after delay"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
if scrollbar.value() >= scrollbar.maximum() - 1:
|
||||
self._user_manually_scrolled = False
|
||||
|
||||
|
||||
def _on_show_details_toggled(self, checked):
|
||||
"""Handle Show Details checkbox toggle"""
|
||||
self._toggle_console_visibility(checked)
|
||||
|
||||
|
||||
def _toggle_console_visibility(self, is_checked):
|
||||
"""Toggle console visibility and window size - matches pattern from other screens"""
|
||||
main_window = None
|
||||
@@ -553,7 +529,6 @@ class ConfigureNewModlistUISetupMixin:
|
||||
# Notify parent to collapse
|
||||
self.resize_request.emit("compact")
|
||||
|
||||
|
||||
def update_top_panel(self):
|
||||
try:
|
||||
result = subprocess.run([
|
||||
@@ -596,7 +571,6 @@ class ConfigureNewModlistUISetupMixin:
|
||||
except Exception as e:
|
||||
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
|
||||
|
||||
|
||||
def _check_protontricks(self):
|
||||
"""Check if protontricks is available before critical operations"""
|
||||
try:
|
||||
@@ -628,4 +602,3 @@ class ConfigureNewModlistUISetupMixin:
|
||||
"Continuing anyway, but some features may not work correctly.")
|
||||
return True # Continue anyway
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
"""Workflow management for ConfigureNewModlistScreen (Mixin)."""
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
from jackify.shared.errors import configuration_failed
|
||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class ConfigureNewModlistWorkflowMixin:
|
||||
"""Mixin providing workflow management for ConfigureNewModlistScreen."""
|
||||
|
||||
@@ -35,6 +30,8 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
return 'fallout4'
|
||||
elif 'nvse_loader.exe' in content or 'fallout new vegas' in content:
|
||||
return 'falloutnv'
|
||||
elif 'fose_loader.exe' in content or 'fallout 3' in content:
|
||||
return 'fallout3'
|
||||
elif 'obse_loader.exe' in content or 'oblivion' in content:
|
||||
return 'oblivion'
|
||||
elif 'starfield' in content:
|
||||
@@ -47,7 +44,6 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
|
||||
return 'skyrim'
|
||||
|
||||
|
||||
def validate_and_start_configure(self):
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
self.config_handler.reload_config()
|
||||
@@ -99,27 +95,27 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
debug_print(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
debug_print("DEBUG: Failed to save resolution")
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
debug_print("DEBUG: Saved resolution cleared")
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
|
||||
# Start configuration - automated workflow handles Steam restart internally
|
||||
self.configure_modlist()
|
||||
|
||||
|
||||
def configure_modlist(self):
|
||||
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
|
||||
# Refresh Proton version and winetricks settings
|
||||
self.config_handler._load_config()
|
||||
|
||||
install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()
|
||||
_raw_mo2_path = os.path.realpath(self.install_dir_edit.text().strip())
|
||||
install_dir = os.path.dirname(_raw_mo2_path) if _raw_mo2_path.endswith('ModOrganizer.exe') else _raw_mo2_path
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
mo2_exe_path = self.install_dir_edit.text().strip()
|
||||
mo2_exe_path = _raw_mo2_path
|
||||
resolution = self.resolution_combo.currentText()
|
||||
if not install_dir or not modlist_name:
|
||||
MessageService.warning(self, "Missing Info", "Install directory or modlist name is missing.", safety_level="low")
|
||||
@@ -128,18 +124,18 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
# Use automated prefix service instead of manual steps
|
||||
self._safe_append_text("")
|
||||
self._safe_append_text("=== Steam Integration Phase ===")
|
||||
self._safe_append_text("Starting automated Steam setup workflow...")
|
||||
logger.info("Starting automated Steam setup workflow...")
|
||||
|
||||
# Start automated prefix workflow
|
||||
self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path, resolution)
|
||||
|
||||
|
||||
def _start_automated_prefix_workflow(self, modlist_name, install_dir, mo2_exe_path, resolution):
|
||||
"""Start the automated prefix workflow using AutomatedPrefixService in a background thread"""
|
||||
ensure_flatpak_steam_filesystem_access(Path(install_dir))
|
||||
from jackify import __version__ as jackify_version
|
||||
self._safe_append_text(f"Jackify v{jackify_version}")
|
||||
self._safe_append_text(f"Initializing automated Steam setup for '{modlist_name}'...")
|
||||
self._safe_append_text("Starting automated Steam shortcut creation and configuration...")
|
||||
logger.info("Jackify v%s", jackify_version)
|
||||
logger.info("Initializing automated Steam setup for '%s'...", modlist_name)
|
||||
logger.info("Starting automated Steam shortcut creation and configuration...")
|
||||
|
||||
# Disable the start button to prevent multiple workflows
|
||||
self.start_btn.setEnabled(False)
|
||||
@@ -148,7 +144,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
class AutomatedPrefixThread(QThread):
|
||||
progress_update = Signal(str)
|
||||
workflow_complete = Signal(object) # Will emit the result tuple
|
||||
error_occurred = Signal(str)
|
||||
error_occurred = Signal(object) # error (JackifyError or str)
|
||||
|
||||
def __init__(self, modlist_name, install_dir, mo2_exe_path, steamdeck, auto_restart):
|
||||
super().__init__()
|
||||
@@ -179,7 +175,10 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
self.workflow_complete.emit(result)
|
||||
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
from jackify.shared.errors import JackifyError, prefix_creation_failed
|
||||
if not isinstance(e, JackifyError):
|
||||
e = prefix_creation_failed(str(e))
|
||||
self.error_occurred.emit(e)
|
||||
|
||||
# Detect Steam Deck once using centralized service
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
@@ -210,7 +209,6 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
self.automated_prefix_thread.error_occurred.connect(self._on_automated_prefix_error)
|
||||
self.automated_prefix_thread.start()
|
||||
|
||||
|
||||
def _on_automated_prefix_complete(self, result):
|
||||
"""Handle completion of the automated prefix workflow"""
|
||||
try:
|
||||
@@ -233,8 +231,8 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip(),
|
||||
last_timestamp)
|
||||
else:
|
||||
self._safe_append_text(f"Automated Steam setup failed")
|
||||
self._safe_append_text("Please check the logs for details.")
|
||||
error_reason = last_timestamp or "Unknown error"
|
||||
self._safe_append_text(f"Automated Steam setup failed: {error_reason}")
|
||||
self.start_btn.setEnabled(True)
|
||||
elif isinstance(result, tuple) and len(result) == 3:
|
||||
# Fallback for old format (backward compatibility)
|
||||
@@ -242,49 +240,42 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
if success:
|
||||
self._safe_append_text(f"Automated Steam setup completed successfully!")
|
||||
self._safe_append_text(f"New AppID assigned: {new_appid}")
|
||||
|
||||
|
||||
# Continue with post-Steam configuration
|
||||
self.continue_configuration_after_automated_prefix(new_appid, self.modlist_name_edit.text().strip(),
|
||||
self.continue_configuration_after_automated_prefix(new_appid, self.modlist_name_edit.text().strip(),
|
||||
os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip())
|
||||
else:
|
||||
self._safe_append_text(f"Automated Steam setup failed")
|
||||
self._safe_append_text("Please check the logs for details.")
|
||||
self.start_btn.setEnabled(True)
|
||||
else:
|
||||
# Handle unexpected result format
|
||||
self._safe_append_text(f"Automated Steam setup failed - unexpected result format")
|
||||
self._safe_append_text("Please check the logs for details.")
|
||||
self.start_btn.setEnabled(True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error handling automated prefix result: %s", e)
|
||||
self._safe_append_text(f"Error handling automated prefix result: {str(e)}")
|
||||
self.start_btn.setEnabled(True)
|
||||
|
||||
|
||||
def _on_automated_prefix_error(self, error_message):
|
||||
def _on_automated_prefix_error(self, error):
|
||||
"""Handle error from the automated prefix workflow"""
|
||||
self._safe_append_text(f"Error during automated Steam setup: {error_message}")
|
||||
self._safe_append_text("Please check the logs for details.")
|
||||
|
||||
# Show critical error dialog to user (don't silently fail)
|
||||
from jackify.backend.services.message_service import MessageService
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Steam Setup Error",
|
||||
f"Error during automated Steam setup:\n\n{error_message}\n\nPlease check the console output for details.",
|
||||
safety_level="medium"
|
||||
)
|
||||
from jackify.shared.errors import JackifyError, classify_exception
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
if not isinstance(error, JackifyError):
|
||||
error = classify_exception(str(error))
|
||||
logger.error(f"Automated prefix error: {error.message}")
|
||||
self._safe_append_text(f"[FAILED] {error.message}")
|
||||
MessageService.show_error(self, error)
|
||||
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
|
||||
def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None):
|
||||
"""Continue the configuration process with the new AppID after automated prefix creation"""
|
||||
# Headers are now shown at start of Steam Integration
|
||||
# No need to show them again here
|
||||
debug_print("Configuration phase continues after Steam Integration")
|
||||
logger.debug("Configuration phase continues after Steam Integration")
|
||||
|
||||
debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}")
|
||||
logger.debug(f"continue_configuration_after_automated_prefix called with appid: {new_appid}")
|
||||
try:
|
||||
# Get resolution from UI
|
||||
resolution = self.resolution_combo.currentText()
|
||||
@@ -305,7 +296,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
'game_name': 'Skyrim Special Edition' # Default for new modlist
|
||||
}
|
||||
self.context = updated_context # Ensure context is always set
|
||||
debug_print(f"Updated context with new AppID: {new_appid}")
|
||||
logger.debug(f"Updated context with new AppID: {new_appid}")
|
||||
|
||||
# Create new config thread with updated context
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
@@ -391,12 +382,10 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
self.config_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error continuing configuration: %s", e, exc_info=True)
|
||||
self._safe_append_text(f"Error continuing configuration: {e}")
|
||||
import traceback
|
||||
self._safe_append_text(f"Full traceback: {traceback.format_exc()}")
|
||||
self.on_configuration_error(str(e))
|
||||
|
||||
|
||||
def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir):
|
||||
"""Continue the configuration process with the corrected AppID after manual steps validation"""
|
||||
try:
|
||||
@@ -414,7 +403,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
'appid': new_appid, # Use the NEW AppID from Steam
|
||||
'game_name': 'Skyrim Special Edition' # Default for new modlist
|
||||
}
|
||||
debug_print(f"Updated context with new AppID: {new_appid}")
|
||||
logger.debug(f"Updated context with new AppID: {new_appid}")
|
||||
|
||||
# Create new config thread with updated context (same as Tuxborn)
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
@@ -500,9 +489,9 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
self.config_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error continuing configuration: %s", e, exc_info=True)
|
||||
self._safe_append_text(f"Error continuing configuration: {e}")
|
||||
MessageService.critical(self, "Configuration Error", f"Failed to continue configuration: {e}", safety_level="medium")
|
||||
|
||||
MessageService.show_error(self, configuration_failed(str(e)))
|
||||
|
||||
def _calculate_time_taken(self) -> str:
|
||||
"""Calculate and format the time taken for the workflow"""
|
||||
@@ -521,4 +510,3 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
else:
|
||||
return f"{elapsed_seconds_remainder} seconds"
|
||||
|
||||
|
||||
|
||||
454
jackify/frontends/gui/screens/install_mo2_screen.py
Normal file
454
jackify/frontends/gui/screens/install_mo2_screen.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Install MO2 Screen
|
||||
|
||||
Downloads and configures a standalone Mod Organizer 2 instance via
|
||||
MO2SetupService. No Wabbajack modlist required.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QCheckBox,
|
||||
QMessageBox, QSizePolicy,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QSize
|
||||
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.shared.errors import mo2_setup_failed
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
from ..services.message_service import MessageService
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
||||
from ..utils import set_responsive_minimum
|
||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||
from ..widgets.file_progress_list import FileProgressList
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MO2SetupWorker(QThread):
|
||||
"""Background worker for standalone MO2 setup"""
|
||||
|
||||
progress_update = Signal(str)
|
||||
log_output = Signal(str)
|
||||
setup_complete = Signal(bool, object, str) # success, app_id (int|None), error_msg
|
||||
|
||||
def __init__(self, install_dir: Path, shortcut_name: str):
|
||||
super().__init__()
|
||||
self.install_dir = install_dir
|
||||
self.shortcut_name = shortcut_name
|
||||
|
||||
def run(self):
|
||||
from jackify.backend.services.mo2_setup_service import MO2SetupService
|
||||
|
||||
def _progress(msg: str):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progress_update.emit(msg)
|
||||
self.log_output.emit(msg)
|
||||
|
||||
try:
|
||||
service = MO2SetupService()
|
||||
success, app_id, error_msg = service.setup_mo2(
|
||||
install_dir=self.install_dir,
|
||||
shortcut_name=self.shortcut_name,
|
||||
progress_callback=_progress,
|
||||
should_cancel=self.isInterruptionRequested,
|
||||
)
|
||||
if self.isInterruptionRequested():
|
||||
self.setup_complete.emit(False, None, "MO2 setup cancelled.")
|
||||
return
|
||||
self.setup_complete.emit(success, app_id, error_msg or "")
|
||||
except Exception as e:
|
||||
logger.exception("Unhandled exception in MO2 setup worker")
|
||||
self.setup_complete.emit(False, None, str(e))
|
||||
|
||||
|
||||
class InstallMO2Screen(ScreenBackMixin, QWidget):
|
||||
"""Standalone MO2 setup screen"""
|
||||
|
||||
resize_request = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stacked_widget=None,
|
||||
additional_tasks_index: int = 3,
|
||||
system_info: Optional[SystemInfo] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = additional_tasks_index
|
||||
self.additional_tasks_index = additional_tasks_index
|
||||
self.system_info = system_info or SystemInfo(is_steamdeck=False)
|
||||
self.debug = DEBUG_BORDERS
|
||||
self.worker = None
|
||||
|
||||
self._user_manually_scrolled = False
|
||||
self._was_at_bottom = True
|
||||
|
||||
self.progress_indicator = OverallProgressIndicator(show_progress_bar=False)
|
||||
self.progress_indicator.set_status("Ready", 0)
|
||||
|
||||
self.file_progress_list = FileProgressList()
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
|
||||
main_layout.setContentsMargins(50, 50, 50, 0)
|
||||
main_layout.setSpacing(12)
|
||||
|
||||
self._setup_header(main_layout)
|
||||
self._setup_upper_section(main_layout)
|
||||
self._setup_status_banner(main_layout)
|
||||
self._setup_console(main_layout)
|
||||
self._setup_buttons(main_layout)
|
||||
|
||||
def _setup_header(self, layout):
|
||||
header_layout = QVBoxLayout()
|
||||
header_layout.setSpacing(1)
|
||||
|
||||
title = QLabel("<b>Setup Mod Organizer 2</b>")
|
||||
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
|
||||
title.setAlignment(Qt.AlignHCenter)
|
||||
title.setMaximumHeight(30)
|
||||
header_layout.addWidget(title)
|
||||
|
||||
desc = QLabel("Download and configure a standalone MO2 instance with a Proton prefix")
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px;")
|
||||
desc.setAlignment(Qt.AlignHCenter)
|
||||
desc.setMaximumHeight(40)
|
||||
header_layout.addWidget(desc)
|
||||
|
||||
header_widget = QWidget()
|
||||
header_widget.setLayout(header_layout)
|
||||
header_widget.setMaximumHeight(75)
|
||||
layout.addWidget(header_widget)
|
||||
|
||||
def _setup_upper_section(self, layout):
|
||||
upper_hbox = QHBoxLayout()
|
||||
upper_hbox.setContentsMargins(0, 0, 0, 0)
|
||||
upper_hbox.setSpacing(16)
|
||||
|
||||
# Left: form
|
||||
form_widget = self._build_form_widget()
|
||||
upper_hbox.addWidget(form_widget, stretch=11)
|
||||
|
||||
# Right: activity window
|
||||
activity_header = QLabel("<b>[Activity]</b>")
|
||||
activity_header.setStyleSheet(
|
||||
f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;"
|
||||
)
|
||||
activity_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
self.file_progress_list.setMinimumSize(QSize(300, 20))
|
||||
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
activity_vbox = QVBoxLayout()
|
||||
activity_vbox.setContentsMargins(0, 0, 0, 0)
|
||||
activity_vbox.setSpacing(2)
|
||||
activity_vbox.addWidget(activity_header)
|
||||
activity_vbox.addWidget(self.file_progress_list, stretch=1)
|
||||
|
||||
activity_widget = QWidget()
|
||||
activity_widget.setLayout(activity_vbox)
|
||||
activity_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
upper_hbox.addWidget(activity_widget, stretch=9)
|
||||
|
||||
upper_section = QWidget()
|
||||
upper_section.setLayout(upper_hbox)
|
||||
upper_section.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
upper_section.setMaximumHeight(240)
|
||||
layout.addWidget(upper_section)
|
||||
|
||||
def _build_form_widget(self):
|
||||
form_vbox = QVBoxLayout()
|
||||
form_vbox.setAlignment(Qt.AlignTop)
|
||||
form_vbox.setContentsMargins(0, 0, 0, 0)
|
||||
form_vbox.setSpacing(8)
|
||||
|
||||
options_header = QLabel("<b>[Options]</b>")
|
||||
options_header.setStyleSheet(
|
||||
f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;"
|
||||
)
|
||||
options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
form_vbox.addWidget(options_header)
|
||||
|
||||
form_grid = QGridLayout()
|
||||
form_grid.setHorizontalSpacing(12)
|
||||
form_grid.setVerticalSpacing(8)
|
||||
form_grid.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Shortcut name
|
||||
form_grid.addWidget(QLabel("Shortcut Name:"), 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.shortcut_name_edit = QLineEdit("Mod Organizer 2")
|
||||
self.shortcut_name_edit.setMaximumHeight(25)
|
||||
form_grid.addWidget(self.shortcut_name_edit, 0, 1)
|
||||
|
||||
# Install directory
|
||||
form_grid.addWidget(QLabel("Install Directory:"), 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
||||
default_dir = str(Path.home() / "ModOrganizer2")
|
||||
self.install_dir_edit = QLineEdit(default_dir)
|
||||
self.install_dir_edit.setMaximumHeight(25)
|
||||
|
||||
browse_btn = QPushButton("Browse")
|
||||
browse_btn.setFixedSize(80, 25)
|
||||
browse_btn.clicked.connect(self._browse_folder)
|
||||
|
||||
dir_hbox = QHBoxLayout()
|
||||
dir_hbox.addWidget(self.install_dir_edit)
|
||||
dir_hbox.addWidget(browse_btn)
|
||||
form_grid.addLayout(dir_hbox, 1, 1)
|
||||
|
||||
form_vbox.addLayout(form_grid)
|
||||
|
||||
info = QLabel(
|
||||
"Jackify will download the latest Mod Organizer 2 release from GitHub, extract it to the "
|
||||
"chosen directory, add it as a non-Steam game, and configure a Proton prefix automatically. "
|
||||
"Steam will be restarted during this process."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #999; font-size: 11px;")
|
||||
form_vbox.addWidget(info)
|
||||
|
||||
form_widget = QWidget()
|
||||
form_widget.setLayout(form_vbox)
|
||||
form_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
return form_widget
|
||||
|
||||
def _setup_status_banner(self, layout):
|
||||
banner_row = QHBoxLayout()
|
||||
banner_row.setContentsMargins(0, 0, 0, 0)
|
||||
banner_row.setSpacing(8)
|
||||
banner_row.addWidget(self.progress_indicator, 1)
|
||||
banner_row.addStretch()
|
||||
|
||||
self.show_details_checkbox = QCheckBox("Show details")
|
||||
self.show_details_checkbox.setChecked(False)
|
||||
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
|
||||
banner_row.addWidget(self.show_details_checkbox)
|
||||
|
||||
banner_widget = QWidget()
|
||||
banner_widget.setLayout(banner_row)
|
||||
banner_widget.setMaximumHeight(45)
|
||||
banner_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
layout.addWidget(banner_widget)
|
||||
|
||||
def _setup_console(self, layout):
|
||||
self.console = QTextEdit()
|
||||
self.console.setReadOnly(True)
|
||||
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
|
||||
self.console.setMinimumHeight(50)
|
||||
self.console.setMaximumHeight(1000)
|
||||
self.console.setFontFamily('monospace')
|
||||
self.console.setVisible(False)
|
||||
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
scrollbar.sliderPressed.connect(lambda: setattr(self, '_user_manually_scrolled', True))
|
||||
scrollbar.sliderReleased.connect(lambda: setattr(self, '_user_manually_scrolled', False))
|
||||
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
|
||||
|
||||
layout.addWidget(self.console, stretch=1)
|
||||
|
||||
def _on_scrollbar_value_changed(self):
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
|
||||
|
||||
def _setup_buttons(self, layout):
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setAlignment(Qt.AlignHCenter)
|
||||
|
||||
self.start_btn = QPushButton("Start Setup")
|
||||
self.start_btn.setFixedHeight(35)
|
||||
self.start_btn.clicked.connect(self._start_setup)
|
||||
btn_row.addWidget(self.start_btn)
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.setFixedHeight(35)
|
||||
self.cancel_btn.clicked.connect(self._go_back)
|
||||
btn_row.addWidget(self.cancel_btn)
|
||||
|
||||
btn_widget = QWidget()
|
||||
btn_widget.setLayout(btn_row)
|
||||
btn_widget.setMaximumHeight(50)
|
||||
layout.addWidget(btn_widget)
|
||||
|
||||
def _on_show_details_toggled(self, checked):
|
||||
self.console.setVisible(checked)
|
||||
self.resize_request.emit("expand" if checked else "collapse")
|
||||
|
||||
def _browse_folder(self):
|
||||
folder = QFileDialog.getExistingDirectory(
|
||||
self, "Select MO2 Installation Folder", str(Path.home()), QFileDialog.ShowDirsOnly
|
||||
)
|
||||
if folder:
|
||||
self.install_dir_edit.setText(os.path.realpath(folder))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Activity window helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Maps a substring of the progress message to (item_id, display_label, OperationType, percent)
|
||||
_ACTIVITY_MAP = [
|
||||
("Fetching latest MO2", "fetch", "Fetching release info", OperationType.UNKNOWN, 0.0),
|
||||
("Downloading ", "download", "Downloading MO2 archive", OperationType.DOWNLOAD, 0.0),
|
||||
("Extracting to ", "extract", "Extracting archive", OperationType.EXTRACT, 0.0),
|
||||
("MO2 installed at", "extract", "Extracting archive", OperationType.EXTRACT, 100.0),
|
||||
("Creating Steam shortcut", "prefix", "Creating Steam shortcut & prefix", OperationType.INSTALL, 0.0),
|
||||
("MO2 setup complete", "complete", "Setup complete", OperationType.INSTALL, 100.0),
|
||||
]
|
||||
|
||||
def _on_activity_progress(self, message: str):
|
||||
for trigger, item_id, label, op_type, pct in self._ACTIVITY_MAP:
|
||||
if trigger in message:
|
||||
fp = FileProgress(
|
||||
filename=label,
|
||||
operation=op_type,
|
||||
percent=pct,
|
||||
current_size=0,
|
||||
total_size=0,
|
||||
)
|
||||
self.file_progress_list.update_files([fp])
|
||||
break
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_setup(self):
|
||||
install_dir_text = self.install_dir_edit.text().strip()
|
||||
if not install_dir_text:
|
||||
MessageService.warning(self, "No Directory", "Please select an installation directory.")
|
||||
return
|
||||
|
||||
install_dir = Path(install_dir_text).resolve()
|
||||
shortcut_name = self.shortcut_name_edit.text().strip() or "Mod Organizer 2"
|
||||
|
||||
confirm = MessageService.question(
|
||||
self,
|
||||
"Confirm MO2 Setup",
|
||||
f"Install Mod Organizer 2 to:\n{install_dir}\n\n"
|
||||
"Jackify will download MO2, add it to Steam, and configure a Proton prefix.\n"
|
||||
"Steam will be restarted during this process.\n\nContinue?",
|
||||
safety_level="medium",
|
||||
)
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
self.console.clear()
|
||||
self.file_progress_list.clear()
|
||||
self.file_progress_list.start_cpu_tracking()
|
||||
|
||||
self.start_btn.setEnabled(False)
|
||||
self.cancel_btn.setEnabled(False)
|
||||
self.progress_indicator.set_status("Starting...", 0)
|
||||
|
||||
self.worker = MO2SetupWorker(install_dir, shortcut_name)
|
||||
self.worker.progress_update.connect(self._on_progress_update)
|
||||
self.worker.progress_update.connect(self._on_activity_progress)
|
||||
self.worker.log_output.connect(self._on_log_output)
|
||||
self.worker.setup_complete.connect(self._on_setup_complete)
|
||||
self.worker.start()
|
||||
|
||||
def _on_progress_update(self, message: str):
|
||||
self.progress_indicator.set_status(message, 0)
|
||||
|
||||
def _on_log_output(self, message: str):
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
|
||||
self.console.append(message)
|
||||
if was_at_bottom and not self._user_manually_scrolled:
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
def _on_setup_complete(self, success: bool, app_id, error_msg: str):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
|
||||
if success:
|
||||
self.progress_indicator.set_status("Setup complete!", 100)
|
||||
MessageService.information(
|
||||
self,
|
||||
"MO2 Setup Complete",
|
||||
f"Mod Organizer 2 has been installed and configured.\n\n"
|
||||
f"Steam AppID: {app_id}\n\n"
|
||||
"Launch MO2 from your Steam library.",
|
||||
)
|
||||
self.install_dir_edit.setText(str(Path.home() / "ModOrganizer2"))
|
||||
self.shortcut_name_edit.setText("Mod Organizer 2")
|
||||
else:
|
||||
self.progress_indicator.set_status("Setup failed", 0)
|
||||
MessageService.show_error(self, mo2_setup_failed(error_msg or "Setup failed."))
|
||||
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setEnabled(True)
|
||||
if self.worker is not None:
|
||||
try:
|
||||
self.worker.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
self.worker = None
|
||||
|
||||
def _go_back(self):
|
||||
if self.worker and self.worker.isRunning():
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"MO2 Setup In Progress",
|
||||
"MO2 setup is still running. Leave this screen and cancel setup?",
|
||||
critical=False,
|
||||
safety_level="medium",
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
self.cleanup_processes()
|
||||
self.collapse_show_details_before_leave()
|
||||
self.go_back()
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Stop active MO2 worker and CPU tracking before screen/app shutdown."""
|
||||
try:
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.worker is not None:
|
||||
try:
|
||||
if self.worker.isRunning():
|
||||
self.worker.requestInterruption()
|
||||
if not self.worker.wait(5000):
|
||||
self.worker.terminate()
|
||||
self.worker.wait(10000)
|
||||
self.worker.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
self.worker = None
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
self.file_progress_list.clear()
|
||||
self.console.clear()
|
||||
self.progress_indicator.set_status("Ready", 0)
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.blockSignals(True)
|
||||
self.show_details_checkbox.setChecked(False)
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
self.console.setVisible(False)
|
||||
self.resize_request.emit("collapse")
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
# Keep MO2 screen consistent with other workflows: details collapsed by default.
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.blockSignals(True)
|
||||
self.show_details_checkbox.setChecked(False)
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
self.console.setVisible(False)
|
||||
self.resize_request.emit("collapse")
|
||||
try:
|
||||
main_window = self.window()
|
||||
if main_window:
|
||||
main_window.setMaximumSize(QSize(16777215, 16777215))
|
||||
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -31,8 +31,11 @@ from jackify.backend.handlers.progress_parser import ProgressStateManager
|
||||
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
|
||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType, FileProgress
|
||||
from jackify.shared.errors import manual_steps_incomplete
|
||||
# Modlist gallery (imported at module level to avoid import delay when opening dialog)
|
||||
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from .install_modlist_dialogs import ModlistFetchThread, SelectionDialog
|
||||
from .install_modlist_ui_setup import InstallModlistUISetupMixin
|
||||
from .install_modlist_console import ConsoleOutputMixin
|
||||
@@ -47,15 +50,7 @@ from .install_modlist_nexus import NexusAuthMixin
|
||||
from .install_modlist_selection import ModlistSelectionMixin
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
|
||||
steam_restart_finished = Signal(bool, str)
|
||||
resize_request = Signal(str) # Signal for expand/collapse like TTW screen
|
||||
def _collect_actionable_controls(self):
|
||||
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
|
||||
@@ -220,7 +215,7 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
||||
# DO NOT resize - let window stay at current size
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: showEvent exception: {e}")
|
||||
logger.debug(f"DEBUG: showEvent exception: {e}")
|
||||
|
||||
def _start_gallery_cache_preload(self):
|
||||
"""DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow"""
|
||||
@@ -252,22 +247,22 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
# Check if we got mods
|
||||
modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods)
|
||||
if modlists_with_mods > 0:
|
||||
debug_print(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)")
|
||||
logger.debug(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)")
|
||||
else:
|
||||
# Cache didn't have mods, but we fetched fresh - should have mods now
|
||||
debug_print("DEBUG: Gallery cache updated")
|
||||
logger.debug("DEBUG: Gallery cache updated")
|
||||
else:
|
||||
debug_print("DEBUG: Failed to load gallery cache")
|
||||
logger.debug("DEBUG: Failed to load gallery cache")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Gallery cache preload error: {str(e)}")
|
||||
logger.debug(f"DEBUG: Gallery cache preload error: {str(e)}")
|
||||
|
||||
# Start thread (non-blocking, invisible to user)
|
||||
self._gallery_cache_preload_thread = GalleryCachePreloadThread()
|
||||
# Don't connect finished signal - we don't need to do anything, just let it run
|
||||
self._gallery_cache_preload_thread.start()
|
||||
|
||||
debug_print("DEBUG: Started background gallery cache preload")
|
||||
logger.debug("DEBUG: Started background gallery cache preload")
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Called when the widget is hidden. Do not clear main window constraints so collapse from go_back() sticks."""
|
||||
@@ -288,17 +283,17 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
if saved_install_parent:
|
||||
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
|
||||
self.install_dir_edit.setText(suggested_install_dir)
|
||||
debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
||||
logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
||||
|
||||
# Update download directory suggestion
|
||||
saved_download_parent = self.config_handler.get_default_download_parent_dir()
|
||||
if saved_download_parent:
|
||||
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
|
||||
self.downloads_dir_edit.setText(suggested_download_dir)
|
||||
debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
||||
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Error updating directory suggestions: {e}")
|
||||
logger.debug(f"DEBUG: Error updating directory suggestions: {e}")
|
||||
|
||||
def _save_parent_directories(self, install_dir, downloads_dir):
|
||||
"""Removed automatic saving - user should set defaults in settings"""
|
||||
@@ -380,9 +375,7 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
elif self._manual_steps_retry_count == 2:
|
||||
retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location."
|
||||
|
||||
MessageService.critical(self, "Manual Steps Incomplete",
|
||||
f"Manual steps validation failed:\n\n{missing_text}\n\n"
|
||||
f"Please complete the missing steps and try again.{retry_guidance}")
|
||||
MessageService.show_error(self, manual_steps_incomplete())
|
||||
# Show manual steps dialog again
|
||||
extra_warning = ""
|
||||
if self._manual_steps_retry_count >= 2:
|
||||
@@ -390,13 +383,7 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
self.show_manual_steps_dialog(extra_warning)
|
||||
else:
|
||||
# Max retries reached
|
||||
MessageService.critical(self, "Manual Steps Failed",
|
||||
"Manual steps validation failed after multiple attempts.\n\n"
|
||||
"Common issues:\n"
|
||||
"• Steam not fully restarted\n"
|
||||
"• Shortcut not launched from Steam\n"
|
||||
"• Flatpak Steam using different file paths\n"
|
||||
"• Proton - Experimental not selected")
|
||||
MessageService.show_error(self, manual_steps_incomplete())
|
||||
self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name)
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
@@ -426,34 +413,77 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
|
||||
|
||||
# Clean up InstallationThread if running
|
||||
if hasattr(self, 'install_thread') and self.install_thread.isRunning():
|
||||
debug_print("DEBUG: Cancelling running InstallationThread")
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(3000) # Wait up to 3 seconds
|
||||
if self.install_thread.isRunning():
|
||||
self.install_thread.terminate()
|
||||
|
||||
# Clean up other threads
|
||||
threads = [
|
||||
'prefix_thread', 'config_thread', 'fetch_thread'
|
||||
]
|
||||
for thread_name in threads:
|
||||
if hasattr(self, thread_name):
|
||||
thread = getattr(self, thread_name)
|
||||
if thread and thread.isRunning():
|
||||
debug_print(f"DEBUG: Terminating {thread_name}")
|
||||
thread.terminate()
|
||||
thread.wait(1000) # Wait up to 1 second
|
||||
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
|
||||
|
||||
def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000):
|
||||
thread = getattr(self, attr_name, None)
|
||||
if thread is None:
|
||||
return
|
||||
try:
|
||||
running = thread.isRunning()
|
||||
except RuntimeError:
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
|
||||
if not running:
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
|
||||
logger.debug(f"DEBUG: Stopping {attr_name}")
|
||||
|
||||
if cancel_method and hasattr(thread, cancel_method):
|
||||
try:
|
||||
getattr(thread, cancel_method)()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
thread.requestInterruption()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
thread.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if thread.wait(cooperative_ms):
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, forcing terminate")
|
||||
try:
|
||||
if cancel_method and hasattr(thread, cancel_method):
|
||||
getattr(thread, cancel_method)()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
thread.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if not thread.wait(force_ms):
|
||||
logger.error(f"ERROR: {attr_name} still running after forced shutdown window")
|
||||
except Exception:
|
||||
pass
|
||||
setattr(self, attr_name, None)
|
||||
|
||||
# Always stop installer thread first; this is the most likely source of QThread teardown aborts.
|
||||
_stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000)
|
||||
|
||||
# Stop remaining worker threads.
|
||||
for thread_name in ('prefix_thread', 'config_thread', 'fetch_thread', '_gallery_cache_preload_thread'):
|
||||
_stop_thread(thread_name)
|
||||
|
||||
def cancel_installation(self):
|
||||
"""Cancel the currently running installation"""
|
||||
reply = MessageService.question(
|
||||
self, "Cancel Installation",
|
||||
"Are you sure you want to cancel the installation?",
|
||||
critical=False # Non-critical, won't steal focus
|
||||
critical=False, # Non-critical, won't steal focus
|
||||
safety_level="medium",
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
@@ -463,19 +493,20 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
self._cancellation_requested = True
|
||||
|
||||
try:
|
||||
# Clear Active Files window and reset progress indicator
|
||||
# Clear Active Files window and update progress indicator
|
||||
if hasattr(self, 'file_progress_list'):
|
||||
self.file_progress_list.clear()
|
||||
if hasattr(self, 'progress_indicator'):
|
||||
self.progress_indicator.reset()
|
||||
self.progress_indicator.set_status("Cancelled", None)
|
||||
|
||||
# Cancel the installation thread if it exists
|
||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
|
||||
self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python
|
||||
if self.install_thread.isRunning():
|
||||
self.install_thread.terminate() # Force terminate if needed
|
||||
self.install_thread.wait(1000)
|
||||
logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying")
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(5000)
|
||||
|
||||
# Cancel the automated prefix thread if it exists
|
||||
if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning():
|
||||
@@ -509,7 +540,7 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"ERROR: Exception during cancellation cleanup: {e}")
|
||||
logger.debug(f"ERROR: Exception during cancellation cleanup: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -573,4 +604,4 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
||||
# Re-enable controls (in case they were disabled from previous errors)
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,183 +9,12 @@ import threading
|
||||
import subprocess
|
||||
import time
|
||||
import os
|
||||
import logging
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class AutomatedPrefixHandlersMixin:
|
||||
"""Mixin providing automated prefix workflow event handlers for InstallModlistScreen."""
|
||||
|
||||
def restart_steam_and_configure(self):
|
||||
"""Restart Steam using backend service directly - DECOUPLED FROM CLI"""
|
||||
debug_print("DEBUG: restart_steam_and_configure called - using direct backend service")
|
||||
progress = QProgressDialog("Restarting Steam...", None, 0, 0, self)
|
||||
progress.setWindowTitle("Restarting Steam")
|
||||
progress.setWindowModality(Qt.WindowModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setValue(0)
|
||||
progress.show()
|
||||
|
||||
def do_restart():
|
||||
debug_print("DEBUG: do_restart thread started - using direct backend service")
|
||||
try:
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
|
||||
# Use backend service directly instead of CLI subprocess
|
||||
# Get system_info from parent screen
|
||||
system_info = getattr(self, 'system_info', None)
|
||||
is_steamdeck = system_info.is_steamdeck if system_info else False
|
||||
shortcut_handler = ShortcutHandler(steamdeck=is_steamdeck)
|
||||
|
||||
debug_print("DEBUG: About to call secure_steam_restart()")
|
||||
success = shortcut_handler.secure_steam_restart()
|
||||
debug_print(f"DEBUG: secure_steam_restart() returned: {success}")
|
||||
|
||||
out = "Steam restart completed successfully." if success else "Steam restart failed."
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Exception in do_restart: {e}")
|
||||
success = False
|
||||
out = str(e)
|
||||
|
||||
self.steam_restart_finished.emit(success, out)
|
||||
|
||||
threading.Thread(target=do_restart, daemon=True).start()
|
||||
self._steam_restart_progress = progress # Store to close later
|
||||
|
||||
def _on_steam_restart_finished(self, success, out):
|
||||
debug_print("DEBUG: _on_steam_restart_finished called")
|
||||
# Safely cleanup progress dialog on main thread
|
||||
if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress:
|
||||
try:
|
||||
self._steam_restart_progress.close()
|
||||
self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Error closing progress dialog: {e}")
|
||||
finally:
|
||||
self._steam_restart_progress = None
|
||||
|
||||
# Controls are managed by the proper control management system
|
||||
if success:
|
||||
self._safe_append_text("Steam restarted successfully.")
|
||||
|
||||
# Force Steam GUI to start after restart
|
||||
# Ensure Steam GUI is visible after restart
|
||||
# start_steam() now uses -foreground, but we'll also try to bring GUI to front
|
||||
debug_print("DEBUG: Ensuring Steam GUI is visible after restart")
|
||||
try:
|
||||
# Wait a moment for Steam processes to stabilize
|
||||
time.sleep(3)
|
||||
# Try multiple methods to ensure GUI opens
|
||||
# Method 1: steam:// protocol (works if Steam is running)
|
||||
try:
|
||||
subprocess.Popen(['xdg-open', 'steam://open/main'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
debug_print("DEBUG: Issued steam://open/main command")
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: steam://open/main failed: {e}")
|
||||
|
||||
# Method 2: Direct steam -foreground command (redundant but ensures GUI)
|
||||
try:
|
||||
subprocess.Popen(['steam', '-foreground'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
debug_print("DEBUG: Issued steam -foreground command")
|
||||
except Exception as e2:
|
||||
debug_print(f"DEBUG: steam -foreground failed: {e2}")
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Error ensuring Steam GUI visibility: {e}")
|
||||
|
||||
# CRITICAL: Bring Jackify window back to focus after Steam restart
|
||||
# Let user continue with installation
|
||||
debug_print("DEBUG: Bringing Jackify window back to focus")
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication
|
||||
# Get the main window - use window() to get top-level widget, then find QMainWindow
|
||||
top_level = self.window()
|
||||
main_window = None
|
||||
|
||||
# Try to find QMainWindow in the widget hierarchy
|
||||
if isinstance(top_level, QMainWindow):
|
||||
main_window = top_level
|
||||
else:
|
||||
# Walk up the parent chain
|
||||
current = self
|
||||
while current:
|
||||
if isinstance(current, QMainWindow):
|
||||
main_window = current
|
||||
break
|
||||
current = current.parent()
|
||||
|
||||
# Last resort: use top-level widget
|
||||
if not main_window and top_level:
|
||||
main_window = top_level
|
||||
|
||||
if main_window:
|
||||
# Restore window if minimized
|
||||
if hasattr(main_window, 'isMinimized') and main_window.isMinimized():
|
||||
main_window.showNormal()
|
||||
|
||||
# Bring to front and activate - use multiple methods for reliability
|
||||
main_window.raise_()
|
||||
main_window.activateWindow()
|
||||
main_window.show()
|
||||
|
||||
# Aggressive focus restoration with multiple attempts
|
||||
# Steam may steal focus, so we retry multiple times over several seconds
|
||||
def restore_focus():
|
||||
if main_window:
|
||||
try:
|
||||
main_window.raise_()
|
||||
main_window.activateWindow()
|
||||
app = QApplication.instance()
|
||||
if app and app.activeWindow() != main_window:
|
||||
debug_print("DEBUG: Window not active, retrying focus restoration")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Immediate attempts
|
||||
QTimer.singleShot(50, restore_focus)
|
||||
QTimer.singleShot(200, restore_focus)
|
||||
QTimer.singleShot(500, restore_focus)
|
||||
# Delayed attempts in case Steam steals focus after initial restoration
|
||||
QTimer.singleShot(1000, restore_focus)
|
||||
QTimer.singleShot(2000, restore_focus)
|
||||
QTimer.singleShot(3000, restore_focus)
|
||||
|
||||
debug_print(f"DEBUG: Jackify window focus restoration scheduled (type: {type(main_window).__name__})")
|
||||
else:
|
||||
debug_print("DEBUG: Could not find main window to bring to focus")
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Error bringing Jackify to focus: {e}")
|
||||
|
||||
# Save context for later use in configuration
|
||||
self._manual_steps_retry_count = 0
|
||||
self._current_modlist_name = self.modlist_name_edit.text().strip()
|
||||
|
||||
# Save resolution for later use in configuration
|
||||
resolution = self.resolution_combo.currentText()
|
||||
# Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)")
|
||||
if resolution != "Leave unchanged":
|
||||
if " (" in resolution:
|
||||
self._current_resolution = resolution.split(" (")[0]
|
||||
else:
|
||||
self._current_resolution = resolution
|
||||
else:
|
||||
self._current_resolution = None
|
||||
|
||||
# Use automated prefix creation instead of manual steps
|
||||
debug_print("DEBUG: Starting automated prefix creation workflow")
|
||||
self._safe_append_text("Starting automated prefix creation workflow...")
|
||||
self.start_automated_prefix_workflow()
|
||||
else:
|
||||
self._safe_append_text("Failed to restart Steam.\n" + out)
|
||||
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.")
|
||||
|
||||
def start_automated_prefix_workflow(self):
|
||||
"""Start the automated prefix creation workflow"""
|
||||
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
|
||||
@@ -208,7 +37,7 @@ class AutomatedPrefixHandlersMixin:
|
||||
# Disable controls during installation
|
||||
self._disable_controls_during_operation()
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
install_dir = os.path.realpath(self.install_dir_edit.text().strip())
|
||||
final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
||||
|
||||
if not os.path.exists(final_exe_path):
|
||||
@@ -239,7 +68,7 @@ class AutomatedPrefixHandlersMixin:
|
||||
class AutomatedPrefixThread(QThread):
|
||||
finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp
|
||||
progress = Signal(str) # progress messages
|
||||
error = Signal(str) # error messages
|
||||
error = Signal(object) # error (JackifyError or str)
|
||||
show_progress_dialog = Signal(str) # show progress dialog with message
|
||||
hide_progress_dialog = Signal() # hide progress dialog
|
||||
conflict_detected = Signal(list) # conflicts list
|
||||
@@ -313,10 +142,14 @@ class AutomatedPrefixHandlersMixin:
|
||||
except Exception as e:
|
||||
# Ensure progress dialog is hidden on error
|
||||
self.hide_progress_dialog.emit()
|
||||
self.error.emit(str(e))
|
||||
from jackify.shared.errors import JackifyError, prefix_creation_failed
|
||||
if not isinstance(e, JackifyError):
|
||||
e = prefix_creation_failed(str(e))
|
||||
self.error.emit(e)
|
||||
|
||||
# Create and start thread (pass downloads_dir for STEAM_COMPAT_MOUNTS)
|
||||
downloads_dir = self.downloads_dir_edit.text().strip() if getattr(self, 'downloads_dir_edit', None) else None
|
||||
_dl_raw = self.downloads_dir_edit.text().strip() if getattr(self, 'downloads_dir_edit', None) else None
|
||||
downloads_dir = os.path.realpath(_dl_raw) if _dl_raw else None
|
||||
self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path, downloads_dir)
|
||||
self.prefix_thread.finished.connect(self.on_automated_prefix_finished)
|
||||
self.prefix_thread.error.connect(self.on_automated_prefix_error)
|
||||
@@ -327,8 +160,8 @@ class AutomatedPrefixHandlersMixin:
|
||||
self.prefix_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}")
|
||||
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
logger.debug(f"DEBUG: Exception in start_automated_prefix_workflow: {e}")
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
|
||||
# Re-enable controls on exception
|
||||
self._enable_controls_after_operation()
|
||||
@@ -337,23 +170,23 @@ class AutomatedPrefixHandlersMixin:
|
||||
"""Handle completion of automated prefix creation"""
|
||||
try:
|
||||
if success:
|
||||
debug_print(f"SUCCESS: Automated prefix creation completed!")
|
||||
debug_print(f"Prefix created at: {prefix_path}")
|
||||
logger.debug(f"SUCCESS: Automated prefix creation completed!")
|
||||
logger.debug(f"Prefix created at: {prefix_path}")
|
||||
if new_appid_str and new_appid_str != "0":
|
||||
debug_print(f"AppID: {new_appid_str}")
|
||||
logger.debug(f"AppID: {new_appid_str}")
|
||||
|
||||
# Convert string AppID back to integer for configuration
|
||||
new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None
|
||||
|
||||
# Continue with configuration using the new AppID and timestamp
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
install_dir = os.path.realpath(self.install_dir_edit.text().strip())
|
||||
self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: Automated prefix creation failed")
|
||||
self._safe_append_text("Please check the logs for details")
|
||||
MessageService.critical(self, "Automated Setup Failed",
|
||||
"Automated prefix creation failed. Please check the console output for details.")
|
||||
error_reason = last_timestamp or "Unknown error"
|
||||
self._safe_append_text(f"ERROR: Automated prefix creation failed: {error_reason}")
|
||||
from jackify.shared.errors import prefix_creation_failed
|
||||
MessageService.show_error(self, prefix_creation_failed(str(error_reason)))
|
||||
# Re-enable controls on failure
|
||||
self._enable_controls_after_operation()
|
||||
self._end_post_install_feedback(success=False)
|
||||
@@ -361,12 +194,14 @@ class AutomatedPrefixHandlersMixin:
|
||||
# Always ensure controls are re-enabled when workflow truly completes
|
||||
pass
|
||||
|
||||
def on_automated_prefix_error(self, error_msg):
|
||||
def on_automated_prefix_error(self, error):
|
||||
"""Handle error in automated prefix creation"""
|
||||
self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}")
|
||||
MessageService.critical(self, "Automated Setup Error",
|
||||
f"Error during automated prefix creation: {error_msg}")
|
||||
# Re-enable controls on error
|
||||
from jackify.shared.errors import JackifyError, classify_exception
|
||||
if not isinstance(error, JackifyError):
|
||||
error = classify_exception(str(error))
|
||||
logger.error(f"Automated prefix error: {error.message}")
|
||||
self._safe_append_text(f"[FAILED] {error.message}")
|
||||
MessageService.show_error(self, error)
|
||||
self._enable_controls_after_operation()
|
||||
self._end_post_install_feedback(success=False)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import manual_steps_incomplete, configuration_failed
|
||||
from jackify.frontends.gui.dialogs import SuccessDialog
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
from jackify.backend.models.modlist import ModlistContext
|
||||
@@ -10,18 +11,11 @@ from pathlib import Path
|
||||
import traceback
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from .install_modlist_shortcut_dialog import InstallModlistShortcutDialogMixin
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
"""Mixin providing configuration phase workflow and dialog management for InstallModlistScreen."""
|
||||
|
||||
@@ -50,13 +44,17 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
finally:
|
||||
self.steam_restart_progress = None
|
||||
# Controls are managed by the proper control management system
|
||||
# Delay focus reclaim so Steam's window finishes painting before we steal it back
|
||||
try:
|
||||
from PySide6.QtCore import QTimer
|
||||
win = self.window()
|
||||
QTimer.singleShot(10000, lambda: (win.raise_(), win.activateWindow()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
||||
"""Detect game type by checking ModOrganizer.ini for loader executables."""
|
||||
from pathlib import Path
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return 'skyrim' # Fallback to most common
|
||||
@@ -116,12 +114,21 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
|
||||
# Check for TTW eligibility before showing final success dialog
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
if self._check_ttw_eligibility(modlist_name, self._current_game_type, install_dir):
|
||||
ttw_modlist_name = modlist_name
|
||||
try:
|
||||
from jackify.backend.utils.modlist_meta import get_modlist_name
|
||||
canonical_name = get_modlist_name(install_dir)
|
||||
if canonical_name:
|
||||
ttw_modlist_name = canonical_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._check_ttw_eligibility(ttw_modlist_name, self._current_game_type, install_dir):
|
||||
# Offer TTW installation
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"Install TTW?",
|
||||
f"{modlist_name} requires Tale of Two Wastelands!\n\n"
|
||||
f"{ttw_modlist_name} requires Tale of Two Wastelands!\n\n"
|
||||
"Would you like to install TTW now?\n\n"
|
||||
"This will:\n"
|
||||
"• Guide you through TTW installation\n"
|
||||
@@ -136,14 +143,16 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self._cleanup_config_thread()
|
||||
# Navigate to TTW screen
|
||||
self._initiate_ttw_workflow(modlist_name, install_dir)
|
||||
self._initiate_ttw_workflow(ttw_modlist_name, install_dir)
|
||||
return # Don't show success dialog yet, will show after TTW completes
|
||||
|
||||
# Check for VNV post-install automation after TTW check
|
||||
vnv_automation_running = self._check_and_run_vnv_automation(modlist_name, install_dir)
|
||||
|
||||
if vnv_automation_running:
|
||||
self._cleanup_config_thread()
|
||||
# Store success dialog params for later (after VNV automation completes)
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
@@ -179,60 +188,49 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
|
||||
except Exception as e:
|
||||
# Non-blocking: if dialog fails, just log and continue
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to show ENB dialog: {e}")
|
||||
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
||||
# Max retries reached - show failure message
|
||||
self._end_post_install_feedback(False)
|
||||
MessageService.critical(self, "Manual Steps Failed",
|
||||
"Manual steps validation failed after multiple attempts.")
|
||||
MessageService.show_error(self, manual_steps_incomplete())
|
||||
else:
|
||||
# Configuration failed for other reasons
|
||||
self._end_post_install_feedback(False)
|
||||
MessageService.critical(self, "Configuration Failed",
|
||||
"Post-install configuration failed. Please check the console output.")
|
||||
MessageService.show_error(self, configuration_failed("Post-install configuration failed."))
|
||||
except Exception as e:
|
||||
# Ensure controls are re-enabled even on unexpected errors
|
||||
self._enable_controls_after_operation()
|
||||
raise
|
||||
# Clean up thread
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except:
|
||||
pass # Ignore errors if already disconnected
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000) # Wait up to 5 seconds
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
self._cleanup_config_thread()
|
||||
|
||||
def on_configuration_error(self, error_message):
|
||||
"""Handle configuration error on main thread"""
|
||||
self._safe_append_text(f"Configuration failed with error: {error_message}")
|
||||
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}")
|
||||
MessageService.show_error(self, configuration_failed(str(error_message)))
|
||||
|
||||
# Re-enable all controls on error
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
# Clean up thread
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except:
|
||||
pass # Ignore errors if already disconnected
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000) # Wait up to 5 seconds
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
self._cleanup_config_thread()
|
||||
|
||||
def _cleanup_config_thread(self):
|
||||
"""Safely stop and release the configuration worker thread."""
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000)
|
||||
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
|
||||
def show_manual_steps_dialog(self, extra_warning=""):
|
||||
modlist_name = self.modlist_name_edit.text().strip() or "your modlist"
|
||||
@@ -278,12 +276,12 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
mo2_exe_path = self._get_mo2_path(install_dir, modlist_name)
|
||||
|
||||
# Add delay to allow Steam filesystem updates to complete
|
||||
self._safe_append_text("Waiting for Steam filesystem updates to complete...")
|
||||
logger.info("Waiting for Steam filesystem updates to complete...")
|
||||
time.sleep(2)
|
||||
|
||||
# CRITICAL: Re-detect the AppID after Steam restart and manual steps
|
||||
# Steam assigns a NEW AppID during restart, different from the one we initially created
|
||||
self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
|
||||
logger.info(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
|
||||
@@ -299,7 +297,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
return
|
||||
|
||||
self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}")
|
||||
self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}")
|
||||
logger.info(f"Validating manual steps completion for AppID: {current_appid}")
|
||||
|
||||
# Check 1: Proton version
|
||||
proton_ok = False
|
||||
@@ -326,12 +324,12 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
modlist_handler.compat_data_path = Path(compat_data_path_str)
|
||||
|
||||
# Check Proton version
|
||||
self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...")
|
||||
logger.info(f"Attempting to detect Proton version for AppID {current_appid}...")
|
||||
if modlist_handler._detect_proton_version():
|
||||
self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'")
|
||||
logger.info(f"Raw detected Proton version: '{modlist_handler.proton_ver}'")
|
||||
if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower():
|
||||
proton_ok = True
|
||||
self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}")
|
||||
logger.info(f"Proton version validated: {modlist_handler.proton_ver}")
|
||||
else:
|
||||
self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)")
|
||||
else:
|
||||
@@ -347,14 +345,14 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
|
||||
self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...")
|
||||
self._safe_append_text("Checking standard Steam locations and Flatpak Steam...")
|
||||
logger.info(f"Searching for compatdata directory for AppID {current_appid}...")
|
||||
logger.info("Checking standard Steam locations and Flatpak Steam...")
|
||||
prefix_path_str = path_handler.find_compat_data(current_appid)
|
||||
self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'")
|
||||
logger.info(f"Compatdata search result: '{prefix_path_str}'")
|
||||
|
||||
if prefix_path_str and os.path.isdir(prefix_path_str):
|
||||
compatdata_ok = True
|
||||
self._safe_append_text(f"Compatdata directory found: {prefix_path_str}")
|
||||
logger.info(f"Compatdata directory found: {prefix_path_str}")
|
||||
else:
|
||||
if prefix_path_str:
|
||||
self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}")
|
||||
@@ -370,7 +368,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
# Handle validation results
|
||||
if proton_ok and compatdata_ok:
|
||||
self._safe_append_text("Manual steps validation passed!")
|
||||
self._safe_append_text("Continuing configuration with updated AppID...")
|
||||
logger.info("Continuing configuration with updated AppID...")
|
||||
|
||||
# Continue configuration with the corrected AppID and context
|
||||
self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir)
|
||||
@@ -390,9 +388,9 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
"""Continue the configuration process with the new AppID after automated prefix creation"""
|
||||
# Headers are now shown at start of Steam Integration
|
||||
# No need to show them again here
|
||||
debug_print("Configuration phase continues after Steam Integration")
|
||||
logger.debug("Configuration phase continues after Steam Integration")
|
||||
|
||||
debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}")
|
||||
logger.debug(f"continue_configuration_after_automated_prefix called with appid: {new_appid}")
|
||||
try:
|
||||
# Update the context with the new AppID (same format as manual steps)
|
||||
updated_context = {
|
||||
@@ -408,7 +406,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition'
|
||||
}
|
||||
self.context = updated_context # Ensure context is always set
|
||||
debug_print(f"Updated context with new AppID: {new_appid}")
|
||||
logger.debug(f"Updated context with new AppID: {new_appid}")
|
||||
|
||||
# Get Steam Deck detection once and pass to ConfigThread
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
@@ -514,7 +512,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
'appid': new_appid # Use the NEW AppID from Steam
|
||||
}
|
||||
|
||||
debug_print(f"Updated context with new AppID: {new_appid}")
|
||||
logger.debug(f"Updated context with new AppID: {new_appid}")
|
||||
|
||||
# Clean up old thread if exists and wait for it to finish
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
@@ -523,7 +521,7 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except:
|
||||
except (RuntimeError, TypeError):
|
||||
pass # Ignore errors if already disconnected
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
@@ -622,4 +620,3 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
return ConfigThread(context, is_steamdeck, detect_game_type_func, parent=self)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QSizePolicy, QApplication
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
import re
|
||||
|
||||
|
||||
class ConsoleOutputMixin:
|
||||
@@ -147,8 +146,16 @@ class ConsoleOutputMixin:
|
||||
self._write_to_log_file(message)
|
||||
return
|
||||
|
||||
# CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode)
|
||||
msg_lower = message.lower()
|
||||
# Engine informational line; keep in debug log only to reduce user-facing noise.
|
||||
if (
|
||||
'contains files with foreign characters' in msg_lower and
|
||||
'using proton 7z.exe for extraction' in msg_lower
|
||||
):
|
||||
self._write_to_log_file(message)
|
||||
return
|
||||
|
||||
# CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode)
|
||||
token_error_keywords = [
|
||||
'token has expired',
|
||||
'token expired',
|
||||
@@ -165,11 +172,9 @@ class ConsoleOutputMixin:
|
||||
|
||||
is_token_error = any(keyword in msg_lower for keyword in token_error_keywords)
|
||||
if is_token_error:
|
||||
# CRITICAL ERROR - always show, even if console is hidden
|
||||
if not hasattr(self, '_token_error_notified'):
|
||||
if not self._token_error_notified:
|
||||
self._token_error_notified = True
|
||||
# Show error dialog immediately
|
||||
MessageService.error(
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Authentication Error",
|
||||
(
|
||||
@@ -268,7 +273,7 @@ class ConsoleOutputMixin:
|
||||
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
# Check if user was at bottom BEFORE adding text
|
||||
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance
|
||||
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1)
|
||||
|
||||
# Add the text
|
||||
self.console.append(clean_text)
|
||||
@@ -365,4 +370,3 @@ class ConsoleOutputMixin:
|
||||
except Exception:
|
||||
# Logging should never break the workflow
|
||||
pass
|
||||
|
||||
|
||||
@@ -5,10 +5,17 @@ Signals are defined at class level (required for Qt signal/slot).
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
import logging
|
||||
|
||||
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code
|
||||
from jackify.shared.errors import JackifyError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class InstallerThread(QThread):
|
||||
"""Runs jackify-engine install in a background thread. Signals at class level."""
|
||||
|
||||
@@ -35,28 +42,46 @@ class InstallerThread(QThread):
|
||||
self._premium_signal_sent = False
|
||||
self._engine_output_buffer = []
|
||||
self._buffer_size = 10
|
||||
self.last_error: Optional[JackifyError] = None
|
||||
self._raw_stderr_lines: list = [] # bounded ring buffer for non-JSON stderr
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
if self.process_manager:
|
||||
self.process_manager.cancel()
|
||||
|
||||
def _read_stderr(self):
|
||||
try:
|
||||
for raw in self.process_manager.proc.stderr:
|
||||
line = raw.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
logger.debug(f"Engine stderr: {line}")
|
||||
error = parse_engine_error_line(line)
|
||||
if error and self.last_error is None:
|
||||
self.last_error = error
|
||||
else:
|
||||
self._raw_stderr_lines.append(line)
|
||||
if len(self._raw_stderr_lines) > 20:
|
||||
self._raw_stderr_lines.pop(0)
|
||||
except Exception as e:
|
||||
logger.debug(f"Stderr reader error: {e}")
|
||||
|
||||
def run(self):
|
||||
from .install_modlist import debug_print
|
||||
try:
|
||||
from jackify.backend.core.modlist_operations import get_jackify_engine_path
|
||||
engine_path = get_jackify_engine_path()
|
||||
if not os.path.exists(engine_path):
|
||||
error_msg = f"Engine not found at: {engine_path}"
|
||||
debug_print(f"DEBUG: {error_msg}")
|
||||
logger.debug(f"DEBUG: {error_msg}")
|
||||
self.installation_finished.emit(False, error_msg)
|
||||
return
|
||||
if not os.access(engine_path, os.X_OK):
|
||||
error_msg = f"Engine is not executable: {engine_path}"
|
||||
debug_print(f"DEBUG: {error_msg}")
|
||||
logger.debug(f"DEBUG: {error_msg}")
|
||||
self.installation_finished.emit(False, error_msg)
|
||||
return
|
||||
debug_print(f"DEBUG: Using engine at: {engine_path}")
|
||||
logger.debug(f"DEBUG: Using engine at: {engine_path}")
|
||||
if self.install_mode == 'file':
|
||||
cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
|
||||
else:
|
||||
@@ -66,9 +91,9 @@ class InstallerThread(QThread):
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if debug_mode:
|
||||
cmd.append('--debug')
|
||||
debug_print("DEBUG: Added --debug flag to jackify-engine command")
|
||||
debug_print(f"DEBUG: FULL Engine command: {' '.join(cmd)}")
|
||||
debug_print(f"DEBUG: modlist value being passed: '{self.modlist}'")
|
||||
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
|
||||
logger.debug(f"DEBUG: FULL Engine command: {' '.join(cmd)}")
|
||||
logger.debug(f"DEBUG: modlist value being passed: '{self.modlist}'")
|
||||
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||
env_vars = {'NEXUS_API_KEY': self.api_key}
|
||||
if self.oauth_info:
|
||||
@@ -77,7 +102,9 @@ class InstallerThread(QThread):
|
||||
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
|
||||
env = get_clean_subprocess_env(env_vars)
|
||||
from jackify.backend.handlers.subprocess_utils import ProcessManager
|
||||
self.process_manager = ProcessManager(cmd, env=env, text=False)
|
||||
self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True)
|
||||
stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
||||
stderr_thread.start()
|
||||
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
buffer = b''
|
||||
last_was_blank = False
|
||||
@@ -100,8 +127,6 @@ class InstallerThread(QThread):
|
||||
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
@@ -141,7 +166,7 @@ class InstallerThread(QThread):
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
debug_print(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
@@ -157,8 +182,6 @@ class InstallerThread(QThread):
|
||||
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
@@ -200,7 +223,7 @@ class InstallerThread(QThread):
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
debug_print(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
@@ -224,6 +247,7 @@ class InstallerThread(QThread):
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self.output_received.emit(decoded)
|
||||
stderr_thread.join(timeout=5)
|
||||
returncode = self.process_manager.wait()
|
||||
if self.process_manager.proc and self.process_manager.proc.stdout:
|
||||
try:
|
||||
@@ -231,7 +255,7 @@ class InstallerThread(QThread):
|
||||
if remaining:
|
||||
decoded_remaining = remaining.decode('utf-8', errors='replace')
|
||||
if decoded_remaining.strip():
|
||||
debug_print(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}")
|
||||
logger.debug(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}")
|
||||
if '[FILE_PROGRESS]' in decoded_remaining:
|
||||
parts = decoded_remaining.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
@@ -239,16 +263,28 @@ class InstallerThread(QThread):
|
||||
else:
|
||||
self.output_received.emit(decoded_remaining)
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Error reading remaining output: {e}")
|
||||
logger.debug(f"DEBUG: Error reading remaining output: {e}")
|
||||
if returncode != 0 and not self.cancelled and self.last_error is None:
|
||||
stderr_detail = "\n".join(self._raw_stderr_lines[-10:]) if self._raw_stderr_lines else ""
|
||||
detail = f"Exit code {returncode}.\n\nEngine output:\n{stderr_detail}" if stderr_detail else f"Exit code {returncode}."
|
||||
fallback = error_from_exit_code(
|
||||
returncode,
|
||||
detail,
|
||||
context={
|
||||
"exit_code": returncode,
|
||||
"stderr_tail_lines": len(self._raw_stderr_lines[-10:]),
|
||||
},
|
||||
)
|
||||
if fallback:
|
||||
self.last_error = fallback
|
||||
|
||||
if self.cancelled:
|
||||
self.installation_finished.emit(False, "Installation cancelled by user")
|
||||
elif returncode == 0:
|
||||
self.installation_finished.emit(True, "Installation completed successfully")
|
||||
else:
|
||||
error_msg = f"Installation failed (exit code {returncode})"
|
||||
debug_print(f"DEBUG: Engine exited with code {returncode}")
|
||||
if self.process_manager.proc:
|
||||
debug_print("DEBUG: Process stderr/stdout may contain error details")
|
||||
logger.debug(f"DEBUG: Engine exited with code {returncode}")
|
||||
self.installation_finished.emit(False, error_msg)
|
||||
except Exception as e:
|
||||
self.installation_finished.emit(False, f"Installation error: {str(e)}")
|
||||
|
||||
@@ -6,8 +6,10 @@ on_installation_output, on_installation_progress, on_premium_required_detected,
|
||||
import time
|
||||
|
||||
from jackify.shared.progress_models import InstallationPhase, OperationType, FileProgress
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class InstallModlistOutputMixin:
|
||||
"""Mixin providing signal handlers for InstallerThread output/progress/premium/progress_updated."""
|
||||
|
||||
@@ -17,6 +19,12 @@ class InstallModlistOutputMixin:
|
||||
self._write_to_log_file(message)
|
||||
return
|
||||
msg_lower = message.lower()
|
||||
if (
|
||||
'contains files with foreign characters' in msg_lower and
|
||||
'using proton 7z.exe for extraction' in msg_lower
|
||||
):
|
||||
self._write_to_log_file(message)
|
||||
return
|
||||
token_error_keywords = [
|
||||
'token has expired', 'token expired', 'oauth token', 'authentication failed',
|
||||
'unauthorized', '401', '403', 'refresh token', 'authorization failed',
|
||||
@@ -24,10 +32,10 @@ class InstallModlistOutputMixin:
|
||||
]
|
||||
is_token_error = any(keyword in msg_lower for keyword in token_error_keywords)
|
||||
if is_token_error:
|
||||
if not hasattr(self, '_token_error_notified'):
|
||||
if not self._token_error_notified:
|
||||
self._token_error_notified = True
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
MessageService.error(
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Authentication Error",
|
||||
(
|
||||
@@ -104,6 +112,10 @@ class InstallModlistOutputMixin:
|
||||
if is_stalled and has_active_downloads:
|
||||
if self._stalled_download_start_time is None:
|
||||
self._stalled_download_start_time = time.time()
|
||||
self._stalled_data_snapshot = progress_state.data_processed
|
||||
elif progress_state.data_processed > self._stalled_data_snapshot:
|
||||
self._stalled_download_start_time = time.time()
|
||||
self._stalled_data_snapshot = progress_state.data_processed
|
||||
else:
|
||||
stalled_duration = time.time() - self._stalled_download_start_time
|
||||
if stalled_duration > 120 and not self._stalled_download_notified:
|
||||
@@ -133,6 +145,7 @@ class InstallModlistOutputMixin:
|
||||
else:
|
||||
self._stalled_download_start_time = None
|
||||
self._stalled_download_notified = False
|
||||
self._stalled_data_snapshot = 0
|
||||
self.progress_indicator.update_progress(progress_state)
|
||||
phase_label = progress_state.get_phase_label()
|
||||
is_installation_phase = (
|
||||
@@ -206,14 +219,12 @@ class InstallModlistOutputMixin:
|
||||
except RuntimeError as e:
|
||||
if "already deleted" in str(e):
|
||||
if getattr(self, 'debug', False):
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Ignoring widget deletion error: {e}")
|
||||
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
if getattr(self, 'debug', False):
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Error updating file progress list: {e}")
|
||||
logger.debug(f"DEBUG: Error updating file progress list: {e}")
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
else:
|
||||
|
||||
@@ -3,19 +3,13 @@ from PySide6.QtCore import QProcess
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import wabbajack_install_failed
|
||||
from jackify.shared.progress_models import InstallationPhase, OperationType, InstallationProgress, FileProgress
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
import time
|
||||
import logging
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ProgressHandlersMixin:
|
||||
"""Mixin providing progress tracking and installation event handlers for InstallModlistScreen."""
|
||||
|
||||
@@ -44,7 +38,9 @@ class ProgressHandlersMixin:
|
||||
)
|
||||
|
||||
if engine_line:
|
||||
logger.warning(f"Nexus Premium required, engine message: {engine_line}")
|
||||
self._safe_append_text(f"[Jackify] Engine message: {engine_line}")
|
||||
logger.warning("Nexus Premium required for this modlist install")
|
||||
self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.")
|
||||
|
||||
MessageService.critical(
|
||||
@@ -87,11 +83,17 @@ class ProgressHandlersMixin:
|
||||
if is_stalled and has_active_downloads:
|
||||
if self._stalled_download_start_time is None:
|
||||
self._stalled_download_start_time = time.time()
|
||||
self._stalled_data_snapshot = progress_state.data_processed
|
||||
elif progress_state.data_processed > self._stalled_data_snapshot:
|
||||
# Bytes are advancing despite 0 speed readout — engine reporting lag, not a real stall
|
||||
self._stalled_download_start_time = time.time()
|
||||
self._stalled_data_snapshot = progress_state.data_processed
|
||||
else:
|
||||
stalled_duration = time.time() - self._stalled_download_start_time
|
||||
# Warn after 2 minutes of stalled downloads
|
||||
if stalled_duration > 120 and not self._stalled_download_notified:
|
||||
self._stalled_download_notified = True
|
||||
logger.warning("Downloads stalled (0.0MB/s for 2+ minutes)")
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Download Stalled",
|
||||
@@ -119,6 +121,7 @@ class ProgressHandlersMixin:
|
||||
# Downloads are active - reset stall timer
|
||||
self._stalled_download_start_time = None
|
||||
self._stalled_download_notified = False
|
||||
self._stalled_data_snapshot = 0
|
||||
|
||||
# Update progress indicator widget
|
||||
self.progress_indicator.update_progress(progress_state)
|
||||
@@ -259,9 +262,9 @@ class ProgressHandlersMixin:
|
||||
return
|
||||
elif progress_state.active_files:
|
||||
if self.debug:
|
||||
debug_print(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files")
|
||||
logger.debug(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files")
|
||||
for fp in progress_state.active_files:
|
||||
debug_print(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
|
||||
logger.debug(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
|
||||
# Pass phase label to update header (e.g., "[Activity - Downloading]")
|
||||
# Explicitly clear summary_info when showing file list
|
||||
try:
|
||||
@@ -270,13 +273,13 @@ class ProgressHandlersMixin:
|
||||
# Widget was deleted - ignore to prevent coredump
|
||||
if "already deleted" in str(e):
|
||||
if self.debug:
|
||||
debug_print(f"DEBUG: Ignoring widget deletion error: {e}")
|
||||
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
# Catch any other exceptions to prevent coredump
|
||||
if self.debug:
|
||||
debug_print(f"DEBUG: Error updating file progress list: {e}")
|
||||
logger.debug(f"DEBUG: Error updating file progress list: {e}")
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
else:
|
||||
@@ -295,7 +298,7 @@ class ProgressHandlersMixin:
|
||||
|
||||
def on_installation_finished(self, success, message):
|
||||
"""Handle installation completion"""
|
||||
debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
||||
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
||||
# R&D: Clear all progress displays when installation completes
|
||||
self.progress_state_manager.reset()
|
||||
# Clear file list but keep CPU tracking running for configuration phase
|
||||
@@ -313,7 +316,21 @@ class ProgressHandlersMixin:
|
||||
overall_percent=100.0
|
||||
)
|
||||
self.progress_indicator.update_progress(final_state)
|
||||
|
||||
|
||||
try:
|
||||
from jackify.backend.utils.modlist_meta import write_modlist_meta
|
||||
thread = getattr(self, 'install_thread', None)
|
||||
if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'modlist_name', None):
|
||||
write_modlist_meta(
|
||||
thread.install_dir,
|
||||
thread.modlist_name,
|
||||
getattr(self, '_current_game_type', None),
|
||||
install_mode=getattr(thread, 'install_mode', 'online'),
|
||||
)
|
||||
except Exception as _meta_err:
|
||||
logger.debug(f"Modlist meta write skipped: {_meta_err}")
|
||||
|
||||
logger.info(f"Installation succeeded: {message}")
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self._safe_append_text(f"\nSuccess: {message}")
|
||||
self.process_finished(0, QProcess.NormalExit) # Simulate successful completion
|
||||
@@ -323,18 +340,25 @@ class ProgressHandlersMixin:
|
||||
|
||||
if self._premium_failure_active:
|
||||
message = "Installation stopped because Nexus Premium is required for automated downloads."
|
||||
|
||||
|
||||
if not self._premium_failure_active:
|
||||
engine_error = getattr(self.install_thread, 'last_error', None)
|
||||
if engine_error:
|
||||
self._engine_error = engine_error
|
||||
self._failure_message = message
|
||||
|
||||
logger.error(f"Installation failed: {message}")
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self._safe_append_text(f"\nError: {message}")
|
||||
self.process_finished(1, QProcess.CrashExit) # Simulate error
|
||||
|
||||
def process_finished(self, exit_code, exit_status):
|
||||
debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
# Reset button states
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
debug_print("DEBUG: Button states reset in process_finished")
|
||||
logger.debug("DEBUG: Button states reset in process_finished")
|
||||
|
||||
|
||||
if exit_code == 0:
|
||||
@@ -350,6 +374,7 @@ class ProgressHandlersMixin:
|
||||
f"Note: Post-install configuration was skipped for unsupported game type: {game_name or game_type}\n\n"
|
||||
f"You will need to manually configure Steam shortcuts and other post-install steps."
|
||||
)
|
||||
logger.warning(f"Post-install configuration skipped for unsupported game: {game_name or game_type}")
|
||||
self._safe_append_text(f"\nModlist installation completed successfully.")
|
||||
self._safe_append_text(f"\nWarning: Post-install configuration skipped for unsupported game: {game_name or game_type}")
|
||||
else:
|
||||
@@ -358,14 +383,15 @@ class ProgressHandlersMixin:
|
||||
|
||||
if auto_restart_enabled:
|
||||
# Auto-accept Steam restart - proceed without dialog
|
||||
self._safe_append_text("\nAuto-accepting Steam restart (unattended mode enabled)")
|
||||
logger.info("Auto-accepting Steam restart (unattended mode enabled)")
|
||||
reply = QMessageBox.Yes # Simulate user clicking Yes
|
||||
else:
|
||||
# Show the normal install complete dialog for supported games
|
||||
reply = MessageService.question(
|
||||
self, "Modlist Install Complete!",
|
||||
"Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!",
|
||||
critical=False # Non-critical, won't steal focus
|
||||
critical=False, # Non-critical, won't steal focus
|
||||
safety_level="medium",
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
@@ -395,6 +421,7 @@ class ProgressHandlersMixin:
|
||||
"Automatic installs currently require Nexus Premium. Non-premium support is planned.",
|
||||
safety_level="medium"
|
||||
)
|
||||
logger.warning("Install stopped: Nexus Premium required")
|
||||
self._safe_append_text("\nInstall stopped: Nexus Premium required.")
|
||||
self._premium_failure_active = False
|
||||
elif hasattr(self, '_cancellation_requested') and self._cancellation_requested:
|
||||
@@ -407,7 +434,14 @@ class ProgressHandlersMixin:
|
||||
if "cancelled by user" in last_output.lower():
|
||||
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
|
||||
else:
|
||||
MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.")
|
||||
logger.error(f"Install failed (exit code {exit_code})")
|
||||
engine_error = getattr(self, '_engine_error', None)
|
||||
if engine_error:
|
||||
self._engine_error = None
|
||||
MessageService.show_error(self, engine_error)
|
||||
else:
|
||||
failure_msg = getattr(self, '_failure_message', None) or f"Exit code {exit_code}."
|
||||
self._failure_message = None
|
||||
MessageService.show_error(self, wabbajack_install_failed(failure_msg))
|
||||
self._safe_append_text(f"\nInstall failed (exit code {exit_code}).")
|
||||
self.console.moveCursor(QTextCursor.End)
|
||||
|
||||
|
||||
@@ -181,15 +181,15 @@ class ModlistSelectionMixin:
|
||||
def browse_wabbajack_file(self):
|
||||
file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)")
|
||||
if file:
|
||||
self.file_edit.setText(file)
|
||||
self.file_edit.setText(os.path.realpath(file))
|
||||
|
||||
def browse_install_dir(self):
|
||||
dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text())
|
||||
if dir:
|
||||
self.install_dir_edit.setText(dir)
|
||||
self.install_dir_edit.setText(os.path.realpath(dir))
|
||||
|
||||
def browse_downloads_dir(self):
|
||||
dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text())
|
||||
if dir:
|
||||
self.downloads_dir_edit.setText(dir)
|
||||
self.downloads_dir_edit.setText(os.path.realpath(dir))
|
||||
|
||||
|
||||
@@ -33,15 +33,13 @@ class TTWIntegrationMixin:
|
||||
|
||||
# Check 3: TTW must not already be installed
|
||||
if self._detect_existing_ttw(install_dir):
|
||||
from .install_modlist import debug_print
|
||||
debug_print("DEBUG: TTW already installed, skipping prompt")
|
||||
logger.debug("DEBUG: TTW already installed, skipping prompt")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Error checking TTW eligibility: {e}")
|
||||
logger.debug(f"DEBUG: Error checking TTW eligibility: {e}")
|
||||
return False
|
||||
|
||||
def _detect_existing_ttw(self, install_dir: str) -> bool:
|
||||
@@ -75,18 +73,15 @@ class TTWIntegrationMixin:
|
||||
# Verify it has actual TTW content by checking for the main ESM
|
||||
ttw_esm = folder / "TaleOfTwoWastelands.esm"
|
||||
if ttw_esm.exists():
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Found existing TTW installation: {folder.name}")
|
||||
logger.debug(f"DEBUG: Found existing TTW installation: {folder.name}")
|
||||
return True
|
||||
else:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
|
||||
logger.debug(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Error detecting existing TTW: {e}")
|
||||
logger.debug(f"DEBUG: Error detecting existing TTW: {e}")
|
||||
return False # Assume not installed on error
|
||||
|
||||
def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str):
|
||||
@@ -103,9 +98,16 @@ class TTWIntegrationMixin:
|
||||
|
||||
# Get reference to TTW screen BEFORE navigation
|
||||
if self.stacked_widget:
|
||||
# Remember which screen to return to after TTW completes
|
||||
self._ttw_return_screen_index = self.stacked_widget.currentIndex()
|
||||
|
||||
# Navigate first — triggers lazy init and reset_screen_to_defaults.
|
||||
# set_modlist_integration_mode must be called AFTER so it overwrites
|
||||
# the default dir that reset_screen_to_defaults populates.
|
||||
self.stacked_widget.setCurrentIndex(5)
|
||||
|
||||
ttw_screen = self.stacked_widget.widget(5)
|
||||
|
||||
# Set integration mode BEFORE navigating to avoid showEvent race condition
|
||||
if hasattr(ttw_screen, 'set_modlist_integration_mode'):
|
||||
ttw_screen.set_modlist_integration_mode(modlist_name, install_dir)
|
||||
|
||||
@@ -113,11 +115,7 @@ class TTWIntegrationMixin:
|
||||
if hasattr(ttw_screen, 'integration_complete'):
|
||||
ttw_screen.integration_complete.connect(self._on_ttw_integration_complete)
|
||||
else:
|
||||
from .install_modlist import debug_print
|
||||
debug_print("WARNING: TTW screen does not support modlist integration mode yet")
|
||||
|
||||
# Navigate to TTW screen AFTER setting integration mode
|
||||
self.stacked_widget.setCurrentIndex(5)
|
||||
logger.debug("WARNING: TTW screen does not support modlist integration mode yet")
|
||||
|
||||
# Force collapsed state shortly after navigation to avoid any
|
||||
# showEvent/layout timing races that may leave it expanded
|
||||
@@ -127,8 +125,7 @@ class TTWIntegrationMixin:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"ERROR: Failed to initiate TTW workflow: {e}")
|
||||
logger.debug(f"ERROR: Failed to initiate TTW workflow: {e}")
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
MessageService.critical(
|
||||
self,
|
||||
@@ -153,9 +150,9 @@ class TTWIntegrationMixin:
|
||||
)
|
||||
return
|
||||
|
||||
# Navigate back to this screen to show success dialog
|
||||
# Navigate back to the screen that initiated TTW
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(4)
|
||||
self.stacked_widget.setCurrentIndex(getattr(self, '_ttw_return_screen_index', 4))
|
||||
|
||||
# Calculate elapsed time from workflow start
|
||||
import time
|
||||
@@ -211,8 +208,7 @@ class TTWIntegrationMixin:
|
||||
success_dialog.show()
|
||||
|
||||
except Exception as e:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"ERROR: Failed to show final success dialog: {e}")
|
||||
logger.debug(f"ERROR: Failed to show final success dialog: {e}")
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
MessageService.critical(
|
||||
self,
|
||||
|
||||
@@ -9,16 +9,9 @@ from jackify.backend.handlers.progress_parser import ProgressStateManager
|
||||
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
|
||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||
import os
|
||||
import logging
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class InstallModlistUISetupMixin:
|
||||
"""Mixin providing UI initialization for InstallModlistScreen."""
|
||||
|
||||
@@ -76,8 +69,9 @@ class InstallModlistUISetupMixin:
|
||||
self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed)
|
||||
self._premium_notice_shown = False
|
||||
self._premium_failure_active = False
|
||||
self._stalled_download_start_time = None # Track when downloads stall
|
||||
self._stalled_download_start_time = None
|
||||
self._stalled_download_notified = False
|
||||
self._stalled_data_snapshot = 0
|
||||
self._post_install_sequence = self._build_post_install_sequence()
|
||||
self._post_install_total_steps = len(self._post_install_sequence)
|
||||
self._post_install_current_step = 0
|
||||
@@ -294,7 +288,7 @@ class InstallModlistUISetupMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
@@ -504,7 +498,6 @@ class InstallModlistUISetupMixin:
|
||||
self.top_timer.start(2000)
|
||||
# --- Start Installation button ---
|
||||
self.start_btn.clicked.connect(self.validate_and_start_install)
|
||||
self.steam_restart_finished.connect(self._on_steam_restart_finished)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,8 +39,7 @@ class VNVAutomationMixin:
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
|
||||
if not game_root:
|
||||
from .install_modlist import debug_print
|
||||
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return False
|
||||
|
||||
# Initialize service to check completion status
|
||||
@@ -91,10 +90,9 @@ class VNVAutomationMixin:
|
||||
return True # VNV automation is running, defer success dialog
|
||||
|
||||
except Exception as e:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"ERROR: Failed to start VNV automation: {e}")
|
||||
logger.debug(f"ERROR: Failed to start VNV automation: {e}")
|
||||
import traceback
|
||||
debug_print(f"Traceback: {traceback.format_exc()}")
|
||||
logger.debug(f"Traceback: {traceback.format_exc()}")
|
||||
return False # Error - show success dialog anyway
|
||||
|
||||
def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root):
|
||||
|
||||
@@ -9,6 +9,8 @@ import time
|
||||
|
||||
from .install_modlist_installer_thread import InstallerThread
|
||||
from .install_modlist_output_mixin import InstallModlistOutputMixin
|
||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||
from jackify.shared.errors import install_dir_create_failed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,8 +21,7 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
def validate_and_start_install(self):
|
||||
import time
|
||||
self._install_workflow_start_time = time.time()
|
||||
from .install_modlist import debug_print
|
||||
debug_print('DEBUG: validate_and_start_install called')
|
||||
logger.debug('DEBUG: validate_and_start_install called')
|
||||
|
||||
# Immediately show "Initialising" status to provide feedback
|
||||
self.progress_indicator.set_status("Initialising...", 0)
|
||||
@@ -90,8 +91,6 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
return
|
||||
|
||||
# Log authentication status at install start (Issue #111 diagnostics)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
auth_method = self.auth_service.get_auth_method()
|
||||
logger.info("=" * 60)
|
||||
logger.info("Authentication Status at Install Start")
|
||||
@@ -144,7 +143,7 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
try:
|
||||
os.makedirs(install_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}")
|
||||
MessageService.show_error(self, install_dir_create_failed(install_dir, str(e)))
|
||||
self._abort_install_validation()
|
||||
return
|
||||
else:
|
||||
@@ -160,7 +159,7 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
try:
|
||||
os.makedirs(downloads_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
MessageService.critical(self, "Error", f"Failed to create downloads directory:\n{e}")
|
||||
MessageService.show_error(self, install_dir_create_failed(downloads_dir, str(e)))
|
||||
self._abort_install_validation()
|
||||
return
|
||||
else:
|
||||
@@ -172,18 +171,17 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
from .install_modlist import debug_print
|
||||
debug_print("DEBUG: Failed to save resolution")
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
from .install_modlist import debug_print
|
||||
debug_print("DEBUG: Saved resolution cleared")
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
|
||||
ensure_flatpak_steam_filesystem_access(Path(install_dir))
|
||||
|
||||
# Handle parent directory saving
|
||||
self._save_parent_directories(install_dir, downloads_dir)
|
||||
|
||||
@@ -228,8 +226,7 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
# For online modlists, try to get game type from selected modlist
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
game_name = self.selected_modlist_info.get('game', '')
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
|
||||
# Map game name to game type
|
||||
game_mapping = {
|
||||
@@ -244,15 +241,12 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
'enderal special edition': 'enderal'
|
||||
}
|
||||
game_type = game_mapping.get(game_name.lower())
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
if not game_type:
|
||||
game_type = 'unknown'
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
|
||||
logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
|
||||
else:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: No selected_modlist_info found")
|
||||
logger.debug(f"DEBUG: No selected_modlist_info found")
|
||||
game_type = 'unknown'
|
||||
|
||||
# Store game type and name for later use
|
||||
@@ -260,15 +254,13 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
self._current_game_name = game_name
|
||||
|
||||
# Check if game is supported
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported")
|
||||
debug_print(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
|
||||
logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported")
|
||||
logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
|
||||
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
|
||||
debug_print(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
|
||||
logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
|
||||
|
||||
if game_type and not is_supported:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
|
||||
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
|
||||
# Show unsupported game dialog
|
||||
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
|
||||
dialog = UnsupportedGameDialog(self, game_name)
|
||||
@@ -285,8 +277,9 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
self.file_progress_list.clear()
|
||||
self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation
|
||||
self._premium_notice_shown = False
|
||||
self._stalled_download_start_time = None # Reset stall detection
|
||||
self._stalled_download_start_time = None
|
||||
self._stalled_download_notified = False
|
||||
self._stalled_data_snapshot = 0
|
||||
self._token_error_notified = False # Reset token error notification
|
||||
self._premium_failure_active = False
|
||||
self._post_install_active = False
|
||||
@@ -319,30 +312,26 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
)
|
||||
return
|
||||
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
|
||||
logger.debug(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
|
||||
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
|
||||
except Exception as e:
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
import traceback
|
||||
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
# Re-enable all controls after exception
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
from .install_modlist import debug_print
|
||||
debug_print(f"DEBUG: Controls re-enabled in exception handler")
|
||||
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
|
||||
|
||||
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
|
||||
from .install_modlist import debug_print
|
||||
debug_print('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
|
||||
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
|
||||
|
||||
# Rotate log file at start of each workflow run (keep 5 backups)
|
||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||
log_handler = LoggingHandler()
|
||||
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
|
||||
|
||||
|
||||
# Clear console for fresh installation output
|
||||
self.console.clear()
|
||||
from jackify import __version__ as jackify_version
|
||||
@@ -368,4 +357,3 @@ class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
# R&D: Pass progress state manager to thread
|
||||
self.install_thread.progress_state_manager = self.progress_state_manager
|
||||
self.install_thread.start()
|
||||
|
||||
|
||||
@@ -26,23 +26,18 @@ from ..dialogs import SuccessDialog
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import manual_steps_incomplete
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from .install_ttw_ui_setup import TTWUISetupMixin
|
||||
from .install_ttw_integration import TTWIntegrationMixin
|
||||
from .install_ttw_requirements import TTWRequirementsMixin
|
||||
from .install_ttw_lifecycle import TTWLifecycleMixin
|
||||
from .install_ttw_installer import TTWInstallerMixin
|
||||
from .install_ttw_workflow import TTWWorkflowMixin
|
||||
from .install_ttw_output import TTWOutputMixin
|
||||
from .install_ttw_ui import TTWUIMixin
|
||||
from .install_ttw_config import TTWConfigMixin
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
class ModlistFetchThread(QThread):
|
||||
result = Signal(list, str)
|
||||
def __init__(self, game_type, log_path, mode='list-modlists'):
|
||||
@@ -82,9 +77,7 @@ class ModlistFetchThread(QThread):
|
||||
# Don't write to log file before workflow starts - just return error
|
||||
self.result.emit([], error_msg)
|
||||
|
||||
|
||||
class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWInstallerMixin, TTWWorkflowMixin, TTWUIMixin, TTWConfigMixin):
|
||||
steam_restart_finished = Signal(bool, str)
|
||||
class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWWorkflowMixin, TTWOutputMixin, TTWUIMixin):
|
||||
resize_request = Signal(str)
|
||||
integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version)
|
||||
|
||||
@@ -142,26 +135,21 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
if saved_install_parent:
|
||||
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
|
||||
self.install_dir_edit.setText(suggested_install_dir)
|
||||
debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
||||
logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
||||
|
||||
# Update download directory suggestion
|
||||
saved_download_parent = self.config_handler.get_default_download_parent_dir()
|
||||
if saved_download_parent:
|
||||
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
|
||||
debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
||||
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Error updating directory suggestions: {e}")
|
||||
logger.debug(f"DEBUG: Error updating directory suggestions: {e}")
|
||||
|
||||
def _save_parent_directories(self, install_dir, downloads_dir):
|
||||
"""Removed automatic saving - user should set defaults in settings"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def browse_wabbajack_file(self):
|
||||
# Use QFileDialog instance to ensure consistent dialog style
|
||||
start_path = self.file_edit.text() if self.file_edit.text() else os.path.expanduser("~")
|
||||
@@ -188,7 +176,6 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
if dirs:
|
||||
self.install_dir_edit.setText(dirs[0])
|
||||
|
||||
|
||||
def update_top_panel(self):
|
||||
try:
|
||||
result = subprocess.run([
|
||||
@@ -249,14 +236,20 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
return True # Continue anyway
|
||||
|
||||
def _write_to_log_file(self, message):
|
||||
"""Write message to workflow log file with timestamp"""
|
||||
"""Write message to workflow log file with timestamp."""
|
||||
try:
|
||||
import re
|
||||
from datetime import datetime
|
||||
clean = re.sub(r'<[^>]+>', '', str(message))
|
||||
if not clean.strip():
|
||||
return
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
with open(self.modlist_log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(f"[{timestamp}] {message}\n")
|
||||
for line in clean.splitlines():
|
||||
stripped = line.rstrip()
|
||||
if stripped:
|
||||
f.write(f"[{timestamp}] {stripped}\n")
|
||||
except Exception:
|
||||
# Logging should never break the workflow
|
||||
pass
|
||||
|
||||
def handle_validation_failure(self, missing_text):
|
||||
@@ -271,9 +264,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
elif self._manual_steps_retry_count == 2:
|
||||
retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location."
|
||||
|
||||
MessageService.critical(self, "Manual Steps Incomplete",
|
||||
f"Manual steps validation failed:\n\n{missing_text}\n\n"
|
||||
f"Please complete the missing steps and try again.{retry_guidance}")
|
||||
MessageService.show_error(self, manual_steps_incomplete())
|
||||
# Show manual steps dialog again
|
||||
extra_warning = ""
|
||||
if self._manual_steps_retry_count >= 2:
|
||||
@@ -281,13 +272,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
self.show_manual_steps_dialog(extra_warning)
|
||||
else:
|
||||
# Max retries reached
|
||||
MessageService.critical(self, "Manual Steps Failed",
|
||||
"Manual steps validation failed after multiple attempts.\n\n"
|
||||
"Common issues:\n"
|
||||
"• Steam not fully restarted\n"
|
||||
"• Shortcut not launched from Steam\n"
|
||||
"• Flatpak Steam using different file paths\n"
|
||||
"• Proton - Experimental not selected")
|
||||
MessageService.show_error(self, manual_steps_incomplete())
|
||||
self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name)
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
@@ -317,11 +302,11 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
|
||||
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
|
||||
|
||||
# Clean up InstallationThread if running
|
||||
if hasattr(self, 'install_thread') and self.install_thread.isRunning():
|
||||
debug_print("DEBUG: Cancelling running InstallationThread")
|
||||
logger.debug("DEBUG: Cancelling running InstallationThread")
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(3000) # Wait up to 3 seconds
|
||||
if self.install_thread.isRunning():
|
||||
@@ -335,7 +320,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
if hasattr(self, thread_name):
|
||||
thread = getattr(self, thread_name)
|
||||
if thread and thread.isRunning():
|
||||
debug_print(f"DEBUG: Terminating {thread_name}")
|
||||
logger.debug(f"DEBUG: Terminating {thread_name}")
|
||||
thread.terminate()
|
||||
thread.wait(1000) # Wait up to 1 second
|
||||
|
||||
@@ -344,7 +329,8 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
reply = MessageService.question(
|
||||
self, "Cancel Installation",
|
||||
"Are you sure you want to cancel the installation?",
|
||||
critical=False # Non-critical, won't steal focus
|
||||
critical=False, # Non-critical, won't steal focus
|
||||
safety_level="medium",
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
@@ -435,13 +421,12 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
"""Reset the screen to default state when navigating back from main menu"""
|
||||
# Reset form fields
|
||||
self.file_edit.setText("")
|
||||
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
|
||||
|
||||
# Clear console and process monitor
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
if not getattr(self, '_integration_mode', False):
|
||||
# Reset form fields only when not pre-populated by a caller
|
||||
self.file_edit.setText("")
|
||||
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
|
||||
# Re-enable controls (in case they were disabled from previous errors)
|
||||
self._enable_controls_after_operation()
|
||||
@@ -449,4 +434,4 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
||||
# Check requirements when screen is actually shown (not on app startup)
|
||||
self.check_requirements()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
"""Configuration workflow methods for InstallTTWScreen (Mixin)."""
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QTimer, Qt, QThread, Signal
|
||||
from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import traceback
|
||||
# Runtime imports to avoid circular dependencies
|
||||
from jackify.frontends.gui.services.message_service import MessageService # Runtime import
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class TTWConfigMixin:
|
||||
"""Mixin providing configuration workflow methods for InstallTTWScreen."""
|
||||
|
||||
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
||||
"""Detect game type by checking ModOrganizer.ini for loader executables."""
|
||||
from pathlib import Path
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return 'skyrim' # Fallback to most common
|
||||
|
||||
try:
|
||||
content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower()
|
||||
|
||||
if 'skse64_loader.exe' in content or 'skyrim special edition' in content:
|
||||
return 'skyrim'
|
||||
elif 'f4se_loader.exe' in content or 'fallout 4' in content:
|
||||
return 'fallout4'
|
||||
elif 'nvse_loader.exe' in content or 'fallout new vegas' in content:
|
||||
return 'falloutnv'
|
||||
elif 'obse_loader.exe' in content or 'oblivion' in content:
|
||||
return 'oblivion'
|
||||
elif 'starfield' in content:
|
||||
return 'starfield'
|
||||
elif 'enderal' in content:
|
||||
return 'enderal'
|
||||
else:
|
||||
return 'skyrim'
|
||||
except Exception as e:
|
||||
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
|
||||
return 'skyrim'
|
||||
|
||||
def restart_steam_and_configure(self):
|
||||
"""Restart Steam using backend service directly - DECOUPLED FROM CLI"""
|
||||
debug_print("DEBUG: restart_steam_and_configure called - using direct backend service")
|
||||
progress = QProgressDialog("Restarting Steam...", None, 0, 0, self)
|
||||
progress.setWindowTitle("Restarting Steam")
|
||||
progress.setWindowModality(Qt.WindowModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setValue(0)
|
||||
progress.show()
|
||||
|
||||
def do_restart():
|
||||
debug_print("DEBUG: do_restart thread started - using direct backend service")
|
||||
try:
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
|
||||
# Use backend service directly instead of CLI subprocess
|
||||
# Get system_info from parent screen
|
||||
system_info = getattr(self, 'system_info', None)
|
||||
is_steamdeck = system_info.is_steamdeck if system_info else False
|
||||
shortcut_handler = ShortcutHandler(steamdeck=is_steamdeck)
|
||||
|
||||
debug_print("DEBUG: About to call secure_steam_restart()")
|
||||
success = shortcut_handler.secure_steam_restart()
|
||||
debug_print(f"DEBUG: secure_steam_restart() returned: {success}")
|
||||
|
||||
out = "Steam restart completed successfully." if success else "Steam restart failed."
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Exception in do_restart: {e}")
|
||||
success = False
|
||||
out = str(e)
|
||||
|
||||
self.steam_restart_finished.emit(success, out)
|
||||
|
||||
threading.Thread(target=do_restart, daemon=True).start()
|
||||
self._steam_restart_progress = progress # Store to close later
|
||||
|
||||
def _on_steam_restart_finished(self, success, out):
|
||||
debug_print("DEBUG: _on_steam_restart_finished called")
|
||||
# Safely cleanup progress dialog on main thread
|
||||
if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress:
|
||||
try:
|
||||
self._steam_restart_progress.close()
|
||||
self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Error closing progress dialog: {e}")
|
||||
finally:
|
||||
self._steam_restart_progress = None
|
||||
|
||||
# Controls are managed by the proper control management system
|
||||
if success:
|
||||
self._safe_append_text("Steam restarted successfully.")
|
||||
|
||||
# Save context for later use in configuration
|
||||
self._manual_steps_retry_count = 0
|
||||
self._current_modlist_name = "TTW Installation" # Fixed name for TTW
|
||||
self._current_resolution = None # TTW doesn't need resolution changes
|
||||
|
||||
# Use automated prefix creation instead of manual steps
|
||||
debug_print("DEBUG: Starting automated prefix creation workflow")
|
||||
self._safe_append_text("Starting automated prefix creation workflow...")
|
||||
self.start_automated_prefix_workflow()
|
||||
else:
|
||||
self._safe_append_text("Failed to restart Steam.\n" + out)
|
||||
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.")
|
||||
|
||||
def start_automated_prefix_workflow(self):
|
||||
# Ensure _current_resolution is always set before starting workflow
|
||||
if not hasattr(self, '_current_resolution') or self._current_resolution is None:
|
||||
resolution = None # TTW doesn't need resolution changes
|
||||
# Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)")
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
if " (" in resolution:
|
||||
self._current_resolution = resolution.split(" (")[0]
|
||||
else:
|
||||
self._current_resolution = resolution
|
||||
else:
|
||||
self._current_resolution = None
|
||||
"""Start the automated prefix creation workflow"""
|
||||
try:
|
||||
# Disable controls during installation
|
||||
self._disable_controls_during_operation()
|
||||
modlist_name = "TTW Installation"
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
||||
|
||||
if not os.path.exists(final_exe_path):
|
||||
# Check if this is Somnium specifically (uses files/ subdirectory)
|
||||
modlist_name_lower = modlist_name.lower()
|
||||
if "somnium" in modlist_name_lower:
|
||||
somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
|
||||
if os.path.exists(somnium_exe_path):
|
||||
final_exe_path = somnium_exe_path
|
||||
self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup")
|
||||
# Show Somnium guidance popup after automated workflow completes
|
||||
self._show_somnium_guidance = True
|
||||
self._somnium_install_dir = install_dir
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}")
|
||||
MessageService.critical(self, "Somnium ModOrganizer.exe Not Found",
|
||||
f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.")
|
||||
return
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
|
||||
MessageService.critical(self, "ModOrganizer.exe Not Found",
|
||||
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
|
||||
return
|
||||
|
||||
# Run automated prefix creation in separate thread
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
class AutomatedPrefixThread(QThread):
|
||||
finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp
|
||||
progress = Signal(str) # progress messages
|
||||
error = Signal(str) # error messages
|
||||
show_progress_dialog = Signal(str) # show progress dialog with message
|
||||
hide_progress_dialog = Signal() # hide progress dialog
|
||||
conflict_detected = Signal(list) # conflicts list
|
||||
|
||||
def __init__(self, modlist_name, install_dir, final_exe_path):
|
||||
super().__init__()
|
||||
self.modlist_name = modlist_name
|
||||
self.install_dir = install_dir
|
||||
self.final_exe_path = final_exe_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
|
||||
def progress_callback(message):
|
||||
self.progress.emit(message)
|
||||
# Show progress dialog during Steam restart
|
||||
if "Steam restarted successfully" in message:
|
||||
self.hide_progress_dialog.emit()
|
||||
elif "Restarting Steam..." in message:
|
||||
self.show_progress_dialog.emit("Restarting Steam...")
|
||||
|
||||
prefix_service = AutomatedPrefixService()
|
||||
# Determine Steam Deck once and pass through the workflow
|
||||
try:
|
||||
import os
|
||||
_is_steamdeck = False
|
||||
if os.path.exists('/etc/os-release'):
|
||||
with open('/etc/os-release') as f:
|
||||
if 'steamdeck' in f.read().lower():
|
||||
_is_steamdeck = True
|
||||
except Exception:
|
||||
_is_steamdeck = False
|
||||
result = prefix_service.run_working_workflow(
|
||||
self.modlist_name, self.install_dir, self.final_exe_path, progress_callback, steamdeck=_is_steamdeck
|
||||
)
|
||||
|
||||
# Handle the result - check for conflicts
|
||||
if isinstance(result, tuple) and len(result) == 4:
|
||||
if result[0] == "CONFLICT":
|
||||
# Conflict detected - emit signal to main GUI
|
||||
conflicts = result[1]
|
||||
self.hide_progress_dialog.emit()
|
||||
self.conflict_detected.emit(conflicts)
|
||||
return
|
||||
else:
|
||||
# Normal result with timestamp
|
||||
success, prefix_path, new_appid, last_timestamp = result
|
||||
elif isinstance(result, tuple) and len(result) == 3:
|
||||
# Fallback for old format (backward compatibility)
|
||||
if result[0] == "CONFLICT":
|
||||
# Conflict detected - emit signal to main GUI
|
||||
conflicts = result[1]
|
||||
self.hide_progress_dialog.emit()
|
||||
self.conflict_detected.emit(conflicts)
|
||||
return
|
||||
else:
|
||||
# Normal result (old format)
|
||||
success, prefix_path, new_appid = result
|
||||
last_timestamp = None
|
||||
else:
|
||||
# Handle non-tuple result
|
||||
success = result
|
||||
prefix_path = ""
|
||||
new_appid = "0"
|
||||
last_timestamp = None
|
||||
|
||||
# Ensure progress dialog is hidden when workflow completes
|
||||
self.hide_progress_dialog.emit()
|
||||
self.finished.emit(success, prefix_path or "", str(new_appid) if new_appid else "0", last_timestamp)
|
||||
|
||||
except Exception as e:
|
||||
# Ensure progress dialog is hidden on error
|
||||
self.hide_progress_dialog.emit()
|
||||
self.error.emit(str(e))
|
||||
|
||||
# Create and start thread
|
||||
self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path)
|
||||
self.prefix_thread.finished.connect(self.on_automated_prefix_finished)
|
||||
self.prefix_thread.error.connect(self.on_automated_prefix_error)
|
||||
self.prefix_thread.progress.connect(self.on_automated_prefix_progress)
|
||||
self.prefix_thread.show_progress_dialog.connect(self.show_steam_restart_progress)
|
||||
self.prefix_thread.hide_progress_dialog.connect(self.hide_steam_restart_progress)
|
||||
self.prefix_thread.conflict_detected.connect(self.show_shortcut_conflict_dialog)
|
||||
self.prefix_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}")
|
||||
import traceback
|
||||
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
|
||||
# Re-enable controls on exception
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None):
|
||||
"""Handle completion of automated prefix creation"""
|
||||
try:
|
||||
if success:
|
||||
debug_print(f"SUCCESS: Automated prefix creation completed!")
|
||||
debug_print(f"Prefix created at: {prefix_path}")
|
||||
if new_appid_str and new_appid_str != "0":
|
||||
debug_print(f"AppID: {new_appid_str}")
|
||||
|
||||
# Convert string AppID back to integer for configuration
|
||||
new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None
|
||||
|
||||
# Continue with configuration using the new AppID and timestamp
|
||||
modlist_name = "TTW Installation"
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: Automated prefix creation failed")
|
||||
self._safe_append_text("Please check the logs for details")
|
||||
MessageService.critical(self, "Automated Setup Failed",
|
||||
"Automated prefix creation failed. Please check the console output for details.")
|
||||
# Re-enable controls on failure
|
||||
self._enable_controls_after_operation()
|
||||
finally:
|
||||
# Always ensure controls are re-enabled when workflow truly completes
|
||||
pass
|
||||
|
||||
def on_automated_prefix_error(self, error_msg):
|
||||
"""Handle error in automated prefix creation"""
|
||||
self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}")
|
||||
MessageService.critical(self, "Automated Setup Error",
|
||||
f"Error during automated prefix creation: {error_msg}")
|
||||
# Re-enable controls on error
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
def on_automated_prefix_progress(self, progress_msg):
|
||||
"""Handle progress updates from automated prefix creation"""
|
||||
self._safe_append_text(progress_msg)
|
||||
|
||||
def on_configuration_progress(self, progress_msg):
|
||||
"""Handle progress updates from modlist configuration"""
|
||||
self._safe_append_text(progress_msg)
|
||||
|
||||
def show_steam_restart_progress(self, message):
|
||||
"""Show Steam restart progress dialog"""
|
||||
from PySide6.QtWidgets import QProgressDialog
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
self.steam_restart_progress = QProgressDialog(message, None, 0, 0, self)
|
||||
self.steam_restart_progress.setWindowTitle("Restarting Steam")
|
||||
self.steam_restart_progress.setWindowModality(Qt.WindowModal)
|
||||
self.steam_restart_progress.setMinimumDuration(0)
|
||||
self.steam_restart_progress.setValue(0)
|
||||
self.steam_restart_progress.show()
|
||||
|
||||
def hide_steam_restart_progress(self):
|
||||
"""Hide Steam restart progress dialog"""
|
||||
if hasattr(self, 'steam_restart_progress') and self.steam_restart_progress:
|
||||
try:
|
||||
self.steam_restart_progress.close()
|
||||
self.steam_restart_progress.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self.steam_restart_progress = None
|
||||
# Controls are managed by the proper control management system
|
||||
|
||||
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
|
||||
"""Handle configuration completion on main thread"""
|
||||
try:
|
||||
# Re-enable controls now that installation/configuration is complete
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
# Check if we need to show Somnium guidance
|
||||
if self._show_somnium_guidance:
|
||||
self._show_somnium_post_install_guidance()
|
||||
|
||||
# Show celebration SuccessDialog after the entire workflow
|
||||
from ..dialogs import SuccessDialog
|
||||
import time
|
||||
if not hasattr(self, '_install_workflow_start_time'):
|
||||
self._install_workflow_start_time = time.time()
|
||||
time_taken = int(time.time() - self._install_workflow_start_time)
|
||||
mins, secs = divmod(time_taken, 60)
|
||||
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
game_name = display_names.get(self._current_game_type, self._current_game_name)
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="install",
|
||||
time_taken=time_str,
|
||||
game_name=game_name,
|
||||
parent=self
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
# TTW workflow does NOT need ENB detection/dialog
|
||||
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
||||
# Max retries reached - show failure message
|
||||
MessageService.critical(self, "Manual Steps Failed",
|
||||
"Manual steps validation failed after multiple attempts.")
|
||||
else:
|
||||
# Configuration failed for other reasons
|
||||
MessageService.critical(self, "Configuration Failed",
|
||||
"Post-install configuration failed. Please check the console output.")
|
||||
except Exception as e:
|
||||
# Ensure controls are re-enabled even on unexpected errors
|
||||
self._enable_controls_after_operation()
|
||||
raise
|
||||
# Clean up thread
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except:
|
||||
pass # Ignore errors if already disconnected
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000) # Wait up to 5 seconds
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
|
||||
def on_configuration_error(self, error_message):
|
||||
"""Handle configuration error on main thread"""
|
||||
self._safe_append_text(f"Configuration failed with error: {error_message}")
|
||||
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}")
|
||||
|
||||
# Re-enable all controls on error
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
# Clean up thread
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except:
|
||||
pass # Ignore errors if already disconnected
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000) # Wait up to 5 seconds
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
|
||||
def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None):
|
||||
"""Continue the configuration process with the new AppID after automated prefix creation"""
|
||||
# Headers are now shown at start of Steam Integration
|
||||
# No need to show them again here
|
||||
debug_print("Configuration phase continues after Steam Integration")
|
||||
|
||||
debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}")
|
||||
try:
|
||||
# Update the context with the new AppID (same format as manual steps)
|
||||
updated_context = {
|
||||
'name': modlist_name,
|
||||
'path': install_dir,
|
||||
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
||||
'modlist_value': None,
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', None),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
||||
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
||||
'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition'
|
||||
}
|
||||
self.context = updated_context # Ensure context is always set
|
||||
debug_print(f"Updated context with new AppID: {new_appid}")
|
||||
|
||||
# Get Steam Deck detection once and pass to ConfigThread
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
is_steamdeck = platform_service.is_steamdeck
|
||||
|
||||
# Create new config thread with updated context
|
||||
class ConfigThread(QThread):
|
||||
progress_update = Signal(str)
|
||||
configuration_complete = Signal(bool, str, str)
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, context, is_steamdeck):
|
||||
super().__init__()
|
||||
self.context = context
|
||||
self.is_steamdeck = is_steamdeck
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.modlist_service import ModlistService
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.models.modlist import ModlistContext
|
||||
from pathlib import Path
|
||||
|
||||
# Initialize backend service with passed Steam Deck detection
|
||||
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
|
||||
modlist_service = ModlistService(system_info)
|
||||
|
||||
# Detect game type from ModOrganizer.ini
|
||||
detected_game_type = self._detect_game_type_from_mo2_ini(self.context['path'])
|
||||
|
||||
# Convert context to ModlistContext for service
|
||||
modlist_context = ModlistContext(
|
||||
name=self.context['name'],
|
||||
install_dir=Path(self.context['path']),
|
||||
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
|
||||
game_type=detected_game_type,
|
||||
nexus_api_key='', # Not needed for configuration
|
||||
modlist_value=self.context.get('modlist_value'),
|
||||
modlist_source=self.context.get('modlist_source', 'identifier'),
|
||||
resolution=self.context.get('resolution'),
|
||||
skip_confirmation=True,
|
||||
engine_installed=True # Skip path manipulation for engine workflows
|
||||
)
|
||||
|
||||
# Add app_id to context
|
||||
modlist_context.app_id = self.context['appid']
|
||||
|
||||
# Define callbacks
|
||||
def progress_callback(message):
|
||||
self.progress_update.emit(message)
|
||||
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# This shouldn't happen since automated prefix creation is complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the service method for post-Steam configuration
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
if not result:
|
||||
self.progress_update.emit("Configuration failed to start")
|
||||
self.error_occurred.emit("Configuration failed to start")
|
||||
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
# Start configuration thread
|
||||
self.config_thread = ConfigThread(updated_context, is_steamdeck)
|
||||
self.config_thread.progress_update.connect(self.on_configuration_progress)
|
||||
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
|
||||
self.config_thread.error_occurred.connect(self.on_configuration_error)
|
||||
self.config_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
self._safe_append_text(f"Error continuing configuration: {e}")
|
||||
import traceback
|
||||
self._safe_append_text(f"Full traceback: {traceback.format_exc()}")
|
||||
self.on_configuration_error(str(e))
|
||||
|
||||
|
||||
def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir):
|
||||
"""Continue the configuration process with the corrected AppID after manual steps validation"""
|
||||
try:
|
||||
# Update the context with the new AppID
|
||||
updated_context = {
|
||||
'name': modlist_name,
|
||||
'path': install_dir,
|
||||
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
||||
'modlist_value': None,
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', None),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed
|
||||
'appid': new_appid # Use the NEW AppID from Steam
|
||||
}
|
||||
|
||||
debug_print(f"Updated context with new AppID: {new_appid}")
|
||||
|
||||
# Clean up old thread if exists and wait for it to finish
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except:
|
||||
pass # Ignore errors if already disconnected
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000) # Wait up to 5 seconds
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
|
||||
# Start new config thread
|
||||
self.config_thread = self._create_config_thread(updated_context)
|
||||
self.config_thread.progress_update.connect(self.on_configuration_progress)
|
||||
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
|
||||
self.config_thread.error_occurred.connect(self.on_configuration_error)
|
||||
self.config_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
self._safe_append_text(f"Error continuing configuration: {e}")
|
||||
self.on_configuration_error(str(e))
|
||||
|
||||
def _create_config_thread(self, context):
|
||||
"""Create a new ConfigThread with proper lifecycle management"""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
# Get Steam Deck detection once
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
is_steamdeck = platform_service.is_steamdeck
|
||||
|
||||
class ConfigThread(QThread):
|
||||
progress_update = Signal(str)
|
||||
configuration_complete = Signal(bool, str, str)
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, context, is_steamdeck, parent=None):
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self.is_steamdeck = is_steamdeck
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.services.modlist_service import ModlistService
|
||||
from jackify.backend.models.modlist import ModlistContext
|
||||
from pathlib import Path
|
||||
|
||||
# Initialize backend service with passed Steam Deck detection
|
||||
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
|
||||
modlist_service = ModlistService(system_info)
|
||||
|
||||
# Detect game type from ModOrganizer.ini
|
||||
detected_game_type = self._detect_game_type_from_mo2_ini(self.context['path'])
|
||||
|
||||
# Convert context to ModlistContext for service
|
||||
modlist_context = ModlistContext(
|
||||
name=self.context['name'],
|
||||
install_dir=Path(self.context['path']),
|
||||
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
|
||||
game_type=detected_game_type,
|
||||
nexus_api_key='', # Not needed for configuration
|
||||
modlist_value=self.context.get('modlist_value', ''),
|
||||
modlist_source=self.context.get('modlist_source', 'identifier'),
|
||||
resolution=self.context.get('resolution'), # Pass resolution from GUI
|
||||
skip_confirmation=True,
|
||||
engine_installed=True # Skip path manipulation for engine workflows
|
||||
)
|
||||
|
||||
# Add app_id to context
|
||||
if 'appid' in self.context:
|
||||
modlist_context.app_id = self.context['appid']
|
||||
|
||||
# Define callbacks
|
||||
def progress_callback(message):
|
||||
self.progress_update.emit(message)
|
||||
|
||||
def completion_callback(success, message, modlist_name):
|
||||
self.configuration_complete.emit(success, message, modlist_name)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Should not reach here -- manual steps already complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the new service method for post-Steam configuration
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
if not result:
|
||||
self.progress_update.emit("WARNING: configure_modlist_post_steam returned False")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}"
|
||||
self.progress_update.emit(f"DEBUG: {error_details}")
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
return ConfigThread(context, is_steamdeck, parent=self)
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"""TTW installer management methods for InstallTTWScreen (Mixin)."""
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QTimer
|
||||
import logging
|
||||
import os
|
||||
# Runtime imports to avoid circular dependencies
|
||||
from jackify.frontends.gui.services.message_service import MessageService # Runtime import
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class TTWInstallerMixin:
|
||||
"""Mixin providing TTW installer management methods for InstallTTWScreen."""
|
||||
|
||||
def check_requirements(self):
|
||||
"""Check and display requirements status"""
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
path_handler = PathHandler()
|
||||
|
||||
# Check game detection
|
||||
detected_games = path_handler.find_vanilla_game_paths()
|
||||
|
||||
# Fallout 3
|
||||
if 'Fallout 3' in detected_games:
|
||||
self.fallout3_status.setText("Fallout 3: Detected")
|
||||
self.fallout3_status.setStyleSheet("color: #3fd0ea;")
|
||||
else:
|
||||
self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam")
|
||||
self.fallout3_status.setStyleSheet("color: #f44336;")
|
||||
|
||||
# Fallout New Vegas
|
||||
if 'Fallout New Vegas' in detected_games:
|
||||
self.fnv_status.setText("Fallout New Vegas: Detected")
|
||||
self.fnv_status.setStyleSheet("color: #3fd0ea;")
|
||||
else:
|
||||
self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam")
|
||||
self.fnv_status.setStyleSheet("color: #f44336;")
|
||||
|
||||
# Update Start button state after checking requirements
|
||||
self._update_start_button_state()
|
||||
|
||||
def _check_ttw_installer_status(self):
|
||||
"""Check TTW_Linux_Installer installation status and update UI"""
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
# Create handler instances
|
||||
filesystem_handler = FileSystemHandler()
|
||||
config_handler = ConfigHandler()
|
||||
system_info = SystemInfo(is_steamdeck=False)
|
||||
ttw_installer_handler = TTWInstallerHandler(
|
||||
steamdeck=False,
|
||||
verbose=False,
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Check if TTW_Linux_Installer is installed
|
||||
ttw_installer_handler._check_installation()
|
||||
|
||||
if ttw_installer_handler.ttw_installer_installed:
|
||||
# Check version against latest
|
||||
update_available, installed_v, latest_v = ttw_installer_handler.is_ttw_installer_update_available()
|
||||
if update_available:
|
||||
version_text = f"Out of date (v{installed_v} → v{latest_v})" if installed_v and latest_v else "Out of date"
|
||||
self.ttw_installer_status.setText(version_text)
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Update now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
else:
|
||||
version_text = f"Ready (v{installed_v})" if installed_v else "Ready"
|
||||
self.ttw_installer_status.setText(version_text)
|
||||
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
|
||||
self.ttw_installer_btn.setText("Update now")
|
||||
self.ttw_installer_btn.setEnabled(False) # Greyed out when ready
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
else:
|
||||
self.ttw_installer_status.setText("Not Found")
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
|
||||
except Exception as e:
|
||||
self.ttw_installer_status.setText("Check Failed")
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
debug_print(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
|
||||
|
||||
def install_ttw_installer(self):
|
||||
"""Install or update TTW_Linux_Installer"""
|
||||
# If not detected, show info dialog
|
||||
try:
|
||||
current_status = self.ttw_installer_status.text().strip()
|
||||
except Exception:
|
||||
current_status = ""
|
||||
if current_status == "Not Found":
|
||||
MessageService.information(
|
||||
self,
|
||||
"TTW_Linux_Installer Installation",
|
||||
(
|
||||
"TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.<br><br>"
|
||||
"Project: <a href=\"https://github.com/SulfurNitride/TTW_Linux_Installer\">github.com/SulfurNitride/TTW_Linux_Installer</a><br>"
|
||||
"Please star the repository and thank the developer.<br><br>"
|
||||
"Jackify will now download and install the pinned TTW_Linux_Installer version (0.0.7)."
|
||||
),
|
||||
safety_level="low",
|
||||
)
|
||||
|
||||
# Update button to show installation in progress
|
||||
self.ttw_installer_btn.setText("Installing...")
|
||||
self.ttw_installer_btn.setEnabled(False)
|
||||
|
||||
self.console.append("Installing/updating TTW_Linux_Installer...")
|
||||
|
||||
# Create background thread for installation
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
class InstallerDownloadThread(QThread):
|
||||
finished = Signal(bool, str) # success, message
|
||||
progress = Signal(str) # progress message
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
# Create handler instances
|
||||
filesystem_handler = FileSystemHandler()
|
||||
config_handler = ConfigHandler()
|
||||
system_info = SystemInfo(is_steamdeck=False)
|
||||
ttw_installer_handler = TTWInstallerHandler(
|
||||
steamdeck=False,
|
||||
verbose=False,
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Install TTW_Linux_Installer (this will download and extract)
|
||||
self.progress.emit("Downloading TTW_Linux_Installer...")
|
||||
success, message = ttw_installer_handler.install_ttw_installer()
|
||||
|
||||
if success:
|
||||
install_path = ttw_installer_handler.ttw_installer_dir
|
||||
self.progress.emit(f"Installation complete: {install_path}")
|
||||
else:
|
||||
self.progress.emit(f"Installation failed: {message}")
|
||||
|
||||
self.finished.emit(success, message)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error installing TTW_Linux_Installer: {str(e)}"
|
||||
self.progress.emit(error_msg)
|
||||
debug_print(f"DEBUG: TTW_Linux_Installer installation error: {e}")
|
||||
self.finished.emit(False, error_msg)
|
||||
|
||||
# Create and start thread
|
||||
self.installer_download_thread = InstallerDownloadThread()
|
||||
self.installer_download_thread.progress.connect(self._on_installer_download_progress)
|
||||
self.installer_download_thread.finished.connect(self._on_installer_download_finished)
|
||||
self.installer_download_thread.start()
|
||||
|
||||
# Update Activity window to show download in progress
|
||||
self.file_progress_list.clear()
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Downloading TTW_Linux_Installer...",
|
||||
progress=0
|
||||
)
|
||||
|
||||
def _on_installer_download_progress(self, message):
|
||||
"""Handle installer download progress updates"""
|
||||
self.console.append(message)
|
||||
# Update Activity window based on progress message
|
||||
if "Downloading" in message:
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Downloading TTW_Linux_Installer...",
|
||||
progress=0 # Indeterminate progress
|
||||
)
|
||||
elif "Extracting" in message or "extracting" in message.lower():
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Extracting TTW_Linux_Installer...",
|
||||
progress=50
|
||||
)
|
||||
elif "complete" in message.lower() or "successfully" in message.lower():
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="TTW_Linux_Installer ready",
|
||||
progress=100
|
||||
)
|
||||
|
||||
def _on_installer_download_finished(self, success, message):
|
||||
"""Handle installer download completion"""
|
||||
if success:
|
||||
self.console.append("TTW_Linux_Installer installed successfully")
|
||||
# Clear Activity window after successful installation
|
||||
self.file_progress_list.clear()
|
||||
# Re-check status after installation (this will update button state correctly)
|
||||
self._check_ttw_installer_status()
|
||||
self._update_start_button_state()
|
||||
else:
|
||||
self.console.append(f"Installation failed: {message}")
|
||||
# Clear Activity window on failure
|
||||
self.file_progress_list.clear()
|
||||
# Re-enable button on failure so user can retry
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
|
||||
def _check_ttw_requirements(self):
|
||||
"""Check TTW requirements before installation"""
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
|
||||
path_handler = PathHandler()
|
||||
|
||||
# Check game detection
|
||||
detected_games = path_handler.find_vanilla_game_paths()
|
||||
missing_games = []
|
||||
|
||||
if 'Fallout 3' not in detected_games:
|
||||
missing_games.append("Fallout 3")
|
||||
if 'Fallout New Vegas' not in detected_games:
|
||||
missing_games.append("Fallout New Vegas")
|
||||
|
||||
if missing_games:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Missing Required Games",
|
||||
f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Check TTW_Linux_Installer using the status we already checked
|
||||
status_text = self.ttw_installer_status.text()
|
||||
if status_text in ("Not Found", "Check Failed"):
|
||||
MessageService.warning(
|
||||
self,
|
||||
"TTW_Linux_Installer Required",
|
||||
"TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer using the 'Install now' button."
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Now collect all actionable controls after UI is fully built
|
||||
self._collect_actionable_controls()
|
||||
|
||||
# Check if all requirements are met and enable/disable Start button
|
||||
self._update_start_button_state()
|
||||
|
||||
def _update_start_button_state(self):
|
||||
"""Enable/disable Start button based on requirements and file selection"""
|
||||
# Check if all requirements are met
|
||||
requirements_met = self._check_ttw_requirements()
|
||||
|
||||
# Check if .mpi file is selected
|
||||
mpi_file_selected = bool(self.file_edit.text().strip())
|
||||
|
||||
# Enable Start button only if both requirements are met and file is selected
|
||||
self.start_btn.setEnabled(requirements_met and mpi_file_selected)
|
||||
|
||||
# Update button text to indicate what's missing
|
||||
if not requirements_met:
|
||||
self.start_btn.setText("Requirements Not Met")
|
||||
elif not mpi_file_selected:
|
||||
self.start_btn.setText("Select TTW .mpi File")
|
||||
else:
|
||||
self.start_btn.setText("Start Installation")
|
||||
|
||||
@@ -8,16 +8,9 @@ import os
|
||||
import json
|
||||
import shutil
|
||||
import re
|
||||
import logging
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class TTWIntegrationMixin:
|
||||
"""Mixin providing modlist integration workflow for InstallTTWScreen."""
|
||||
|
||||
@@ -36,13 +29,17 @@ class TTWIntegrationMixin:
|
||||
self._integration_modlist_name = modlist_name
|
||||
self._integration_install_dir = install_dir
|
||||
|
||||
# Pre-populate output dir to install TTW directly into the modlist mods folder,
|
||||
# avoiding the wasteful copy step during integration.
|
||||
ttw_target = Path(install_dir) / "mods" / "[NoDelete] Tale of Two Wastelands"
|
||||
self.install_dir_edit.setText(str(ttw_target))
|
||||
|
||||
# Reset saved geometry so showEvent can properly collapse from current window size
|
||||
self._saved_geometry = None
|
||||
self._saved_min_size = None
|
||||
|
||||
# Update UI to show integration mode
|
||||
debug_print(f"TTW screen set to integration mode for modlist: {modlist_name}")
|
||||
debug_print(f"Installation directory: {install_dir}")
|
||||
logger.debug(f"TTW screen set to integration mode for modlist: {modlist_name}")
|
||||
logger.debug(f"TTW output pre-populated to: {ttw_target}")
|
||||
|
||||
def _perform_modlist_integration(self):
|
||||
"""Integrate TTW into the modlist automatically
|
||||
@@ -75,16 +72,31 @@ class TTWIntegrationMixin:
|
||||
if version_match:
|
||||
ttw_version = version_match.group(1)
|
||||
|
||||
# If TTW was installed directly into the modlist mods dir (integration mode
|
||||
# pre-populate), rename to the versioned folder name and skip the copy step.
|
||||
skip_copy = False
|
||||
mods_dir = Path(self._integration_install_dir) / "mods"
|
||||
if ttw_output_dir.parent == mods_dir:
|
||||
versioned_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}".strip() if ttw_version else "[NoDelete] Tale of Two Wastelands"
|
||||
versioned_path = mods_dir / versioned_name
|
||||
if ttw_output_dir != versioned_path and ttw_output_dir.exists():
|
||||
logger.debug(f"Renaming TTW output: {ttw_output_dir.name} -> {versioned_name}")
|
||||
ttw_output_dir.rename(versioned_path)
|
||||
ttw_output_dir = versioned_path
|
||||
skip_copy = True
|
||||
logger.debug("TTW already in mods dir — skipping copy step")
|
||||
|
||||
# Create background thread for integration
|
||||
class IntegrationThread(QThread):
|
||||
finished = Signal(bool, str) # success, ttw_version
|
||||
progress = Signal(str) # progress message
|
||||
|
||||
def __init__(self, ttw_output_path, modlist_install_dir, ttw_version):
|
||||
def __init__(self, ttw_output_path, modlist_install_dir, ttw_version, skip_copy):
|
||||
super().__init__()
|
||||
self.ttw_output_path = ttw_output_path
|
||||
self.modlist_install_dir = modlist_install_dir
|
||||
self.ttw_version = ttw_version
|
||||
self.skip_copy = skip_copy
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
@@ -94,11 +106,12 @@ class TTWIntegrationMixin:
|
||||
success = TTWInstallerHandler.integrate_ttw_into_modlist(
|
||||
ttw_output_path=self.ttw_output_path,
|
||||
modlist_install_dir=self.modlist_install_dir,
|
||||
ttw_version=self.ttw_version
|
||||
ttw_version=self.ttw_version,
|
||||
skip_copy=self.skip_copy,
|
||||
)
|
||||
self.finished.emit(success, self.ttw_version)
|
||||
except Exception as e:
|
||||
debug_print(f"ERROR: Integration thread failed: {e}")
|
||||
logger.debug(f"ERROR: Integration thread failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self.finished.emit(False, self.ttw_version)
|
||||
@@ -142,7 +155,8 @@ class TTWIntegrationMixin:
|
||||
self.integration_thread = IntegrationThread(
|
||||
ttw_output_dir,
|
||||
Path(self._integration_install_dir),
|
||||
ttw_version
|
||||
ttw_version,
|
||||
skip_copy,
|
||||
)
|
||||
self.integration_thread.progress.connect(self._safe_append_text)
|
||||
self.integration_thread.finished.connect(self._on_integration_thread_finished)
|
||||
@@ -156,7 +170,7 @@ class TTWIntegrationMixin:
|
||||
|
||||
error_msg = f"Integration error: {str(e)}"
|
||||
self._safe_append_text(f"\nError: {error_msg}")
|
||||
debug_print(f"ERROR: {error_msg}")
|
||||
logger.debug(f"ERROR: {error_msg}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self.integration_complete.emit(False, "")
|
||||
@@ -213,7 +227,7 @@ class TTWIntegrationMixin:
|
||||
)
|
||||
self.integration_complete.emit(False, ttw_version)
|
||||
except Exception as e:
|
||||
debug_print(f"ERROR: Failed to handle integration completion: {e}")
|
||||
logger.debug(f"ERROR: Failed to handle integration completion: {e}")
|
||||
self.integration_complete.emit(False, ttw_version)
|
||||
|
||||
def _create_ttw_mod_archive(self, automated=False):
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
"""Window lifecycle and resize handlers for InstallTTWScreen (Mixin)."""
|
||||
from PySide6.QtCore import QTimer, QSize, Qt
|
||||
from PySide6.QtGui import QResizeEvent
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from ..utils import set_responsive_minimum
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class TTWLifecycleMixin:
|
||||
"""Mixin providing window lifecycle and resize management for InstallTTWScreen."""
|
||||
|
||||
@@ -58,7 +51,7 @@ class TTWLifecycleMixin:
|
||||
def showEvent(self, event):
|
||||
"""Called when the widget becomes visible"""
|
||||
super().showEvent(event)
|
||||
debug_print(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}")
|
||||
logger.debug(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}")
|
||||
|
||||
# Check TTW_Linux_Installer status asynchronously (non-blocking) after screen opens
|
||||
from PySide6.QtCore import QTimer
|
||||
@@ -80,7 +73,7 @@ class TTWLifecycleMixin:
|
||||
is_steamdeck = True
|
||||
|
||||
if is_steamdeck:
|
||||
debug_print("DEBUG: Steam Deck detected, keeping expanded")
|
||||
logger.debug("DEBUG: Steam Deck detected, keeping expanded")
|
||||
# Force expanded state and hide checkbox
|
||||
if self.show_details_checkbox.isVisible():
|
||||
self.show_details_checkbox.setVisible(False)
|
||||
@@ -91,27 +84,27 @@ class TTWLifecycleMixin:
|
||||
self.console.setMaximumHeight(16777215) # Remove height limit
|
||||
return
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Steam Deck check exception: {e}")
|
||||
logger.debug(f"DEBUG: Steam Deck check exception: {e}")
|
||||
pass
|
||||
debug_print(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}")
|
||||
logger.debug(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}")
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.blockSignals(True)
|
||||
self.show_details_checkbox.setChecked(False)
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
|
||||
debug_print("DEBUG: Calling _toggle_console_visibility(Unchecked)")
|
||||
logger.debug("DEBUG: Calling _toggle_console_visibility(Unchecked)")
|
||||
self._toggle_console_visibility(_Qt.Unchecked)
|
||||
# Force the window to compact height to eliminate bottom whitespace
|
||||
main_window = self.window()
|
||||
debug_print(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}")
|
||||
logger.debug(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}")
|
||||
if main_window:
|
||||
# Save original geometry once
|
||||
if self._saved_geometry is None:
|
||||
self._saved_geometry = main_window.geometry()
|
||||
debug_print(f"DEBUG: Saved geometry: {self._saved_geometry}")
|
||||
logger.debug(f"DEBUG: Saved geometry: {self._saved_geometry}")
|
||||
if self._saved_min_size is None:
|
||||
self._saved_min_size = main_window.minimumSize()
|
||||
debug_print(f"DEBUG: Saved min size: {self._saved_min_size}")
|
||||
logger.debug(f"DEBUG: Saved min size: {self._saved_min_size}")
|
||||
|
||||
# Fixed compact size - same as menu screens
|
||||
from PySide6.QtCore import QSize
|
||||
@@ -127,14 +120,14 @@ class TTWLifecycleMixin:
|
||||
# Notify parent to ensure compact
|
||||
try:
|
||||
self.resize_request.emit('collapse')
|
||||
debug_print("DEBUG: Emitted resize_request collapse signal")
|
||||
logger.debug("DEBUG: Emitted resize_request collapse signal")
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Exception emitting signal: {e}")
|
||||
logger.debug(f"DEBUG: Exception emitting signal: {e}")
|
||||
pass
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: showEvent exception: {e}")
|
||||
logger.debug(f"DEBUG: showEvent exception: {e}")
|
||||
import traceback
|
||||
debug_print(f"DEBUG: {traceback.format_exc()}")
|
||||
logger.debug(f"DEBUG: {traceback.format_exc()}")
|
||||
pass
|
||||
|
||||
def hideEvent(self, event):
|
||||
@@ -148,8 +141,8 @@ class TTWLifecycleMixin:
|
||||
# Important when console is expanded
|
||||
main_window.setMaximumSize(QSize(16777215, 16777215))
|
||||
main_window.setMinimumSize(QSize(0, 0))
|
||||
debug_print("DEBUG: Install TTW hideEvent - cleared window size constraints")
|
||||
logger.debug("DEBUG: Install TTW hideEvent - cleared window size constraints")
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: hideEvent exception: {e}")
|
||||
logger.debug(f"DEBUG: hideEvent exception: {e}")
|
||||
pass
|
||||
|
||||
|
||||
177
jackify/frontends/gui/screens/install_ttw_output.py
Normal file
177
jackify/frontends/gui/screens/install_ttw_output.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""TTW output processing mixin for InstallTTWScreen."""
|
||||
import re
|
||||
import time
|
||||
|
||||
from ..utils import strip_ansi_control_codes
|
||||
|
||||
|
||||
class TTWOutputMixin:
|
||||
"""Mixin providing output and progress signal handlers for InstallTTWScreen."""
|
||||
|
||||
def on_installation_output_batch(self, messages):
|
||||
"""Handle batched output from TTW_Linux_Installer (pre-cleaned in worker thread)."""
|
||||
if not hasattr(self, '_ttw_seen_lines'):
|
||||
self._ttw_seen_lines = set()
|
||||
self._ttw_current_phase = None
|
||||
self._ttw_last_progress = 0
|
||||
self._ttw_last_activity_update = 0
|
||||
self.ttw_start_time = time.time()
|
||||
|
||||
lines_to_display = []
|
||||
html_fragments = []
|
||||
show_details_due_to_error = False
|
||||
latest_progress = None
|
||||
|
||||
for cleaned in messages:
|
||||
if not cleaned:
|
||||
continue
|
||||
|
||||
lower_cleaned = cleaned.lower()
|
||||
|
||||
try:
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
latest_progress = (current, total, percent)
|
||||
|
||||
if 'loading manifest:' in lower_cleaned:
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
|
||||
is_warning = 'warning:' in lower_cleaned
|
||||
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
|
||||
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
||||
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
||||
|
||||
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
|
||||
|
||||
if should_show:
|
||||
if is_error or is_warning:
|
||||
color = '#f44336' if is_error else '#ff9800'
|
||||
prefix = "WARNING: " if is_warning else "ERROR: "
|
||||
escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
html_fragments.append(f'<span style="color: {color};">{escaped}</span>')
|
||||
show_details_due_to_error = True
|
||||
else:
|
||||
lines_to_display.append(cleaned)
|
||||
|
||||
if latest_progress:
|
||||
current, total, percent = latest_progress
|
||||
current_time = time.time()
|
||||
if abs(percent - self._ttw_last_progress) >= 1 or (current_time - self._ttw_last_activity_update) >= 0.5:
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
self._ttw_last_progress = percent
|
||||
self._ttw_last_activity_update = current_time
|
||||
|
||||
if html_fragments or lines_to_display:
|
||||
try:
|
||||
if html_fragments:
|
||||
self.console.insertHtml('<br>'.join(html_fragments) + '<br>')
|
||||
if lines_to_display:
|
||||
self.console.append('\n'.join(lines_to_display))
|
||||
if show_details_due_to_error and not self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.setChecked(True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_installation_output(self, message):
|
||||
"""Single-message output handler (not currently wired to the batch thread)."""
|
||||
if not hasattr(self, '_ttw_seen_lines'):
|
||||
self._ttw_seen_lines = set()
|
||||
self._ttw_last_extraction_progress = 0
|
||||
self._ttw_last_file_operation_time = 0
|
||||
self._ttw_file_operation_count = 0
|
||||
self._ttw_current_phase = None
|
||||
self._ttw_last_progress_line = None
|
||||
self._ttw_progress_line_text = None
|
||||
|
||||
if message.strip().startswith('[Jackify]'):
|
||||
self._write_to_log_file(message)
|
||||
return
|
||||
|
||||
cleaned = strip_ansi_control_codes(message).strip()
|
||||
|
||||
filtered_chars = []
|
||||
for char in cleaned:
|
||||
code = ord(char)
|
||||
is_emoji = (
|
||||
(0x1F300 <= code <= 0x1F9FF) or
|
||||
(0x1F600 <= code <= 0x1F64F) or
|
||||
(0x2600 <= code <= 0x26FF) or
|
||||
(0x2700 <= code <= 0x27BF)
|
||||
)
|
||||
if not is_emoji:
|
||||
filtered_chars.append(char)
|
||||
cleaned = ''.join(filtered_chars).strip()
|
||||
|
||||
if not cleaned:
|
||||
return
|
||||
|
||||
if not hasattr(self, 'ttw_start_time'):
|
||||
self.ttw_start_time = time.time()
|
||||
|
||||
lower_cleaned = cleaned.lower()
|
||||
|
||||
try:
|
||||
self._write_to_log_file(cleaned)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
|
||||
if 'loading manifest:' in lower_cleaned:
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
|
||||
is_warning = 'warning:' in lower_cleaned
|
||||
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
|
||||
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
||||
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
||||
|
||||
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
|
||||
|
||||
if should_show:
|
||||
try:
|
||||
if is_error or is_warning:
|
||||
color = '#f44336' if is_error else '#ff9800'
|
||||
prefix = "WARNING: " if is_warning else "ERROR: "
|
||||
escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
html = f'<span style="color: {color};">{escaped}</span><br>'
|
||||
self.console.insertHtml(html)
|
||||
if not self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.setChecked(True)
|
||||
else:
|
||||
self.console.append(cleaned)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_installation_progress(self, progress_message):
|
||||
"""Replace the last console line for in-place progress updates."""
|
||||
from PySide6.QtGui import QTextCursor
|
||||
cursor = self.console.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor)
|
||||
cursor.removeSelectedText()
|
||||
cursor.insertText(progress_message)
|
||||
@@ -6,16 +6,9 @@ from pathlib import Path
|
||||
import os
|
||||
import requests
|
||||
import traceback
|
||||
import logging
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class TTWRequirementsMixin:
|
||||
"""Mixin providing TTW installer requirement checking and validation for InstallTTWScreen."""
|
||||
|
||||
@@ -117,7 +110,7 @@ class TTWRequirementsMixin:
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
debug_print(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
|
||||
logger.debug(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
|
||||
|
||||
def install_ttw_installer(self):
|
||||
"""Install or update TTW_Linux_Installer"""
|
||||
@@ -185,7 +178,7 @@ class TTWRequirementsMixin:
|
||||
except Exception as e:
|
||||
error_msg = f"Error installing TTW_Linux_Installer: {str(e)}"
|
||||
self.progress.emit(error_msg)
|
||||
debug_print(f"DEBUG: TTW_Linux_Installer installation error: {e}")
|
||||
logger.debug(f"DEBUG: TTW_Linux_Installer installation error: {e}")
|
||||
self.finished.emit(False, error_msg)
|
||||
|
||||
# Create and start thread
|
||||
|
||||
151
jackify/frontends/gui/screens/install_ttw_thread.py
Normal file
151
jackify/frontends/gui/screens/install_ttw_thread.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""TTW installation worker thread."""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
import time
|
||||
|
||||
from ..utils import strip_ansi_control_codes
|
||||
|
||||
|
||||
class TTWInstallationThread(QThread):
|
||||
output_batch_received = Signal(list)
|
||||
progress_received = Signal(str)
|
||||
installation_finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, mpi_path, install_dir):
|
||||
super().__init__()
|
||||
self.mpi_path = mpi_path
|
||||
self.install_dir = install_dir
|
||||
self.cancelled = False
|
||||
self.proc = None
|
||||
self.output_buffer = []
|
||||
self.last_emit_time = 0
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
try:
|
||||
if self.proc and self.proc.poll() is None:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def process_and_buffer_line(self, raw_line):
|
||||
"""Clean one output line and queue it for batched emit."""
|
||||
cleaned = strip_ansi_control_codes(raw_line).strip()
|
||||
|
||||
filtered_chars = []
|
||||
for char in cleaned:
|
||||
code = ord(char)
|
||||
is_emoji = (
|
||||
(0x1F300 <= code <= 0x1F9FF) or
|
||||
(0x1F600 <= code <= 0x1F64F) or
|
||||
(0x2600 <= code <= 0x26FF) or
|
||||
(0x2700 <= code <= 0x27BF)
|
||||
)
|
||||
if not is_emoji:
|
||||
filtered_chars.append(char)
|
||||
cleaned = ''.join(filtered_chars).strip()
|
||||
|
||||
if cleaned:
|
||||
self.output_buffer.append(cleaned)
|
||||
|
||||
def flush_output_buffer(self):
|
||||
"""Emit buffered lines as a batch."""
|
||||
if self.output_buffer:
|
||||
self.output_batch_received.emit(self.output_buffer[:])
|
||||
self.output_buffer.clear()
|
||||
self.last_emit_time = time.time()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
self.process_and_buffer_line("Initializing TTW installation...")
|
||||
self.flush_output_buffer()
|
||||
|
||||
filesystem_handler = FileSystemHandler()
|
||||
config_handler = ConfigHandler()
|
||||
ttw_handler = TTWInstallerHandler(
|
||||
steamdeck=False,
|
||||
verbose=False,
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler,
|
||||
)
|
||||
|
||||
output_file = tempfile.NamedTemporaryFile(
|
||||
mode='w+', delete=False, suffix='.ttw_output', encoding='utf-8'
|
||||
)
|
||||
output_file_path = Path(output_file.name)
|
||||
output_file.close()
|
||||
|
||||
self.process_and_buffer_line("Starting TTW installation...")
|
||||
self.flush_output_buffer()
|
||||
|
||||
self.proc, error_msg = ttw_handler.start_ttw_installation(
|
||||
Path(self.mpi_path),
|
||||
Path(self.install_dir),
|
||||
output_file_path,
|
||||
)
|
||||
|
||||
if not self.proc:
|
||||
self.installation_finished.emit(False, error_msg or "Failed to start TTW installation")
|
||||
return
|
||||
|
||||
self.process_and_buffer_line("TTW_Linux_Installer process started, monitoring output...")
|
||||
self.flush_output_buffer()
|
||||
|
||||
last_position = 0
|
||||
BATCH_INTERVAL = 0.3
|
||||
|
||||
while self.proc.poll() is None:
|
||||
if self.cancelled:
|
||||
break
|
||||
|
||||
try:
|
||||
with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
f.seek(last_position)
|
||||
new_lines = f.readlines()
|
||||
last_position = f.tell()
|
||||
|
||||
for line in new_lines:
|
||||
if self.cancelled:
|
||||
break
|
||||
self.process_and_buffer_line(line.rstrip())
|
||||
|
||||
if time.time() - self.last_emit_time >= BATCH_INTERVAL:
|
||||
self.flush_output_buffer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
try:
|
||||
with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
f.seek(last_position)
|
||||
for line in f.readlines():
|
||||
self.process_and_buffer_line(line.rstrip())
|
||||
self.flush_output_buffer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
output_file_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ttw_handler.cleanup_ttw_process(self.proc)
|
||||
|
||||
returncode = self.proc.returncode if self.proc else -1
|
||||
if self.cancelled:
|
||||
self.installation_finished.emit(False, "Installation cancelled by user")
|
||||
elif returncode == 0:
|
||||
self.installation_finished.emit(True, "TTW installation completed successfully!")
|
||||
else:
|
||||
self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self.installation_finished.emit(False, f"Installation error: {str(e)}")
|
||||
@@ -9,15 +9,6 @@ from ..utils import set_responsive_minimum # Runtime import
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class TTWUIMixin:
|
||||
"""Mixin providing UI helper methods for InstallTTWScreen."""
|
||||
|
||||
@@ -93,7 +84,7 @@ class TTWUIMixin:
|
||||
|
||||
# On Steam Deck, skip window resizing - keep default Steam Deck window size
|
||||
if is_steamdeck:
|
||||
debug_print("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility")
|
||||
logger.debug("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility")
|
||||
return
|
||||
|
||||
# Restore main window to normal size (clear any compact constraints)
|
||||
@@ -146,7 +137,7 @@ class TTWUIMixin:
|
||||
|
||||
# On Steam Deck, skip window resizing to keep maximized state
|
||||
if is_steamdeck:
|
||||
debug_print("DEBUG: Steam Deck detected, skipping window resize in collapse branch")
|
||||
logger.debug("DEBUG: Steam Deck detected, skipping window resize in collapse branch")
|
||||
return
|
||||
|
||||
# Use fixed compact height for consistency across all workflow screens
|
||||
|
||||
@@ -358,7 +358,6 @@ class TTWUISetupMixin:
|
||||
self.top_timer.start(2000)
|
||||
# --- Start Installation button ---
|
||||
self.start_btn.clicked.connect(self.validate_and_start_install)
|
||||
self.steam_restart_finished.connect(self._on_steam_restart_finished)
|
||||
|
||||
# Initialize process tracking
|
||||
self.process = None
|
||||
|
||||
@@ -1,66 +1,49 @@
|
||||
"""TTW installation workflow methods for InstallTTWScreen (Mixin)."""
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QTimer, Qt, QThread, Signal, QProcess
|
||||
from PySide6.QtCore import QTimer, Qt, QProcess
|
||||
from PySide6.QtWidgets import QMessageBox, QApplication
|
||||
from PySide6.QtGui import QTextCursor
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import shutil
|
||||
import tempfile
|
||||
# Runtime imports to avoid circular dependencies
|
||||
from jackify.frontends.gui.services.message_service import MessageService # Runtime import
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler # Runtime import
|
||||
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog # Runtime import
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE # Runtime import
|
||||
from ..utils import strip_ansi_control_codes # Runtime import
|
||||
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import install_dir_create_failed, wabbajack_install_failed
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
class TTWWorkflowMixin:
|
||||
"""Mixin providing installation workflow methods for InstallTTWScreen."""
|
||||
|
||||
def validate_and_start_install(self):
|
||||
import time
|
||||
self._install_workflow_start_time = time.time()
|
||||
debug_print('DEBUG: validate_and_start_install called')
|
||||
logger.debug('DEBUG: validate_and_start_install called')
|
||||
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
self.config_handler.reload_config()
|
||||
debug_print('DEBUG: Reloaded config from disk')
|
||||
logger.debug('DEBUG: Reloaded config from disk')
|
||||
|
||||
# Check TTW requirements first
|
||||
if not self._check_ttw_requirements():
|
||||
return
|
||||
|
||||
# Check protontricks before proceeding
|
||||
|
||||
if not self._check_protontricks():
|
||||
return
|
||||
|
||||
# Disable all controls during installation (except Cancel)
|
||||
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
|
||||
try:
|
||||
# TTW only needs .mpi file
|
||||
mpi_path = self.file_edit.text().strip()
|
||||
if not mpi_path or not os.path.isfile(mpi_path) or not mpi_path.endswith('.mpi'):
|
||||
MessageService.warning(self, "Invalid TTW File", "Please select a valid TTW .mpi file.")
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
|
||||
# Validate required fields
|
||||
|
||||
missing_fields = []
|
||||
if not install_dir:
|
||||
missing_fields.append("Install Directory")
|
||||
@@ -68,13 +51,12 @@ class TTWWorkflowMixin:
|
||||
MessageService.warning(self, "Missing Required Fields", f"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields))
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
# Validate install directory
|
||||
|
||||
mpi_path = os.path.realpath(mpi_path)
|
||||
install_dir = os.path.realpath(install_dir)
|
||||
validation_handler = ValidationHandler()
|
||||
from pathlib import Path
|
||||
install_dir_path = Path(install_dir)
|
||||
|
||||
# Check for dangerous directories first (system roots, etc.)
|
||||
|
||||
if validation_handler.is_dangerous_directory(install_dir_path):
|
||||
dlg = WarningDialog(
|
||||
f"The directory '{install_dir}' is a system or user root and cannot be used for TTW installation.",
|
||||
@@ -83,14 +65,11 @@ class TTWWorkflowMixin:
|
||||
if not dlg.exec() or not dlg.confirmed:
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
# Check if directory exists and is not empty - TTW_Linux_Installer will overwrite existing files
|
||||
|
||||
if install_dir_path.exists() and install_dir_path.is_dir():
|
||||
# Check if directory contains any files
|
||||
try:
|
||||
has_files = any(install_dir_path.iterdir())
|
||||
if has_files:
|
||||
# Directory exists and is not empty - warn user about deletion
|
||||
dlg = WarningDialog(
|
||||
f"The TTW output directory already exists and contains files:\n{install_dir}\n\n"
|
||||
f"All files in this directory will be deleted before installation.\n\n"
|
||||
@@ -100,8 +79,7 @@ class TTWWorkflowMixin:
|
||||
if not dlg.exec() or not dlg.confirmed:
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
# User confirmed - delete all contents of the directory
|
||||
|
||||
import shutil
|
||||
try:
|
||||
for item in install_dir_path.iterdir():
|
||||
@@ -109,81 +87,67 @@ class TTWWorkflowMixin:
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
debug_print(f"DEBUG: Deleted all contents of {install_dir}")
|
||||
logger.debug(f"DEBUG: Deleted all contents of {install_dir}")
|
||||
except Exception as e:
|
||||
MessageService.critical(self, "Error", f"Failed to delete directory contents:\n{e}")
|
||||
MessageService.show_error(self, install_dir_create_failed(str(install_dir), str(e)))
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Error checking directory contents: {e}")
|
||||
# If we can't check, proceed
|
||||
|
||||
logger.debug(f"DEBUG: Error checking directory contents: {e}")
|
||||
|
||||
if not os.path.isdir(install_dir):
|
||||
create = MessageService.question(self, "Create Directory?",
|
||||
f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?",
|
||||
critical=False # Non-critical, won't steal focus
|
||||
critical=False
|
||||
)
|
||||
if create == QMessageBox.Yes:
|
||||
try:
|
||||
os.makedirs(install_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}")
|
||||
MessageService.show_error(self, install_dir_create_failed(install_dir, str(e)))
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
else:
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
# Start TTW installation
|
||||
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
|
||||
# Update button states for installation
|
||||
|
||||
self.start_btn.setEnabled(False)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
debug_print(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}')
|
||||
|
||||
logger.debug(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}')
|
||||
self.run_ttw_installer(mpi_path, install_dir)
|
||||
except Exception as e:
|
||||
debug_print(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
import traceback
|
||||
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
# Re-enable all controls after exception
|
||||
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
debug_print(f"DEBUG: Controls re-enabled in exception handler")
|
||||
logger.debug("DEBUG: Controls re-enabled in exception handler")
|
||||
|
||||
def run_ttw_installer(self, mpi_path, install_dir):
|
||||
debug_print('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER')
|
||||
logger.debug('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER')
|
||||
|
||||
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
|
||||
# Refresh Proton version and winetricks settings
|
||||
self.config_handler._load_config()
|
||||
|
||||
# Rotate log file at start of each workflow run (keep 5 backups)
|
||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||
from pathlib import Path
|
||||
log_handler = LoggingHandler()
|
||||
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
|
||||
|
||||
# Clear console for fresh installation output
|
||||
self.console.clear()
|
||||
self._safe_append_text("Starting TTW installation...")
|
||||
|
||||
# Initialize Activity window with immediate feedback
|
||||
self.file_progress_list.clear()
|
||||
self._update_ttw_phase("Initializing TTW installation", 0, 0, 0)
|
||||
# Force UI update immediately
|
||||
QApplication.processEvents()
|
||||
|
||||
# Show status banner and show details checkbox
|
||||
self.status_banner.setVisible(True)
|
||||
self.status_banner.setText("Initializing TTW installation...")
|
||||
self.show_details_checkbox.setVisible(True)
|
||||
|
||||
# Reset banner to default blue color for new installation
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
background-color: #2a2a2a;
|
||||
color: {JACKIFY_COLOR_BLUE};
|
||||
@@ -195,420 +159,36 @@ class TTWWorkflowMixin:
|
||||
|
||||
self.ttw_start_time = time.time()
|
||||
|
||||
# Start a timer to update elapsed time
|
||||
self.ttw_elapsed_timer = QTimer()
|
||||
self.ttw_elapsed_timer.timeout.connect(self._update_ttw_elapsed_time)
|
||||
self.ttw_elapsed_timer.start(1000) # Update every second
|
||||
self.ttw_elapsed_timer.start(1000)
|
||||
|
||||
# Update UI state for installation
|
||||
self.start_btn.setEnabled(False)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
# Create installation thread
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
class TTWInstallationThread(QThread):
|
||||
output_batch_received = Signal(list) # Batched output lines
|
||||
progress_received = Signal(str)
|
||||
installation_finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, mpi_path, install_dir):
|
||||
super().__init__()
|
||||
self.mpi_path = mpi_path
|
||||
self.install_dir = install_dir
|
||||
self.cancelled = False
|
||||
self.proc = None
|
||||
self.output_buffer = [] # Buffer for batching output
|
||||
self.last_emit_time = 0 # Track when we last emitted
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
try:
|
||||
if self.proc and self.proc.poll() is None:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def process_and_buffer_line(self, raw_line):
|
||||
"""Process line in worker thread and add to buffer"""
|
||||
# Strip ANSI codes
|
||||
cleaned = strip_ansi_control_codes(raw_line).strip()
|
||||
|
||||
# Strip emojis (do this in worker thread, not UI thread)
|
||||
filtered_chars = []
|
||||
for char in cleaned:
|
||||
code = ord(char)
|
||||
is_emoji = (
|
||||
(0x1F300 <= code <= 0x1F9FF) or
|
||||
(0x1F600 <= code <= 0x1F64F) or
|
||||
(0x2600 <= code <= 0x26FF) or
|
||||
(0x2700 <= code <= 0x27BF)
|
||||
)
|
||||
if not is_emoji:
|
||||
filtered_chars.append(char)
|
||||
cleaned = ''.join(filtered_chars).strip()
|
||||
|
||||
# Only buffer non-empty lines
|
||||
if cleaned:
|
||||
self.output_buffer.append(cleaned)
|
||||
|
||||
def flush_output_buffer(self):
|
||||
"""Emit buffered lines as a batch"""
|
||||
if self.output_buffer:
|
||||
self.output_batch_received.emit(self.output_buffer[:])
|
||||
self.output_buffer.clear()
|
||||
self.last_emit_time = time.time()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
# Emit startup message
|
||||
self.process_and_buffer_line("Initializing TTW installation...")
|
||||
self.flush_output_buffer()
|
||||
|
||||
# Create backend handler
|
||||
filesystem_handler = FileSystemHandler()
|
||||
config_handler = ConfigHandler()
|
||||
ttw_handler = TTWInstallerHandler(
|
||||
steamdeck=False,
|
||||
verbose=False,
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Create temporary output file
|
||||
output_file = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.ttw_output', encoding='utf-8')
|
||||
output_file_path = Path(output_file.name)
|
||||
output_file.close()
|
||||
|
||||
# Start installation via backend (non-blocking)
|
||||
self.process_and_buffer_line("Starting TTW installation...")
|
||||
self.flush_output_buffer()
|
||||
|
||||
self.proc, error_msg = ttw_handler.start_ttw_installation(
|
||||
Path(self.mpi_path),
|
||||
Path(self.install_dir),
|
||||
output_file_path
|
||||
)
|
||||
|
||||
if not self.proc:
|
||||
self.installation_finished.emit(False, error_msg or "Failed to start TTW installation")
|
||||
return
|
||||
|
||||
self.process_and_buffer_line("TTW_Linux_Installer process started, monitoring output...")
|
||||
self.flush_output_buffer()
|
||||
|
||||
# Poll output file with batching for UI responsiveness
|
||||
last_position = 0
|
||||
BATCH_INTERVAL = 0.3 # Emit batches every 300ms
|
||||
|
||||
while self.proc.poll() is None:
|
||||
if self.cancelled:
|
||||
break
|
||||
|
||||
try:
|
||||
# Read new content from file
|
||||
with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
f.seek(last_position)
|
||||
new_lines = f.readlines()
|
||||
last_position = f.tell()
|
||||
|
||||
# Process lines in worker thread (heavy work done here, not UI thread)
|
||||
for line in new_lines:
|
||||
if self.cancelled:
|
||||
break
|
||||
self.process_and_buffer_line(line.rstrip())
|
||||
|
||||
# Emit batch if enough time has passed
|
||||
current_time = time.time()
|
||||
if current_time - self.last_emit_time >= BATCH_INTERVAL:
|
||||
self.flush_output_buffer()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sleep longer since we're batching
|
||||
time.sleep(0.1)
|
||||
|
||||
# Read any remaining output
|
||||
try:
|
||||
with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
f.seek(last_position)
|
||||
remaining_lines = f.readlines()
|
||||
for line in remaining_lines:
|
||||
self.process_and_buffer_line(line.rstrip())
|
||||
self.flush_output_buffer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clean up
|
||||
try:
|
||||
output_file_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ttw_handler.cleanup_ttw_process(self.proc)
|
||||
|
||||
# Check result
|
||||
returncode = self.proc.returncode if self.proc else -1
|
||||
if self.cancelled:
|
||||
self.installation_finished.emit(False, "Installation cancelled by user")
|
||||
elif returncode == 0:
|
||||
self.installation_finished.emit(True, "TTW installation completed successfully!")
|
||||
else:
|
||||
self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self.installation_finished.emit(False, f"Installation error: {str(e)}")
|
||||
|
||||
# Start the installation thread
|
||||
from .install_ttw_thread import TTWInstallationThread
|
||||
self.install_thread = TTWInstallationThread(mpi_path, install_dir)
|
||||
# Use QueuedConnection to ensure signals are processed asynchronously and don't block UI
|
||||
self.install_thread.output_batch_received.connect(self.on_installation_output_batch, Qt.QueuedConnection)
|
||||
self.install_thread.progress_received.connect(self.on_installation_progress, Qt.QueuedConnection)
|
||||
self.install_thread.installation_finished.connect(self.on_installation_finished, Qt.QueuedConnection)
|
||||
|
||||
# Start thread and immediately process events to show initial UI state
|
||||
self.install_thread.start()
|
||||
QApplication.processEvents() # Process any pending events to update UI immediately
|
||||
|
||||
def on_installation_output_batch(self, messages):
|
||||
"""Handle batched output from TTW_Linux_Installer (already processed in worker thread)"""
|
||||
# Lines are already cleaned (ANSI codes stripped, emojis removed) in worker thread
|
||||
# CRITICAL: Accumulate all console updates and do ONE widget update per batch
|
||||
|
||||
if not hasattr(self, '_ttw_seen_lines'):
|
||||
self._ttw_seen_lines = set()
|
||||
self._ttw_current_phase = None
|
||||
self._ttw_last_progress = 0
|
||||
self._ttw_last_activity_update = 0
|
||||
self.ttw_start_time = time.time()
|
||||
|
||||
# Accumulate lines to display (do ONE console update at end)
|
||||
lines_to_display = []
|
||||
html_fragments = []
|
||||
show_details_due_to_error = False
|
||||
latest_progress = None # Track latest progress to update activity ONCE per batch
|
||||
|
||||
for cleaned in messages:
|
||||
if not cleaned:
|
||||
continue
|
||||
|
||||
lower_cleaned = cleaned.lower()
|
||||
|
||||
# Extract progress (but don't update UI yet - wait until end of batch)
|
||||
try:
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
latest_progress = (current, total, percent)
|
||||
|
||||
if 'loading manifest:' in lower_cleaned:
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine if we should show this line
|
||||
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
|
||||
is_warning = 'warning:' in lower_cleaned
|
||||
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
|
||||
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
||||
|
||||
# Filter out meaningless standalone messages (just "OK", etc.)
|
||||
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
||||
|
||||
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
|
||||
|
||||
if should_show:
|
||||
if is_error or is_warning:
|
||||
color = '#f44336' if is_error else '#ff9800'
|
||||
prefix = "WARNING: " if is_warning else "ERROR: "
|
||||
escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
html_fragments.append(f'<span style="color: {color};">{escaped}</span>')
|
||||
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 = '<br>'.join(html_fragments)
|
||||
self.console.insertHtml(combined_html + '<br>')
|
||||
|
||||
if lines_to_display:
|
||||
combined_text = '\n'.join(lines_to_display)
|
||||
self.console.append(combined_text)
|
||||
|
||||
if show_details_due_to_error and not self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.setChecked(True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_installation_output(self, message):
|
||||
"""Handle regular output from TTW_Linux_Installer with comprehensive filtering and smart parsing"""
|
||||
# Initialize tracking structures
|
||||
if not hasattr(self, '_ttw_seen_lines'):
|
||||
self._ttw_seen_lines = set()
|
||||
self._ttw_last_extraction_progress = 0
|
||||
self._ttw_last_file_operation_time = 0
|
||||
self._ttw_file_operation_count = 0
|
||||
self._ttw_current_phase = None
|
||||
self._ttw_last_progress_line = None
|
||||
self._ttw_progress_line_text = None
|
||||
|
||||
# Filter out internal status messages from user console
|
||||
if message.strip().startswith('[Jackify]'):
|
||||
# Log internal messages to file but don't show in console
|
||||
self._write_to_log_file(message)
|
||||
return
|
||||
|
||||
# Strip ANSI terminal control codes
|
||||
cleaned = strip_ansi_control_codes(message).strip()
|
||||
|
||||
# Strip emojis from output (TTW_Linux_Installer includes emojis)
|
||||
# Use character-by-character filtering to avoid regex recursion issues
|
||||
# Safer than regex for emoji removal
|
||||
filtered_chars = []
|
||||
for char in cleaned:
|
||||
code = ord(char)
|
||||
# Check if character is in emoji ranges - skip emojis
|
||||
is_emoji = (
|
||||
(0x1F300 <= code <= 0x1F9FF) or # Miscellaneous Symbols and Pictographs
|
||||
(0x1F600 <= code <= 0x1F64F) or # Emoticons
|
||||
(0x2600 <= code <= 0x26FF) or # Miscellaneous Symbols
|
||||
(0x2700 <= code <= 0x27BF) # Dingbats
|
||||
)
|
||||
if not is_emoji:
|
||||
filtered_chars.append(char)
|
||||
cleaned = ''.join(filtered_chars).strip()
|
||||
|
||||
# Filter out empty lines
|
||||
if not cleaned:
|
||||
return
|
||||
|
||||
# Initialize start time if not set
|
||||
if not hasattr(self, 'ttw_start_time'):
|
||||
self.ttw_start_time = time.time()
|
||||
|
||||
lower_cleaned = cleaned.lower()
|
||||
|
||||
# === MINIMAL PROCESSING: Match standalone behavior as closely as possible ===
|
||||
# When running standalone: output goes directly to terminal, no processing
|
||||
# Here: We must process each line, but do it as efficiently as possible
|
||||
|
||||
# Always log to file (simple, no recursion risk)
|
||||
try:
|
||||
self._write_to_log_file(cleaned)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract progress for Activity window (minimal regex, wrapped in try/except)
|
||||
try:
|
||||
# Try [X/Y] pattern
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
phase = self._ttw_current_phase or "Processing"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
|
||||
# Try "Loading manifest: X/Y"
|
||||
if 'loading manifest:' in lower_cleaned:
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
self._ttw_current_phase = "Loading manifest"
|
||||
self._update_ttw_activity(current, total, percent)
|
||||
except Exception:
|
||||
pass # Skip if regex fails
|
||||
|
||||
# Determine if we should show this line
|
||||
# By default: only show errors, warnings, milestones
|
||||
# Everything else: only in details mode
|
||||
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
|
||||
is_warning = 'warning:' in lower_cleaned
|
||||
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
|
||||
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
||||
|
||||
# Filter out meaningless standalone messages (just "OK", etc.)
|
||||
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
||||
|
||||
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
|
||||
|
||||
if should_show:
|
||||
# Direct console append - no recursion, no complex processing
|
||||
try:
|
||||
if is_error or is_warning:
|
||||
# Color code errors/warnings
|
||||
color = '#f44336' if is_error else '#ff9800'
|
||||
prefix = "WARNING: " if is_warning else "ERROR: "
|
||||
escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
html = f'<span style="color: {color};">{escaped}</span><br>'
|
||||
self.console.insertHtml(html)
|
||||
if not self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.setChecked(True)
|
||||
else:
|
||||
self.console.append(cleaned)
|
||||
except Exception:
|
||||
pass # Don't break on console errors
|
||||
|
||||
return
|
||||
|
||||
def on_installation_progress(self, progress_message):
|
||||
"""Replace the last line in the console for progress updates"""
|
||||
cursor = self.console.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor)
|
||||
cursor.removeSelectedText()
|
||||
cursor.insertText(progress_message)
|
||||
# Don't force scroll for progress updates - let user control
|
||||
QApplication.processEvents()
|
||||
|
||||
def on_installation_finished(self, success, message):
|
||||
"""Handle installation completion"""
|
||||
debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
||||
"""Handle installation completion."""
|
||||
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
||||
|
||||
# Stop elapsed timer
|
||||
if hasattr(self, 'ttw_elapsed_timer'):
|
||||
self.ttw_elapsed_timer.stop()
|
||||
|
||||
# Update status banner
|
||||
if success:
|
||||
elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0
|
||||
minutes = elapsed // 60
|
||||
seconds = elapsed % 60
|
||||
self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s")
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
self.status_banner.setStyleSheet("""
|
||||
background-color: #1a4d1a;
|
||||
color: #4CAF50;
|
||||
padding: 8px;
|
||||
@@ -620,7 +200,7 @@ class TTWWorkflowMixin:
|
||||
self.process_finished(0, QProcess.NormalExit)
|
||||
else:
|
||||
self.status_banner.setText(f"Installation failed: {message}")
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
self.status_banner.setStyleSheet("""
|
||||
background-color: #4d1a1a;
|
||||
color: #f44336;
|
||||
padding: 8px;
|
||||
@@ -632,32 +212,28 @@ class TTWWorkflowMixin:
|
||||
self.process_finished(1, QProcess.CrashExit)
|
||||
|
||||
def process_finished(self, exit_code, exit_status):
|
||||
debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
# Reset button states
|
||||
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
debug_print("DEBUG: Button states reset in process_finished")
|
||||
|
||||
logger.debug("DEBUG: Button states reset in process_finished")
|
||||
|
||||
if exit_code == 0:
|
||||
# TTW installation complete
|
||||
self._safe_append_text("\nTTW installation completed successfully!")
|
||||
self._safe_append_text("The merged TTW files have been created in the output directory.")
|
||||
|
||||
# Check if we're in modlist integration mode
|
||||
if self._integration_mode:
|
||||
self._safe_append_text("\nIntegrating TTW into modlist...")
|
||||
self._perform_modlist_integration()
|
||||
else:
|
||||
# Standard mode - ask user if they want to create a mod archive for MO2
|
||||
reply = MessageService.question(
|
||||
self, "TTW Installation Complete!",
|
||||
"Tale of Two Wastelands installation completed successfully!\n\n"
|
||||
f"Output location: {self.install_dir_edit.text()}\n\n"
|
||||
"Would you like to create a zipped mod archive for MO2?\n"
|
||||
"This will package the TTW files for easy installation into Mod Organizer 2.",
|
||||
critical=False
|
||||
critical=False,
|
||||
safety_level="medium",
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
@@ -670,12 +246,10 @@ class TTWWorkflowMixin:
|
||||
safety_level="medium"
|
||||
)
|
||||
else:
|
||||
# Check for user cancellation first
|
||||
last_output = self.console.toPlainText()
|
||||
if "cancelled by user" in last_output.lower():
|
||||
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
|
||||
else:
|
||||
MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.")
|
||||
MessageService.show_error(self, wabbajack_install_failed(f"Exit code {exit_code}. Check the console output for details."))
|
||||
self._safe_append_text(f"\nInstall failed (exit code {exit_code}).")
|
||||
self.console.moveCursor(QTextCursor.End)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from PySide6.QtCore import Qt, QThread, Signal, QSize
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.shared.errors import wabbajack_install_failed
|
||||
from ..services.message_service import MessageService
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
||||
from ..utils import set_responsive_minimum
|
||||
@@ -362,7 +363,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
)
|
||||
|
||||
if folder:
|
||||
self.install_folder = Path(folder)
|
||||
self.install_folder = Path(folder).resolve()
|
||||
self.install_dir_edit.setText(str(self.install_folder))
|
||||
self.start_btn.setEnabled(True)
|
||||
|
||||
@@ -377,7 +378,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
MessageService.warning(self, "No Folder Selected", "Please select an installation folder first.")
|
||||
return
|
||||
|
||||
self.install_folder = Path(install_dir_text)
|
||||
self.install_folder = Path(install_dir_text).resolve()
|
||||
|
||||
# Get shortcut name
|
||||
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
|
||||
@@ -390,7 +391,8 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
"This will download Wabbajack, add to Steam, install WebView2,\n"
|
||||
"and configure the Wine prefix automatically.\n\n"
|
||||
"Steam will be restarted during installation.\n\n"
|
||||
"Continue?"
|
||||
"Continue?",
|
||||
safety_level="medium",
|
||||
)
|
||||
|
||||
if confirm != QMessageBox.Yes:
|
||||
@@ -555,7 +557,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
self.cancel_btn.setEnabled(True)
|
||||
else:
|
||||
self.progress_indicator.set_status("Installation failed", 0)
|
||||
MessageService.critical(self, "Installation Failed", message)
|
||||
MessageService.show_error(self, wabbajack_install_failed(message))
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setEnabled(True)
|
||||
|
||||
|
||||
@@ -6,8 +6,13 @@ Provides message boxes that don't steal focus from the current application
|
||||
import random
|
||||
import string
|
||||
from typing import Optional
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QCheckBox
|
||||
from PySide6.QtWidgets import (
|
||||
QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout,
|
||||
QCheckBox, QTextEdit, QPushButton, QDialog, QDialogButtonBox, QSizePolicy,
|
||||
QStyle,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
|
||||
class NonFocusMessageBox(QMessageBox):
|
||||
@@ -118,6 +123,7 @@ class SafeMessageBox(NonFocusMessageBox):
|
||||
|
||||
def _setup_medium_safety(self, danger_action: str, safe_action: str):
|
||||
"""Medium safety: requires wait period"""
|
||||
self._danger_action_text = danger_action
|
||||
self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole)
|
||||
self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole)
|
||||
self.setDefaultButton(self.cancel_btn)
|
||||
@@ -143,7 +149,8 @@ class SafeMessageBox(NonFocusMessageBox):
|
||||
if self.safety_level == "high":
|
||||
self.proceed_btn.setText(f"Please wait {self.countdown_remaining}s...")
|
||||
else:
|
||||
self.proceed_btn.setText(f"OK ({self.countdown_remaining}s)")
|
||||
action_label = getattr(self, "_danger_action_text", "OK")
|
||||
self.proceed_btn.setText(f"{action_label} ({self.countdown_remaining}s)")
|
||||
self.proceed_btn.setEnabled(False)
|
||||
if hasattr(self, 'cancel_btn'):
|
||||
self.cancel_btn.setEnabled(False)
|
||||
@@ -154,7 +161,7 @@ class SafeMessageBox(NonFocusMessageBox):
|
||||
if self.safety_level == "high":
|
||||
self.proceed_btn.setText("Proceed")
|
||||
else:
|
||||
self.proceed_btn.setText("OK")
|
||||
self.proceed_btn.setText(getattr(self, "_danger_action_text", "OK"))
|
||||
self.proceed_btn.setEnabled(True)
|
||||
if hasattr(self, 'cancel_btn'):
|
||||
self.cancel_btn.setEnabled(True)
|
||||
@@ -284,4 +291,147 @@ class MessageService:
|
||||
clicked = msg_box.clickedButton()
|
||||
if clicked and clicked.text() == "Yes":
|
||||
return QMessageBox.Yes
|
||||
return QMessageBox.No
|
||||
return QMessageBox.No
|
||||
|
||||
@staticmethod
|
||||
def show_error(parent: Optional[QWidget], error) -> None:
|
||||
"""Show a structured error dialog for a JackifyError.
|
||||
|
||||
Displays title, plain-English message, optional "what to do" suggestion,
|
||||
and an optional collapsible technical detail pane.
|
||||
|
||||
Args:
|
||||
parent: Parent widget (may be None).
|
||||
error: A JackifyError instance (imported inside to preserve
|
||||
backend/frontend separation).
|
||||
"""
|
||||
from jackify.shared.errors import JackifyError
|
||||
|
||||
if not isinstance(error, JackifyError):
|
||||
# Fallback for plain exceptions
|
||||
dialog = _ErrorDialog(parent, str(error), str(error), None, [], None)
|
||||
dialog.exec()
|
||||
return
|
||||
|
||||
dialog = _ErrorDialog(
|
||||
parent,
|
||||
error.title,
|
||||
error.message,
|
||||
error.suggestion,
|
||||
getattr(error, 'solutions', []),
|
||||
error.technical,
|
||||
)
|
||||
dialog.exec()
|
||||
|
||||
|
||||
class _ErrorDialog(QDialog):
|
||||
"""Internal dialog used by MessageService.show_error()."""
|
||||
|
||||
_DETAIL_HEIGHT = 140
|
||||
|
||||
def __init__(self, parent, title: str, message: str,
|
||||
suggestion: Optional[str], solutions, technical: Optional[str]):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(title)
|
||||
self.setWindowModality(Qt.ApplicationModal)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose)
|
||||
self._technical = technical
|
||||
self._detail_visible = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# Icon + message row
|
||||
icon_label = QLabel()
|
||||
icon_label.setPixmap(
|
||||
self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxCritical).pixmap(32, 32)
|
||||
)
|
||||
icon_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
|
||||
msg_label = QLabel(message)
|
||||
msg_label.setWordWrap(True)
|
||||
msg_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
|
||||
top_row = QHBoxLayout()
|
||||
top_row.addWidget(icon_label)
|
||||
top_row.addWidget(msg_label, 1)
|
||||
layout.addLayout(top_row)
|
||||
|
||||
# Suggestion row
|
||||
if suggestion:
|
||||
sug_label = QLabel(f"What to do: {suggestion}")
|
||||
sug_label.setWordWrap(True)
|
||||
sug_label.setStyleSheet("color: #aaaaaa; padding-left: 42px;")
|
||||
layout.addWidget(sug_label)
|
||||
|
||||
# Numbered solutions list
|
||||
if solutions:
|
||||
steps_label = QLabel("Things to try:")
|
||||
steps_label.setStyleSheet("color: #cccccc; padding-left: 42px; font-weight: bold;")
|
||||
layout.addWidget(steps_label)
|
||||
for i, step in enumerate(solutions, start=1):
|
||||
step_label = QLabel(f" {i}. {step}")
|
||||
step_label.setWordWrap(True)
|
||||
step_label.setStyleSheet("color: #aaaaaa; padding-left: 52px;")
|
||||
layout.addWidget(step_label)
|
||||
|
||||
# Technical detail toggle
|
||||
if technical:
|
||||
self._toggle_btn = QPushButton("Show technical detail")
|
||||
self._toggle_btn.setCheckable(False)
|
||||
self._toggle_btn.setStyleSheet(
|
||||
"QPushButton { text-align: left; border: none; color: #888888; "
|
||||
"padding: 0; font-size: 11px; } "
|
||||
"QPushButton:hover { color: #cccccc; }"
|
||||
)
|
||||
self._toggle_btn.clicked.connect(self._toggle_detail)
|
||||
layout.addWidget(self._toggle_btn)
|
||||
|
||||
self._detail_edit = QTextEdit()
|
||||
self._detail_edit.setReadOnly(True)
|
||||
self._detail_edit.setPlainText(technical)
|
||||
mono = QFont("Monospace")
|
||||
mono.setStyleHint(QFont.TypeWriter)
|
||||
self._detail_edit.setFont(mono)
|
||||
self._detail_edit.setStyleSheet(
|
||||
"background-color: #1a1a1a; color: #cccccc; "
|
||||
"border: 1px solid #333333; border-radius: 4px;"
|
||||
)
|
||||
self._detail_edit.setFixedHeight(self._DETAIL_HEIGHT)
|
||||
self._detail_edit.hide()
|
||||
layout.addWidget(self._detail_edit)
|
||||
|
||||
# OK button — disabled for 3s to prevent accidental dismissal
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok)
|
||||
buttons.accepted.connect(self.accept)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self._ok_btn = buttons.button(QDialogButtonBox.Ok)
|
||||
self._ok_countdown = 3
|
||||
self._ok_btn.setEnabled(False)
|
||||
self._ok_btn.setText(f"OK ({self._ok_countdown}s)")
|
||||
self._ok_timer = QTimer(self)
|
||||
self._ok_timer.timeout.connect(self._tick_ok_countdown)
|
||||
self._ok_timer.start(1000)
|
||||
|
||||
self.setMinimumWidth(440)
|
||||
self.adjustSize()
|
||||
|
||||
def _tick_ok_countdown(self):
|
||||
self._ok_countdown -= 1
|
||||
if self._ok_countdown > 0:
|
||||
self._ok_btn.setText(f"OK ({self._ok_countdown}s)")
|
||||
else:
|
||||
self._ok_timer.stop()
|
||||
self._ok_btn.setText("OK")
|
||||
self._ok_btn.setEnabled(True)
|
||||
|
||||
def _toggle_detail(self):
|
||||
self._detail_visible = not self._detail_visible
|
||||
if self._detail_visible:
|
||||
self._detail_edit.show()
|
||||
self._toggle_btn.setText("Hide technical detail")
|
||||
else:
|
||||
self._detail_edit.hide()
|
||||
self._toggle_btn.setText("Show technical detail")
|
||||
self.adjustSize()
|
||||
|
||||
@@ -5,7 +5,7 @@ File progress item widget for a single file's progress display.
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QHBoxLayout, QLabel, QProgressBar, QSizePolicy
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||
@@ -17,13 +17,8 @@ class FileProgressItem(QWidget):
|
||||
def __init__(self, file_progress: FileProgress, parent=None):
|
||||
super().__init__(parent)
|
||||
self.file_progress = file_progress
|
||||
self._target_percent = file_progress.percent
|
||||
self._current_display_percent = file_progress.percent
|
||||
self._spinner_position = 0
|
||||
self._is_indeterminate = False
|
||||
self._animation_timer = QTimer(self)
|
||||
self._animation_timer.timeout.connect(self._animate_progress)
|
||||
self._animation_timer.setInterval(16)
|
||||
self._is_queued = False
|
||||
self._setup_ui()
|
||||
self._update_display()
|
||||
|
||||
@@ -73,22 +68,24 @@ class FileProgressItem(QWidget):
|
||||
def _get_operation_symbol(self) -> str:
|
||||
symbols = {
|
||||
OperationType.DOWNLOAD: "↓",
|
||||
OperationType.EXTRACT: "↻",
|
||||
OperationType.EXTRACT: "↻",
|
||||
OperationType.VALIDATE: "✓",
|
||||
OperationType.INSTALL: "→",
|
||||
OperationType.INSTALL: "→",
|
||||
}
|
||||
return symbols.get(self.file_progress.operation, "•")
|
||||
|
||||
def _truncate_filename(self, filename: str, max_length: int = 40) -> str:
|
||||
if len(filename) <= max_length:
|
||||
return filename
|
||||
return filename[:max_length-3] + "..."
|
||||
return filename[:max_length - 3] + "..."
|
||||
|
||||
def _update_display(self):
|
||||
is_summary = hasattr(self.file_progress, '_is_summary') and self.file_progress._is_summary
|
||||
no_progress_bar = hasattr(self.file_progress, '_no_progress_bar') and self.file_progress._no_progress_bar
|
||||
is_summary = getattr(self.file_progress, '_is_summary', False)
|
||||
no_progress_bar = getattr(self.file_progress, '_no_progress_bar', False)
|
||||
|
||||
if 'Installing Files' in self.file_progress.filename or 'Converting Texture' in self.file_progress.filename or 'BSA:' in self.file_progress.filename:
|
||||
if ('Installing Files' in self.file_progress.filename
|
||||
or 'Converting Texture' in self.file_progress.filename
|
||||
or 'BSA:' in self.file_progress.filename):
|
||||
name_display = self.file_progress.filename
|
||||
elif self.file_progress.filename.startswith('Wine component:'):
|
||||
rest = self.file_progress.filename.split(':', 1)[1].strip()
|
||||
@@ -106,7 +103,8 @@ class FileProgressItem(QWidget):
|
||||
self.filename_label.setToolTip(self.file_progress.filename)
|
||||
|
||||
if no_progress_bar:
|
||||
self._animation_timer.stop()
|
||||
self._is_indeterminate = False
|
||||
self._is_queued = False
|
||||
self.percent_label.setText("")
|
||||
self.progress_bar.setVisible(False)
|
||||
return
|
||||
@@ -116,80 +114,58 @@ class FileProgressItem(QWidget):
|
||||
if is_summary:
|
||||
summary_step = getattr(self.file_progress, '_summary_step', 0)
|
||||
summary_max = getattr(self.file_progress, '_summary_max', 0)
|
||||
|
||||
self._is_queued = False
|
||||
if summary_max > 0:
|
||||
percent = (summary_step / summary_max) * 100.0
|
||||
self._target_percent = max(0, min(100, percent))
|
||||
if not self._animation_timer.isActive():
|
||||
self._animation_timer.start()
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self._set_determinate((summary_step / summary_max) * 100.0)
|
||||
else:
|
||||
self._is_indeterminate = True
|
||||
self.percent_label.setText("")
|
||||
self.progress_bar.setRange(0, 100)
|
||||
if not self._animation_timer.isActive():
|
||||
self._animation_timer.start()
|
||||
self._set_indeterminate()
|
||||
return
|
||||
|
||||
is_queued = (
|
||||
self.file_progress.total_size > 0 and
|
||||
self.file_progress.percent == 0 and
|
||||
self.file_progress.current_size == 0 and
|
||||
self.file_progress.speed <= 0
|
||||
self.file_progress.total_size > 0
|
||||
and self.file_progress.percent == 0
|
||||
and self.file_progress.current_size == 0
|
||||
and self.file_progress.speed <= 0
|
||||
)
|
||||
|
||||
if is_queued:
|
||||
self._is_queued = True
|
||||
self._is_indeterminate = False
|
||||
self._animation_timer.stop()
|
||||
self.percent_label.setText("Queued")
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
return
|
||||
|
||||
self._is_queued = False
|
||||
has_meaningful_progress = (
|
||||
self.file_progress.percent > 0 or
|
||||
(self.file_progress.total_size > 0 and self.file_progress.current_size > 0) or
|
||||
(self.file_progress.speed > 0 and self.file_progress.percent >= 0)
|
||||
self.file_progress.percent > 0
|
||||
or (self.file_progress.total_size > 0 and self.file_progress.current_size > 0)
|
||||
or (self.file_progress.speed > 0 and self.file_progress.percent >= 0)
|
||||
)
|
||||
|
||||
if has_meaningful_progress:
|
||||
self._is_indeterminate = False
|
||||
self._target_percent = max(0, self.file_progress.percent)
|
||||
if not self._animation_timer.isActive():
|
||||
self._animation_timer.start()
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self._set_determinate(max(0.0, self.file_progress.percent))
|
||||
else:
|
||||
self._set_indeterminate()
|
||||
|
||||
def _set_indeterminate(self):
|
||||
if not self._is_indeterminate:
|
||||
self._is_indeterminate = True
|
||||
self.percent_label.setText("")
|
||||
self.progress_bar.setRange(0, 100)
|
||||
if not self._animation_timer.isActive():
|
||||
self._animation_timer.start()
|
||||
# Qt's QProgressStyleAnimation drives this automatically — no manual timer needed
|
||||
self.progress_bar.setRange(0, 0)
|
||||
self.percent_label.setText("")
|
||||
|
||||
def _animate_progress(self):
|
||||
def _set_determinate(self, percent: float):
|
||||
if self._is_indeterminate:
|
||||
self._spinner_position = (self._spinner_position + 4) % 200
|
||||
if self._spinner_position < 100:
|
||||
display_value = self._spinner_position
|
||||
else:
|
||||
display_value = 200 - self._spinner_position
|
||||
self.progress_bar.setValue(display_value)
|
||||
self._is_indeterminate = False
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(int(max(0.0, min(100.0, percent))))
|
||||
if self.file_progress.percent > 0:
|
||||
self.percent_label.setText(f"{percent:.0f}%")
|
||||
else:
|
||||
diff = self._target_percent - self._current_display_percent
|
||||
if abs(diff) >= 0.1:
|
||||
self._current_display_percent += diff * 0.2
|
||||
self._current_display_percent = max(0, min(100, self._current_display_percent))
|
||||
|
||||
display_percent = self._current_display_percent
|
||||
self.progress_bar.setValue(int(display_percent))
|
||||
if self.file_progress.percent > 0:
|
||||
self.percent_label.setText(f"{display_percent:.0f}%")
|
||||
else:
|
||||
self.percent_label.setText("")
|
||||
self.percent_label.setText("")
|
||||
|
||||
def update_progress(self, file_progress: FileProgress):
|
||||
self.file_progress = file_progress
|
||||
self._update_display()
|
||||
|
||||
def cleanup(self):
|
||||
if self._animation_timer.isActive():
|
||||
self._animation_timer.stop()
|
||||
pass
|
||||
|
||||
@@ -12,9 +12,9 @@ import time
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QProgressBar, QHBoxLayout, QSizePolicy
|
||||
QHBoxLayout, QSizePolicy
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSize, QTimer
|
||||
from PySide6.QtCore import Qt, QSize, QTimer, QThread, Signal
|
||||
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
|
||||
@@ -24,11 +24,95 @@ from .file_progress_item import FileProgressItem
|
||||
__all__ = ['SummaryProgressWidget', 'FileProgressItem', 'FileProgressList']
|
||||
|
||||
|
||||
class _CpuWorker(QThread):
|
||||
"""Background worker for CPU usage sampling — keeps psutil off the main thread."""
|
||||
result = Signal(str)
|
||||
caches_updated = Signal(object, object, float) # process_cache, child_cache, smoothed_pct
|
||||
|
||||
def __init__(self, last_pct, process_cache, child_cache):
|
||||
super().__init__()
|
||||
self._last_pct = last_pct
|
||||
self._process_cache = process_cache
|
||||
self._child_cache = dict(child_cache) if child_cache else {}
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
import psutil, os
|
||||
|
||||
if self._process_cache is None:
|
||||
self._process_cache = psutil.Process(os.getpid())
|
||||
# Establish baseline (blocking, but only once and in background)
|
||||
self._process_cache.cpu_percent(interval=0.1)
|
||||
|
||||
num_cpus = psutil.cpu_count() or 1
|
||||
total_cpu = self._process_cache.cpu_percent(interval=None) / num_cpus
|
||||
|
||||
current_child_pids = set()
|
||||
try:
|
||||
for child in self._process_cache.children(recursive=True):
|
||||
try:
|
||||
current_child_pids.add(child.pid)
|
||||
if child.pid not in self._child_cache:
|
||||
# Baseline in background — no longer blocks main thread
|
||||
child.cpu_percent(interval=0.1)
|
||||
self._child_cache[child.pid] = child
|
||||
continue
|
||||
total_cpu += self._child_cache[child.pid].cpu_percent(interval=None) / num_cpus
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
for pid in set(self._child_cache.keys()) - current_child_pids:
|
||||
del self._child_cache[pid]
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
jackify_names = [
|
||||
'jackify-engine', 'texconv', 'texdiag', 'directxtex',
|
||||
'texconv_jackify', 'texdiag_jackify', 'directxtex_jackify',
|
||||
'7z', '7zz', 'bsarch', 'wine', 'wine64', 'wine64-preloader',
|
||||
'steam-run', 'proton',
|
||||
]
|
||||
tracked_pids = {self._process_cache.pid} | current_child_pids
|
||||
try:
|
||||
for proc in psutil.process_iter(['name', 'pid', 'cmdline']):
|
||||
try:
|
||||
if proc.pid in tracked_pids:
|
||||
continue
|
||||
proc_name = proc.info.get('name', '').lower()
|
||||
cmdline_str = ' '.join(proc.info.get('cmdline', []) or []).lower()
|
||||
is_jackify = any(n in proc_name for n in jackify_names)
|
||||
if not is_jackify and cmdline_str:
|
||||
is_jackify = any(n in cmdline_str for n in jackify_names)
|
||||
if not is_jackify:
|
||||
is_jackify = any(f'{n}.exe' in cmdline_str for n in jackify_names)
|
||||
if not is_jackify:
|
||||
is_jackify = 'jackify' in cmdline_str and any(
|
||||
t in cmdline_str for t in ['engine', 'tools', 'binaries']
|
||||
)
|
||||
if is_jackify:
|
||||
if proc.pid not in self._child_cache:
|
||||
proc.cpu_percent(interval=0.1)
|
||||
self._child_cache[proc.pid] = proc
|
||||
continue
|
||||
total_cpu += self._child_cache[proc.pid].cpu_percent(interval=None) / num_cpus
|
||||
tracked_pids.add(proc.pid)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._last_pct > 0:
|
||||
total_cpu = self._last_pct * 0.3 + total_cpu * 0.7
|
||||
display = min(100.0, total_cpu)
|
||||
self.result.emit(f"CPU: {display:.0f}%")
|
||||
self.caches_updated.emit(self._process_cache, self._child_cache, total_cpu)
|
||||
|
||||
except Exception:
|
||||
self.result.emit("")
|
||||
|
||||
|
||||
def _debug_log(message):
|
||||
"""Log message only if debug mode is enabled"""
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
if ConfigHandler().get('debug_mode', False):
|
||||
print(message)
|
||||
|
||||
|
||||
@@ -37,49 +121,36 @@ class FileProgressList(QWidget):
|
||||
Widget displaying a list of files currently being processed.
|
||||
Shows individual progress for each file.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
Initialize file progress list.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._file_items: dict[str, FileProgressItem] = {}
|
||||
self._summary_widget: Optional[SummaryProgressWidget] = None
|
||||
self._last_phase: Optional[str] = None # Track phase changes for transition messages
|
||||
self._transition_label: Optional[QLabel] = None # Label for "Preparing..." message
|
||||
self._last_summary_time: float = 0.0 # Track when summary widget was last shown
|
||||
self._summary_hold_duration: float = 0.5 # Hold summary for minimum 0.5s to prevent flicker
|
||||
self._last_summary_update: float = 0.0 # Track last summary update for throttling
|
||||
self._summary_update_interval: float = 0.1 # Update summary every 100ms (simple throttling)
|
||||
|
||||
self._last_phase: Optional[str] = None
|
||||
self._transition_label: Optional[QLabel] = None
|
||||
self._last_summary_time: float = 0.0
|
||||
self._summary_hold_duration: float = 0.5
|
||||
self._last_summary_update: float = 0.0
|
||||
self._summary_update_interval: float = 0.1
|
||||
|
||||
self._setup_ui()
|
||||
# Set size policy to match Process Monitor - expand to fill available space
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI - match Process Monitor layout structure exactly."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(2) # Match Process Monitor spacing (was 4, now 2)
|
||||
layout.setSpacing(2)
|
||||
|
||||
# Header row with CPU usage only (tab label replaces "[Activity]" header)
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.setSpacing(8)
|
||||
|
||||
# CPU usage indicator (right-aligned)
|
||||
self.cpu_label = QLabel("")
|
||||
self.cpu_label.setStyleSheet("color: #888; font-size: 11px; margin-bottom: 2px;")
|
||||
self.cpu_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
header_layout.addStretch() # Push CPU label to the right
|
||||
header_layout.addStretch()
|
||||
header_layout.addWidget(self.cpu_label, 0)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# List widget for file items - match Process Monitor size constraints
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setStyleSheet("""
|
||||
QListWidget {
|
||||
@@ -95,86 +166,55 @@ class FileProgressList(QWidget):
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
""")
|
||||
# Match Process Monitor minimum size: QSize(300, 20)
|
||||
self.list_widget.setMinimumSize(QSize(300, 20))
|
||||
# Match Process Monitor - no maximum height constraint, expand to fill available space
|
||||
# The list will scroll if there are more items than can fit
|
||||
self.list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
# Match Process Monitor size policy - expand to fill available space
|
||||
self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
layout.addWidget(self.list_widget, stretch=1) # Match Process Monitor stretch
|
||||
|
||||
# Throttle timer for updates when there are many files
|
||||
import time
|
||||
layout.addWidget(self.list_widget, stretch=1)
|
||||
|
||||
self._last_update_time = 0.0
|
||||
|
||||
# CPU usage tracking
|
||||
# CPU usage tracking — worker thread to avoid blocking the main thread
|
||||
self._cpu_timer = QTimer(self)
|
||||
self._cpu_timer.timeout.connect(self._update_cpu_usage)
|
||||
self._cpu_timer.setInterval(2000) # Update every 2 seconds
|
||||
self._cpu_timer.timeout.connect(self._start_cpu_worker)
|
||||
self._cpu_timer.setInterval(2000)
|
||||
self._last_cpu_percent = 0.0
|
||||
self._cpu_process_cache = None # Cache the process object for better performance
|
||||
self._child_process_cache = {} # Cache child Process objects by PID for persistent CPU tracking
|
||||
|
||||
self._cpu_process_cache = None
|
||||
self._child_process_cache = {}
|
||||
self._cpu_worker = None
|
||||
|
||||
def update_files(self, file_progresses: list[FileProgress], current_phase: str = None, summary_info: dict = None):
|
||||
"""
|
||||
Update the list with current file progresses.
|
||||
|
||||
Args:
|
||||
file_progresses: List of FileProgress objects for active files
|
||||
current_phase: Optional phase name to display in header (e.g., "Downloading", "Extracting")
|
||||
summary_info: Optional dict with 'current_step' and 'max_steps' for summary display (e.g., Installing phase)
|
||||
"""
|
||||
# Throttle updates to prevent UI freezing with many files
|
||||
# If we have many files (>50), throttle updates to every 100ms
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
# Throttle for large file lists
|
||||
if len(file_progresses) > 50:
|
||||
if current_time - self._last_update_time < 0.1: # 100ms throttle
|
||||
return # Skip this update
|
||||
if current_time - self._last_update_time < 0.1:
|
||||
return
|
||||
self._last_update_time = current_time
|
||||
|
||||
# If we have summary info (e.g., Installing phase), show summary widget instead of file list
|
||||
|
||||
# Summary widget path (Installing phase etc.)
|
||||
if summary_info and not file_progresses:
|
||||
current_time = time.time()
|
||||
|
||||
# Get new values
|
||||
current_step = summary_info.get('current_step', 0)
|
||||
max_steps = summary_info.get('max_steps', 0)
|
||||
phase_name = current_phase or "Installing files"
|
||||
max_steps = summary_info.get('max_steps', 0)
|
||||
phase_name = current_phase or "Installing files"
|
||||
|
||||
# Check if summary widget already exists and is valid
|
||||
summary_widget_valid = self._summary_widget and shiboken6.isValid(self._summary_widget)
|
||||
if not summary_widget_valid:
|
||||
self._summary_widget = None
|
||||
|
||||
# If widget exists, check if we should throttle the update
|
||||
if self._summary_widget:
|
||||
# Throttle updates to prevent flickering with rapidly changing counters
|
||||
if current_time - self._last_summary_update < self._summary_update_interval:
|
||||
return # Skip update, too soon
|
||||
|
||||
# Update existing summary widget (no clearing needed)
|
||||
return
|
||||
self._summary_widget.update_progress(current_step, max_steps)
|
||||
# Update phase name if it changed
|
||||
if self._summary_widget.phase_name != phase_name:
|
||||
self._summary_widget.phase_name = phase_name
|
||||
self._summary_widget._update_display()
|
||||
self._last_summary_update = current_time
|
||||
return
|
||||
|
||||
# Widget doesn't exist - create it (only clear when creating new widget)
|
||||
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self._clear_item_widgets()
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
|
||||
# Create new summary widget
|
||||
self._summary_widget = SummaryProgressWidget(phase_name, current_step, max_steps)
|
||||
summary_item = QListWidgetItem()
|
||||
summary_item.setSizeHint(self._summary_widget.sizeHint())
|
||||
@@ -183,453 +223,195 @@ class FileProgressList(QWidget):
|
||||
self.list_widget.setItemWidget(summary_item, self._summary_widget)
|
||||
self._last_summary_time = current_time
|
||||
self._last_summary_update = current_time
|
||||
|
||||
return
|
||||
|
||||
# Clear summary widget and transition label when showing file list
|
||||
# But only if enough time has passed to prevent flickering
|
||||
current_time = time.time()
|
||||
|
||||
# Remove stale summary widget
|
||||
if self._summary_widget:
|
||||
# Hold summary widget for minimum duration to prevent rapid flickering
|
||||
if current_time - self._last_summary_time >= self._summary_hold_duration:
|
||||
# Remove summary widget from list
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == "__summary__":
|
||||
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
self._remove_keyed_item("__summary__")
|
||||
self._summary_widget = None
|
||||
else:
|
||||
# Too soon to clear summary, keep it visible
|
||||
return
|
||||
|
||||
# Clear transition label if it exists
|
||||
# Remove transition label
|
||||
if self._transition_label:
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == "__transition__":
|
||||
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
self._remove_keyed_item("__transition__")
|
||||
self._transition_label = None
|
||||
|
||||
|
||||
if not file_progresses:
|
||||
# No files - check if this is a phase transition
|
||||
if current_phase and self._last_phase and current_phase != self._last_phase:
|
||||
# Phase changed - show transition message briefly
|
||||
self._show_transition_message(current_phase)
|
||||
else:
|
||||
# Show empty state but keep header stable
|
||||
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self._clear_item_widgets()
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
|
||||
# Update last phase tracker
|
||||
if current_phase:
|
||||
self._last_phase = current_phase
|
||||
return
|
||||
|
||||
# Determine phase from file operations if not provided
|
||||
|
||||
# Resolve phase label from operations if not provided
|
||||
if not current_phase and file_progresses:
|
||||
# Get the most common operation type
|
||||
operations = [fp.operation for fp in file_progresses if fp.operation != OperationType.UNKNOWN]
|
||||
if operations:
|
||||
operation_counts = {}
|
||||
counts = {}
|
||||
for op in operations:
|
||||
operation_counts[op] = operation_counts.get(op, 0) + 1
|
||||
most_common = max(operation_counts.items(), key=lambda x: x[1])[0]
|
||||
counts[op] = counts.get(op, 0) + 1
|
||||
phase_map = {
|
||||
OperationType.DOWNLOAD: "Downloading",
|
||||
OperationType.EXTRACT: "Extracting",
|
||||
OperationType.EXTRACT: "Extracting",
|
||||
OperationType.VALIDATE: "Validating",
|
||||
OperationType.INSTALL: "Installing",
|
||||
OperationType.INSTALL: "Installing",
|
||||
}
|
||||
current_phase = phase_map.get(most_common, "")
|
||||
|
||||
# Remove completed files
|
||||
# Build set of current item keys (using stable keys for counters)
|
||||
current_phase = phase_map.get(max(counts, key=counts.get), "")
|
||||
|
||||
# Build stable key set from incoming data
|
||||
current_keys = set()
|
||||
for fp in file_progresses:
|
||||
if 'Installing Files:' in fp.filename:
|
||||
current_keys.add("__installing_files__")
|
||||
elif 'Converting Texture:' in fp.filename:
|
||||
base_name = fp.filename.split('(')[0].strip()
|
||||
current_keys.add(f"__texture_{base_name}__")
|
||||
elif fp.filename.startswith('BSA:'):
|
||||
bsa_name = fp.filename.split('(')[0].strip()
|
||||
current_keys.add(f"__bsa_{bsa_name}__")
|
||||
elif fp.filename.startswith('Wine component:'):
|
||||
rest = fp.filename.split(':', 1)[1].strip()
|
||||
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
|
||||
current_keys.add(f"__wine_comp_{comp_id}__")
|
||||
else:
|
||||
current_keys.add(fp.filename)
|
||||
|
||||
current_keys.add(self._stable_key(fp))
|
||||
|
||||
# Remove items no longer active
|
||||
for item_key in list(self._file_items.keys()):
|
||||
if item_key not in current_keys:
|
||||
# Find and remove the item
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == item_key:
|
||||
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
del self._file_items[item_key]
|
||||
|
||||
# Update or add files - maintain specific ordering
|
||||
# Use stable identifiers for special items (like "Installing Files: X/Y")
|
||||
for idx, file_progress in enumerate(file_progresses):
|
||||
# For items with changing counters in filename, use a stable key
|
||||
if 'Installing Files:' in file_progress.filename:
|
||||
item_key = "__installing_files__"
|
||||
elif 'Converting Texture:' in file_progress.filename:
|
||||
base_name = file_progress.filename.split('(')[0].strip()
|
||||
item_key = f"__texture_{base_name}__"
|
||||
elif file_progress.filename.startswith('BSA:'):
|
||||
bsa_name = file_progress.filename.split('(')[0].strip()
|
||||
item_key = f"__bsa_{bsa_name}__"
|
||||
elif file_progress.filename.startswith('Wine component:'):
|
||||
rest = file_progress.filename.split(':', 1)[1].strip()
|
||||
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
|
||||
item_key = f"__wine_comp_{comp_id}__"
|
||||
else:
|
||||
item_key = file_progress.filename
|
||||
|
||||
|
||||
# Update existing or add new items
|
||||
for file_progress in file_progresses:
|
||||
item_key = self._stable_key(file_progress)
|
||||
|
||||
if item_key in self._file_items:
|
||||
# Update existing widget - DO NOT reorder items (causes segfaults)
|
||||
# Reordering with takeItem/insertItem can delete widgets and cause crashes
|
||||
# Order is less important than stability - just update the widget in place
|
||||
item_widget = self._file_items[item_key]
|
||||
# CRITICAL: Check widget is still valid before updating
|
||||
if shiboken6.isValid(item_widget):
|
||||
try:
|
||||
item_widget.update_progress(file_progress)
|
||||
except RuntimeError:
|
||||
# Widget was deleted - remove from dict and create new one below
|
||||
del self._file_items[item_key]
|
||||
# Fall through to create new widget
|
||||
else:
|
||||
# Update successful - skip creating new widget
|
||||
continue
|
||||
except RuntimeError:
|
||||
del self._file_items[item_key]
|
||||
else:
|
||||
# Widget invalid - remove from dict and create new one
|
||||
del self._file_items[item_key]
|
||||
# Fall through to create new widget
|
||||
# Create new widget (either because it didn't exist or was invalid)
|
||||
# CRITICAL: Use addItem instead of insertItem to avoid position conflicts
|
||||
# Order is less important than stability - addItem is safer than insertItem
|
||||
|
||||
item_widget = FileProgressItem(file_progress)
|
||||
list_item = QListWidgetItem()
|
||||
list_item.setSizeHint(item_widget.sizeHint())
|
||||
list_item.setData(Qt.UserRole, item_key) # Use stable key
|
||||
self.list_widget.addItem(list_item) # Use addItem for safety (avoids segfaults)
|
||||
list_item.setData(Qt.UserRole, item_key)
|
||||
self.list_widget.addItem(list_item)
|
||||
self.list_widget.setItemWidget(list_item, item_widget)
|
||||
self._file_items[item_key] = item_widget
|
||||
|
||||
# Update last phase tracker
|
||||
if current_phase:
|
||||
self._last_phase = current_phase
|
||||
|
||||
def _show_transition_message(self, new_phase: str):
|
||||
"""Show a brief 'Preparing...' message during phase transitions."""
|
||||
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
|
||||
def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0):
|
||||
file_progress = FileProgress(
|
||||
filename=label,
|
||||
operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN,
|
||||
percent=progress,
|
||||
current_size=0,
|
||||
total_size=0,
|
||||
)
|
||||
self.update_files([file_progress], current_phase=None)
|
||||
|
||||
def clear_summary(self):
|
||||
if self._summary_widget:
|
||||
self._remove_keyed_item("__summary__")
|
||||
self._summary_widget = None
|
||||
|
||||
def clear(self):
|
||||
self._clear_item_widgets()
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
self._summary_widget = None
|
||||
self._transition_label = None
|
||||
self._last_phase = None
|
||||
self.stop_cpu_tracking()
|
||||
self.cpu_label.setText("")
|
||||
|
||||
def start_cpu_tracking(self):
|
||||
if not self._cpu_timer.isActive():
|
||||
self._cpu_timer.start()
|
||||
self._start_cpu_worker()
|
||||
|
||||
def stop_cpu_tracking(self):
|
||||
self._cpu_timer.stop()
|
||||
if self._cpu_worker and self._cpu_worker.isRunning():
|
||||
self._cpu_worker.quit()
|
||||
self._cpu_worker.wait(500)
|
||||
self._cpu_worker = None
|
||||
|
||||
def _start_cpu_worker(self):
|
||||
# Skip if a worker is already running to avoid pileup
|
||||
if self._cpu_worker and self._cpu_worker.isRunning():
|
||||
return
|
||||
self._cpu_worker = _CpuWorker(self._last_cpu_percent, self._cpu_process_cache, self._child_process_cache)
|
||||
self._cpu_worker.result.connect(self._on_cpu_result)
|
||||
self._cpu_worker.caches_updated.connect(self._on_cpu_caches)
|
||||
self._cpu_worker.start()
|
||||
|
||||
def _on_cpu_result(self, text: str):
|
||||
self.cpu_label.setText(text)
|
||||
|
||||
def _on_cpu_caches(self, process_cache, child_cache, smoothed_pct):
|
||||
self._cpu_process_cache = process_cache
|
||||
self._child_process_cache = child_cache
|
||||
self._last_cpu_percent = smoothed_pct
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _stable_key(fp: FileProgress) -> str:
|
||||
if 'Installing Files:' in fp.filename:
|
||||
return "__installing_files__"
|
||||
if 'Converting Texture:' in fp.filename:
|
||||
return f"__texture_{fp.filename.split('(')[0].strip()}__"
|
||||
if fp.filename.startswith('BSA:'):
|
||||
return f"__bsa_{fp.filename.split('(')[0].strip()}__"
|
||||
if fp.filename.startswith('Wine component:'):
|
||||
rest = fp.filename.split(':', 1)[1].strip()
|
||||
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
|
||||
return f"__wine_comp_{comp_id}__"
|
||||
return fp.filename
|
||||
|
||||
def _clear_item_widgets(self):
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
|
||||
def _remove_keyed_item(self, key: str):
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == key:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
|
||||
def _show_transition_message(self, new_phase: str):
|
||||
self._clear_item_widgets()
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
|
||||
# Header removed - tab label provides context
|
||||
|
||||
# Create or update transition label
|
||||
if self._transition_label is None or not shiboken6.isValid(self._transition_label):
|
||||
self._transition_label = QLabel()
|
||||
self._transition_label.setAlignment(Qt.AlignCenter)
|
||||
self._transition_label.setStyleSheet("color: #888; font-style: italic; padding: 20px;")
|
||||
|
||||
self._transition_label.setText(f"Preparing {new_phase.lower()}...")
|
||||
|
||||
# Add to list widget
|
||||
transition_item = QListWidgetItem()
|
||||
transition_item.setSizeHint(self._transition_label.sizeHint())
|
||||
transition_item.setData(Qt.UserRole, "__transition__")
|
||||
self.list_widget.addItem(transition_item)
|
||||
self.list_widget.setItemWidget(transition_item, self._transition_label)
|
||||
|
||||
# Remove transition message after brief delay (will be replaced by actual content)
|
||||
# The next update_files call with actual content will clear this automatically
|
||||
|
||||
def clear_summary(self):
|
||||
"""Remove the summary widget so file-list items can take over immediately."""
|
||||
if self._summary_widget:
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item and item.data(Qt.UserRole) == "__summary__":
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.takeItem(i)
|
||||
break
|
||||
self._summary_widget = None
|
||||
|
||||
def clear(self):
|
||||
"""Clear all file items."""
|
||||
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item:
|
||||
widget = self.list_widget.itemWidget(item)
|
||||
if widget:
|
||||
self.list_widget.removeItemWidget(item)
|
||||
self.list_widget.clear()
|
||||
self._file_items.clear()
|
||||
self._summary_widget = None
|
||||
self._transition_label = None
|
||||
self._last_phase = None
|
||||
# Header removed - tab label provides context
|
||||
# Stop CPU timer and clear CPU label
|
||||
self.stop_cpu_tracking()
|
||||
self.cpu_label.setText("")
|
||||
|
||||
def start_cpu_tracking(self):
|
||||
"""Start tracking CPU usage."""
|
||||
if not self._cpu_timer.isActive():
|
||||
# Initialize process and take first measurement to establish baseline
|
||||
try:
|
||||
import psutil
|
||||
import os
|
||||
self._cpu_process_cache = psutil.Process(os.getpid())
|
||||
# First call with interval to establish baseline
|
||||
self._cpu_process_cache.cpu_percent(interval=0.1)
|
||||
# Cache child processes
|
||||
self._child_process_cache = {}
|
||||
for child in self._cpu_process_cache.children(recursive=True):
|
||||
try:
|
||||
child.cpu_percent(interval=0.1)
|
||||
self._child_process_cache[child.pid] = child
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._cpu_timer.start()
|
||||
self._update_cpu_usage() # Update immediately after baseline
|
||||
|
||||
def stop_cpu_tracking(self):
|
||||
"""Stop tracking CPU usage."""
|
||||
if self._cpu_timer.isActive():
|
||||
self._cpu_timer.stop()
|
||||
|
||||
def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0):
|
||||
"""
|
||||
Add or update a single status item in the Activity window.
|
||||
Useful for simple status messages like "Downloading...", "Extracting...", etc.
|
||||
|
||||
Args:
|
||||
item_id: Unique identifier for this item
|
||||
label: Display label for the item
|
||||
progress: Progress percentage (0-100), or 0 for indeterminate
|
||||
"""
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
|
||||
# Create a FileProgress object for this status item
|
||||
file_progress = FileProgress(
|
||||
filename=label,
|
||||
operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN,
|
||||
percent=progress,
|
||||
current_size=0,
|
||||
total_size=0
|
||||
)
|
||||
|
||||
# Use update_files with a single-item list
|
||||
self.update_files([file_progress], current_phase=None)
|
||||
|
||||
def _update_cpu_usage(self):
|
||||
"""
|
||||
Update CPU usage display with Jackify-related processes.
|
||||
|
||||
Shows total CPU usage across all cores as a percentage of system capacity.
|
||||
E.g., on an 8-core system:
|
||||
- 100% = using all 8 cores fully
|
||||
- 50% = using 4 cores fully (or 8 cores at half capacity)
|
||||
- 12.5% = using 1 core fully
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Get or create process cache
|
||||
if self._cpu_process_cache is None:
|
||||
self._cpu_process_cache = psutil.Process(os.getpid())
|
||||
|
||||
# Get current process CPU (Jackify GUI)
|
||||
# cpu_percent() returns percentage relative to one core
|
||||
# We need to divide by num_cpus to get system-wide percentage
|
||||
num_cpus = psutil.cpu_count()
|
||||
|
||||
main_cpu_raw = self._cpu_process_cache.cpu_percent(interval=None)
|
||||
main_cpu = main_cpu_raw / num_cpus
|
||||
total_cpu = main_cpu
|
||||
|
||||
# Add CPU usage from ALL child processes recursively
|
||||
# Includes jackify-engine, texconv.exe, wine processes, etc.
|
||||
child_count = 0
|
||||
child_cpu_sum = 0.0
|
||||
try:
|
||||
children = self._cpu_process_cache.children(recursive=True)
|
||||
current_child_pids = set()
|
||||
|
||||
for child in children:
|
||||
try:
|
||||
current_child_pids.add(child.pid)
|
||||
|
||||
# Check if this is a new process we haven't cached
|
||||
if child.pid not in self._child_process_cache:
|
||||
# Cache new process and establish baseline
|
||||
child.cpu_percent(interval=0.1)
|
||||
self._child_process_cache[child.pid] = child
|
||||
# Skip this iteration since baseline was just set
|
||||
continue
|
||||
|
||||
# Use cached process object for consistent cpu_percent tracking
|
||||
cached_child = self._child_process_cache[child.pid]
|
||||
child_cpu_raw = cached_child.cpu_percent(interval=None)
|
||||
child_cpu = child_cpu_raw / num_cpus
|
||||
total_cpu += child_cpu
|
||||
child_count += 1
|
||||
child_cpu_sum += child_cpu_raw
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
# Clean up cache for processes that no longer exist
|
||||
dead_pids = set(self._child_process_cache.keys()) - current_child_pids
|
||||
for pid in dead_pids:
|
||||
del self._child_process_cache[pid]
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
# Also search for ALL Jackify-related processes by name/cmdline
|
||||
# Catches non-direct children: shell launches, Proton/wine wrappers, etc.
|
||||
# children() is recursive, so typically only finds Proton spawn cases
|
||||
tracked_pids = {self._cpu_process_cache.pid} # Avoid double-counting
|
||||
tracked_pids.update(current_child_pids)
|
||||
|
||||
extra_count = 0
|
||||
extra_cpu_sum = 0.0
|
||||
try:
|
||||
for proc in psutil.process_iter(['name', 'pid', 'cmdline']):
|
||||
try:
|
||||
if proc.pid in tracked_pids:
|
||||
continue
|
||||
|
||||
proc_name = proc.info.get('name', '').lower()
|
||||
cmdline = proc.info.get('cmdline', [])
|
||||
cmdline_str = ' '.join(cmdline).lower() if cmdline else ''
|
||||
|
||||
# Match Jackify-related process names (include Proton/wine wrappers)
|
||||
# Include all tools that jackify-engine uses during installation
|
||||
jackify_names = [
|
||||
'jackify-engine', # Main engine
|
||||
'texconv', # Texture conversion
|
||||
'texdiag', # Texture diagnostics
|
||||
'directxtex', # DirectXTex helper binaries
|
||||
'texconv_jackify', # Bundled texconv build
|
||||
'texdiag_jackify', # Bundled texdiag build
|
||||
'directxtex_jackify', # Bundled DirectXTex build
|
||||
'7z', # Archive extraction (7z)
|
||||
'7zz', # Archive extraction (7zz)
|
||||
'bsarch', # BSA archive tool
|
||||
'wine', # Proton/wine launcher
|
||||
'wine64', # Proton/wine 64-bit launcher
|
||||
'wine64-preloader', # Proton/wine preloader
|
||||
'steam-run', # Steam runtime wrapper
|
||||
'proton', # Proton launcher scripts
|
||||
]
|
||||
|
||||
# Check process name
|
||||
is_jackify = any(name in proc_name for name in jackify_names)
|
||||
|
||||
# Check command line (e.g., wine running jackify tools, or paths containing jackify)
|
||||
if not is_jackify and cmdline_str:
|
||||
# Check for jackify tool names in command line (catches wine running texconv.exe, etc.)
|
||||
# Includes texconv, texconv.exe, texdiag, 7z, 7zz, bsarch, jackify-engine
|
||||
is_jackify = any(name in cmdline_str for name in jackify_names)
|
||||
|
||||
# Also check for .exe variants (wine runs .exe files)
|
||||
if not is_jackify:
|
||||
exe_names = [f'{name}.exe' for name in jackify_names]
|
||||
is_jackify = any(exe_name in cmdline_str for exe_name in exe_names)
|
||||
|
||||
# Also check if command line contains jackify paths
|
||||
if not is_jackify:
|
||||
is_jackify = 'jackify' in cmdline_str and any(
|
||||
tool in cmdline_str for tool in ['engine', 'tools', 'binaries']
|
||||
)
|
||||
|
||||
if is_jackify:
|
||||
# Check if this is a new process we haven't cached
|
||||
if proc.pid not in self._child_process_cache:
|
||||
# Establish baseline for new process and cache it
|
||||
proc.cpu_percent(interval=0.1)
|
||||
self._child_process_cache[proc.pid] = proc
|
||||
# Skip this iteration since baseline was just set
|
||||
continue
|
||||
|
||||
# Use cached process object
|
||||
cached_proc = self._child_process_cache[proc.pid]
|
||||
proc_cpu_raw = cached_proc.cpu_percent(interval=None)
|
||||
proc_cpu = proc_cpu_raw / num_cpus
|
||||
total_cpu += proc_cpu
|
||||
tracked_pids.add(proc.pid)
|
||||
extra_count += 1
|
||||
extra_cpu_sum += proc_cpu_raw
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Smooth the value slightly to reduce jitter (less aggressive than before)
|
||||
if self._last_cpu_percent > 0:
|
||||
total_cpu = (self._last_cpu_percent * 0.3) + (total_cpu * 0.7)
|
||||
self._last_cpu_percent = total_cpu
|
||||
|
||||
# Always show CPU percentage when tracking is active
|
||||
# Cap at 100% for display (shouldn't exceed but just in case)
|
||||
display_percent = min(100.0, total_cpu)
|
||||
|
||||
if display_percent >= 0.1:
|
||||
self.cpu_label.setText(f"CPU: {display_percent:.0f}%")
|
||||
else:
|
||||
# Show 0% instead of hiding to indicate tracking is active
|
||||
self.cpu_label.setText("CPU: 0%")
|
||||
|
||||
except Exception as e:
|
||||
# Show error indicator if tracking fails
|
||||
import sys
|
||||
print(f"CPU tracking error: {e}", file=sys.stderr)
|
||||
self.cpu_label.setText("")
|
||||
|
||||
|
||||
|
||||
@@ -103,11 +103,22 @@ class OverallProgressIndicator(QWidget):
|
||||
"""
|
||||
# Update status text
|
||||
display_text = progress.display_text
|
||||
if not display_text or display_text == "Processing...":
|
||||
display_text = progress.phase_name or progress.phase.value.title() or "Processing..."
|
||||
|
||||
# Add total download size, remaining size (MB/GB), and ETA for download phase
|
||||
from jackify.shared.progress_models import InstallationPhase, FileProgress
|
||||
if not display_text or display_text == "Processing...":
|
||||
if progress.phase == InstallationPhase.UNKNOWN:
|
||||
# Don't overwrite the banner with "Unknown" for unrecognized section headers;
|
||||
# preserve whatever was showing before.
|
||||
current = self.status_label.text()
|
||||
if current and current not in ("Ready to install", "Unknown", "Processing...", ""):
|
||||
display_text = current
|
||||
else:
|
||||
display_text = "Processing..."
|
||||
else:
|
||||
display_text = progress.phase_name or progress.phase.value.title() or "Processing..."
|
||||
if progress.phase == InstallationPhase.DOWNLOAD and progress.phase_max_steps > 0 and progress.phase_step <= 0:
|
||||
display_text = display_text.replace(f"[{progress.phase_step}/{progress.phase_max_steps}]", "").replace(" ", " ").strip()
|
||||
|
||||
# Add total download size, remaining size (MB/GB), and ETA for download phase
|
||||
if progress.phase == InstallationPhase.DOWNLOAD:
|
||||
# Try to get overall download totals - either from data_total or aggregate from active_files
|
||||
total_bytes = progress.data_total
|
||||
@@ -188,20 +199,30 @@ class OverallProgressIndicator(QWidget):
|
||||
from jackify.shared.progress_models import InstallationPhase
|
||||
is_bsa_building = progress.get_phase_label() == "Building BSAs"
|
||||
|
||||
# For install/extract/download/BSA building phases, prefer step-based progress (more accurate)
|
||||
# Prevent carrying over 100% from previous phases
|
||||
if progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT, InstallationPhase.DOWNLOAD) or is_bsa_building:
|
||||
# Download phase often has byte-level progress before step counters move.
|
||||
# Prefer byte progress first to avoid misleading 0% while downloading.
|
||||
if progress.phase == InstallationPhase.DOWNLOAD:
|
||||
if progress.data_total > 0:
|
||||
display_percent = (progress.data_processed / progress.data_total) * 100.0
|
||||
elif progress.active_files:
|
||||
aggregate_total = sum(f.total_size for f in progress.active_files if f.total_size > 0)
|
||||
aggregate_current = sum(f.current_size for f in progress.active_files if f.current_size > 0)
|
||||
if aggregate_total > 0:
|
||||
display_percent = (aggregate_current / aggregate_total) * 100.0
|
||||
if display_percent <= 0 and progress.phase_max_steps > 0 and progress.phase_step > 0:
|
||||
display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0
|
||||
elif display_percent <= 0 and progress.overall_percent > 0 and progress.overall_percent < 100.0:
|
||||
display_percent = progress.overall_percent
|
||||
# For install/extract/BSA phases, prefer step progress, then bytes.
|
||||
elif progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT) or is_bsa_building:
|
||||
if progress.phase_max_steps > 0:
|
||||
display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0
|
||||
elif progress.data_total > 0 and progress.data_processed > 0:
|
||||
display_percent = (progress.data_processed / progress.data_total) * 100.0
|
||||
elif progress.overall_percent > 0 and progress.overall_percent < 100.0:
|
||||
display_percent = progress.overall_percent
|
||||
else:
|
||||
# If no step/data info, use overall_percent but only if it's reasonable
|
||||
# Don't carry over 100% from previous phase
|
||||
if progress.overall_percent > 0 and progress.overall_percent < 100.0:
|
||||
display_percent = progress.overall_percent
|
||||
else:
|
||||
display_percent = 0.0 # Reset if we don't have valid progress
|
||||
display_percent = 0.0 # Reset if we don't have valid progress
|
||||
else:
|
||||
# For other phases, prefer data progress, then overall_percent, then step progress
|
||||
if progress.data_total > 0 and progress.data_processed > 0:
|
||||
@@ -211,6 +232,8 @@ class OverallProgressIndicator(QWidget):
|
||||
elif progress.phase_max_steps > 0:
|
||||
display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0
|
||||
|
||||
# Clamp to avoid transient parser values creating invalid percentages.
|
||||
display_percent = max(0.0, min(100.0, display_percent))
|
||||
self.progress_bar.setValue(int(display_percent))
|
||||
|
||||
# Update tooltip with detailed information
|
||||
@@ -264,4 +287,3 @@ class OverallProgressIndicator(QWidget):
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setToolTip("")
|
||||
self.status_label.setToolTip("")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user