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

This commit is contained in:
Omni
2026-06-21 21:47:48 +01:00
parent 33b3fbaed2
commit 7fff107389
483 changed files with 11150 additions and 6050 deletions
@@ -32,6 +32,15 @@ class MainWindowBackendMixin:
from jackify.backend.services.update_service import UpdateService
from jackify import __version__
self.update_service = UpdateService(__version__)
from jackify.backend.services.nxm_ipc import NxmIpcServer
self._nxm_ipc_server = NxmIpcServer(self)
self._nxm_ipc_server.url_received.connect(self._on_nxm_url_received)
self._nxm_ipc_server.start()
from jackify.backend.services.nxm_protocol import ensure_nxm_registered
ensure_nxm_registered()
logger.debug(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}")
def _is_steamdeck(self):
@@ -126,8 +126,39 @@ class MainWindowDialogsMixin:
start_new_session=True
)
def _on_nxm_url_received(self, url: str) -> None:
"""Handle an incoming nxm:// URL from the IPC server or initial launch."""
try:
from jackify.backend.services.nxm_url import parse_nxm_url
from jackify.backend.services import nxm_session
from jackify.tools.verify_install import discover_installed_modlists
from jackify.frontends.gui.dialogs.nxm_download_dialog import NxmDownloadDialog
nxm = parse_nxm_url(url)
modlists = discover_installed_modlists()
if not modlists:
from jackify.frontends.gui.dialogs.nxm_download_dialog import show_no_modlists_error
show_no_modlists_error()
return
# Prefer the actively running MO2 instance; fall back to session memory.
auto_start = nxm_session.detect_active_mo2_modlist(modlists)
if auto_start is None:
remembered = nxm_session.get_remembered_modlist()
if remembered:
auto_start = next((m for m in modlists if m["name"] == remembered), None)
dlg = NxmDownloadDialog(nxm, modlists, parent=self, auto_start_modlist=auto_start)
dlg.show()
dlg.raise_()
dlg.activateWindow()
except Exception as e:
logger.warning("Failed to handle NXM URL %r: %s", url, e)
def cleanup_processes(self):
try:
if hasattr(self, '_nxm_ipc_server') and self._nxm_ipc_server is not None:
self._nxm_ipc_server.stop()
if hasattr(self, '_update_thread') and self._update_thread is not None:
self._update_thread = self._stop_qthread(self._update_thread, "_update_thread")
if hasattr(self, '_gallery_cache_preload_thread') and self._gallery_cache_preload_thread is not None:
@@ -135,10 +135,10 @@ class MainWindowGeometryMixin:
self.showMaximized()
def _on_child_resize_request(self, mode: str):
logger.debug(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}")
logger.debug(f"_on_child_resize_request called with mode='{mode}', current_size={self.size()}")
try:
if self.system_info and self.system_info.is_steamdeck:
logger.debug("DEBUG: Steam Deck detected, ignoring resize request")
logger.debug("Steam Deck detected, ignoring resize request")
try:
if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox:
self.install_ttw_screen.show_details_checkbox.setVisible(False)
@@ -183,7 +183,7 @@ class MainWindowGeometryMixin:
before = self.size()
self._programmatic_resize = True
self.resize(self.size().width(), target_height)
logger.debug(f"DEBUG: Animated fallback resize from {before} to {self.size()}")
logger.debug(f"Animated fallback resize from {before} to {self.size()}")
QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False))
return
start_rect = self.geometry()
@@ -65,6 +65,47 @@ class MainWindowStartupMixin:
except Exception as e:
print(f"Error checking protontricks: {e}")
def _check_tool_updates_on_startup(self):
class _ToolUpdateCheckThread(QThread):
updates_found = Signal(bool)
def run(self):
try:
from jackify.backend.services.tool_registry import ToolRegistry, get_effective_definitions
registry = ToolRegistry()
for defn in get_effective_definitions():
if defn.pinned_version is not None:
continue
status = registry.get_status(defn.tool_id)
if not status or not status.installed:
continue
logger.debug(
"Startup tool update check: %s installed=%s version=%s",
defn.tool_id, status.installed, status.installed_version,
)
tag = registry.check_latest_version(defn.tool_id)
logger.debug("Startup tool update check: %s latest=%s", defn.tool_id, tag)
if tag and status.installed_version and tag.lstrip("v") != status.installed_version.lstrip("v"):
logger.debug("Startup tool update check: update available for %s", defn.tool_id)
self.updates_found.emit(True)
return
self.updates_found.emit(False)
except Exception as e:
logger.warning("Tool update check failed: %s", e, exc_info=True)
self.updates_found.emit(False)
def on_result(has_updates: bool):
from PySide6.QtCore import QTimer
QTimer.singleShot(0, lambda: (
self.main_menu.notify_tool_updates(has_updates)
if hasattr(self, 'main_menu') and hasattr(self.main_menu, 'notify_tool_updates')
else None
))
self._tool_update_check_thread = _ToolUpdateCheckThread()
self._tool_update_check_thread.updates_found.connect(on_result)
self._tool_update_check_thread.start()
def _check_for_updates_on_startup(self):
try:
logger.debug("Checking for updates on startup...")
@@ -76,13 +76,13 @@ class MainWindowUIMixin:
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft)
bottom_bar_layout.addStretch(1)
kofi_link = QLabel('<a href="#" style="color:#72A5F2; text-decoration:none;">Support on Ko-fi</a>')
kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;")
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, alignment=Qt.AlignCenter)
bottom_bar_layout.addWidget(kofi_link)
bottom_bar_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;")
@@ -229,9 +229,9 @@ class MainWindowUIMixin:
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,
from jackify.frontends.gui.screens.tools_hub import ToolsHubScreen
screen = ToolsHubScreen(
stacked_widget=self.stacked_widget, main_menu_index=0, ttw_screen_index=5,
)
self.third_party_tools_screen = screen
return screen
@@ -19,6 +19,7 @@ Usage:
"""
import logging
import warnings
from typing import List, Optional
logger = logging.getLogger(__name__)
@@ -46,10 +47,12 @@ class ThreadLifecycleMixin:
return None
for name in (signal_names or []):
try:
getattr(thread, name).disconnect()
except Exception:
pass
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
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.
@@ -68,6 +71,36 @@ class ThreadLifecycleMixin:
pass
self._park_all_threads()
def _kill_prefix_wine_processes(self, appid: str = '') -> None:
"""Kill wine/winetricks subprocesses on user-initiated cancel.
Called before parking threads so the blocked subprocess.run() calls inside
the thread actually return rather than running until completion.
"""
import os
import subprocess
for pattern in ('winetricks', 'protontricks'):
subprocess.run(['pkill', '-9', '-f', pattern], capture_output=True)
if not appid:
return
try:
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.handlers.winetricks_handler import WinetricksHandler
compat = PathHandler.find_compat_data(str(appid))
if not compat:
return
pfx = str(compat / 'pfx')
wine_bin = WinetricksHandler()._get_wine_binary_for_prefix(pfx)
if not wine_bin:
return
wineserver = os.path.join(os.path.dirname(wine_bin), 'wineserver')
if os.path.isfile(wineserver):
subprocess.run([wineserver, '-k'],
env={**os.environ, 'WINEPREFIX': pfx},
timeout=5, capture_output=True)
except Exception:
pass
def _park_all_threads(self):
"""Park every running QThread attribute found on this instance.