Sync from development - prepare for v0.1.2

This commit is contained in:
Omni
2025-09-18 08:18:59 +01:00
parent 70b18004e1
commit 1cd4caf04b
61 changed files with 1349 additions and 503 deletions

View File

@@ -0,0 +1,400 @@
"""
About dialog for Jackify.
This dialog displays system information, version details, and provides
access to update checking and external links.
"""
import logging
import os
import platform
import subprocess
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QGroupBox, QTextEdit, QApplication
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QFont, QClipboard
from ....backend.services.update_service import UpdateService
from ....backend.models.configuration import SystemInfo
from .... import __version__
logger = logging.getLogger(__name__)
class UpdateCheckThread(QThread):
"""Background thread for checking updates."""
update_check_finished = Signal(object) # UpdateInfo or None
def __init__(self, update_service: UpdateService):
super().__init__()
self.update_service = update_service
def run(self):
"""Check for updates in background."""
try:
update_info = self.update_service.check_for_updates()
self.update_check_finished.emit(update_info)
except Exception as e:
logger.error(f"Error checking for updates: {e}")
self.update_check_finished.emit(None)
class AboutDialog(QDialog):
"""About dialog showing system info and app details."""
def __init__(self, system_info: SystemInfo, parent=None):
super().__init__(parent)
self.system_info = system_info
self.update_service = UpdateService(__version__)
self.update_check_thread = None
self.setup_ui()
self.setup_connections()
def setup_ui(self):
"""Set up the dialog UI."""
self.setWindowTitle("About Jackify")
self.setModal(True)
self.setFixedSize(520, 520)
layout = QVBoxLayout(self)
# Header
header_layout = QVBoxLayout()
# App icon/name
title_label = QLabel("Jackify")
title_font = QFont()
title_font.setPointSize(18)
title_font.setBold(True)
title_label.setFont(title_font)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("color: #3fd0ea; margin: 10px;")
header_layout.addWidget(title_label)
subtitle_label = QLabel(f"v{__version__}")
subtitle_font = QFont()
subtitle_font.setPointSize(12)
subtitle_label.setFont(subtitle_font)
subtitle_label.setAlignment(Qt.AlignCenter)
subtitle_label.setStyleSheet("color: #666; margin-bottom: 10px;")
header_layout.addWidget(subtitle_label)
tagline_label = QLabel("Simplifying Wabbajack modlist installation and configuration on Linux")
tagline_label.setAlignment(Qt.AlignCenter)
tagline_label.setStyleSheet("color: #888; margin-bottom: 20px;")
header_layout.addWidget(tagline_label)
layout.addLayout(header_layout)
# System Information Group
system_group = QGroupBox("System Information")
system_layout = QVBoxLayout(system_group)
system_info_text = self._get_system_info_text()
system_info_label = QLabel(system_info_text)
system_info_label.setStyleSheet("font-family: monospace; font-size: 10pt; color: #ccc;")
system_info_label.setWordWrap(True)
system_layout.addWidget(system_info_label)
layout.addWidget(system_group)
# Jackify Information Group
jackify_group = QGroupBox("Jackify Information")
jackify_layout = QVBoxLayout(jackify_group)
jackify_info_text = self._get_jackify_info_text()
jackify_info_label = QLabel(jackify_info_text)
jackify_info_label.setStyleSheet("font-family: monospace; font-size: 10pt; color: #ccc;")
jackify_layout.addWidget(jackify_info_label)
layout.addWidget(jackify_group)
# Update status
self.update_status_label = QLabel("")
self.update_status_label.setStyleSheet("color: #666; font-size: 10pt; margin: 5px;")
self.update_status_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.update_status_label)
# Buttons
button_layout = QHBoxLayout()
# Update check button
self.update_button = QPushButton("Check for Updates")
self.update_button.clicked.connect(self.check_for_updates)
self.update_button.setStyleSheet("""
QPushButton {
background-color: #23272e;
color: #3fd0ea;
font-weight: bold;
padding: 8px 16px;
border-radius: 4px;
border: 2px solid #3fd0ea;
}
QPushButton:hover {
background-color: #3fd0ea;
color: #23272e;
}
QPushButton:pressed {
background-color: #2bb8d6;
color: #23272e;
}
QPushButton:disabled {
background-color: #444;
color: #666;
border-color: #666;
}
""")
button_layout.addWidget(self.update_button)
button_layout.addStretch()
# Copy Info button
copy_button = QPushButton("Copy Info")
copy_button.clicked.connect(self.copy_system_info)
button_layout.addWidget(copy_button)
# External links
github_button = QPushButton("GitHub")
github_button.clicked.connect(self.open_github)
button_layout.addWidget(github_button)
nexus_button = QPushButton("Nexus")
nexus_button.clicked.connect(self.open_nexus)
button_layout.addWidget(nexus_button)
layout.addLayout(button_layout)
# Close button
close_layout = QHBoxLayout()
close_layout.addStretch()
close_button = QPushButton("Close")
close_button.setDefault(True)
close_button.clicked.connect(self.accept)
close_layout.addWidget(close_button)
layout.addLayout(close_layout)
def setup_connections(self):
"""Set up signal connections."""
pass
def _get_system_info_text(self) -> str:
"""Get formatted system information."""
try:
# OS info
os_info = self._get_os_info()
kernel = platform.release()
# Desktop environment
desktop = self._get_desktop_environment()
# Display server
display_server = self._get_display_server()
return f"• OS: {os_info}\n• Kernel: {kernel}\n• Desktop: {desktop}\n• Display: {display_server}"
except Exception as e:
logger.error(f"Error getting system info: {e}")
return "• System info unavailable"
def _get_jackify_info_text(self) -> str:
"""Get formatted Jackify information."""
try:
# Engine version
engine_version = self._get_engine_version()
# Python version
python_version = platform.python_version()
return f"• Engine: {engine_version}\n• Python: {python_version}"
except Exception as e:
logger.error(f"Error getting Jackify info: {e}")
return "• Jackify info unavailable"
def _get_os_info(self) -> str:
"""Get OS distribution name and version."""
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
lines = f.readlines()
pretty_name = None
name = None
version = None
for line in lines:
line = line.strip()
if line.startswith("PRETTY_NAME="):
pretty_name = line.split("=", 1)[1].strip('"')
elif line.startswith("NAME="):
name = line.split("=", 1)[1].strip('"')
elif line.startswith("VERSION="):
version = line.split("=", 1)[1].strip('"')
# Prefer PRETTY_NAME, fallback to NAME + VERSION
if pretty_name:
return pretty_name
elif name and version:
return f"{name} {version}"
elif name:
return name
# Fallback to platform info
return f"{platform.system()} {platform.release()}"
except Exception as e:
logger.error(f"Error getting OS info: {e}")
return "Unknown Linux"
def _get_desktop_environment(self) -> str:
"""Get desktop environment."""
try:
# Try XDG_CURRENT_DESKTOP first
desktop = os.environ.get("XDG_CURRENT_DESKTOP")
if desktop:
return desktop
# Fallback to DESKTOP_SESSION
desktop = os.environ.get("DESKTOP_SESSION")
if desktop:
return desktop
# Try detecting common DEs
if os.environ.get("KDE_FULL_SESSION"):
return "KDE"
elif os.environ.get("GNOME_DESKTOP_SESSION_ID"):
return "GNOME"
elif os.environ.get("XFCE4_SESSION"):
return "XFCE"
return "Unknown"
except Exception as e:
logger.error(f"Error getting desktop environment: {e}")
return "Unknown"
def _get_display_server(self) -> str:
"""Get display server type (Wayland or X11)."""
try:
# Check XDG_SESSION_TYPE first
session_type = os.environ.get("XDG_SESSION_TYPE")
if session_type:
return session_type.capitalize()
# Check for Wayland display
if os.environ.get("WAYLAND_DISPLAY"):
return "Wayland"
# Check for X11 display
if os.environ.get("DISPLAY"):
return "X11"
return "Unknown"
except Exception as e:
logger.error(f"Error getting display server: {e}")
return "Unknown"
def _get_engine_version(self) -> str:
"""Get jackify-engine version."""
try:
# Try to execute jackify-engine --version
engine_path = Path(__file__).parent.parent.parent.parent / "engine" / "jackify-engine"
if engine_path.exists():
result = subprocess.run([str(engine_path), "--version"],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
version = result.stdout.strip()
# Extract just the version number (before the +commit hash)
if '+' in version:
version = version.split('+')[0]
return f"v{version}"
return "Unknown"
except Exception as e:
logger.error(f"Error getting engine version: {e}")
return "Unknown"
def check_for_updates(self):
"""Check for updates in background."""
if self.update_check_thread and self.update_check_thread.isRunning():
return
self.update_button.setEnabled(False)
self.update_button.setText("Checking...")
self.update_status_label.setText("Checking for updates...")
self.update_check_thread = UpdateCheckThread(self.update_service)
self.update_check_thread.update_check_finished.connect(self.update_check_finished)
self.update_check_thread.start()
def update_check_finished(self, update_info):
"""Handle update check completion."""
self.update_button.setEnabled(True)
self.update_button.setText("Check for Updates")
if update_info:
self.update_status_label.setText(f"Update available: v{update_info.version}")
self.update_status_label.setStyleSheet("color: #3fd0ea; font-size: 10pt; margin: 5px;")
# Show update dialog
from .update_dialog import UpdateDialog
update_dialog = UpdateDialog(update_info, self.update_service, self)
update_dialog.exec()
else:
self.update_status_label.setText("You're running the latest version")
self.update_status_label.setStyleSheet("color: #666; font-size: 10pt; margin: 5px;")
def copy_system_info(self):
"""Copy system information to clipboard."""
try:
info_text = f"""Jackify v{__version__} (Engine {self._get_engine_version()})
OS: {self._get_os_info()} ({platform.release()})
Desktop: {self._get_desktop_environment()} ({self._get_display_server()})
Python: {platform.python_version()}"""
clipboard = QApplication.clipboard()
clipboard.setText(info_text)
# Briefly update button text
sender = self.sender()
original_text = sender.text()
sender.setText("Copied!")
# Reset button text after delay
from PySide6.QtCore import QTimer
QTimer.singleShot(1000, lambda: sender.setText(original_text))
except Exception as e:
logger.error(f"Error copying system info: {e}")
def open_github(self):
"""Open GitHub repository."""
try:
import webbrowser
webbrowser.open("https://github.com/Omni-guides/Jackify")
except Exception as e:
logger.error(f"Error opening GitHub: {e}")
def open_nexus(self):
"""Open Nexus Mods page."""
try:
import webbrowser
webbrowser.open("https://www.nexusmods.com/site/mods/1427")
except Exception as e:
logger.error(f"Error opening Nexus: {e}")
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()
event.accept()

View File

@@ -325,6 +325,29 @@ class SettingsDialog(QDialog):
download_dir_row.addWidget(self.download_dir_edit)
download_dir_row.addWidget(self.download_dir_btn)
dir_layout.addRow(QLabel("Downloads Base Dir:"), download_dir_row)
# Jackify Data Directory
from jackify.shared.paths import get_jackify_data_dir
current_jackify_dir = str(get_jackify_data_dir())
self.jackify_data_dir_edit = QLineEdit(current_jackify_dir)
self.jackify_data_dir_edit.setToolTip("Directory for Jackify data (logs, downloads, temp files). Default: ~/Jackify")
self.jackify_data_dir_btn = QPushButton()
self.jackify_data_dir_btn.setIcon(QIcon.fromTheme("folder-open"))
self.jackify_data_dir_btn.setToolTip("Browse for directory")
self.jackify_data_dir_btn.setFixedWidth(32)
self.jackify_data_dir_btn.clicked.connect(lambda: self._pick_directory(self.jackify_data_dir_edit))
jackify_data_dir_row = QHBoxLayout()
jackify_data_dir_row.addWidget(self.jackify_data_dir_edit)
jackify_data_dir_row.addWidget(self.jackify_data_dir_btn)
# Reset to default button
reset_jackify_dir_btn = QPushButton("Reset")
reset_jackify_dir_btn.setToolTip("Reset to default (~/ Jackify)")
reset_jackify_dir_btn.setFixedWidth(50)
reset_jackify_dir_btn.clicked.connect(lambda: self.jackify_data_dir_edit.setText(str(Path.home() / "Jackify")))
jackify_data_dir_row.addWidget(reset_jackify_dir_btn)
dir_layout.addRow(QLabel("Jackify Data Dir:"), jackify_data_dir_row)
main_layout.addWidget(dir_group)
main_layout.addSpacing(12)
@@ -464,7 +487,14 @@ class SettingsDialog(QDialog):
# Save modlist base dirs
self.config_handler.set("modlist_install_base_dir", self.install_dir_edit.text().strip())
self.config_handler.set("modlist_downloads_base_dir", self.download_dir_edit.text().strip())
# Save jackify data directory (always store actual path, never None)
jackify_data_dir = self.jackify_data_dir_edit.text().strip()
self.config_handler.set("jackify_data_dir", jackify_data_dir)
self.config_handler.save_config()
# Refresh cached paths in GUI screens if Jackify directory changed
self._refresh_gui_paths()
# Check if debug mode changed and prompt for restart
new_debug_mode = self.debug_checkbox.isChecked()
if new_debug_mode != self._original_debug_mode:
@@ -484,6 +514,29 @@ class SettingsDialog(QDialog):
MessageService.information(self, "Settings Saved", "Settings have been saved successfully.", safety_level="low")
self.accept()
def _refresh_gui_paths(self):
"""Refresh cached paths in all GUI screens."""
try:
# Get the main window through parent relationship
main_window = self.parent()
if not main_window or not hasattr(main_window, 'stacked_widget'):
return
# Refresh paths in all screens that have the method
screens_to_refresh = [
getattr(main_window, 'install_modlist_screen', None),
getattr(main_window, 'configure_new_modlist_screen', None),
getattr(main_window, 'configure_existing_modlist_screen', None),
getattr(main_window, 'tuxborn_screen', None),
]
for screen in screens_to_refresh:
if screen and hasattr(screen, 'refresh_paths'):
screen.refresh_paths()
except Exception as e:
print(f"Warning: Could not refresh GUI paths: {e}")
def _bold_label(self, text):
label = QLabel(text)
label.setStyleSheet("font-weight: bold; color: #fff;")
@@ -655,7 +708,7 @@ class JackifyMainWindow(QMainWindow):
# Spacer
bottom_bar_layout.addStretch(1)
# Settings button (right)
# Settings button (right side)
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
@@ -663,6 +716,14 @@ class JackifyMainWindow(QMainWindow):
settings_btn.linkActivated.connect(self.open_settings_dialog)
bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
# About button (right side)
about_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">About</a>')
about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
about_btn.setOpenExternalLinks(False)
about_btn.linkActivated.connect(self.open_about_dialog)
bottom_bar_layout.addWidget(about_btn, alignment=Qt.AlignRight)
# --- Main Layout ---
central_widget = QWidget()
main_layout = QVBoxLayout()
@@ -808,6 +869,16 @@ class JackifyMainWindow(QMainWindow):
import traceback
traceback.print_exc()
def open_about_dialog(self):
try:
from jackify.frontends.gui.dialogs.about_dialog import AboutDialog
dlg = AboutDialog(self.system_info, self)
dlg.exec()
except Exception as e:
print(f"[ERROR] Exception in open_about_dialog: {e}")
import traceback
traceback.print_exc()
def resource_path(relative_path):
if hasattr(sys, '_MEIPASS'):

View File

@@ -34,8 +34,7 @@ class ConfigureExistingModlistScreen(QWidget):
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.debug = DEBUG_BORDERS
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Configure_Existing_Modlist_workflow.log')
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
self.refresh_paths()
# --- Detect Steam Deck ---
steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower()
@@ -297,6 +296,41 @@ class ConfigureExistingModlistScreen(QWidget):
# Time tracking for workflow completion
self._workflow_start_time = None
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
self._actionable_controls = [
# Main action button
self.start_btn,
# Form fields
self.shortcut_combo,
# Resolution controls
self.resolution_combo,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during configure operations (except Cancel)"""
for control in self._actionable_controls:
if control:
control.setEnabled(False)
def _enable_controls_after_operation(self):
"""Re-enable all actionable controls after configure operations complete"""
for control in self._actionable_controls:
if control:
control.setEnabled(True)
def refresh_paths(self):
"""Refresh cached paths when config changes."""
from jackify.shared.paths import get_jackify_logs_dir
self.modlist_log_path = get_jackify_logs_dir() / 'Configure_Existing_Modlist_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def resizeEvent(self, event):
"""Handle window resize to prioritize form over console"""
@@ -382,17 +416,22 @@ class ConfigureExistingModlistScreen(QWidget):
log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
# Disable controls during configuration
self._disable_controls_during_operation()
# Get selected shortcut
idx = self.shortcut_combo.currentIndex() - 1 # Account for 'Please Select...'
from jackify.frontends.gui.services.message_service import MessageService
if idx < 0 or idx >= len(self.shortcut_map):
MessageService.critical(self, "No Shortcut Selected", "Please select a ModOrganizer.exe Steam shortcut to configure.", safety_level="medium")
self._enable_controls_after_operation()
return
shortcut = self.shortcut_map[idx]
modlist_name = shortcut.get('AppName', '')
install_dir = shortcut.get('StartDir', '')
if not modlist_name or not install_dir:
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
self._enable_controls_after_operation()
return
resolution = self.resolution_combo.currentText()
# Handle resolution saving
@@ -505,6 +544,9 @@ class ConfigureExistingModlistScreen(QWidget):
def on_configuration_complete(self, success, message, modlist_name):
"""Handle configuration completion"""
# Re-enable all controls when workflow completes
self._enable_controls_after_operation()
if success:
# Calculate time taken
time_taken = self._calculate_time_taken()
@@ -525,6 +567,9 @@ class ConfigureExistingModlistScreen(QWidget):
def on_configuration_error(self, error_message):
"""Handle configuration error"""
# Re-enable all controls on error
self._enable_controls_after_operation()
self._safe_append_text(f"Configuration error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")
@@ -559,8 +604,8 @@ class ConfigureExistingModlistScreen(QWidget):
if self.config_process and self.config_process.state() == QProcess.Running:
self.config_process.terminate()
self.config_process.waitForFinished(2000)
# Reset button states
self.start_btn.setEnabled(True)
# Re-enable all controls
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
def show_next_steps_dialog(self, message):

View File

@@ -1,7 +1,7 @@
"""
ConfigureNewModlistScreen for Jackify GUI
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
from PySide6.QtGui import QPixmap, QTextCursor
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
@@ -106,8 +106,7 @@ class ConfigureNewModlistScreen(QWidget):
self.protontricks_service = ProtontricksDetectionService()
# Path for workflow log
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Configure_New_Modlist_workflow.log')
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
self.refresh_paths()
# Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False
@@ -211,7 +210,6 @@ class ConfigureNewModlistScreen(QWidget):
"7680x4320"
])
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.resolution_combo, 2, 1)
# Load saved resolution if available
saved_resolution = self.resolution_service.get_saved_resolution()
@@ -236,6 +234,27 @@ class ConfigureNewModlistScreen(QWidget):
else:
self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0)
# Horizontal layout for resolution dropdown and auto-restart checkbox
resolution_and_restart_layout = QHBoxLayout()
resolution_and_restart_layout.setSpacing(12)
# Resolution dropdown (made smaller)
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
resolution_and_restart_layout.addWidget(self.resolution_combo)
# Add stretch to push checkbox to the right
resolution_and_restart_layout.addStretch()
# Auto-accept Steam restart checkbox (right-aligned)
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended configuration")
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
# Update the form grid to use the combined layout
form_grid.addLayout(resolution_and_restart_layout, 2, 1)
form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid)
@@ -338,6 +357,44 @@ class ConfigureNewModlistScreen(QWidget):
self.start_btn.clicked.connect(self.validate_and_start_configure)
# --- Connect steam_restart_finished signal ---
self.steam_restart_finished.connect(self._on_steam_restart_finished)
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
self._actionable_controls = [
# Main action button
self.start_btn,
# Form fields
self.modlist_name_edit,
self.install_dir_edit,
# Resolution controls
self.resolution_combo,
# Checkboxes
self.auto_restart_checkbox,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during configure operations (except Cancel)"""
for control in self._actionable_controls:
if control:
control.setEnabled(False)
def _enable_controls_after_operation(self):
"""Re-enable all actionable controls after configure operations complete"""
for control in self._actionable_controls:
if control:
control.setEnabled(True)
def refresh_paths(self):
"""Refresh cached paths when config changes."""
from jackify.shared.paths import get_jackify_logs_dir
self.modlist_log_path = get_jackify_logs_dir() / 'Configure_New_Modlist_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def resizeEvent(self, event):
"""Handle window resize to prioritize form over console"""
@@ -522,23 +579,38 @@ class ConfigureNewModlistScreen(QWidget):
# Start time tracking
self._workflow_start_time = time.time()
# Disable controls during configuration (after validation passes)
self._disable_controls_during_operation()
# Validate modlist name
modlist_name = self.modlist_name_edit.text().strip()
if not modlist_name:
MessageService.warning(self, "Missing Name", "Please specify a name for your modlist", safety_level="low")
self._enable_controls_after_operation()
return
# --- Shortcut creation will be handled by automated workflow ---
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower()
shortcut_handler = ShortcutHandler(steamdeck=steamdeck) # Still needed for Steam restart
# --- User confirmation before restarting Steam ---
reply = MessageService.question(
self, "Ready to Configure Modlist",
"Would you like to restart Steam and begin post-install configuration now? Restarting Steam could close any games you have open!",
safety_level="medium"
)
print(f"DEBUG: Steam restart dialog returned: {reply!r}")
# Check if auto-restart is enabled
auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
if auto_restart_enabled:
# Auto-accept Steam restart - proceed without dialog
self._safe_append_text("Auto-accepting Steam restart (unattended mode enabled)")
reply = QMessageBox.Yes # Simulate user clicking Yes
else:
# --- User confirmation before restarting Steam ---
reply = MessageService.question(
self, "Ready to Configure Modlist",
"Would you like to restart Steam and begin post-install configuration now? Restarting Steam could close any games you have open!",
safety_level="medium"
)
debug_print(f"DEBUG: Steam restart dialog returned: {reply!r}")
if reply not in (QMessageBox.Yes, QMessageBox.Ok, QMessageBox.AcceptRole):
self._enable_controls_after_operation()
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(0)
return
@@ -562,7 +634,6 @@ class ConfigureNewModlistScreen(QWidget):
progress.setMinimumDuration(0)
progress.setValue(0)
progress.show()
self.setEnabled(False)
def do_restart():
try:
ok = shortcut_handler.secure_steam_restart()
@@ -579,7 +650,7 @@ class ConfigureNewModlistScreen(QWidget):
if hasattr(self, '_steam_restart_progress'):
self._steam_restart_progress.close()
del self._steam_restart_progress
self.setEnabled(True)
self._enable_controls_after_operation()
if success:
self._safe_append_text("Steam restarted successfully.")
@@ -722,7 +793,7 @@ class ConfigureNewModlistScreen(QWidget):
"""Handle error from the automated prefix workflow"""
self._safe_append_text(f"Error during automated Steam setup: {error_message}")
self._safe_append_text("Please check the logs for details.")
self.start_btn.setEnabled(True)
self._enable_controls_after_operation()
def show_shortcut_conflict_dialog(self, conflicts):
"""Show dialog to resolve shortcut name conflicts"""
@@ -1162,8 +1233,8 @@ class ConfigureNewModlistScreen(QWidget):
def on_configuration_complete(self, success, message, modlist_name):
"""Handle configuration completion (same as Tuxborn)"""
# Always re-enable the start button when workflow completes
self.start_btn.setEnabled(True)
# Re-enable all controls when workflow completes
self._enable_controls_after_operation()
if success:
# Calculate time taken
@@ -1185,8 +1256,8 @@ class ConfigureNewModlistScreen(QWidget):
def on_configuration_error(self, error_message):
"""Handle configuration error"""
# Re-enable the start button on error
self.start_btn.setEnabled(True)
# Re-enable all controls on error
self._enable_controls_after_operation()
self._safe_append_text(f"Configuration error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")

View File

@@ -355,9 +355,8 @@ class InstallModlistScreen(QWidget):
self.online_modlists = {} # {game_type: [modlist_dict, ...]}
self.modlist_details = {} # {modlist_name: modlist_dict}
# Path for workflow log
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Modlist_Install_workflow.log')
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
# Initialize log path (can be refreshed via refresh_paths method)
self.refresh_paths()
# Initialize services early
from jackify.backend.services.api_key_service import APIKeyService
@@ -459,11 +458,11 @@ class InstallModlistScreen(QWidget):
file_layout.setContentsMargins(0, 0, 0, 0)
self.file_edit = QLineEdit()
self.file_edit.setMinimumWidth(400)
file_btn = QPushButton("Browse")
file_btn.clicked.connect(self.browse_wabbajack_file)
self.file_btn = QPushButton("Browse")
self.file_btn.clicked.connect(self.browse_wabbajack_file)
file_layout.addWidget(QLabel(".wabbajack File:"))
file_layout.addWidget(self.file_edit)
file_layout.addWidget(file_btn)
file_layout.addWidget(self.file_btn)
self.file_group.setLayout(file_layout)
file_tab_vbox.addWidget(self.file_group)
file_tab.setLayout(file_tab_vbox)
@@ -484,22 +483,22 @@ class InstallModlistScreen(QWidget):
install_dir_label = QLabel("Install Directory:")
self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir())
self.install_dir_edit.setMaximumHeight(25) # Force compact height
browse_install_btn = QPushButton("Browse")
browse_install_btn.clicked.connect(self.browse_install_dir)
self.browse_install_btn = QPushButton("Browse")
self.browse_install_btn.clicked.connect(self.browse_install_dir)
install_dir_hbox = QHBoxLayout()
install_dir_hbox.addWidget(self.install_dir_edit)
install_dir_hbox.addWidget(browse_install_btn)
install_dir_hbox.addWidget(self.browse_install_btn)
form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(install_dir_hbox, 1, 1)
# Downloads Dir
downloads_dir_label = QLabel("Downloads Directory:")
self.downloads_dir_edit = QLineEdit(self.config_handler.get_modlist_downloads_base_dir())
self.downloads_dir_edit.setMaximumHeight(25) # Force compact height
browse_downloads_btn = QPushButton("Browse")
browse_downloads_btn.clicked.connect(self.browse_downloads_dir)
self.browse_downloads_btn = QPushButton("Browse")
self.browse_downloads_btn.clicked.connect(self.browse_downloads_dir)
downloads_dir_hbox = QHBoxLayout()
downloads_dir_hbox.addWidget(self.downloads_dir_edit)
downloads_dir_hbox.addWidget(browse_downloads_btn)
downloads_dir_hbox.addWidget(self.browse_downloads_btn)
form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(downloads_dir_hbox, 2, 1)
# Nexus API Key
@@ -603,7 +602,25 @@ class InstallModlistScreen(QWidget):
self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0)
form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.resolution_combo, 5, 1)
# Horizontal layout for resolution dropdown and auto-restart checkbox
resolution_and_restart_layout = QHBoxLayout()
resolution_and_restart_layout.setSpacing(12)
# Resolution dropdown (made smaller)
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
resolution_and_restart_layout.addWidget(self.resolution_combo)
# Add stretch to push checkbox to the right
resolution_and_restart_layout.addStretch()
# Auto-accept Steam restart checkbox (right-aligned)
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation")
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
form_grid.addLayout(resolution_and_restart_layout, 5, 1)
form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid)
@@ -723,6 +740,57 @@ class InstallModlistScreen(QWidget):
# Initialize process tracking
self.process = None
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
self._actionable_controls = [
# Main action button
self.start_btn,
# Game/modlist selection
self.game_type_btn,
self.modlist_btn,
# Source tabs (entire tab widget)
self.source_tabs,
# Form fields
self.modlist_name_edit,
self.install_dir_edit,
self.downloads_dir_edit,
self.api_key_edit,
self.file_edit,
# Browse buttons
self.browse_install_btn,
self.browse_downloads_btn,
self.file_btn,
# Resolution controls
self.resolution_combo,
# Checkboxes
self.save_api_key_checkbox,
self.auto_restart_checkbox,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during install/configure operations (except Cancel)"""
for control in self._actionable_controls:
if control:
control.setEnabled(False)
def _enable_controls_after_operation(self):
"""Re-enable all actionable controls after install/configure operations complete"""
for control in self._actionable_controls:
if control:
control.setEnabled(True)
def refresh_paths(self):
"""Refresh cached paths when config changes."""
from jackify.shared.paths import get_jackify_logs_dir
self.modlist_log_path = get_jackify_logs_dir() / 'Modlist_Install_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def _open_url_safe(self, url):
"""Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller"""
@@ -1121,6 +1189,9 @@ class InstallModlistScreen(QWidget):
if not self._check_protontricks():
return
# Disable all controls during installation (except Cancel)
self._disable_controls_during_operation()
try:
tab_index = self.source_tabs.currentIndex()
install_mode = 'online'
@@ -1128,12 +1199,14 @@ class InstallModlistScreen(QWidget):
modlist = self.file_edit.text().strip()
if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'):
MessageService.warning(self, "Invalid Modlist", "Please select a valid .wabbajack file.")
self._enable_controls_after_operation()
return
install_mode = 'file'
else:
modlist = self.modlist_btn.text().strip()
if not modlist or modlist in ("Select Modlist", "Fetching modlists...", "No modlists found.", "Error fetching modlists."):
MessageService.warning(self, "Invalid Modlist", "Please select a valid modlist.")
self._enable_controls_after_operation()
return
# For online modlists, use machine_url instead of display name
@@ -1159,6 +1232,7 @@ class InstallModlistScreen(QWidget):
missing_fields.append("Nexus API Key")
if missing_fields:
MessageService.warning(self, "Missing Required Fields", f"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields))
self._enable_controls_after_operation()
return
validation_handler = ValidationHandler()
from pathlib import Path
@@ -1324,14 +1398,11 @@ class InstallModlistScreen(QWidget):
debug_print(f"DEBUG: Exception in validate_and_start_install: {e}")
import traceback
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
# Re-enable the button in case of exception
self.start_btn.setEnabled(True)
# Re-enable all controls after exception
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
# Also re-enable the entire widget
self.setEnabled(True)
debug_print(f"DEBUG: Widget re-enabled in exception handler, widget enabled: {self.isEnabled()}")
print(f"DEBUG: Widget re-enabled in exception handler, widget enabled: {self.isEnabled()}") # Always print
debug_print(f"DEBUG: Controls re-enabled in exception handler")
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online'):
debug_print('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
@@ -1501,12 +1572,21 @@ class InstallModlistScreen(QWidget):
self._safe_append_text(f"\nModlist installation completed successfully.")
self._safe_append_text(f"\nWarning: Post-install configuration skipped for unsupported game: {game_name or game_type}")
else:
# Show the normal install complete dialog for supported games
reply = MessageService.question(
self, "Modlist Install Complete!",
"Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!",
critical=False # Non-critical, won't steal focus
)
# Check if auto-restart is enabled
auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
if auto_restart_enabled:
# Auto-accept Steam restart - proceed without dialog
self._safe_append_text("\nAuto-accepting Steam restart (unattended mode enabled)")
reply = QMessageBox.Yes # Simulate user clicking Yes
else:
# Show the normal install complete dialog for supported games
reply = MessageService.question(
self, "Modlist Install Complete!",
"Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!",
critical=False # Non-critical, won't steal focus
)
if reply == QMessageBox.Yes:
# --- Create Steam shortcut BEFORE restarting Steam ---
# Proceed directly to automated prefix creation
@@ -1522,6 +1602,8 @@ class InstallModlistScreen(QWidget):
"You can manually add the modlist to Steam later if desired.",
safety_level="medium"
)
# Re-enable controls since operation is complete
self._enable_controls_after_operation()
else:
# Check for user cancellation first
last_output = self.console.toPlainText()
@@ -1611,9 +1693,6 @@ class InstallModlistScreen(QWidget):
progress.setMinimumDuration(0)
progress.setValue(0)
progress.show()
self.setEnabled(False)
debug_print(f"DEBUG: Widget disabled in restart_steam_and_configure, widget enabled: {self.isEnabled()}")
print(f"DEBUG: Widget disabled in restart_steam_and_configure, widget enabled: {self.isEnabled()}") # Always print
def do_restart():
debug_print("DEBUG: do_restart thread started - using direct backend service")
@@ -1651,9 +1730,7 @@ class InstallModlistScreen(QWidget):
finally:
self._steam_restart_progress = None
self.setEnabled(True)
debug_print(f"DEBUG: Widget re-enabled in _on_steam_restart_finished, widget enabled: {self.isEnabled()}")
print(f"DEBUG: Widget re-enabled in _on_steam_restart_finished, widget enabled: {self.isEnabled()}") # Always print
# Controls are managed by the proper control management system
if success:
self._safe_append_text("Steam restarted successfully.")
@@ -1676,6 +1753,8 @@ class InstallModlistScreen(QWidget):
def start_automated_prefix_workflow(self):
"""Start the automated prefix creation workflow"""
try:
# Disable controls during installation
self._disable_controls_during_operation()
modlist_name = self.modlist_name_edit.text().strip()
install_dir = self.install_dir_edit.text().strip()
final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
@@ -1784,33 +1863,43 @@ class InstallModlistScreen(QWidget):
import traceback
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
# Re-enable controls on exception
self._enable_controls_after_operation()
def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None):
"""Handle completion of automated prefix creation"""
if success:
debug_print(f"SUCCESS: Automated prefix creation completed!")
debug_print(f"Prefix created at: {prefix_path}")
if new_appid_str and new_appid_str != "0":
debug_print(f"AppID: {new_appid_str}")
# Convert string AppID back to integer for configuration
new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None
# Continue with configuration using the new AppID and timestamp
modlist_name = self.modlist_name_edit.text().strip()
install_dir = self.install_dir_edit.text().strip()
self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
else:
self._safe_append_text(f"ERROR: Automated prefix creation failed")
self._safe_append_text("Please check the logs for details")
MessageService.critical(self, "Automated Setup Failed",
"Automated prefix creation failed. Please check the console output for details.")
try:
if success:
debug_print(f"SUCCESS: Automated prefix creation completed!")
debug_print(f"Prefix created at: {prefix_path}")
if new_appid_str and new_appid_str != "0":
debug_print(f"AppID: {new_appid_str}")
# Convert string AppID back to integer for configuration
new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None
# Continue with configuration using the new AppID and timestamp
modlist_name = self.modlist_name_edit.text().strip()
install_dir = self.install_dir_edit.text().strip()
self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
else:
self._safe_append_text(f"ERROR: Automated prefix creation failed")
self._safe_append_text("Please check the logs for details")
MessageService.critical(self, "Automated Setup Failed",
"Automated prefix creation failed. Please check the console output for details.")
# Re-enable controls on failure
self._enable_controls_after_operation()
finally:
# Always ensure controls are re-enabled when workflow truly completes
pass
def on_automated_prefix_error(self, error_msg):
"""Handle error in automated prefix creation"""
self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}")
MessageService.critical(self, "Automated Setup Error",
f"Error during automated prefix creation: {error_msg}")
# Re-enable controls on error
self._enable_controls_after_operation()
def on_automated_prefix_progress(self, progress_msg):
"""Handle progress updates from automated prefix creation"""
@@ -1831,7 +1920,6 @@ class InstallModlistScreen(QWidget):
self.steam_restart_progress.setMinimumDuration(0)
self.steam_restart_progress.setValue(0)
self.steam_restart_progress.show()
self.setEnabled(False)
def hide_steam_restart_progress(self):
"""Hide Steam restart progress dialog"""
@@ -1843,45 +1931,53 @@ class InstallModlistScreen(QWidget):
pass
finally:
self.steam_restart_progress = None
self.setEnabled(True)
# Controls are managed by the proper control management system
def on_configuration_complete(self, success, message, modlist_name):
"""Handle configuration completion on main thread"""
if success:
# Show celebration SuccessDialog after the entire workflow
from ..dialogs import SuccessDialog
import time
if not hasattr(self, '_install_workflow_start_time'):
self._install_workflow_start_time = time.time()
time_taken = int(time.time() - self._install_workflow_start_time)
mins, secs = divmod(time_taken, 60)
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
game_name = display_names.get(self._current_game_type, self._current_game_name)
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
time_taken=time_str,
game_name=game_name,
parent=self
)
success_dialog.show()
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
# Max retries reached - show failure message
MessageService.critical(self, "Manual Steps Failed",
"Manual steps validation failed after multiple attempts.")
else:
# Configuration failed for other reasons
MessageService.critical(self, "Configuration Failed",
"Post-install configuration failed. Please check the console output.")
try:
# Re-enable controls now that installation/configuration is complete
self._enable_controls_after_operation()
if success:
# Show celebration SuccessDialog after the entire workflow
from ..dialogs import SuccessDialog
import time
if not hasattr(self, '_install_workflow_start_time'):
self._install_workflow_start_time = time.time()
time_taken = int(time.time() - self._install_workflow_start_time)
mins, secs = divmod(time_taken, 60)
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
game_name = display_names.get(self._current_game_type, self._current_game_name)
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
time_taken=time_str,
game_name=game_name,
parent=self
)
success_dialog.show()
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
# Max retries reached - show failure message
MessageService.critical(self, "Manual Steps Failed",
"Manual steps validation failed after multiple attempts.")
else:
# Configuration failed for other reasons
MessageService.critical(self, "Configuration Failed",
"Post-install configuration failed. Please check the console output.")
except Exception as e:
# Ensure controls are re-enabled even on unexpected errors
self._enable_controls_after_operation()
raise
# Clean up thread
if hasattr(self, 'config_thread') and self.config_thread is not None:
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
@@ -1940,8 +2036,8 @@ class InstallModlistScreen(QWidget):
else:
# User clicked Cancel or closed the dialog - cancel the workflow
self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.")
# Reset button states
self.start_btn.setEnabled(True)
# Re-enable all controls when workflow is cancelled
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
@@ -2513,8 +2609,8 @@ class InstallModlistScreen(QWidget):
# Cleanup any remaining processes
self.cleanup_processes()
# Reset button states
self.start_btn.setEnabled(True)
# Reset button states and re-enable all controls
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)

View File

@@ -106,8 +106,7 @@ class TuxbornInstallerScreen(QWidget):
self.modlist_details = {} # {modlist_name: modlist_dict}
# Path for workflow log
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Tuxborn_Installer_workflow.log')
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
self.refresh_paths()
# Initialize services early
from jackify.backend.services.api_key_service import APIKeyService
@@ -440,6 +439,12 @@ class TuxbornInstallerScreen(QWidget):
self.start_btn.clicked.connect(self.validate_and_start_install)
self.steam_restart_finished.connect(self._on_steam_restart_finished)
def refresh_paths(self):
"""Refresh cached paths when config changes."""
from jackify.shared.paths import get_jackify_logs_dir
self.modlist_log_path = get_jackify_logs_dir() / 'Tuxborn_Installer_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def _open_url_safe(self, url):
"""Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller"""
import subprocess