Release v0.6.0

This commit is contained in:
Omni
2026-04-20 20:57:23 +01:00
parent 69fabb32e6
commit 2ff09a1448
144 changed files with 4841 additions and 1306 deletions

View File

@@ -96,6 +96,7 @@ class AdditionalTasksScreen(QWidget):
("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"),
("Configure Tool Compatibility", "tool_config", "Apply xEdit, Pandora and DLL fixes to an existing modlist prefix"),
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
]
@@ -153,6 +154,8 @@ class AdditionalTasksScreen(QWidget):
self._show_wabbajack_installer()
elif action_id == "setup_mo2":
self._show_mo2_setup()
elif action_id == "tool_config":
self._show_tool_config()
elif action_id == "coming_soon":
self._show_coming_soon_info()
elif action_id == "return_main_menu":
@@ -185,6 +188,10 @@ class AdditionalTasksScreen(QWidget):
"Check back later for more functionality!"
)
def _show_tool_config(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(11)
def _return_to_main_menu(self):
"""Return to main menu"""
if self.stacked_widget:

View File

@@ -33,8 +33,10 @@ from .configure_existing_modlist_console import ConfigureExistingModlistConsoleM
from .screen_back_mixin import ScreenBackMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class ConfigureExistingModlistScreen(
ThreadLifecycleMixin,
ScreenBackMixin,
TTWIntegrationMixin,
ConfigureExistingModlistUIMixin,
@@ -46,38 +48,25 @@ class ConfigureExistingModlistScreen(
):
resize_request = Signal(str)
def _park_thread(self, thread, signal_names=None):
"""Disconnect a running thread from this screen and keep it alive until it finishes."""
if thread is None:
return None
signal_names = signal_names or []
for signal_name in signal_names:
def hideEvent(self, event):
if getattr(self, '_vnv_controller', None) is not None:
try:
getattr(thread, signal_name).disconnect()
self._vnv_controller.cleanup()
except Exception:
pass
if not hasattr(self, "_parked_threads"):
self._parked_threads = []
self._parked_threads.append(thread)
self._parked_threads = [t for t in self._parked_threads if getattr(t, "isRunning", lambda: False)()]
return None
super().hideEvent(event)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
if getattr(self, '_vnv_controller', None) is not None:
try:
if isinstance(value, QThread) and value.isRunning():
signal_names = []
for candidate in ("finished_signal", "progress_update", "configuration_complete", "error_occurred"):
if hasattr(value, candidate):
signal_names.append(candidate)
setattr(self, attr_name, self._park_thread(value, signal_names))
self._vnv_controller.cleanup()
self._vnv_controller = None
except Exception:
pass
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
self._park_all_threads()
def cancel_and_cleanup(self):
"""Handle Cancel button - clean up processes and go back"""
@@ -124,6 +113,14 @@ class ConfigureExistingModlistScreen(
if install_dir:
game_type = self._detect_game_type_from_mo2_ini(install_dir)
appid = getattr(self, '_current_appid', '')
if appid:
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
ModlistHandler().set_steam_grid_images(str(appid), install_dir, game_type=game_type)
logger.debug("Applied Steam artwork for appid %s", appid)
except Exception as e:
logger.warning("Failed to apply Steam artwork: %s", e)
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)

View File

@@ -15,34 +15,12 @@ class ConfigureExistingModlistWorkflowMixin:
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
"""Detect game type by checking ModOrganizer.ini for loader executables."""
from pathlib import Path
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
return 'skyrim' # Fallback to most common
"""Detect special game type using the canonical ModlistHandler detection."""
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 '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:
return 'starfield'
elif 'enderal' in content:
return 'enderal'
else:
return 'skyrim'
from jackify.backend.handlers.modlist_handler import ModlistHandler
return ModlistHandler().detect_special_game_type(install_dir) or 'skyrim'
except Exception as e:
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
logger.warning("Game type detection failed, defaulting to skyrim: %s", e)
return 'skyrim'
def validate_and_start_configure(self):
@@ -78,6 +56,7 @@ class ConfigureExistingModlistWorkflowMixin:
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
self._enable_controls_after_operation()
return
self._current_appid = shortcut.get('AppID', shortcut.get('appid', ''))
resolution = self.resolution_combo.currentText()
# Handle resolution saving
if resolution and resolution != "Leave unchanged":
@@ -152,7 +131,7 @@ class ConfigureExistingModlistWorkflowMixin:
modlist_context = ModlistContext(
name=self.modlist_name,
install_dir=Path(self.install_dir),
download_dir=Path(self.install_dir).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration-only
modlist_value='', # Not needed for existing modlist
@@ -187,7 +166,7 @@ class ConfigureExistingModlistWorkflowMixin:
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
if not success:
self.error_occurred.emit(
"Configuration did not complete successfully. "

View File

@@ -36,10 +36,11 @@ from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, Modl
from .screen_back_mixin import ScreenBackMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__)
class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
resize_request = Signal(str)
def cancel_and_cleanup(self):
@@ -165,32 +166,7 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
logger.debug("DEBUG: cleanup called - cleaning up threads")
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
# Clean up automated prefix thread if running
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread and self.automated_prefix_thread.isRunning():
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 (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():
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 (RuntimeError, TypeError):
pass
self.config_thread.terminate()
self.config_thread.wait(2000) # Wait up to 2 seconds
self._park_all_threads()

View File

@@ -4,7 +4,7 @@ import re
import time
from PySide6.QtCore import QTimer, Qt
from PySide6.QtWidgets import QFileDialog
from jackify.frontends.gui.utils import browse_file
from jackify.shared.progress_models import FileProgress, OperationType
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
@@ -168,7 +168,7 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
del self._component_install_list
def browse_install_dir(self):
file, _ = QFileDialog.getOpenFileName(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)")
file = browse_file(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)")
if file:
self.install_dir_edit.setText(os.path.realpath(file))
self.install_dir_edit.setText(file)

View File

@@ -84,21 +84,26 @@ class ConfigureNewModlistDialogsMixin:
except Exception:
pass
def hideEvent(self, event):
if getattr(self, '_vnv_controller', None) is not None:
try:
self._vnv_controller.cleanup()
except Exception:
pass
super().hideEvent(event)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
if getattr(self, '_vnv_controller', None) is not None:
try:
self._vnv_controller.cleanup()
self._vnv_controller = None
except Exception:
pass
self._stop_focus_reclaim()
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
try:
if isinstance(value, QThread) and value.isRunning():
value.terminate()
value.wait(2000)
setattr(self, attr_name, None)
except Exception:
pass
self._park_all_threads()
def show_shortcut_conflict_dialog(self, conflicts):
"""Show dialog to reuse an existing shortcut or choose a new name."""
@@ -148,6 +153,12 @@ class ConfigureNewModlistDialogsMixin:
self._restore_controls_after_shortcut_dialog_abort()
return
self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.")
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
_game_type = self._detect_game_type_from_mo2_ini(install_dir)
ModlistHandler().set_steam_grid_images(str(existing_appid), install_dir, game_type=_game_type)
except Exception as _e:
logger.warning("Failed to apply Steam artwork on shortcut reuse: %s", _e)
self.continue_configuration_after_automated_prefix(
str(existing_appid),
existing_name,

View File

@@ -251,6 +251,24 @@ class ConfigureNewModlistWorkflowMixin:
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)
finally:
self._cleanup_automated_prefix_thread()
def _cleanup_automated_prefix_thread(self):
"""Safely release the automated prefix thread after it has finished."""
if not hasattr(self, 'automated_prefix_thread') or self.automated_prefix_thread is None:
return
try:
self.automated_prefix_thread.progress_update.disconnect()
self.automated_prefix_thread.workflow_complete.disconnect()
self.automated_prefix_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass
if self.automated_prefix_thread.isRunning():
self.automated_prefix_thread.quit()
self.automated_prefix_thread.wait(5000)
self.automated_prefix_thread.deleteLater()
self.automated_prefix_thread = None
def _on_automated_prefix_error(self, error):
"""Handle error from the automated prefix workflow"""
@@ -261,8 +279,8 @@ class ConfigureNewModlistWorkflowMixin:
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._cleanup_automated_prefix_thread()
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"""
@@ -327,7 +345,7 @@ class ConfigureNewModlistWorkflowMixin:
modlist_context = ModlistContext(
name=self.context['name'],
install_dir=Path(self.context['path']),
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value'),
@@ -434,7 +452,7 @@ class ConfigureNewModlistWorkflowMixin:
modlist_context = ModlistContext(
name=self.context['name'],
install_dir=Path(self.context['path']),
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value='', # Not needed for existing modlist

View File

@@ -0,0 +1,442 @@
"""
Configure Tool Compatibility screen.
Applies Wine registry settings for modding tools (xEdit, Pandora, DLL overrides)
to an existing configured modlist prefix. Available from Additional Tasks.
"""
import logging
import subprocess
from typing import Optional
from PySide6.QtCore import Qt, QSize, QThread, QTimer, Signal
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QGridLayout, QHBoxLayout, QLabel,
QPushButton, QSizePolicy, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
)
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
from jackify.frontends.gui.services.message_service import MessageService
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from jackify.frontends.gui.utils import set_responsive_minimum
logger = logging.getLogger(__name__)
class _ShortcutLoaderThread(QThread):
finished_signal = Signal(list) # list of {"name": str, "appid": str}
error_signal = Signal(str)
def run(self):
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
handler = ModlistHandler()
discovered = handler.discover_executable_shortcuts("ModOrganizer.exe")
shortcuts = [
{"name": m.get("name", "Unknown"), "appid": str(m.get("appid", ""))}
for m in discovered
if m.get("appid")
]
self.finished_signal.emit(shortcuts)
except Exception as e:
self.error_signal.emit(str(e))
class _ApplyThread(QThread):
log_signal = Signal(str)
finished_signal = Signal(bool)
def __init__(self, appid: str):
super().__init__()
self._appid = appid
def run(self):
from jackify.backend.services.tool_config_service import apply_tool_config_for_appid
ok = apply_tool_config_for_appid(self._appid, log=self.log_signal.emit)
self.finished_signal.emit(ok)
class ConfigureToolConfigScreen(ThreadLifecycleMixin, QWidget):
"""Apply tool compatibility settings to an existing modlist prefix."""
def __init__(self, stacked_widget=None, additional_tasks_index: int = 3, parent=None):
super().__init__(parent)
self.stacked_widget = stacked_widget
self.additional_tasks_index = additional_tasks_index
self.debug = DEBUG_BORDERS
self._shortcuts: list = []
self._loader: Optional[_ShortcutLoaderThread] = None
self._apply_thread: Optional[_ApplyThread] = None
self._setup_ui()
def _setup_ui(self):
main_vbox = QVBoxLayout(self)
main_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_vbox.setContentsMargins(50, 50, 50, 0)
main_vbox.setSpacing(12)
if self.debug:
self.setStyleSheet("border: 2px solid magenta;")
# --- Header ---
header_widget = QWidget()
header_layout = QVBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(2)
title = QLabel("<b>Configure Tool Compatibility</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
header_layout.addSpacing(10)
desc = QLabel(
"Applies Wine registry settings needed for modding tools to work correctly: "
"xEdit family (WinXP compatibility), Pandora (window decoration), "
"and global DLL overrides."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; font-size: 13px;")
desc.setAlignment(Qt.AlignHCenter)
desc.setMaximumHeight(50)
header_layout.addWidget(desc)
header_layout.addSpacing(12)
header_widget.setLayout(header_layout)
header_widget.setFixedHeight(120)
if self.debug:
header_widget.setStyleSheet("border: 2px solid pink;")
main_vbox.addWidget(header_widget)
# --- Upper section: form (left) + tabs (right) ---
upper_hbox = QHBoxLayout()
upper_hbox.setContentsMargins(0, 0, 0, 0)
upper_hbox.setSpacing(16)
# Left: form
user_config_vbox = QVBoxLayout()
user_config_vbox.setAlignment(Qt.AlignTop)
user_config_vbox.setSpacing(4)
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)
user_config_vbox.addWidget(options_header)
form_grid = QGridLayout()
form_grid.setHorizontalSpacing(12)
form_grid.setVerticalSpacing(6)
form_grid.setContentsMargins(0, 0, 0, 0)
modlist_label = QLabel("Modlist:")
form_grid.addWidget(modlist_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
self._combo = QComboBox()
self._combo.setMinimumWidth(280)
self._combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._combo.addItem("Loading modlists...")
self._combo.setEnabled(False)
form_grid.addWidget(self._combo, 0, 1)
form_widget = QWidget()
form_widget.setLayout(form_grid)
form_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
user_config_vbox.addWidget(form_widget)
user_config_vbox.addSpacing(10)
tools_info = QLabel(
"<b>Tools configured by this workflow:</b><br>"
"&nbsp;&nbsp;xEdit family &nbsp;|&nbsp; Synthesis &nbsp;|&nbsp; Pandora<br>"
"<br>"
"Run this once after installing a modlist if modding tools are not "
"launching correctly from within Mod Organizer 2.<br>"
"<br>"
"<i>These fixes are applied on a best-effort basis. Tool compatibility "
"can change with Proton and Wine updates. Not all tools are guaranteed "
"to work on all Proton versions.</i>"
)
tools_info.setWordWrap(True)
tools_info.setStyleSheet("color: #aaa; font-size: 12px;")
tools_info.setAlignment(Qt.AlignLeft | Qt.AlignTop)
user_config_vbox.addWidget(tools_info)
# Buttons (apply + back) - placed in left column like other screens
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self._apply_btn = QPushButton("Apply Tool Configurations")
self._apply_btn.setEnabled(False)
btn_row.addWidget(self._apply_btn)
self._back_btn = QPushButton("Back")
self._back_btn.clicked.connect(self._go_back)
btn_row.addWidget(self._back_btn)
btn_row.insertStretch(0, 1)
btn_row.addStretch(1)
self.show_details_checkbox = QCheckBox("Show details")
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output")
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50)
if self.debug:
btn_row_widget.setStyleSheet("border: 2px solid red;")
self.btn_row_widget = btn_row_widget
user_config_widget = QWidget()
user_config_widget.setLayout(user_config_vbox)
user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
# Right: Activity + Process Monitor tabs
self._activity_log = QTextEdit()
self._activity_log.setReadOnly(True)
self._activity_log.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self._activity_log.setMinimumSize(QSize(300, 20))
self._activity_log.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._activity_log.setStyleSheet(
f"background: #222; color: {JACKIFY_COLOR_BLUE}; "
"font-family: monospace; font-size: 11px; border: 1px solid #444;"
)
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(
f"background: #222; color: {JACKIFY_COLOR_BLUE}; "
"font-family: monospace; font-size: 11px; border: 1px solid #444;"
)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
self.process_monitor_widget = process_monitor_widget
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet(
"QTabWidget::pane { background: #222; border: 1px solid #444; } "
"QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } "
"QTabBar::tab:selected { background: #333; color: #3fd0ea; } "
"QTabWidget { margin: 0px; padding: 0px; } "
"QTabBar { margin: 0px; padding: 0px; }"
)
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
self.activity_tabs.setDocumentMode(False)
self.activity_tabs.setTabPosition(QTabWidget.North)
if self.debug:
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.addTab(self._activity_log, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(self.activity_tabs, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
upper_section_widget.setMaximumHeight(280)
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
main_vbox.addWidget(upper_section_widget)
# --- Status banner ---
self._status_banner = QLabel("Ready to apply")
self._status_banner.setAlignment(Qt.AlignCenter)
self._status_banner.setStyleSheet(f"""
background-color: #2a2a2a;
color: {JACKIFY_COLOR_BLUE};
padding: 6px 8px;
border-radius: 4px;
font-weight: bold;
font-size: 13px;
""")
self._status_banner.setMaximumHeight(34)
self._status_banner.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self._status_banner, 1)
banner_row.addStretch()
banner_row.addWidget(self.show_details_checkbox)
banner_row_widget = QWidget()
banner_row_widget.setLayout(banner_row)
banner_row_widget.setMaximumHeight(45)
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
main_vbox.addWidget(banner_row_widget)
# --- Console (hidden by default) ---
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setFontFamily("monospace")
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
main_vbox.addWidget(self.console, stretch=1)
main_vbox.addWidget(btn_row_widget, alignment=Qt.AlignHCenter)
self.main_overall_vbox = main_vbox
self.setLayout(main_vbox)
# Process monitor refresh timer
self._top_timer = QTimer(self)
self._top_timer.timeout.connect(self._update_top_panel)
self._top_timer.start(2000)
self._apply_btn.clicked.connect(self._on_apply)
# ------------------------------------------------------------------
def showEvent(self, event):
super().showEvent(event)
logger.info("Configure Tool Compatibility screen opened")
try:
main_window = self.window()
if main_window:
set_responsive_minimum(main_window, min_width=960, min_height=520)
except Exception:
pass
self._load_shortcuts()
def _load_shortcuts(self):
if self._loader and self._loader.isRunning():
return
self._combo.clear()
self._combo.addItem("Loading modlists...")
self._combo.setEnabled(False)
self._apply_btn.setEnabled(False)
self._loader = _ShortcutLoaderThread()
self._loader.finished_signal.connect(self._on_shortcuts_loaded)
self._loader.error_signal.connect(self._on_shortcuts_error)
self._loader.start()
def _on_shortcuts_loaded(self, shortcuts: list):
self._shortcuts = shortcuts
self._combo.clear()
if not shortcuts:
self._combo.addItem("No configured modlists found")
self._combo.setEnabled(False)
self._apply_btn.setEnabled(False)
return
for s in shortcuts:
self._combo.addItem(s["name"])
self._combo.setEnabled(True)
self._apply_btn.setEnabled(True)
def _on_shortcuts_error(self, error: str):
self._combo.clear()
self._combo.addItem("Error loading modlists")
self._combo.setEnabled(False)
self._activity_log.append(f"Failed to load modlists: {error}")
def _on_apply(self):
idx = self._combo.currentIndex()
if idx < 0 or idx >= len(self._shortcuts):
return
shortcut = self._shortcuts[idx]
appid = shortcut["appid"]
name = shortcut["name"]
self._activity_log.clear()
self.console.clear()
self._activity_log.append(f"Applying tool configurations to: {name} (AppID {appid})")
self._status_banner.setText("Applying...")
logger.info("Applying tool compat config: %s (AppID %s)", name, appid)
self._apply_btn.setEnabled(False)
self._combo.setEnabled(False)
self._apply_thread = _ApplyThread(appid)
self._apply_thread.log_signal.connect(self._activity_log.append)
self._apply_thread.log_signal.connect(self.console.append)
self._apply_thread.finished_signal.connect(self._on_apply_finished)
self._apply_thread.start()
def _on_apply_finished(self, success: bool):
self._apply_thread = None
self._apply_btn.setEnabled(True)
self._combo.setEnabled(True)
if success:
self._activity_log.append("\nDone. Tool compatibility settings applied successfully.")
self._status_banner.setText("Applied successfully")
logger.info("Tool compat config applied successfully")
idx = self._combo.currentIndex()
name = self._shortcuts[idx]["name"] if 0 <= idx < len(self._shortcuts) else "Modlist"
from ..dialogs.success_dialog import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=name,
workflow_type="tool_config",
time_taken="",
parent=self
)
success_dialog.show()
else:
self._activity_log.append("\nFailed. Check the output above for details.")
self._status_banner.setText("Apply failed - see details")
logger.warning("Tool compat config failed")
MessageService.warning(
self, "Failed",
"Tool configuration did not complete successfully.\nSee the log for details."
)
def _go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.additional_tasks_index)
def _on_show_details_toggled(self, checked: bool):
if checked:
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215)
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else:
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
def _update_top_panel(self):
try:
result = subprocess.run(
["ps", "-eo", "pcpu,pmem,comm,args"],
stdout=subprocess.PIPE, text=True, timeout=2
)
lines = result.stdout.splitlines()
header = "CPU%\tMEM%\tCOMMAND"
filtered = [header]
rows = []
for line in lines[1:]:
ll = line.lower()
if (
"wine" in ll or "wine64" in ll or "protontricks" in ll
or "jackify-engine" in ll
) and "jackify-gui.py" not in ll:
cols = line.strip().split(None, 3)
if len(cols) >= 3:
rows.append(cols)
rows.sort(key=lambda x: float(x[0]), reverse=True)
for cols in rows:
filtered.append("\t".join(cols))
if len(filtered) == 1:
filtered.append("[No relevant processes]")
self.process_monitor.setPlainText("\n".join(filtered))
except Exception as e:
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
def cleanup_processes(self):
self._park_all_threads()

View File

@@ -13,7 +13,7 @@ from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QCheckBox,
QLineEdit, QGridLayout, QTextEdit, QCheckBox,
QMessageBox, QSizePolicy,
)
from PySide6.QtCore import Qt, QThread, Signal, QSize
@@ -25,7 +25,7 @@ from jackify.shared.progress_models import FileProgress, OperationType
from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from ..services.message_service import MessageService
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import set_responsive_minimum
from ..utils import set_responsive_minimum, browse_directory
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
from ..widgets.progress_indicator import OverallProgressIndicator
from ..widgets.file_progress_list import FileProgressList
@@ -303,11 +303,9 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
self.resize_request.emit("compact")
def _browse_folder(self):
folder = QFileDialog.getExistingDirectory(
self, "Select MO2 Installation Folder", str(Path.home()), QFileDialog.ShowDirsOnly
)
folder = browse_directory(self, "Select MO2 Installation Folder", str(Path.home()))
if folder:
self.install_dir_edit.setText(os.path.realpath(folder))
self.install_dir_edit.setText(folder)
# ------------------------------------------------------------------
# Activity window helpers
@@ -511,9 +509,7 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
try:
if self.worker.isRunning():
self.worker.requestInterruption()
if not self.worker.wait(5000):
self.worker.terminate()
self.worker.wait(10000)
self.worker.wait(10000)
self.worker.deleteLater()
except Exception:
pass

View File

@@ -49,8 +49,9 @@ from .install_modlist_workflow import InstallWorkflowMixin
from .install_modlist_nexus import NexusAuthMixin
from .install_modlist_selection import ModlistSelectionMixin
from .screen_back_mixin import ScreenBackMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
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)"""
@@ -411,16 +412,25 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
btn_exit.clicked.connect(on_exit)
dlg.exec()
def hideEvent(self, event):
if getattr(self, '_vnv_controller', None) is not None:
try:
self._vnv_controller.cleanup()
except Exception:
pass
super().hideEvent(event)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
self._stop_focus_reclaim()
# Disconnect all thread signals before any stopping - prevents callbacks to
# a dying widget if threads emit between now and actual termination.
self._park_all_threads()
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)
@@ -460,16 +470,12 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
except Exception:
pass
logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, forcing terminate")
logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, waiting for forced shutdown window")
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")
@@ -545,21 +551,18 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
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():
self.prefix_thread.terminate()
self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.prefix_thread.isRunning():
self.prefix_thread.terminate() # Force terminate if needed
self.prefix_thread.wait(1000)
# Cancel the configuration thread if it exists
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.config_thread.isRunning():
self.config_thread.terminate() # Force terminate if needed
self.config_thread.wait(1000)
# Park prefix/config threads - disconnect their signals and let them
# finish naturally rather than terminating unsafely.
if hasattr(self, 'prefix_thread') and self.prefix_thread:
self.prefix_thread = self._park_thread(
self.prefix_thread,
["progress_update", "workflow_complete", "error_occurred"],
)
if hasattr(self, 'config_thread') and self.config_thread:
self.config_thread = self._park_thread(
self.config_thread,
["progress_update", "configuration_complete", "error_occurred"],
)
# Cleanup any remaining processes
self.cleanup_processes()

View File

@@ -77,6 +77,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion on main thread"""
try:
install_dir = self.install_dir_edit.text().strip()
# Stop CPU tracking now that everything is complete
self.file_progress_list.stop_cpu_tracking()
# Re-enable controls now that installation/configuration is complete
@@ -107,7 +108,6 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
game_name = display_names.get(self._current_game_type, self._current_game_name)
# Check for TTW eligibility before showing final success dialog
install_dir = self.install_dir_edit.text().strip()
ttw_modlist_name = modlist_name
try:
from jackify.backend.utils.modlist_meta import get_modlist_name
@@ -470,7 +470,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
modlist_context = ModlistContext(
name=self.context['name'],
install_dir=Path(self.context['path']),
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value'),
@@ -603,7 +603,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
modlist_context = ModlistContext(
name=self.context['name'],
install_dir=Path(self.context['path']),
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value', ''),

View File

@@ -35,7 +35,7 @@ class InstallerThread(QThread):
def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name,
install_mode='online', progress_state_manager=None, auth_service=None,
oauth_info=None, skip_disk_check=False):
oauth_info=None):
super().__init__()
self.modlist = modlist
self.install_dir = install_dir
@@ -48,7 +48,6 @@ class InstallerThread(QThread):
self.progress_state_manager = progress_state_manager
self.auth_service = auth_service
self.oauth_info = oauth_info
self.skip_disk_check = skip_disk_check
self._premium_signal_sent = False
self._non_premium_info_sent = False
self._engine_output_buffer = []
@@ -277,17 +276,17 @@ class InstallerThread(QThread):
if debug_mode:
cmd.append('--debug')
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
if self.skip_disk_check:
cmd.append('--skip-disk-check')
logger.debug("DEBUG: Added --skip-disk-check 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
writeback_path = str(self.auth_service.get_token_writeback_path()) if self.auth_service else None
env_vars = {'NEXUS_API_KEY': self.api_key}
if self.oauth_info:
env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
if writeback_path:
env_vars['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
env = get_clean_subprocess_env(env_vars)
# Install-time resource preflight: keep this visible in workflow output so
@@ -472,6 +471,8 @@ class InstallerThread(QThread):
self.output_received.emit(decoded)
stderr_thread.join(timeout=5)
returncode = self.process_manager.wait()
if writeback_path and self.auth_service:
self.auth_service.apply_token_writeback(writeback_path)
if self.process_manager.proc and self.process_manager.proc.stdout:
try:
remaining = self.process_manager.proc.stdout.read()

View File

@@ -258,7 +258,7 @@ class NexusAuthMixin:
wait_label = QLabel(
"Waiting for authorisation...\n\n"
"Please complete authorisation in your browser.\n\n"
"Your browser may ask permission to open Jackify click Open or Allow."
"Your browser may ask permission to open Jackify - click Open or Allow."
)
wait_label.setWordWrap(True)
wait_label.setStyleSheet("color: #ccc; font-size: 12px;")
@@ -366,8 +366,6 @@ class NexusAuthMixin:
oauth_thread.wait(100)
if oauth_cancelled[0]:
oauth_thread.wait(2000)
if oauth_thread.isRunning():
oauth_thread.terminate()
break
wait_dialog.close()

View File

@@ -89,7 +89,7 @@ class ProgressHandlersMixin:
"When your browser opens a Nexus page, click <b>\"Slow Download\"</b>."
" For non-Nexus manual links, follow the site instructions shown in the page.<br><br>"
"<b>Watch folder:</b> Jackify watches the folder shown in that dialog for newly downloaded files. "
"Files detected there are validated and moved automatically into your modlist downloads folder "
"Files detected there are validated and moved automatically into your modlist downloads folder - "
"you do not need to move files manually. If your browser saves to a different location, "
"please set the Watch Folder to that directory before starting the download of mod archives."
)
@@ -132,7 +132,7 @@ class ProgressHandlersMixin:
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
# 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:
@@ -406,18 +406,6 @@ class ProgressHandlersMixin:
if self._premium_failure_active:
message = "Installation stopped because Nexus Premium is required for automated downloads."
if not self._premium_failure_active and not cancellation_detected:
thread = getattr(self, 'install_thread', None)
if (thread
and not getattr(thread, '_install_progress_started', False)
and getattr(getattr(thread, 'last_error', None), 'title', '') == "Disk Full"):
ctx = getattr(thread, '_last_error_raw_context', {})
if self._handle_preflight_disk_space(ctx):
return
self._installation_cancelled = True
self.process_finished(130, QProcess.NormalExit)
return
if not self._premium_failure_active:
engine_error = getattr(self.install_thread, 'last_error', None)
if engine_error:
@@ -429,98 +417,6 @@ class ProgressHandlersMixin:
self._safe_append_text(f"\nError: {message}")
self.process_finished(1, QProcess.CrashExit) # Simulate error
def _handle_preflight_disk_space(self, ctx: dict) -> bool:
"""Show pre-flight filesystem warning dialog. Returns True if user chose Continue Anyway."""
from PySide6.QtWidgets import QMessageBox
if ctx.get('offending_names'):
name_max = ctx.get('name_max', 255)
offending_names = ctx.get('offending_names') or []
examples = "\n".join(f" {n}" for n in offending_names[:3])
if len(offending_names) > 3:
examples += f"\n ...and {len(offending_names) - 3} more"
body = (
f"Your filesystem limits filenames to {name_max} characters, but this modlist "
f"contains files with longer names.\n\n"
f"Affected files:\n{examples}\n\n"
f"Installation may fail for those files. Using ext4, btrfs, or XFS on a "
f"non-encrypted mount is recommended.\n\n"
f"You can attempt to continue — some files may not extract correctly."
)
dlg = QMessageBox(self)
dlg.setWindowTitle("Filename Length Warning")
dlg.setText("Filesystem filename length limit detected.")
dlg.setInformativeText(body)
dlg.setIcon(QMessageBox.Warning)
else:
archive_bytes = ctx.get('archive_bytes', 0)
install_bytes = ctx.get('install_bytes', 0)
same_drive = ctx.get('same_drive', False)
def _fmt(b):
if b >= 1024 ** 3:
return f"{b / 1024 ** 3:.1f} GB"
if b >= 1024 ** 2:
return f"{b / 1024 ** 2:.1f} MB"
return f"{b} bytes" if b else "unknown"
if same_drive:
space_lines = (
f"Downloads and install are on the same drive.\n"
f"Archives require: {_fmt(archive_bytes)}\n"
f"Installed files require: {_fmt(install_bytes)}"
)
else:
space_lines = (
f"Download space required: {_fmt(archive_bytes)}\n"
f"Install space required: {_fmt(install_bytes)}"
)
body = (
f"The disk space check reports that there may not be enough free space to complete "
f"this installation.\n\n"
f"{space_lines}\n\n"
f"If this is a modlist update, the actual space needed is likely far less — most files "
f"are already present and will be reused rather than re-downloaded.\n\n"
f"You can continue and free up space while downloads are running, "
f"or cancel to resolve the space issue first."
)
dlg = QMessageBox(self)
dlg.setWindowTitle("Disk Space Warning")
dlg.setText("Not enough free disk space detected.")
dlg.setInformativeText(body)
dlg.setIcon(QMessageBox.Warning)
continue_btn = dlg.addButton("Continue Anyway", QMessageBox.AcceptRole)
dlg.addButton("Cancel", QMessageBox.RejectRole)
dlg.setDefaultButton(continue_btn)
dlg.exec()
if dlg.clickedButton() is not continue_btn:
return False
thread = getattr(self, 'install_thread', None)
if not thread:
return False
modlist = getattr(thread, 'modlist', None)
install_dir = getattr(thread, 'install_dir', None)
downloads_dir = getattr(thread, 'downloads_dir', None)
api_key = getattr(thread, 'api_key', None)
install_mode = getattr(thread, 'install_mode', 'online')
oauth_info = getattr(thread, 'oauth_info', None)
if not (modlist and install_dir and downloads_dir and api_key):
return False
logger.info("Pre-flight filesystem check bypassed by user — restarting with --skip-disk-check")
self._safe_append_text("\n[WARN] Filesystem check bypassed. Continuing installation...\n")
self.run_modlist_installer(
modlist, install_dir, downloads_dir, api_key,
install_mode, oauth_info, skip_disk_check=True,
)
return True
def process_finished(self, exit_code, exit_status):
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
# Reset button states
@@ -600,6 +496,14 @@ class ProgressHandlersMixin:
self._safe_append_text(
f"Update mode: reusing existing Steam shortcut AppID {self._existing_shortcut_appid}."
)
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
_game_type = self._detect_game_type_from_mo2_ini(install_dir)
ModlistHandler().set_steam_grid_images(
str(self._existing_shortcut_appid), install_dir, game_type=_game_type
)
except Exception as _e:
logger.warning("Failed to apply Steam artwork on update install: %s", _e)
self.continue_configuration_after_automated_prefix(
self._existing_shortcut_appid,
modlist_name,

View File

@@ -1,6 +1,7 @@
"""Modlist selection methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtWidgets import QFileDialog, QMessageBox, QApplication, QDialog
from PySide6.QtWidgets import QMessageBox, QApplication, QDialog
from jackify.frontends.gui.utils import browse_directory, browse_file
from PySide6.QtCore import QTimer, Qt
import logging
import os
@@ -38,6 +39,9 @@ class ModlistSelectionMixin:
"Starfield": "starfield",
"Oblivion Remastered": "oblivion_remastered",
"Enderal": "enderal",
"Skyrim VR": "skyrimvr",
"Fallout 4 VR": "fallout4vr",
"Baldur's Gate 3": "bg3",
"Other": "other"
}
cli_game_type = game_type_map.get(game_type, "other")
@@ -139,6 +143,9 @@ class ModlistSelectionMixin:
"Starfield": "Starfield",
"Oblivion Remastered": "Oblivion",
"Enderal": "Enderal Special Edition",
"Skyrim VR": "Skyrim VR",
"Fallout 4 VR": "Fallout 4 VR",
"Baldur's Gate 3": "Baldur's Gate 3",
"Other": None
}
@@ -161,7 +168,8 @@ class ModlistSelectionMixin:
'game': metadata.gameHumanFriendly,
'description': metadata.description,
'nsfw': metadata.nsfw,
'force_down': metadata.forceDown
'force_down': metadata.forceDown,
'readme_url': metadata.links.readme if metadata.links else None,
}
self.modlist_name_edit.setText(metadata.title)
@@ -179,17 +187,17 @@ class ModlistSelectionMixin:
self.modlist_btn.setEnabled(True)
def browse_wabbajack_file(self):
file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)")
file = browse_file(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)")
if file:
self.file_edit.setText(os.path.realpath(file))
self.file_edit.setText(file)
def browse_install_dir(self):
dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text())
dir = browse_directory(self, "Select Install Directory", self.install_dir_edit.text())
if dir:
self.install_dir_edit.setText(os.path.realpath(dir))
self.install_dir_edit.setText(dir)
def browse_downloads_dir(self):
dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text())
dir = browse_directory(self, "Select Downloads Directory", self.downloads_dir_edit.text())
if dir:
self.downloads_dir_edit.setText(os.path.realpath(dir))
self.downloads_dir_edit.setText(dir)

