Sync from development - prepare for v0.4.0

This commit is contained in:
Omni
2026-02-25 17:40:43 +00:00
parent 2eb54b9a36
commit 805718222a
324 changed files with 4914 additions and 4567 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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())

View File

@@ -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:

View File

@@ -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:

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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()}")

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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}]")

View File

@@ -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"

View File

@@ -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

View File

@@ -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))

View 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()

View File

@@ -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

View File

@@ -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"

View 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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)}")

View File

@@ -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:

View File

@@ -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)

View File

@@ -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))

View File

@@ -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,

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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")

View File

@@ -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):

View File

@@ -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

View 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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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)

View File

@@ -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

View 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)}")

View File

@@ -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

View File

@@ -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

View File

@@ -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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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("")

View File

@@ -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("")