Release v0.6.0

This commit is contained in:
Omni
2026-04-20 20:57:23 +01:00
parent 69fabb32e6
commit 2ff09a1448
144 changed files with 4841 additions and 1306 deletions

View File

@@ -11,12 +11,21 @@ import signal
import logging
from .main import JackifyCLI
from jackify.shared.logging import LoggingHandler
from jackify import __version__ as jackify_version
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def _setup_cli_logging() -> logging.Logger:
debug_mode = '--debug' in sys.argv or '-d' in sys.argv
if not debug_mode:
try:
from jackify.backend.handlers.config_handler import ConfigHandler
debug_mode = ConfigHandler().get('debug_mode', False)
except Exception:
pass
return LoggingHandler().setup_application_logging(debug_mode)
root_logger = _setup_cli_logging()
root_logger.info("Jackify %s starting (CLI)", jackify_version)
def terminate_children(signum, frame):
"""Signal handler to terminate child processes on exit"""

View File

@@ -209,7 +209,6 @@ class InstallModlistCommand:
'modlist_value': getattr(args, 'modlist_value', None),
'skip_confirmation': True,
'resolution': getattr(args, 'resolution', None),
'skip_disk_check': getattr(args, 'skip_disk_check', False),
}
def _validate_install_context(self, context: dict) -> bool:
@@ -317,16 +316,21 @@ class InstallModlistCommand:
# Check if game is supported
if game_type and not modlist_cli.check_game_support(game_type):
# Show unsupported game warning
supported_games = modlist_cli.wabbajack_parser.get_supported_games_display_names()
supported_games_str = ", ".join(supported_games)
print(f"\n{COLOR_WARNING}Game Support Notice{COLOR_RESET}")
print(f"{COLOR_WARNING}While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to: {supported_games_str}.{COLOR_RESET}")
print(f"{COLOR_WARNING}We are working to add more automated support in future releases!{COLOR_RESET}")
# Ask for confirmation to continue
response = input(f"{COLOR_PROMPT}Click Enter to continue with the modlist installation, or type 'cancel' to abort: {COLOR_RESET}").strip().lower()
response = input(f"{COLOR_PROMPT}Press Enter to continue, or type 'cancel' to abort: {COLOR_RESET}").strip().lower()
if response == 'cancel':
print("[INFO] Modlist installation cancelled by user.")
return 1
elif game_type in ('skyrimvr', 'fallout4vr'):
game_label = "Skyrim VR" if game_type == 'skyrimvr' else "Fallout 4 VR"
print(f"\n{COLOR_WARNING}VR Platform Notice{COLOR_RESET}")
print(f"{COLOR_WARNING}{game_label} modlist detected. Jackify will handle the install and prefix setup, but running VR modlists on Linux requires a working VR platform (SteamVR, ALVR, WiVRn, etc.) configured independently.{COLOR_RESET}")
print(f"{COLOR_WARNING}VR support is best effort. Full functionality depends on your VR setup.{COLOR_RESET}")
response = input(f"{COLOR_PROMPT}Press Enter to continue, or type 'cancel' to abort: {COLOR_RESET}").strip().lower()
if response == 'cancel':
print("[INFO] Modlist installation cancelled by user.")
return 1

View File

@@ -411,8 +411,6 @@ class JackifyCLI:
parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)')
parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)')
parser.add_argument('--update', action='store_true', help='Check for and install updates')
parser.add_argument('--skip-disk-check', action='store_true', help='Skip the pre-flight disk space check (use when retrying after a disk-full warning)')
# Add command-specific arguments
self.commands['install_modlist'].add_top_level_args(parser)

View File

