mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 02:23:45 +02:00
Release v0.7.1 - Remote Manifest System, Stability Fixes
This commit is contained in:
@@ -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...")
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user