Initial public release v0.1.0 - Linux Wabbajack Modlist Application

Jackify provides native Linux support for Wabbajack modlist installation
   and management with automated Steam integration and Proton configuration.

   Key Features:
   - Almost Native Linux implementation (texconv.exe run via proton)
   - Automated Steam shortcut creation and Proton prefix management
   - Both CLI and GUI interfaces, with Steam Deck optimization

   Supported Games:
   - Skyrim Special Edition
   - Fallout 4
   - Fallout New Vegas
   - Oblivion, Starfield, Enderal, and diverse other games

   Technical Architecture:
   - Clean separation between frontend and backend services
   - Powered by jackify-engine 0.3.x for Wabbajack-matching modlist installation
This commit is contained in:
Omni
2025-09-05 20:46:24 +01:00
commit cd591c14e3
445 changed files with 40398 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
"""
GUI Screens Module
Contains all the GUI screen components for Jackify.
"""
from .main_menu import MainMenu
from .tuxborn_installer import TuxbornInstallerScreen
from .modlist_tasks import ModlistTasksScreen
from .install_modlist import InstallModlistScreen
from .configure_new_modlist import ConfigureNewModlistScreen
from .configure_existing_modlist import ConfigureExistingModlistScreen
__all__ = [
'MainMenu',
'TuxbornInstallerScreen',
'ModlistTasksScreen',
'InstallModlistScreen',
'ConfigureNewModlistScreen',
'ConfigureExistingModlistScreen'
]

View File

