Release v0.7.1 - Remote Manifest System, Stability Fixes

This commit is contained in:
Omni
2026-07-02 21:04:19 +01:00
parent 6def3b480a
commit e5d329a2dc
96 changed files with 1950 additions and 1382 deletions
@@ -38,7 +38,7 @@ class AdditionalMenuHandler:
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Setup Mod Organizer 2")
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application")
print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton not needed for standard modlist installs{COLOR_RESET}")
print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton - not needed for standard modlist installs{COLOR_RESET}")
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Create Diagnostic Bundle")
print(f" {COLOR_ACTION}→ Package logs and system info for support{COLOR_RESET}")
print(f"{COLOR_SELECTION}6.{COLOR_RESET} Nexus Mods Authorization")
@@ -339,8 +339,9 @@ class ManualDownloadDialog(QDialog):
colour = _STATUS_COLOURS.get(item.status, '#808080')
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
status_cell.setForeground(QColor(colour))
if item.error_message:
status_cell.setToolTip(item.error_message)
tooltip_parts = [p for p in (item.download_reason, item.error_message) if p]
if tooltip_parts:
status_cell.setToolTip("\n".join(tooltip_parts))
self._table.setItem(row, _COL_STATUS, status_cell)
def _update_row(self, row: int, item: DownloadItem) -> None:
@@ -349,7 +350,8 @@ class ManualDownloadDialog(QDialog):
if status_cell:
status_cell.setText(_STATUS_LABELS.get(item.status, item.status))
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
status_cell.setToolTip(item.error_message or "")
tooltip_parts = [p for p in (item.download_reason, item.error_message) if p]
status_cell.setToolTip("\n".join(tooltip_parts))
def _rebuild_row_map(self) -> None:
self._row_map.clear()
@@ -0,0 +1,138 @@
"""Guided dialog for installing a Nexus-only tool via manual browser download."""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QLineEdit, QFileDialog, QFrame,
)
from jackify.frontends.gui.services.message_service import open_url
logger = logging.getLogger(__name__)
class NexusManualInstallDialog(QDialog):
"""
Guides the user through manually downloading a Nexus-only tool and handing
the archive to Jackify for extraction and installation.
"""
def __init__(self, tool_id: str, display_name: str, nexus_url: str, parent=None):
super().__init__(parent)
self._tool_id = tool_id
self._nexus_url = nexus_url
self._archive_path: Optional[Path] = None
self.setWindowTitle(f"Install {display_name}")
self.setModal(True)
self.setMinimumWidth(480)
self.setStyleSheet("QDialog { background: #181818; color: #fff; }")
self._build_ui(display_name)
self.adjustSize()
def _build_ui(self, display_name: str) -> None:
main_layout = QVBoxLayout(self)
main_layout.setSpacing(0)
main_layout.setContentsMargins(20, 20, 20, 20)
card = QFrame(self)
card.setObjectName("dialogCard")
card.setFrameShape(QFrame.StyledPanel)
card.setFrameShadow(QFrame.Raised)
card.setStyleSheet(
"QFrame#dialogCard { "
" background: #2d2d2d; "
" border-radius: 12px; "
" border: 1px solid #555; "
"}"
)
card_layout = QVBoxLayout(card)
card_layout.setSpacing(16)
card_layout.setContentsMargins(28, 28, 28, 28)
title_label = QLabel(f"Manual download required: {display_name}")
title_label.setStyleSheet("color: #3fd0ea; font-size: 14px; font-weight: 600;")
title_label.setWordWrap(True)
card_layout.addWidget(title_label)
body_label = QLabel(
f"{display_name} is only available on Nexus Mods. As you do not have Nexus "
"Premium, please perform the following steps manually:\n\n"
f"1. Click 'Open Nexus Page' below and click Manual Download on the Nexus page "
f"to download {display_name}\n"
"2. Once the download is complete, click 'Browse...' below and select the "
"downloaded archive\n"
"3. Click Install to complete the installation."
)
body_label.setWordWrap(True)
card_layout.addWidget(body_label)
nexus_btn = QPushButton("Open Nexus Page")
nexus_btn.clicked.connect(self._open_nexus)
card_layout.addWidget(nexus_btn)
file_row = QHBoxLayout()
self._file_edit = QLineEdit()
self._file_edit.setPlaceholderText("No file selected...")
self._file_edit.setReadOnly(True)
self._file_edit.setStyleSheet(
"QLineEdit { "
" background: #1a1a1a; "
" color: #fff; "
" border: 1px solid #555; "
" border-radius: 4px; "
" padding: 8px; "
"}"
)
file_row.addWidget(self._file_edit)
browse_btn = QPushButton("Browse...")
browse_btn.setMinimumWidth(90)
browse_btn.clicked.connect(self._browse)
file_row.addWidget(browse_btn)
card_layout.addLayout(file_row)
btn_row = QHBoxLayout()
btn_row.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setMinimumWidth(100)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(cancel_btn)
self._install_btn = QPushButton("Install")
self._install_btn.setDefault(True)
self._install_btn.setMinimumWidth(100)
self._install_btn.setEnabled(False)
self._install_btn.clicked.connect(self.accept)
btn_row.addWidget(self._install_btn)
card_layout.addLayout(btn_row)
main_layout.addWidget(card)
def _open_nexus(self) -> None:
if self._nexus_url:
open_url(self._nexus_url)
def _browse(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
"Select downloaded archive",
str(Path.home() / "Downloads"),
"Archives (*.zip *.tar.gz *.tar.xz *.7z);;All files (*)",
)
if path:
self._archive_path = Path(path)
self._file_edit.setText(path)
self._install_btn.setEnabled(True)
@property
def selected_archive(self) -> Optional[Path]:
return self._archive_path
@property
def tool_id(self) -> str:
return self._tool_id
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
from jackify.backend.services.nxm_url import NxmUrl
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
import jackify.backend.services.nxm_session as nxm_session
logger = logging.getLogger(__name__)
@@ -117,7 +118,7 @@ class _DownloadThread(QThread):
self.progress.emit(downloaded, total)
class NxmDownloadDialog(QDialog):
class NxmDownloadDialog(ThreadLifecycleMixin, QDialog):
"""Modlist picker and download runner for incoming nxm:// links.
When auto_start_modlist is provided the picker is hidden and the download
@@ -72,9 +72,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
main_layout.addLayout(btn_layout)
except Exception as e:
print(f"[ERROR] Exception in SettingsDialog.__init__: {e}")
import traceback
traceback.print_exc()
logger.error(f"Exception in SettingsDialog.__init__: {e}", exc_info=True)
def _toggle_api_key_visibility(self, checked):
eye_icon = QIcon.fromTheme("view-visible")
@@ -402,7 +400,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
screen.refresh_paths()
except Exception as e:
print(f"Warning: Could not refresh GUI paths: {e}")
logger.warning(f"Could not refresh GUI paths: {e}")
def _bold_label(self, text):
label = QLabel(text)
@@ -16,6 +16,8 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QPixmap, QIcon, QFont
from jackify.frontends.gui.services.message_service import open_url
logger = logging.getLogger(__name__)
@@ -38,6 +40,8 @@ class SuccessDialog(QDialog):
time_taken: str,
game_name: str = None,
verification_results=None,
disabled_problem_mods=None,
readme_url: str = None,
parent=None,
):
super().__init__(parent)
@@ -46,6 +50,8 @@ class SuccessDialog(QDialog):
self.time_taken = time_taken
self.game_name = game_name
self.verification_results = verification_results
self.disabled_problem_mods = disabled_problem_mods or []
self.readme_url = readme_url
self.setWindowTitle("Complete" if (verification_results and verification_results.failures) else "Success!")
self.setWindowModality(Qt.NonModal)
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
@@ -180,6 +186,25 @@ class SuccessDialog(QDialog):
if self.verification_results is not None:
self._add_verification_section(card_layout)
# Problem mods that were auto-disabled
if self.disabled_problem_mods:
self._add_problem_mods_section(card_layout)
# Readme link (install workflow only)
if self.readme_url:
readme_label = QLabel(
f'<a href="{self.readme_url}" style="color:#3fd0ea; text-decoration:none;">'
"Open modlist readme"
"</a>"
)
readme_label.setAlignment(Qt.AlignCenter)
readme_label.setStyleSheet(
"QLabel { color: #3fd0ea; font-size: 11px; margin-top: 4px; padding: 4px; background-color: transparent; }"
)
readme_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
readme_label.linkActivated.connect(open_url)
card_layout.addWidget(readme_label)
# Subtle Ko-Fi support link
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#3fd0ea; text-decoration:none;">Enjoying Jackify? Support development ♥</a>')
kofi_label.setAlignment(Qt.AlignCenter)
@@ -193,7 +218,7 @@ class SuccessDialog(QDialog):
"}"
)
kofi_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
kofi_label.setOpenExternalLinks(True)
kofi_label.linkActivated.connect(open_url)
card_layout.addWidget(kofi_label)
layout.addStretch()
@@ -390,6 +415,29 @@ class SuccessDialog(QDialog):
except Exception as exc:
logger.error("Could not open verification dialog: %s", exc)
def _add_problem_mods_section(self, card_layout):
"""Add an auto-disabled problem mods section to the card layout."""
from PySide6.QtWidgets import QFrame
sep = QFrame()
sep.setFrameShape(QFrame.HLine)
sep.setStyleSheet("color: #444;")
card_layout.addWidget(sep)
header = QLabel("Compatibility Notice")
header.setStyleSheet("font-size: 12px; font-weight: bold; color: #c8a050; margin-top: 4px;")
card_layout.addWidget(header)
msg_text = (
"Due to known compatibility issues with Proton, the following mods were "
"automatically disabled:\n\n"
+ "\n".join(f" - {name}" for name in self.disabled_problem_mods)
)
msg_label = QLabel(msg_text)
msg_label.setWordWrap(True)
msg_label.setStyleSheet("font-size: 11px; color: #bbb; margin-bottom: 4px;")
card_layout.addWidget(msg_label)
def _update_countdown(self):
if self._countdown > 0:
self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)")
+13 -2
View File
@@ -310,7 +310,12 @@ def main(initial_nxm_url: str = ""):
# Global cleanup function for signal handling
def emergency_cleanup():
logger.debug("Cleanup: terminating jackify-engine processes")
logger.debug("Cleanup: draining QThreads and terminating jackify-engine processes")
try:
from jackify.frontends.gui.mixins.thread_registry import drain_all_threads
drain_all_threads(timeout_ms=5000)
except Exception:
pass
try:
import subprocess
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
@@ -389,6 +394,7 @@ def main(initial_nxm_url: str = ""):
# Start background update check after window is shown
window._check_for_updates_on_startup()
window._check_tool_updates_on_startup()
window._prefetch_manifests_on_startup()
if initial_nxm_url:
from PySide6.QtCore import QTimer
@@ -397,7 +403,12 @@ def main(initial_nxm_url: str = ""):
# Ensure cleanup on exit
import atexit
atexit.register(emergency_cleanup)
try:
from jackify.frontends.gui.mixins.thread_registry import drain_all_threads
app.aboutToQuit.connect(lambda: drain_all_threads(timeout_ms=8000))
except Exception:
pass
return app.exec()
if __name__ == "__main__":
@@ -64,14 +64,14 @@ class MainWindowBackendMixin:
if status['target_achieved']:
logger.debug(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
else:
print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
logger.info(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
else:
status = resource_manager.get_limit_status()
print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
logger.warning(f"Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
instructions = resource_manager.get_manual_increase_instructions()
print(f"Manual increase instructions available for {instructions['distribution']}")
logger.debug(f"Manual increase instructions available for {instructions['distribution']}")
except Exception as e:
print(f"Warning: Error applying resource limits: {e}")
logger.warning(f"Error applying resource limits: {e}")
@@ -6,6 +6,7 @@ Settings, About, open URL, cleanup_processes, closeEvent.
import logging
import os
import subprocess
import warnings
logger = logging.getLogger(__name__)
@@ -25,15 +26,19 @@ class MainWindowDialogsMixin:
return None
# Disconnect all signals before stopping to prevent callbacks to a dying widget.
try:
thread.finished.disconnect()
except Exception:
pass
for _sig in ("update_available", "no_update", "check_failed", "cache_ready", "progress_update"):
# disconnect() with no receivers connected raises via Python's warnings module
# (not as a catchable exception), so it must be suppressed rather than try/excepted.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
getattr(thread, _sig).disconnect()
thread.finished.disconnect()
except Exception:
pass
for _sig in ("update_available", "no_update", "check_failed", "cache_ready", "progress_update"):
try:
getattr(thread, _sig).disconnect()
except Exception:
pass
try:
thread.requestInterruption()
@@ -78,9 +83,7 @@ class MainWindowDialogsMixin:
dlg.finished.connect(on_dialog_finished)
dlg.exec()
except Exception as e:
print(f"[ERROR] Exception in open_settings_dialog: {e}")
import traceback
traceback.print_exc()
logger.error(f"Exception in open_settings_dialog: {e}", exc_info=True)
self._settings_dialog = None
def open_about_dialog(self):
@@ -104,9 +107,7 @@ class MainWindowDialogsMixin:
dlg.finished.connect(on_dialog_finished)
dlg.exec()
except Exception as e:
print(f"[ERROR] Exception in open_about_dialog: {e}")
import traceback
traceback.print_exc()
logger.error(f"Exception in open_about_dialog: {e}", exc_info=True)
self._about_dialog = None
def _open_url(self, url: str):
@@ -186,15 +187,12 @@ class MainWindowDialogsMixin:
screen.cleanup_processes()
elif hasattr(screen, 'cleanup'):
screen.cleanup()
elif hasattr(screen, 'worker'):
worker = getattr(screen, 'worker', None)
setattr(screen, 'worker', self._stop_qthread(worker, f"{screen.__class__.__name__}.worker"))
try:
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
except Exception:
pass
except Exception as e:
print(f"Error during cleanup: {e}")
logger.error(f"Error during cleanup: {e}")
def closeEvent(self, event):
self._save_geometry_on_quit()
@@ -53,17 +53,17 @@ class MainWindowStartupMixin:
return
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
if not is_installed:
print(f"Protontricks not found: {details}")
logger.warning(f"Protontricks not found: {details}")
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
result = dialog.exec()
if result == QDialog.Rejected:
print("User chose to exit due to missing protontricks")
logger.info("User chose to exit due to missing protontricks")
sys.exit(1)
else:
logger.debug(f"Protontricks detected: {details}")
except Exception as e:
print(f"Error checking protontricks: {e}")
logger.error(f"Error checking protontricks: {e}")
def _check_tool_updates_on_startup(self):
class _ToolUpdateCheckThread(QThread):
@@ -106,6 +106,40 @@ class MainWindowStartupMixin:
self._tool_update_check_thread.updates_found.connect(on_result)
self._tool_update_check_thread.start()
def _prefetch_manifests_on_startup(self):
class _ManifestPrefetchThread(QThread):
def run(self):
try:
from jackify.backend.services.tool_registry import (
fetch_remote_manifest as fetch_tools,
apply_remote_manifest as apply_tools,
)
tools = fetch_tools()
if tools:
apply_tools(tools)
logger.info("Tools manifest refreshed at startup (%d tools)", len(tools))
else:
logger.info("Tools manifest prefetch returned no data (bundled manifest in use)")
except Exception as e:
logger.info("Tools manifest prefetch failed: %s", e)
try:
from jackify.backend.services.problem_mods_service import (
fetch_remote_manifest as fetch_problems,
apply_remote_manifest as apply_problems,
)
problems = fetch_problems()
if problems:
apply_problems(problems)
logger.info("Problem mods manifest refreshed at startup")
else:
logger.info("Problem mods manifest prefetch returned no data (bundled manifest in use)")
except Exception as e:
logger.info("Problem mods manifest prefetch failed: %s", e)
self._manifest_prefetch_thread = _ManifestPrefetchThread()
self._manifest_prefetch_thread.start()
def _check_for_updates_on_startup(self):
try:
logger.debug("Checking for updates on startup...")
+19 -6
View File
@@ -72,30 +72,43 @@ class MainWindowUIMixin:
bottom_bar_style += " border: 2px solid lime;"
bottom_bar.setStyleSheet(bottom_bar_style)
# Three-zone layout (left / center / right) with equal stretch factors on the
# outer zones, so the center zone (Ko-fi) stays visually centered on the bar
# regardless of how wide the version label or Settings/About block are.
left_zone = QWidget()
left_zone_layout = QHBoxLayout(left_zone)
left_zone_layout.setContentsMargins(0, 0, 0, 0)
version_label = QLabel(f"Jackify v{__version__}")
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft)
bottom_bar_layout.addStretch(1)
left_zone_layout.addWidget(version_label, alignment=Qt.AlignLeft)
left_zone_layout.addStretch(1)
bottom_bar_layout.addWidget(left_zone, 1)
kofi_link = QLabel('<a href="#" style="color:#3fd0ea; text-decoration:none;">Support on Ko-fi</a>')
kofi_link.setStyleSheet("color: #3fd0ea; font-size: 13px;")
kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
kofi_link.setOpenExternalLinks(False)
kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1"))
kofi_link.setToolTip("Support Jackify development")
bottom_bar_layout.addWidget(kofi_link)
bottom_bar_layout.addStretch(1)
bottom_bar_layout.addWidget(kofi_link, 0, alignment=Qt.AlignCenter)
right_zone = QWidget()
right_zone_layout = QHBoxLayout(right_zone)
right_zone_layout.setContentsMargins(0, 0, 0, 0)
right_zone_layout.addStretch(1)
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
settings_btn.setOpenExternalLinks(False)
settings_btn.linkActivated.connect(self.open_settings_dialog)
bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
right_zone_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
about_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">About</a>')
about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
about_btn.setOpenExternalLinks(False)
about_btn.linkActivated.connect(self.open_about_dialog)
bottom_bar_layout.addWidget(about_btn, alignment=Qt.AlignRight)
right_zone_layout.addWidget(about_btn, alignment=Qt.AlignRight)
bottom_bar_layout.addWidget(right_zone, 1)
central_widget = QWidget()
main_layout = QVBoxLayout()
@@ -22,13 +22,9 @@ import logging
import warnings
from typing import List, Optional
logger = logging.getLogger(__name__)
from jackify.frontends.gui.mixins.thread_registry import register_managed_thread
# Module-level registry keeps references to parked threads alive independent
# of screen widget lifetime. Screens are destroyed on navigation; without this,
# _parked_threads on self evaporates and the GC destroys still-running threads,
# triggering Qt's "QThread: Destroyed while thread is still running" abort.
_PARKED_THREAD_REGISTRY: set = set()
logger = logging.getLogger(__name__)
class ThreadLifecycleMixin:
@@ -38,8 +34,8 @@ class ThreadLifecycleMixin:
"""Disconnect a thread from this screen and let it finish on its own.
Disconnects the named signals so no callbacks fire on this (potentially
dying) widget. Keeps a reference in _parked_threads so the thread is
not garbage-collected before it finishes.
dying) widget. Keeps a reference alive via the global registry until the
thread finishes.
Returns None so callers can do: self.thread = self._park_thread(self.thread, [...])
"""
@@ -54,13 +50,9 @@ class ThreadLifecycleMixin:
except Exception:
pass
# Register in the module-level set so the reference survives screen destruction.
# Remove from registry when the thread finishes so it can be GC'd cleanly.
_PARKED_THREAD_REGISTRY.add(thread)
try:
thread.finished.connect(lambda t=thread: _PARKED_THREAD_REGISTRY.discard(t))
except Exception:
pass
# Hand the thread to the global registry so it survives screen destruction
# and is drained cleanly on app exit.
register_managed_thread(thread)
return None
def hideEvent(self, event):
@@ -71,6 +63,14 @@ class ThreadLifecycleMixin:
pass
self._park_all_threads()
def closeEvent(self, event):
"""Park all running threads when the widget is closed."""
self._park_all_threads()
try:
super().closeEvent(event)
except Exception:
pass
def _kill_prefix_wine_processes(self, appid: str = '') -> None:
"""Kill wine/winetricks subprocesses on user-initiated cancel.
@@ -105,19 +105,11 @@ class ThreadLifecycleMixin:
"""Park every running QThread attribute found on this instance.
Inspects instance variables, disconnects common signal names from any
running QThread, and parks them. Used in cleanup_processes() / closeEvent().
running QThread, and registers them globally. Used in cleanup_processes()
/ closeEvent() / hideEvent().
"""
from PySide6.QtCore import QThread
_common_signals = (
"finished_signal",
"progress_update",
"workflow_complete",
"configuration_complete",
"error_occurred",
"status_update",
"finished",
)
from jackify.frontends.gui.mixins.thread_registry import _COMMON_SIGNAL_NAMES
for attr_name, value in list(vars(self).items()):
try:
@@ -125,7 +117,7 @@ class ThreadLifecycleMixin:
continue
if not value.isRunning():
continue
signal_names = [s for s in _common_signals if hasattr(value, s)]
signal_names = [s for s in _COMMON_SIGNAL_NAMES if hasattr(value, s)]
setattr(self, attr_name, self._park_thread(value, signal_names))
except Exception:
pass
@@ -0,0 +1,121 @@
"""
Application-wide QThread registry.
All managed threads are registered here. On app exit `drain_all_threads` disconnects
signals, requests cancellation, and waits for each thread so no QThread outlives its
Python wrapper or fires signals into destroyed widgets.
Usage in threads that are not owned by a ThreadLifecycleMixin widget (e.g. orphaned
workers) call `register_managed_thread` directly. ThreadLifecycleMixin calls it
automatically inside `_park_thread`.
"""
import logging
import warnings
logger = logging.getLogger(__name__)
# Central set of live managed threads. Python objects are kept alive here until their
# QThread.finished signal fires, preventing GC from destroying running threads.
_MANAGED_THREADS: set = set()
# Common signal names to disconnect during drain / park.
_COMMON_SIGNAL_NAMES = (
"finished",
"finished_signal",
"progress_update",
"workflow_complete",
"configuration_complete",
"error_occurred",
"status_update",
"output_received",
"progress_received",
"installation_finished",
"cache_ready",
"update_available",
"no_update",
"check_failed",
"completed",
"done",
"name_ready",
"progress",
)
def register_managed_thread(thread) -> None:
"""Add a QThread to the global registry and auto-remove it when it finishes.
Safe to call multiple times on the same thread.
"""
if thread is None:
return
_MANAGED_THREADS.add(thread)
try:
thread.finished.connect(lambda t=thread: _MANAGED_THREADS.discard(t))
except Exception:
pass
def drain_all_threads(timeout_ms: int = 8000) -> None:
"""Disconnect signals, request cancellation, and wait for every registered thread.
Called on `QApplication.aboutToQuit` and from the emergency cleanup handler.
Does not call terminate() - threads are given `timeout_ms` to finish gracefully.
If a thread does not exit in time, a warning is logged and we move on.
"""
snapshot = list(_MANAGED_THREADS)
if not snapshot:
return
logger.debug("Draining %d managed thread(s)", len(snapshot))
for thread in snapshot:
try:
if not thread.isRunning():
_MANAGED_THREADS.discard(thread)
continue
except RuntimeError:
_MANAGED_THREADS.discard(thread)
continue
# Disconnect all known signals so no callbacks fire into destroyed widgets.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
for name in _COMMON_SIGNAL_NAMES:
try:
getattr(thread, name).disconnect()
except Exception:
pass
# Signal cancellation where supported.
if hasattr(thread, "cancel"):
try:
thread.cancel()
except Exception:
pass
try:
thread.requestInterruption()
except Exception:
pass
try:
thread.quit()
except Exception:
pass
try:
if not thread.wait(timeout_ms):
logger.warning(
"Thread %s did not stop within %dms during drain",
thread.__class__.__name__,
timeout_ms,
)
except Exception:
pass
try:
thread.deleteLater()
except Exception:
pass
_MANAGED_THREADS.discard(thread)
logger.debug("Thread drain complete")
@@ -17,11 +17,12 @@ from PySide6.QtGui import QFont
from jackify.backend.models.configuration import SystemInfo
from ..shared_theme import JACKIFY_COLOR_BLUE
from ..utils import set_responsive_minimum
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__)
class AdditionalTasksScreen(QWidget):
class AdditionalTasksScreen(ThreadLifecycleMixin, QWidget):
"""Additional Tasks screen for automation and standalone tools."""
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
@@ -1,4 +1,5 @@
# Copy of ConfigureNewModlistScreen, adapted for existing modlists
import warnings
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *
@@ -58,7 +59,6 @@ class ConfigureExistingModlistScreen(
super().hideEvent(event)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
if getattr(self, '_vnv_controller', None) is not None:
try:
self._vnv_controller.cleanup()
@@ -67,7 +67,6 @@ class ConfigureExistingModlistScreen(
pass
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
self._park_all_threads()
def cancel_and_cleanup(self):
"""Handle Cancel button - clean up processes and go back"""
@@ -91,7 +90,7 @@ class ConfigureExistingModlistScreen(
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
except Exception as e:
print(f"Warning: Failed to set initial collapsed state: {e}")
logger.warning(f"Failed to set initial collapsed state: {e}")
# Shortcut loading is handled by reset_screen_to_defaults() → refresh_modlist_list()
# which fires via _debug_screen_change on every navigation to this screen.
@@ -183,11 +182,13 @@ class ConfigureExistingModlistScreen(
if not hasattr(self, 'config_thread') or self.config_thread is None:
return
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
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
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()
@@ -1,6 +1,7 @@
"""Shortcut loading for ConfigureExistingModlistScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal, QObject
import logging
import warnings
logger = logging.getLogger(__name__)
class ConfigureExistingModlistShortcutsMixin:
@@ -75,14 +76,16 @@ class ConfigureExistingModlistShortcutsMixin:
if hasattr(self, '_park_thread'):
self._park_thread(self._shortcut_loader, ["finished_signal", "error_signal"])
else:
try:
self._shortcut_loader.finished_signal.disconnect()
except Exception:
pass
try:
self._shortcut_loader.error_signal.disconnect()
except Exception:
pass
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
self._shortcut_loader.finished_signal.disconnect()
except Exception:
pass
try:
self._shortcut_loader.error_signal.disconnect()
except Exception:
pass
if not hasattr(self, '_old_loaders'):
self._old_loaders = []
self._old_loaders.append(self._shortcut_loader)
@@ -101,14 +104,13 @@ class ConfigureExistingModlistShortcutsMixin:
def _on_shortcuts_loaded(self, shortcuts):
"""Update UI when shortcuts are loaded"""
self.mo2_shortcuts = shortcuts
# Update the dropdown
if hasattr(self, 'shortcut_combo'):
self.shortcut_combo.clear()
self.shortcut_combo.setEnabled(True)
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map.clear()
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
self.shortcut_combo.addItem(display)
@@ -15,13 +15,33 @@ class ConfigureExistingModlistWorkflowMixin:
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
"""Detect special game type using the canonical ModlistHandler detection."""
"""Detect game type for the verifier from ModOrganizer.ini."""
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
return ModlistHandler().detect_special_game_type(install_dir) or 'skyrim'
special = ModlistHandler().detect_special_game_type(install_dir)
if special:
return special
except Exception as e:
logger.warning("Game type detection failed, defaulting to skyrim: %s", e)
return 'skyrim'
logger.warning("Special game type detection failed: %s", e)
# detect_special_game_type only covers non-default games; read gameName= directly
try:
from pathlib import Path
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
if mo2_ini.exists():
for raw_line in mo2_ini.read_text(errors='ignore').splitlines():
line = raw_line.strip().lower()
if line.startswith("gamename="):
val = line[len("gamename="):]
if "fallout 4" in val:
return "fallout4"
if "skyrim" in val:
return "skyrim"
except Exception as e:
logger.warning("ModOrganizer.ini gameName read failed: %s", e)
logger.warning("Could not determine game type for %s, verifier will run generic checks only", install_dir)
return 'unknown'
def validate_and_start_configure(self):
# Reload config to pick up any settings changes made in Settings dialog
@@ -2,6 +2,7 @@
ConfigureNewModlistScreen for Jackify GUI
"""
import logging
import warnings
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
from PySide6.QtGui import QPixmap, QTextCursor
@@ -126,12 +127,14 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
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
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass
if self.config_thread.isRunning():
self.config_thread.quit()
@@ -93,7 +93,6 @@ class ConfigureNewModlistDialogsMixin:
super().hideEvent(event)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
if getattr(self, '_vnv_controller', None) is not None:
try:
self._vnv_controller.cleanup()
@@ -103,7 +102,6 @@ class ConfigureNewModlistDialogsMixin:
self._stop_focus_reclaim()
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
self._park_all_threads()
def show_shortcut_conflict_dialog(self, conflicts):
"""Show dialog to reuse an existing shortcut or choose a new name."""
@@ -601,9 +601,9 @@ class ConfigureNewModlistUISetupMixin:
return True
except Exception as e:
print(f"Error checking protontricks: {e}")
logger.error(f"Error checking protontricks: {e}")
from jackify.frontends.gui.services.message_service import MessageService
MessageService.warning(self, "Protontricks Check Failed",
MessageService.warning(self, "Protontricks Check Failed",
f"Unable to verify protontricks installation: {e}\n\n"
"Continuing anyway, but some features may not work correctly.")
return True # Continue anyway
@@ -4,6 +4,7 @@ from PySide6.QtCore import QThread, Signal
import os
import time
import logging
import warnings
from jackify.shared.resolution_utils import get_resolution_fallback
from jackify.shared.errors import configuration_failed
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
@@ -262,12 +263,14 @@ class ConfigureNewModlistWorkflowMixin:
"""Safely release the automated prefix thread after it has finished."""
if not hasattr(self, 'automated_prefix_thread') or self.automated_prefix_thread is None:
return
try:
self.automated_prefix_thread.progress_update.disconnect()
self.automated_prefix_thread.workflow_complete.disconnect()
self.automated_prefix_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
self.automated_prefix_thread.progress_update.disconnect()
self.automated_prefix_thread.workflow_complete.disconnect()
self.automated_prefix_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass
if self.automated_prefix_thread.isRunning():
self.automated_prefix_thread.quit()
self.automated_prefix_thread.wait(5000)
@@ -438,5 +438,3 @@ class ConfigureToolConfigScreen(ThreadLifecycleMixin, QWidget):
except Exception as e:
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
def cleanup_processes(self):
self._park_all_threads()
@@ -30,6 +30,7 @@ from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
from ..widgets.progress_indicator import OverallProgressIndicator
from ..widgets.file_progress_list import FileProgressList
from .screen_back_mixin import ScreenBackMixin
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__)
@@ -74,7 +75,7 @@ class MO2SetupWorker(QThread):
self.setup_complete.emit(False, None, str(e))
class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
class InstallMO2Screen(ThreadLifecycleMixin, ScreenBackMixin, FocusReclaimMixin, QWidget):
"""Standalone MO2 setup screen"""
resize_request = Signal(str)
@@ -498,23 +499,12 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
self.go_back()
def cleanup_processes(self):
"""Stop active MO2 worker and CPU tracking before screen/app shutdown."""
self._stop_focus_reclaim()
try:
self.file_progress_list.stop_cpu_tracking()
except Exception:
pass
if self.worker is not None:
try:
if self.worker.isRunning():
self.worker.requestInterruption()
self.worker.wait(10000)
self.worker.deleteLater()
except Exception:
pass
self.worker = None
def reset_screen_to_defaults(self):
self.file_progress_list.clear()
self.console.clear()
@@ -198,8 +198,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height)
except Exception as e:
if self.debug:
print(f"DEBUG: Error calculating upper section height: {e}")
pass
logger.debug(f"Error calculating upper section height: {e}")
# Calculate heights immediately after forcing layout update
# Prevents visible layout shift
@@ -356,7 +355,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
return True
except Exception as e:
print(f"Error checking protontricks: {e}")
logger.error(f"Error checking protontricks: {e}")
MessageService.warning(self, "Protontricks Check Failed",
f"Unable to verify protontricks installation: {e}\n\n"
"Continuing anyway, but some features may not work correctly.")
@@ -424,7 +423,6 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
super().hideEvent(event)
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)
@@ -440,74 +438,23 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
self._stop_focus_reclaim()
# Disconnect all thread signals before any stopping - prevents callbacks to
# a dying widget if threads emit between now and actual termination.
self._park_all_threads()
def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000):
thread = getattr(self, attr_name, None)
if thread is None:
return
# install_thread needs cancel() to kill the child subprocess, not just
# signal disconnection. The extended wait gives the engine time to flush.
thread = getattr(self, 'install_thread', None)
if thread is not None:
try:
running = thread.isRunning()
except RuntimeError:
setattr(self, attr_name, None)
return
if not running:
setattr(self, attr_name, None)
return
logger.debug(f"Stopping {attr_name}")
if cancel_method and hasattr(thread, cancel_method):
running = False
if running:
try:
getattr(thread, cancel_method)()
thread.cancel()
except Exception:
pass
else:
try:
thread.requestInterruption()
except Exception:
pass
try:
thread.quit()
except Exception:
pass
try:
if thread.wait(cooperative_ms):
setattr(self, attr_name, None)
return
except Exception:
pass
logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, waiting for forced shutdown window")
try:
if cancel_method and hasattr(thread, cancel_method):
getattr(thread, cancel_method)()
except Exception:
pass
try:
if not thread.wait(force_ms):
logger.error(f"ERROR: {attr_name} still running after forced shutdown window")
except Exception:
pass
setattr(self, attr_name, None)
# Always stop installer thread first; it needs cancel() not terminate().
_stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000)
# Stop any remaining QThread instances on this object, regardless of attribute name.
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
if attr_name == 'install_thread':
continue
try:
if isinstance(value, QThread):
_stop_thread(attr_name)
except Exception:
pass
if not thread.wait(15000):
logger.warning("install_thread did not stop in 15s")
thread.wait(10000)
self.install_thread = None
def cancel_installation(self):
"""Cancel the currently running installation"""
@@ -1,4 +1,5 @@
"""Configuration phase workflow for InstallModlistScreen (Mixin)."""
import warnings
from PySide6.QtWidgets import QMessageBox, QProgressDialog
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
@@ -178,6 +179,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
'time_taken': time_str,
'game_name': game_name,
'enb_detected': enb_detected,
'readme_url': getattr(self, '_readme_url', None),
},
)
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
@@ -233,12 +235,14 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
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
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass
if self.config_thread.isRunning():
self.config_thread.quit()
@@ -525,12 +529,14 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
# Clean up old thread if exists and wait for it to finish
if hasattr(self, 'config_thread') and self.config_thread is not None:
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass # Ignore errors if already disconnected
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass # Ignore errors if already disconnected
if self.config_thread.isRunning():
self.config_thread.quit()
self.config_thread.wait(5000) # Wait up to 5 seconds
@@ -618,7 +624,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
except Exception as e:
error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}"
self.progress_update.emit(f"DEBUG: {error_details}")
self.progress_update.emit(f"ERROR: {error_details}")
self.error_occurred.emit(str(e))
return ConfigThread(context, is_steamdeck, detect_game_type_func, parent=self)
@@ -307,7 +307,7 @@ class InstallerThread(QThread):
"""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.
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.
@@ -360,7 +360,7 @@ class InstallerThread(QThread):
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
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.
@@ -391,7 +391,7 @@ class InstallerThread(QThread):
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
# 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
@@ -3,6 +3,7 @@ from pathlib import Path
from PySide6.QtWidgets import QMessageBox, QApplication, QDialog
from jackify.frontends.gui.utils import browse_directory, browse_file
from PySide6.QtCore import QTimer, Qt
from PySide6.QtGui import QFontMetrics
import logging
import os
import re
@@ -160,7 +161,11 @@ class ModlistSelectionMixin:
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)
metrics = QFontMetrics(self.modlist_btn.font())
available_width = self.modlist_btn.width() - 24 # padding allowance
elided_title = metrics.elidedText(metadata.title, Qt.ElideRight, available_width)
self.modlist_btn.setText(elided_title)
self.modlist_btn.setToolTip(metadata.title)
self.selected_modlist_info = {
'machine_url': metadata.namespacedName,
'title': metadata.title,
@@ -162,6 +162,7 @@ class InstallModlistUISetupMixin:
# --- Modlist Selection ---
self.modlist_btn = QPushButton("Select Modlist")
self.modlist_btn.setMinimumWidth(300)
self.modlist_btn.setMaximumWidth(300)
self.modlist_btn.clicked.connect(self.open_modlist_dialog)
self.modlist_btn.setEnabled(False)
online_layout.addWidget(QLabel("Game Type:"))
@@ -6,6 +6,12 @@ import shutil
import time
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from jackify.backend.services.update_detection import (
evaluate_update_candidate as _svc_evaluate,
find_existing_shortcut_appid as _svc_find_appid,
normalize_version_token,
normalize_modlist_name,
)
from .install_modlist_output_mixin import InstallModlistOutputMixin
from .install_modlist_workflow_execution import InstallWorkflowExecutionMixin
@@ -15,27 +21,16 @@ logger = logging.getLogger(__name__)
class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMixin):
"""Mixin providing installation workflow methods for InstallModlistScreen."""
@staticmethod
def _normalize_version_token(value: str | None) -> str | None:
"""Return a normalized version token for lightweight equality checks."""
if value is None:
return None
token = str(value).strip()
if not token:
return None
token = token.lstrip("vV")
return token.lower()
@staticmethod
def _normalize_modlist_name(value: str | None) -> str:
return " ".join((value or "").strip().lower().split())
# normalize_version_token and normalize_modlist_name are imported from update_detection service
_normalize_version_token = staticmethod(normalize_version_token)
_normalize_modlist_name = staticmethod(normalize_modlist_name)
def _get_requested_modlist_version(self, install_mode: str) -> str | None:
"""Return selected modlist version from gallery metadata when available."""
if install_mode != "online":
return None
info = getattr(self, "selected_modlist_info", None) or {}
return self._normalize_version_token(info.get("version"))
return normalize_version_token(info.get("version"))
def _evaluate_update_candidate(
self,
@@ -44,52 +39,8 @@ class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMi
install_mode: str,
existing_appid: str | None,
) -> tuple[bool, dict]:
"""
Decide whether update-mode prompt should be shown.
Policy:
- Require existing shortcut AppID and jackify_meta.json.
- Require modlist identity match (requested name == installed meta name).
- Version relation is informational:
- `different` when both requested/installed versions are available and differ.
- `same` when both are available and equal.
- `unknown` when either side is missing.
"""
from jackify.backend.utils.modlist_meta import read_modlist_meta
result = {
"eligible": False,
"reason": "unknown",
"requested_version": None,
"installed_version": None,
"version_relation": "unknown",
"installed_name": None,
}
if not existing_appid:
result["reason"] = "missing_shortcut_appid"
return False, result
meta = read_modlist_meta(install_dir)
if not meta:
result["reason"] = "missing_meta"
return False, result
installed_name = (meta.get("modlist_name") or "").strip()
result["installed_name"] = installed_name
if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
result["reason"] = "modlist_name_mismatch"
return False, result
requested_version = self._get_requested_modlist_version(install_mode)
installed_version = self._normalize_version_token(meta.get("modlist_version"))
result["requested_version"] = requested_version
result["installed_version"] = installed_version
if requested_version and installed_version:
result["version_relation"] = "same" if requested_version == installed_version else "different"
result["eligible"] = True
result["reason"] = "eligible"
return True, result
return _svc_evaluate(modlist_name, install_dir, existing_appid, requested_version)
def _resolve_modorganizer_ini_path(self, install_dir: str) -> str | None:
"""Return ModOrganizer.ini path for standard/special layouts."""
@@ -276,38 +227,7 @@ class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMi
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
"""Return existing Steam shortcut AppID for this install dir/name when present."""
try:
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
install_real = os.path.realpath(install_dir)
candidate_exes = [
os.path.join(install_real, "ModOrganizer.exe"),
os.path.join(install_real, "files", "ModOrganizer.exe"), # Somnium layout
]
for exe_path in candidate_exes:
if not os.path.exists(exe_path):
continue
appid = shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
if appid:
return appid
# Fallback: match by name + start dir from shortcuts.vdf even if exe moved
for shortcut in shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
if (
(shortcut.get("AppName", "").strip() == modlist_name.strip())
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
):
raw_appid = shortcut.get("appid")
if raw_appid is not None:
return str(int(raw_appid) & 0xFFFFFFFF)
except Exception as e:
logger.warning("Update detection: failed shortcut lookup: %s", e)
return None
return _svc_find_appid(modlist_name, install_dir)
def _prompt_update_or_new_install(
self,
@@ -8,6 +8,7 @@ import os
from .install_modlist_installer_thread import InstallerThread
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
from jackify.backend.models.game_types import GAME_DISPLAY_NAMES, GAME_NAME_TO_TYPE
from jackify.shared.errors import install_dir_create_failed
logger = logging.getLogger(__name__)
@@ -307,57 +308,20 @@ class InstallWorkflowExecutionMixin:
if result:
if isinstance(result, tuple):
game_type, raw_game_type = result
# Get display name for the game
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
if game_type == 'unknown' and raw_game_type:
game_name = raw_game_type
else:
game_name = display_names.get(game_type, game_type)
game_name = GAME_DISPLAY_NAMES.get(game_type, game_type)
else:
game_type = result
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
game_name = display_names.get(game_type, game_type)
game_name = GAME_DISPLAY_NAMES.get(game_type, game_type)
else:
# For online modlists, try to get game type from selected modlist
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
readme_url = self.selected_modlist_info.get('readme_url')
game_name = self.selected_modlist_info.get('game', '')
logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'")
# Map game name to game type
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion_remastered': 'oblivion_remastered',
'oblivion remastered': 'oblivion_remastered',
'enderal': 'enderal',
'enderal special edition': 'enderal',
'skyrim vr': 'skyrimvr',
'fallout 4 vr': 'fallout4vr',
'cyberpunk 2077': 'cp2077',
"baldur's gate 3": 'bg3',
}
game_type = game_mapping.get(game_name.lower())
game_type = GAME_NAME_TO_TYPE.get(game_name.lower())
logger.debug(f"Mapped game_name '{game_name}' to game_type: '{game_type}'")
if not game_type:
game_type = 'unknown'
@@ -504,7 +468,7 @@ class InstallWorkflowExecutionMixin:
readme_url = readme_url.replace("raw.githubusercontent.com", "github.com")
readme_url = readme_url.replace("/main/", "/blob/main/")
readme_url = readme_url.replace("/master/", "/blob/master/")
logger.info(f"Opening modlist readme: {readme_url}")
logger.info("Opening modlist readme: %s", readme_url)
_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)
@@ -512,6 +476,7 @@ class InstallWorkflowExecutionMixin:
"Modlist readme opened in your browser. "
"Check it for any manual post-install steps before launching the game."
)
self._readme_url = readme_url or None
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)
@@ -620,8 +585,9 @@ class InstallWorkflowExecutionMixin:
concurrent_limit = max(1, min(5, concurrent_limit))
self._safe_append_text(
f"\n[Manual Download Required] {count} file(s) need manual download.\n"
f"Opening download dialog - check your taskbar if it does not appear in front.\n"
f"\n[Manual Download Required] {count} file(s) need manual download "
f"(rate limit, access error, or non-premium).\n"
f"Opening download dialog - it will appear in front momentarily.\n"
)
logger.info(
f"[MDL-1006] Manual download protocol initialized | count={count} "
@@ -663,3 +629,5 @@ class InstallWorkflowExecutionMixin:
if not self._manual_dl_dialog.isVisible():
self._manual_dl_dialog.show()
self._manual_dl_dialog.raise_()
self._manual_dl_dialog.activateWindow()
+5 -9
View File
@@ -233,8 +233,8 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
return True
except Exception as e:
print(f"Error checking protontricks: {e}")
MessageService.warning(self, "Protontricks Check Failed",
logger.error(f"Error checking protontricks: {e}")
MessageService.warning(self, "Protontricks Check Failed",
f"Unable to verify protontricks installation: {e}\n\n"
"Continuing anyway, but some features may not work correctly.")
return True # Continue anyway
@@ -305,14 +305,10 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
dlg.exec()
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
# Disconnect all signals first - prevents callbacks to a dying widget.
self._park_all_threads()
# install_thread gets a cooperative cancel signal on top of the park.
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
if hasattr(self, 'install_thread') and self.install_thread:
try:
self.install_thread.cancel()
if self.install_thread.isRunning():
self.install_thread.cancel()
except Exception:
pass
@@ -3,6 +3,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QLineEd
from PySide6.QtCore import Qt, QTimer, QSize
from PySide6.QtGui import QFont
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from jackify.frontends.gui.services.message_service import open_url
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
@@ -110,7 +111,8 @@ class TTWUISetupMixin:
)
instruction_text.setWordWrap(True)
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
instruction_text.setOpenExternalLinks(True)
instruction_text.setTextInteractionFlags(Qt.TextBrowserInteraction)
instruction_text.linkActivated.connect(open_url)
user_config_vbox.addWidget(instruction_text)
# --- Compact Form Grid for inputs (align with other screens) ---
@@ -91,6 +91,52 @@ class InstallVerifierMixin:
except Exception as e:
logger.warning("JContainers fix check failed (non-fatal): %s", e)
def _apply_problem_mods_disable(
self, install_dir: str, game_type: str, success_params: dict, appid: str = ""
) -> None:
"""Disable known-problematic mods and apply prefix fixes across all profiles."""
try:
from jackify.backend.services.problem_mods_service import (
disable_problem_mods,
create_prefix_dirs,
get_enabled_mods,
)
install_path = Path(install_dir)
all_disabled: list = []
all_enabled_mods: set = set()
for modlist_txt in install_path.glob("profiles/*/modlist.txt"):
disabled = disable_problem_mods(modlist_txt, game_type)
for name in disabled:
if name not in all_disabled:
all_disabled.append(name)
all_enabled_mods |= get_enabled_mods(modlist_txt)
if all_disabled:
logger.info(
"Disabled %d problem mod(s) for %s (%s): %s",
len(all_disabled),
success_params.get("modlist_name", ""),
game_type,
", ".join(all_disabled),
)
success_params["disabled_problem_mods"] = all_disabled
resolved_appid = str(appid or self._get_appid_for_install_dir(install_dir) or "")
pfx = _resolve_pfx_for_appid(resolved_appid) if resolved_appid else None
if pfx and all_enabled_mods:
created = create_prefix_dirs(pfx, game_type, all_enabled_mods)
if created:
logger.info(
"Created %d prefix dir(s) for %s (%s): %s",
len(created),
success_params.get("modlist_name", ""),
game_type,
", ".join(created),
)
except Exception as e:
logger.warning("Problem mods fix check failed (non-fatal): %s", e)
def _run_verifier_then_show_success(
self,
install_dir: str,
@@ -105,6 +151,7 @@ class InstallVerifierMixin:
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
"""
self._maybe_apply_jcontainers_fix(install_dir, game_type)
self._apply_problem_mods_disable(install_dir, game_type, success_params, appid)
if hasattr(self, "progress_indicator"):
self.progress_indicator.set_status("Verifying installation...", 100)
if hasattr(self, "file_progress_list"):
@@ -170,6 +217,8 @@ class InstallVerifierMixin:
time_taken=params["time_taken"],
game_name=params.get("game_name"),
verification_results=verification_results,
disabled_problem_mods=params.get("disabled_problem_mods"),
readme_url=params.get("readme_url"),
parent=self,
)
dlg.show()
@@ -4,6 +4,7 @@ Enhanced Modlist Gallery Screen for Jackify GUI.
Provides visual browsing, filtering, and selection of modlists using
rich metadata from jackify-engine.
"""
import warnings
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QLineEdit, QComboBox, QCheckBox, QScrollArea, QGridLayout,
@@ -255,23 +256,27 @@ class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixi
if timer is not None:
timer.stop()
# Kill any in-progress engine subprocess so the loader thread exits
# naturally and releases the engine call lock.
if hasattr(self, 'gallery_service'):
self.gallery_service.cancel()
for attr in ('_loader_thread', '_validation_thread'):
thread = getattr(self, attr, None)
if thread is None:
continue
# Disconnect all signals before terminating - prevents callbacks into
# a partially-destroyed dialog
try:
thread.disconnect()
except Exception:
pass
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
thread.disconnect()
except Exception:
pass
if thread.isRunning():
# terminate() is required here: these threads run plain Python code
# with no Qt event loop, so quit() is a no-op and wait() alone
# would time out, leaving the thread running when the C++ QThread
# object is destroyed (which aborts the process).
thread.terminate()
thread.wait(3000)
# Give the thread time to exit naturally after cancel().
# Only fall back to terminate() if it is still stuck after that.
if not thread.wait(2000):
thread.terminate()
thread.wait(1000)
# Abort any pending image network requests
if hasattr(self, 'image_manager'):
@@ -28,6 +28,7 @@ from PySide6.QtGui import QFont, QPalette, QColor, QPixmap
from jackify.backend.models.configuration import SystemInfo
from ..shared_theme import JACKIFY_COLOR_BLUE
from ..utils import set_responsive_minimum
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
# Constants
DEBUG_BORDERS = False
@@ -35,7 +36,7 @@ DEBUG_BORDERS = False
logger = logging.getLogger(__name__)
class ModlistTasksScreen(QWidget):
class ModlistTasksScreen(ThreadLifecycleMixin, QWidget):
"""
Migrated Modlist Tasks screen that uses backend services directly.
@@ -233,5 +234,4 @@ class ModlistTasksScreen(QWidget):
pass
def cleanup(self):
"""Clean up resources when the screen is closed"""
pass
self._park_all_threads()
+45 -3
View File
@@ -8,6 +8,7 @@ the cards are rebuilt and version checks restart.
"""
import logging
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtCore import Qt, QThread, Signal
@@ -74,6 +75,22 @@ class _ToolActionThread(QThread):
self.finished_signal.emit(self._tool_id, ok, msg)
class _ArchiveInstallThread(QThread):
finished_signal = Signal(str, bool, str) # tool_id, success, message
def __init__(self, tool_id: str, archive_path: Path):
super().__init__()
self._tool_id = tool_id
self._archive_path = archive_path
def run(self):
try:
ok, msg = ToolRegistry().install_from_archive(self._tool_id, self._archive_path)
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]
@@ -258,7 +275,7 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
def _on_manifest_ready(self, definitions: List[ToolDefinition]):
current_ids = set(self._cards.keys())
new_ids = {d.tool_id for d in definitions}
new_ids = {d.tool_id for d in definitions if not d.hidden}
apply_remote_manifest(definitions)
if current_ids != new_ids:
if self._version_thread and self._version_thread.isRunning():
@@ -336,6 +353,13 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
def _on_action_finished(self, tool_id: str, success: bool, message: str):
self._action_thread = None
card = self._cards.get(tool_id)
if not success and message.startswith("NEXUS_MANUAL_REQUIRED:"):
if card:
card.set_busy(False)
self._start_nexus_manual_install(tool_id, message[len("NEXUS_MANUAL_REQUIRED:"):])
return
if success:
status = ToolRegistry().get_status(tool_id)
if status and status.installed and card:
@@ -351,6 +375,23 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
card.set_busy(False)
MessageService.warning(self, "Failed", message)
def _start_nexus_manual_install(self, tool_id: str, nexus_url: str) -> None:
from jackify.frontends.gui.dialogs.nexus_manual_install_dialog import NexusManualInstallDialog
defn = next((d for d in get_effective_definitions() if d.tool_id == tool_id), None)
display_name = defn.display_name if defn else tool_id
dlg = NexusManualInstallDialog(tool_id, display_name, nexus_url, parent=self)
if dlg.exec() != NexusManualInstallDialog.Accepted:
return
archive = dlg.selected_archive
if not archive:
return
card = self._cards.get(tool_id)
if card:
card.set_busy(True, "Installing...")
self._action_thread = _ArchiveInstallThread(tool_id, archive)
self._action_thread.finished_signal.connect(self._on_action_finished)
self._action_thread.start()
def _on_update_all(self):
updates = [tid for tid, card in self._cards.items()
if card._status.installed and card._status.update_available]
@@ -389,6 +430,9 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
status = ToolRegistry().get_status(tool_id)
if not status:
return
if not status.definition.github_repo:
MessageService.warning(self, "Change Version", "Version selection is only available for GitHub-hosted tools.")
return
if card:
card.set_busy(True, "Fetching releases...")
self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo)
@@ -465,5 +509,3 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup_processes(self):
self._park_all_threads()
+40 -11
View File
@@ -6,7 +6,6 @@ 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
@@ -16,7 +15,7 @@ from PySide6.QtWidgets import (
)
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.services.message_service import MessageService, open_url
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
logger = logging.getLogger(__name__)
@@ -28,6 +27,12 @@ _C_SET_ACTIVE = "#4a5568"
_C_BACK = "#4a5568"
_C_DISABLED = "#333"
_STYLE_BTN_INVISIBLE = (
"QPushButton { background: transparent; border: none; color: transparent; "
"font-size: 11px; font-weight: bold; padding: 4px 8px; min-width: 90px; }"
"QPushButton:hover { background: transparent; }"
)
_BADGE_NOT_INSTALLED = ("#555", "#ccc")
_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8")
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
@@ -78,7 +83,18 @@ class ToolCard(QFrame):
info_col = QVBoxLayout()
info_col.setSpacing(2)
self._name_label = QLabel(f"<b>{status.definition.display_name}</b>")
url = status.definition.upstream_url
if url:
name_html = (
f'<a href="{url}" style="color: #e0e0e0; text-decoration: none; font-weight: bold;">'
f'{status.definition.display_name}</a>'
)
else:
name_html = f"<b>{status.definition.display_name}</b>"
self._name_label = QLabel(name_html)
self._name_label.setTextFormat(Qt.RichText)
self._name_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
self._name_label.linkActivated.connect(self._open_url)
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)
@@ -118,7 +134,6 @@ class ToolCard(QFrame):
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("...")
@@ -150,7 +165,10 @@ class ToolCard(QFrame):
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_update.setStyleSheet(
_STYLE_BTN_INVISIBLE
)
self._btn_update.setEnabled(False)
self._btn_more.setEnabled(False)
return
@@ -170,9 +188,7 @@ class ToolCard(QFrame):
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}"
)
self._version_label.setText(f"Installed: {iv}\nLatest: {lv}")
if not installed:
self._btn_primary.setText("Install")
@@ -197,9 +213,14 @@ class ToolCard(QFrame):
else:
self._btn_primary.setVisible(False)
self._btn_update.setVisible(installed and update_avail and not self._busy)
if installed and update_avail:
if installed and update_avail and not self._busy:
self._btn_update.setStyleSheet(btn_style(_C_UPDATE))
self._btn_update.setEnabled(True)
else:
self._btn_update.setStyleSheet(
_STYLE_BTN_INVISIBLE
)
self._btn_update.setEnabled(False)
self._btn_more.setEnabled(not self._busy)
def set_latest_version(self, tag: str) -> bool:
@@ -289,6 +310,9 @@ class ToolCard(QFrame):
else:
self._launch()
def _open_url(self, url: str):
open_url(url)
def _launch(self):
binary = ToolRegistry().get_binary_path(self._tool_id)
if not binary:
@@ -313,13 +337,18 @@ class ToolCard(QFrame):
"QMenu::item:disabled { color: #555; }"
)
defn = self._status.definition
upstream_action = menu.addAction("Open Website")
upstream_action.setEnabled(bool(defn.upstream_url))
menu.addSeparator()
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():
if chosen == upstream_action and defn.upstream_url:
self._open_url(defn.upstream_url)
elif 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))
@@ -30,6 +30,7 @@ from ..utils import set_responsive_minimum, browse_directory
from ..widgets.file_progress_list import FileProgressList
from ..widgets.progress_indicator import OverallProgressIndicator
from .screen_back_mixin import ScreenBackMixin
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__)
@@ -89,7 +90,7 @@ class WabbajackInstallerWorker(QThread):
self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "")
class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
class WabbajackInstallerScreen(ThreadLifecycleMixin, ScreenBackMixin, FocusReclaimMixin, QWidget):
"""Wabbajack installer GUI screen following standard Jackify layout"""
resize_request = Signal(str)
@@ -389,14 +390,13 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
# Get shortcut name
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
# Confirm with user (standard dialog - no safety countdown needed for this operation)
confirm = MessageService.question(
self,
"Confirm Installation",
f"Install Wabbajack to:\n{self.install_folder}\n\n"
"This will download Wabbajack, add to Steam, install WebView2,\n"
"and configure the Wine prefix automatically.\n\n"
"Steam will be restarted during installation.\n\n"
"Warning: Steam will be restarted during installation - this will close any running game.\n\n"
"Continue?",
safety_level="medium",
)
@@ -620,15 +620,6 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
def cleanup_processes(self):
self._stop_focus_reclaim()
if self.worker is not None:
try:
if self.worker.isRunning():
self.worker.requestInterruption()
self.worker.wait(5000)
self.worker.deleteLater()
except Exception:
pass
self.worker = None
def showEvent(self, event):
"""Called when widget becomes visible"""
@@ -3,9 +3,31 @@ Non-Focus-Stealing Message Service for Jackify
Provides message boxes that don't steal focus from the current application
"""
import logging
import os
import random
import string
import subprocess
import warnings
from typing import Optional
logger = logging.getLogger(__name__)
def open_url(url: str) -> None:
"""Open a URL in the system browser, safe to call from within an AppImage."""
env = os.environ.copy()
if "APPIMAGE" in env or "APPDIR" in env:
for var in ("LD_LIBRARY_PATH", "PYTHONPATH", "PYTHONHOME", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH"):
env.pop(var, None)
try:
subprocess.Popen(
["xdg-open", url], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
except Exception as e:
logger.warning("Failed to open URL %s: %s", url, e)
from PySide6.QtWidgets import (
QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout,
QCheckBox, QTextEdit, QPushButton, QDialog, QDialogButtonBox, QSizePolicy,
@@ -61,14 +83,16 @@ class SafeMessageBox(NonFocusMessageBox):
self._setup_low_safety(danger_action, safe_action)
# --- Fix: For question dialogs, set proceed/cancel button return values, but do NOT call setStandardButtons ---
if is_question and hasattr(self, 'proceed_btn'):
self.proceed_btn.setText(danger_action)
self.proceed_btn.setProperty('role', QMessageBox.YesRole)
self.proceed_btn.clicked.disconnect()
self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes))
self.cancel_btn.setText(safe_action)
self.cancel_btn.setProperty('role', QMessageBox.NoRole)
self.cancel_btn.clicked.disconnect()
self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No))
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
self.proceed_btn.setText(danger_action)
self.proceed_btn.setProperty('role', QMessageBox.YesRole)
self.proceed_btn.clicked.disconnect()
self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes))
self.cancel_btn.setText(safe_action)
self.cancel_btn.setProperty('role', QMessageBox.NoRole)
self.cancel_btn.clicked.disconnect()
self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No))
def _setup_high_safety(self, danger_action: str, safe_action: str):
"""High safety: requires typing confirmation code"""
@@ -6,6 +6,7 @@ worker thread management, and completion callbacks.
"""
import logging
import warnings
from pathlib import Path
from typing import Callable, Optional
@@ -301,6 +302,8 @@ class VNVAutomationController(QObject):
dialog.load_items(manager.items)
dialog.finished.connect(lambda _result: self._cancel_manual_download_flow(on_complete, state))
dialog.show()
dialog.raise_()
dialog.activateWindow()
def _cancel_manual_download_flow(self, on_complete, state: dict) -> None:
if state["done"]:
@@ -342,10 +345,12 @@ class VNVAutomationController(QObject):
self._manual_dialog = None
self._manual_manager = None
if dialog is not None:
try:
dialog.finished.disconnect()
except Exception:
pass
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
dialog.finished.disconnect()
except Exception:
pass
try:
dialog.close()
except Exception:
@@ -7,9 +7,12 @@ R&D NOTE: This is experimental code for investigation purposes.
"""
from typing import Optional
import logging
import shiboken6
import time
logger = logging.getLogger(__name__)
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
QHBoxLayout, QSizePolicy
@@ -113,7 +116,7 @@ class _CpuWorker(QThread):
def _debug_log(message):
from jackify.backend.handlers.config_handler import ConfigHandler
if ConfigHandler().get('debug_mode', False):
print(message)
logger.debug(message)
class FileProgressList(QWidget):