mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
Sync from development - prepare for v0.1.4
This commit is contained in:
@@ -7,6 +7,7 @@ This replaces the legacy jackify_gui implementation with a refactored architectu
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Suppress xkbcommon locale errors (harmless but annoying)
|
||||
@@ -81,6 +82,9 @@ if '--env-diagnostic' in sys.argv:
|
||||
|
||||
from jackify import __version__ as jackify_version
|
||||
|
||||
# Initialize logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if '--help' in sys.argv or '-h' in sys.argv:
|
||||
print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n\nIf no options are given, the GUI will launch by default.\n""")
|
||||
sys.exit(0)
|
||||
@@ -98,7 +102,7 @@ sys.path.insert(0, str(src_dir))
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QPushButton,
|
||||
QStackedWidget, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QCheckBox, QSpinBox, QMessageBox, QGroupBox, QGridLayout, QFileDialog, QToolButton, QStyle
|
||||
QStackedWidget, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QCheckBox, QSpinBox, QMessageBox, QGroupBox, QGridLayout, QFileDialog, QToolButton, QStyle, QComboBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtGui import QIcon
|
||||
@@ -298,6 +302,33 @@ class SettingsDialog(QDialog):
|
||||
main_layout.addWidget(api_group)
|
||||
main_layout.addSpacing(12)
|
||||
|
||||
# --- Proton Version Section ---
|
||||
proton_group = QGroupBox("Proton Version")
|
||||
proton_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
|
||||
proton_layout = QHBoxLayout()
|
||||
proton_group.setLayout(proton_layout)
|
||||
|
||||
self.proton_dropdown = QComboBox()
|
||||
self.proton_dropdown.setToolTip("Select Proton version for shortcut creation and texture processing")
|
||||
self.proton_dropdown.setMinimumWidth(200)
|
||||
|
||||
# Populate Proton dropdown
|
||||
self._populate_proton_dropdown()
|
||||
|
||||
# Refresh button for Proton detection
|
||||
refresh_btn = QPushButton("↻")
|
||||
refresh_btn.setFixedSize(30, 30)
|
||||
refresh_btn.setToolTip("Refresh Proton version list")
|
||||
refresh_btn.clicked.connect(self._refresh_proton_dropdown)
|
||||
|
||||
proton_layout.addWidget(QLabel("Proton Version:"))
|
||||
proton_layout.addWidget(self.proton_dropdown)
|
||||
proton_layout.addWidget(refresh_btn)
|
||||
proton_layout.addStretch()
|
||||
|
||||
main_layout.addWidget(proton_group)
|
||||
main_layout.addSpacing(12)
|
||||
|
||||
# --- Directories & Paths Section ---
|
||||
dir_group = QGroupBox("Directories & Paths")
|
||||
dir_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
|
||||
@@ -447,6 +478,85 @@ class SettingsDialog(QDialog):
|
||||
api_key = text.strip()
|
||||
self.config_handler.save_api_key(api_key)
|
||||
|
||||
def _get_proton_10_path(self):
|
||||
"""Get Proton 10 path if available, fallback to auto"""
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
available_protons = WineUtils.scan_valve_proton_versions()
|
||||
|
||||
# Look for Proton 10.x
|
||||
for proton in available_protons:
|
||||
if proton['version'].startswith('10.'):
|
||||
return proton['path']
|
||||
|
||||
# Fallback to auto if no Proton 10 found
|
||||
return 'auto'
|
||||
except:
|
||||
return 'auto'
|
||||
|
||||
def _populate_proton_dropdown(self):
|
||||
"""Populate Proton version dropdown with detected versions (includes GE-Proton and Valve Proton)"""
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Get all available Proton versions (GE-Proton + Valve Proton)
|
||||
available_protons = WineUtils.scan_all_proton_versions()
|
||||
|
||||
# Add "Auto" option first
|
||||
self.proton_dropdown.addItem("Auto", "auto")
|
||||
|
||||
# Add detected Proton versions with type indicators
|
||||
for proton in available_protons:
|
||||
proton_name = proton.get('name', 'Unknown Proton')
|
||||
proton_type = proton.get('type', 'Unknown')
|
||||
|
||||
# Format display name to show type for clarity
|
||||
if proton_type == 'GE-Proton':
|
||||
display_name = f"{proton_name} (GE)"
|
||||
elif proton_type == 'Valve-Proton':
|
||||
display_name = f"{proton_name}"
|
||||
else:
|
||||
display_name = proton_name
|
||||
|
||||
self.proton_dropdown.addItem(display_name, str(proton['path']))
|
||||
|
||||
# Load saved preference and determine UI selection
|
||||
saved_proton = self.config_handler.get('proton_path', self._get_proton_10_path())
|
||||
|
||||
# Check if saved path matches any specific Proton in dropdown
|
||||
found_match = False
|
||||
for i in range(self.proton_dropdown.count()):
|
||||
if self.proton_dropdown.itemData(i) == saved_proton:
|
||||
self.proton_dropdown.setCurrentIndex(i)
|
||||
found_match = True
|
||||
break
|
||||
|
||||
# If no exact match found, check if it's a resolved auto-selection
|
||||
if not found_match and saved_proton != "auto":
|
||||
# This means config has a resolved path from previous "Auto" selection
|
||||
# Show "Auto" in UI since user chose auto-detection
|
||||
for i in range(self.proton_dropdown.count()):
|
||||
if self.proton_dropdown.itemData(i) == "auto":
|
||||
self.proton_dropdown.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to populate Proton dropdown: {e}")
|
||||
# Fallback: just show auto
|
||||
self.proton_dropdown.addItem("Auto", "auto")
|
||||
|
||||
def _refresh_proton_dropdown(self):
|
||||
"""Refresh Proton dropdown with latest detected versions"""
|
||||
current_selection = self.proton_dropdown.currentData()
|
||||
self.proton_dropdown.clear()
|
||||
self._populate_proton_dropdown()
|
||||
|
||||
# Restore selection if still available
|
||||
for i in range(self.proton_dropdown.count()):
|
||||
if self.proton_dropdown.itemData(i) == current_selection:
|
||||
self.proton_dropdown.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
def _save(self):
|
||||
# Validate values
|
||||
for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items():
|
||||
@@ -490,6 +600,33 @@ class SettingsDialog(QDialog):
|
||||
# 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)
|
||||
|
||||
# Save Proton selection - resolve "auto" to actual path
|
||||
selected_proton_path = self.proton_dropdown.currentData()
|
||||
if selected_proton_path == "auto":
|
||||
# Resolve "auto" to actual best Proton path using unified detection
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
best_proton = WineUtils.select_best_proton()
|
||||
|
||||
if best_proton:
|
||||
resolved_path = str(best_proton['path'])
|
||||
resolved_version = best_proton['name']
|
||||
else:
|
||||
resolved_path = "auto"
|
||||
resolved_version = "auto"
|
||||
except:
|
||||
resolved_path = "auto"
|
||||
resolved_version = "auto"
|
||||
else:
|
||||
# User selected specific Proton version
|
||||
resolved_path = selected_proton_path
|
||||
# Extract version from dropdown text
|
||||
resolved_version = self.proton_dropdown.currentText()
|
||||
|
||||
self.config_handler.set("proton_path", resolved_path)
|
||||
self.config_handler.set("proton_version", resolved_version)
|
||||
|
||||
self.config_handler.save_config()
|
||||
|
||||
# Refresh cached paths in GUI screens if Jackify directory changed
|
||||
|
||||
@@ -22,6 +22,7 @@ from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from ..dialogs import SuccessDialog
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
@@ -1033,7 +1034,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
try:
|
||||
# Get resolution from UI
|
||||
resolution = self.resolution_combo.currentText()
|
||||
resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else '2560x1600'
|
||||
resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else None
|
||||
|
||||
# Update the context with the new AppID (same format as manual steps)
|
||||
mo2_exe_path = self.install_dir_edit.text().strip()
|
||||
@@ -1082,7 +1083,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
nexus_api_key='', # Not needed for configuration
|
||||
modlist_value=self.context.get('modlist_value'),
|
||||
modlist_source=self.context.get('modlist_source', 'identifier'),
|
||||
resolution=self.context.get('resolution', '2560x1600'),
|
||||
resolution=self.context.get('resolution') or get_resolution_fallback(None),
|
||||
skip_confirmation=True
|
||||
)
|
||||
|
||||
|
||||
@@ -367,6 +367,10 @@ class InstallModlistScreen(QWidget):
|
||||
self.resolution_service = ResolutionService()
|
||||
self.config_handler = ConfigHandler()
|
||||
self.protontricks_service = ProtontricksDetectionService()
|
||||
|
||||
# Somnium guidance tracking
|
||||
self._show_somnium_guidance = False
|
||||
self._somnium_install_dir = None
|
||||
|
||||
# Scroll tracking for professional auto-scroll behavior
|
||||
self._user_manually_scrolled = False
|
||||
@@ -1356,7 +1360,8 @@ class InstallModlistScreen(QWidget):
|
||||
'oblivion': 'oblivion',
|
||||
'starfield': 'starfield',
|
||||
'oblivion_remastered': 'oblivion_remastered',
|
||||
'enderal': 'enderal'
|
||||
'enderal': 'enderal',
|
||||
'enderal special edition': 'enderal'
|
||||
}
|
||||
game_type = game_mapping.get(game_name.lower())
|
||||
debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
@@ -1373,6 +1378,7 @@ class InstallModlistScreen(QWidget):
|
||||
|
||||
# Check if game is supported
|
||||
debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported")
|
||||
debug_print(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
|
||||
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
|
||||
debug_print(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
|
||||
|
||||
@@ -1760,10 +1766,26 @@ class InstallModlistScreen(QWidget):
|
||||
final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
||||
|
||||
if not os.path.exists(final_exe_path):
|
||||
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
|
||||
MessageService.critical(self, "ModOrganizer.exe Not Found",
|
||||
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
|
||||
return
|
||||
# Check if this is Somnium specifically (uses files/ subdirectory)
|
||||
modlist_name_lower = modlist_name.lower()
|
||||
if "somnium" in modlist_name_lower:
|
||||
somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
|
||||
if os.path.exists(somnium_exe_path):
|
||||
final_exe_path = somnium_exe_path
|
||||
self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup")
|
||||
# Show Somnium guidance popup after automated workflow completes
|
||||
self._show_somnium_guidance = True
|
||||
self._somnium_install_dir = install_dir
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}")
|
||||
MessageService.critical(self, "Somnium ModOrganizer.exe Not Found",
|
||||
f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.")
|
||||
return
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
|
||||
MessageService.critical(self, "ModOrganizer.exe Not Found",
|
||||
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
|
||||
return
|
||||
|
||||
# Run automated prefix creation in separate thread
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
@@ -1940,6 +1962,10 @@ class InstallModlistScreen(QWidget):
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
# Check if we need to show Somnium guidance
|
||||
if self._show_somnium_guidance:
|
||||
self._show_somnium_post_install_guidance()
|
||||
|
||||
# Show celebration SuccessDialog after the entire workflow
|
||||
from ..dialogs import SuccessDialog
|
||||
import time
|
||||
@@ -2041,11 +2067,20 @@ class InstallModlistScreen(QWidget):
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
|
||||
def _get_mo2_path(self, install_dir, modlist_name):
|
||||
"""Get ModOrganizer.exe path, handling Somnium's non-standard structure"""
|
||||
mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
||||
if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower():
|
||||
somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
|
||||
if os.path.exists(somnium_path):
|
||||
mo2_exe_path = somnium_path
|
||||
return mo2_exe_path
|
||||
|
||||
def validate_manual_steps_completion(self):
|
||||
"""Validate that manual steps were actually completed and handle retry logic"""
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
||||
mo2_exe_path = self._get_mo2_path(install_dir, modlist_name)
|
||||
|
||||
# Add delay to allow Steam filesystem updates to complete
|
||||
self._safe_append_text("Waiting for Steam filesystem updates to complete...")
|
||||
@@ -2283,7 +2318,7 @@ class InstallModlistScreen(QWidget):
|
||||
updated_context = {
|
||||
'name': modlist_name,
|
||||
'path': install_dir,
|
||||
'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"),
|
||||
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
||||
'modlist_value': None,
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', '2560x1600'),
|
||||
@@ -2381,7 +2416,7 @@ class InstallModlistScreen(QWidget):
|
||||
updated_context = {
|
||||
'name': modlist_name,
|
||||
'path': install_dir,
|
||||
'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"),
|
||||
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
||||
'modlist_value': None,
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', '2560x1600'),
|
||||
@@ -2616,6 +2651,26 @@ class InstallModlistScreen(QWidget):
|
||||
|
||||
self._safe_append_text("Installation cancelled by user.")
|
||||
|
||||
def _show_somnium_post_install_guidance(self):
|
||||
"""Show guidance popup for Somnium post-installation steps"""
|
||||
from ..widgets.message_service import MessageService
|
||||
|
||||
guidance_text = f"""<b>Somnium Post-Installation Required</b><br><br>
|
||||
Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:<br><br>
|
||||
<b>1.</b> Launch the Steam shortcut created for Somnium<br>
|
||||
<b>2.</b> In ModOrganizer, go to Settings → Executables<br>
|
||||
<b>3.</b> For each executable entry (SKSE64, etc.), update the binary path to point to:<br>
|
||||
<code>{self._somnium_install_dir}/files/root/Enderal Special Edition/skse64_loader.exe</code><br><br>
|
||||
<b>Note:</b> Full Somnium support will be added in a future Jackify update.<br><br>
|
||||
<i>You can also refer to the Somnium installation guide at:<br>
|
||||
https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
||||
|
||||
MessageService.information(self, "Somnium Setup Required", guidance_text)
|
||||
|
||||
# Reset the guidance flag
|
||||
self._show_somnium_guidance = False
|
||||
self._somnium_install_dir = None
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
"""Handle Cancel button - clean up processes and go back"""
|
||||
self.cleanup_processes()
|
||||
|
||||
@@ -94,6 +94,7 @@ class UnsupportedGameDialog(QDialog):
|
||||
<li><strong>Oblivion</strong></li>
|
||||
<li><strong>Starfield</strong></li>
|
||||
<li><strong>Oblivion Remastered</strong></li>
|
||||
<li><strong>Enderal</strong></li>
|
||||
</ul>
|
||||
|
||||
<p>For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.</p>
|
||||
@@ -113,6 +114,7 @@ class UnsupportedGameDialog(QDialog):
|
||||
<li><strong>Oblivion</strong></li>
|
||||
<li><strong>Starfield</strong></li>
|
||||
<li><strong>Oblivion Remastered</strong></li>
|
||||
<li><strong>Enderal</strong></li>
|
||||
</ul>
|
||||
|
||||
<p>For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.</p>
|
||||
|
||||
Reference in New Issue
Block a user