@@ -39,8 +39,10 @@ class AdditionalMenuHandler:
print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via Proton){COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Setup Mod Organizer 2")
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Configure Tool Compatibility")
print(f" {COLOR_ACTION}→ Apply Wine registry settings for xEdit, Synthesis, Pandora, Nemesis{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
@@ -52,6 +54,8 @@ class AdditionalMenuHandler:
self._execute_install_wabbajack(cli_instance)
elif selection == "4":
self._execute_setup_mo2(cli_instance)
elif selection == "5":
self._execute_configure_tool_compat(cli_instance)
elif selection == "0":
break
else:
@@ -150,7 +154,7 @@ class AdditionalMenuHandler:
else:
output_path = Path(output_path).expanduser()
# Check if output directory already has content mirror GUI behaviour
# Check if output directory already has content - mirror GUI behaviour
if output_path.exists() and output_path.is_dir():
try:
has_files = any(output_path.iterdir())
@@ -405,3 +409,68 @@ class AdditionalMenuHandler:
if self.logger:
self.logger.debug("AdditionalMenuHandler: Executing Setup MO2 command")
command.run()
def _execute_configure_tool_compat(self, cli_instance):
"""Apply tool compatibility settings to an existing configured modlist prefix."""
from jackify.backend.handlers.modlist_handler import ModlistHandler
from jackify.backend.services.tool_config_service import apply_tool_config_for_appid
from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS
self._clear_screen()
print_jackify_banner()
print_section_header("Configure Tool Compatibility")
print(f"{COLOR_INFO}Discovering configured modlists...{COLOR_RESET}")
try:
handler = ModlistHandler()
discovered = handler.discover_executable_shortcuts("ModOrganizer.exe")
shortcuts = [
{"name": m.get("name", "Unknown"), "appid": str(m.get("appid", ""))}
for m in discovered
if m.get("appid")
]
except Exception as e:
print(f"{COLOR_ERROR}Failed to discover modlists: {e}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
if not shortcuts:
print(f"{COLOR_WARNING}No configured modlists found.{COLOR_RESET}")
print(f"{COLOR_INFO}Install and configure a modlist first.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print()
for i, s in enumerate(shortcuts, 1):
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {s['name']}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel")
selection = input(f"\n{COLOR_PROMPT}Select modlist (0-{len(shortcuts)}): {COLOR_RESET}").strip()
if selection == "0" or not selection:
return
try:
idx = int(selection) - 1
if idx < 0 or idx >= len(shortcuts):
raise ValueError()
except ValueError:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
chosen = shortcuts[idx]
print(f"\n{COLOR_INFO}Applying tool compatibility settings for: {chosen['name']}{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a few minutes...{COLOR_RESET}\n")
def _log(msg: str):
print(f"{COLOR_INFO}{msg}{COLOR_RESET}")
ok = apply_tool_config_for_appid(chosen["appid"], log=_log)
if ok:
print(f"\n{COLOR_SUCCESS}Tool compatibility configured successfully.{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}Tool compatibility configuration failed. Check logs for details.{COLOR_RESET}")
input("\nPress Enter to return to menu...")

View File

@@ -38,7 +38,7 @@ def handle_protocol_url(url: str):
if error:
error_description = params.get('error_description', ['No description'])[0]
_log_error(f"OAuth error: {error} {error_description}")
_log_error(f"OAuth error: {error} - {error_description}")
return
if not code or not state:

View File

@@ -22,6 +22,7 @@ from PySide6.QtGui import QFont, QClipboard
from ....backend.services.update_service import UpdateService
from ....backend.models.configuration import SystemInfo
from .... import __version__
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__)
@@ -45,7 +46,7 @@ class UpdateCheckThread(QThread):
self.update_check_finished.emit(None)
class AboutDialog(QDialog):
class AboutDialog(ThreadLifecycleMixin, QDialog):
"""About dialog showing system info and app details."""
def __init__(self, system_info: SystemInfo, parent=None):
@@ -420,8 +421,7 @@ Python: {platform.python_version()}"""
def closeEvent(self, event):
"""Handle dialog close event."""
if self.update_check_thread and self.update_check_thread.isRunning():
self.update_check_thread.terminate()
self.update_check_thread.wait()
self.update_check_thread = self._park_thread(
self.update_check_thread, ["update_available", "no_update", "check_failed"]
)
event.accept()

View File

@@ -12,7 +12,7 @@ from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QWidget,
QSpacerItem, QSizePolicy, QFrame, QApplication
)
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QIcon, QFont
logger = logging.getLogger(__name__)
@@ -145,7 +145,8 @@ class ENBProtonDialog(QDialog):
# OK button
btn_row = QHBoxLayout()
btn_row.addStretch()
self.ok_btn = QPushButton("I Understand")
self.ok_btn = QPushButton("I Understand (3s)")
self.ok_btn.setEnabled(False)
self.ok_btn.setStyleSheet(
"QPushButton { "
" background: #3fb7d6; "
@@ -162,9 +163,19 @@ class ENBProtonDialog(QDialog):
"QPushButton:pressed { "
" background: #2d8fa8; "
"}"
"QPushButton:disabled { "
" background: #555; "
" color: #aaa; "
"}"
)
self.ok_btn.clicked.connect(self.accept)
btn_row.addWidget(self.ok_btn)
self._protect_countdown = 3
self._protect_timer = QTimer(self)
self._protect_timer.setInterval(1000)
self._protect_timer.timeout.connect(self._on_protect_tick)
self._protect_timer.start()
btn_row.addStretch()
layout.addLayout(btn_row)
@@ -173,6 +184,15 @@ class ENBProtonDialog(QDialog):
logger.info(f"ENBProtonDialog created for modlist: {modlist_name}")
def _on_protect_tick(self):
self._protect_countdown -= 1
if self._protect_countdown > 0:
self.ok_btn.setText(f"I Understand ({self._protect_countdown}s)")
else:
self._protect_timer.stop()
self.ok_btn.setText("I Understand")
self.ok_btn.setEnabled(True)
def _set_dialog_icon(self):
"""Set the dialog icon to Wabbajack icon if available"""
try:

View File

@@ -361,7 +361,8 @@ class ManualDownloadDialog(QDialog):
logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True)
def _on_pick_folder(self) -> None:
chosen = QFileDialog.getExistingDirectory(self, "Select watch folder", str(self._watch_dir))
from jackify.frontends.gui.utils import browse_directory
chosen = browse_directory(self, "Select watch folder", str(self._watch_dir))
if chosen:
from jackify.backend.services.download_watcher_service import WatcherConfig
self._watch_dir = Path(chosen)
@@ -441,7 +442,7 @@ class ManualDownloadDialog(QDialog):
def _on_all_done_slot(self, completed: int, skipped: int) -> None:
from PySide6.QtCore import QTimer
self._progress_label.setText(
f"All downloads complete ({completed} accepted, {skipped} deferred) closing..."
f"All downloads complete ({completed} accepted, {skipped} deferred) - closing..."
)
# Raise now while the dialog is still visible so the user sees the completion state
self._raise_main_window()

View File

@@ -11,6 +11,7 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QPixmap, QIcon, QFont
from .. import shared_theme
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class FlatpakInstallThread(QThread):
@@ -26,7 +27,7 @@ class FlatpakInstallThread(QThread):
self.finished.emit(success, message)
class ProtontricksErrorDialog(QDialog):
class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
"""
Dialog shown when protontricks is not found
Provides options to install via Flatpak or get native installation guidance
@@ -322,7 +323,7 @@ class ProtontricksErrorDialog(QDialog):
def closeEvent(self, event):
"""Handle dialog close event"""
if self.install_thread and self.install_thread.isRunning():
self.install_thread.terminate()
self.install_thread.wait()
self.install_thread = self._park_thread(
self.install_thread, ["install_complete", "install_failed", "progress_update"]
)
event.accept()

View File

@@ -92,9 +92,10 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
self.api_show_btn.setStyleSheet("")
def _pick_directory(self, line_edit):
dir_path = QFileDialog.getExistingDirectory(self, "Select Directory", line_edit.text() or os.path.expanduser("~"))
from jackify.frontends.gui.utils import browse_directory
dir_path = browse_directory(self, "Select Directory", line_edit.text())
if dir_path:
line_edit.setText(os.path.realpath(dir_path))
line_edit.setText(dir_path)
def _show_help(self):
MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low")
@@ -125,6 +126,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
api_key = text.strip()
self.config_handler.save_api_key(api_key)
def _update_oauth_status(self):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
@@ -309,6 +311,10 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
self.config_handler.set("game_proton_path", resolved_game_path)
self.config_handler.set("game_proton_version", resolved_game_version)
# Save auto tool compat preference
self.config_handler.set('auto_tool_compat', self.auto_tool_compat_checkbox.isChecked())
self.config_handler.set('force_github_updates', self.force_github_updates_checkbox.isChecked())
# Save component installation method preference
if self.winetricks_radio.isChecked():
method = 'winetricks'

View File

@@ -170,6 +170,7 @@ class SettingsDialogTabsMixin:
advanced_layout.addWidget(auth_group)
advanced_layout.addSpacing(12)
self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json")
self.resource_settings = self._load_json(self.resource_settings_path)
self.resource_edits = {}
@@ -275,6 +276,27 @@ class SettingsDialogTabsMixin:
self.component_method_group.addButton(self.protontricks_radio, 1)
component_method_layout.addWidget(self.protontricks_radio)
component_layout.addLayout(component_method_layout)
self.auto_tool_compat_checkbox = QCheckBox("Apply tool compatibility settings during install/configure")
self.auto_tool_compat_checkbox.setChecked(self.config_handler.get('auto_tool_compat', True))
self.auto_tool_compat_checkbox.setToolTip(
"Automatically apply Wine registry fixes for xEdit, Pandora, and DLL overrides "
"at the end of every install or configure workflow. Disable if you find it adds "
"noticeable delay."
)
self.auto_tool_compat_checkbox.setStyleSheet("color: #fff;")
component_layout.addWidget(self.auto_tool_compat_checkbox)
self.force_github_updates_checkbox = QCheckBox("Use GitHub as update source (bypass Nexus CDN)")
self.force_github_updates_checkbox.setChecked(self.config_handler.get('force_github_updates', False))
self.force_github_updates_checkbox.setToolTip(
"Always download Jackify updates directly from GitHub Releases instead of Nexus CDN. "
"Enable this if self-updates fail or stall. GitHub delivers the AppImage directly; "
"Nexus delivers a .7z archive that Jackify must extract."
)
self.force_github_updates_checkbox.setStyleSheet("color: #fff;")
component_layout.addWidget(self.force_github_updates_checkbox)
advanced_layout.addWidget(component_group)
advanced_layout.addStretch()
self.tab_widget.addTab(advanced_tab, "Advanced")

View File

@@ -92,6 +92,8 @@ class SuccessDialog(QDialog):
suffix_text = "configured successfully!"
elif self.workflow_type == "configure_existing":
suffix_text = "configuration updated successfully!"
elif self.workflow_type == "tool_config":
suffix_text = "tool compatibility configured successfully!"
else:
# Fallback for other workflow types
message_text = self._build_success_message()
@@ -118,18 +120,19 @@ class SuccessDialog(QDialog):
# Ensure the label uses full width of the card before wrapping
card_layout.addWidget(message_label)
# Time taken
time_label = QLabel(f"Completed in {self.time_taken}")
time_label.setAlignment(Qt.AlignCenter)
time_label.setStyleSheet(
"QLabel { "
" font-size: 12px; "
" color: #b0b0b0; "
" font-style: italic; "
" margin-bottom: 10px; "
"}"
)
card_layout.addWidget(time_label)
# Time taken (omit label if time is not available)
if self.time_taken:
time_label = QLabel(f"Completed in {self.time_taken}")
time_label.setAlignment(Qt.AlignCenter)
time_label.setStyleSheet(
"QLabel { "
" font-size: 12px; "
" color: #b0b0b0; "
" font-style: italic; "
" margin-bottom: 10px; "
"}"
)
card_layout.addWidget(time_label)
# Next steps guidance
next_steps_text = self._build_next_steps()
@@ -240,15 +243,37 @@ class SuccessDialog(QDialog):
game_display = self.game_name or self.modlist_name
base_message = ""
if self.workflow_type == "tuxborn":
if self.workflow_type == "tool_config":
base_message = (
f"Modding tools for {self.modlist_name} are now configured. "
"xEdit, Synthesis, Pandora, and DLL overrides are ready to use from within Mod Organizer 2."
)
elif self.workflow_type == "tuxborn":
base_message = f"You can now launch Tuxborn from Steam and enjoy your modded {game_display} experience!"
elif self.workflow_type == "install" and self.modlist_name == "Wabbajack":
base_message = "You can now launch Wabbajack from Steam and install modlists. Once the modlist install is complete, you can run \"Configure New Modlist\" in Jackify to complete the configuration for running the modlist on Linux."
else:
base_message = f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!"
try:
from jackify.backend.handlers.config_handler import ConfigHandler
auto_tool_compat = ConfigHandler().get('auto_tool_compat', True)
except Exception:
auto_tool_compat = True
tool_hint = (
"<br><br>"
"<span style=\"color:#b0b0b0; font-size:12px;\">"
"If you use modding tools such as xEdit, Synthesis, or Pandora, "
"run <b>Configure Tool Compatibility</b> from the Additional Tasks menu."
"</span>"
) if not auto_tool_compat else ""
base_message = (
f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!"
f"{tool_hint}"
)
# ENB Proton warning shown in separate dialog
return base_message
return base_message
def _update_countdown(self):
if self._countdown > 0:

View File

@@ -17,6 +17,7 @@ from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QPixmap, QFont
from ....backend.services.update_service import UpdateService, UpdateInfo
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__)
@@ -51,7 +52,7 @@ class UpdateDownloadThread(QThread):
self.download_finished.emit(None)
class UpdateDialog(QDialog):
class UpdateDialog(ThreadLifecycleMixin, QDialog):
"""Dialog for notifying users about updates and handling downloads."""
def __init__(self, update_info: UpdateInfo, update_service: UpdateService, parent=None):
@@ -335,9 +336,7 @@ class UpdateDialog(QDialog):
def closeEvent(self, event):
"""Handle dialog close event."""
if self.download_thread and self.download_thread.isRunning():
# Cancel download if in progress
self.download_thread.terminate()
self.download_thread.wait()
self.download_thread = self._park_thread(
self.download_thread, ["progress_updated", "download_finished"]
)
event.accept()

View File

@@ -218,7 +218,8 @@ def main():
import signal
# Enable faulthandler to both stderr and file
try:
log_dir = Path.home() / '.local' / 'share' / 'jackify' / 'logs'
from jackify.shared.paths import get_jackify_logs_dir
log_dir = get_jackify_logs_dir()
log_dir.mkdir(parents=True, exist_ok=True)
trace_file = open(log_dir / 'segfault_trace.txt', 'w')
faulthandler.enable(file=trace_file, all_threads=True)
@@ -248,28 +249,30 @@ def main():
config_handler.set('debug_mode', True)
import logging
# Initialize file logging on root logger so all modules inherit it
# Initialize root logger: jackify.log (INFO, always) + jackify-debug.log (DEBUG, debug mode only)
from jackify.shared.logging import LoggingHandler
logging_handler = LoggingHandler()
# Only rotate log file when debug mode is enabled
root_logger = LoggingHandler().setup_application_logging(debug_mode)
def _unhandled_exception(exc_type, exc_value, exc_tb):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_tb)
return
logging.getLogger().critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_tb))
sys.excepthook = _unhandled_exception
_mode = 'AppImage' if os.environ.get('APPIMAGE') else 'dev'
root_logger.info("Jackify %s starting (GUI, %s)", jackify_version, _mode)
if debug_mode:
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-debug.log')
root_logger = logging_handler.setup_logger('', 'jackify-debug.log', is_general=True, debug_mode=debug_mode) # Empty name = root logger
# CRITICAL: Set root logger level BEFORE any child loggers are used
# DEBUG messages from child loggers must propagate
if debug_mode:
root_logger.setLevel(logging.DEBUG)
logging.getLogger().setLevel(logging.DEBUG) # Also set on root via getLogger() for compatibility
root_logger.debug("CLI --debug flag detected, saved debug_mode=True to config")
root_logger.info("Debug mode enabled (from config or CLI)")
else:
root_logger.setLevel(logging.WARNING)
logging.getLogger().setLevel(logging.WARNING)
# Root logger should not propagate (it's the top level)
# Child loggers will propagate to root logger by default (unless they explicitly set propagate=False)
root_logger.propagate = False
root_logger.debug("Debug mode enabled")
try:
from jackify.shared.paths import get_jackify_logs_dir
_flatpak = (Path.home() / ".var/app/com.valvesoftware.Steam").exists()
_steam_type = 'Flatpak' if _flatpak else 'native'
root_logger.info("Steam: %s | log dir: %s", _steam_type, get_jackify_logs_dir())
except Exception:
pass
dev_mode = '--dev' in sys.argv
@@ -292,7 +295,7 @@ def main():
# Set up signal handlers for graceful shutdown
import signal
def signal_handler(sig, frame):
print(f"Received signal {sig}, cleaning up...")
logging.getLogger().info("Received signal %s, cleaning up...", sig)
emergency_cleanup()
app.quit()

View File

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

View File

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

View 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

View File