@@ -0,0 +1,710 @@
# Copy of ConfigureNewModlistScreen, adapted for existing modlists
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import ansi_to_html
import os
import subprocess
import sys
import threading
import time
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
import traceback
import signal
from jackify.backend.core.modlist_operations import get_jackify_engine_path
from jackify.backend.handlers.subprocess_utils import ProcessManager
from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog
def debug_print(message):
"""Print debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
print(message)
class ConfigureExistingModlistScreen(QWidget):
steam_restart_finished = Signal(bool, str)
def __init__(self, stacked_widget=None, main_menu_index=0):
super().__init__()
debug_print("DEBUG: ConfigureExistingModlistScreen __init__ called")
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)
# --- Detect Steam Deck ---
steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower()
self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
# Initialize services early
from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler
self.api_key_service = APIKeyService()
self.resolution_service = ResolutionService()
self.config_handler = ConfigHandler()
# --- Fetch shortcuts for ModOrganizer.exe using existing backend functionality ---
# Use existing discover_executable_shortcuts which already filters by protontricks availability
from jackify.backend.handlers.modlist_handler import ModlistHandler
# Initialize modlist handler with empty config dict to use default initialization
modlist_handler = ModlistHandler({})
discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
# Convert to shortcut_handler format for UI compatibility
self.mo2_shortcuts = []
for modlist in discovered_modlists:
# Convert discovered modlist format to shortcut format
shortcut = {
'AppName': modlist.get('name', 'Unknown'),
'AppID': modlist.get('appid', ''),
'StartDir': modlist.get('path', ''),
'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe"
}
self.mo2_shortcuts.append(shortcut)
# --- UI Layout ---
main_overall_vbox = QVBoxLayout(self)
main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin
if self.debug:
self.setStyleSheet("border: 2px solid magenta;")
# --- Header (title, description) ---
header_layout = QVBoxLayout()
header_layout.setSpacing(1) # Reduce spacing between title and description
title = QLabel("<b>Configure Existing Modlist</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
title.setAlignment(Qt.AlignHCenter)
title.setMaximumHeight(30) # Force compact height
header_layout.addWidget(title)
desc = QLabel(
"This screen allows you to configure an existing modlist in Jackify. "
"Select your Steam shortcut for ModOrganizer.exe, set your resolution, and complete post-install configuration."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;")
desc.setAlignment(Qt.AlignHCenter)
desc.setMaximumHeight(40) # Force compact height for description
header_layout.addWidget(desc)
header_widget = QWidget()
header_widget.setLayout(header_layout)
header_widget.setMaximumHeight(75) # Match other screens
if self.debug:
header_widget.setStyleSheet("border: 2px solid pink;")
header_widget.setToolTip("HEADER_SECTION")
main_overall_vbox.addWidget(header_widget)
# --- Upper section: shortcut selector (left) + process monitor (right) ---
upper_hbox = QHBoxLayout()
upper_hbox.setContentsMargins(0, 0, 0, 0)
upper_hbox.setSpacing(16)
user_config_vbox = QVBoxLayout()
user_config_vbox.setAlignment(Qt.AlignTop)
# --- [Options] header (moved here for alignment) ---
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) # Reduced from 8 to 6 for better readability
form_grid.setContentsMargins(0, 0, 0, 0)
# --- Shortcut selector ---
shortcut_label = QLabel("Select Modlist:")
self.shortcut_combo = QComboBox()
self.shortcut_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map = []
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})"
self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut)
# Add refresh button next to dropdown
refresh_btn = QPushButton("")
refresh_btn.setToolTip("Refresh modlist list")
refresh_btn.setFixedSize(30, 30)
refresh_btn.clicked.connect(self.refresh_modlist_list)
# Create horizontal layout for dropdown and refresh button
shortcut_hbox = QHBoxLayout()
shortcut_hbox.addWidget(self.shortcut_combo)
shortcut_hbox.addWidget(refresh_btn)
shortcut_hbox.setSpacing(4)
shortcut_hbox.setStretch(0, 1) # Make dropdown expand
form_grid.addWidget(shortcut_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(shortcut_hbox, 0, 1)
# --- Info message under shortcut selector ---
info_label = QLabel("<span style='color:#aaa'>If you don't see your modlist entry in this list, please ensure you have added it to Steam as a non-steam game, set a proton version in properties, and have started the modlist Steam entry at least once. You can also click the refresh button (↻) to update the list.</span>")
info_label.setWordWrap(True)
form_grid.addWidget(info_label, 1, 0, 1, 2)
# --- Resolution selector ---
resolution_label = QLabel("Resolution:")
self.resolution_combo = QComboBox()
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.resolution_combo.addItem("Leave unchanged")
self.resolution_combo.addItems([
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"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()
is_steam_deck = False
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
is_steam_deck = True
except Exception:
pass
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
elif is_steam_deck:
# Set default to 1280x800 (Steam Deck)
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
if "1280x800 (Steam Deck)" in combo_items:
self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)"))
else:
self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0)
form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid)
form_section_widget.setMinimumHeight(160) # Reduced to match compact form
form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown
if self.debug:
form_section_widget.setStyleSheet("border: 2px solid blue;")
form_section_widget.setToolTip("FORM_SECTION")
user_config_vbox.addWidget(form_section_widget)
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self.start_btn = QPushButton("Start Configuration")
btn_row.addWidget(self.start_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.go_back)
btn_row.addWidget(cancel_btn)
user_config_widget = QWidget()
user_config_widget.setLayout(user_config_vbox)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
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;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(process_monitor_widget, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
upper_section_widget.setToolTip("UPPER_SECTION")
main_overall_vbox.addWidget(upper_section_widget)
# Remove spacing - console should expand to fill available space
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing
self.console.setMaximumHeight(1000) # Allow growth when space available
self.console.setFontFamily('monospace')
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
self.console.setToolTip("CONSOLE")
# Set up scroll tracking for professional auto-scroll behavior
self._setup_scroll_tracking()
# Wrap button row in widget for debug borders
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact
if self.debug:
btn_row_widget.setStyleSheet("border: 2px solid red;")
btn_row_widget.setToolTip("BUTTON_ROW")
# Create a container that holds console + button row with proper spacing
console_and_buttons_widget = QWidget()
console_and_buttons_layout = QVBoxLayout()
console_and_buttons_layout.setContentsMargins(0, 0, 0, 0)
console_and_buttons_layout.setSpacing(8) # Small gap between console and buttons
console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space
console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container
console_and_buttons_widget.setLayout(console_and_buttons_layout)
if self.debug:
console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;")
console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER")
main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space
self.setLayout(main_overall_vbox)
self.process = None
self.log_timer = None
self.last_log_pos = 0
self.top_timer = QTimer(self)
self.top_timer.timeout.connect(self.update_top_panel)
self.top_timer.start(2000)
self.start_btn.clicked.connect(self.validate_and_start_configure)
self.steam_restart_finished.connect(self._on_steam_restart_finished)
# Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False
self._was_at_bottom = True
# Time tracking for workflow completion
self._workflow_start_time = None
def resizeEvent(self, event):
"""Handle window resize to prioritize form over console"""
super().resizeEvent(event)
self._adjust_console_for_form_priority()
def _adjust_console_for_form_priority(self):
"""Console now dynamically fills available space with stretch=1, no manual calculation needed"""
# The console automatically fills remaining space due to stretch=1 in the layout
# Remove any fixed height constraints to allow natural stretching
self.console.setMaximumHeight(16777215) # Reset to default maximum
self.console.setMinimumHeight(50) # Keep minimum height for usability
def _setup_scroll_tracking(self):
"""Set up scroll tracking for professional auto-scroll behavior"""
scrollbar = self.console.verticalScrollBar()
scrollbar.sliderPressed.connect(self._on_scrollbar_pressed)
scrollbar.sliderReleased.connect(self._on_scrollbar_released)
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
def _on_scrollbar_pressed(self):
"""User started manually scrolling"""
self._user_manually_scrolled = True
def _on_scrollbar_released(self):
"""User finished manually scrolling"""
self._user_manually_scrolled = False
def _on_scrollbar_value_changed(self):
"""Track if user is at bottom of scroll area"""
scrollbar = self.console.verticalScrollBar()
# Use tolerance to account for rounding and rapid updates
self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
# If user manually scrolls to bottom, reset manual scroll flag
if self._was_at_bottom and self._user_manually_scrolled:
# Small delay to allow user to scroll away if they want
from PySide6.QtCore import QTimer
QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom)
def _reset_manual_scroll_if_at_bottom(self):
"""Reset manual scroll flag if user is still at bottom after delay"""
scrollbar = self.console.verticalScrollBar()
if scrollbar.value() >= scrollbar.maximum() - 1:
self._user_manually_scrolled = False
def _safe_append_text(self, text):
"""Append text with professional auto-scroll behavior"""
# Write all messages to log file
self._write_to_log_file(text)
scrollbar = self.console.verticalScrollBar()
# Check if user was at bottom BEFORE adding text
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance
# Add the text
self.console.append(text)
# Auto-scroll if user was at bottom and hasn't manually scrolled
# Re-check bottom state after text addition for better reliability
if (was_at_bottom and not self._user_manually_scrolled) or \
(not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2):
scrollbar.setValue(scrollbar.maximum())
# Ensure user can still manually scroll up during rapid updates
if scrollbar.value() == scrollbar.maximum():
self._was_at_bottom = True
def _write_to_log_file(self, message):
"""Write message to workflow log file with timestamp"""
try:
from datetime import datetime
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with open(self.modlist_log_path, 'a', encoding='utf-8') as f:
f.write(f"[{timestamp}] {message}\n")
except Exception:
# Logging should never break the workflow
pass
def validate_and_start_configure(self):
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
from pathlib import Path
log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
# 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")
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")
return
resolution = self.resolution_combo.currentText()
# Handle resolution saving
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
debug_print(f"DEBUG: Resolution saved successfully: {resolution}")
else:
debug_print("DEBUG: Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
debug_print("DEBUG: Saved resolution cleared")
# Start the workflow (no shortcut creation needed)
self.start_workflow(modlist_name, install_dir, resolution)
def start_workflow(self, modlist_name, install_dir, resolution):
"""Start the configuration workflow using backend service directly"""
try:
# Start time tracking
self._workflow_start_time = time.time()
self._safe_append_text("[Jackify] Starting post-install configuration...")
# Create configuration thread using backend service
from PySide6.QtCore import QThread, Signal
class ConfigurationThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
error_occurred = Signal(str)
def __init__(self, modlist_name, install_dir, resolution):
super().__init__()
self.modlist_name = modlist_name
self.install_dir = install_dir
self.resolution = resolution
def run(self):
try:
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
import os
# Initialize backend service
system_info = SystemInfo(is_steamdeck=False) # TODO: Detect Steam Deck
modlist_service = ModlistService(system_info)
# Create modlist context for existing modlist configuration
mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe")
modlist_context = ModlistContext(
name=self.modlist_name,
install_dir=Path(self.install_dir),
download_dir=Path(self.install_dir).parent / 'Downloads', # Default
game_type='skyrim', # Default for now - TODO: detect from modlist
nexus_api_key='', # Not needed for configuration-only
modlist_value='', # Not needed for existing modlist
modlist_source='existing',
skip_confirmation=True
)
# For existing modlists, add resolution if specified
if self.resolution != "Leave unchanged":
modlist_context.resolution = self.resolution.split()[0]
# Define callbacks
def progress_callback(message):
self.progress_update.emit(message)
def completion_callback(success, message, modlist_name):
self.configuration_complete.emit(success, message, modlist_name)
def manual_steps_callback(modlist_name, retry_count):
# Existing modlists shouldn't need manual steps, but handle gracefully
self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})")
# Call the working configuration service method
self.progress_update.emit("Starting existing modlist configuration...")
# For existing modlists, call configure_modlist_post_steam directly
# since Steam setup and manual steps should already be done
success = modlist_service.configure_modlist_post_steam(
context=modlist_context,
progress_callback=progress_callback,
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
if not success:
self.error_occurred.emit("Configuration failed - check logs for details")
except Exception as e:
import traceback
error_msg = f"Configuration error: {e}\n{traceback.format_exc()}"
self.error_occurred.emit(error_msg)
# Create and start the configuration thread
self.config_thread = ConfigurationThread(modlist_name, install_dir, resolution)
self.config_thread.progress_update.connect(self._safe_append_text)
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
self.config_thread.error_occurred.connect(self.on_configuration_error)
self.config_thread.start()
except Exception as e:
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium")
def on_configuration_complete(self, success, message, modlist_name):
"""Handle configuration completion"""
if success:
# Calculate time taken
time_taken = self._calculate_time_taken()
# Show success dialog with celebration
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="configure_existing",
time_taken=time_taken,
game_name=getattr(self, '_current_game_name', None),
parent=self
)
success_dialog.show()
else:
self._safe_append_text(f"Configuration failed: {message}")
MessageService.critical(self, "Configuration Failed",
f"Configuration failed: {message}", safety_level="medium")
def on_configuration_error(self, error_message):
"""Handle configuration error"""
self._safe_append_text(f"Configuration error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")
def show_manual_steps_dialog(self, extra_warning=""):
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
msg = (
f"<b>Manual Proton Setup Required for <span style='color:#3fd0ea'>{modlist_name}</span></b><br>"
"After Steam restarts, complete the following steps in Steam:<br>"
f"1. Locate the '<b>{modlist_name}</b>' entry in your Steam Library<br>"
"2. Right-click and select 'Properties'<br>"
"3. Switch to the 'Compatibility' tab<br>"
"4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'<br>"
"5. Select 'Proton - Experimental' from the dropdown menu<br>"
"6. Close the Properties window<br>"
f"7. Launch '<b>{modlist_name}</b>' from your Steam Library<br>"
"8. Wait for Wabbajack to download its files and fully load<br>"
"9. Once Wabbajack has fully loaded, CLOSE IT completely and return here<br>"
"<br>Once you have completed ALL the steps above, click OK to continue."
f"{extra_warning}"
)
reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium")
if reply == QMessageBox.Yes:
if self.config_process and self.config_process.state() == QProcess.Running:
self.config_process.write(b'\n')
self.config_process.waitForBytesWritten(1000)
self._config_prompt_state = None
self._manual_steps_buffer = []
else:
# User clicked Cancel or closed the dialog - cancel the workflow
self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.")
# Terminate the configuration process
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)
self.cancel_btn.setVisible(True)
def show_next_steps_dialog(self, message):
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication
dlg = QDialog(self)
dlg.setWindowTitle("Next Steps")
dlg.setModal(True)
layout = QVBoxLayout(dlg)
label = QLabel(message)
label.setWordWrap(True)
layout.addWidget(label)
btn_row = QHBoxLayout()
btn_return = QPushButton("Return")
btn_exit = QPushButton("Exit")
btn_row.addWidget(btn_return)
btn_row.addWidget(btn_exit)
layout.addLayout(btn_row)
def on_return():
dlg.accept()
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(0)
def on_exit():
QApplication.quit()
btn_return.clicked.connect(on_return)
btn_exit.clicked.connect(on_exit)
dlg.exec()
def go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
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]
process_rows = []
for line in lines[1:]:
line_lower = line.lower()
# Include jackify-engine and related heavy processes
heavy_processes = (
"jackify-engine" in line_lower or "7zz" in line_lower or
"compressonator" in line_lower or "wine" in line_lower or
"wine64" in line_lower or "protontricks" in line_lower
)
# Include Python processes running configure-modlist command
configure_processes = (
"python" in line_lower and "configure-modlist" in line_lower
)
# Include configuration threads that might be running
config_threads = (
hasattr(self, 'config_thread') and
self.config_thread and
self.config_thread.isRunning() and
("python" in line_lower or "jackify" in line_lower)
)
if (heavy_processes or configure_processes or config_threads) and "jackify-gui.py" not in line_lower:
cols = line.strip().split(None, 3)
if len(cols) >= 3:
process_rows.append(cols)
process_rows.sort(key=lambda x: float(x[0]), reverse=True)
for cols in process_rows:
filtered.append('\t'.join(cols))
if len(filtered) == 1:
filtered.append("[No Jackify-related processes found]")
self.process_monitor.setPlainText('\n'.join(filtered))
except Exception as e:
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
def _on_steam_restart_finished(self, success, message):
pass
def refresh_modlist_list(self):
"""Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts"""
try:
# Re-detect shortcuts using existing backend functionality
from jackify.backend.handlers.modlist_handler import ModlistHandler
# Initialize modlist handler with empty config dict to use default initialization
modlist_handler = ModlistHandler({})
discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
# Convert to shortcut_handler format for UI compatibility
self.mo2_shortcuts = []
for modlist in discovered_modlists:
# Convert discovered modlist format to shortcut format
shortcut = {
'AppName': modlist.get('name', 'Unknown'),
'AppID': modlist.get('appid', ''),
'StartDir': modlist.get('path', ''),
'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe"
}
self.mo2_shortcuts.append(shortcut)
# Clear and repopulate the combo box
self.shortcut_combo.clear()
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map.clear()
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})"
self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut)
# Show feedback to user in UI only (don't write to log before workflow starts)
# Feedback is shown by the updated dropdown items
except Exception as e:
# Don't write to log file before workflow starts - just show error in UI
MessageService.warning(self, "Refresh Error", f"Failed to refresh modlist list: {e}", safety_level="low")
def _calculate_time_taken(self) -> str:
"""Calculate and format the time taken for the workflow"""
if self._workflow_start_time is None:
return "unknown time"
elapsed_seconds = time.time() - self._workflow_start_time
elapsed_minutes = int(elapsed_seconds // 60)
elapsed_seconds_remainder = int(elapsed_seconds % 60)
if elapsed_minutes > 0:
if elapsed_minutes == 1:
return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_seconds_remainder} seconds"
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")
# Clean up config thread if running
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
debug_print("DEBUG: Terminating ConfigurationThread")
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except:
pass
self.config_thread.terminate()
self.config_thread.wait(2000) # Wait up to 2 seconds

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
"""
MainMenu screen for Jackify GUI (Refactored)
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from PySide6.QtGui import QPixmap, QFont
from PySide6.QtCore import Qt
import os
from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT
class MainMenu(QWidget):
def __init__(self, stacked_widget=None, dev_mode=False):
super().__init__()
self.stacked_widget = stacked_widget
self.dev_mode = dev_mode
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
layout.setContentsMargins(50, 50, 50, 50)
layout.setSpacing(20)
# Title
title = QLabel("<b>Jackify</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
layout.addWidget(title)
# Description
desc = QLabel(
"Manage your modlists with native Linux tools. "
"Choose from the options below to install, "
"configure, or manage modlists."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
desc.setAlignment(Qt.AlignHCenter)
layout.addWidget(desc)
# Separator
layout.addSpacing(16)
sep = QLabel()
sep.setFixedHeight(2)
sep.setStyleSheet("background: #fff;")
layout.addWidget(sep)
layout.addSpacing(16)
# Menu buttons
button_width = 400
button_height = 60
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Coming Soon...", "coming_soon", "More features coming soon!"),
]
if self.dev_mode:
MENU_ITEMS.append(("Hoolamike Tasks", "hoolamike_tasks", "Manage Hoolamike modding tools"))
MENU_ITEMS.append(("Additional Tasks", "additional_tasks", "Additional utilities and tools"))
MENU_ITEMS.append(("Exit Jackify", "exit_jackify", "Close the application"))
for label, action_id, description in MENU_ITEMS:
# Main button
btn = QPushButton(label)
btn.setFixedSize(button_width, 50)
btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
text-align: center;
}}
QPushButton:hover {{
background-color: #5a6578;
}}
QPushButton:pressed {{
background-color: {JACKIFY_COLOR_BLUE};
}}
""")
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
# Button container with proper alignment
btn_container = QWidget()
btn_layout = QVBoxLayout()
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(4)
btn_layout.setAlignment(Qt.AlignHCenter)
btn_layout.addWidget(btn)
# Description label with proper alignment
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 12px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width) # Match button width for proper alignment
btn_layout.addWidget(desc_label)
btn_container.setLayout(btn_layout)
layout.addWidget(btn_container)
# Disclaimer
layout.addSpacing(20)
disclaimer = QLabel(DISCLAIMER_TEXT)
disclaimer.setWordWrap(True)
disclaimer.setAlignment(Qt.AlignCenter)
disclaimer.setStyleSheet("color: #666; font-size: 10px;")
disclaimer.setFixedWidth(button_width)
layout.addWidget(disclaimer, alignment=Qt.AlignHCenter)
self.setLayout(layout)
def menu_action(self, action_id):
if action_id == "exit_jackify":
from PySide6.QtWidgets import QApplication
QApplication.quit()
elif action_id == "coming_soon":
# Show a friendly message about upcoming features
from PySide6.QtWidgets import QMessageBox
msg = QMessageBox(self)
msg.setWindowTitle("Coming Soon")
msg.setText("More features are coming in future releases!\n\nFor now, you can install and configure any modlist using the 'Modlist Tasks' button.")
msg.setIcon(QMessageBox.Information)
msg.exec()
elif action_id == "modlist_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(3)
elif action_id == "return_main_menu":
# This is the main menu, so do nothing
pass
elif self.stacked_widget:
self.stacked_widget.setCurrentIndex(2) # Placeholder for now

View File

@@ -0,0 +1,214 @@
"""
Migrated Modlist Tasks Screen
This is a migrated version of the original modlist tasks menu that uses backend services
directly instead of subprocess calls to jackify-cli.py.
Key changes:
- Uses backend services directly instead of subprocess.Popen()
- Direct backend service integration
- Maintains same UI and workflow
- Improved error handling and progress reporting
"""
import os
import sys
import logging
from pathlib import Path
from typing import List, Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QGridLayout, QSizePolicy, QApplication, QFrame, QMessageBox
)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont, QPalette, QColor, QPixmap
# Import our GUI services
from jackify.backend.models.configuration import SystemInfo
from ..shared_theme import JACKIFY_COLOR_BLUE
# Constants
DEBUG_BORDERS = False
logger = logging.getLogger(__name__)
class ModlistTasksScreen(QWidget):
"""
Migrated Modlist Tasks screen that uses backend services directly.
This replaces the original ModlistTasksMenu's subprocess calls with
direct navigation to existing automated workflows.
"""
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None, dev_mode=False):
super().__init__()
logger.info("ModlistTasksScreen initializing (migrated version)")
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.debug = DEBUG_BORDERS
self.dev_mode = dev_mode
# Initialize backend services
if system_info is None:
system_info = SystemInfo(is_steamdeck=self._is_steamdeck())
self.system_info = system_info
# Setup UI
self._setup_ui()
logger.info("ModlistTasksScreen initialized (migrated version)")
def _is_steamdeck(self) -> bool:
"""Check if running on Steam Deck"""
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
content = f.read()
if "steamdeck" in content:
return True
return False
except Exception:
return False
def _setup_ui(self):
"""Set up the user interface"""
main_layout = QVBoxLayout(self)
main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_layout.setContentsMargins(50, 50, 50, 50)
if self.debug:
self.setStyleSheet("border: 2px solid green;")
# Header section
self._setup_header(main_layout)
# Menu buttons section
self._setup_menu_buttons(main_layout)
# Bottom navigation
self._setup_navigation(main_layout)
def _setup_header(self, layout):
"""Set up the header section"""
header_layout = QVBoxLayout()
header_layout.setSpacing(2)
# Title
title = QLabel("<b>Modlist Tasks</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
# Add a spacer to match main menu vertical spacing
header_layout.addSpacing(16)
# Description
desc = QLabel(
"Manage your modlists with native Linux tools. Choose "
"from the options below to install or configure modlists.<br>&nbsp;"
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
desc.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(desc)
header_layout.addSpacing(24)
# Separator
sep = QLabel()
sep.setFixedHeight(2)
sep.setStyleSheet("background: #fff;")
header_layout.addWidget(sep)
header_layout.addSpacing(16)
layout.addLayout(header_layout)
def _setup_menu_buttons(self, layout):
"""Set up the menu buttons section"""
# Menu options
MENU_ITEMS = [
("Install a Modlist (Automated)", "install_modlist", "Download and install modlists automatically"),
("Configure New Modlist (Post-Download)", "configure_new_modlist", "Configure a newly downloaded modlist"),
("Configure Existing Modlist (In Steam)", "configure_existing_modlist", "Reconfigure an existing Steam modlist"),
]
if self.dev_mode:
MENU_ITEMS.append(("Install Wabbajack Application", "install_wabbajack", "Set up the Wabbajack application"))
MENU_ITEMS.append(("Return to Main Menu", "return_main_menu", "Go back to the main menu"))
# Create grid layout for buttons
button_grid = QGridLayout()
button_grid.setSpacing(16)
button_grid.setAlignment(Qt.AlignHCenter)
button_width = 400
button_height = 50
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
# Create button
btn = QPushButton(label)
btn.setFixedSize(button_width, button_height)
btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
text-align: center;
}}
QPushButton:hover {{
background-color: #5a6578;
}}
QPushButton:pressed {{
background-color: {JACKIFY_COLOR_BLUE};
}}
""")
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
# Create description label
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 12px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)
# Add to grid
button_grid.addWidget(btn, i * 2, 0, Qt.AlignHCenter)
button_grid.addWidget(desc_label, i * 2 + 1, 0, Qt.AlignHCenter)
layout.addLayout(button_grid)
def _setup_navigation(self, layout):
"""Set up the navigation section"""
# Remove the bottom navigation bar entirely (no gray Back to Main Menu button)
pass
def menu_action(self, action_id):
"""Handle menu button clicks"""
logger.info(f"Modlist tasks menu action: {action_id}")
if not self.stacked_widget:
return
# Navigate to different screens based on action
if action_id == "return_main_menu":
self.stacked_widget.setCurrentIndex(0)
elif action_id == "install_modlist":
self.stacked_widget.setCurrentIndex(4)
elif action_id == "configure_new_modlist":
self.stacked_widget.setCurrentIndex(5)
elif action_id == "configure_existing_modlist":
self.stacked_widget.setCurrentIndex(6)
def go_back(self):
"""Return to main menu"""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup(self):
"""Clean up resources when the screen is closed"""
pass

File diff suppressed because it is too large Load Diff