mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:07:45 +02:00
Release v0.6.0
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
98
jackify/frontends/gui/mixins/thread_lifecycle_mixin.py
Normal file
98
jackify/frontends/gui/mixins/thread_lifecycle_mixin.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
442
jackify/frontends/gui/screens/configure_tool_config_screen.py
Normal file
442
jackify/frontends/gui/screens/configure_tool_config_screen.py
Normal 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>"
|
||||
" xEdit family | Synthesis | 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} "
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
478
jackify/frontends/gui/screens/third_party_tools.py
Normal file
478
jackify/frontends/gui/screens/third_party_tools.py
Normal 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()
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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("")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user