@@ -96,6 +96,7 @@ class AdditionalTasksScreen(QWidget):
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"),
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"),
("Configure Tool Compatibility", "tool_config", "Apply xEdit, Pandora and DLL fixes to an existing modlist prefix"),
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
]
@@ -153,6 +154,8 @@ class AdditionalTasksScreen(QWidget):
self._show_wabbajack_installer()
elif action_id == "setup_mo2":
self._show_mo2_setup()
elif action_id == "tool_config":
self._show_tool_config()
elif action_id == "coming_soon":
self._show_coming_soon_info()
elif action_id == "return_main_menu":
@@ -185,6 +188,10 @@ class AdditionalTasksScreen(QWidget):
"Check back later for more functionality!"
)
def _show_tool_config(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(11)
def _return_to_main_menu(self):
"""Return to main menu"""
if self.stacked_widget:

View File

@@ -33,8 +33,10 @@ from .configure_existing_modlist_console import ConfigureExistingModlistConsoleM
from .screen_back_mixin import ScreenBackMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class ConfigureExistingModlistScreen(
ThreadLifecycleMixin,
ScreenBackMixin,
TTWIntegrationMixin,
ConfigureExistingModlistUIMixin,
@@ -46,38 +48,25 @@ class ConfigureExistingModlistScreen(
):
resize_request = Signal(str)
def _park_thread(self, thread, signal_names=None):
"""Disconnect a running thread from this screen and keep it alive until it finishes."""
if thread is None:
return None
signal_names = signal_names or []
for signal_name in signal_names:
def hideEvent(self, event):
if getattr(self, '_vnv_controller', None) is not None:
try:
getattr(thread, signal_name).disconnect()
self._vnv_controller.cleanup()
except Exception:
pass
if not hasattr(self, "_parked_threads"):
self._parked_threads = []
self._parked_threads.append(thread)
self._parked_threads = [t for t in self._parked_threads if getattr(t, "isRunning", lambda: False)()]
return None
super().hideEvent(event)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
if getattr(self, '_vnv_controller', None) is not None:
try:
if isinstance(value, QThread) and value.isRunning():
signal_names = []
for candidate in ("finished_signal", "progress_update", "configuration_complete", "error_occurred"):
if hasattr(value, candidate):
signal_names.append(candidate)
setattr(self, attr_name, self._park_thread(value, signal_names))
self._vnv_controller.cleanup()
self._vnv_controller = None
except Exception:
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"""
@@ -124,6 +113,14 @@ class ConfigureExistingModlistScreen(
if install_dir:
game_type = self._detect_game_type_from_mo2_ini(install_dir)
appid = getattr(self, '_current_appid', '')
if appid:
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
ModlistHandler().set_steam_grid_images(str(appid), install_dir, game_type=game_type)
logger.debug("Applied Steam artwork for appid %s", appid)
except Exception as e:
logger.warning("Failed to apply Steam artwork: %s", e)
if game_type in ('falloutnv', 'fallout_new_vegas'):
from jackify.backend.utils.modlist_meta import get_modlist_name
identified_name = get_modlist_name(install_dir)

View File

@@ -15,34 +15,12 @@ class ConfigureExistingModlistWorkflowMixin:
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
"""Detect game type by checking ModOrganizer.ini for loader executables."""
from pathlib import Path
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
return 'skyrim' # Fallback to most common
"""Detect special game type using the canonical ModlistHandler detection."""
try:
content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower()
if 'skse64_loader.exe' in content or 'skyrim special edition' in content:
return 'skyrim'
elif 'f4se_loader.exe' in content or 'fallout 4' in content:
return 'fallout4'
elif 'nvse_loader.exe' in content or 'fallout new vegas' in content:
return 'falloutnv'
elif 'fose_loader.exe' in content or 'fallout 3' in content:
return 'fallout3'
elif 'obse_loader.exe' in content or 'oblivion' in content:
return 'oblivion'
elif 'starfield' in content:
return 'starfield'
elif 'enderal' in content:
return 'enderal'
else:
return 'skyrim'
from jackify.backend.handlers.modlist_handler import ModlistHandler
return ModlistHandler().detect_special_game_type(install_dir) or 'skyrim'
except Exception as e:
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
logger.warning("Game type detection failed, defaulting to skyrim: %s", e)
return 'skyrim'
def validate_and_start_configure(self):
@@ -78,6 +56,7 @@ class ConfigureExistingModlistWorkflowMixin:
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
self._enable_controls_after_operation()
return
self._current_appid = shortcut.get('AppID', shortcut.get('appid', ''))
resolution = self.resolution_combo.currentText()
# Handle resolution saving
if resolution and resolution != "Leave unchanged":
@@ -152,7 +131,7 @@ class ConfigureExistingModlistWorkflowMixin:
modlist_context = ModlistContext(
name=self.modlist_name,
install_dir=Path(self.install_dir),
download_dir=Path(self.install_dir).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration-only
modlist_value='', # Not needed for existing modlist
@@ -187,7 +166,7 @@ class ConfigureExistingModlistWorkflowMixin:
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
if not success:
self.error_occurred.emit(
"Configuration did not complete successfully. "

View File

@@ -36,10 +36,11 @@ from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, Modl
from .screen_back_mixin import ScreenBackMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__)
class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
resize_request = Signal(str)
def cancel_and_cleanup(self):
@@ -165,32 +166,7 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
logger.debug("DEBUG: cleanup called - cleaning up threads")
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
# Clean up automated prefix thread if running
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread and self.automated_prefix_thread.isRunning():
logger.debug("DEBUG: Terminating AutomatedPrefixThread")
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
self.automated_prefix_thread.terminate()
self.automated_prefix_thread.wait(2000) # Wait up to 2 seconds
# Clean up config thread if running
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
logger.debug("DEBUG: Terminating ConfigThread")
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass
self.config_thread.terminate()
self.config_thread.wait(2000) # Wait up to 2 seconds
self._park_all_threads()

View File

@@ -4,7 +4,7 @@ import re
import time
from PySide6.QtCore import QTimer, Qt
from PySide6.QtWidgets import QFileDialog
from jackify.frontends.gui.utils import browse_file
from jackify.shared.progress_models import FileProgress, OperationType
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
@@ -168,7 +168,7 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
del self._component_install_list
def browse_install_dir(self):
file, _ = QFileDialog.getOpenFileName(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)")
file = browse_file(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)")
if file:
self.install_dir_edit.setText(os.path.realpath(file))
self.install_dir_edit.setText(file)

View File

@@ -84,21 +84,26 @@ class ConfigureNewModlistDialogsMixin:
except Exception:
pass
def hideEvent(self, event):
if getattr(self, '_vnv_controller', None) is not None:
try:
self._vnv_controller.cleanup()
except Exception:
pass
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()
self._vnv_controller = None
except Exception:
pass
self._stop_focus_reclaim()
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
try:
if isinstance(value, QThread) and value.isRunning():
value.terminate()
value.wait(2000)
setattr(self, attr_name, None)
except Exception:
pass
self._park_all_threads()
def show_shortcut_conflict_dialog(self, conflicts):
"""Show dialog to reuse an existing shortcut or choose a new name."""
@@ -148,6 +153,12 @@ class ConfigureNewModlistDialogsMixin:
self._restore_controls_after_shortcut_dialog_abort()
return
self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.")
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
_game_type = self._detect_game_type_from_mo2_ini(install_dir)
ModlistHandler().set_steam_grid_images(str(existing_appid), install_dir, game_type=_game_type)
except Exception as _e:
logger.warning("Failed to apply Steam artwork on shortcut reuse: %s", _e)
self.continue_configuration_after_automated_prefix(
str(existing_appid),
existing_name,

View File

@@ -251,6 +251,24 @@ class ConfigureNewModlistWorkflowMixin:
logger.error("Error handling automated prefix result: %s", e)
self._safe_append_text(f"Error handling automated prefix result: {str(e)}")
self.start_btn.setEnabled(True)
finally:
self._cleanup_automated_prefix_thread()
def _cleanup_automated_prefix_thread(self):
"""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
if self.automated_prefix_thread.isRunning():
self.automated_prefix_thread.quit()
self.automated_prefix_thread.wait(5000)
self.automated_prefix_thread.deleteLater()
self.automated_prefix_thread = None
def _on_automated_prefix_error(self, error):
"""Handle error from the automated prefix workflow"""
@@ -261,8 +279,8 @@ class ConfigureNewModlistWorkflowMixin:
logger.error(f"Automated prefix error: {error.message}")
self._safe_append_text(f"[FAILED] {error.message}")
MessageService.show_error(self, error)
self._enable_controls_after_operation()
self._cleanup_automated_prefix_thread()
def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None):
"""Continue the configuration process with the new AppID after automated prefix creation"""
@@ -327,7 +345,7 @@ class ConfigureNewModlistWorkflowMixin:
modlist_context = ModlistContext(
name=self.context['name'],
install_dir=Path(self.context['path']),
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value'),
@@ -434,7 +452,7 @@ class ConfigureNewModlistWorkflowMixin:
modlist_context = ModlistContext(
name=self.context['name'],
install_dir=Path(self.context['path']),
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value='', # Not needed for existing modlist

View File

@@ -0,0 +1,442 @@
"""
Configure Tool Compatibility screen.
Applies Wine registry settings for modding tools (xEdit, Pandora, DLL overrides)
to an existing configured modlist prefix. Available from Additional Tasks.
"""
import logging
import subprocess
from typing import Optional
from PySide6.QtCore import Qt, QSize, QThread, QTimer, Signal
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QGridLayout, QHBoxLayout, QLabel,
QPushButton, QSizePolicy, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
)
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
from jackify.frontends.gui.services.message_service import MessageService
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from jackify.frontends.gui.utils import set_responsive_minimum
logger = logging.getLogger(__name__)
class _ShortcutLoaderThread(QThread):
finished_signal = Signal(list) # list of {"name": str, "appid": str}
error_signal = Signal(str)
def run(self):
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
handler = ModlistHandler()
discovered = handler.discover_executable_shortcuts("ModOrganizer.exe")
shortcuts = [
{"name": m.get("name", "Unknown"), "appid": str(m.get("appid", ""))}
for m in discovered
if m.get("appid")
]
self.finished_signal.emit(shortcuts)
except Exception as e:
self.error_signal.emit(str(e))
class _ApplyThread(QThread):
log_signal = Signal(str)
finished_signal = Signal(bool)
def __init__(self, appid: str):
super().__init__()
self._appid = appid
def run(self):
from jackify.backend.services.tool_config_service import apply_tool_config_for_appid
ok = apply_tool_config_for_appid(self._appid, log=self.log_signal.emit)
self.finished_signal.emit(ok)
class ConfigureToolConfigScreen(ThreadLifecycleMixin, QWidget):
"""Apply tool compatibility settings to an existing modlist prefix."""
def __init__(self, stacked_widget=None, additional_tasks_index: int = 3, parent=None):
super().__init__(parent)
self.stacked_widget = stacked_widget
self.additional_tasks_index = additional_tasks_index
self.debug = DEBUG_BORDERS
self._shortcuts: list = []
self._loader: Optional[_ShortcutLoaderThread] = None
self._apply_thread: Optional[_ApplyThread] = None
self._setup_ui()
def _setup_ui(self):
main_vbox = QVBoxLayout(self)
main_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_vbox.setContentsMargins(50, 50, 50, 0)
main_vbox.setSpacing(12)
if self.debug:
self.setStyleSheet("border: 2px solid magenta;")
# --- Header ---
header_widget = QWidget()
header_layout = QVBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(2)
title = QLabel("<b>Configure Tool Compatibility</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
header_layout.addSpacing(10)
desc = QLabel(
"Applies Wine registry settings needed for modding tools to work correctly: "
"xEdit family (WinXP compatibility), Pandora (window decoration), "
"and global DLL overrides."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; font-size: 13px;")
desc.setAlignment(Qt.AlignHCenter)
desc.setMaximumHeight(50)
header_layout.addWidget(desc)
header_layout.addSpacing(12)
header_widget.setLayout(header_layout)
header_widget.setFixedHeight(120)
if self.debug:
header_widget.setStyleSheet("border: 2px solid pink;")
main_vbox.addWidget(header_widget)
# --- Upper section: form (left) + tabs (right) ---
upper_hbox = QHBoxLayout()
upper_hbox.setContentsMargins(0, 0, 0, 0)
upper_hbox.setSpacing(16)
# Left: form
user_config_vbox = QVBoxLayout()
user_config_vbox.setAlignment(Qt.AlignTop)
user_config_vbox.setSpacing(4)
options_header = QLabel("<b>[Options]</b>")
options_header.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;")
options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
user_config_vbox.addWidget(options_header)
form_grid = QGridLayout()
form_grid.setHorizontalSpacing(12)
form_grid.setVerticalSpacing(6)
form_grid.setContentsMargins(0, 0, 0, 0)
modlist_label = QLabel("Modlist:")
form_grid.addWidget(modlist_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
self._combo = QComboBox()
self._combo.setMinimumWidth(280)
self._combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._combo.addItem("Loading modlists...")
self._combo.setEnabled(False)
form_grid.addWidget(self._combo, 0, 1)
form_widget = QWidget()
form_widget.setLayout(form_grid)
form_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
user_config_vbox.addWidget(form_widget)
user_config_vbox.addSpacing(10)
tools_info = QLabel(
"<b>Tools configured by this workflow:</b><br>"
"&nbsp;&nbsp;xEdit family &nbsp;|&nbsp; Synthesis &nbsp;|&nbsp; Pandora<br>"
"<br>"
"Run this once after installing a modlist if modding tools are not "
"launching correctly from within Mod Organizer 2.<br>"
"<br>"
"<i>These fixes are applied on a best-effort basis. Tool compatibility "
"can change with Proton and Wine updates. Not all tools are guaranteed "
"to work on all Proton versions.</i>"
)
tools_info.setWordWrap(True)
tools_info.setStyleSheet("color: #aaa; font-size: 12px;")
tools_info.setAlignment(Qt.AlignLeft | Qt.AlignTop)
user_config_vbox.addWidget(tools_info)
# Buttons (apply + back) - placed in left column like other screens
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self._apply_btn = QPushButton("Apply Tool Configurations")
self._apply_btn.setEnabled(False)
btn_row.addWidget(self._apply_btn)
self._back_btn = QPushButton("Back")
self._back_btn.clicked.connect(self._go_back)
btn_row.addWidget(self._back_btn)
btn_row.insertStretch(0, 1)
btn_row.addStretch(1)
self.show_details_checkbox = QCheckBox("Show details")
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output")
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50)
if self.debug:
btn_row_widget.setStyleSheet("border: 2px solid red;")
self.btn_row_widget = btn_row_widget
user_config_widget = QWidget()
user_config_widget.setLayout(user_config_vbox)
user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
# Right: Activity + Process Monitor tabs
self._activity_log = QTextEdit()
self._activity_log.setReadOnly(True)
self._activity_log.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self._activity_log.setMinimumSize(QSize(300, 20))
self._activity_log.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._activity_log.setStyleSheet(
f"background: #222; color: {JACKIFY_COLOR_BLUE}; "
"font-family: monospace; font-size: 11px; border: 1px solid #444;"
)
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(
f"background: #222; color: {JACKIFY_COLOR_BLUE}; "
"font-family: monospace; font-size: 11px; border: 1px solid #444;"
)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
self.process_monitor_widget = process_monitor_widget
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet(
"QTabWidget::pane { background: #222; border: 1px solid #444; } "
"QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } "
"QTabBar::tab:selected { background: #333; color: #3fd0ea; } "
"QTabWidget { margin: 0px; padding: 0px; } "
"QTabBar { margin: 0px; padding: 0px; }"
)
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
self.activity_tabs.setDocumentMode(False)
self.activity_tabs.setTabPosition(QTabWidget.North)
if self.debug:
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.addTab(self._activity_log, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(self.activity_tabs, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
upper_section_widget.setMaximumHeight(280)
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
main_vbox.addWidget(upper_section_widget)
# --- Status banner ---
self._status_banner = QLabel("Ready to apply")
self._status_banner.setAlignment(Qt.AlignCenter)
self._status_banner.setStyleSheet(f"""
background-color: #2a2a2a;
color: {JACKIFY_COLOR_BLUE};
padding: 6px 8px;
border-radius: 4px;
font-weight: bold;
font-size: 13px;
""")
self._status_banner.setMaximumHeight(34)
self._status_banner.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self._status_banner, 1)
banner_row.addStretch()
banner_row.addWidget(self.show_details_checkbox)
banner_row_widget = QWidget()
banner_row_widget.setLayout(banner_row)
banner_row_widget.setMaximumHeight(45)
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
main_vbox.addWidget(banner_row_widget)
# --- Console (hidden by default) ---
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setFontFamily("monospace")
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
main_vbox.addWidget(self.console, stretch=1)
main_vbox.addWidget(btn_row_widget, alignment=Qt.AlignHCenter)
self.main_overall_vbox = main_vbox
self.setLayout(main_vbox)
# Process monitor refresh timer
self._top_timer = QTimer(self)
self._top_timer.timeout.connect(self._update_top_panel)
self._top_timer.start(2000)
self._apply_btn.clicked.connect(self._on_apply)
# ------------------------------------------------------------------
def showEvent(self, event):
super().showEvent(event)
logger.info("Configure Tool Compatibility screen opened")
try:
main_window = self.window()
if main_window:
set_responsive_minimum(main_window, min_width=960, min_height=520)
except Exception:
pass
self._load_shortcuts()
def _load_shortcuts(self):
if self._loader and self._loader.isRunning():
return
self._combo.clear()
self._combo.addItem("Loading modlists...")
self._combo.setEnabled(False)
self._apply_btn.setEnabled(False)
self._loader = _ShortcutLoaderThread()
self._loader.finished_signal.connect(self._on_shortcuts_loaded)
self._loader.error_signal.connect(self._on_shortcuts_error)
self._loader.start()
def _on_shortcuts_loaded(self, shortcuts: list):
self._shortcuts = shortcuts
self._combo.clear()
if not shortcuts:
self._combo.addItem("No configured modlists found")
self._combo.setEnabled(False)
self._apply_btn.setEnabled(False)
return
for s in shortcuts:
self._combo.addItem(s["name"])
self._combo.setEnabled(True)
self._apply_btn.setEnabled(True)
def _on_shortcuts_error(self, error: str):
self._combo.clear()
self._combo.addItem("Error loading modlists")
self._combo.setEnabled(False)
self._activity_log.append(f"Failed to load modlists: {error}")
def _on_apply(self):
idx = self._combo.currentIndex()
if idx < 0 or idx >= len(self._shortcuts):
return
shortcut = self._shortcuts[idx]
appid = shortcut["appid"]
name = shortcut["name"]
self._activity_log.clear()
self.console.clear()
self._activity_log.append(f"Applying tool configurations to: {name} (AppID {appid})")
self._status_banner.setText("Applying...")
logger.info("Applying tool compat config: %s (AppID %s)", name, appid)
self._apply_btn.setEnabled(False)
self._combo.setEnabled(False)
self._apply_thread = _ApplyThread(appid)
self._apply_thread.log_signal.connect(self._activity_log.append)
self._apply_thread.log_signal.connect(self.console.append)
self._apply_thread.finished_signal.connect(self._on_apply_finished)
self._apply_thread.start()
def _on_apply_finished(self, success: bool):
self._apply_thread = None
self._apply_btn.setEnabled(True)
self._combo.setEnabled(True)
if success:
self._activity_log.append("\nDone. Tool compatibility settings applied successfully.")
self._status_banner.setText("Applied successfully")
logger.info("Tool compat config applied successfully")
idx = self._combo.currentIndex()
name = self._shortcuts[idx]["name"] if 0 <= idx < len(self._shortcuts) else "Modlist"
from ..dialogs.success_dialog import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=name,
workflow_type="tool_config",
time_taken="",
parent=self
)
success_dialog.show()
else:
self._activity_log.append("\nFailed. Check the output above for details.")
self._status_banner.setText("Apply failed - see details")
logger.warning("Tool compat config failed")
MessageService.warning(
self, "Failed",
"Tool configuration did not complete successfully.\nSee the log for details."
)
def _go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.additional_tasks_index)
def _on_show_details_toggled(self, checked: bool):
if checked:
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215)
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else:
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
def _update_top_panel(self):
try:
result = subprocess.run(
["ps", "-eo", "pcpu,pmem,comm,args"],
stdout=subprocess.PIPE, text=True, timeout=2
)
lines = result.stdout.splitlines()
header = "CPU%\tMEM%\tCOMMAND"
filtered = [header]
rows = []
for line in lines[1:]:
ll = line.lower()
if (
"wine" in ll or "wine64" in ll or "protontricks" in ll
or "jackify-engine" in ll
) and "jackify-gui.py" not in ll:
cols = line.strip().split(None, 3)
if len(cols) >= 3:
rows.append(cols)
rows.sort(key=lambda x: float(x[0]), reverse=True)
for cols in rows:
filtered.append("\t".join(cols))
if len(filtered) == 1:
filtered.append("[No relevant processes]")
self.process_monitor.setPlainText("\n".join(filtered))
except Exception as e:
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
def cleanup_processes(self):
self._park_all_threads()

View File

@@ -13,7 +13,7 @@ from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QCheckBox,
QLineEdit, QGridLayout, QTextEdit, QCheckBox,
QMessageBox, QSizePolicy,
)
from PySide6.QtCore import Qt, QThread, Signal, QSize
@@ -25,7 +25,7 @@ from jackify.shared.progress_models import FileProgress, OperationType
from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from ..services.message_service import MessageService
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import set_responsive_minimum
from ..utils import set_responsive_minimum, browse_directory
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
from ..widgets.progress_indicator import OverallProgressIndicator
from ..widgets.file_progress_list import FileProgressList
@@ -303,11 +303,9 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
self.resize_request.emit("compact")
def _browse_folder(self):
folder = QFileDialog.getExistingDirectory(
self, "Select MO2 Installation Folder", str(Path.home()), QFileDialog.ShowDirsOnly
)
folder = browse_directory(self, "Select MO2 Installation Folder", str(Path.home()))
if folder:
self.install_dir_edit.setText(os.path.realpath(folder))
self.install_dir_edit.setText(folder)
# ------------------------------------------------------------------
# Activity window helpers
@@ -511,9 +509,7 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
try:
if self.worker.isRunning():
self.worker.requestInterruption()
if not self.worker.wait(5000):
self.worker.terminate()
self.worker.wait(10000)
self.worker.wait(10000)
self.worker.deleteLater()
except Exception:
pass

View File

@@ -49,8 +49,9 @@ from .install_modlist_workflow import InstallWorkflowMixin
from .install_modlist_nexus import NexusAuthMixin
from .install_modlist_selection import ModlistSelectionMixin
from .screen_back_mixin import ScreenBackMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
resize_request = Signal(str) # Signal for expand/collapse like TTW screen
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
@@ -411,16 +412,25 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
btn_exit.clicked.connect(on_exit)
dlg.exec()
def hideEvent(self, event):
if getattr(self, '_vnv_controller', None) is not None:
try:
self._vnv_controller.cleanup()
except Exception:
pass
super().hideEvent(event)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
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)
@@ -460,16 +470,12 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
except Exception:
pass
logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, forcing terminate")
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:
thread.terminate()
except Exception:
pass
try:
if not thread.wait(force_ms):
logger.error(f"ERROR: {attr_name} still running after forced shutdown window")
@@ -545,21 +551,18 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
self.install_thread.cancel()
self.install_thread.wait(5000)
# Cancel the automated prefix thread if it exists
if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning():
self.prefix_thread.terminate()
self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.prefix_thread.isRunning():
self.prefix_thread.terminate() # Force terminate if needed
self.prefix_thread.wait(1000)
# Cancel the configuration thread if it exists
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.config_thread.isRunning():
self.config_thread.terminate() # Force terminate if needed
self.config_thread.wait(1000)
# Park prefix/config threads - disconnect their signals and let them
# finish naturally rather than terminating unsafely.
if hasattr(self, 'prefix_thread') and self.prefix_thread:
self.prefix_thread = self._park_thread(
self.prefix_thread,
["progress_update", "workflow_complete", "error_occurred"],
)
if hasattr(self, 'config_thread') and self.config_thread:
self.config_thread = self._park_thread(
self.config_thread,
["progress_update", "configuration_complete", "error_occurred"],
)
# Cleanup any remaining processes
self.cleanup_processes()

