Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support

This commit is contained in:
Omni
2026-06-21 21:47:48 +01:00
parent 33b3fbaed2
commit 7fff107389
483 changed files with 11150 additions and 6050 deletions
+203 -27
View File
@@ -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
+22 -8
View File
@@ -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)
+45 -24
View File
@@ -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()
+469
View File
@@ -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))