View File

@@ -101,7 +101,7 @@ class TTWIntegrationMixin:
# 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.
# 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)

View File

@@ -155,7 +155,7 @@ class InstallModlistUISetupMixin:
online_layout = QHBoxLayout()
online_layout.setContentsMargins(0, 0, 0, 0)
# --- Game Type Selection ---
self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Other"]
self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Skyrim VR", "Fallout 4 VR", "Baldur's Gate 3", "Other"]
self.game_type_btn = QPushButton("Please Select...")
self.game_type_btn.setMinimumWidth(200)
self.game_type_btn.clicked.connect(self.open_game_type_dialog)

View File

@@ -165,6 +165,8 @@ class InstallWorkflowExecutionMixin:
# Handle resolution saving
resolution = self.resolution_combo.currentText()
if resolution and resolution != "Leave unchanged":
raw_resolution = resolution.split(" (")[0] if " (" in resolution else resolution
self._current_resolution = raw_resolution
success = self.resolution_service.save_resolution(resolution)
if success:
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
@@ -185,9 +187,11 @@ class InstallWorkflowExecutionMixin:
game_type = None
game_name = None
readme_url = None
if install_mode == 'file':
# Parse .wabbajack file to get game type
wabbajack_path = Path(modlist)
readme_url = self.wabbajack_parser.parse_wabbajack_readme(wabbajack_path)
result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path)
if result:
if isinstance(result, tuple):
@@ -221,6 +225,7 @@ class InstallWorkflowExecutionMixin:
else:
# For online modlists, try to get game type from selected modlist
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
readme_url = self.selected_modlist_info.get('readme_url')
game_name = self.selected_modlist_info.get('game', '')
logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
@@ -233,8 +238,13 @@ class InstallWorkflowExecutionMixin:
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion_remastered': 'oblivion_remastered',
'oblivion remastered': 'oblivion_remastered',
'enderal': 'enderal',
'enderal special edition': 'enderal'
'enderal special edition': 'enderal',
'skyrim vr': 'skyrimvr',
'fallout 4 vr': 'fallout4vr',
'cyberpunk 2077': 'cp2077',
"baldur's gate 3": 'bg3',
}
game_type = game_mapping.get(game_name.lower())
logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
@@ -257,12 +267,16 @@ class InstallWorkflowExecutionMixin:
if game_type and not is_supported:
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)
if not dialog.show_dialog(self, game_name):
self._abort_install_validation()
return
elif game_type in ('skyrimvr', 'fallout4vr'):
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
if not UnsupportedGameDialog.show_dialog(self, game_name, vr_warning=True):
self._abort_install_validation()
return
self.console.clear()
self.process_monitor.clear()
@@ -372,6 +386,20 @@ class InstallWorkflowExecutionMixin:
)
return
if readme_url:
import subprocess
if "raw.githubusercontent.com" in readme_url:
readme_url = readme_url.replace("raw.githubusercontent.com", "github.com")
readme_url = readme_url.replace("/main/", "/blob/main/")
readme_url = readme_url.replace("/master/", "/blob/master/")
logger.info(f"Opening modlist readme: {readme_url}")
clean_env = {k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "LD_PRELOAD")}
subprocess.Popen(["xdg-open", readme_url], env=clean_env)
self._safe_append_text(
"Modlist readme opened in your browser. "
"Check it for any manual post-install steps before launching the game."
)
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:
@@ -384,7 +412,7 @@ class InstallWorkflowExecutionMixin:
self.cancel_install_btn.setVisible(False)
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, skip_disk_check=False):
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
# Rotate log file at start of each workflow run (keep 5 backups)
@@ -409,7 +437,6 @@ class InstallWorkflowExecutionMixin:
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
oauth_info=oauth_info, # Pass OAuth state for auto-refresh
skip_disk_check=skip_disk_check,
)
self.install_thread.output_received.connect(self.on_installation_output)
self.install_thread.progress_received.connect(self.on_installation_progress)
@@ -473,7 +500,7 @@ class InstallWorkflowExecutionMixin:
self._safe_append_text(
f"\n[Manual Download Required] {count} file(s) need manual download.\n"
f"Opening download dialog check your taskbar if it does not appear in front.\n"
f"Opening download dialog - check your taskbar if it does not appear in front.\n"
)
logger.info(
f"[MDL-1006] Manual download protocol initialized | count={count} "

View File

@@ -6,7 +6,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayo
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl
from PySide6.QtGui import QPixmap, QTextCursor, QPainter, QFont
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import ansi_to_html, strip_ansi_control_codes, set_responsive_minimum
from ..utils import ansi_to_html, strip_ansi_control_codes, set_responsive_minimum, _get_sidebar_urls
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
import os
@@ -37,6 +37,7 @@ from .install_ttw_workflow import TTWWorkflowMixin
from .install_ttw_output import TTWOutputMixin
from .install_ttw_ui import TTWUIMixin
from .screen_back_mixin import ScreenBackMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class ModlistFetchThread(QThread):
result = Signal(list, str)
@@ -77,7 +78,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, TTWWorkflowMixin, TTWOutputMixin, TTWUIMixin):
class InstallTTWScreen(ThreadLifecycleMixin, 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)
@@ -151,24 +152,24 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
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("~")
dialog = QFileDialog(self, "Select TTW .mpi File")
dialog.setFileMode(QFileDialog.ExistingFile)
dialog.setNameFilter("MPI Files (*.mpi);;All Files (*)")
dialog.setDirectory(start_path)
dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog for consistency
dialog.setOption(QFileDialog.DontUseNativeDialog, True)
dialog.setSidebarUrls(_get_sidebar_urls())
if dialog.exec() == QDialog.Accepted:
files = dialog.selectedFiles()
if files:
self.file_edit.setText(files[0])
def browse_install_dir(self):
# Use QFileDialog instance to match file browser style exactly
dialog = QFileDialog(self, "Select Install Directory")
dialog.setFileMode(QFileDialog.Directory)
dialog.setOption(QFileDialog.ShowDirsOnly, True)
dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog to match file browser
dialog.setOption(QFileDialog.DontUseNativeDialog, True)
dialog.setSidebarUrls(_get_sidebar_urls())
if self.install_dir_edit.text():
dialog.setDirectory(self.install_dir_edit.text())
if dialog.exec() == QDialog.Accepted:
@@ -302,28 +303,13 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
# install_thread uses cancel() for cooperative shutdown before terminate.
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
logger.debug("DEBUG: Cancelling running InstallationThread")
self.install_thread.cancel()
self.install_thread.wait(3000)
if self.install_thread.isRunning():
self.install_thread.terminate()
self.install_thread.wait(2000)
self.install_thread = None
# Disconnect all signals first - prevents callbacks to a dying widget.
self._park_all_threads()
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
if attr_name == 'install_thread':
continue
# install_thread gets a cooperative cancel signal on top of the park.
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
try:
if isinstance(value, QThread) and value.isRunning():
logger.debug(f"DEBUG: Terminating {attr_name}")
value.terminate()
value.wait(2000)
setattr(self, attr_name, None)
self.install_thread.cancel()
except Exception:
pass
@@ -355,29 +341,13 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
font-size: 13px;
""")
# Cancel the installation thread if it exists
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
self.install_thread.cancel()
self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.install_thread.isRunning():
self.install_thread.terminate() # Force terminate if needed
self.install_thread.wait(1000)
# Cancel the automated prefix thread if it exists
if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning():
self.prefix_thread.terminate()
self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.prefix_thread.isRunning():
self.prefix_thread.terminate() # Force terminate if needed
self.prefix_thread.wait(1000)
# Cancel the configuration thread if it exists
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.config_thread.isRunning():
self.config_thread.terminate() # Force terminate if needed
self.config_thread.wait(1000)
# Park all threads first (disconnects signals), then send cooperative cancel.
self._park_all_threads()
if hasattr(self, 'install_thread') and self.install_thread:
try:
self.install_thread.cancel()
except Exception:
pass
# Cleanup any remaining processes
self.cleanup_processes()

View File

@@ -84,7 +84,7 @@ class TTWIntegrationMixin:
ttw_output_dir.rename(versioned_path)
ttw_output_dir = versioned_path
skip_copy = True
logger.debug("TTW already in mods dir skipping copy step")
logger.debug("TTW already in mods dir - skipping copy step")
# Create background thread for integration
class IntegrationThread(QThread):

View File

@@ -64,6 +64,7 @@ class MainMenu(QWidget):
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
# ("Third Party Tools", "third_party_tools", "Install and manage Sulfur's Linux-native modding tools"), # v0.7
("Exit Jackify", "exit_jackify", "Close the application"),
]
@@ -150,6 +151,8 @@ class MainMenu(QWidget):
self.stacked_widget.setCurrentIndex(2)
elif action_id == "additional_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(3)
elif action_id == "third_party_tools" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(10)
elif action_id == "return_main_menu":
pass
elif self.stacked_widget:

View File

@@ -248,6 +248,40 @@ class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixi
self.modlist_selected.emit(metadata)
self.accept()
def closeEvent(self, event):
"""Stop background threads before the dialog is destroyed."""
# Stop the loading dot animation timer first
timer = getattr(self, '_loading_dot_timer', None)
if timer is not None:
timer.stop()
for attr in ('_loader_thread', '_validation_thread'):
thread = getattr(self, attr, None)
if thread is None:
continue
# Disconnect all signals before terminating - prevents callbacks into
# a partially-destroyed dialog
try:
thread.disconnect()
except Exception:
pass
if thread.isRunning():
# terminate() is required here: these threads run plain Python code
# with no Qt event loop, so quit() is a no-op and wait() alone
# would time out, leaving the thread running when the C++ QThread
# object is destroyed (which aborts the process).
thread.terminate()
thread.wait(3000)
# Abort any pending image network requests
if hasattr(self, 'image_manager'):
try:
self.image_manager.network_manager.clearConnectionCache()
except Exception:
pass
super().closeEvent(event)
# Re-export for backward compatibility
__all__ = ['ImageManager', 'ModlistCard', 'ModlistDetailDialog', 'ModlistGalleryDialog']

View File

@@ -16,7 +16,7 @@ class FocusReclaimMixin:
"""
def _stop_focus_reclaim(self):
pass # No timer to stop single-shot, no state
pass # No timer to stop - single-shot, no state
def _start_focus_reclaim_retries(self):
QTimer.singleShot(500, self._focus_reclaim_tick)

