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
@@ -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")