View File

@@ -77,6 +77,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion on main thread"""
try:
install_dir = self.install_dir_edit.text().strip()
# Stop CPU tracking now that everything is complete
self.file_progress_list.stop_cpu_tracking()
# Re-enable controls now that installation/configuration is complete
@@ -107,7 +108,6 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
game_name = display_names.get(self._current_game_type, self._current_game_name)
# Check for TTW eligibility before showing final success dialog
install_dir = self.install_dir_edit.text().strip()
ttw_modlist_name = modlist_name
try:
from jackify.backend.utils.modlist_meta import get_modlist_name
@@ -470,7 +470,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
modlist_context = ModlistContext(
name=self.context['name'],
install_dir=Path(self.context['path']),
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value'),
@@ -603,7 +603,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
modlist_context = ModlistContext(
name=self.context['name'],
install_dir=Path(self.context['path']),
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
download_dir=None,
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value', ''),

View File

@@ -35,7 +35,7 @@ class InstallerThread(QThread):
def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name,
install_mode='online', progress_state_manager=None, auth_service=None,
oauth_info=None, skip_disk_check=False):
oauth_info=None):
super().__init__()
self.modlist = modlist
self.install_dir = install_dir
@@ -48,7 +48,6 @@ class InstallerThread(QThread):
self.progress_state_manager = progress_state_manager
self.auth_service = auth_service
self.oauth_info = oauth_info
self.skip_disk_check = skip_disk_check
self._premium_signal_sent = False
self._non_premium_info_sent = False
self._engine_output_buffer = []
@@ -277,17 +276,17 @@ class InstallerThread(QThread):
if debug_mode:
cmd.append('--debug')
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
if self.skip_disk_check:
cmd.append('--skip-disk-check')
logger.debug("DEBUG: Added --skip-disk-check flag to jackify-engine command")
logger.debug(f"DEBUG: FULL Engine command: {' '.join(cmd)}")
logger.debug(f"DEBUG: modlist value being passed: '{self.modlist}'")
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
writeback_path = str(self.auth_service.get_token_writeback_path()) if self.auth_service else None
env_vars = {'NEXUS_API_KEY': self.api_key}
if self.oauth_info:
env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
if writeback_path:
env_vars['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
env = get_clean_subprocess_env(env_vars)
# Install-time resource preflight: keep this visible in workflow output so
@@ -472,6 +471,8 @@ class InstallerThread(QThread):
self.output_received.emit(decoded)
stderr_thread.join(timeout=5)
returncode = self.process_manager.wait()
if writeback_path and self.auth_service:
self.auth_service.apply_token_writeback(writeback_path)
if self.process_manager.proc and self.process_manager.proc.stdout:
try:
remaining = self.process_manager.proc.stdout.read()

View File

@@ -258,7 +258,7 @@ class NexusAuthMixin:
wait_label = QLabel(
"Waiting for authorisation...\n\n"
"Please complete authorisation in your browser.\n\n"
"Your browser may ask permission to open Jackify click Open or Allow."
"Your browser may ask permission to open Jackify - click Open or Allow."
)
wait_label.setWordWrap(True)
wait_label.setStyleSheet("color: #ccc; font-size: 12px;")
@@ -366,8 +366,6 @@ class NexusAuthMixin:
oauth_thread.wait(100)
if oauth_cancelled[0]:
oauth_thread.wait(2000)
if oauth_thread.isRunning():
oauth_thread.terminate()
break
wait_dialog.close()

View File

@@ -89,7 +89,7 @@ class ProgressHandlersMixin:
"When your browser opens a Nexus page, click <b>\"Slow Download\"</b>."
" For non-Nexus manual links, follow the site instructions shown in the page.<br><br>"
"<b>Watch folder:</b> Jackify watches the folder shown in that dialog for newly downloaded files. "
"Files detected there are validated and moved automatically into your modlist downloads folder "
"Files detected there are validated and moved automatically into your modlist downloads folder - "
"you do not need to move files manually. If your browser saves to a different location, "
"please set the Watch Folder to that directory before starting the download of mod archives."
)
@@ -132,7 +132,7 @@ class ProgressHandlersMixin:
self._stalled_download_start_time = time.time()
self._stalled_data_snapshot = progress_state.data_processed
elif progress_state.data_processed > self._stalled_data_snapshot:
# Bytes are advancing despite 0 speed readout engine reporting lag, not a real stall
# Bytes are advancing despite 0 speed readout - engine reporting lag, not a real stall
self._stalled_download_start_time = time.time()
self._stalled_data_snapshot = progress_state.data_processed
else:
@@ -406,18 +406,6 @@ class ProgressHandlersMixin:
if self._premium_failure_active:
message = "Installation stopped because Nexus Premium is required for automated downloads."
if not self._premium_failure_active and not cancellation_detected:
thread = getattr(self, 'install_thread', None)
if (thread
and not getattr(thread, '_install_progress_started', False)
and getattr(getattr(thread, 'last_error', None), 'title', '') == "Disk Full"):
ctx = getattr(thread, '_last_error_raw_context', {})
if self._handle_preflight_disk_space(ctx):
return
self._installation_cancelled = True
self.process_finished(130, QProcess.NormalExit)
return
if not self._premium_failure_active:
engine_error = getattr(self.install_thread, 'last_error', None)
if engine_error:
@@ -429,98 +417,6 @@ class ProgressHandlersMixin:
self._safe_append_text(f"\nError: {message}")
self.process_finished(1, QProcess.CrashExit) # Simulate error
def _handle_preflight_disk_space(self, ctx: dict) -> bool:
"""Show pre-flight filesystem warning dialog. Returns True if user chose Continue Anyway."""
from PySide6.QtWidgets import QMessageBox
if ctx.get('offending_names'):
name_max = ctx.get('name_max', 255)
offending_names = ctx.get('offending_names') or []
examples = "\n".join(f" {n}" for n in offending_names[:3])
if len(offending_names) > 3:
examples += f"\n ...and {len(offending_names) - 3} more"
body = (
f"Your filesystem limits filenames to {name_max} characters, but this modlist "
f"contains files with longer names.\n\n"
f"Affected files:\n{examples}\n\n"
f"Installation may fail for those files. Using ext4, btrfs, or XFS on a "
f"non-encrypted mount is recommended.\n\n"
f"You can attempt to continue — some files may not extract correctly."
)
dlg = QMessageBox(self)
dlg.setWindowTitle("Filename Length Warning")
dlg.setText("Filesystem filename length limit detected.")
dlg.setInformativeText(body)
dlg.setIcon(QMessageBox.Warning)
else:
archive_bytes = ctx.get('archive_bytes', 0)
install_bytes = ctx.get('install_bytes', 0)
same_drive = ctx.get('same_drive', False)
def _fmt(b):
if b >= 1024 ** 3:
return f"{b / 1024 ** 3:.1f} GB"
if b >= 1024 ** 2:
return f"{b / 1024 ** 2:.1f} MB"
return f"{b} bytes" if b else "unknown"
if same_drive:
space_lines = (
f"Downloads and install are on the same drive.\n"
f"Archives require: {_fmt(archive_bytes)}\n"
f"Installed files require: {_fmt(install_bytes)}"
)
else:
space_lines = (
f"Download space required: {_fmt(archive_bytes)}\n"
f"Install space required: {_fmt(install_bytes)}"
)
body = (
f"The disk space check reports that there may not be enough free space to complete "
f"this installation.\n\n"
f"{space_lines}\n\n"
f"If this is a modlist update, the actual space needed is likely far less — most files "
f"are already present and will be reused rather than re-downloaded.\n\n"
f"You can continue and free up space while downloads are running, "
f"or cancel to resolve the space issue first."
)
dlg = QMessageBox(self)
dlg.setWindowTitle("Disk Space Warning")
dlg.setText("Not enough free disk space detected.")
dlg.setInformativeText(body)
dlg.setIcon(QMessageBox.Warning)
continue_btn = dlg.addButton("Continue Anyway", QMessageBox.AcceptRole)
dlg.addButton("Cancel", QMessageBox.RejectRole)
dlg.setDefaultButton(continue_btn)
dlg.exec()
if dlg.clickedButton() is not continue_btn:
return False
thread = getattr(self, 'install_thread', None)
if not thread:
return False
modlist = getattr(thread, 'modlist', None)
install_dir = getattr(thread, 'install_dir', None)
downloads_dir = getattr(thread, 'downloads_dir', None)
api_key = getattr(thread, 'api_key', None)
install_mode = getattr(thread, 'install_mode', 'online')
oauth_info = getattr(thread, 'oauth_info', None)
if not (modlist and install_dir and downloads_dir and api_key):
return False
logger.info("Pre-flight filesystem check bypassed by user — restarting with --skip-disk-check")
self._safe_append_text("\n[WARN] Filesystem check bypassed. Continuing installation...\n")
self.run_modlist_installer(
modlist, install_dir, downloads_dir, api_key,
install_mode, oauth_info, skip_disk_check=True,
)
return True
def process_finished(self, exit_code, exit_status):
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
# Reset button states
@@ -600,6 +496,14 @@ class ProgressHandlersMixin:
self._safe_append_text(
f"Update mode: reusing existing Steam shortcut AppID {self._existing_shortcut_appid}."
)
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
_game_type = self._detect_game_type_from_mo2_ini(install_dir)
ModlistHandler().set_steam_grid_images(
str(self._existing_shortcut_appid), install_dir, game_type=_game_type
)
except Exception as _e:
logger.warning("Failed to apply Steam artwork on update install: %s", _e)
self.continue_configuration_after_automated_prefix(
self._existing_shortcut_appid,
modlist_name,

View File

@@ -1,6 +1,7 @@
"""Modlist selection methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtWidgets import QFileDialog, QMessageBox, QApplication, QDialog
from PySide6.QtWidgets import QMessageBox, QApplication, QDialog
from jackify.frontends.gui.utils import browse_directory, browse_file
from PySide6.QtCore import QTimer, Qt
import logging
import os
@@ -38,6 +39,9 @@ class ModlistSelectionMixin:
"Starfield": "starfield",
"Oblivion Remastered": "oblivion_remastered",
"Enderal": "enderal",
"Skyrim VR": "skyrimvr",
"Fallout 4 VR": "fallout4vr",
"Baldur's Gate 3": "bg3",
"Other": "other"
}
cli_game_type = game_type_map.get(game_type, "other")
@@ -139,6 +143,9 @@ class ModlistSelectionMixin:
"Starfield": "Starfield",
"Oblivion Remastered": "Oblivion",
"Enderal": "Enderal Special Edition",
"Skyrim VR": "Skyrim VR",
"Fallout 4 VR": "Fallout 4 VR",
"Baldur's Gate 3": "Baldur's Gate 3",
"Other": None
}
@@ -161,7 +168,8 @@ class ModlistSelectionMixin:
'game': metadata.gameHumanFriendly,
'description': metadata.description,
'nsfw': metadata.nsfw,
'force_down': metadata.forceDown
'force_down': metadata.forceDown,
'readme_url': metadata.links.readme if metadata.links else None,
}
self.modlist_name_edit.setText(metadata.title)
@@ -179,17 +187,17 @@ class ModlistSelectionMixin:
self.modlist_btn.setEnabled(True)
def browse_wabbajack_file(self):
file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)")
file = browse_file(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)")
if file:
self.file_edit.setText(os.path.realpath(file))
self.file_edit.setText(file)
def browse_install_dir(self):
dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text())
dir = browse_directory(self, "Select Install Directory", self.install_dir_edit.text())
if dir:
self.install_dir_edit.setText(os.path.realpath(dir))
self.install_dir_edit.setText(dir)
def browse_downloads_dir(self):
dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text())
dir = browse_directory(self, "Select Downloads Directory", self.downloads_dir_edit.text())
if dir:
self.downloads_dir_edit.setText(os.path.realpath(dir))
self.downloads_dir_edit.setText(dir)