View File

@@ -0,0 +1,478 @@
"""
Third Party Tools screen.
Lists independently-managed tools with install status, version info,
and Install / Update / Downgrade / Uninstall actions per tool.
Version checks run in a background thread so the screen loads instantly.
"""
import logging
from typing import Dict, Optional
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from jackify.backend.services.tool_registry import TOOL_DEFINITIONS, ToolRegistry, ToolStatus
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
from jackify.frontends.gui.services.message_service import MessageService
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
from jackify.frontends.gui.utils import set_responsive_minimum
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
_BTN_INSTALL = "#1a5fa8"
_BTN_UPDATE = "#2a6e2a"
_BTN_DOWNGRADE = "#7a5a00"
_BTN_UNINSTALL = "#6b2020"
_BTN_DISABLED = "#333"
_BADGE_NOT_INSTALLED = ("#555", "#ccc") # bg, fg
_BADGE_UP_TO_DATE = ("#1e4d1e", "#8fdc8f")
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
_BADGE_CHECKING = ("#333", "#888")
def _btn_style(colour: str, disabled: bool = False) -> str:
bg = _BTN_DISABLED if disabled else colour
return f"""
QPushButton {{
background-color: {bg};
color: {'#666' if disabled else 'white'};
border: none; border-radius: 4px;
font-size: 11px; font-weight: bold;
padding: 4px 8px;
}}
QPushButton:hover {{ background-color: {'#444' if disabled else bg}; }}
QPushButton:pressed {{ background-color: {bg}; }}
"""
# ---------------------------------------------------------------------------
# Background version-check thread
# ---------------------------------------------------------------------------
class _VersionCheckThread(QThread):
version_ready = Signal(str, str) # tool_id, latest_version_tag
def run(self):
registry = ToolRegistry()
for defn in TOOL_DEFINITIONS:
try:
tag = registry.check_latest_version(defn.tool_id)
if tag:
self.version_ready.emit(defn.tool_id, tag)
except Exception as e:
logger.debug("Version check failed for %s: %s", defn.tool_id, e)
# ---------------------------------------------------------------------------
# Background install/update/downgrade/uninstall thread
# ---------------------------------------------------------------------------
class _ToolActionThread(QThread):
finished_signal = Signal(str, bool, str) # tool_id, success, message
def __init__(self, tool_id: str, action: str):
super().__init__()
self._tool_id = tool_id
self._action = action
def run(self):
registry = ToolRegistry()
try:
if self._action == "install":
ok, msg = registry.install(self._tool_id)
elif self._action == "update":
ok, msg = registry.update(self._tool_id)
elif self._action == "downgrade":
ok, msg = registry.downgrade(self._tool_id)
elif self._action == "uninstall":
ok, msg = registry.uninstall(self._tool_id)
else:
ok, msg = False, f"Unknown action: {self._action}"
except Exception as e:
ok, msg = False, str(e)
self.finished_signal.emit(self._tool_id, ok, msg)
# ---------------------------------------------------------------------------
# Per-tool card widget
# ---------------------------------------------------------------------------
class _ToolCard(QFrame):
action_requested = Signal(str, str) # tool_id, action
def __init__(self, status: ToolStatus, parent=None):
super().__init__(parent)
self._tool_id = status.definition.tool_id
self._status = status
self._busy = False
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet("""
QFrame {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
}
""")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
outer = QHBoxLayout()
outer.setContentsMargins(14, 10, 14, 10)
outer.setSpacing(12)
# --- Left: name + description ---
info_col = QVBoxLayout()
info_col.setSpacing(2)
tier_tag = " [required]" if status.definition.tier == 1 else ""
name_label = QLabel(f"<b>{status.definition.display_name}</b>{tier_tag}")
name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
info_col.addWidget(name_label)
desc_label = QLabel(status.definition.description)
desc_label.setWordWrap(True)
desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;")
info_col.addWidget(desc_label)
info_widget = QWidget()
info_widget.setLayout(info_col)
info_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
info_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(info_widget, stretch=3)
# --- Centre: status badge + version ---
centre_col = QVBoxLayout()
centre_col.setSpacing(4)
centre_col.setAlignment(Qt.AlignCenter)
self._badge = QLabel()
self._badge.setAlignment(Qt.AlignCenter)
self._badge.setFixedWidth(130)
self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;")
centre_col.addWidget(self._badge, alignment=Qt.AlignCenter)
self._version_label = QLabel()
self._version_label.setAlignment(Qt.AlignCenter)
self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;")
centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter)
centre_widget = QWidget()
centre_widget.setLayout(centre_col)
centre_widget.setFixedWidth(150)
centre_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(centre_widget)
# --- Right: action buttons ---
btn_col = QVBoxLayout()
btn_col.setSpacing(4)
btn_col.setAlignment(Qt.AlignCenter)
self._btn_primary = QPushButton()
self._btn_primary.setFixedWidth(90)
self._btn_primary.clicked.connect(self._on_primary)
btn_col.addWidget(self._btn_primary)
self._btn_downgrade = QPushButton("Downgrade")
self._btn_downgrade.setFixedWidth(90)
self._btn_downgrade.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "downgrade"))
btn_col.addWidget(self._btn_downgrade)
self._btn_uninstall = QPushButton("Uninstall")
self._btn_uninstall.setFixedWidth(90)
self._btn_uninstall.clicked.connect(self._on_uninstall)
btn_col.addWidget(self._btn_uninstall)
btn_widget = QWidget()
btn_widget.setLayout(btn_col)
btn_widget.setFixedWidth(110)
btn_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(btn_widget)
self.setLayout(outer)
self._refresh_ui(status)
# ------------------------------------------------------------------
def _refresh_ui(self, status: ToolStatus):
self._status = status
installed = status.installed
update_avail = status.update_available
can_downgrade = status.can_downgrade
can_uninstall = status.definition.can_uninstall
# Badge
if not installed:
bg, fg = _BADGE_NOT_INSTALLED
badge_text = "Not Installed"
elif update_avail:
bg, fg = _BADGE_UPDATE_AVAIL
badge_text = "Update Available"
else:
bg, fg = _BADGE_UP_TO_DATE
badge_text = "Installed"
self._badge.setText(badge_text)
self._badge.setStyleSheet(
f"background-color: {bg}; color: {fg}; border-radius: 3px; "
f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
)
# Version line
installed_ver = status.installed_version or "-"
latest_ver = status.latest_version or "checking..."
if installed:
self._version_label.setText(f"Installed: {installed_ver}\nLatest: {latest_ver}")
else:
self._version_label.setText(f"Latest: {latest_ver}")
# Primary button
if not installed:
self._btn_primary.setText("Install")
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
self._btn_primary.setEnabled(True)
elif update_avail:
self._btn_primary.setText("Update")
self._btn_primary.setStyleSheet(_btn_style(_BTN_UPDATE))
self._btn_primary.setEnabled(True)
else:
self._btn_primary.setText("Reinstall")
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
self._btn_primary.setEnabled(True)
# Downgrade button
self._btn_downgrade.setStyleSheet(_btn_style(_BTN_DOWNGRADE, disabled=not can_downgrade))
self._btn_downgrade.setEnabled(can_downgrade and not self._busy)
# Uninstall button
self._btn_uninstall.setVisible(can_uninstall)
if can_uninstall:
self._btn_uninstall.setStyleSheet(_btn_style(_BTN_UNINSTALL, disabled=not installed))
self._btn_uninstall.setEnabled(installed and not self._busy)
if self._busy:
self._btn_primary.setEnabled(False)
self._btn_primary.setStyleSheet(_btn_style(_BTN_DISABLED, disabled=True))
def set_latest_version(self, tag: str):
self._status.latest_version = tag
if self._status.installed and self._status.installed_version:
installed = self._status.installed_version.lstrip("v")
latest = tag.lstrip("v")
self._status.update_available = latest != installed
self._refresh_ui(self._status)
def set_busy(self, busy: bool, label: Optional[str] = None):
self._busy = busy
if busy and label:
self._btn_primary.setText(label)
self._refresh_ui(self._status)
def mark_installed(self, version: str):
self._status.installed = True
self._status.installed_version = version
self._status.update_available = False
self._busy = False
self._refresh_ui(self._status)
def mark_uninstalled(self):
self._status.installed = False
self._status.installed_version = None
self._status.update_available = False
self._busy = False
self._refresh_ui(self._status)
# ------------------------------------------------------------------
def _on_primary(self):
if not self._status.installed:
self.action_requested.emit(self._tool_id, "install")
elif self._status.update_available:
self.action_requested.emit(self._tool_id, "update")
else:
self.action_requested.emit(self._tool_id, "install")
def _on_uninstall(self):
confirmed = MessageService.question(
self,
"Uninstall Tool",
f"Uninstall {self._status.definition.display_name}?\n\nThis will delete the installed files.",
)
if confirmed:
self.action_requested.emit(self._tool_id, "uninstall")
# ---------------------------------------------------------------------------
# Main screen
# ---------------------------------------------------------------------------
class ThirdPartyToolsScreen(ThreadLifecycleMixin, QWidget):
"""Third Party Tools management screen."""
def __init__(self, stacked_widget=None, main_menu_index: int = 0, parent=None):
super().__init__(parent)
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self._cards: Dict[str, _ToolCard] = {}
self._action_thread: Optional[_ToolActionThread] = None
self._version_thread: Optional[_VersionCheckThread] = None
self._setup_ui()
def _setup_ui(self):
root = QVBoxLayout()
root.setContentsMargins(30, 24, 30, 24)
root.setSpacing(0)
self.setLayout(root)
# Header
title = QLabel("<b>Third Party Tools</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
root.addWidget(title)
root.addSpacing(6)
desc = QLabel(
"Install and manage independently-updated tools used by Jackify workflows or run via MO2.\n"
"Tools marked [required] are needed by existing Jackify workflows."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #aaa; font-size: 12px;")
desc.setAlignment(Qt.AlignHCenter)
root.addWidget(desc)
root.addSpacing(10)
sep = QLabel()
sep.setFixedHeight(1)
sep.setStyleSheet("background: #444;")
root.addWidget(sep)
root.addSpacing(12)
# Scrollable tool list
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
list_widget = QWidget()
list_widget.setStyleSheet("background: transparent;")
self._list_layout = QVBoxLayout()
self._list_layout.setContentsMargins(0, 0, 0, 0)
self._list_layout.setSpacing(8)
list_widget.setLayout(self._list_layout)
registry = ToolRegistry()
for status in registry.get_all_statuses():
card = _ToolCard(status)
card.action_requested.connect(self._on_action)
self._cards[status.definition.tool_id] = card
self._list_layout.addWidget(card)
self._list_layout.addStretch()
scroll.setWidget(list_widget)
root.addWidget(scroll, stretch=1)
root.addSpacing(12)
# Back button
back_row = QHBoxLayout()
back_row.addStretch()
back_btn = QPushButton("Back to Main Menu")
back_btn.setFixedSize(160, 34)
back_btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568; color: white;
border: none; border-radius: 5px;
font-size: 12px; font-weight: bold;
}}
QPushButton:hover {{ background-color: #5a6578; }}
QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}
""")
back_btn.clicked.connect(self._go_back)
back_row.addWidget(back_btn)
back_row.addStretch()
root.addLayout(back_row)
# ------------------------------------------------------------------
# Version check on show
# ------------------------------------------------------------------
def showEvent(self, event):
super().showEvent(event)
try:
main_window = self.window()
if main_window:
set_responsive_minimum(main_window, min_width=960, min_height=520)
except Exception:
pass
self._start_version_check()
def _start_version_check(self):
if self._version_thread and self._version_thread.isRunning():
return
self._version_thread = _VersionCheckThread()
self._version_thread.version_ready.connect(self._on_version_ready)
self._version_thread.start()
def _on_version_ready(self, tool_id: str, tag: str):
card = self._cards.get(tool_id)
if card:
card.set_latest_version(tag)
# ------------------------------------------------------------------
# Action dispatch
# ------------------------------------------------------------------
def _on_action(self, tool_id: str, action: str):
if self._action_thread and self._action_thread.isRunning():
MessageService.information(self, "Busy", "Another operation is already running. Please wait.")
return
card = self._cards.get(tool_id)
if card:
label_map = {"install": "Installing...", "update": "Updating...",
"downgrade": "Downgrading...", "uninstall": "Removing..."}
card.set_busy(True, label_map.get(action, "Working..."))
self._action_thread = _ToolActionThread(tool_id, action)
self._action_thread.finished_signal.connect(self._on_action_finished)
self._action_thread.start()
def _on_action_finished(self, tool_id: str, success: bool, message: str):
self._action_thread = None
card = self._cards.get(tool_id)
if success:
registry = ToolRegistry()
status = registry.get_status(tool_id)
if status and status.installed and card:
card.mark_installed(status.installed_version or "")
if status.latest_version:
card.set_latest_version(status.latest_version)
elif card:
card.mark_uninstalled()
MessageService.information(self, "Done", message)
else:
if card:
card.set_busy(False)
MessageService.warning(self, "Failed", message)
# ------------------------------------------------------------------
def _go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup_processes(self):
self._park_all_threads()

View File

@@ -13,7 +13,7 @@ from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox,
QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox,
QMessageBox
)
from PySide6.QtCore import Qt, QThread, Signal, QSize
@@ -26,7 +26,7 @@ from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from ..services.message_service import MessageService
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
from ..utils import set_responsive_minimum
from ..utils import set_responsive_minimum, browse_directory
from ..widgets.file_progress_list import FileProgressList
from ..widgets.progress_indicator import OverallProgressIndicator
from .screen_back_mixin import ScreenBackMixin
@@ -367,13 +367,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
def _browse_folder(self):
"""Browse for installation folder"""
folder = QFileDialog.getExistingDirectory(
self,
"Select Wabbajack Installation Folder",
str(Path.home()),
QFileDialog.ShowDirsOnly
)
folder = browse_directory(self, "Select Wabbajack Installation Folder", str(Path.home()))
if folder:
self.install_folder = Path(folder).resolve()
self.install_dir_edit.setText(str(self.install_folder))
@@ -626,6 +620,15 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
def cleanup_processes(self):
self._stop_focus_reclaim()
if self.worker is not None:
try:
if self.worker.isRunning():
self.worker.requestInterruption()
self.worker.wait(5000)
self.worker.deleteLater()
except Exception:
pass
self.worker = None
def showEvent(self, event):
"""Called when widget becomes visible"""