mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 02:23:45 +02:00
Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
Additional Tasks & Tools Screen
|
||||
|
||||
Simple screen for TTW automation only.
|
||||
Follows the same pattern as ModlistTasksScreen.
|
||||
Additional tools and automation. Follows the same pattern as ModlistTasksScreen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -23,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdditionalTasksScreen(QWidget):
|
||||
"""Simple Additional Tasks screen for TTW only"""
|
||||
"""Additional Tasks screen for automation and standalone tools."""
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
|
||||
install_mo2_screen_index: int = 9):
|
||||
@@ -67,7 +66,7 @@ class AdditionalTasksScreen(QWidget):
|
||||
header_layout.addSpacing(10)
|
||||
|
||||
# Description area with fixed height
|
||||
desc = QLabel("TTW automation, Wabbajack installer, and additional tools.")
|
||||
desc = QLabel("Wabbajack installer, MO2 setup, and additional tools.")
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #ccc; font-size: 13px;")
|
||||
desc.setAlignment(Qt.AlignHCenter)
|
||||
@@ -93,10 +92,11 @@ class AdditionalTasksScreen(QWidget):
|
||||
"""Set up the menu buttons section"""
|
||||
# Menu options
|
||||
MENU_ITEMS = [
|
||||
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"),
|
||||
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
|
||||
("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"),
|
||||
("Run Install Verifier", "run_verifier", "Check an installed modlist for common configuration problems"),
|
||||
("Configure Tool Compatibility", "tool_config", "Apply xEdit, Pandora and DLL fixes to an existing modlist prefix"),
|
||||
("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"),
|
||||
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
|
||||
("Create Diagnostic Bundle", "diagnostic_bundle", "Package logs and system info for support reporting"),
|
||||
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
|
||||
]
|
||||
|
||||
@@ -148,25 +148,19 @@ class AdditionalTasksScreen(QWidget):
|
||||
|
||||
def _handle_button_click(self, action_id):
|
||||
"""Handle button clicks"""
|
||||
if action_id == "ttw_install":
|
||||
self._show_ttw_info()
|
||||
if action_id == "run_verifier":
|
||||
self._run_install_verifier()
|
||||
elif action_id == "wabbajack_install":
|
||||
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 == "diagnostic_bundle":
|
||||
self._run_diagnostic_bundle()
|
||||
elif action_id == "return_main_menu":
|
||||
self._return_to_main_menu()
|
||||
|
||||
def _show_ttw_info(self):
|
||||
"""Navigate to TTW installation screen"""
|
||||
if self.stacked_widget:
|
||||
# Navigate to TTW installation screen (index 5)
|
||||
self.stacked_widget.setCurrentIndex(5)
|
||||
|
||||
def _show_wabbajack_installer(self):
|
||||
"""Navigate to Wabbajack installer screen"""
|
||||
if self.stacked_widget:
|
||||
@@ -178,20 +172,202 @@ class AdditionalTasksScreen(QWidget):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.install_mo2_screen_index)
|
||||
|
||||
def _show_coming_soon_info(self):
|
||||
"""Show coming soon info"""
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.information(
|
||||
self,
|
||||
"Coming Soon",
|
||||
"Additional tools and features will be added in future updates.\n\n"
|
||||
"Check back later for more functionality!"
|
||||
)
|
||||
|
||||
def _show_tool_config(self):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(11)
|
||||
|
||||
def _run_install_verifier(self):
|
||||
"""Prompt user to pick a modlist, run the verifier, and show results."""
|
||||
from ..services.message_service import MessageService
|
||||
try:
|
||||
from jackify.backend.services.install_verifier_service import _load_verifier
|
||||
verifier_mod = _load_verifier()
|
||||
modlists = verifier_mod.discover_installed_modlists()
|
||||
except Exception as e:
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Verifier Error",
|
||||
f"Could not load install verifier: {e}",
|
||||
)
|
||||
return
|
||||
|
||||
if not modlists:
|
||||
MessageService.information(
|
||||
self,
|
||||
"No Modlists Found",
|
||||
"No installed modlists were found in Steam shortcuts.\n\n"
|
||||
"Ensure ModOrganizer.exe shortcuts exist in Steam for your modlists.",
|
||||
)
|
||||
return
|
||||
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QListWidgetItem, QPushButton, QLabel, QHBoxLayout
|
||||
picker = QDialog(self)
|
||||
picker.setWindowTitle("Select Modlist to Verify")
|
||||
picker.setMinimumWidth(480)
|
||||
picker.setMinimumHeight(260)
|
||||
picker_layout = QVBoxLayout(picker)
|
||||
picker_layout.addWidget(QLabel("Select a modlist to verify:"))
|
||||
|
||||
lw = QListWidget()
|
||||
for m in modlists:
|
||||
pfx_ok = m["pfx"] and m["pfx"].is_dir()
|
||||
suffix = "" if pfx_ok else " (prefix not found)"
|
||||
item = QListWidgetItem(f"{m['name']}{suffix}")
|
||||
item.setData(1000, m)
|
||||
lw.addItem(item)
|
||||
lw.setCurrentRow(0)
|
||||
picker_layout.addWidget(lw)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
ok_btn = QPushButton("Run Verifier")
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
picker_layout.addLayout(btn_row)
|
||||
|
||||
ok_btn.clicked.connect(picker.accept)
|
||||
cancel_btn.clicked.connect(picker.reject)
|
||||
lw.itemDoubleClicked.connect(lambda _: picker.accept())
|
||||
|
||||
if picker.exec() != QDialog.Accepted:
|
||||
return
|
||||
|
||||
selected_item = lw.currentItem()
|
||||
if not selected_item:
|
||||
return
|
||||
selected = selected_item.data(1000)
|
||||
|
||||
pfx = selected.get("pfx")
|
||||
if not pfx or not pfx.is_dir():
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Prefix Not Found",
|
||||
f"The Proton prefix for '{selected['name']}' was not found.\n\n"
|
||||
"Launch the modlist from Steam at least once to create the prefix.",
|
||||
)
|
||||
return
|
||||
|
||||
from PySide6.QtCore import QThread, Signal as _Signal
|
||||
|
||||
class _VerifierThread(QThread):
|
||||
done = _Signal(object)
|
||||
|
||||
def __init__(self, verifier_module, entry, parent=None):
|
||||
super().__init__(parent)
|
||||
self._verifier = verifier_module
|
||||
self._entry = entry
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
r = self._verifier.run_verification(
|
||||
pfx=self._entry["pfx"],
|
||||
modlist_dir=self._entry["modlist_dir"],
|
||||
game_type=self._entry["game_type"],
|
||||
appid=self._entry["appid"],
|
||||
modlist_name=self._entry.get("name", ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("On-demand verifier error: %s", exc)
|
||||
r = None
|
||||
self.done.emit(r)
|
||||
|
||||
from jackify.frontends.gui.services.message_service import MessageService as _MS
|
||||
progress_dlg = QDialog(self)
|
||||
progress_dlg.setWindowTitle("Verifying...")
|
||||
progress_dlg.setModal(True)
|
||||
prog_layout = QVBoxLayout(progress_dlg)
|
||||
prog_layout.addWidget(QLabel(f"Running verifier for '{selected['name']}'...\nThis may take a moment."))
|
||||
progress_dlg.setFixedSize(340, 100)
|
||||
progress_dlg.show()
|
||||
|
||||
self._verifier_ondemand_thread = _VerifierThread(verifier_mod, selected, parent=self)
|
||||
|
||||
def _on_done(results):
|
||||
progress_dlg.accept()
|
||||
self._verifier_ondemand_thread = None
|
||||
if results is None:
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Verifier Error",
|
||||
"The verifier encountered an error and could not complete.",
|
||||
)
|
||||
return
|
||||
from jackify.frontends.gui.dialogs.verification_results_dialog import VerificationResultsDialog
|
||||
dlg = VerificationResultsDialog(results, parent=self)
|
||||
dlg.exec()
|
||||
|
||||
self._verifier_ondemand_thread.done.connect(_on_done)
|
||||
self._verifier_ondemand_thread.start()
|
||||
|
||||
def _run_diagnostic_bundle(self):
|
||||
"""Open the diagnostic bundle dialog; bundle is only created when the user confirms."""
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit
|
||||
)
|
||||
from PySide6.QtCore import QThread, Signal as _Signal
|
||||
|
||||
class _BundleThread(QThread):
|
||||
done = _Signal(object, str) # (bundle_path or None, error_msg)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.diagnostic_service import build_bundle
|
||||
path = build_bundle()
|
||||
self.done.emit(path, "")
|
||||
except Exception as exc:
|
||||
self.done.emit(None, str(exc))
|
||||
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Diagnostic Bundle")
|
||||
dlg.setMinimumWidth(600)
|
||||
dlg.setMinimumHeight(220)
|
||||
dlg.setStyleSheet("QDialog { background: #181818; color: #fff; }")
|
||||
|
||||
layout = QVBoxLayout(dlg)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
status_label = QLabel("Package logs and system info into a file for support reporting.")
|
||||
layout.addWidget(status_label)
|
||||
|
||||
path_box = QTextEdit()
|
||||
path_box.setReadOnly(True)
|
||||
path_box.setMinimumHeight(60)
|
||||
path_box.setVisible(False)
|
||||
layout.addWidget(path_box)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
create_btn = QPushButton("Create Bundle")
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
btn_row.addWidget(create_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
cancel_btn.clicked.connect(dlg.reject)
|
||||
|
||||
def _on_create():
|
||||
create_btn.setEnabled(False)
|
||||
cancel_btn.setEnabled(False)
|
||||
status_label.setText("Collecting logs and system info...")
|
||||
self._diag_thread = _BundleThread(parent=self)
|
||||
self._diag_thread.done.connect(_on_done)
|
||||
self._diag_thread.start()
|
||||
|
||||
def _on_done(bundle_path, error):
|
||||
self._diag_thread = None
|
||||
cancel_btn.setEnabled(True)
|
||||
cancel_btn.setText("Close")
|
||||
if not bundle_path:
|
||||
status_label.setText(f"Failed: {error}")
|
||||
return
|
||||
status_label.setText("Bundle created:")
|
||||
path_box.setPlainText(str(bundle_path))
|
||||
path_box.setVisible(True)
|
||||
|
||||
create_btn.clicked.connect(_on_create)
|
||||
dlg.exec()
|
||||
|
||||
def _return_to_main_menu(self):
|
||||
"""Return to main menu"""
|
||||
if self.stacked_widget:
|
||||
|
||||
@@ -22,7 +22,6 @@ from jackify.backend.handlers.subprocess_utils import ProcessManager
|
||||
from jackify.backend.services.api_key_service import APIKeyService
|
||||
from jackify.backend.services.resolution_service import ResolutionService
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from ..dialogs import SuccessDialog
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,12 +32,14 @@ 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 .install_verifier_mixin import InstallVerifierMixin
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
class ConfigureExistingModlistScreen(
|
||||
ThreadLifecycleMixin,
|
||||
ScreenBackMixin,
|
||||
TTWIntegrationMixin,
|
||||
InstallVerifierMixin,
|
||||
ConfigureExistingModlistUIMixin,
|
||||
ConfigureExistingModlistWorkflowMixin,
|
||||
ConfigureExistingModlistShortcutsMixin,
|
||||
@@ -73,6 +74,7 @@ class ConfigureExistingModlistScreen(
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
self._kill_prefix_wine_processes(str(getattr(self, '_current_appid', '') or ''))
|
||||
self.cleanup_processes()
|
||||
self.collapse_show_details_before_leave()
|
||||
self.go_back()
|
||||
@@ -105,6 +107,10 @@ class ConfigureExistingModlistScreen(
|
||||
|
||||
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
|
||||
"""Handle configuration completion"""
|
||||
if getattr(self, '_awaiting_steam_restart', False):
|
||||
self._deferred_completion_args = (success, message, modlist_name, enb_detected)
|
||||
return
|
||||
|
||||
# Re-enable all controls when workflow completes
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
@@ -137,34 +143,27 @@ class ConfigureExistingModlistScreen(
|
||||
'time_taken': self._calculate_time_taken(),
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
'install_dir': install_dir,
|
||||
'game_type': game_type,
|
||||
'appid': getattr(self, '_current_appid', '') or '',
|
||||
}
|
||||
return
|
||||
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show success dialog with celebration
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="configure_existing",
|
||||
time_taken=time_taken,
|
||||
game_name=getattr(self, '_current_game_name', None),
|
||||
parent=self
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=install_dir,
|
||||
game_type=game_type,
|
||||
appid=getattr(self, '_current_appid', '') or '',
|
||||
success_params={
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'configure_existing',
|
||||
'time_taken': time_taken,
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
|
||||
if enb_detected:
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Failed to show ENB dialog: %s", e)
|
||||
else:
|
||||
self._safe_append_text(f"Configuration failed: {message}")
|
||||
MessageService.show_error(self, configuration_failed(str(message)))
|
||||
@@ -184,17 +183,19 @@ class ConfigureExistingModlistScreen(
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
|
||||
try:
|
||||
getattr(self.config_thread, sig_name).disconnect()
|
||||
except (RuntimeError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000)
|
||||
|
||||
# wait() ensures the OS thread has fully exited before deleteLater,
|
||||
# preventing "QThread destroyed while still running" if isRunning()
|
||||
# briefly trails the actual thread exit.
|
||||
self.config_thread.wait(3000)
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
|
||||
@@ -225,7 +226,7 @@ class ConfigureExistingModlistScreen(
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running threads when the screen is closed"""
|
||||
logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread")
|
||||
logger.debug("cleanup called - cleaning up ConfigurationThread")
|
||||
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
@@ -233,7 +234,7 @@ class ConfigureExistingModlistScreen(
|
||||
|
||||
# Clean up config thread if running
|
||||
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
|
||||
logger.debug("DEBUG: Parking ConfigurationThread")
|
||||
logger.debug("Parking ConfigurationThread")
|
||||
self.config_thread = self._park_thread(
|
||||
self.config_thread,
|
||||
["progress_update", "configuration_complete", "error_occurred"],
|
||||
|
||||
@@ -12,6 +12,52 @@ class ConfigureExistingModlistConsoleMixin:
|
||||
|
||||
def _handle_progress_update(self, text):
|
||||
"""Handle progress updates - update console, activity window, and progress indicator"""
|
||||
if text.startswith("[NATIVE_DL] "):
|
||||
parts = text.split(None, 3)
|
||||
if len(parts) == 4:
|
||||
_, component, pct_str, speed_str = parts
|
||||
try:
|
||||
pct, speed = float(pct_str), float(speed_str)
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component} | {pct:.0f}% ({speed:.1f} MB/s)",
|
||||
pct,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
return
|
||||
if text.startswith("[NATIVE_INSTALL] "):
|
||||
component = text[17:].strip()
|
||||
self._stop_component_install_pulse()
|
||||
done = getattr(self, '_native_done_components', 0)
|
||||
total = getattr(self, '_native_total_components', 0)
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component}{suffix}",
|
||||
0.0,
|
||||
)
|
||||
self.progress_indicator.set_status(f"Installing {component}...", 60)
|
||||
self._current_native_component = component
|
||||
self._native_done_components = done + 1
|
||||
return
|
||||
if text.startswith("[NATIVE_WAIT] "):
|
||||
parts = text.split(None, 2)
|
||||
if len(parts) >= 3:
|
||||
component, elapsed_s = parts[1], parts[2].strip()
|
||||
done = getattr(self, '_native_done_components', 1)
|
||||
total = getattr(self, '_native_total_components', 0)
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component} | {elapsed_s}s{suffix}",
|
||||
0.0,
|
||||
)
|
||||
self.progress_indicator.set_status(f"Installing {component}... ({elapsed_s}s)", 60)
|
||||
return
|
||||
|
||||
# Always append to console
|
||||
self._safe_append_text(text)
|
||||
|
||||
@@ -27,13 +73,24 @@ class ConfigureExistingModlistConsoleMixin:
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Applying registry files...", 40)
|
||||
self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0)
|
||||
elif "installing wine components" in message_lower or "wine component" in message_lower:
|
||||
elif (
|
||||
"installing wine components" in message_lower
|
||||
or "wine component" in message_lower
|
||||
or "vcrun" in message_lower
|
||||
or ("dotnet" in message_lower and "fix" not in message_lower)
|
||||
):
|
||||
self.progress_indicator.set_status("Installing wine components...", 60)
|
||||
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
|
||||
self._start_component_install_pulse()
|
||||
comp_list = self._parse_wine_components_message(text)
|
||||
if comp_list:
|
||||
self._start_component_install_pulse_with_components(comp_list)
|
||||
self._native_total_components = len(comp_list)
|
||||
self._native_done_components = 0
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine components: {len(comp_list)} queued",
|
||||
0.0,
|
||||
)
|
||||
elif not getattr(self, '_component_install_timer', None) or not self._component_install_timer.isActive():
|
||||
self._start_component_install_pulse()
|
||||
elif "wine components verified" in message_lower or "wine components installed" in message_lower:
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Wine components installed", 65)
|
||||
@@ -127,14 +184,22 @@ class ConfigureExistingModlistConsoleMixin:
|
||||
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
|
||||
return
|
||||
if hasattr(self, '_component_install_list') and self._component_install_list:
|
||||
progresses = [
|
||||
FileProgress(
|
||||
filename=f"Wine component: {comp}",
|
||||
operation=OperationType.UNKNOWN,
|
||||
percent=0.0,
|
||||
)
|
||||
for comp in self._component_install_list
|
||||
]
|
||||
dl_state = getattr(self, '_native_component_progress', {})
|
||||
progresses = []
|
||||
for comp in self._component_install_list:
|
||||
if comp in dl_state:
|
||||
pct, speed = dl_state[comp]
|
||||
progresses.append(FileProgress(
|
||||
filename=f"Wine component: {comp} | {pct:.0f}% ({speed:.1f} MB/s)",
|
||||
operation=OperationType.DOWNLOAD,
|
||||
percent=pct,
|
||||
))
|
||||
else:
|
||||
progresses.append(FileProgress(
|
||||
filename=f"Wine component: {comp}",
|
||||
operation=OperationType.UNKNOWN,
|
||||
percent=0.0,
|
||||
))
|
||||
self.file_progress_list.update_files(progresses, current_phase=None)
|
||||
else:
|
||||
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
|
||||
@@ -146,5 +211,7 @@ class ConfigureExistingModlistConsoleMixin:
|
||||
self._component_install_timer = None
|
||||
if hasattr(self, '_component_install_list'):
|
||||
del self._component_install_list
|
||||
if hasattr(self, '_native_component_progress'):
|
||||
del self._native_component_progress
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class ConfigureExistingModlistUIMixin:
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None):
|
||||
super().__init__()
|
||||
logger.debug("DEBUG: ConfigureExistingModlistScreen __init__ called")
|
||||
logger.debug("ConfigureExistingModlistScreen __init__ called")
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
@@ -183,7 +183,7 @@ class ConfigureExistingModlistUIMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
|
||||
@@ -56,20 +56,21 @@ 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', ''))
|
||||
raw_appid = shortcut.get('AppID', shortcut.get('appid', ''))
|
||||
self._current_appid = str(raw_appid) if raw_appid != '' else ''
|
||||
resolution = self.resolution_combo.currentText()
|
||||
# Handle resolution saving
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
logger.debug("Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
logger.debug("Saved resolution cleared")
|
||||
# Start the workflow (no shortcut creation needed)
|
||||
self.start_workflow(modlist_name, install_dir, resolution)
|
||||
|
||||
@@ -104,6 +105,7 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
progress_update = Signal(str)
|
||||
configuration_complete = Signal(bool, str, str, bool)
|
||||
error_occurred = Signal(str)
|
||||
steam_restart_needed = Signal(str, str, str) # app_name, exe_path, dl_path
|
||||
|
||||
def __init__(self, modlist_name, install_dir, resolution, system_info, detect_func):
|
||||
super().__init__()
|
||||
@@ -128,10 +130,11 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
|
||||
# Create modlist context for existing modlist configuration
|
||||
mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe")
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
modlist_context = ModlistContext(
|
||||
name=self.modlist_name,
|
||||
install_dir=Path(self.install_dir),
|
||||
download_dir=None,
|
||||
download_dir=resolve_mo2_download_dir(Path(self.install_dir)),
|
||||
game_type=detected_game_type,
|
||||
nexus_api_key='', # Not needed for configuration-only
|
||||
modlist_value='', # Not needed for existing modlist
|
||||
@@ -147,26 +150,31 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
# Define callbacks
|
||||
def progress_callback(message):
|
||||
self.progress_update.emit(message)
|
||||
|
||||
|
||||
# Store completion args rather than emitting immediately so we can
|
||||
# emit steam_restart_needed first when a restart is required.
|
||||
completion_args = [None]
|
||||
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Existing modlists shouldn't need manual steps, but handle gracefully
|
||||
self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})")
|
||||
|
||||
# Call the working configuration service method
|
||||
completion_args[0] = (success, message, modlist_name, enb_detected)
|
||||
|
||||
self.progress_update.emit("Starting existing modlist configuration...")
|
||||
|
||||
# For existing modlists, call configure_modlist_post_steam directly
|
||||
# since Steam setup and manual steps should already be done
|
||||
success = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
if getattr(modlist_context, 'steam_restart_needed', False):
|
||||
self.steam_restart_needed.emit(
|
||||
getattr(modlist_context, 'mounts_app_name', ''),
|
||||
getattr(modlist_context, 'mounts_exe_path', ''),
|
||||
getattr(modlist_context, 'mounts_dl_path', ''),
|
||||
)
|
||||
|
||||
if completion_args[0] is not None:
|
||||
self.configuration_complete.emit(*completion_args[0])
|
||||
|
||||
if not success:
|
||||
self.error_occurred.emit(
|
||||
"Configuration did not complete successfully. "
|
||||
@@ -183,12 +191,53 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
self.config_thread.progress_update.connect(self._handle_progress_update)
|
||||
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
|
||||
self.config_thread.error_occurred.connect(self.on_configuration_error)
|
||||
self.config_thread.steam_restart_needed.connect(self._on_steam_restart_needed) # (app_name, exe_path, dl_path)
|
||||
self.config_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
|
||||
MessageService.show_error(self, configuration_failed(str(e)))
|
||||
|
||||
def _on_steam_restart_needed(self, app_name: str, exe_path: str, dl_path: str):
|
||||
"""Prompt before stopping Steam to write a deferred STEAM_COMPAT_MOUNTS update.
|
||||
|
||||
Receives the shortcut identity and path from the signal so it can call
|
||||
ensure_mounts_in_steam_compat on the GUI's ShortcutHandler (not the backend's).
|
||||
|
||||
Sets _awaiting_steam_restart before showing the dialog so that
|
||||
on_configuration_complete (fired by the nested event loop during exec())
|
||||
defers the success/ENB dialogs until after this handler finishes.
|
||||
"""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
self._awaiting_steam_restart = True
|
||||
try:
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"Restart Steam?",
|
||||
"The download directory mount needs to be added to STEAM_COMPAT_MOUNTS for this "
|
||||
"modlist. Steam must be stopped to write this change safely.\n\n"
|
||||
"Any running game will be closed. Do you want Jackify to restart Steam now?",
|
||||
safety_level="medium",
|
||||
)
|
||||
if reply == QMessageBox.No:
|
||||
logger.info("User declined Steam restart; STEAM_COMPAT_MOUNTS update skipped")
|
||||
else:
|
||||
try:
|
||||
from jackify.backend.services.steam_restart_service import shutdown_steam, start_steam
|
||||
shutdown_steam()
|
||||
self.shortcut_handler.ensure_mounts_in_steam_compat(app_name, exe_path, dl_path)
|
||||
start_steam()
|
||||
except Exception as e:
|
||||
logger.warning("Steam restart/mounts update failed: %s", e)
|
||||
finally:
|
||||
self._awaiting_steam_restart = False
|
||||
if hasattr(self, '_deferred_completion_args') and self._deferred_completion_args is not None:
|
||||
args = self._deferred_completion_args
|
||||
self._deferred_completion_args = None
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(0, lambda: self.on_configuration_complete(*args))
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
|
||||
"""Check if VNV automation should run and start it if applicable.
|
||||
|
||||
@@ -227,26 +276,18 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type=params['workflow_type'],
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self,
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=params.get('install_dir', ''),
|
||||
game_type=params.get('game_type', 'unknown'),
|
||||
appid=params.get('appid', ''),
|
||||
success_params={
|
||||
'modlist_name': params['modlist_name'],
|
||||
'workflow_type': params['workflow_type'],
|
||||
'time_taken': params['time_taken'],
|
||||
'game_name': params.get('game_name'),
|
||||
'enb_detected': params.get('enb_detected', False),
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
def show_manual_steps_dialog(self, extra_warning=""):
|
||||
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
|
||||
|
||||
@@ -24,7 +24,6 @@ from jackify.backend.handlers.subprocess_utils import ProcessManager
|
||||
from jackify.backend.services.api_key_service import APIKeyService
|
||||
from jackify.backend.services.resolution_service import ResolutionService
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from ..dialogs import SuccessDialog
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
@@ -36,11 +35,12 @@ 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 .install_verifier_mixin import InstallVerifierMixin
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
|
||||
class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, InstallVerifierMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
|
||||
resize_request = Signal(str)
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
@@ -48,6 +48,8 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
appid = str(getattr(self, 'context', {}).get('appid', '') or '')
|
||||
self._kill_prefix_wine_processes(appid)
|
||||
self.cleanup_processes()
|
||||
self.collapse_show_details_before_leave()
|
||||
self.go_back()
|
||||
@@ -84,31 +86,27 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
|
||||
'time_taken': self._calculate_time_taken(),
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
'install_dir': install_dir,
|
||||
'game_type': game_type,
|
||||
'appid': getattr(self, '_current_appid', '') or '',
|
||||
}
|
||||
return
|
||||
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="configure_new",
|
||||
time_taken=time_taken,
|
||||
game_name=getattr(self, '_current_game_name', None),
|
||||
parent=self
|
||||
game_type = self._detect_game_type_from_mo2_ini(install_dir) if install_dir else "unknown"
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=install_dir or "",
|
||||
game_type=game_type,
|
||||
success_params={
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'configure_new',
|
||||
'time_taken': time_taken,
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if enb_detected:
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
else:
|
||||
self._safe_append_text(f"Configuration failed: {message}")
|
||||
MessageService.show_error(self, configuration_failed(str(message)))
|
||||
|
||||
@@ -15,6 +15,51 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
|
||||
|
||||
def _handle_progress_update(self, text):
|
||||
"""Handle progress updates - update console, activity window, and progress indicator."""
|
||||
if text.startswith("[NATIVE_DL] "):
|
||||
parts = text.split(None, 3)
|
||||
if len(parts) == 4:
|
||||
_, component, pct_str, speed_str = parts
|
||||
try:
|
||||
pct, speed = float(pct_str), float(speed_str)
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component} | {pct:.0f}% ({speed:.1f} MB/s)",
|
||||
pct,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
return
|
||||
if text.startswith("[NATIVE_INSTALL] "):
|
||||
component = text[17:].strip()
|
||||
self._stop_component_install_pulse()
|
||||
done = getattr(self, '_native_done_components', 0)
|
||||
total = getattr(self, '_native_total_components', 0)
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component}{suffix}",
|
||||
0.0,
|
||||
)
|
||||
self.progress_indicator.set_status(f"Installing {component}...", 80)
|
||||
self._current_native_component = component
|
||||
self._native_done_components = done + 1
|
||||
return
|
||||
if text.startswith("[NATIVE_WAIT] "):
|
||||
parts = text.split(None, 2)
|
||||
if len(parts) >= 3:
|
||||
component, elapsed_s = parts[1], parts[2].strip()
|
||||
done = getattr(self, '_native_done_components', 1)
|
||||
total = getattr(self, '_native_total_components', 0)
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component} | {elapsed_s}s{suffix}",
|
||||
0.0,
|
||||
)
|
||||
self.progress_indicator.set_status(f"Installing {component}... ({elapsed_s}s)", 80)
|
||||
return
|
||||
self._safe_append_text(text)
|
||||
|
||||
message_lower = text.lower()
|
||||
@@ -47,11 +92,17 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
|
||||
self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0)
|
||||
elif "installing wine components" in message_lower or "wine component" in message_lower:
|
||||
self.progress_indicator.set_status("Installing wine components...", 80)
|
||||
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
|
||||
self._start_component_install_pulse()
|
||||
comp_list = self._parse_wine_components_message(text)
|
||||
if comp_list:
|
||||
self._start_component_install_pulse_with_components(comp_list)
|
||||
self._native_total_components = len(comp_list)
|
||||
self._native_done_components = 0
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine components: {len(comp_list)} queued",
|
||||
0.0,
|
||||
)
|
||||
elif not hasattr(self, '_component_install_timer') or not self._component_install_timer:
|
||||
self._start_component_install_pulse()
|
||||
elif "wine components verified" in message_lower or "wine components installed" in message_lower:
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Wine components installed", 85)
|
||||
|
||||
@@ -234,26 +234,18 @@ class ConfigureNewModlistDialogsMixin:
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type=params['workflow_type'],
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self,
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=params.get('install_dir', ''),
|
||||
game_type=params.get('game_type', 'unknown'),
|
||||
appid=params.get('appid', ''),
|
||||
success_params={
|
||||
'modlist_name': params['modlist_name'],
|
||||
'workflow_type': params['workflow_type'],
|
||||
'time_taken': params['time_taken'],
|
||||
'game_name': params.get('game_name'),
|
||||
'enb_detected': params.get('enb_detected', False),
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
dlg = QDialog(self)
|
||||
|
||||
@@ -18,7 +18,7 @@ class ConfigureNewModlistUISetupMixin:
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, dev_mode=False, system_info=None):
|
||||
super().__init__()
|
||||
logger.debug("DEBUG: ConfigureNewModlistScreen __init__ called")
|
||||
logger.debug("ConfigureNewModlistScreen __init__ called")
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
self.dev_mode = dev_mode
|
||||
@@ -178,7 +178,7 @@ class ConfigureNewModlistUISetupMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
|
||||
@@ -95,14 +95,14 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
logger.debug("Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
logger.debug("Saved resolution cleared")
|
||||
|
||||
# Start configuration - automated workflow handles Steam restart internally
|
||||
self.configure_modlist()
|
||||
@@ -134,7 +134,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
ensure_flatpak_steam_filesystem_access(Path(install_dir))
|
||||
from jackify import __version__ as jackify_version
|
||||
logger.info("Jackify v%s", jackify_version)
|
||||
logger.info("Initializing automated Steam setup for '%s'...", modlist_name)
|
||||
logger.info("Initialising automated Steam setup for '%s'...", modlist_name)
|
||||
logger.info("Starting automated Steam shortcut creation and configuration...")
|
||||
|
||||
# Disable the start button to prevent multiple workflows
|
||||
@@ -162,9 +162,13 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
def progress_callback(message):
|
||||
self.progress_update.emit(message)
|
||||
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
download_dir = resolve_mo2_download_dir(Path(self.install_dir))
|
||||
|
||||
result = prefix_service.run_working_workflow(
|
||||
self.modlist_name, self.install_dir, self.mo2_exe_path,
|
||||
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart
|
||||
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart,
|
||||
download_dir=download_dir,
|
||||
)
|
||||
|
||||
self.workflow_complete.emit(result)
|
||||
@@ -304,8 +308,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
'modlist_source': None,
|
||||
'resolution': resolution_value,
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
||||
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
||||
'appid': new_appid,
|
||||
'game_name': 'Skyrim Special Edition' # Default for new modlist
|
||||
}
|
||||
self.context = updated_context # Ensure context is always set
|
||||
@@ -364,11 +367,6 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# This shouldn't happen since automated prefix creation is complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the service method for post-Steam configuration
|
||||
self.progress_update.emit("")
|
||||
self.progress_update.emit("=== Configuration Phase ===")
|
||||
self.progress_update.emit("")
|
||||
@@ -376,7 +374,6 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
@@ -412,8 +409,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
'mo2_exe_path': mo2_exe_path,
|
||||
'resolution': resolution.split()[0] if resolution != "Leave unchanged" else None,
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed
|
||||
'appid': new_appid, # Use the NEW AppID from Steam
|
||||
'appid': new_appid,
|
||||
'game_name': 'Skyrim Special Edition' # Default for new modlist
|
||||
}
|
||||
logger.debug(f"Updated context with new AppID: {new_appid}")
|
||||
@@ -472,17 +468,11 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Should not reach here -- manual steps already complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the working configuration service method
|
||||
self.progress_update.emit("Starting configuration with backend service...")
|
||||
|
||||
|
||||
success = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
|
||||
@@ -49,9 +49,10 @@ 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 .install_verifier_mixin import InstallVerifierMixin
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
|
||||
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifierMixin, 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)"""
|
||||
@@ -78,6 +79,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
self.nexus_login_btn,
|
||||
# Checkboxes
|
||||
self.auto_restart_checkbox,
|
||||
self.engine_checkbox,
|
||||
]
|
||||
|
||||
def _disable_controls_during_operation(self):
|
||||
@@ -113,12 +115,13 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
|
||||
|
||||
def _open_url_safe(self, url):
|
||||
"""Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime"""
|
||||
import subprocess
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
try:
|
||||
subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(['xdg-open', url], env=clean_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not open URL {url}: {e}")
|
||||
logger.warning(f"Could not open URL {url}: {e}")
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle window resize to prioritize form over console"""
|
||||
@@ -216,7 +219,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
||||
# DO NOT resize - let window stay at current size
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: showEvent exception: {e}")
|
||||
logger.debug(f"showEvent exception: {e}")
|
||||
|
||||
def _start_gallery_cache_preload(self):
|
||||
"""DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow"""
|
||||
@@ -248,22 +251,22 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
# Check if we got mods
|
||||
modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods)
|
||||
if modlists_with_mods > 0:
|
||||
logger.debug(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)")
|
||||
logger.debug(f"Gallery cache ready ({modlists_with_mods} modlists with mods)")
|
||||
else:
|
||||
# Cache didn't have mods, but we fetched fresh - should have mods now
|
||||
logger.debug("DEBUG: Gallery cache updated")
|
||||
logger.debug("Gallery cache updated")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to load gallery cache")
|
||||
logger.debug("Failed to load gallery cache")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Gallery cache preload error: {str(e)}")
|
||||
logger.debug(f"Gallery cache preload error: {str(e)}")
|
||||
|
||||
# Start thread (non-blocking, invisible to user)
|
||||
self._gallery_cache_preload_thread = GalleryCachePreloadThread()
|
||||
# Don't connect finished signal - we don't need to do anything, just let it run
|
||||
self._gallery_cache_preload_thread.start()
|
||||
|
||||
logger.debug("DEBUG: Started background gallery cache preload")
|
||||
logger.debug("Started background gallery cache preload")
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Called when the widget is hidden. Do not clear main window constraints so collapse from go_back() sticks."""
|
||||
@@ -284,17 +287,17 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
if saved_install_parent:
|
||||
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
|
||||
self.install_dir_edit.setText(suggested_install_dir)
|
||||
logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
||||
logger.debug(f"Updated install directory suggestion: {suggested_install_dir}")
|
||||
|
||||
# Update download directory suggestion
|
||||
saved_download_parent = self.config_handler.get_default_download_parent_dir()
|
||||
if saved_download_parent:
|
||||
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
|
||||
self.downloads_dir_edit.setText(suggested_download_dir)
|
||||
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
||||
logger.debug(f"Updated download directory suggestion: {suggested_download_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error updating directory suggestions: {e}")
|
||||
logger.debug(f"Error updating directory suggestions: {e}")
|
||||
|
||||
def _save_parent_directories(self, install_dir, downloads_dir):
|
||||
"""Removed automatic saving - user should set defaults in settings"""
|
||||
@@ -422,6 +425,15 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
self._stop_clf3_decompress_pulse()
|
||||
|
||||
fpl = getattr(self, 'file_progress_list', None)
|
||||
if fpl is not None:
|
||||
try:
|
||||
fpl.stop_cpu_tracking()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
@@ -446,7 +458,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
|
||||
logger.debug(f"DEBUG: Stopping {attr_name}")
|
||||
logger.debug(f"Stopping {attr_name}")
|
||||
|
||||
if cancel_method and hasattr(thread, cancel_method):
|
||||
try:
|
||||
@@ -543,13 +555,16 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
self._pending_manual_download_events = None
|
||||
|
||||
# Cancel the installation thread if it exists
|
||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python
|
||||
if self.install_thread.isRunning():
|
||||
logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying")
|
||||
try:
|
||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(5000)
|
||||
self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python
|
||||
if self.install_thread.isRunning():
|
||||
logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying")
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(5000)
|
||||
except RuntimeError:
|
||||
self.install_thread = None
|
||||
|
||||
# Park prefix/config threads - disconnect their signals and let them
|
||||
# finish naturally rather than terminating unsafely.
|
||||
@@ -559,6 +574,16 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
["progress_update", "workflow_complete", "error_occurred"],
|
||||
)
|
||||
if hasattr(self, 'config_thread') and self.config_thread:
|
||||
_ctx = getattr(self, 'context', None)
|
||||
_appid = str(
|
||||
getattr(self, '_current_appid', '')
|
||||
or ((_ctx.get('appid', '') if isinstance(_ctx, dict) else '') or '')
|
||||
)
|
||||
try:
|
||||
self.config_thread.requestInterruption()
|
||||
except Exception:
|
||||
pass
|
||||
self._kill_prefix_wine_processes(_appid)
|
||||
self.config_thread = self._park_thread(
|
||||
self.config_thread,
|
||||
["progress_update", "configuration_complete", "error_occurred"],
|
||||
|
||||
@@ -160,11 +160,11 @@ class AutomatedPrefixHandlersMixin:
|
||||
self.prefix_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception in start_automated_prefix_workflow: {e}")
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
logger.debug(f"Exception in start_automated_prefix_workflow: {e}")
|
||||
self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
|
||||
# Re-enable controls on exception
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
|
||||
def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None):
|
||||
"""Handle completion of automated prefix creation"""
|
||||
|
||||
@@ -5,7 +5,6 @@ from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
from PySide6.QtGui import QFont
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import manual_steps_incomplete, configuration_failed
|
||||
from jackify.frontends.gui.dialogs import SuccessDialog
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
from jackify.backend.models.modlist import ModlistContext
|
||||
from pathlib import Path
|
||||
@@ -22,7 +21,8 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
|
||||
def on_configuration_progress(self, progress_msg):
|
||||
"""Handle progress updates from modlist configuration"""
|
||||
self._safe_append_text(progress_msg)
|
||||
if not (progress_msg.startswith("[NATIVE_DL] ") or progress_msg.startswith("[NATIVE_INSTALL] ") or progress_msg.startswith("[NATIVE_WAIT] ")):
|
||||
self._safe_append_text(progress_msg)
|
||||
self._handle_post_install_progress(progress_msg)
|
||||
|
||||
def show_steam_restart_progress(self, message):
|
||||
@@ -90,7 +90,6 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
if self._show_somnium_guidance:
|
||||
self._show_somnium_post_install_guidance()
|
||||
|
||||
# Show celebration SuccessDialog after the entire workflow
|
||||
if not hasattr(self, '_install_workflow_start_time'):
|
||||
self._install_workflow_start_time = time.time()
|
||||
time_taken = int(time.time() - self._install_workflow_start_time)
|
||||
@@ -147,15 +146,16 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
|
||||
if vnv_automation_running:
|
||||
self._cleanup_config_thread()
|
||||
# Store success dialog params for later (after VNV automation completes)
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': "update" if getattr(self, "_is_update_install", False) else "install",
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': enb_detected
|
||||
'enb_detected': enb_detected,
|
||||
'install_dir': install_dir,
|
||||
'game_type': getattr(self, '_current_game_type', 'unknown') or 'unknown',
|
||||
'appid': getattr(self, '_current_appid', '') or '',
|
||||
}
|
||||
# Keep post-install feedback active during VNV automation
|
||||
# Don't show success dialog yet - will be shown in _on_vnv_complete
|
||||
return
|
||||
|
||||
# No VNV automation - end post-install feedback now
|
||||
@@ -167,29 +167,19 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
except Exception as e:
|
||||
logger.warning("Update mode verify: failed post-config INI verification: %s", e)
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show normal success dialog
|
||||
workflow_type = "update" if getattr(self, "_is_update_install", False) else "install"
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type=workflow_type,
|
||||
time_taken=time_str,
|
||||
game_name=game_name,
|
||||
parent=self
|
||||
game_type_for_verify = getattr(self, '_current_game_type', 'unknown') or 'unknown'
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=install_dir,
|
||||
game_type=game_type_for_verify,
|
||||
success_params={
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': workflow_type,
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': enb_detected,
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
|
||||
if enb_detected:
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
|
||||
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
|
||||
except Exception as e:
|
||||
# Non-blocking: if dialog fails, just log and continue
|
||||
logger.warning(f"Failed to show ENB dialog: {e}")
|
||||
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
||||
# Max retries reached - show failure message
|
||||
self._end_post_install_feedback(False)
|
||||
@@ -426,8 +416,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', None),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
||||
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
||||
'appid': new_appid,
|
||||
'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition'
|
||||
}
|
||||
self.context = updated_context # Ensure context is always set
|
||||
@@ -490,15 +479,9 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Should not reach here -- prefix creation already complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the service method for post-Steam configuration
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
@@ -533,8 +516,8 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', None),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed
|
||||
'appid': new_appid # Use the NEW AppID from Steam
|
||||
'appid': new_appid
|
||||
|
||||
}
|
||||
|
||||
logger.debug(f"Updated context with new AppID: {new_appid}")
|
||||
@@ -624,15 +607,9 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
def completion_callback(success, message, modlist_name):
|
||||
self.configuration_complete.emit(success, message, modlist_name)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Should not reach here -- manual steps already complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the new service method for post-Steam configuration
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Optional
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
import logging
|
||||
|
||||
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code
|
||||
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code, nexus_url_from_error_line
|
||||
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error
|
||||
from jackify.shared.errors import JackifyError, cc_content_missing, creation_kit_missing
|
||||
|
||||
@@ -35,14 +35,17 @@ 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):
|
||||
oauth_info=None, game_type=None, clf3_cdn_url=None, engine_id=None):
|
||||
super().__init__()
|
||||
self.modlist = modlist
|
||||
self.install_dir = install_dir
|
||||
self.downloads_dir = downloads_dir
|
||||
self.api_key = api_key
|
||||
self.modlist_name = modlist_name
|
||||
self.game_type = game_type
|
||||
self.install_mode = install_mode
|
||||
self.clf3_cdn_url = clf3_cdn_url
|
||||
self.engine_id = engine_id
|
||||
self.cancelled = False
|
||||
self.process_manager = None
|
||||
self.progress_state_manager = progress_state_manager
|
||||
@@ -126,16 +129,58 @@ class InstallerThread(QThread):
|
||||
|
||||
return False
|
||||
|
||||
# CLF3 tracing line patterns (after ANSI stripping, from tracing_subscriber::fmt default format)
|
||||
_CLF3_TRACING_INFO_RE = re.compile(
|
||||
r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(INFO|DEBUG|TRACE)\s+'
|
||||
)
|
||||
_CLF3_TRACING_WARN_RE = re.compile(
|
||||
r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(WARN|ERROR)\s+\S+:\s+(.*)'
|
||||
)
|
||||
_CLF3_PHASE_HEADER_RE = re.compile(r'^=== .+ ===$')
|
||||
_CLF3_ANSI_RE = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
|
||||
def _read_stderr(self):
|
||||
import time as _time
|
||||
_stderr_log = os.environ.get("JACKIFY_CLF3_VERBOSE")
|
||||
_stderr_fh = open("/tmp/clf3_stderr.log", "w", encoding="utf-8") if _stderr_log else None
|
||||
_last_clf3_phase: str = ''
|
||||
try:
|
||||
for raw in self.process_manager.proc.stderr:
|
||||
line = raw.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
logger.debug(f"Engine stderr: {line}")
|
||||
if _stderr_fh:
|
||||
_stderr_fh.write(line + "\n")
|
||||
_stderr_fh.flush()
|
||||
self._raw_stderr_lines.append(line)
|
||||
if len(self._raw_stderr_lines) > 40:
|
||||
self._raw_stderr_lines.pop(0)
|
||||
|
||||
# CLF3: JSON events are on stdout; human-readable detail and tracing go to stderr.
|
||||
# Forward phase headers and log() messages to Show Details.
|
||||
# Verbose INFO/DEBUG/TRACE tracing is dropped; WARN/ERROR is shown stripped.
|
||||
clf3_parser = getattr(self, '_clf3_parser', None)
|
||||
if clf3_parser is not None:
|
||||
clean = self._CLF3_ANSI_RE.sub('', line)
|
||||
error = parse_engine_error_line(clean)
|
||||
if error and self.last_error is None:
|
||||
self.last_error = error
|
||||
warn_m = self._CLF3_TRACING_WARN_RE.match(clean)
|
||||
if warn_m:
|
||||
self.output_received.emit(f"[WARN] {warn_m.group(2)}\n")
|
||||
elif not self._CLF3_TRACING_INFO_RE.match(clean):
|
||||
if self._CLF3_PHASE_HEADER_RE.match(clean):
|
||||
if clean != _last_clf3_phase:
|
||||
_last_clf3_phase = clean
|
||||
self.output_received.emit(clean + '\n')
|
||||
else:
|
||||
self.output_received.emit(clean + '\n')
|
||||
_act = getattr(self, '_clf3_last_activity', None)
|
||||
if _act is not None:
|
||||
_act[0] = _time.monotonic()
|
||||
continue
|
||||
|
||||
error = parse_engine_error_line(line)
|
||||
if error and self.last_error is None:
|
||||
self.last_error = error
|
||||
@@ -149,9 +194,13 @@ class InstallerThread(QThread):
|
||||
if self.last_error is None and is_cc_content_error(line):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(line) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(line):
|
||||
self.last_error = creation_kit_missing()
|
||||
self.last_error = creation_kit_missing(self.game_type)
|
||||
except Exception as e:
|
||||
logger.debug(f"Stderr reader error: {e}")
|
||||
finally:
|
||||
if _stderr_fh:
|
||||
_stderr_fh.close()
|
||||
logger.info("CLF3 stderr log written to /tmp/clf3_stderr.log")
|
||||
|
||||
def _remember_stdout_line(self, line: str) -> None:
|
||||
"""Keep a bounded tail of meaningful stdout lines for failure diagnostics."""
|
||||
@@ -214,6 +263,9 @@ class InstallerThread(QThread):
|
||||
"""Build a user-facing failure message with the best available root cause."""
|
||||
root_cause = self._extract_root_cause_line()
|
||||
if root_cause:
|
||||
nexus_url = nexus_url_from_error_line(root_cause)
|
||||
if nexus_url:
|
||||
root_cause = f"{root_cause}\nMod page: {nexus_url}"
|
||||
if self._resource_limit_hint and "file descriptor" not in root_cause.lower():
|
||||
return f"{root_cause}\n\nPossible contributing issue: {self._resource_limit_hint}"
|
||||
return root_cause
|
||||
@@ -251,42 +303,195 @@ class InstallerThread(QThread):
|
||||
"Install failed, but the engine did not provide a specific error line."
|
||||
)
|
||||
|
||||
def _run_clf3_heartbeat(self, last_activity: list, stop: threading.Event) -> None:
|
||||
"""Emit periodic 'finalising' updates to Show Details when stdout goes silent.
|
||||
|
||||
Fires once when silence exceeds THRESHOLD, then repeats every REPEAT seconds
|
||||
of continued silence — so extended waits remain visible to the user.
|
||||
Samples /proc/<pid>/io to show write throughput, giving the user concrete
|
||||
evidence that extraction is progressing even when CLF3 emits no output.
|
||||
Resets when real output arrives so a new silence period can trigger it again.
|
||||
"""
|
||||
import copy
|
||||
import time as _time
|
||||
THRESHOLD = 8.0
|
||||
INTERVAL = 5.0
|
||||
REPEAT = 30.0
|
||||
last_heartbeat_at = [0.0]
|
||||
while not stop.wait(INTERVAL):
|
||||
proc = self.process_manager.proc if self.process_manager else None
|
||||
if not proc or proc.poll() is not None:
|
||||
break
|
||||
now = _time.monotonic()
|
||||
elapsed_since_activity = now - last_activity[0]
|
||||
if elapsed_since_activity < THRESHOLD:
|
||||
continue
|
||||
# Allow first fire when silence starts; then only re-fire every REPEAT seconds.
|
||||
already_fired = last_heartbeat_at[0] >= last_activity[0]
|
||||
if already_fired and (now - last_heartbeat_at[0]) < REPEAT:
|
||||
continue
|
||||
clf3_parser = getattr(self, '_clf3_parser', None)
|
||||
phase = (clf3_parser.get_state().phase_name if clf3_parser else None) or 'Working'
|
||||
wait_secs = int(elapsed_since_activity)
|
||||
# Sample write_bytes to show active extraction throughput.
|
||||
# The N/N dispatch counter fires when all archives are handed to worker threads;
|
||||
# the workers themselves continue decompressing in parallel after that point.
|
||||
# /proc/<pid>/io write_bytes confirms real I/O is happening.
|
||||
if not already_fired:
|
||||
total = clf3_parser.get_state().phase_max_steps if clf3_parser else 0
|
||||
archive_str = f"{total} archives" if total else "archives"
|
||||
self.output_received.emit(f"[Decompressing {archive_str}: workers running...]\n")
|
||||
if clf3_parser:
|
||||
from jackify.shared.progress_models import InstallationPhase
|
||||
current = clf3_parser.get_state()
|
||||
heartbeat_state = copy.copy(current)
|
||||
heartbeat_state.phase = InstallationPhase.FINALIZE
|
||||
heartbeat_state.phase_name = "Decompressing"
|
||||
heartbeat_state.phase_step = 0
|
||||
heartbeat_state.phase_max_steps = 0
|
||||
heartbeat_state.overall_percent = 99.0
|
||||
heartbeat_state.message = "Decompressing archives..."
|
||||
self.progress_updated.emit(heartbeat_state)
|
||||
last_heartbeat_at[0] = now
|
||||
|
||||
def _run_clf3_stdout_loop(self, last_activity: list) -> None:
|
||||
"""Read CLF3 stdout line-by-line and forward to Show Details.
|
||||
|
||||
JSON progress events (keyed on "type") and plain human-readable text both arrive
|
||||
on stdout. Manual download events (keyed on "event") also appear on stdout.
|
||||
|
||||
Extraction dispatch counters ("Extracting: N/M") are dropped — named per-archive
|
||||
completion lines already cover this information.
|
||||
Directive counters ("Processing: N/M") are buffered; only the final value is
|
||||
emitted when the next non-counter line arrives, avoiding a 1315-line flood.
|
||||
"""
|
||||
import time as _time
|
||||
_COUNTER_LINE_RE = re.compile(r'^(?:Extracting|Building BSA|DDS Transform): \d+/\d+$')
|
||||
_PROCESSING_RE = re.compile(r'^Processing: \d+/\d+$')
|
||||
_PHASE_HEADER_RE = re.compile(r'^=== .+ ===$')
|
||||
_buffered_processing = None
|
||||
_last_phase_header: str = ''
|
||||
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
for raw in self.process_manager.proc.stdout:
|
||||
if self.cancelled:
|
||||
self.cancel()
|
||||
break
|
||||
decoded = ansi_escape.sub(b'', raw).decode('utf-8', errors='replace').rstrip('\r\n')
|
||||
stripped = decoded.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
last_activity[0] = _time.monotonic()
|
||||
self._remember_stdout_line(decoded)
|
||||
if self._handle_engine_event(decoded):
|
||||
continue
|
||||
if stripped.startswith('{'):
|
||||
clf3_parser = getattr(self, '_clf3_parser', None)
|
||||
if clf3_parser and clf3_parser.process_line(stripped):
|
||||
state = clf3_parser.get_state()
|
||||
self.progress_updated.emit(state)
|
||||
msg = state.message
|
||||
if msg.startswith('Extracting ') and '(' in msg and state.phase_name == "Extracting":
|
||||
# Relabel as "Queuing" — the N/M counter tracks archive dispatch
|
||||
# to worker threads, not completion of decompression.
|
||||
self.output_received.emit(msg.replace('Extracting ', 'Queuing ', 1) + '\n')
|
||||
continue
|
||||
if _COUNTER_LINE_RE.match(stripped):
|
||||
continue
|
||||
if _PROCESSING_RE.match(stripped):
|
||||
_buffered_processing = stripped
|
||||
continue
|
||||
if _buffered_processing is not None:
|
||||
self.output_received.emit(_buffered_processing + '\n')
|
||||
_buffered_processing = None
|
||||
if _PHASE_HEADER_RE.match(stripped):
|
||||
if stripped == _last_phase_header:
|
||||
continue
|
||||
_last_phase_header = stripped
|
||||
self.output_received.emit(decoded + '\n')
|
||||
if _buffered_processing is not None:
|
||||
self.output_received.emit(_buffered_processing + '\n')
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.core.modlist_operations import get_jackify_engine_path
|
||||
engine_path = get_jackify_engine_path()
|
||||
if not os.path.exists(engine_path):
|
||||
error_msg = f"Engine not found at: {engine_path}"
|
||||
logger.debug(f"DEBUG: {error_msg}")
|
||||
from jackify.backend.services.engine_invoker import (
|
||||
get_active_engine_id, get_engine_path, build_install_command,
|
||||
resolve_game_dir, resolve_game_location,
|
||||
)
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
|
||||
engine_id = self.engine_id if self.engine_id else get_active_engine_id()
|
||||
engine_path = get_engine_path(engine_id)
|
||||
if not engine_path or not os.path.exists(engine_path):
|
||||
error_msg = f"Engine not found: {engine_id} ({engine_path or 'path unknown'})"
|
||||
logger.error(error_msg)
|
||||
self.installation_finished.emit(False, error_msg)
|
||||
return
|
||||
if not os.access(engine_path, os.X_OK):
|
||||
error_msg = f"Engine is not executable: {engine_path}"
|
||||
logger.debug(f"DEBUG: {error_msg}")
|
||||
logger.error(error_msg)
|
||||
self.installation_finished.emit(False, error_msg)
|
||||
return
|
||||
logger.debug(f"DEBUG: Using engine at: {engine_path}")
|
||||
if self.install_mode == 'file':
|
||||
cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
|
||||
else:
|
||||
cmd = [engine_path, "install", "--show-file-progress", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
|
||||
logger.debug(f"Using engine {engine_id} at: {engine_path}")
|
||||
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if debug_mode:
|
||||
cmd.append('--debug')
|
||||
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
|
||||
logger.debug(f"DEBUG: FULL Engine command: {' '.join(cmd)}")
|
||||
logger.debug(f"DEBUG: modlist value being passed: '{self.modlist}'")
|
||||
game_dir = None
|
||||
clf3_mode = (engine_id == "clf3")
|
||||
if clf3_mode:
|
||||
location = resolve_game_location(self.game_type)
|
||||
if location:
|
||||
game_dir, game_store = location
|
||||
if game_store != 'steam':
|
||||
store_label = {'gog': 'GOG', 'epic': 'Epic Games'}.get(game_store, game_store)
|
||||
self.output_received.emit(
|
||||
f"[WARN] Game detected from {store_label}, not Steam. "
|
||||
"Most Wabbajack modlists require the Steam version. "
|
||||
"If the install fails with hash errors, a store version mismatch is likely the cause.\n"
|
||||
)
|
||||
else:
|
||||
logger.warning("CLF3: could not resolve game directory for game_type=%s", self.game_type)
|
||||
|
||||
if clf3_mode and self.clf3_cdn_url and not os.path.isfile(self.modlist):
|
||||
self.output_received.emit("Downloading modlist file via CLF3...\n")
|
||||
import subprocess as _sp
|
||||
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||
fetch_cmd = [engine_path, "fetch", self.clf3_cdn_url, "--output", self.modlist]
|
||||
logger.debug("CLF3 fetch command: %s", " ".join(fetch_cmd))
|
||||
fetch_env = get_clean_subprocess_env({})
|
||||
fetch_cwd = os.path.dirname(self.modlist) or os.path.expanduser("~")
|
||||
os.makedirs(fetch_cwd, exist_ok=True)
|
||||
fetch_result = _sp.run(fetch_cmd, capture_output=True, text=True, env=fetch_env, cwd=fetch_cwd)
|
||||
if fetch_result.returncode != 0:
|
||||
err = fetch_result.stderr.strip() or fetch_result.stdout.strip() or "unknown error"
|
||||
self.installation_finished.emit(False, f"Failed to download modlist file:\n\n{err}")
|
||||
return
|
||||
self.output_received.emit("Modlist file ready.\n")
|
||||
|
||||
cmd = build_install_command(
|
||||
engine_id=engine_id,
|
||||
engine_path=engine_path,
|
||||
wabbajack=self.modlist,
|
||||
install_dir=self.install_dir,
|
||||
downloads_dir=self.downloads_dir,
|
||||
game_dir=game_dir,
|
||||
install_mode=self.install_mode,
|
||||
debug=debug_mode,
|
||||
)
|
||||
logger.debug(f"FULL Engine command: {' '.join(cmd)}")
|
||||
logger.debug(f"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
|
||||
if clf3_mode:
|
||||
env_vars = {'NEXUS_OAUTH_TOKEN': self.api_key}
|
||||
else:
|
||||
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
|
||||
@@ -306,172 +511,188 @@ class InstallerThread(QThread):
|
||||
logger.debug(f"Resource preflight check failed: {e}")
|
||||
|
||||
from jackify.backend.handlers.subprocess_utils import ProcessManager
|
||||
if clf3_mode:
|
||||
import time as _time
|
||||
from jackify.backend.handlers.progress_parser_clf3 import CLF3ProgressStateManager
|
||||
self._clf3_parser = CLF3ProgressStateManager()
|
||||
self._clf3_last_activity = [_time.monotonic()]
|
||||
else:
|
||||
self._clf3_parser = None
|
||||
self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True, enable_stdin=True)
|
||||
stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
||||
stderr_thread.start()
|
||||
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
buffer = b''
|
||||
last_was_blank = False
|
||||
while True:
|
||||
if self.cancelled:
|
||||
self.cancel()
|
||||
break
|
||||
char = self.process_manager.read_stdout_char()
|
||||
if not char:
|
||||
break
|
||||
buffer += char
|
||||
while b'\n' in buffer or b'\r' in buffer:
|
||||
if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True):
|
||||
line, buffer = buffer.split(b'\r', 1)
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"Matched pattern: '{matched_pattern}'")
|
||||
logger.warning(f"Triggering line: '{decoded.strip()}'")
|
||||
logger.warning("AUTHENTICATION DIAGNOSTICS:")
|
||||
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
|
||||
if self.api_key:
|
||||
logger.warning(f" Auth value length: {len(self.api_key)} chars")
|
||||
if len(self.api_key) >= 8:
|
||||
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
|
||||
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
|
||||
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
|
||||
if auth_method == 'oauth' and self.auth_service:
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
logger.warning(" OAuth Token Status:")
|
||||
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
|
||||
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if 'refresh_token_age_days' in token_info:
|
||||
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
|
||||
if token_info.get('error'):
|
||||
logger.warning(f" Error: {token_info['error']}")
|
||||
logger.warning("Previous engine output (last 10 lines):")
|
||||
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
|
||||
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(decoded):
|
||||
self.last_error = creation_kit_missing()
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
self._install_progress_started = True
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.progress_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self.progress_received.emit(decoded + '\r')
|
||||
elif b'\n' in buffer:
|
||||
line, buffer = buffer.split(b'\n', 1)
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"Matched pattern: '{matched_pattern}'")
|
||||
logger.warning(f"Triggering line: '{decoded.strip()}'")
|
||||
logger.warning("AUTHENTICATION DIAGNOSTICS:")
|
||||
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
|
||||
if self.api_key:
|
||||
logger.warning(f" Auth value length: {len(self.api_key)} chars")
|
||||
if len(self.api_key) >= 8:
|
||||
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
|
||||
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
|
||||
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
|
||||
if auth_method == 'oauth' and self.auth_service:
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
logger.warning(" OAuth Token Status:")
|
||||
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
|
||||
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if 'refresh_token_age_days' in token_info:
|
||||
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
|
||||
if token_info.get('error'):
|
||||
logger.warning(f" Error: {token_info['error']}")
|
||||
logger.warning("Previous engine output (last 10 lines):")
|
||||
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
|
||||
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower():
|
||||
self._non_premium_info_sent = True
|
||||
self.non_premium_detected.emit()
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(decoded):
|
||||
self.last_error = creation_kit_missing()
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if self._handle_engine_event(decoded):
|
||||
last_was_blank = False
|
||||
continue
|
||||
|
||||
if clf3_mode:
|
||||
heartbeat_stop = threading.Event()
|
||||
heartbeat_thread = threading.Thread(
|
||||
target=self._run_clf3_heartbeat,
|
||||
args=(self._clf3_last_activity, heartbeat_stop),
|
||||
daemon=True,
|
||||
)
|
||||
heartbeat_thread.start()
|
||||
self._run_clf3_stdout_loop(self._clf3_last_activity)
|
||||
heartbeat_stop.set()
|
||||
heartbeat_thread.join(timeout=2.0)
|
||||
else:
|
||||
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
buffer = b''
|
||||
last_was_blank = False
|
||||
while True:
|
||||
if self.cancelled:
|
||||
self.cancel()
|
||||
break
|
||||
char = self.process_manager.read_stdout_char()
|
||||
if not char:
|
||||
break
|
||||
buffer += char
|
||||
while b'\n' in buffer or b'\r' in buffer:
|
||||
if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True):
|
||||
line, buffer = buffer.split(b'\r', 1)
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"Matched pattern: '{matched_pattern}'")
|
||||
logger.warning(f"Triggering line: '{decoded.strip()}'")
|
||||
logger.warning("AUTHENTICATION DIAGNOSTICS:")
|
||||
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
|
||||
if self.api_key:
|
||||
logger.warning(f" Auth value length: {len(self.api_key)} chars")
|
||||
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
|
||||
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
|
||||
if auth_method == 'oauth' and self.auth_service:
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
logger.warning(" OAuth Token Status:")
|
||||
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
|
||||
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if 'refresh_token_age_days' in token_info:
|
||||
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
|
||||
if token_info.get('error'):
|
||||
logger.warning(f" Error: {token_info['error']}")
|
||||
logger.warning("Previous engine output (last 10 lines):")
|
||||
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
|
||||
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(decoded):
|
||||
self.last_error = creation_kit_missing(self.game_type)
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
self._install_progress_started = True
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.progress_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self.progress_received.emit(decoded + '\r')
|
||||
elif b'\n' in buffer:
|
||||
line, buffer = buffer.split(b'\n', 1)
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"Matched pattern: '{matched_pattern}'")
|
||||
logger.warning(f"Triggering line: '{decoded.strip()}'")
|
||||
logger.warning("AUTHENTICATION DIAGNOSTICS:")
|
||||
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
|
||||
if self.api_key:
|
||||
logger.warning(f" Auth value length: {len(self.api_key)} chars")
|
||||
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
|
||||
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
|
||||
if auth_method == 'oauth' and self.auth_service:
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
logger.warning(" OAuth Token Status:")
|
||||
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
|
||||
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if 'refresh_token_age_days' in token_info:
|
||||
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
|
||||
if token_info.get('error'):
|
||||
logger.warning(f" Error: {token_info['error']}")
|
||||
logger.warning("Previous engine output (last 10 lines):")
|
||||
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
|
||||
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower():
|
||||
self._non_premium_info_sent = True
|
||||
self.non_premium_detected.emit()
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(decoded):
|
||||
self.last_error = creation_kit_missing(self.game_type)
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if self._handle_engine_event(decoded):
|
||||
last_was_blank = False
|
||||
continue
|
||||
self._remember_stdout_line(decoded)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
self._install_progress_started = True
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
last_was_blank = False
|
||||
continue
|
||||
if decoded.strip() == '':
|
||||
if not last_was_blank:
|
||||
self.output_received.emit('\n')
|
||||
last_was_blank = True
|
||||
else:
|
||||
self.output_received.emit(decoded + '\n')
|
||||
last_was_blank = False
|
||||
if buffer:
|
||||
line = ansi_escape.sub(b'', buffer)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self._remember_stdout_line(decoded)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
self._install_progress_started = True
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
last_was_blank = False
|
||||
continue
|
||||
if decoded.strip() == '':
|
||||
if not last_was_blank:
|
||||
self.output_received.emit('\n')
|
||||
last_was_blank = True
|
||||
else:
|
||||
self.output_received.emit(decoded + '\n')
|
||||
last_was_blank = False
|
||||
if buffer:
|
||||
line = ansi_escape.sub(b'', buffer)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self._remember_stdout_line(decoded)
|
||||
self.output_received.emit(decoded)
|
||||
self.output_received.emit(decoded)
|
||||
stderr_thread.join(timeout=5)
|
||||
returncode = self.process_manager.wait()
|
||||
if writeback_path and self.auth_service:
|
||||
if writeback_path and self.auth_service and not clf3_mode:
|
||||
self.auth_service.apply_token_writeback(writeback_path)
|
||||
if self.process_manager.proc and self.process_manager.proc.stdout:
|
||||
try:
|
||||
@@ -479,7 +700,7 @@ class InstallerThread(QThread):
|
||||
if remaining:
|
||||
decoded_remaining = remaining.decode('utf-8', errors='replace')
|
||||
if decoded_remaining.strip():
|
||||
logger.debug(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}")
|
||||
logger.debug(f"Remaining output after process exit: {decoded_remaining[:500]}")
|
||||
if '[FILE_PROGRESS]' in decoded_remaining:
|
||||
parts = decoded_remaining.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
@@ -487,7 +708,7 @@ class InstallerThread(QThread):
|
||||
else:
|
||||
self.output_received.emit(decoded_remaining)
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error reading remaining output: {e}")
|
||||
logger.debug(f"Error reading remaining output: {e}")
|
||||
if returncode != 0 and not self.cancelled and self.last_error is None:
|
||||
stderr_tail = self._raw_stderr_lines[-10:] if self._raw_stderr_lines else []
|
||||
stdout_tail = self._raw_stdout_lines[-10:] if self._raw_stdout_lines else []
|
||||
|
||||
@@ -35,24 +35,45 @@ class InstallModlistOutputMixin:
|
||||
if not self._token_error_notified:
|
||||
self._token_error_notified = True
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Authentication Error",
|
||||
(
|
||||
"Nexus Mods authentication has failed. This may be due to:\n\n"
|
||||
"• OAuth token expired and refresh failed\n"
|
||||
"• Nexus Premium required for this modlist\n"
|
||||
"• Network connectivity issues\n\n"
|
||||
"Please check the console output (Show Details) for more information.\n"
|
||||
"You may need to re-authorize in Settings."
|
||||
),
|
||||
safety_level="high"
|
||||
)
|
||||
guidance = (
|
||||
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
|
||||
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
|
||||
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
|
||||
)
|
||||
engine_id = getattr(self, '_active_session_engine_id', None)
|
||||
if engine_id == 'clf3':
|
||||
MessageService.critical(
|
||||
self,
|
||||
"CLF3 Authentication Error",
|
||||
(
|
||||
"CLF3 could not authenticate with Nexus Mods.\n\n"
|
||||
"The CLF3 binary stores its own Nexus API key, which may have "
|
||||
"expired or been revoked. Your Jackify OAuth is unaffected.\n\n"
|
||||
"To fix: generate an API key at nexus.mods.com (account page), "
|
||||
"then run in a terminal:\n\n"
|
||||
" clf3 set-api-key YOUR_KEY\n\n"
|
||||
"OAuth support for CLF3 will be automatic after the next CLF3 release."
|
||||
),
|
||||
safety_level="high"
|
||||
)
|
||||
guidance = (
|
||||
"\n[Jackify] CLF3 auth failed. CLF3 uses its own saved Nexus API key "
|
||||
"(not your Jackify OAuth). Run: clf3 set-api-key YOUR_KEY\n"
|
||||
)
|
||||
else:
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Authentication Error",
|
||||
(
|
||||
"Nexus Mods authentication has failed. This may be due to:\n\n"
|
||||
"• OAuth token expired and refresh failed\n"
|
||||
"• Nexus Premium required for this modlist\n"
|
||||
"• Network connectivity issues\n\n"
|
||||
"Please check the console output (Show Details) for more information.\n"
|
||||
"You may need to re-authorize in Settings."
|
||||
),
|
||||
safety_level="high"
|
||||
)
|
||||
guidance = (
|
||||
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
|
||||
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
|
||||
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
|
||||
)
|
||||
self._safe_append_text(guidance)
|
||||
if not self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.setChecked(True)
|
||||
@@ -219,12 +240,12 @@ class InstallModlistOutputMixin:
|
||||
except RuntimeError as e:
|
||||
if "already deleted" in str(e):
|
||||
if getattr(self, 'debug', False):
|
||||
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
|
||||
logger.debug(f"Ignoring widget deletion error: {e}")
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
if getattr(self, 'debug', False):
|
||||
logger.debug(f"DEBUG: Error updating file progress list: {e}")
|
||||
logger.debug(f"Error updating file progress list: {e}")
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Post-install UI feedback management for InstallModlistScreen (Mixin)."""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
@@ -7,6 +8,8 @@ from PySide6.QtCore import QTimer
|
||||
|
||||
from jackify.shared.progress_models import InstallationProgress, InstallationPhase, FileProgress, OperationType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostInstallFeedbackMixin:
|
||||
"""Mixin providing post-install progress tracking and UI feedback for InstallModlistScreen."""
|
||||
@@ -180,6 +183,21 @@ class PostInstallFeedbackMixin:
|
||||
"bsa decompression:",
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'final_config',
|
||||
'label': "Performing final tasks",
|
||||
'keywords': [
|
||||
"digicert",
|
||||
"certificate",
|
||||
"windows version",
|
||||
"windows 11",
|
||||
"mscoree",
|
||||
"tool compat",
|
||||
"nemesis setup",
|
||||
"applying tool",
|
||||
"symlink",
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'config_finalize',
|
||||
'label': "Finalising Jackify configuration",
|
||||
@@ -200,15 +218,54 @@ class PostInstallFeedbackMixin:
|
||||
self._post_install_last_label = "Preparing Steam integration"
|
||||
total = max(1, self._post_install_total_steps)
|
||||
self._update_post_install_ui(self._post_install_last_label, 0, total)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
def _handle_post_install_progress(self, message: str):
|
||||
"""Translate backend progress messages into collapsed-mode feedback."""
|
||||
if not self._post_install_active or not message:
|
||||
if not self._post_install_active and message:
|
||||
logger.debug("[PULSE] _handle_post_install_progress skipped - post_install_active=False, msg=%r", message[:60])
|
||||
return
|
||||
|
||||
text = message.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
if any(kw in text.lower() for kw in ['wine', 'vcrun', 'dotnet', 'winetricks', 'component']):
|
||||
logger.debug("[PULSE] progress msg (wine-related): %r, step=%s, timer_active=%s",
|
||||
text[:80], getattr(self, '_post_install_current_step', 'N/A'),
|
||||
bool(getattr(self, '_component_install_timer', None) and
|
||||
getattr(self._component_install_timer, 'isActive', lambda: False)()))
|
||||
|
||||
if text.startswith("[NATIVE_DL] "):
|
||||
parts = text.split(None, 3)
|
||||
if len(parts) == 4:
|
||||
_, component, pct_str, speed_str = parts
|
||||
try:
|
||||
if not hasattr(self, '_native_component_progress'):
|
||||
self._native_component_progress = {}
|
||||
self._native_component_progress[component] = (float(pct_str), float(speed_str))
|
||||
except ValueError:
|
||||
pass
|
||||
return
|
||||
if text.startswith("[NATIVE_INSTALL] "):
|
||||
component = text[17:].strip()
|
||||
if hasattr(self, '_native_component_progress'):
|
||||
self._native_component_progress.pop(component, None)
|
||||
done = getattr(self, '_native_done_components', 0)
|
||||
self._current_native_component = component
|
||||
self._native_done_components = done + 1
|
||||
if hasattr(self, '_component_install_list') and self._component_install_list:
|
||||
total = len(self._component_install_list)
|
||||
remaining = max(0, total - (done + 1))
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_files(
|
||||
[FileProgress(filename=f"Wine component: {component}{suffix}", operation=OperationType.UNKNOWN, percent=0.0)],
|
||||
current_phase=None,
|
||||
)
|
||||
return
|
||||
|
||||
normalized = text.lower()
|
||||
total = max(1, self._post_install_total_steps)
|
||||
matched = False
|
||||
@@ -239,15 +296,19 @@ class PostInstallFeedbackMixin:
|
||||
# Must remove summary widget so pulser items display immediately
|
||||
# (otherwise the 0.5s hold blocks update_files from adding items).
|
||||
if step['id'] == 'wine_components':
|
||||
logger.debug("[PULSE] wine_components step matched, msg=%r", text[:80])
|
||||
self.file_progress_list.clear_summary()
|
||||
self.progress_indicator.set_status(
|
||||
"Installing Wine components...",
|
||||
int((self._post_install_current_step / total) * 100)
|
||||
)
|
||||
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
|
||||
timer_active = hasattr(self, '_component_install_timer') and self._component_install_timer and self._component_install_timer.isActive()
|
||||
logger.debug("[PULSE] timer_active=%s", timer_active)
|
||||
if not timer_active:
|
||||
self._start_component_install_pulse()
|
||||
# Always check for component list updates (may come in later messages)
|
||||
comp_list = self._parse_wine_components_message(text)
|
||||
logger.debug("[PULSE] comp_list=%s", comp_list)
|
||||
if comp_list:
|
||||
self._start_component_install_pulse_with_components(comp_list)
|
||||
break
|
||||
@@ -364,16 +425,11 @@ class PostInstallFeedbackMixin:
|
||||
|
||||
def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None):
|
||||
"""Update progress indicator + activity summary for post-install steps."""
|
||||
# Use the label as the primary display, but include step info in Activity window
|
||||
display_label = label
|
||||
if detail:
|
||||
# Remove timestamp prefix from detail messages
|
||||
clean_detail = self._strip_timestamp_prefix(detail.strip())
|
||||
if clean_detail:
|
||||
# Filter out winetricks/protontricks internal messages (perl, wine paths, etc.)
|
||||
# These are implementation details, not user-facing status
|
||||
if any(keyword in clean_detail.lower() for keyword in ['perl:', 'wine:', '/usr/bin/', 'winetricks:', 'protontricks:']):
|
||||
# Use original label, ignore internal tool messages
|
||||
pass
|
||||
elif clean_detail.lower().startswith(label.lower()):
|
||||
display_label = clean_detail
|
||||
@@ -383,23 +439,24 @@ class PostInstallFeedbackMixin:
|
||||
step_clamped = max(0, min(step, total))
|
||||
overall_percent = (step_clamped / total) * 100.0
|
||||
|
||||
# CRITICAL: Ensure both displays use the SAME step counter
|
||||
# Progress banner uses phase_step/phase_max_steps from progress_state
|
||||
progress_state = InstallationProgress(
|
||||
phase=InstallationPhase.FINALIZE,
|
||||
phase_name=display_label, # This will show in progress banner
|
||||
phase_step=step_clamped, # This creates [step/total] in display_text
|
||||
phase_name=display_label,
|
||||
phase_step=step_clamped,
|
||||
phase_max_steps=total,
|
||||
overall_percent=overall_percent
|
||||
)
|
||||
self.progress_indicator.update_progress(progress_state)
|
||||
|
||||
# Activity window uses summary_info with the SAME step counter
|
||||
# When the component pulse timer is active, it owns the Activity window.
|
||||
# Writing a summary widget here would block the heartbeat's file items via the 0.5s hold.
|
||||
if getattr(self, '_component_install_timer', None) and self._component_install_timer.isActive():
|
||||
return
|
||||
|
||||
summary_info = {
|
||||
'current_step': step_clamped, # Must match phase_step above
|
||||
'max_steps': total, # Must match phase_max_steps above
|
||||
'current_step': step_clamped,
|
||||
'max_steps': total,
|
||||
}
|
||||
# Use the same label for consistency
|
||||
self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info)
|
||||
|
||||
def _end_post_install_feedback(self, success: bool):
|
||||
@@ -414,6 +471,8 @@ class PostInstallFeedbackMixin:
|
||||
self._update_post_install_ui(label, final_step, total)
|
||||
self._post_install_active = False
|
||||
self._post_install_last_label = label
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
|
||||
def _parse_wine_components_message(self, text: str):
|
||||
"""Extract list of wine component names from backend status message, or None."""
|
||||
@@ -429,40 +488,60 @@ class PostInstallFeedbackMixin:
|
||||
|
||||
def _start_component_install_pulse(self):
|
||||
"""Start pulsing Activity item for Wine component installation."""
|
||||
logger.debug("[PULSE] _start_component_install_pulse called, post_install_active=%s", getattr(self, '_post_install_active', 'N/A'))
|
||||
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
|
||||
if not getattr(self, '_component_install_timer', None):
|
||||
self._component_install_timer = QTimer(self)
|
||||
self._component_install_timer.timeout.connect(self._component_install_heartbeat)
|
||||
self._component_install_timer.start(100)
|
||||
self._component_install_start_time = time.time()
|
||||
logger.debug("[PULSE] component install timer started")
|
||||
|
||||
def _start_component_install_pulse_with_components(self, components: list):
|
||||
"""Replace single item with one Activity entry per component, each with pulsing progress."""
|
||||
"""Show queued count; heartbeat switches to per-component display as each starts."""
|
||||
logger.debug("[PULSE] _start_component_install_pulse_with_components called, components=%s", components)
|
||||
self._component_install_list = components
|
||||
progresses = [
|
||||
FileProgress(
|
||||
filename=f"Wine component: {comp}",
|
||||
operation=OperationType.UNKNOWN,
|
||||
percent=0.0,
|
||||
)
|
||||
for comp in components
|
||||
]
|
||||
self.file_progress_list.update_files(progresses, current_phase=None)
|
||||
self._native_total_components = len(components)
|
||||
self._native_done_components = 0
|
||||
self._current_native_component = None
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__wine_components__",
|
||||
f"Wine components: {len(components)} queued",
|
||||
0.0,
|
||||
)
|
||||
|
||||
def _component_install_heartbeat(self):
|
||||
"""Heartbeat to keep component install item(s) pulsing."""
|
||||
"""Heartbeat to keep component install item pulsing."""
|
||||
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
|
||||
logger.debug("[PULSE] heartbeat fired but no start_time, skipping")
|
||||
return
|
||||
current = getattr(self, '_current_native_component', None)
|
||||
if hasattr(self, '_component_install_list') and self._component_install_list:
|
||||
progresses = [
|
||||
FileProgress(
|
||||
filename=f"Wine component: {comp}",
|
||||
operation=OperationType.UNKNOWN,
|
||||
percent=0.0,
|
||||
total = len(self._component_install_list)
|
||||
done = getattr(self, '_native_done_components', 0)
|
||||
dl_state = getattr(self, '_native_component_progress', {})
|
||||
if current:
|
||||
if current in dl_state:
|
||||
pct, speed = dl_state[current]
|
||||
label = f"Wine component: {current} | {pct:.0f}% ({speed:.1f} MB/s)"
|
||||
op = OperationType.DOWNLOAD
|
||||
pct_val = pct
|
||||
else:
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
label = f"Wine component: {current}{suffix}"
|
||||
op = OperationType.UNKNOWN
|
||||
pct_val = 0.0
|
||||
self.file_progress_list.update_files(
|
||||
[FileProgress(filename=label, operation=op, percent=pct_val)],
|
||||
current_phase=None,
|
||||
)
|
||||
else:
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__wine_components__",
|
||||
f"Wine components: {total} queued",
|
||||
0.0,
|
||||
)
|
||||
for comp in self._component_install_list
|
||||
]
|
||||
self.file_progress_list.update_files(progresses, current_phase=None)
|
||||
else:
|
||||
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
|
||||
|
||||
@@ -473,6 +552,8 @@ class PostInstallFeedbackMixin:
|
||||
self._component_install_timer = None
|
||||
if hasattr(self, '_component_install_list'):
|
||||
del self._component_install_list
|
||||
if hasattr(self, '_native_component_progress'):
|
||||
del self._native_component_progress
|
||||
|
||||
def _start_bsa_decompress_pulse(self):
|
||||
"""Keep the Activity window alive during long BSA decompression runs."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Progress and installation event handlers for InstallModlistScreen (Mixin)."""
|
||||
from PySide6.QtCore import QProcess
|
||||
from PySide6.QtCore import QProcess, QTimer
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
@@ -187,7 +187,11 @@ class ProgressHandlersMixin:
|
||||
)
|
||||
is_extraction_phase = (
|
||||
progress_state.phase == InstallationPhase.EXTRACT or
|
||||
(progress_state.phase_name and 'extract' in progress_state.phase_name.lower())
|
||||
(progress_state.phase_name and (
|
||||
'extract' in progress_state.phase_name.lower()
|
||||
or 'queu' in progress_state.phase_name.lower()
|
||||
or 'decompress' in progress_state.phase_name.lower()
|
||||
))
|
||||
)
|
||||
|
||||
# Detect BSA building phase - check multiple indicators
|
||||
@@ -228,23 +232,29 @@ class ProgressHandlersMixin:
|
||||
self._bsa_hold_deadline = now_mono
|
||||
|
||||
if is_installation_phase:
|
||||
# During installation, we may have BSA building AND file installation happening
|
||||
# Show both: install summary + any active BSA files
|
||||
# Render loop handles smooth updates - just set target state
|
||||
|
||||
self._stop_clf3_decompress_pulse()
|
||||
current_step = progress_state.phase_step
|
||||
phase_lower = (progress_state.phase_name or "").lower()
|
||||
|
||||
if "bsa" in phase_lower:
|
||||
step_label = f"Building BSA: {current_step}/{progress_state.phase_max_steps}"
|
||||
elif "dds" in phase_lower or "texture" in phase_lower:
|
||||
step_label = f"Converting Textures: {current_step}/{progress_state.phase_max_steps}"
|
||||
elif "extract" in phase_lower or "queu" in phase_lower:
|
||||
step_label = f"Queuing Archives: {current_step}/{progress_state.phase_max_steps}"
|
||||
else:
|
||||
step_label = f"Installing Files: {current_step}/{progress_state.phase_max_steps}"
|
||||
|
||||
display_items = []
|
||||
|
||||
# Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size)
|
||||
if current_step > 0 or progress_state.phase_max_steps > 0:
|
||||
install_line = FileProgress(
|
||||
filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}",
|
||||
filename=step_label,
|
||||
operation=OperationType.INSTALL,
|
||||
percent=0.0,
|
||||
speed=-1.0
|
||||
)
|
||||
install_line._no_progress_bar = True # Flag to hide progress bar
|
||||
install_line._no_progress_bar = True
|
||||
display_items.append(install_line)
|
||||
|
||||
# Lines 2+: Show converting textures and BSA files
|
||||
@@ -293,25 +303,31 @@ class ProgressHandlersMixin:
|
||||
# Update target state (render loop handles smooth display)
|
||||
# Explicitly pass None for summary_info to clear any stale summary data
|
||||
if display_items:
|
||||
self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None)
|
||||
self.file_progress_list.update_files(display_items, current_phase=phase_label or "Installing", summary_info=None)
|
||||
return
|
||||
elif is_extraction_phase:
|
||||
# Show summary info for Extracting phase (step count)
|
||||
# Render loop handles smooth updates - just set target state
|
||||
# Explicitly pass empty list for file_progresses to clear any stale file list
|
||||
current_step = progress_state.phase_step
|
||||
summary_info = {
|
||||
'current_step': current_step,
|
||||
'max_steps': progress_state.phase_max_steps,
|
||||
}
|
||||
phase_display_name = phase_label or "Extracting"
|
||||
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
|
||||
phase_lower = (progress_state.phase_name or "").lower()
|
||||
if 'decompress' in phase_lower:
|
||||
label = progress_state.message or "Decompressing archives..."
|
||||
self._clf3_decompress_label = label
|
||||
if not getattr(self, '_clf3_decompress_timer', None):
|
||||
self._start_clf3_decompress_pulse(label)
|
||||
else:
|
||||
self._stop_clf3_decompress_pulse()
|
||||
current_step = progress_state.phase_step
|
||||
summary_info = {
|
||||
'current_step': current_step,
|
||||
'max_steps': progress_state.phase_max_steps,
|
||||
}
|
||||
phase_display_name = phase_label or "Queuing Archives"
|
||||
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
|
||||
return
|
||||
elif progress_state.active_files:
|
||||
self._stop_clf3_decompress_pulse()
|
||||
if self.debug:
|
||||
logger.debug(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files")
|
||||
logger.debug(f"Updating file progress list with {len(progress_state.active_files)} files")
|
||||
for fp in progress_state.active_files:
|
||||
logger.debug(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
|
||||
logger.debug(f" - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
|
||||
# Pass phase label to update header (e.g., "[Activity - Downloading]")
|
||||
# Explicitly clear summary_info when showing file list
|
||||
try:
|
||||
@@ -320,33 +336,64 @@ class ProgressHandlersMixin:
|
||||
# Widget was deleted - ignore to prevent coredump
|
||||
if "already deleted" in str(e):
|
||||
if self.debug:
|
||||
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
|
||||
logger.debug(f"Ignoring widget deletion error: {e}")
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
# Catch any other exceptions to prevent coredump
|
||||
if self.debug:
|
||||
logger.debug(f"DEBUG: Error updating file progress list: {e}")
|
||||
logger.debug(f"Error updating file progress list: {e}")
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
else:
|
||||
# Show empty state so widget stays visible even when no files are active
|
||||
self._stop_clf3_decompress_pulse()
|
||||
# When there are no active files but phase progress counters are set (CLF3 streaming
|
||||
# pipeline: extraction runs inside the Downloading phase with no per-file events),
|
||||
# show a summary widget so the Activity tab is not blank.
|
||||
summary_info = None
|
||||
if progress_state.phase_step > 0 or progress_state.phase_max_steps > 0:
|
||||
summary_info = {
|
||||
'current_step': progress_state.phase_step,
|
||||
'max_steps': progress_state.phase_max_steps,
|
||||
}
|
||||
try:
|
||||
self.file_progress_list.update_files([], current_phase=phase_label)
|
||||
self.file_progress_list.update_files([], current_phase=phase_label, summary_info=summary_info)
|
||||
except RuntimeError as e:
|
||||
# Widget was deleted - ignore to prevent coredump
|
||||
if "already deleted" in str(e):
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
# Catch any other exceptions to prevent coredump
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
logger.error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
|
||||
def _start_clf3_decompress_pulse(self, label: str = "Decompressing archives..."):
|
||||
self._clf3_decompress_label = label
|
||||
if not getattr(self, '_clf3_decompress_timer', None):
|
||||
self._clf3_decompress_timer = QTimer(self)
|
||||
self._clf3_decompress_timer.timeout.connect(self._clf3_decompress_heartbeat)
|
||||
self._clf3_decompress_timer.start(250)
|
||||
|
||||
def _clf3_decompress_heartbeat(self):
|
||||
label = getattr(self, '_clf3_decompress_label', "Decompressing archives...")
|
||||
self.file_progress_list.update_or_add_item("__clf3_decompress__", label, 0.0)
|
||||
|
||||
def _stop_clf3_decompress_pulse(self):
|
||||
timer = getattr(self, '_clf3_decompress_timer', None)
|
||||
if timer:
|
||||
timer.stop()
|
||||
self._clf3_decompress_timer = None
|
||||
self._clf3_decompress_label = None
|
||||
|
||||
def on_installation_finished(self, success, message):
|
||||
"""Handle installation completion"""
|
||||
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
||||
# R&D: Clear all progress displays when installation completes
|
||||
self._stop_clf3_decompress_pulse()
|
||||
logger.debug(f"on_installation_finished called with success={success}, message={message}")
|
||||
# installation_finished is emitted from inside run() via a queued connection,
|
||||
# so run() may still be executing its finally block when this slot fires.
|
||||
# Destroying the thread object while run() is still on the stack causes
|
||||
# "QThread: Destroyed while thread is still running" / SIGABRT.
|
||||
thread = getattr(self, 'install_thread', None)
|
||||
if thread and thread.isRunning():
|
||||
thread.wait(3000)
|
||||
self.progress_state_manager.reset()
|
||||
# Clear file list but keep CPU tracking running for configuration phase
|
||||
self.file_progress_list.list_widget.clear()
|
||||
@@ -382,6 +429,13 @@ class ProgressHandlersMixin:
|
||||
except Exception as _meta_err:
|
||||
logger.debug(f"Modlist meta write skipped: {_meta_err}")
|
||||
|
||||
try:
|
||||
from jackify.backend.utils.clf3_postinstall import inject_mo2_download_dir
|
||||
if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'downloads_dir', None):
|
||||
inject_mo2_download_dir(thread.install_dir, thread.downloads_dir)
|
||||
except Exception as _ini_err:
|
||||
logger.debug(f"MO2 INI download_directory injection skipped: {_ini_err}")
|
||||
|
||||
logger.info(f"Installation succeeded: {message}")
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self._safe_append_text(f"\nSuccess: {message}")
|
||||
@@ -418,12 +472,12 @@ class ProgressHandlersMixin:
|
||||
self.process_finished(1, QProcess.CrashExit) # Simulate error
|
||||
|
||||
def process_finished(self, exit_code, exit_status):
|
||||
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
logger.debug(f"process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
# Reset button states
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug("DEBUG: Button states reset in process_finished")
|
||||
logger.debug("Button states reset in process_finished")
|
||||
|
||||
# Stop manual download manager if it is still running (e.g. install failed mid-phase)
|
||||
if getattr(self, '_manual_dl_manager', None) is not None:
|
||||
|
||||
@@ -153,13 +153,13 @@ class ModlistSelectionMixin:
|
||||
if hasattr(self, 'current_game_type'):
|
||||
game_filter = game_type_to_human_friendly.get(self.current_game_type)
|
||||
|
||||
dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self)
|
||||
self._gallery_dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self)
|
||||
if cursor_overridden:
|
||||
QApplication.restoreOverrideCursor()
|
||||
cursor_overridden = False
|
||||
|
||||
if dlg.exec() == QDialog.Accepted and dlg.selected_metadata:
|
||||
metadata = dlg.selected_metadata
|
||||
if self._gallery_dlg.exec() == QDialog.Accepted and self._gallery_dlg.selected_metadata:
|
||||
metadata = self._gallery_dlg.selected_metadata
|
||||
self.modlist_btn.setText(metadata.title)
|
||||
self.selected_modlist_info = {
|
||||
'machine_url': metadata.namespacedName,
|
||||
@@ -170,6 +170,7 @@ class ModlistSelectionMixin:
|
||||
'nsfw': metadata.nsfw,
|
||||
'force_down': metadata.forceDown,
|
||||
'readme_url': metadata.links.readme if metadata.links else None,
|
||||
'download_url': metadata.links.download if metadata.links else None,
|
||||
}
|
||||
self.modlist_name_edit.setText(metadata.title)
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Steam shortcut conflict handling for InstallModlistScreen (Mixin)."""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstallModlistShortcutDialogMixin:
|
||||
"""Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen."""
|
||||
@@ -62,7 +67,7 @@ class InstallModlistShortcutDialogMixin:
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
return
|
||||
self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.")
|
||||
self.continue_configuration_after_automated_prefix(int(existing_appid), modlist_name, install_dir, None)
|
||||
self._reuse_shortcut_with_prefix_check(int(existing_appid), modlist_name, install_dir)
|
||||
return
|
||||
|
||||
if action == "new":
|
||||
@@ -79,6 +84,54 @@ class InstallModlistShortcutDialogMixin:
|
||||
self._safe_append_text("Shortcut creation cancelled by user")
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
|
||||
def _reuse_shortcut_with_prefix_check(self, appid: int, modlist_name: str, install_dir: str) -> None:
|
||||
"""Continue configuration after conflict resolution, creating the prefix if it is missing."""
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
svc = AutomatedPrefixService()
|
||||
if svc.get_prefix_path(appid):
|
||||
self.continue_configuration_after_automated_prefix(appid, modlist_name, install_dir, None)
|
||||
return
|
||||
|
||||
logger.info("Proton prefix missing for AppID %s; creating before configuration", appid)
|
||||
self._safe_append_text("[00:00:00] Proton prefix not found; creating prefix...")
|
||||
|
||||
class _PrefixCreateThread(QThread):
|
||||
finished = Signal(bool)
|
||||
|
||||
def __init__(self, appid):
|
||||
super().__init__()
|
||||
self._appid = appid
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
ok = AutomatedPrefixService().create_prefix_with_proton_wrapper(self._appid)
|
||||
self.finished.emit(ok)
|
||||
except Exception as exc:
|
||||
logger.error("Prefix creation thread failed: %s", exc)
|
||||
self.finished.emit(False)
|
||||
|
||||
_thread = _PrefixCreateThread(appid)
|
||||
|
||||
def _on_done(success):
|
||||
_thread.deleteLater()
|
||||
if not success:
|
||||
logger.error("Failed to create Proton prefix for AppID %s", appid)
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Prefix Creation Failed",
|
||||
"Jackify could not create the Proton prefix.\n\n"
|
||||
"Try launching the modlist from Steam once to initialise it, then run Configure.",
|
||||
)
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
return
|
||||
self._safe_append_text("[00:00:00] Proton prefix created.")
|
||||
self.continue_configuration_after_automated_prefix(appid, modlist_name, install_dir, None)
|
||||
|
||||
_thread.finished.connect(_on_done)
|
||||
self._prefix_create_thread = _thread
|
||||
_thread.start()
|
||||
|
||||
def retry_automated_workflow_with_new_name(self, new_name):
|
||||
"""Retry the automated workflow with a new shortcut name."""
|
||||
self.modlist_name_edit.setText(new_name)
|
||||
|
||||
@@ -33,13 +33,13 @@ class TTWIntegrationMixin:
|
||||
|
||||
# Check 3: TTW must not already be installed
|
||||
if self._detect_existing_ttw(install_dir):
|
||||
logger.debug("DEBUG: TTW already installed, skipping prompt")
|
||||
logger.debug("TTW already installed, skipping prompt")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error checking TTW eligibility: {e}")
|
||||
logger.debug(f"Error checking TTW eligibility: {e}")
|
||||
return False
|
||||
|
||||
def _detect_existing_ttw(self, install_dir: str) -> bool:
|
||||
@@ -73,15 +73,15 @@ class TTWIntegrationMixin:
|
||||
# Verify it has actual TTW content by checking for the main ESM
|
||||
ttw_esm = folder / "TaleOfTwoWastelands.esm"
|
||||
if ttw_esm.exists():
|
||||
logger.debug(f"DEBUG: Found existing TTW installation: {folder.name}")
|
||||
logger.debug(f"Found existing TTW installation: {folder.name}")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
|
||||
logger.debug(f"Found TTW folder but no ESM, skipping: {folder.name}")
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error detecting existing TTW: {e}")
|
||||
logger.debug(f"Error detecting existing TTW: {e}")
|
||||
return False # Assume not installed on error
|
||||
|
||||
def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str):
|
||||
@@ -173,40 +173,34 @@ class TTWIntegrationMixin:
|
||||
vnv_automation_running = self._check_and_run_vnv_automation(self._ttw_modlist_name, self._ttw_install_dir)
|
||||
|
||||
if vnv_automation_running:
|
||||
# Store success dialog params for later (after VNV automation completes)
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'install',
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': False, # TTW installs don't have ENB
|
||||
'ttw_version': ttw_version if 'ttw_version' in locals() else None
|
||||
'enb_detected': False,
|
||||
'install_dir': getattr(self, '_ttw_install_dir', '') or '',
|
||||
'game_type': 'falloutnv',
|
||||
'appid': getattr(self, '_current_appid', '') or '',
|
||||
}
|
||||
# Keep post-install feedback active during VNV automation
|
||||
# Don't show success dialog yet - will be shown in _on_vnv_complete
|
||||
return
|
||||
|
||||
# No VNV automation - end post-install feedback now
|
||||
self._end_post_install_feedback(True)
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show enhanced success dialog
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="install",
|
||||
time_taken=time_str,
|
||||
game_name=game_name,
|
||||
parent=self
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=getattr(self, '_ttw_install_dir', '') or '',
|
||||
game_type='falloutnv',
|
||||
appid=getattr(self, '_current_appid', '') or '',
|
||||
success_params={
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'install',
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': False,
|
||||
},
|
||||
)
|
||||
|
||||
# Add TTW installation info to dialog if possible
|
||||
if 'ttw_version' in locals() and hasattr(success_dialog, 'add_info_line'):
|
||||
success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully")
|
||||
|
||||
success_dialog.show()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"ERROR: Failed to show final success dialog: {e}")
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
|
||||
@@ -104,7 +104,7 @@ class InstallModlistUISetupMixin:
|
||||
header_layout = QVBoxLayout()
|
||||
header_layout.setSpacing(1) # Reduce spacing between title and description
|
||||
# Title (no logo)
|
||||
title = QLabel("<b>Install a Modlist (Automated)</b>")
|
||||
title = QLabel("<b>Install a Modlist</b>")
|
||||
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
|
||||
title.setAlignment(Qt.AlignHCenter)
|
||||
title.setMaximumHeight(30) # Force compact height
|
||||
@@ -252,6 +252,7 @@ class InstallModlistUISetupMixin:
|
||||
# Update nexus status on init
|
||||
self._update_nexus_status()
|
||||
|
||||
|
||||
# --- Resolution Dropdown ---
|
||||
resolution_label = QLabel("Resolution:")
|
||||
self.resolution_combo = QComboBox()
|
||||
@@ -293,7 +294,7 @@ class InstallModlistUISetupMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
@@ -304,23 +305,44 @@ class InstallModlistUISetupMixin:
|
||||
# Otherwise, default is 'Leave unchanged' (index 0)
|
||||
form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
# Horizontal layout for resolution dropdown and auto-restart checkbox
|
||||
# Horizontal layout for resolution dropdown and right-side checkboxes
|
||||
resolution_and_restart_layout = QHBoxLayout()
|
||||
resolution_and_restart_layout.setSpacing(12)
|
||||
|
||||
# Resolution dropdown (made smaller)
|
||||
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
|
||||
|
||||
self.resolution_combo.setMaximumWidth(280)
|
||||
resolution_and_restart_layout.addWidget(self.resolution_combo)
|
||||
|
||||
# Add stretch to push checkbox to the right
|
||||
|
||||
resolution_and_restart_layout.addStretch()
|
||||
|
||||
# Auto-accept Steam restart checkbox (right-aligned)
|
||||
|
||||
right_checks_layout = QVBoxLayout()
|
||||
right_checks_layout.setSpacing(4)
|
||||
right_checks_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
|
||||
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
|
||||
self.auto_restart_checkbox.setChecked(False)
|
||||
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation")
|
||||
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
|
||||
|
||||
right_checks_layout.addWidget(self.auto_restart_checkbox)
|
||||
|
||||
engine_row = QHBoxLayout()
|
||||
engine_row.setSpacing(4)
|
||||
engine_row.setContentsMargins(0, 0, 0, 0)
|
||||
self.engine_checkbox = QCheckBox("Use Experimental Engine")
|
||||
self.engine_checkbox.setToolTip("Use CLF3 (SulfurNitride) as the install engine instead of jackify-engine")
|
||||
self.engine_checkbox.toggled.connect(self._on_engine_checkbox_toggled)
|
||||
engine_row.addWidget(self.engine_checkbox)
|
||||
engine_whats_this = QLabel('<a href="https://github.com/Omni-guides/Jackify/wiki/Install-Engines" style="color: #6fa8dc; font-size: 11px;">(what\'s this?)</a>')
|
||||
engine_whats_this.setOpenExternalLinks(False)
|
||||
engine_whats_this.linkActivated.connect(self._open_url_safe)
|
||||
engine_row.addWidget(engine_whats_this)
|
||||
engine_row.addStretch()
|
||||
engine_row_widget = QWidget()
|
||||
engine_row_widget.setLayout(engine_row)
|
||||
self._engine_row_widget = engine_row_widget
|
||||
right_checks_layout.addWidget(engine_row_widget)
|
||||
self._init_engine_checkbox()
|
||||
|
||||
resolution_and_restart_layout.addLayout(right_checks_layout)
|
||||
|
||||
form_grid.addLayout(resolution_and_restart_layout, 5, 1)
|
||||
form_section_widget = QWidget()
|
||||
form_section_widget.setLayout(form_grid)
|
||||
@@ -514,3 +536,13 @@ class InstallModlistUISetupMixin:
|
||||
|
||||
# Now collect all actionable controls after UI is fully built
|
||||
self._collect_actionable_controls()
|
||||
|
||||
def _init_engine_checkbox(self) -> None:
|
||||
from jackify.backend.services.tool_registry import get_active_engine_id
|
||||
self._engine_row_widget.setVisible(True)
|
||||
self.engine_checkbox.blockSignals(True)
|
||||
self.engine_checkbox.setChecked(get_active_engine_id() == "clf3")
|
||||
self.engine_checkbox.blockSignals(False)
|
||||
|
||||
def _on_engine_checkbox_toggled(self, checked: bool) -> None:
|
||||
pass # state is read at install time; persistent default lives in Settings
|
||||
|
||||
@@ -50,23 +50,15 @@ class VNVAutomationMixin:
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type="install",
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self,
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=params.get('install_dir', ''),
|
||||
game_type=params.get('game_type', 'unknown'),
|
||||
appid=params.get('appid', ''),
|
||||
success_params={
|
||||
'modlist_name': params['modlist_name'],
|
||||
'workflow_type': params.get('workflow_type', 'install'),
|
||||
'time_taken': params['time_taken'],
|
||||
'game_name': params.get('game_name'),
|
||||
'enb_detected': params.get('enb_detected', False),
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Execution workflow methods for InstallModlistScreen (Mixin)."""
|
||||
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QMessageBox
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -14,15 +15,107 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class InstallWorkflowExecutionMixin:
|
||||
"""Mixin containing install-run and manual-download dialog execution methods."""
|
||||
|
||||
def _session_engine_id(self) -> str:
|
||||
"""Return the engine to use for this install based on the install screen checkbox."""
|
||||
return "clf3" if self.engine_checkbox.isChecked() else "jackify-engine"
|
||||
|
||||
def _ensure_clf3_installed(self) -> bool:
|
||||
"""
|
||||
If CLF3 is already installed, return True immediately.
|
||||
If not, show a download dialog and install it, returning True on success.
|
||||
"""
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
status = ToolRegistry().get_status("clf3")
|
||||
if status and status.installed:
|
||||
return True
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"CLF3 Not Installed",
|
||||
"The experimental engine (CLF3) is not installed.\n\n"
|
||||
"Download and install it now to continue?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return False
|
||||
|
||||
return self._download_clf3_with_dialog()
|
||||
|
||||
def _download_clf3_with_dialog(self) -> bool:
|
||||
"""Download CLF3 in a modal dialog with a pulsing progress bar. Returns True on success."""
|
||||
|
||||
class _Clf3InstallThread(QThread):
|
||||
finished_signal = Signal(bool, str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ok, msg = ToolRegistry().install("clf3")
|
||||
self.finished_signal.emit(ok, msg)
|
||||
except Exception as exc:
|
||||
self.finished_signal.emit(False, str(exc))
|
||||
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Installing CLF3")
|
||||
dlg.setModal(True)
|
||||
dlg.setMinimumWidth(360)
|
||||
layout = QVBoxLayout(dlg)
|
||||
layout.setSpacing(12)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
|
||||
label = QLabel("Downloading CLF3 (experimental engine)...")
|
||||
label.setStyleSheet("color: #ccc; font-size: 13px;")
|
||||
layout.addWidget(label)
|
||||
|
||||
bar = QProgressBar()
|
||||
bar.setRange(0, 0)
|
||||
bar.setTextVisible(False)
|
||||
bar.setFixedHeight(6)
|
||||
bar.setStyleSheet(f"""
|
||||
QProgressBar {{ border: none; background-color: #333; border-radius: 3px; }}
|
||||
QProgressBar::chunk {{ background-color: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}
|
||||
""")
|
||||
layout.addWidget(bar)
|
||||
|
||||
result = [False, ""]
|
||||
|
||||
thread = _Clf3InstallThread()
|
||||
|
||||
def on_done(ok: bool, msg: str):
|
||||
result[0] = ok
|
||||
result[1] = msg
|
||||
dlg.accept()
|
||||
|
||||
thread.finished_signal.connect(on_done)
|
||||
thread.start()
|
||||
dlg.exec()
|
||||
thread.wait(5000)
|
||||
|
||||
if not result[0]:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"CLF3 Install Failed",
|
||||
f"Could not install CLF3:\n\n{result[1]}",
|
||||
)
|
||||
return False
|
||||
|
||||
label.setText("CLF3 installed.")
|
||||
return True
|
||||
|
||||
def validate_and_start_install(self):
|
||||
import time
|
||||
self._install_workflow_start_time = time.time()
|
||||
logger.debug('DEBUG: validate_and_start_install called')
|
||||
|
||||
# Disable controls before processEvents to prevent double-click re-entry
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
# Immediately show "Initialising" status to provide feedback
|
||||
self.progress_indicator.set_status("Initialising...", 0)
|
||||
from PySide6.QtWidgets import QApplication
|
||||
QApplication.processEvents() # Force UI update
|
||||
QApplication.processEvents()
|
||||
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
self.config_handler.reload_config()
|
||||
@@ -30,12 +123,18 @@ class InstallWorkflowExecutionMixin:
|
||||
# Check protontricks before proceeding
|
||||
if not self._check_protontricks():
|
||||
self.progress_indicator.reset()
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
# Disable all controls during installation (except Cancel)
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
try:
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
downloads_dir = self.downloads_dir_edit.text().strip()
|
||||
|
||||
if self._session_engine_id() == "clf3" and not self._ensure_clf3_installed():
|
||||
self.progress_indicator.reset()
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
tab_index = self.source_tabs.currentIndex()
|
||||
install_mode = 'online'
|
||||
if tab_index == 1: # .wabbajack File tab
|
||||
@@ -70,8 +169,22 @@ class InstallWorkflowExecutionMixin:
|
||||
|
||||
# CRITICAL: Use machine_url, NOT button text
|
||||
modlist = machine_url
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
downloads_dir = self.downloads_dir_edit.text().strip()
|
||||
|
||||
if self._session_engine_id() == "clf3":
|
||||
download_url = self.selected_modlist_info.get('download_url')
|
||||
if not download_url:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Download URL Unavailable",
|
||||
"Could not determine the download URL for this modlist.\n\n"
|
||||
"Use the '.wabbajack File' tab to select a local file instead."
|
||||
)
|
||||
return
|
||||
from jackify.shared.paths import get_jackify_downloads_dir
|
||||
list_id = machine_url.split('/')[-1] if '/' in machine_url else machine_url
|
||||
wabbajack_local = str(get_jackify_downloads_dir() / f"{list_id}.wabbajack")
|
||||
modlist = wabbajack_local
|
||||
self._clf3_cdn_url = download_url
|
||||
|
||||
# Get authentication token (OAuth or API key) with automatic refresh
|
||||
api_key, oauth_info = self.auth_service.get_auth_for_engine()
|
||||
@@ -92,8 +205,6 @@ class InstallWorkflowExecutionMixin:
|
||||
logger.info("Authentication Status at Install Start")
|
||||
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
|
||||
logger.info(f"Token length: {len(api_key)} chars")
|
||||
if len(api_key) >= 8:
|
||||
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
|
||||
|
||||
if auth_method == 'oauth':
|
||||
token_handler = self.auth_service.token_handler
|
||||
@@ -169,14 +280,14 @@ class InstallWorkflowExecutionMixin:
|
||||
self._current_resolution = raw_resolution
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
logger.debug("Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
logger.debug("Saved resolution cleared")
|
||||
|
||||
ensure_flatpak_steam_filesystem_access(Path(install_dir))
|
||||
|
||||
@@ -227,7 +338,7 @@ class InstallWorkflowExecutionMixin:
|
||||
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}'")
|
||||
logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
|
||||
# Map game name to game type
|
||||
game_mapping = {
|
||||
@@ -247,12 +358,12 @@ class InstallWorkflowExecutionMixin:
|
||||
"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}'")
|
||||
logger.debug(f"Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
if not game_type:
|
||||
game_type = 'unknown'
|
||||
logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
|
||||
logger.debug(f"Game type not found in mapping, setting to 'unknown'")
|
||||
else:
|
||||
logger.debug(f"DEBUG: No selected_modlist_info found")
|
||||
logger.debug(f"No selected_modlist_info found")
|
||||
game_type = 'unknown'
|
||||
|
||||
# Store game type and name for later use
|
||||
@@ -260,13 +371,13 @@ class InstallWorkflowExecutionMixin:
|
||||
self._current_game_name = game_name
|
||||
|
||||
# Check if game is supported
|
||||
logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported")
|
||||
logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
|
||||
logger.debug(f"Checking if game_type '{game_type}' is supported")
|
||||
logger.debug(f"game_type='{game_type}', game_name='{game_name}'")
|
||||
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
|
||||
logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
|
||||
logger.debug(f"is_supported_game('{game_type}') returned: {is_supported}")
|
||||
|
||||
if game_type and not is_supported:
|
||||
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
|
||||
logger.debug(f"Game '{game_type}' is not supported, showing dialog")
|
||||
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
|
||||
dialog = UnsupportedGameDialog(self, game_name)
|
||||
if not dialog.show_dialog(self, game_name):
|
||||
@@ -366,7 +477,8 @@ class InstallWorkflowExecutionMixin:
|
||||
self._record_pre_update_ini_snapshot(install_real)
|
||||
|
||||
# CRITICAL: Final safety check - ensure online modlists use machine_url
|
||||
if install_mode == 'online':
|
||||
# CLF3 is exempt: it uses a pre-resolved local .wabbajack path, not machine_url
|
||||
if install_mode == 'online' and self._session_engine_id() != "clf3":
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
expected_machine_url = self.selected_modlist_info.get('machine_url')
|
||||
if expected_machine_url:
|
||||
@@ -393,27 +505,31 @@ class InstallWorkflowExecutionMixin:
|
||||
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)
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
subprocess.Popen(["xdg-open", readme_url], env=clean_env, start_new_session=True)
|
||||
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}')
|
||||
logger.debug(f"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:
|
||||
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
import traceback
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
# Re-enable all controls after exception
|
||||
logger.error("Unexpected error in validate_and_start_install", exc_info=True)
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Installation Error",
|
||||
f"Could not start the installation.\n\n{e}\n\n"
|
||||
f"Details were written to the Jackify log at:\n{get_jackify_logs_dir()}",
|
||||
)
|
||||
|
||||
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)
|
||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||
@@ -434,10 +550,15 @@ class InstallWorkflowExecutionMixin:
|
||||
self._downloads_dir = downloads_dir
|
||||
self.install_thread = InstallerThread(
|
||||
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
|
||||
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
|
||||
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
|
||||
progress_state_manager=self.progress_state_manager,
|
||||
auth_service=self.auth_service,
|
||||
oauth_info=oauth_info,
|
||||
game_type=getattr(self, '_current_game_type', None),
|
||||
clf3_cdn_url=getattr(self, '_clf3_cdn_url', None),
|
||||
engine_id=self._session_engine_id(),
|
||||
)
|
||||
self._clf3_cdn_url = None
|
||||
self._active_session_engine_id = self._session_engine_id()
|
||||
self.install_thread.output_received.connect(self.on_installation_output)
|
||||
self.install_thread.progress_received.connect(self.on_installation_progress)
|
||||
self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update
|
||||
|
||||
@@ -116,10 +116,13 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
def _open_url_safe(self, url):
|
||||
"""Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime"""
|
||||
import subprocess
|
||||
import os
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
try:
|
||||
subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(['xdg-open', url], env=clean_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not open URL {url}: {e}")
|
||||
logger.warning(f"Could not open URL {url}: {e}")
|
||||
|
||||
def _load_saved_parent_directories(self):
|
||||
"""No-op: do not pre-populate install/download directories from saved values."""
|
||||
@@ -136,16 +139,16 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
if saved_install_parent:
|
||||
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
|
||||
self.install_dir_edit.setText(suggested_install_dir)
|
||||
logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
||||
logger.debug(f"Updated install directory suggestion: {suggested_install_dir}")
|
||||
|
||||
# Update download directory suggestion
|
||||
saved_download_parent = self.config_handler.get_default_download_parent_dir()
|
||||
if saved_download_parent:
|
||||
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
|
||||
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
||||
logger.debug(f"Updated download directory suggestion: {suggested_download_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error updating directory suggestions: {e}")
|
||||
logger.debug(f"Error updating directory suggestions: {e}")
|
||||
|
||||
def _save_parent_directories(self, install_dir, downloads_dir):
|
||||
"""Removed automatic saving - user should set defaults in settings"""
|
||||
@@ -341,14 +344,25 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
font-size: 13px;
|
||||
""")
|
||||
|
||||
# Park all threads first (disconnects signals), then send cooperative cancel.
|
||||
self._park_all_threads()
|
||||
# Send process-group kill first so the subprocess tree dies before we
|
||||
# disconnect signals or wait on the thread.
|
||||
if hasattr(self, 'install_thread') and self.install_thread:
|
||||
try:
|
||||
self.install_thread.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Park threads (disconnects signals so no callbacks fire on the dying widget).
|
||||
self._park_all_threads()
|
||||
|
||||
# Wait up to 5 s for the thread to exit after the kill signal.
|
||||
if hasattr(self, 'install_thread') and self.install_thread:
|
||||
try:
|
||||
self.install_thread.wait(5000)
|
||||
except Exception:
|
||||
pass
|
||||
self.install_thread = None
|
||||
|
||||
# Cleanup any remaining processes
|
||||
self.cleanup_processes()
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ class TTWIntegrationMixin:
|
||||
self.status_banner.setText("TTW integration completed successfully!")
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: #28a745;
|
||||
background-color: #1a3040;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
|
||||
@@ -51,7 +51,7 @@ class TTWLifecycleMixin:
|
||||
def showEvent(self, event):
|
||||
"""Called when the widget becomes visible"""
|
||||
super().showEvent(event)
|
||||
logger.debug(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}")
|
||||
logger.debug(f"TTW showEvent - integration_mode={self._integration_mode}")
|
||||
|
||||
# Check TTW_Linux_Installer status asynchronously (non-blocking) after screen opens
|
||||
from PySide6.QtCore import QTimer
|
||||
@@ -73,7 +73,7 @@ class TTWLifecycleMixin:
|
||||
is_steamdeck = True
|
||||
|
||||
if is_steamdeck:
|
||||
logger.debug("DEBUG: Steam Deck detected, keeping expanded")
|
||||
logger.debug("Steam Deck detected, keeping expanded")
|
||||
# Force expanded state and hide checkbox
|
||||
if self.show_details_checkbox.isVisible():
|
||||
self.show_details_checkbox.setVisible(False)
|
||||
@@ -84,27 +84,27 @@ class TTWLifecycleMixin:
|
||||
self.console.setMaximumHeight(16777215) # Remove height limit
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Steam Deck check exception: {e}")
|
||||
logger.debug(f"Steam Deck check exception: {e}")
|
||||
pass
|
||||
logger.debug(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}")
|
||||
logger.debug(f"Checkbox checked={self.show_details_checkbox.isChecked()}")
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.blockSignals(True)
|
||||
self.show_details_checkbox.setChecked(False)
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
|
||||
logger.debug("DEBUG: Calling _toggle_console_visibility(Unchecked)")
|
||||
logger.debug("Calling _toggle_console_visibility(Unchecked)")
|
||||
self._toggle_console_visibility(_Qt.Unchecked)
|
||||
# Force the window to compact height to eliminate bottom whitespace
|
||||
main_window = self.window()
|
||||
logger.debug(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}")
|
||||
logger.debug(f"main_window={main_window}, size={main_window.size() if main_window else None}")
|
||||
if main_window:
|
||||
# Save original geometry once
|
||||
if self._saved_geometry is None:
|
||||
self._saved_geometry = main_window.geometry()
|
||||
logger.debug(f"DEBUG: Saved geometry: {self._saved_geometry}")
|
||||
logger.debug(f"Saved geometry: {self._saved_geometry}")
|
||||
if self._saved_min_size is None:
|
||||
self._saved_min_size = main_window.minimumSize()
|
||||
logger.debug(f"DEBUG: Saved min size: {self._saved_min_size}")
|
||||
logger.debug(f"Saved min size: {self._saved_min_size}")
|
||||
|
||||
# Fixed compact size - same as menu screens
|
||||
from PySide6.QtCore import QSize
|
||||
@@ -120,14 +120,14 @@ class TTWLifecycleMixin:
|
||||
# Notify parent to ensure compact
|
||||
try:
|
||||
self.resize_request.emit('collapse')
|
||||
logger.debug("DEBUG: Emitted resize_request collapse signal")
|
||||
logger.debug("Emitted resize_request collapse signal")
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception emitting signal: {e}")
|
||||
logger.debug(f"Exception emitting signal: {e}")
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: showEvent exception: {e}")
|
||||
logger.debug(f"showEvent exception: {e}")
|
||||
import traceback
|
||||
logger.debug(f"DEBUG: {traceback.format_exc()}")
|
||||
logger.debug(f"{traceback.format_exc()}")
|
||||
pass
|
||||
|
||||
def hideEvent(self, event):
|
||||
@@ -141,8 +141,8 @@ class TTWLifecycleMixin:
|
||||
# Important when console is expanded
|
||||
main_window.setMaximumSize(QSize(16777215, 16777215))
|
||||
main_window.setMinimumSize(QSize(0, 0))
|
||||
logger.debug("DEBUG: Install TTW hideEvent - cleared window size constraints")
|
||||
logger.debug("Install TTW hideEvent - cleared window size constraints")
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: hideEvent exception: {e}")
|
||||
logger.debug(f"hideEvent exception: {e}")
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,291 +1,119 @@
|
||||
"""TTW installer requirements and validation for InstallTTWScreen (Mixin)."""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from pathlib import Path
|
||||
import os
|
||||
import requests
|
||||
import traceback
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maps TTW-required game display names to Jackify game_type strings.
|
||||
_TTW_GAMES = {
|
||||
'Fallout 3': 'fallout3',
|
||||
'Fallout New Vegas': 'falloutnv',
|
||||
}
|
||||
|
||||
|
||||
def _detect_ttw_games() -> Dict[str, Tuple[Path, str]]:
|
||||
"""
|
||||
Return a dict of {display_name: (path, store)} for each TTW-required game found.
|
||||
Tries Steam appmanifests first, then Heroic (GOG/Epic).
|
||||
"""
|
||||
from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder
|
||||
finder = VanillaGameFinder()
|
||||
results = {}
|
||||
for display_name, game_type in _TTW_GAMES.items():
|
||||
location = finder.find(game_type)
|
||||
if location:
|
||||
results[display_name] = location
|
||||
return results
|
||||
|
||||
|
||||
class TTWRequirementsMixin:
|
||||
"""Mixin providing TTW installer requirement checking and validation for InstallTTWScreen."""
|
||||
|
||||
def check_requirements(self):
|
||||
"""Check and display requirements status"""
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
path_handler = PathHandler()
|
||||
|
||||
# Check game detection
|
||||
detected_games = path_handler.find_vanilla_game_paths()
|
||||
|
||||
# Fallout 3
|
||||
if 'Fallout 3' in detected_games:
|
||||
self.fallout3_status.setText("Fallout 3: Detected")
|
||||
self.fallout3_status.setStyleSheet("color: #3fd0ea;")
|
||||
else:
|
||||
self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam")
|
||||
self.fallout3_status.setStyleSheet("color: #f44336;")
|
||||
|
||||
# Fallout New Vegas
|
||||
if 'Fallout New Vegas' in detected_games:
|
||||
self.fnv_status.setText("Fallout New Vegas: Detected")
|
||||
self.fnv_status.setStyleSheet("color: #3fd0ea;")
|
||||
else:
|
||||
self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam")
|
||||
self.fnv_status.setStyleSheet("color: #f44336;")
|
||||
|
||||
# Update Start button state after checking requirements
|
||||
self._update_start_button_state()
|
||||
|
||||
def _check_ttw_installer_status(self):
|
||||
"""Check TTW_Linux_Installer installation status and update UI"""
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
# Create handler instances
|
||||
filesystem_handler = FileSystemHandler()
|
||||
config_handler = ConfigHandler()
|
||||
system_info = SystemInfo(is_steamdeck=False)
|
||||
ttw_installer_handler = TTWInstallerHandler(
|
||||
steamdeck=False,
|
||||
verbose=False,
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Check if TTW_Linux_Installer is installed
|
||||
ttw_installer_handler._check_installation()
|
||||
_ttw_installer_ready: bool = False
|
||||
|
||||
if ttw_installer_handler.ttw_installer_installed:
|
||||
# Check version against pinned/latest
|
||||
update_available, installed_v, target_v = ttw_installer_handler.is_ttw_installer_update_available()
|
||||
if update_available:
|
||||
# Determine if this is a downgrade or upgrade
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTW_INSTALLER_PINNED_VERSION
|
||||
if TTW_INSTALLER_PINNED_VERSION and installed_v and target_v:
|
||||
# If we have a pinned version and installed is newer, it's a downgrade
|
||||
try:
|
||||
# Simple version comparison - if installed version string is longer/more complex, likely newer
|
||||
# For now, just check if they're different and show appropriate message
|
||||
if installed_v != target_v:
|
||||
version_text = f"Update to v{target_v} (currently v{installed_v})"
|
||||
else:
|
||||
version_text = f"Update available (v{installed_v} → v{target_v})"
|
||||
except Exception:
|
||||
version_text = f"Update to v{target_v}" if target_v else "Update available"
|
||||
else:
|
||||
# Normal update (newer version available)
|
||||
version_text = f"Update available (v{installed_v} → v{target_v})" if installed_v and target_v else "Update available"
|
||||
self.ttw_installer_status.setText(version_text)
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Update now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
else:
|
||||
version_text = f"Ready (v{installed_v})" if installed_v else "Ready"
|
||||
self.ttw_installer_status.setText(version_text)
|
||||
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
|
||||
self.ttw_installer_btn.setText("Update now")
|
||||
self.ttw_installer_btn.setEnabled(False) # Greyed out when ready
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
def check_requirements(self):
|
||||
detected = _detect_ttw_games()
|
||||
|
||||
for display_name, label_widget in (
|
||||
('Fallout 3', self.fallout3_status),
|
||||
('Fallout New Vegas', self.fnv_status),
|
||||
):
|
||||
if display_name in detected:
|
||||
_, store = detected[display_name]
|
||||
store_label = {'steam': 'Steam', 'gog': 'GOG', 'epic': 'Epic'}.get(store, store)
|
||||
label_widget.setText(f"{display_name}: Detected ({store_label})")
|
||||
label_widget.setStyleSheet("color: #3fd0ea;")
|
||||
else:
|
||||
self.ttw_installer_status.setText("Not Found")
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
|
||||
label_widget.setText(f"{display_name}: Not Found")
|
||||
label_widget.setStyleSheet("color: #f44336;")
|
||||
|
||||
self._update_start_button_state()
|
||||
|
||||
def _check_ttw_installer_status(self):
|
||||
status = None
|
||||
try:
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
status = ToolRegistry().get_status("ttw_installer")
|
||||
self._ttw_installer_ready = bool(status and status.installed)
|
||||
except Exception as e:
|
||||
self.ttw_installer_status.setText("Check Failed")
|
||||
logger.debug("TTW installer status check failed: %s", e)
|
||||
self._ttw_installer_ready = False
|
||||
|
||||
if self._ttw_installer_ready:
|
||||
version_text = f"Ready (v{status.installed_version})" if status and status.installed_version else "Ready"
|
||||
self.ttw_installer_status.setText(version_text)
|
||||
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
|
||||
self.ttw_installer_btn.setVisible(False)
|
||||
else:
|
||||
self.ttw_installer_status.setText("Not installed - install via Tools Hub")
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setText("Open Tools Hub")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
logger.debug(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
|
||||
|
||||
self._update_start_button_state()
|
||||
|
||||
def install_ttw_installer(self):
|
||||
"""Install or update TTW_Linux_Installer"""
|
||||
# If not detected, show info dialog
|
||||
try:
|
||||
current_status = self.ttw_installer_status.text().strip()
|
||||
except Exception:
|
||||
current_status = ""
|
||||
if current_status == "Not Found":
|
||||
MessageService.information(
|
||||
self,
|
||||
"TTW_Linux_Installer Installation",
|
||||
(
|
||||
"TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.<br><br>"
|
||||
"Project: <a href=\"https://github.com/SulfurNitride/TTW_Linux_Installer\">github.com/SulfurNitride/TTW_Linux_Installer</a><br>"
|
||||
"Please star the repository and thank the developer.<br><br>"
|
||||
"Jackify will now download and install the latest Linux build of TTW_Linux_Installer."
|
||||
),
|
||||
safety_level="low",
|
||||
)
|
||||
"""Navigate to Tools Hub for TTW Linux Installer management."""
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(10)
|
||||
|
||||
# Update button to show installation in progress
|
||||
self.ttw_installer_btn.setText("Installing...")
|
||||
self.ttw_installer_btn.setEnabled(False)
|
||||
def _check_ttw_requirements(self, silent: bool = False) -> bool:
|
||||
detected = _detect_ttw_games()
|
||||
missing = [name for name in _TTW_GAMES if name not in detected]
|
||||
|
||||
self.console.append("Installing/updating TTW_Linux_Installer...")
|
||||
|
||||
# Create background thread for installation
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
class InstallerDownloadThread(QThread):
|
||||
finished = Signal(bool, str) # success, message
|
||||
progress = Signal(str) # progress message
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
# Create handler instances
|
||||
filesystem_handler = FileSystemHandler()
|
||||
config_handler = ConfigHandler()
|
||||
system_info = SystemInfo(is_steamdeck=False)
|
||||
ttw_installer_handler = TTWInstallerHandler(
|
||||
steamdeck=False,
|
||||
verbose=False,
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Install TTW_Linux_Installer (this will download and extract)
|
||||
self.progress.emit("Downloading TTW_Linux_Installer...")
|
||||
success, message = ttw_installer_handler.install_ttw_installer()
|
||||
|
||||
if success:
|
||||
install_path = ttw_installer_handler.ttw_installer_dir
|
||||
self.progress.emit(f"Installation complete: {install_path}")
|
||||
else:
|
||||
self.progress.emit(f"Installation failed: {message}")
|
||||
|
||||
self.finished.emit(success, message)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error installing TTW_Linux_Installer: {str(e)}"
|
||||
self.progress.emit(error_msg)
|
||||
logger.debug(f"DEBUG: TTW_Linux_Installer installation error: {e}")
|
||||
self.finished.emit(False, error_msg)
|
||||
|
||||
# Create and start thread
|
||||
self.installer_download_thread = InstallerDownloadThread()
|
||||
self.installer_download_thread.progress.connect(self._on_installer_download_progress)
|
||||
self.installer_download_thread.finished.connect(self._on_installer_download_finished)
|
||||
self.installer_download_thread.start()
|
||||
|
||||
# Update Activity window to show download in progress
|
||||
self.file_progress_list.clear()
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Downloading TTW_Linux_Installer...",
|
||||
progress=0
|
||||
)
|
||||
|
||||
def _on_installer_download_progress(self, message):
|
||||
"""Handle installer download progress updates"""
|
||||
self.console.append(message)
|
||||
# Update Activity window based on progress message
|
||||
if "Downloading" in message:
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Downloading TTW_Linux_Installer...",
|
||||
progress=0 # Indeterminate progress
|
||||
)
|
||||
elif "Extracting" in message or "extracting" in message.lower():
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Extracting TTW_Linux_Installer...",
|
||||
progress=50
|
||||
)
|
||||
elif "complete" in message.lower() or "successfully" in message.lower():
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="TTW_Linux_Installer ready",
|
||||
progress=100
|
||||
)
|
||||
|
||||
def _on_installer_download_finished(self, success, message):
|
||||
"""Handle installer download completion"""
|
||||
if success:
|
||||
self.console.append("TTW_Linux_Installer installed successfully")
|
||||
# Clear Activity window after successful installation
|
||||
self.file_progress_list.clear()
|
||||
# Re-check status after installation (this will update button state correctly)
|
||||
self._check_ttw_installer_status()
|
||||
self._update_start_button_state()
|
||||
else:
|
||||
self.console.append(f"Installation failed: {message}")
|
||||
# Clear Activity window on failure
|
||||
self.file_progress_list.clear()
|
||||
# Re-enable button on failure so user can retry
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
|
||||
def _check_ttw_requirements(self):
|
||||
"""Check TTW requirements before installation"""
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
|
||||
path_handler = PathHandler()
|
||||
|
||||
# Check game detection
|
||||
detected_games = path_handler.find_vanilla_game_paths()
|
||||
missing_games = []
|
||||
|
||||
if 'Fallout 3' not in detected_games:
|
||||
missing_games.append("Fallout 3")
|
||||
if 'Fallout New Vegas' not in detected_games:
|
||||
missing_games.append("Fallout New Vegas")
|
||||
|
||||
if missing_games:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Missing Required Games",
|
||||
f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}"
|
||||
)
|
||||
if missing:
|
||||
if not silent:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Missing Required Games",
|
||||
f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\n"
|
||||
f"Not found: {', '.join(missing)}\n\n"
|
||||
"Install via Steam, GOG (through Heroic), or another supported store."
|
||||
)
|
||||
return False
|
||||
|
||||
# Check TTW_Linux_Installer using the status we already checked
|
||||
status_text = self.ttw_installer_status.text()
|
||||
if status_text in ("Not Found", "Check Failed"):
|
||||
MessageService.warning(
|
||||
self,
|
||||
"TTW_Linux_Installer Required",
|
||||
"TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer using the 'Install now' button."
|
||||
)
|
||||
|
||||
if not self._ttw_installer_ready:
|
||||
if not silent:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"TTW Linux Installer Required",
|
||||
"TTW Linux Installer is not installed.\n\nInstall it from the Tools Hub before proceeding."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _update_start_button_state(self):
|
||||
"""Enable/disable Start button based on requirements and file selection"""
|
||||
# Check if all requirements are met
|
||||
requirements_met = self._check_ttw_requirements()
|
||||
|
||||
# Check if .mpi file is selected
|
||||
requirements_met = self._check_ttw_requirements(silent=True)
|
||||
mpi_file_selected = bool(self.file_edit.text().strip())
|
||||
|
||||
# Enable Start button only if both requirements are met and file is selected
|
||||
self.start_btn.setEnabled(requirements_met and mpi_file_selected)
|
||||
|
||||
# Update button text to indicate what's missing
|
||||
if not requirements_met:
|
||||
self.start_btn.setText("Requirements Not Met")
|
||||
elif not mpi_file_selected:
|
||||
self.start_btn.setText("Select TTW .mpi File")
|
||||
else:
|
||||
self.start_btn.setText("Start Installation")
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""TTW installation worker thread."""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
from ..utils import strip_ansi_control_codes
|
||||
@@ -21,11 +23,15 @@ class TTWInstallationThread(QThread):
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
try:
|
||||
if self.proc and self.proc.poll() is None:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
if self.proc and self.proc.poll() is None:
|
||||
try:
|
||||
pgid = os.getpgid(self.proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except Exception:
|
||||
try:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def process_and_buffer_line(self, raw_line):
|
||||
"""Clean one output line and queue it for batched emit."""
|
||||
@@ -62,7 +68,7 @@ class TTWInstallationThread(QThread):
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
self.process_and_buffer_line("Initializing TTW installation...")
|
||||
self.process_and_buffer_line("Initialising TTW installation...")
|
||||
self.flush_output_buffer()
|
||||
|
||||
filesystem_handler = FileSystemHandler()
|
||||
|
||||
@@ -84,7 +84,7 @@ class TTWUIMixin:
|
||||
|
||||
# On Steam Deck, skip window resizing - keep default Steam Deck window size
|
||||
if is_steamdeck:
|
||||
logger.debug("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility")
|
||||
logger.debug("Steam Deck detected, skipping window resize in _toggle_console_visibility")
|
||||
return
|
||||
|
||||
# Restore main window to normal size (clear any compact constraints)
|
||||
@@ -137,7 +137,7 @@ class TTWUIMixin:
|
||||
|
||||
# On Steam Deck, skip window resizing to keep maximized state
|
||||
if is_steamdeck:
|
||||
logger.debug("DEBUG: Steam Deck detected, skipping window resize in collapse branch")
|
||||
logger.debug("Steam Deck detected, skipping window resize in collapse branch")
|
||||
return
|
||||
|
||||
# Use fixed compact height for consistency across all workflow screens
|
||||
|
||||
@@ -23,10 +23,9 @@ class TTWWorkflowMixin:
|
||||
def validate_and_start_install(self):
|
||||
import time
|
||||
self._install_workflow_start_time = time.time()
|
||||
logger.debug('DEBUG: validate_and_start_install called')
|
||||
|
||||
self.config_handler.reload_config()
|
||||
logger.debug('DEBUG: Reloaded config from disk')
|
||||
logger.debug("Reloaded config from disk")
|
||||
|
||||
if not self._check_ttw_requirements():
|
||||
return
|
||||
@@ -87,13 +86,13 @@ class TTWWorkflowMixin:
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
logger.debug(f"DEBUG: Deleted all contents of {install_dir}")
|
||||
logger.debug(f"Deleted all contents of {install_dir}")
|
||||
except Exception as e:
|
||||
MessageService.show_error(self, install_dir_create_failed(str(install_dir), str(e)))
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error checking directory contents: {e}")
|
||||
logger.debug(f"Error checking directory contents: {e}")
|
||||
|
||||
if not os.path.isdir(install_dir):
|
||||
create = MessageService.question(self, "Create Directory?",
|
||||
@@ -118,18 +117,15 @@ class TTWWorkflowMixin:
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
logger.debug(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}')
|
||||
logger.debug(f"Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}")
|
||||
self.run_ttw_installer(mpi_path, install_dir)
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug("DEBUG: Controls re-enabled in exception handler")
|
||||
|
||||
def run_ttw_installer(self, mpi_path, install_dir):
|
||||
logger.debug('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER')
|
||||
logger.debug("run_ttw_installer called - USING THREADED BACKEND WRAPPER")
|
||||
|
||||
self.config_handler._load_config()
|
||||
|
||||
@@ -141,11 +137,11 @@ class TTWWorkflowMixin:
|
||||
self._safe_append_text("Starting TTW installation...")
|
||||
|
||||
self.file_progress_list.clear()
|
||||
self._update_ttw_phase("Initializing TTW installation", 0, 0, 0)
|
||||
self._update_ttw_phase("Initialising TTW installation", 0, 0, 0)
|
||||
QApplication.processEvents()
|
||||
|
||||
self.status_banner.setVisible(True)
|
||||
self.status_banner.setText("Initializing TTW installation...")
|
||||
self.status_banner.setText("Initialising TTW installation...")
|
||||
self.show_details_checkbox.setVisible(True)
|
||||
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
@@ -178,7 +174,7 @@ class TTWWorkflowMixin:
|
||||
|
||||
def on_installation_finished(self, success, message):
|
||||
"""Handle installation completion."""
|
||||
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
||||
logger.debug(f"on_installation_finished called with success={success}, message={message}")
|
||||
|
||||
if hasattr(self, 'ttw_elapsed_timer'):
|
||||
self.ttw_elapsed_timer.stop()
|
||||
@@ -189,8 +185,8 @@ class TTWWorkflowMixin:
|
||||
seconds = elapsed % 60
|
||||
self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s")
|
||||
self.status_banner.setStyleSheet("""
|
||||
background-color: #1a4d1a;
|
||||
color: #4CAF50;
|
||||
background-color: #1a3040;
|
||||
color: #3fd0ea;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
@@ -220,11 +216,11 @@ class TTWWorkflowMixin:
|
||||
self.process_finished(1, QProcess.CrashExit)
|
||||
|
||||
def process_finished(self, exit_code, exit_status):
|
||||
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
logger.debug(f"process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug("DEBUG: Button states reset in process_finished")
|
||||
logger.debug("Button states reset in process_finished")
|
||||
|
||||
if exit_code == 0:
|
||||
self._safe_append_text("\nTTW installation completed successfully!")
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Mixin that runs verify_install.py before showing the success dialog."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VerifierThread(QThread):
|
||||
finished = Signal(object)
|
||||
|
||||
def __init__(self, pfx: Path, modlist_dir: Path, game_type: str, appid: str, modlist_name: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self.pfx = pfx
|
||||
self.modlist_dir = modlist_dir
|
||||
self.game_type = game_type
|
||||
self.appid = appid
|
||||
self.modlist_name = modlist_name
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.install_verifier_service import run_install_verification
|
||||
results = run_install_verification(self.pfx, self.modlist_dir, self.game_type, self.appid, self.modlist_name)
|
||||
except Exception as e:
|
||||
logger.warning("Verifier thread error: %s", e)
|
||||
results = None
|
||||
self.finished.emit(results)
|
||||
|
||||
|
||||
def _resolve_pfx_for_appid(appid: str) -> Optional[Path]:
|
||||
from jackify.backend.services.install_verifier_service import resolve_pfx_for_appid
|
||||
return resolve_pfx_for_appid(appid)
|
||||
|
||||
|
||||
class InstallVerifierMixin:
|
||||
"""Mixin: run the verifier before showing the success dialog."""
|
||||
|
||||
def _get_appid_for_install_dir(self, install_dir: str) -> str:
|
||||
stored = getattr(self, "_current_appid", "") or ""
|
||||
if stored:
|
||||
return stored
|
||||
try:
|
||||
import os
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
sh = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
|
||||
for sc in sh.find_shortcuts_by_exe("ModOrganizer.exe"):
|
||||
if os.path.realpath(sc.get("StartDir", "")) == os.path.realpath(install_dir):
|
||||
raw = sc.get("appid")
|
||||
if raw is not None:
|
||||
return str(int(raw) & 0xFFFFFFFF)
|
||||
except Exception as e:
|
||||
logger.debug("AppID lookup failed: %s", e)
|
||||
return ""
|
||||
|
||||
def _maybe_apply_jcontainers_fix(self, install_dir: str, game_type: str) -> None:
|
||||
"""Apply the JContainers Linux fix if needed, with a countdown confirmation dialog."""
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_fixup_handler import (
|
||||
check_jcontainers_needs_fix,
|
||||
apply_jcontainers_fix,
|
||||
)
|
||||
needs_fix = check_jcontainers_needs_fix(Path(install_dir), game_type)
|
||||
if not needs_fix:
|
||||
return
|
||||
|
||||
from jackify.frontends.gui.services.message_service import SafeMessageBox
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
dlg = SafeMessageBox(parent=self, safety_level="low")
|
||||
dlg.setup_safety_features(
|
||||
title="JContainers Compatibility Fix",
|
||||
message=(
|
||||
"The mod JContainers has been detected. The Nexusmods version of "
|
||||
"JContainers is known to cause crashes on Linux/Proton.\n\n"
|
||||
"A fixed version is available from the mod's GitHub page - would you "
|
||||
"like the fixed version to be applied now?\n\n"
|
||||
"The original DLL will be backed up as part of the process."
|
||||
),
|
||||
danger_action="Yes",
|
||||
safe_action="No",
|
||||
is_question=True,
|
||||
)
|
||||
result = dlg.exec()
|
||||
if result == QMessageBox.Yes:
|
||||
apply_jcontainers_fix(Path(install_dir), game_type)
|
||||
logger.info("JContainers fix applied post-configure")
|
||||
except Exception as e:
|
||||
logger.warning("JContainers fix check failed (non-fatal): %s", e)
|
||||
|
||||
def _run_verifier_then_show_success(
|
||||
self,
|
||||
install_dir: str,
|
||||
game_type: str,
|
||||
success_params: dict,
|
||||
appid: str = "",
|
||||
):
|
||||
"""
|
||||
Show 'Verifying...' state, run the verifier in a background thread,
|
||||
then show SuccessDialog with results embedded.
|
||||
|
||||
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
|
||||
"""
|
||||
self._maybe_apply_jcontainers_fix(install_dir, game_type)
|
||||
if hasattr(self, "progress_indicator"):
|
||||
self.progress_indicator.set_status("Verifying installation...", 100)
|
||||
if hasattr(self, "file_progress_list"):
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__verifier__", "Verifying installation...", 0.0
|
||||
)
|
||||
|
||||
resolved_appid = str(appid or self._get_appid_for_install_dir(install_dir) or "")
|
||||
pfx = _resolve_pfx_for_appid(resolved_appid)
|
||||
|
||||
if not install_dir or pfx is None:
|
||||
logger.info(
|
||||
"Verifier skipped: pfx not found (appid=%s dir=%s)",
|
||||
resolved_appid, install_dir,
|
||||
)
|
||||
self._show_success_dialog(success_params, verification_results=None)
|
||||
return
|
||||
|
||||
self._verifier_thread = VerifierThread(
|
||||
pfx=pfx,
|
||||
modlist_dir=Path(install_dir),
|
||||
game_type=game_type,
|
||||
appid=resolved_appid,
|
||||
modlist_name=success_params.get("modlist_name", ""),
|
||||
parent=self,
|
||||
)
|
||||
self._verifier_thread.finished.connect(
|
||||
lambda r: self._on_verifier_complete_show_success(r, success_params)
|
||||
)
|
||||
self._verifier_thread.start()
|
||||
|
||||
def _on_verifier_complete_show_success(self, results, success_params: dict):
|
||||
if self._verifier_thread is not None:
|
||||
self._verifier_thread.wait(2000)
|
||||
self._verifier_thread.deleteLater()
|
||||
self._verifier_thread = None
|
||||
if results is not None:
|
||||
n_pass = len(results.passes)
|
||||
n_warn = len(results.warnings)
|
||||
n_fail = len(results.failures)
|
||||
logger.info(
|
||||
"Install verification: %d passed, %d warnings, %d failures",
|
||||
n_pass, n_warn, n_fail,
|
||||
)
|
||||
for msg in results.failures:
|
||||
logger.warning("Verifier FAIL: %s", msg)
|
||||
for msg in results.warnings:
|
||||
logger.info("Verifier WARN: %s", msg)
|
||||
else:
|
||||
logger.warning("Install verifier returned no results (script error)")
|
||||
|
||||
self._show_success_dialog(success_params, verification_results=results)
|
||||
|
||||
def _show_success_dialog(self, params: dict, verification_results=None):
|
||||
"""Clear the activity window and show SuccessDialog with optional verification results."""
|
||||
if hasattr(self, "file_progress_list"):
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from jackify.frontends.gui.dialogs import SuccessDialog
|
||||
dlg = SuccessDialog(
|
||||
modlist_name=params["modlist_name"],
|
||||
workflow_type=params["workflow_type"],
|
||||
time_taken=params["time_taken"],
|
||||
game_name=params.get("game_name"),
|
||||
verification_results=verification_results,
|
||||
parent=self,
|
||||
)
|
||||
dlg.show()
|
||||
|
||||
if params.get("enb_detected"):
|
||||
try:
|
||||
from jackify.frontends.gui.dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params["modlist_name"], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
@@ -8,11 +8,18 @@ import os
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT
|
||||
from ..utils import set_responsive_minimum
|
||||
|
||||
_TOOLS_HUB_ACTION = "third_party_tools"
|
||||
_UPDATE_COLOUR = "#f0c040"
|
||||
_NORMAL_DESC_COLOUR = "#999"
|
||||
|
||||
class MainMenu(QWidget):
|
||||
def __init__(self, stacked_widget=None, dev_mode=False):
|
||||
super().__init__()
|
||||
self.stacked_widget = stacked_widget
|
||||
self.dev_mode = dev_mode
|
||||
self._tools_hub_btn: QPushButton = None
|
||||
self._tools_hub_desc: QLabel = None
|
||||
self._tools_hub_desc_original: str = ""
|
||||
layout = QVBoxLayout()
|
||||
layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
|
||||
layout.setContentsMargins(30, 30, 30, 30)
|
||||
@@ -63,35 +70,17 @@ class MainMenu(QWidget):
|
||||
button_height = 40
|
||||
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
|
||||
("Additional Tasks", "additional_tasks", "Verifier, diagnostics, Nexus OAuth, and more"),
|
||||
("Tools Hub", "third_party_tools", "Install and manage additional engines and modding tools"),
|
||||
("Exit Jackify", "exit_jackify", "Close the application"),
|
||||
]
|
||||
|
||||
for label, action_id, description in MENU_ITEMS:
|
||||
# Main button
|
||||
btn = QPushButton(label)
|
||||
btn.setFixedSize(button_width, button_height) # Use variable height
|
||||
btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: #4a5568;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #5a6578;
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {JACKIFY_COLOR_BLUE};
|
||||
}}
|
||||
""")
|
||||
btn.setFixedSize(button_width, button_height)
|
||||
btn.setStyleSheet(self._btn_style())
|
||||
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
|
||||
|
||||
# Button container with proper alignment
|
||||
btn_container = QWidget()
|
||||
btn_layout = QVBoxLayout()
|
||||
btn_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -99,10 +88,9 @@ class MainMenu(QWidget):
|
||||
btn_layout.setAlignment(Qt.AlignHCenter)
|
||||
btn_layout.addWidget(btn)
|
||||
|
||||
# Description label with proper alignment
|
||||
desc_label = QLabel(description)
|
||||
desc_label.setAlignment(Qt.AlignHCenter)
|
||||
desc_label.setStyleSheet("color: #999; font-size: 11px;")
|
||||
desc_label.setStyleSheet(f"color: {_NORMAL_DESC_COLOUR}; font-size: 11px;")
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setFixedWidth(button_width)
|
||||
btn_layout.addWidget(desc_label)
|
||||
@@ -110,6 +98,11 @@ class MainMenu(QWidget):
|
||||
btn_container.setLayout(btn_layout)
|
||||
layout.addWidget(btn_container)
|
||||
|
||||
if action_id == _TOOLS_HUB_ACTION:
|
||||
self._tools_hub_btn = btn
|
||||
self._tools_hub_desc = desc_label
|
||||
self._tools_hub_desc_original = description
|
||||
|
||||
# Disclaimer
|
||||
layout.addSpacing(12)
|
||||
disclaimer = QLabel(DISCLAIMER_TEXT)
|
||||
@@ -135,6 +128,34 @@ class MainMenu(QWidget):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _btn_style(self, highlight: bool = False) -> str:
|
||||
border = f"1px solid {_UPDATE_COLOUR}" if highlight else "none"
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background-color: #4a5568;
|
||||
color: white;
|
||||
border: {border};
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #5a6578; }}
|
||||
QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}
|
||||
"""
|
||||
|
||||
def notify_tool_updates(self, has_updates: bool) -> None:
|
||||
if not self._tools_hub_btn or not self._tools_hub_desc:
|
||||
return
|
||||
if has_updates:
|
||||
self._tools_hub_btn.setStyleSheet(self._btn_style(highlight=True))
|
||||
self._tools_hub_desc.setText("Updates available")
|
||||
self._tools_hub_desc.setStyleSheet(f"color: {_UPDATE_COLOUR}; font-size: 11px; font-weight: bold;")
|
||||
else:
|
||||
self._tools_hub_btn.setStyleSheet(self._btn_style(highlight=False))
|
||||
self._tools_hub_desc.setText(self._tools_hub_desc_original)
|
||||
self._tools_hub_desc.setStyleSheet(f"color: {_NORMAL_DESC_COLOUR}; font-size: 11px;")
|
||||
|
||||
def menu_action(self, action_id):
|
||||
if action_id == "exit_jackify":
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
@@ -137,7 +137,7 @@ class ModlistTasksScreen(QWidget):
|
||||
"""Set up the menu buttons section"""
|
||||
# Menu options
|
||||
MENU_ITEMS = [
|
||||
("Install a Modlist (Automated)", "install_modlist", "Download and install modlists automatically"),
|
||||
("Install a Modlist", "install_modlist", "Download and install modlists automatically"),
|
||||
("Configure New Modlist (Post-Download)", "configure_new_modlist", "Configure a newly downloaded modlist"),
|
||||
("Configure Existing Modlist (In Steam)", "configure_existing_modlist", "Reconfigure an existing Steam modlist"),
|
||||
]
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Tools Hub screen.
|
||||
|
||||
Manages independently-versioned engines and tools. On each show, the tool list
|
||||
is rebuilt from the effective definitions (remote manifest if fetched, else
|
||||
baked-in). A background thread fetches the manifest; if the tool list changes
|
||||
the cards are rebuilt and version checks restart.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QLabel,
|
||||
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from jackify.backend.services.tool_registry import (
|
||||
ToolDefinition, ToolRegistry, ToolStatus,
|
||||
apply_remote_manifest, fetch_remote_manifest, fetch_release_list,
|
||||
get_active_engine_id, get_effective_definitions,
|
||||
)
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
from jackify.frontends.gui.screens.tools_hub_card import ToolCard, btn_style, section_header
|
||||
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__)
|
||||
|
||||
_C_UPDATE = "#4a5568"
|
||||
_C_BACK = "#4a5568"
|
||||
_C_INSTALL = "#1a5fa8"
|
||||
|
||||
|
||||
# -- background threads ------------------------------------------------------
|
||||
class _VersionCheckThread(QThread):
|
||||
version_ready = Signal(str, str) # tool_id, latest_tag
|
||||
|
||||
def run(self):
|
||||
registry = ToolRegistry()
|
||||
for defn in get_effective_definitions():
|
||||
try:
|
||||
tag = registry.check_latest_version(defn.tool_id)
|
||||
self.version_ready.emit(defn.tool_id, tag or "unknown")
|
||||
except Exception as e:
|
||||
logger.debug("Version check failed for %s: %s", defn.tool_id, e)
|
||||
self.version_ready.emit(defn.tool_id, "unknown")
|
||||
|
||||
|
||||
class _ToolActionThread(QThread):
|
||||
finished_signal = Signal(str, bool, str) # tool_id, success, message
|
||||
|
||||
def __init__(self, tool_id: str, action: str, version: Optional[str] = None):
|
||||
super().__init__()
|
||||
self._tool_id = tool_id
|
||||
self._action = action
|
||||
self._version = version
|
||||
|
||||
def run(self):
|
||||
registry = ToolRegistry()
|
||||
try:
|
||||
if self._action == "install":
|
||||
ok, msg = registry.install(self._tool_id, version=self._version)
|
||||
elif self._action == "update":
|
||||
ok, msg = registry.update(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)
|
||||
|
||||
|
||||
class _ManifestFetchThread(QThread):
|
||||
manifest_ready = Signal(list) # List[ToolDefinition]
|
||||
|
||||
def run(self):
|
||||
result = fetch_remote_manifest()
|
||||
if result:
|
||||
self.manifest_ready.emit(result)
|
||||
|
||||
|
||||
class _ReleaseFetchThread(QThread):
|
||||
releases_ready = Signal(str, list) # tool_id, List[dict]
|
||||
|
||||
def __init__(self, tool_id: str, github_repo: str):
|
||||
super().__init__()
|
||||
self._tool_id = tool_id
|
||||
self._github_repo = github_repo
|
||||
|
||||
def run(self):
|
||||
releases = fetch_release_list(self._github_repo)
|
||||
self.releases_ready.emit(self._tool_id, releases)
|
||||
|
||||
|
||||
# -- main screen -------------------------------------------------------------
|
||||
class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""Tools Hub: engine selection and third-party tool management."""
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index: int = 0, ttw_screen_index: int = 5, parent=None):
|
||||
super().__init__(parent)
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
self.ttw_screen_index = ttw_screen_index
|
||||
|
||||
self._cards: Dict[str, ToolCard] = {}
|
||||
self._action_thread: Optional[_ToolActionThread] = None
|
||||
self._version_thread: Optional[_VersionCheckThread] = None
|
||||
self._manifest_thread: Optional[_ManifestFetchThread] = None
|
||||
self._release_thread: Optional[_ReleaseFetchThread] = None
|
||||
self._active_engine_id = get_active_engine_id()
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
root = QVBoxLayout()
|
||||
root.setContentsMargins(30, 24, 30, 24)
|
||||
root.setSpacing(0)
|
||||
self.setLayout(root)
|
||||
|
||||
header_row = QHBoxLayout()
|
||||
self._btn_update_all = QPushButton("Update All")
|
||||
self._btn_update_all.setFixedSize(100, 30)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
|
||||
self._btn_update_all.setEnabled(False)
|
||||
self._btn_update_all.clicked.connect(self._on_update_all)
|
||||
# Left spacer matches button width so title stays centred
|
||||
left_spacer = QWidget()
|
||||
left_spacer.setFixedWidth(100)
|
||||
header_row.addWidget(left_spacer)
|
||||
header_row.addStretch()
|
||||
title = QLabel("<b>Tools Hub</b>")
|
||||
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
|
||||
header_row.addWidget(title)
|
||||
header_row.addStretch()
|
||||
header_row.addWidget(self._btn_update_all)
|
||||
root.addLayout(header_row)
|
||||
|
||||
root.addSpacing(6)
|
||||
|
||||
disclaimer = QLabel(
|
||||
"Some of these tools are developed and maintained by their respective authors, "
|
||||
"independently of Jackify. Jackify provides download and update management "
|
||||
"as a convenience only. The Jackify project offers no warranty or support "
|
||||
"for third-party tools."
|
||||
)
|
||||
disclaimer.setWordWrap(True)
|
||||
disclaimer.setStyleSheet("color: #aaa; font-size: 12px;")
|
||||
root.addWidget(disclaimer)
|
||||
|
||||
root.addSpacing(10)
|
||||
sep = QLabel()
|
||||
sep.setFixedHeight(2)
|
||||
sep.setStyleSheet("background: #fff;")
|
||||
root.addWidget(sep)
|
||||
root.addSpacing(12)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.NoFrame)
|
||||
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
|
||||
self._list_widget = QWidget()
|
||||
self._list_widget.setStyleSheet("background: transparent;")
|
||||
self._list_layout = QVBoxLayout()
|
||||
self._list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._list_layout.setSpacing(6)
|
||||
self._list_widget.setLayout(self._list_layout)
|
||||
|
||||
scroll.setWidget(self._list_widget)
|
||||
root.addWidget(scroll, stretch=1)
|
||||
|
||||
root.addSpacing(12)
|
||||
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: {_C_BACK}; color: white; border: none; "
|
||||
f"border-radius: 6px; font-size: 12px; font-weight: bold; }}"
|
||||
f"QPushButton:hover {{ background-color: #5a6578; }}"
|
||||
f"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)
|
||||
|
||||
# card list management
|
||||
|
||||
def _rebuild_card_list(self):
|
||||
while self._list_layout.count():
|
||||
item = self._list_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
self._cards.clear()
|
||||
|
||||
statuses = ToolRegistry().get_all_statuses()
|
||||
engines = [s for s in statuses if s.definition.is_engine]
|
||||
tools = [s for s in statuses if not s.definition.is_engine]
|
||||
|
||||
if engines:
|
||||
self._list_layout.addWidget(section_header("Engine"))
|
||||
self._list_layout.addSpacing(4)
|
||||
for s in engines:
|
||||
self._add_card(s)
|
||||
self._list_layout.addSpacing(10)
|
||||
|
||||
if tools:
|
||||
self._list_layout.addWidget(section_header("Tools"))
|
||||
self._list_layout.addSpacing(4)
|
||||
for s in tools:
|
||||
self._add_card(s)
|
||||
|
||||
placeholder = QLabel("More tools coming soon")
|
||||
placeholder.setAlignment(Qt.AlignCenter)
|
||||
placeholder.setStyleSheet(
|
||||
"color: #555; font-size: 12px; font-style: italic; "
|
||||
"background-color: #222; border: 1px dashed #333; "
|
||||
"border-radius: 6px; padding: 10px;"
|
||||
)
|
||||
self._list_layout.addWidget(placeholder)
|
||||
|
||||
self._list_layout.addStretch()
|
||||
|
||||
def _add_card(self, status: ToolStatus):
|
||||
card = ToolCard(status, self._active_engine_id)
|
||||
card.action_requested.connect(self._on_action)
|
||||
card.engine_activated.connect(self._on_engine_activated)
|
||||
self._cards[status.definition.tool_id] = card
|
||||
self._list_layout.addWidget(card)
|
||||
|
||||
# show / manifest / version check
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
try:
|
||||
mw = self.window()
|
||||
if mw:
|
||||
set_responsive_minimum(mw, min_width=960, min_height=520)
|
||||
except Exception:
|
||||
pass
|
||||
self._active_engine_id = get_active_engine_id()
|
||||
self._rebuild_card_list()
|
||||
self._start_manifest_fetch()
|
||||
self._start_version_check()
|
||||
|
||||
def _start_manifest_fetch(self):
|
||||
if self._manifest_thread and self._manifest_thread.isRunning():
|
||||
return
|
||||
self._manifest_thread = _ManifestFetchThread()
|
||||
self._manifest_thread.manifest_ready.connect(self._on_manifest_ready)
|
||||
self._manifest_thread.start()
|
||||
|
||||
def _on_manifest_ready(self, definitions: List[ToolDefinition]):
|
||||
current_ids = set(self._cards.keys())
|
||||
new_ids = {d.tool_id for d in definitions}
|
||||
apply_remote_manifest(definitions)
|
||||
if current_ids != new_ids:
|
||||
if self._version_thread and self._version_thread.isRunning():
|
||||
self._version_thread.quit()
|
||||
self._rebuild_card_list()
|
||||
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:
|
||||
has_update = card.set_latest_version(tag)
|
||||
if has_update:
|
||||
self._btn_update_all.setEnabled(True)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE))
|
||||
any_updates = any(c._status.update_available for c in self._cards.values())
|
||||
main_menu = self._get_main_menu()
|
||||
if main_menu:
|
||||
main_menu.notify_tool_updates(any_updates)
|
||||
|
||||
def _get_main_menu(self):
|
||||
try:
|
||||
from jackify.frontends.gui.screens.main_menu import MainMenu
|
||||
w = self.window()
|
||||
if w and hasattr(w, 'main_menu'):
|
||||
return w.main_menu
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# engine activation
|
||||
|
||||
def _on_engine_activated(self, tool_id: str):
|
||||
self._active_engine_id = tool_id
|
||||
for card in self._cards.values():
|
||||
card.set_active_engine(tool_id)
|
||||
card = self._cards.get(tool_id)
|
||||
name = card._status.definition.display_name if card else tool_id
|
||||
MessageService.information(self, "Engine Changed", f"{name} is now the active engine.")
|
||||
|
||||
# action dispatch
|
||||
|
||||
def _on_action(self, tool_id: str, action: str):
|
||||
if action == "launch_jackify_ui":
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.ttw_screen_index)
|
||||
ttw_screen = self.stacked_widget.widget(self.ttw_screen_index)
|
||||
if hasattr(ttw_screen, 'main_menu_index'):
|
||||
ttw_screen.main_menu_index = self.stacked_widget.indexOf(self)
|
||||
return
|
||||
if self._action_thread and self._action_thread.isRunning():
|
||||
MessageService.information(self, "Busy", "Another operation is running. Please wait.")
|
||||
return
|
||||
|
||||
if action == "downgrade":
|
||||
self._start_downgrade_flow(tool_id)
|
||||
return
|
||||
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
label_map = {
|
||||
"install": "Installing...", "update": "Updating...", "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:
|
||||
status = ToolRegistry().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()
|
||||
if message:
|
||||
MessageService.information(self, "Done", message)
|
||||
else:
|
||||
if card:
|
||||
card.set_busy(False)
|
||||
MessageService.warning(self, "Failed", message)
|
||||
|
||||
def _on_update_all(self):
|
||||
updates = [tid for tid, card in self._cards.items()
|
||||
if card._status.installed and card._status.update_available]
|
||||
if not updates:
|
||||
return
|
||||
names = ", ".join(self._cards[tid]._status.definition.display_name for tid in updates)
|
||||
if MessageService.question(self, "Update All", f"Update the following tools?\n\n{names}") != QMessageBox.Yes:
|
||||
return
|
||||
self._pending_updates: List[str] = updates
|
||||
self._run_next_update()
|
||||
|
||||
def _run_next_update(self):
|
||||
if not self._pending_updates:
|
||||
return
|
||||
tool_id = self._pending_updates.pop(0)
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
card.set_busy(True, "Updating...")
|
||||
self._action_thread = _ToolActionThread(tool_id, "update")
|
||||
self._action_thread.finished_signal.connect(self._on_update_all_step)
|
||||
self._action_thread.start()
|
||||
|
||||
def _on_update_all_step(self, tool_id: str, success: bool, message: str):
|
||||
self._action_thread = None
|
||||
self._on_action_finished(tool_id, success, message if not success else "")
|
||||
if getattr(self, "_pending_updates", []):
|
||||
self._run_next_update()
|
||||
else:
|
||||
any_remaining = any(c._status.installed and c._status.update_available for c in self._cards.values())
|
||||
self._btn_update_all.setEnabled(any_remaining)
|
||||
if not any_remaining:
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
|
||||
|
||||
def _start_downgrade_flow(self, tool_id: str):
|
||||
card = self._cards.get(tool_id)
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
if not status:
|
||||
return
|
||||
if card:
|
||||
card.set_busy(True, "Fetching releases...")
|
||||
self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo)
|
||||
self._release_thread.releases_ready.connect(self._on_releases_ready)
|
||||
self._release_thread.start()
|
||||
|
||||
def _on_releases_ready(self, tool_id: str, releases: list):
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
card.set_busy(False)
|
||||
if not releases:
|
||||
MessageService.warning(self, "Change Version", "Could not fetch release list from GitHub.")
|
||||
return
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
current = status.installed_version if status else None
|
||||
version = self._show_version_picker(tool_id, releases, current)
|
||||
if not version:
|
||||
return
|
||||
if card:
|
||||
card.set_busy(True, "Changing version...")
|
||||
self._action_thread = _ToolActionThread(tool_id, "install", version=version)
|
||||
self._action_thread.finished_signal.connect(self._on_action_finished)
|
||||
self._action_thread.start()
|
||||
|
||||
def _show_version_picker(self, tool_id: str, releases: list, current_version: Optional[str]) -> Optional[str]:
|
||||
dlg = QDialog(self.window())
|
||||
dlg.setWindowTitle("Select Version")
|
||||
dlg.setWindowModality(Qt.ApplicationModal)
|
||||
dlg.setMinimumWidth(360)
|
||||
dlg.setStyleSheet(
|
||||
"QDialog { background-color: #232323; color: #e0e0e0; }"
|
||||
"QLabel { color: #e0e0e0; background: transparent; border: none; }"
|
||||
"QComboBox { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; "
|
||||
" border-radius: 4px; padding: 4px 8px; }"
|
||||
"QComboBox QAbstractItemView { background-color: #2a2a2a; color: #e0e0e0; "
|
||||
" selection-background-color: #3a3a3a; }"
|
||||
)
|
||||
layout = QVBoxLayout(dlg)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
if current_version:
|
||||
lbl = QLabel(f"Currently installed: <b>{current_version}</b>")
|
||||
lbl.setTextFormat(Qt.RichText)
|
||||
layout.addWidget(lbl)
|
||||
combo = QComboBox()
|
||||
for rel in releases:
|
||||
tag = rel.get("tag_name") or rel.get("name", "")
|
||||
date = (rel.get("published_at") or "")[:10]
|
||||
combo.addItem(f"{tag} ({date})", userData=tag)
|
||||
layout.addWidget(combo)
|
||||
sep = QLabel()
|
||||
sep.setFixedHeight(1)
|
||||
sep.setStyleSheet("background: #3a3a3a; border: none;")
|
||||
layout.addWidget(sep)
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setFixedSize(90, 30)
|
||||
cancel_btn.setStyleSheet(btn_style(_C_BACK))
|
||||
cancel_btn.clicked.connect(dlg.reject)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
install_btn = QPushButton("Install")
|
||||
install_btn.setFixedSize(90, 30)
|
||||
install_btn.setStyleSheet(btn_style(_C_INSTALL))
|
||||
install_btn.setDefault(True)
|
||||
install_btn.clicked.connect(dlg.accept)
|
||||
btn_row.addWidget(install_btn)
|
||||
layout.addLayout(btn_row)
|
||||
if dlg.exec() != QDialog.Accepted:
|
||||
return None
|
||||
return combo.currentData()
|
||||
|
||||
def _go_back(self):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._park_all_threads()
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Tools Hub card widget.
|
||||
|
||||
Per-tool card showing status badge, version, and action buttons.
|
||||
Engines show Set Active / Active badge; tools with can_launch show Launch.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFrame, QHBoxLayout, QLabel,
|
||||
QMenu, QPushButton, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from jackify.backend.services.tool_registry import ToolRegistry, ToolStatus, set_active_engine_id
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_C_INSTALL = "#1a5fa8"
|
||||
_C_UPDATE = "#4a5568"
|
||||
_C_LAUNCH = "#1a5fa8"
|
||||
_C_SET_ACTIVE = "#4a5568"
|
||||
_C_BACK = "#4a5568"
|
||||
_C_DISABLED = "#333"
|
||||
|
||||
_BADGE_NOT_INSTALLED = ("#555", "#ccc")
|
||||
_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8")
|
||||
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
|
||||
_BADGE_ACTIVE = ("#0e3d5a", JACKIFY_COLOR_BLUE)
|
||||
|
||||
|
||||
def btn_style(colour: str, disabled: bool = False, width: int = 90) -> str:
|
||||
bg = _C_DISABLED if disabled else colour
|
||||
hover = "#444" if disabled else colour
|
||||
return (
|
||||
f"QPushButton {{ background-color: {bg}; color: {'#666' if disabled else 'white'}; "
|
||||
f"border: none; border-radius: 4px; font-size: 11px; font-weight: bold; "
|
||||
f"padding: 4px 8px; min-width: {width}px; }}"
|
||||
f"QPushButton:hover {{ background-color: {hover}; }}"
|
||||
)
|
||||
|
||||
|
||||
def section_header(text: str) -> QLabel:
|
||||
lbl = QLabel(text.upper())
|
||||
lbl.setStyleSheet(
|
||||
"color: #777; font-size: 10px; font-weight: bold; letter-spacing: 1px; "
|
||||
"background: transparent; border: none; padding: 0;"
|
||||
)
|
||||
return lbl
|
||||
|
||||
|
||||
class ToolCard(QFrame):
|
||||
action_requested = Signal(str, str) # tool_id, action
|
||||
engine_activated = Signal(str) # tool_id
|
||||
|
||||
def __init__(self, status: ToolStatus, active_engine_id: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._tool_id = status.definition.tool_id
|
||||
self._status = status
|
||||
self._active_engine_id = active_engine_id
|
||||
self._busy = False
|
||||
self._busy_label: Optional[str] = None
|
||||
|
||||
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, 7, 14, 7)
|
||||
outer.setSpacing(12)
|
||||
|
||||
info_col = QVBoxLayout()
|
||||
info_col.setSpacing(2)
|
||||
self._name_label = QLabel(f"<b>{status.definition.display_name}</b>")
|
||||
self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
|
||||
info_col.addWidget(self._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_w = QWidget()
|
||||
info_w.setLayout(info_col)
|
||||
info_w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
info_w.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(info_w, stretch=3)
|
||||
|
||||
centre_col = QVBoxLayout()
|
||||
centre_col.setSpacing(4)
|
||||
centre_col.setAlignment(Qt.AlignCenter)
|
||||
self._badge = QLabel()
|
||||
self._badge.setAlignment(Qt.AlignCenter)
|
||||
self._badge.setFixedWidth(140)
|
||||
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_w = QWidget()
|
||||
centre_w.setLayout(centre_col)
|
||||
centre_w.setFixedWidth(160)
|
||||
centre_w.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(centre_w)
|
||||
|
||||
btn_col = QVBoxLayout()
|
||||
btn_col.setSpacing(3)
|
||||
btn_col.setAlignment(Qt.AlignCenter)
|
||||
self._btn_primary = QPushButton()
|
||||
self._btn_primary.setFixedWidth(100)
|
||||
self._btn_primary.clicked.connect(self._on_primary)
|
||||
btn_col.addWidget(self._btn_primary)
|
||||
self._btn_update = QPushButton("Update")
|
||||
self._btn_update.setFixedWidth(100)
|
||||
self._btn_update.setVisible(False)
|
||||
self._btn_update.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "update"))
|
||||
btn_col.addWidget(self._btn_update)
|
||||
self._btn_more = QPushButton("...")
|
||||
self._btn_more.setFixedWidth(100)
|
||||
self._btn_more.setStyleSheet(btn_style(_C_BACK))
|
||||
self._btn_more.clicked.connect(self._on_more)
|
||||
btn_col.addWidget(self._btn_more)
|
||||
btn_w = QWidget()
|
||||
btn_w.setLayout(btn_col)
|
||||
btn_w.setFixedWidth(120)
|
||||
btn_w.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(btn_w)
|
||||
|
||||
self.setLayout(outer)
|
||||
self._refresh_ui()
|
||||
|
||||
def _refresh_ui(self):
|
||||
defn = self._status.definition
|
||||
installed = self._status.installed
|
||||
update_avail = self._status.update_available
|
||||
is_active = defn.is_engine and self._active_engine_id == self._tool_id
|
||||
|
||||
if self._busy:
|
||||
self._badge.setText("Working...")
|
||||
self._badge.setStyleSheet(
|
||||
"background-color: #555; color: #ccc; border-radius: 3px; "
|
||||
"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
|
||||
)
|
||||
self._btn_primary.setText(self._busy_label or "Working...")
|
||||
self._btn_primary.setEnabled(False)
|
||||
self._btn_primary.setVisible(True)
|
||||
self._btn_update.setVisible(False)
|
||||
self._btn_more.setEnabled(False)
|
||||
return
|
||||
|
||||
if defn.is_engine and is_active:
|
||||
bg, fg, badge_text = *_BADGE_ACTIVE, "Active Engine"
|
||||
elif not installed:
|
||||
bg, fg, badge_text = *_BADGE_NOT_INSTALLED, "Not Installed"
|
||||
elif update_avail:
|
||||
bg, fg, badge_text = *_BADGE_UPDATE_AVAIL, "Update Available"
|
||||
else:
|
||||
bg, fg, badge_text = *_BADGE_UP_TO_DATE, "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;"
|
||||
)
|
||||
|
||||
iv = self._status.installed_version or "-"
|
||||
lv = self._status.latest_version or "checking..."
|
||||
self._version_label.setText(
|
||||
f"Installed: {iv}\nLatest: {lv}" if installed else f"Latest: {lv}"
|
||||
)
|
||||
|
||||
if not installed:
|
||||
self._btn_primary.setText("Install")
|
||||
self._btn_primary.setStyleSheet(btn_style(_C_INSTALL))
|
||||
self._btn_primary.setEnabled(not self._busy)
|
||||
self._btn_primary.setVisible(True)
|
||||
elif defn.is_engine:
|
||||
if is_active:
|
||||
self._btn_primary.setText("Active")
|
||||
self._btn_primary.setStyleSheet(btn_style(_C_DISABLED, disabled=True))
|
||||
self._btn_primary.setEnabled(False)
|
||||
else:
|
||||
self._btn_primary.setText("Set Active")
|
||||
self._btn_primary.setStyleSheet(btn_style(_C_SET_ACTIVE))
|
||||
self._btn_primary.setEnabled(not self._busy)
|
||||
self._btn_primary.setVisible(True)
|
||||
elif defn.can_launch:
|
||||
self._btn_primary.setText("Launch")
|
||||
self._btn_primary.setStyleSheet(btn_style(_C_LAUNCH))
|
||||
self._btn_primary.setEnabled(not self._busy)
|
||||
self._btn_primary.setVisible(True)
|
||||
else:
|
||||
self._btn_primary.setVisible(False)
|
||||
|
||||
self._btn_update.setVisible(installed and update_avail and not self._busy)
|
||||
if installed and update_avail:
|
||||
self._btn_update.setStyleSheet(btn_style(_C_UPDATE))
|
||||
self._btn_more.setEnabled(not self._busy)
|
||||
|
||||
def set_latest_version(self, tag: str) -> bool:
|
||||
self._status.latest_version = tag
|
||||
if self._status.installed and self._status.installed_version and tag != "unknown":
|
||||
self._status.update_available = tag.lstrip("v") != self._status.installed_version.lstrip("v")
|
||||
self._refresh_ui()
|
||||
return self._status.update_available
|
||||
|
||||
def set_active_engine(self, active_id: str):
|
||||
self._active_engine_id = active_id
|
||||
self._refresh_ui()
|
||||
|
||||
def set_busy(self, busy: bool, label: Optional[str] = None):
|
||||
self._busy = busy
|
||||
self._busy_label = label if busy else None
|
||||
self._refresh_ui()
|
||||
|
||||
def mark_installed(self, version: str):
|
||||
self._status.installed = True
|
||||
self._status.installed_version = version
|
||||
self._status.update_available = False
|
||||
self._busy = False
|
||||
self._busy_label = None
|
||||
self._refresh_ui()
|
||||
|
||||
def mark_uninstalled(self):
|
||||
self._status.installed = False
|
||||
self._status.installed_version = None
|
||||
self._status.update_available = False
|
||||
self._busy = False
|
||||
self._busy_label = None
|
||||
self._refresh_ui()
|
||||
|
||||
def _prompt_uninstall(self, display_name: str):
|
||||
dlg = QDialog(self.window())
|
||||
dlg.setWindowTitle("Uninstall Tool")
|
||||
dlg.setWindowModality(Qt.ApplicationModal)
|
||||
dlg.setAttribute(Qt.WA_DeleteOnClose)
|
||||
dlg.setMinimumWidth(340)
|
||||
dlg.setStyleSheet(
|
||||
"QDialog { background-color: #232323; color: #e0e0e0; }"
|
||||
"QLabel { color: #e0e0e0; font-size: 13px; background: transparent; border: none; }"
|
||||
)
|
||||
layout = QVBoxLayout(dlg)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(16)
|
||||
msg = QLabel(f"Uninstall <b>{display_name}</b>?<br><br>This will delete the installed files.")
|
||||
msg.setTextFormat(Qt.RichText)
|
||||
msg.setWordWrap(True)
|
||||
layout.addWidget(msg)
|
||||
sep = QLabel()
|
||||
sep.setFixedHeight(1)
|
||||
sep.setStyleSheet("background: #3a3a3a; border: none;")
|
||||
layout.addWidget(sep)
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setSpacing(8)
|
||||
btn_row.addStretch()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setFixedSize(90, 30)
|
||||
cancel_btn.setStyleSheet(btn_style(_C_BACK))
|
||||
cancel_btn.setDefault(True)
|
||||
cancel_btn.clicked.connect(dlg.reject)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
uninstall_btn = QPushButton("Uninstall")
|
||||
uninstall_btn.setFixedSize(90, 30)
|
||||
uninstall_btn.setStyleSheet(btn_style("#8b2020"))
|
||||
uninstall_btn.clicked.connect(dlg.accept)
|
||||
btn_row.addWidget(uninstall_btn)
|
||||
layout.addLayout(btn_row)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
self.action_requested.emit(self._tool_id, "uninstall")
|
||||
|
||||
def _on_primary(self):
|
||||
defn = self._status.definition
|
||||
if not self._status.installed:
|
||||
self.action_requested.emit(self._tool_id, "install")
|
||||
elif defn.is_engine:
|
||||
try:
|
||||
set_active_engine_id(self._tool_id)
|
||||
self.engine_activated.emit(self._tool_id)
|
||||
except Exception as e:
|
||||
MessageService.warning(self, "Error", str(e))
|
||||
elif defn.can_launch:
|
||||
if self._tool_id == "ttw_installer":
|
||||
self.action_requested.emit(self._tool_id, "launch_jackify_ui")
|
||||
else:
|
||||
self._launch()
|
||||
|
||||
def _launch(self):
|
||||
binary = ToolRegistry().get_binary_path(self._tool_id)
|
||||
if not binary:
|
||||
MessageService.warning(
|
||||
self, "Not Found",
|
||||
f"No executable found for {self._status.definition.display_name}. Try reinstalling it."
|
||||
)
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(binary)], start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
MessageService.warning(self, "Launch Failed", str(e))
|
||||
|
||||
def _on_more(self):
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet(
|
||||
"QMenu { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; }"
|
||||
"QMenu::item:selected { background-color: #3a3a3a; }"
|
||||
"QMenu::item:disabled { color: #555; }"
|
||||
)
|
||||
defn = self._status.definition
|
||||
downgrade_action = menu.addAction("Change Version")
|
||||
downgrade_action.setEnabled(self._status.can_downgrade and not self._busy)
|
||||
uninstall_action = menu.addAction("Uninstall")
|
||||
uninstall_action.setEnabled(defn.can_uninstall and self._status.installed and not self._busy)
|
||||
|
||||
chosen = menu.exec(self._btn_more.mapToGlobal(self._btn_more.rect().bottomLeft()))
|
||||
if chosen == downgrade_action and downgrade_action.isEnabled():
|
||||
self.action_requested.emit(self._tool_id, "downgrade")
|
||||
elif chosen == uninstall_action and uninstall_action.isEnabled():
|
||||
QTimer.singleShot(0, lambda: self._prompt_uninstall(defn.display_name))
|
||||
Reference in New Issue
Block a user