View File

@@ -101,7 +101,7 @@ class TTWIntegrationMixin:
# Remember which screen to return to after TTW completes
self._ttw_return_screen_index = self.stacked_widget.currentIndex()
# Navigate first triggers lazy init and reset_screen_to_defaults.
# Navigate first - triggers lazy init and reset_screen_to_defaults.
# set_modlist_integration_mode must be called AFTER so it overwrites
# the default dir that reset_screen_to_defaults populates.
self.stacked_widget.setCurrentIndex(5)

View File

@@ -155,7 +155,7 @@ class InstallModlistUISetupMixin:
online_layout = QHBoxLayout()
online_layout.setContentsMargins(0, 0, 0, 0)
# --- Game Type Selection ---
self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Other"]
self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Skyrim VR", "Fallout 4 VR", "Baldur's Gate 3", "Other"]
self.game_type_btn = QPushButton("Please Select...")
self.game_type_btn.setMinimumWidth(200)
self.game_type_btn.clicked.connect(self.open_game_type_dialog)

View File

@@ -165,6 +165,8 @@ class InstallWorkflowExecutionMixin:
# Handle resolution saving
resolution = self.resolution_combo.currentText()
if resolution and resolution != "Leave unchanged":
raw_resolution = resolution.split(" (")[0] if " (" in resolution else resolution
self._current_resolution = raw_resolution
success = self.resolution_service.save_resolution(resolution)
if success:
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
@@ -185,9 +187,11 @@ class InstallWorkflowExecutionMixin:
game_type = None
game_name = None
readme_url = None
if install_mode == 'file':
# Parse .wabbajack file to get game type
wabbajack_path = Path(modlist)
readme_url = self.wabbajack_parser.parse_wabbajack_readme(wabbajack_path)
result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path)
if result:
if isinstance(result, tuple):
@@ -221,6 +225,7 @@ class InstallWorkflowExecutionMixin:
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"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
@@ -233,8 +238,13 @@ class InstallWorkflowExecutionMixin:
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion_remastered': 'oblivion_remastered',
'oblivion remastered': 'oblivion_remastered',
'enderal': 'enderal',
'enderal special edition': '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())
logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
@@ -257,12 +267,16 @@ class InstallWorkflowExecutionMixin:
if game_type and not is_supported:
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
# Show unsupported game dialog
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
dialog = UnsupportedGameDialog(self, game_name)
if not dialog.show_dialog(self, game_name):
self._abort_install_validation()
return
elif game_type in ('skyrimvr', 'fallout4vr'):
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
if not UnsupportedGameDialog.show_dialog(self, game_name, vr_warning=True):
self._abort_install_validation()
return
self.console.clear()
self.process_monitor.clear()
@@ -372,6 +386,20 @@ class InstallWorkflowExecutionMixin:
)
return
if readme_url:
import subprocess
if "raw.githubusercontent.com" in readme_url:
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}")
clean_env = {k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "LD_PRELOAD")}
subprocess.Popen(["xdg-open", readme_url], env=clean_env)
self._safe_append_text(
"Modlist readme opened in your browser. "
"Check it for any manual post-install steps before launching the game."
)
logger.debug(f'DEBUG: 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)
except Exception as e:
@@ -384,7 +412,7 @@ class InstallWorkflowExecutionMixin:
self.cancel_install_btn.setVisible(False)
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None, skip_disk_check=False):
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
# Rotate log file at start of each workflow run (keep 5 backups)
@@ -409,7 +437,6 @@ class InstallWorkflowExecutionMixin:
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
oauth_info=oauth_info, # Pass OAuth state for auto-refresh
skip_disk_check=skip_disk_check,
)
self.install_thread.output_received.connect(self.on_installation_output)
self.install_thread.progress_received.connect(self.on_installation_progress)
@@ -473,7 +500,7 @@ class InstallWorkflowExecutionMixin:
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"Opening download dialog - check your taskbar if it does not appear in front.\n"
)
logger.info(
f"[MDL-1006] Manual download protocol initialized | count={count} "

View File

@@ -6,7 +6,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayo
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl
from PySide6.QtGui import QPixmap, QTextCursor, QPainter, QFont
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import ansi_to_html, strip_ansi_control_codes, set_responsive_minimum
from ..utils import ansi_to_html, strip_ansi_control_codes, set_responsive_minimum, _get_sidebar_urls
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
import os
@@ -37,6 +37,7 @@ from .install_ttw_workflow import TTWWorkflowMixin
from .install_ttw_output import TTWOutputMixin
from .install_ttw_ui import TTWUIMixin
from .screen_back_mixin import ScreenBackMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class ModlistFetchThread(QThread):
result = Signal(list, str)
@@ -77,7 +78,7 @@ class ModlistFetchThread(QThread):
# Don't write to log file before workflow starts - just return error
self.result.emit([], error_msg)
class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWWorkflowMixin, TTWOutputMixin, TTWUIMixin):
class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWWorkflowMixin, TTWOutputMixin, TTWUIMixin):
resize_request = Signal(str)
integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version)
@@ -151,24 +152,24 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
pass
def browse_wabbajack_file(self):
# Use QFileDialog instance to ensure consistent dialog style
start_path = self.file_edit.text() if self.file_edit.text() else os.path.expanduser("~")
dialog = QFileDialog(self, "Select TTW .mpi File")
dialog.setFileMode(QFileDialog.ExistingFile)
dialog.setNameFilter("MPI Files (*.mpi);;All Files (*)")
dialog.setDirectory(start_path)
dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog for consistency
dialog.setOption(QFileDialog.DontUseNativeDialog, True)
dialog.setSidebarUrls(_get_sidebar_urls())
if dialog.exec() == QDialog.Accepted:
files = dialog.selectedFiles()
if files:
self.file_edit.setText(files[0])
def browse_install_dir(self):
# Use QFileDialog instance to match file browser style exactly
dialog = QFileDialog(self, "Select Install Directory")
dialog.setFileMode(QFileDialog.Directory)
dialog.setOption(QFileDialog.ShowDirsOnly, True)
dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog to match file browser
dialog.setOption(QFileDialog.DontUseNativeDialog, True)
dialog.setSidebarUrls(_get_sidebar_urls())
if self.install_dir_edit.text():
dialog.setDirectory(self.install_dir_edit.text())
if dialog.exec() == QDialog.Accepted:
@@ -302,28 +303,13 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
# install_thread uses cancel() for cooperative shutdown before terminate.
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
logger.debug("DEBUG: Cancelling running InstallationThread")
self.install_thread.cancel()
self.install_thread.wait(3000)
if self.install_thread.isRunning():
self.install_thread.terminate()
self.install_thread.wait(2000)
self.install_thread = None
# Disconnect all signals first - prevents callbacks to a dying widget.
self._park_all_threads()
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
if attr_name == 'install_thread':
continue
# 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():
try:
if isinstance(value, QThread) and value.isRunning():
logger.debug(f"DEBUG: Terminating {attr_name}")
value.terminate()
value.wait(2000)
setattr(self, attr_name, None)
self.install_thread.cancel()
except Exception:
pass
@@ -355,29 +341,13 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
font-size: 13px;
""")
# Cancel the installation thread if it exists
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
self.install_thread.cancel()
self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.install_thread.isRunning():
self.install_thread.terminate() # Force terminate if needed
self.install_thread.wait(1000)
# Cancel the automated prefix thread if it exists
if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning():
self.prefix_thread.terminate()
self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.prefix_thread.isRunning():
self.prefix_thread.terminate() # Force terminate if needed
self.prefix_thread.wait(1000)
# Cancel the configuration thread if it exists
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.config_thread.isRunning():
self.config_thread.terminate() # Force terminate if needed
self.config_thread.wait(1000)
# Park all threads first (disconnects signals), then send cooperative cancel.
self._park_all_threads()
if hasattr(self, 'install_thread') and self.install_thread:
try:
self.install_thread.cancel()
except Exception:
pass
# Cleanup any remaining processes
self.cleanup_processes()

View File

@@ -84,7 +84,7 @@ class TTWIntegrationMixin:
ttw_output_dir.rename(versioned_path)
ttw_output_dir = versioned_path
skip_copy = True
logger.debug("TTW already in mods dir skipping copy step")
logger.debug("TTW already in mods dir - skipping copy step")
# Create background thread for integration
class IntegrationThread(QThread):

View File

@@ -64,6 +64,7 @@ class MainMenu(QWidget):
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
# ("Third Party Tools", "third_party_tools", "Install and manage Sulfur's Linux-native modding tools"), # v0.7
("Exit Jackify", "exit_jackify", "Close the application"),
]
@@ -150,6 +151,8 @@ class MainMenu(QWidget):
self.stacked_widget.setCurrentIndex(2)
elif action_id == "additional_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(3)
elif action_id == "third_party_tools" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(10)
elif action_id == "return_main_menu":
pass
elif self.stacked_widget:

View File

@@ -248,6 +248,40 @@ class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixi
self.modlist_selected.emit(metadata)
self.accept()
def closeEvent(self, event):
"""Stop background threads before the dialog is destroyed."""
# Stop the loading dot animation timer first
timer = getattr(self, '_loading_dot_timer', None)
if timer is not None:
timer.stop()
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
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)
# Abort any pending image network requests
if hasattr(self, 'image_manager'):
try:
self.image_manager.network_manager.clearConnectionCache()
except Exception:
pass
super().closeEvent(event)
# Re-export for backward compatibility
__all__ = ['ImageManager', 'ModlistCard', 'ModlistDetailDialog', 'ModlistGalleryDialog']

View File

@@ -16,7 +16,7 @@ class FocusReclaimMixin:
"""
def _stop_focus_reclaim(self):
pass # No timer to stop single-shot, no state
pass # No timer to stop - single-shot, no state
def _start_focus_reclaim_retries(self):
QTimer.singleShot(500, self._focus_reclaim_tick)

