mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-17 12:47:44 +02:00
Release v0.6.0
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
442
jackify/frontends/gui/screens/configure_tool_config_screen.py
Normal file
442
jackify/frontends/gui/screens/configure_tool_config_screen.py
Normal 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>"
|
||||
" xEdit family | Synthesis | 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} "
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
478
jackify/frontends/gui/screens/third_party_tools.py
Normal file
478
jackify/frontends/gui/screens/third_party_tools.py
Normal 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()
|
||||
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user