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