View File

@@ -0,0 +1,478 @@
"""
Third Party Tools screen.
Lists independently-managed tools with install status, version info,
and Install / Update / Downgrade / Uninstall actions per tool.
Version checks run in a background thread so the screen loads instantly.
"""
import logging
from typing import Dict, Optional
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from jackify.backend.services.tool_registry import TOOL_DEFINITIONS, ToolRegistry, ToolStatus
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
from jackify.frontends.gui.services.message_service import MessageService
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
from jackify.frontends.gui.utils import set_responsive_minimum
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
_BTN_INSTALL = "#1a5fa8"
_BTN_UPDATE = "#2a6e2a"
_BTN_DOWNGRADE = "#7a5a00"
_BTN_UNINSTALL = "#6b2020"
_BTN_DISABLED = "#333"
_BADGE_NOT_INSTALLED = ("#555", "#ccc") # bg, fg
_BADGE_UP_TO_DATE = ("#1e4d1e", "#8fdc8f")
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
_BADGE_CHECKING = ("#333", "#888")
def _btn_style(colour: str, disabled: bool = False) -> str:
bg = _BTN_DISABLED if disabled else colour
return f"""
QPushButton {{
background-color: {bg};
color: {'#666' if disabled else 'white'};
border: none; border-radius: 4px;
font-size: 11px; font-weight: bold;
padding: 4px 8px;
}}
QPushButton:hover {{ background-color: {'#444' if disabled else bg}; }}
QPushButton:pressed {{ background-color: {bg}; }}
"""
# ---------------------------------------------------------------------------
# Background version-check thread
# ---------------------------------------------------------------------------
class _VersionCheckThread(QThread):
version_ready = Signal(str, str) # tool_id, latest_version_tag
def run(self):
registry = ToolRegistry()
for defn in TOOL_DEFINITIONS:
try:
tag = registry.check_latest_version(defn.tool_id)
if tag:
self.version_ready.emit(defn.tool_id, tag)
except Exception as e:
logger.debug("Version check failed for %s: %s", defn.tool_id, e)
# ---------------------------------------------------------------------------
# Background install/update/downgrade/uninstall thread
# ---------------------------------------------------------------------------
class _ToolActionThread(QThread):
finished_signal = Signal(str, bool, str) # tool_id, success, message
def __init__(self, tool_id: str, action: str):
super().__init__()
self._tool_id = tool_id
self._action = action
def run(self):
registry = ToolRegistry()
try:
if self._action == "install":
ok, msg = registry.install(self._tool_id)
elif self._action == "update":
ok, msg = registry.update(self._tool_id)
elif self._action == "downgrade":
ok, msg = registry.downgrade(self._tool_id)
elif self._action == "uninstall":
ok, msg = registry.uninstall(self._tool_id)
else:
ok, msg = False, f"Unknown action: {self._action}"
except Exception as e:
ok, msg = False, str(e)
self.finished_signal.emit(self._tool_id, ok, msg)
# ---------------------------------------------------------------------------
# Per-tool card widget
# ---------------------------------------------------------------------------
class _ToolCard(QFrame):
action_requested = Signal(str, str) # tool_id, action
def __init__(self, status: ToolStatus, parent=None):
super().__init__(parent)
self._tool_id = status.definition.tool_id
self._status = status
self._busy = False
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet("""
QFrame {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
}
""")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
outer = QHBoxLayout()
outer.setContentsMargins(14, 10, 14, 10)
outer.setSpacing(12)
# --- Left: name + description ---
info_col = QVBoxLayout()
info_col.setSpacing(2)
tier_tag = " [required]" if status.definition.tier == 1 else ""
name_label = QLabel(f"<b>{status.definition.display_name}</b>{tier_tag}")
name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
info_col.addWidget(name_label)
desc_label = QLabel(status.definition.description)
desc_label.setWordWrap(True)
desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;")
info_col.addWidget(desc_label)
info_widget = QWidget()
info_widget.setLayout(info_col)
info_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
info_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(info_widget, stretch=3)
# --- Centre: status badge + version ---
centre_col = QVBoxLayout()
centre_col.setSpacing(4)
centre_col.setAlignment(Qt.AlignCenter)
self._badge = QLabel()
self._badge.setAlignment(Qt.AlignCenter)
self._badge.setFixedWidth(130)
self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;")
centre_col.addWidget(self._badge, alignment=Qt.AlignCenter)
self._version_label = QLabel()
self._version_label.setAlignment(Qt.AlignCenter)
self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;")
centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter)
centre_widget = QWidget()
centre_widget.setLayout(centre_col)
centre_widget.setFixedWidth(150)
centre_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(centre_widget)
# --- Right: action buttons ---
btn_col = QVBoxLayout()
btn_col.setSpacing(4)
btn_col.setAlignment(Qt.AlignCenter)
self._btn_primary = QPushButton()
self._btn_primary.setFixedWidth(90)
self._btn_primary.clicked.connect(self._on_primary)
btn_col.addWidget(self._btn_primary)
self._btn_downgrade = QPushButton("Downgrade")
self._btn_downgrade.setFixedWidth(90)
self._btn_downgrade.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "downgrade"))
btn_col.addWidget(self._btn_downgrade)
self._btn_uninstall = QPushButton("Uninstall")
self._btn_uninstall.setFixedWidth(90)
self._btn_uninstall.clicked.connect(self._on_uninstall)
btn_col.addWidget(self._btn_uninstall)
btn_widget = QWidget()
btn_widget.setLayout(btn_col)
btn_widget.setFixedWidth(110)
btn_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(btn_widget)
self.setLayout(outer)
self._refresh_ui(status)
# ------------------------------------------------------------------
def _refresh_ui(self, status: ToolStatus):
self._status = status
installed = status.installed
update_avail = status.update_available
can_downgrade = status.can_downgrade
can_uninstall = status.definition.can_uninstall
# Badge
if not installed:
bg, fg = _BADGE_NOT_INSTALLED
badge_text = "Not Installed"
elif update_avail:
bg, fg = _BADGE_UPDATE_AVAIL
badge_text = "Update Available"
else:
bg, fg = _BADGE_UP_TO_DATE
badge_text = "Installed"
self._badge.setText(badge_text)
self._badge.setStyleSheet(
f"background-color: {bg}; color: {fg}; border-radius: 3px; "
f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
)
# Version line
installed_ver = status.installed_version or "-"
latest_ver = status.latest_version or "checking..."
if installed:
self._version_label.setText(f"Installed: {installed_ver}\nLatest: {latest_ver}")
else:
self._version_label.setText(f"Latest: {latest_ver}")
# Primary button
if not installed:
self._btn_primary.setText("Install")
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
self._btn_primary.setEnabled(True)
elif update_avail:
self._btn_primary.setText("Update")
self._btn_primary.setStyleSheet(_btn_style(_BTN_UPDATE))
self._btn_primary.setEnabled(True)
else:
self._btn_primary.setText("Reinstall")
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
self._btn_primary.setEnabled(True)
# Downgrade button
self._btn_downgrade.setStyleSheet(_btn_style(_BTN_DOWNGRADE, disabled=not can_downgrade))
self._btn_downgrade.setEnabled(can_downgrade and not self._busy)
# Uninstall button
self._btn_uninstall.setVisible(can_uninstall)
if can_uninstall:
self._btn_uninstall.setStyleSheet(_btn_style(_BTN_UNINSTALL, disabled=not installed))
self._btn_uninstall.setEnabled(installed and not self._busy)
if self._busy:
self._btn_primary.setEnabled(False)
self._btn_primary.setStyleSheet(_btn_style(_BTN_DISABLED, disabled=True))
def set_latest_version(self, tag: str):
self._status.latest_version = tag
if self._status.installed and self._status.installed_version:
installed = self._status.installed_version.lstrip("v")
latest = tag.lstrip("v")
self._status.update_available = latest != installed
self._refresh_ui(self._status)
def set_busy(self, busy: bool, label: Optional[str] = None):
self._busy = busy
if busy and label:
self._btn_primary.setText(label)
self._refresh_ui(self._status)
def mark_installed(self, version: str):
self._status.installed = True
self._status.installed_version = version
self._status.update_available = False
self._busy = False
self._refresh_ui(self._status)
def mark_uninstalled(self):
self._status.installed = False
self._status.installed_version = None
self._status.update_available = False
self._busy = False
self._refresh_ui(self._status)
# ------------------------------------------------------------------
def _on_primary(self):
if not self._status.installed:
self.action_requested.emit(self._tool_id, "install")
elif self._status.update_available:
self.action_requested.emit(self._tool_id, "update")
else:
self.action_requested.emit(self._tool_id, "install")
def _on_uninstall(self):
confirmed = MessageService.question(
self,
"Uninstall Tool",
f"Uninstall {self._status.definition.display_name}?\n\nThis will delete the installed files.",
)
if confirmed:
self.action_requested.emit(self._tool_id, "uninstall")
# ---------------------------------------------------------------------------
# Main screen
# ---------------------------------------------------------------------------
class ThirdPartyToolsScreen(ThreadLifecycleMixin, QWidget):
"""Third Party Tools management screen."""
def __init__(self, stacked_widget=None, main_menu_index: int = 0, parent=None):
super().__init__(parent)
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self._cards: Dict[str, _ToolCard] = {}
self._action_thread: Optional[_ToolActionThread] = None
self._version_thread: Optional[_VersionCheckThread] = None
self._setup_ui()
def _setup_ui(self):
root = QVBoxLayout()
root.setContentsMargins(30, 24, 30, 24)
root.setSpacing(0)
self.setLayout(root)
# Header
title = QLabel("<b>Third Party Tools</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
root.addWidget(title)
root.addSpacing(6)
desc = QLabel(
"Install and manage independently-updated tools used by Jackify workflows or run via MO2.\n"
"Tools marked [required] are needed by existing Jackify workflows."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #aaa; font-size: 12px;")
desc.setAlignment(Qt.AlignHCenter)
root.addWidget(desc)
root.addSpacing(10)
sep = QLabel()
sep.setFixedHeight(1)
sep.setStyleSheet("background: #444;")
root.addWidget(sep)
root.addSpacing(12)
# Scrollable tool list
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
list_widget = QWidget()
list_widget.setStyleSheet("background: transparent;")
self._list_layout = QVBoxLayout()
self._list_layout.setContentsMargins(0, 0, 0, 0)
self._list_layout.setSpacing(8)
list_widget.setLayout(self._list_layout)
registry = ToolRegistry()
for status in registry.get_all_statuses():
card = _ToolCard(status)
card.action_requested.connect(self._on_action)
self._cards[status.definition.tool_id] = card
self._list_layout.addWidget(card)
self._list_layout.addStretch()
scroll.setWidget(list_widget)
root.addWidget(scroll, stretch=1)
root.addSpacing(12)
# Back button
back_row = QHBoxLayout()
back_row.addStretch()
back_btn = QPushButton("Back to Main Menu")
back_btn.setFixedSize(160, 34)
back_btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568; color: white;
border: none; border-radius: 5px;
font-size: 12px; font-weight: bold;
}}
QPushButton:hover {{ background-color: #5a6578; }}
QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}
""")
back_btn.clicked.connect(self._go_back)
back_row.addWidget(back_btn)
back_row.addStretch()
root.addLayout(back_row)
# ------------------------------------------------------------------
# Version check on show
# ------------------------------------------------------------------
def showEvent(self, event):
super().showEvent(event)
try:
main_window = self.window()
if main_window:
set_responsive_minimum(main_window, min_width=960, min_height=520)
except Exception:
pass
self._start_version_check()
def _start_version_check(self):
if self._version_thread and self._version_thread.isRunning():
return
self._version_thread = _VersionCheckThread()
self._version_thread.version_ready.connect(self._on_version_ready)
self._version_thread.start()
def _on_version_ready(self, tool_id: str, tag: str):
card = self._cards.get(tool_id)
if card:
card.set_latest_version(tag)
# ------------------------------------------------------------------
# Action dispatch
# ------------------------------------------------------------------
def _on_action(self, tool_id: str, action: str):
if self._action_thread and self._action_thread.isRunning():
MessageService.information(self, "Busy", "Another operation is already running. Please wait.")
return
card = self._cards.get(tool_id)
if card:
label_map = {"install": "Installing...", "update": "Updating...",
"downgrade": "Downgrading...", "uninstall": "Removing..."}
card.set_busy(True, label_map.get(action, "Working..."))
self._action_thread = _ToolActionThread(tool_id, action)
self._action_thread.finished_signal.connect(self._on_action_finished)
self._action_thread.start()
def _on_action_finished(self, tool_id: str, success: bool, message: str):
self._action_thread = None
card = self._cards.get(tool_id)
if success:
registry = ToolRegistry()
status = registry.get_status(tool_id)
if status and status.installed and card:
card.mark_installed(status.installed_version or "")
if status.latest_version:
card.set_latest_version(status.latest_version)
elif card:
card.mark_uninstalled()
MessageService.information(self, "Done", message)
else:
if card:
card.set_busy(False)
MessageService.warning(self, "Failed", message)
# ------------------------------------------------------------------
def _go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup_processes(self):
self._park_all_threads()

View File

@@ -13,7 +13,7 @@ from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox,
QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox,
QMessageBox
)
from PySide6.QtCore import Qt, QThread, Signal, QSize
@@ -26,7 +26,7 @@ from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from ..services.message_service import MessageService
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
from ..utils import set_responsive_minimum
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
@@ -367,13 +367,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
def _browse_folder(self):
"""Browse for installation folder"""
folder = QFileDialog.getExistingDirectory(
self,
"Select Wabbajack Installation Folder",
str(Path.home()),
QFileDialog.ShowDirsOnly
)
folder = browse_directory(self, "Select Wabbajack Installation Folder", str(Path.home()))
if folder:
self.install_folder = Path(folder).resolve()
self.install_dir_edit.setText(str(self.install_folder))
@@ -626,6 +620,15 @@ 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"""

View File

@@ -401,7 +401,7 @@ class _ErrorDialog(QDialog):
self._detail_edit.hide()
layout.addWidget(self._detail_edit)
# OK button disabled for 3s to prevent accidental dismissal
# OK button - disabled for 3s to prevent accidental dismissal
buttons = QDialogButtonBox(QDialogButtonBox.Ok)
buttons.accepted.connect(self.accept)
layout.addWidget(buttons)

View File

@@ -14,6 +14,11 @@ from PySide6.QtWidgets import QMessageBox, QWidget
logger = logging.getLogger(__name__)
# Keep references to orphaned workers alive until they finish naturally.
# If cleanup() is called while a long-running worker (e.g. BSA decompression)
# is still going, dropping self._worker would let GC destroy a running QThread.
_ORPHANED_WORKERS: set = set()
class _VNVWorker(QThread):
"""Background thread for VNV automation."""
@@ -183,7 +188,7 @@ class VNVAutomationController(QObject):
)
return True
else:
# Nexus API unavailable can't auto-track the download.
# Nexus API unavailable - can't auto-track the download.
# Open the mod page so the user can get it manually and inform
# them where to place it so the worker finds it next time.
logger.warning("VNV non-premium: Nexus API query failed, cannot open download manager")
@@ -195,7 +200,7 @@ class VNVAutomationController(QObject):
from .message_service import MessageService
MessageService.information(
parent,
"VNV Tools Manual Download Required",
"VNV Tools - Manual Download Required",
"Jackify could not query the Nexus download URL(s) (check your Nexus login in Settings).\n\n"
"Your modlist has been installed successfully.\n\n"
"To complete VNV post-install setup, please:\n"
@@ -204,7 +209,7 @@ class VNVAutomationController(QObject):
"2. Download the BSA Decompressor package from:\n"
" nexusmods.com/newvegas/mods/65854\n\n"
f"3. Place the archive(s) in:\n {vnv_service.cache_dir}\n\n"
"4. Re-configure the modlist Jackify will detect the files automatically.",
"4. Re-configure the modlist - Jackify will detect the files automatically.",
)
return False
@@ -224,7 +229,7 @@ class VNVAutomationController(QObject):
return False
def _dispatch_worker_start(self):
"""Slot always runs on the main thread due to queued signal delivery."""
"""Slot - always runs on the main thread due to queued signal delivery."""
if self._pending_worker_start:
fn = self._pending_worker_start
self._pending_worker_start = None
@@ -254,7 +259,7 @@ class VNVAutomationController(QObject):
def _on_all_done(_completed, _skipped):
# _check_all_done() runs in the watcher background thread (Python
# threading.Thread no Qt event loop). QTimer.singleShot is
# threading.Thread - no Qt event loop). QTimer.singleShot is
# unreliable from non-Qt threads. Instead, emit a signal: because
# VNVAutomationController was created on the main thread, Qt uses a
# queued connection automatically and delivers the slot on the main thread.
@@ -397,6 +402,9 @@ class VNVAutomationController(QObject):
self._pending_worker_start = None
self._stop_manual_download_flow()
if self._worker and self._worker.isRunning():
self._worker.terminate()
self._worker.wait(2000)
self._worker = None
# Worker may still be running a long operation (BSA decompression etc).
# Park it in the global set so the reference outlives this controller.
worker = self._worker
_ORPHANED_WORKERS.add(worker)
worker.finished.connect(lambda w=worker: _ORPHANED_WORKERS.discard(w))
self._worker = None

