mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-17 14:07:45 +02:00
Release v0.6.0
This commit is contained in:
@@ -3,9 +3,12 @@ Main window dialogs and cleanup mixin.
|
||||
Settings, About, open URL, cleanup_processes, closeEvent.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from jackify.frontends.gui.dialogs.settings_dialog import SettingsDialog
|
||||
|
||||
|
||||
@@ -21,6 +24,17 @@ class MainWindowDialogsMixin:
|
||||
except RuntimeError:
|
||||
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"):
|
||||
try:
|
||||
getattr(thread, _sig).disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
thread.requestInterruption()
|
||||
except Exception:
|
||||
@@ -37,14 +51,9 @@ class MainWindowDialogsMixin:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
thread.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if not thread.wait(10000):
|
||||
print(f"WARNING: {thread_name} still running during shutdown")
|
||||
logger.warning("%s still running during shutdown", thread_name)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -38,8 +38,8 @@ class MainWindowUIMixin:
|
||||
self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode)
|
||||
self.stacked_widget.addWidget(self.main_menu) # index 0
|
||||
|
||||
# Indexes 1-9: insert lightweight placeholders now; real screens on demand.
|
||||
for _ in range(9):
|
||||
# Indexes 1-11: insert lightweight placeholders now; real screens on demand.
|
||||
for _ in range(11):
|
||||
self.stacked_widget.addWidget(_LazyPlaceholder())
|
||||
|
||||
# Factory map: index -> callable that creates and caches the real screen.
|
||||
@@ -53,6 +53,8 @@ class MainWindowUIMixin:
|
||||
7: self._make_wabbajack_installer_screen,
|
||||
8: self._make_configure_existing_modlist_screen,
|
||||
9: self._make_install_mo2_screen,
|
||||
10: self._make_third_party_tools_screen,
|
||||
11: self._make_configure_tool_config_screen,
|
||||
}
|
||||
|
||||
self.stacked_widget.currentChanged.connect(self._lazy_init_screen)
|
||||
@@ -121,7 +123,7 @@ class MainWindowUIMixin:
|
||||
# Block signals for the entire swap including setCurrentWidget so that:
|
||||
# (a) Qt's auto-current-change on removeWidget doesn't cascade into the
|
||||
# other placeholders via a re-entrant _lazy_init_screen call, and
|
||||
# (b) setCurrentWidget does not fire a second currentChanged — the outer
|
||||
# (b) setCurrentWidget does not fire a second currentChanged - the outer
|
||||
# currentChanged (which triggered this lazy init) is still being
|
||||
# dispatched and will reach _debug_screen_change with the real screen
|
||||
# already in place, so reset_screen_to_defaults runs exactly once.
|
||||
@@ -226,6 +228,22 @@ class MainWindowUIMixin:
|
||||
pass
|
||||
return screen
|
||||
|
||||
def _make_third_party_tools_screen(self):
|
||||
from jackify.frontends.gui.screens.third_party_tools import ThirdPartyToolsScreen
|
||||
screen = ThirdPartyToolsScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0,
|
||||
)
|
||||
self.third_party_tools_screen = screen
|
||||
return screen
|
||||
|
||||
def _make_configure_tool_config_screen(self):
|
||||
from jackify.frontends.gui.screens.configure_tool_config_screen import ConfigureToolConfigScreen
|
||||
screen = ConfigureToolConfigScreen(
|
||||
stacked_widget=self.stacked_widget, additional_tasks_index=3,
|
||||
)
|
||||
self.configure_tool_config_screen = screen
|
||||
return screen
|
||||
|
||||
def _debug_screen_change(self, index):
|
||||
try:
|
||||
idx = int(index) if index is not None else 0
|
||||
@@ -253,6 +271,8 @@ class MainWindowUIMixin:
|
||||
7: "Wabbajack Installer",
|
||||
8: "Configure Existing Modlist",
|
||||
9: "Install MO2 Screen",
|
||||
10: "Third Party Tools",
|
||||
11: "Configure Tool Compatibility",
|
||||
}
|
||||
screen_name = screen_names.get(idx, f"Unknown Screen (Index {idx})")
|
||||
widget = self.stacked_widget.widget(idx)
|
||||
|
||||
98
jackify/frontends/gui/mixins/thread_lifecycle_mixin.py
Normal file
98
jackify/frontends/gui/mixins/thread_lifecycle_mixin.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Safe QThread teardown mixin for workflow screens.
|
||||
|
||||
PySide6 segfaults if a QThread emits a signal to a C++ Qt object that has
|
||||
already been deleted (e.g. because the user navigated away). The fix is to
|
||||
disconnect all signals from a thread before the owning screen can be destroyed,
|
||||
then let the thread finish naturally rather than calling terminate().
|
||||
|
||||
Usage:
|
||||
class MyScreen(ThreadLifecycleMixin, QWidget):
|
||||
def hideEvent(self, event):
|
||||
super().hideEvent(event)
|
||||
self.my_thread = self._park_thread(
|
||||
self.my_thread, ["finished_signal", "progress_update"]
|
||||
)
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._park_all_threads()
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
class ThreadLifecycleMixin:
|
||||
"""Mixin providing safe QThread signal-disconnect parking for screen widgets."""
|
||||
|
||||
def _park_thread(self, thread, signal_names: Optional[List[str]] = None):
|
||||
"""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.
|
||||
|
||||
Returns None so callers can do: self.thread = self._park_thread(self.thread, [...])
|
||||
"""
|
||||
if thread is None:
|
||||
return None
|
||||
|
||||
for name in (signal_names or []):
|
||||
try:
|
||||
getattr(thread, name).disconnect()
|
||||
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
|
||||
return None
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Park all running threads when the screen is hidden/navigated away from."""
|
||||
try:
|
||||
super().hideEvent(event)
|
||||
except Exception:
|
||||
pass
|
||||
self._park_all_threads()
|
||||
|
||||
def _park_all_threads(self):
|
||||
"""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().
|
||||
"""
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
_common_signals = (
|
||||
"finished_signal",
|
||||
"progress_update",
|
||||
"workflow_complete",
|
||||
"configuration_complete",
|
||||
"error_occurred",
|
||||
"status_update",
|
||||
"finished",
|
||||
)
|
||||
|
||||
for attr_name, value in list(vars(self).items()):
|
||||
try:
|
||||
if not isinstance(value, QThread):
|
||||
continue
|
||||
if not value.isRunning():
|
||||
continue
|
||||
signal_names = [s for s in _common_signals if hasattr(value, s)]
|
||||
setattr(self, attr_name, self._park_thread(value, signal_names))
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user