View File

@@ -1,10 +1,11 @@
"""
GUI Utilities for Jackify Frontend
"""
import os
import re
from typing import Tuple, Optional
from PySide6.QtWidgets import QApplication, QWidget
from PySide6.QtCore import QSize, QPoint
from typing import Tuple, Optional, List
from PySide6.QtWidgets import QApplication, QWidget, QFileDialog
from PySide6.QtCore import QSize, QPoint, QUrl
ANSI_COLOR_MAP = {
'30': 'black', '31': 'red', '32': 'green', '33': 'yellow', '34': 'blue', '35': 'magenta', '36': 'cyan', '37': 'white',
@@ -317,8 +318,53 @@ def apply_window_size_and_position(
width, height = calculate_window_size(
window, width_ratio, height_ratio, min_width, min_height, max_width, max_height
)
# Calculate and set position
pos = calculate_window_position(window, width, height, parent)
window.resize(width, height)
window.move(pos)
def _get_sidebar_urls() -> List[QUrl]:
"""Return QUrl list for home dir plus any mounted volumes under /run/media/."""
urls = [QUrl.fromLocalFile(os.path.expanduser("~"))]
run_media = "/run/media"
if os.path.isdir(run_media):
try:
for entry in os.scandir(run_media):
if entry.is_dir():
# /run/media/<user>/<volume> - add the user subdir and all volumes
try:
for vol in os.scandir(entry.path):
if vol.is_dir():
urls.append(QUrl.fromLocalFile(vol.path))
except PermissionError:
urls.append(QUrl.fromLocalFile(entry.path))
except PermissionError:
pass
return urls
def browse_directory(parent: QWidget, title: str, start_path: str = "") -> str:
"""Open a directory browser dialog with SD card sidebar entries on Steam Deck."""
dialog = QFileDialog(parent, title, start_path or os.path.expanduser("~"))
dialog.setFileMode(QFileDialog.Directory)
dialog.setOption(QFileDialog.ShowDirsOnly, True)
dialog.setSidebarUrls(_get_sidebar_urls())
if dialog.exec():
selected = dialog.selectedFiles()
return os.path.realpath(selected[0]) if selected else ""
return ""
def browse_file(parent: QWidget, title: str, start_path: str = "", file_filter: str = "") -> str:
"""Open a file browser dialog with SD card sidebar entries on Steam Deck."""
dialog = QFileDialog(parent, title, start_path or os.path.expanduser("~"))
dialog.setFileMode(QFileDialog.ExistingFile)
if file_filter:
dialog.setNameFilter(file_filter)
dialog.setSidebarUrls(_get_sidebar_urls())
if dialog.exec():
selected = dialog.selectedFiles()
return os.path.realpath(selected[0]) if selected else ""
return ""

View File

@@ -149,7 +149,7 @@ class FileProgressItem(QWidget):
def _set_indeterminate(self):
if not self._is_indeterminate:
self._is_indeterminate = True
# Qt's QProgressStyleAnimation drives this automatically no manual timer needed
# Qt's QProgressStyleAnimation drives this automatically - no manual timer needed
self.progress_bar.setRange(0, 0)
self.percent_label.setText("")

View File

@@ -25,7 +25,7 @@ __all__ = ['SummaryProgressWidget', 'FileProgressItem', 'FileProgressList']
class _CpuWorker(QThread):
"""Background worker for CPU usage sampling keeps psutil off the main thread."""
"""Background worker for CPU usage sampling - keeps psutil off the main thread."""
result = Signal(str)
caches_updated = Signal(object, object, float) # process_cache, child_cache, smoothed_pct
@@ -53,7 +53,7 @@ class _CpuWorker(QThread):
try:
current_child_pids.add(child.pid)
if child.pid not in self._child_cache:
# Baseline in background no longer blocks main thread
# Baseline in background - no longer blocks main thread
child.cpu_percent(interval=0.1)
self._child_cache[child.pid] = child
continue
@@ -173,7 +173,7 @@ class FileProgressList(QWidget):
self._last_update_time = 0.0
# CPU usage tracking worker thread to avoid blocking the main thread
# CPU usage tracking - worker thread to avoid blocking the main thread
self._cpu_timer = QTimer(self)
self._cpu_timer.timeout.connect(self._start_cpu_worker)
self._cpu_timer.setInterval(2000)
@@ -342,9 +342,7 @@ class FileProgressList(QWidget):
self._cpu_timer.stop()
if self._cpu_worker and self._cpu_worker.isRunning():
self._cpu_worker.quit()
if not self._cpu_worker.wait(500):
self._cpu_worker.terminate()
self._cpu_worker.wait(1000)
self._cpu_worker.wait(1000)
self._cpu_worker = None
def _start_cpu_worker(self):

View File

@@ -24,15 +24,16 @@ class UnsupportedGameDialog(QDialog):
# Signal emitted when user clicks OK to continue
continue_installation = Signal()
def __init__(self, parent=None, game_name: str = None):
def __init__(self, parent=None, game_name: str = None, vr_warning: bool = False):
super().__init__(parent)
self.game_name = game_name
self.vr_warning = vr_warning
self.setup_ui()
self.setup_connections()
def setup_ui(self):
"""Set up the dialog UI."""
self.setWindowTitle("Game Support Notice")
self.setWindowTitle("VR Platform Notice" if self.vr_warning else "Game Support Notice")
self.setModal(True)
self.setFixedSize(500, 500)
@@ -49,7 +50,7 @@ class UnsupportedGameDialog(QDialog):
icon_label.setFixedSize(32, 32)
icon_label.setStyleSheet("color: #e67e22;")
title_layout.addWidget(icon_label)
title_label = QLabel("<b>Game Support Notice</b>")
title_label = QLabel("<b>VR Platform Notice</b>" if self.vr_warning else "<b>Game Support Notice</b>")
title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
title_label.setStyleSheet("color: #3fd0ea;")
title_layout.addWidget(title_label)
@@ -82,7 +83,24 @@ class UnsupportedGameDialog(QDialog):
""")
# Create the message content
if self.game_name:
if self.vr_warning:
game_label = self.game_name or "a VR modlist"
message = f"""<p><strong>You are about to install {game_label}.</strong></p>
<p>Jackify will handle the download, Wine prefix setup, and Steam shortcut creation as normal. However, getting VR modlists running on Linux involves platform dependencies that are outside Jackify's control:</p>
<ul>
<li>SteamVR must be installed and working with your headset before launching</li>
<li>Your VR runtime (SteamVR, ALVR, WiVRn, etc.) must be configured separately</li>
<li>Some modlists may require additional manual steps documented by the list author</li>
</ul>
<p>Jackify's VR support is <strong>best effort</strong>. The install and configuration will proceed normally, but whether the modlist runs correctly depends heavily on your VR platform setup.</p>
<p><strong>Always consult your modlist's installation guide</strong> for any additional manual steps required after Jackify completes.</p>
<p>Click <strong>Continue</strong> to proceed, or <strong>Cancel</strong> to go back.</p>"""
elif self.game_name:
message = f"""<p><strong>You are about to install a modlist for <em>{self.game_name}</em>.</strong></p>
<p>While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to:</p>
@@ -187,17 +205,18 @@ class UnsupportedGameDialog(QDialog):
self.accepted.connect(self.continue_installation.emit)
@staticmethod
def show_dialog(parent=None, game_name: str = None) -> bool:
def show_dialog(parent=None, game_name: str = None, vr_warning: bool = False) -> bool:
"""
Show the unsupported game dialog and return the user's choice.
Show the dialog and return the user's choice.
Args:
parent: Parent widget
game_name: Name of the unsupported game (optional)
game_name: Name of the game (optional)
vr_warning: Show VR best-effort warning instead of unsupported game notice
Returns:
True if user clicked Continue, False if Cancel
"""
dialog = UnsupportedGameDialog(parent, game_name)
dialog = UnsupportedGameDialog(parent, game_name, vr_warning=vr_warning)
result = dialog.exec()
return result == QDialog.DialogCode.Accepted