mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:07:45 +02:00
402 lines
20 KiB
Python
402 lines
20 KiB
Python
"""Settings dialog for Jackify GUI."""
|
|
from PySide6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QCheckBox,
|
|
QTabWidget, QFileDialog, QMessageBox, QProgressDialog, QApplication, QToolButton
|
|
)
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtGui import QIcon
|
|
from pathlib import Path
|
|
import json
|
|
import os
|
|
import logging
|
|
|
|
from jackify.frontends.gui.services.message_service import MessageService
|
|
from .settings_dialog_tabs import SettingsDialogTabsMixin
|
|
from .settings_dialog_proton import SettingsDialogProtonMixin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog):
|
|
def __init__(self, parent=None):
|
|
try:
|
|
super().__init__(parent)
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
import logging
|
|
self.logger = logging.getLogger(__name__)
|
|
self.config_handler = ConfigHandler()
|
|
self._original_debug_mode = self.config_handler.get('debug_mode', False)
|
|
self.setWindowTitle("Settings")
|
|
self.setModal(True)
|
|
self.setMinimumWidth(650)
|
|
self.setMaximumWidth(800)
|
|
self.setStyleSheet("QDialog { background-color: #232323; color: #eee; } QPushButton:hover { background-color: #333; }")
|
|
|
|
main_layout = QVBoxLayout()
|
|
self.setLayout(main_layout)
|
|
|
|
# Create tab widget
|
|
self.tab_widget = QTabWidget()
|
|
self.tab_widget.setStyleSheet("""
|
|
QTabWidget::pane { border: 1px solid #555; background: #232323; }
|
|
QTabBar::tab { background: #333; color: #eee; padding: 8px 16px; margin: 2px; }
|
|
QTabBar::tab:selected { background: #555; }
|
|
QTabBar::tab:hover { background: #444; }
|
|
""")
|
|
main_layout.addWidget(self.tab_widget)
|
|
|
|
# Create tabs
|
|
self._create_general_tab()
|
|
self._create_advanced_tab()
|
|
|
|
# --- Save/Close/Help Buttons ---
|
|
btn_layout = QHBoxLayout()
|
|
self.help_btn = QPushButton("Help")
|
|
self.help_btn.setToolTip("Help/documentation coming soon!")
|
|
self.help_btn.clicked.connect(self._show_help)
|
|
btn_layout.addWidget(self.help_btn)
|
|
btn_layout.addStretch(1)
|
|
save_btn = QPushButton("Save")
|
|
close_btn = QPushButton("Close")
|
|
save_btn.clicked.connect(self._save)
|
|
close_btn.clicked.connect(self.reject)
|
|
btn_layout.addWidget(save_btn)
|
|
btn_layout.addWidget(close_btn)
|
|
|
|
# Add error label for validation messages
|
|
self.error_label = QLabel("")
|
|
self.error_label.setStyleSheet("QLabel { color: #ff6b6b; }")
|
|
main_layout.addWidget(self.error_label)
|
|
|
|
main_layout.addSpacing(10)
|
|
main_layout.addLayout(btn_layout)
|
|
|
|
except Exception as e:
|
|
print(f"[ERROR] Exception in SettingsDialog.__init__: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
def _toggle_api_key_visibility(self, checked):
|
|
eye_icon = QIcon.fromTheme("view-visible")
|
|
if not eye_icon.isNull():
|
|
self.api_show_btn.setIcon(eye_icon)
|
|
self.api_show_btn.setText("")
|
|
else:
|
|
self.api_show_btn.setIcon(QIcon())
|
|
self.api_show_btn.setText("\U0001F441")
|
|
if checked:
|
|
self.api_key_edit.setEchoMode(QLineEdit.Normal)
|
|
self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }")
|
|
else:
|
|
self.api_key_edit.setEchoMode(QLineEdit.Password)
|
|
self.api_show_btn.setStyleSheet("")
|
|
|
|
def _pick_directory(self, line_edit):
|
|
dir_path = QFileDialog.getExistingDirectory(self, "Select Directory", line_edit.text() or os.path.expanduser("~"))
|
|
if dir_path:
|
|
line_edit.setText(os.path.realpath(dir_path))
|
|
|
|
def _show_help(self):
|
|
MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low")
|
|
|
|
def _load_json(self, path):
|
|
if os.path.exists(path):
|
|
try:
|
|
with open(path, 'r') as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
def _save_json(self, path, data):
|
|
try:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, 'w') as f:
|
|
json.dump(data, f, indent=2)
|
|
except Exception as e:
|
|
MessageService.warning(self, "Error", f"Failed to save {path}: {e}", safety_level="medium")
|
|
|
|
def _clear_api_key(self):
|
|
self.api_key_edit.setText("")
|
|
self.config_handler.clear_api_key()
|
|
MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low")
|
|
|
|
def _on_api_key_changed(self, text):
|
|
api_key = text.strip()
|
|
self.config_handler.save_api_key(api_key)
|
|
|
|
def _update_oauth_status(self):
|
|
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
|
auth_service = NexusAuthService()
|
|
authenticated, method, username = auth_service.get_auth_status()
|
|
if authenticated and method == 'oauth':
|
|
tier_label = ""
|
|
try:
|
|
token = auth_service.get_auth_token()
|
|
if token:
|
|
from jackify.backend.services.nexus_premium_service import NexusPremiumService
|
|
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=True)
|
|
tier_label = " [Premium]" if is_premium else " [Free]"
|
|
except Exception:
|
|
pass
|
|
display = f"Authorised as {username}{tier_label}" if username else "Authorised"
|
|
self.oauth_status_label.setText(display)
|
|
self.oauth_status_label.setStyleSheet("color: #3fd0ea;")
|
|
self.oauth_btn.setText("Revoke")
|
|
elif method == 'oauth_expired':
|
|
self.oauth_status_label.setText("OAuth token expired")
|
|
self.oauth_status_label.setStyleSheet("color: #FFA726;")
|
|
self.oauth_btn.setText("Re-authorise")
|
|
else:
|
|
self.oauth_status_label.setText("Not authorised")
|
|
self.oauth_status_label.setStyleSheet("color: #f44336;")
|
|
self.oauth_btn.setText("Authorise")
|
|
|
|
def _handle_oauth_click(self):
|
|
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
|
auth_service = NexusAuthService()
|
|
authenticated, method, _ = auth_service.get_auth_status()
|
|
if authenticated and method == 'oauth':
|
|
reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low")
|
|
if reply == QMessageBox.Yes:
|
|
auth_service.revoke_oauth()
|
|
self._update_oauth_status()
|
|
MessageService.information(self, "Revoked", "OAuth authorisation has been revoked.", safety_level="low")
|
|
else:
|
|
reply = MessageService.question(self, "Authorise with Nexus",
|
|
"Your browser will open for Nexus authorisation.\n\n"
|
|
"Note: Your browser may ask permission to open 'xdg-open'\n"
|
|
"or Jackify's protocol handler - please click 'Open' or 'Allow'.\n\n"
|
|
"Please log in and authorise Jackify when prompted.\n\n"
|
|
"Continue?", safety_level="low")
|
|
if reply != QMessageBox.Yes:
|
|
return
|
|
progress = QProgressDialog(
|
|
"Waiting for authorisation...\n\nPlease check your browser.",
|
|
"Cancel", 0, 0, self
|
|
)
|
|
progress.setWindowTitle("Nexus OAuth")
|
|
progress.setWindowModality(Qt.WindowModal)
|
|
progress.setMinimumDuration(0)
|
|
progress.setMinimumWidth(400)
|
|
progress.show()
|
|
QApplication.processEvents()
|
|
def show_message(msg):
|
|
progress.setLabelText(f"Waiting for authorisation...\n\n{msg}")
|
|
QApplication.processEvents()
|
|
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
|
|
progress.close()
|
|
QApplication.processEvents()
|
|
self._update_oauth_status()
|
|
if success:
|
|
_, _, username = auth_service.get_auth_status()
|
|
msg = "OAuth authorisation successful!"
|
|
if username:
|
|
msg += f"\n\nAuthorised as: {username}"
|
|
MessageService.information(self, "Success", msg, safety_level="low")
|
|
else:
|
|
MessageService.warning(self, "Failed", "OAuth authorisation failed or was cancelled.", safety_level="low")
|
|
|
|
def _save(self):
|
|
try:
|
|
# Validate values (only if resource_edits exist)
|
|
for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items():
|
|
if max_tasks_spin.value() > 128:
|
|
self.error_label.setText(f"Invalid value for {k}: Max Tasks must be <= 128.")
|
|
return
|
|
if self.bandwidth_spin and self.bandwidth_spin.value() > 1000000:
|
|
self.error_label.setText("Bandwidth limit must be <= 1,000,000 KB/s.")
|
|
return
|
|
self.error_label.setText("")
|
|
|
|
# Save resource settings
|
|
for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items():
|
|
resource_data = self.resource_settings.get(k, {})
|
|
resource_data['MaxTasks'] = max_tasks_spin.value()
|
|
self.resource_settings[k] = resource_data
|
|
|
|
# Save bandwidth limit to Downloads resource MaxThroughput (only if bandwidth UI exists)
|
|
if self.bandwidth_spin:
|
|
if "Downloads" not in self.resource_settings:
|
|
self.resource_settings["Downloads"] = {"MaxTasks": 16} # Provide default MaxTasks
|
|
# Convert KB/s to bytes/s for storage (resource_settings.json expects bytes)
|
|
bandwidth_kb = self.bandwidth_spin.value()
|
|
bandwidth_bytes = bandwidth_kb * 1024
|
|
self.resource_settings["Downloads"]["MaxThroughput"] = bandwidth_bytes
|
|
|
|
# Save all resource settings (including bandwidth) in one operation
|
|
self._save_json(self.resource_settings_path, self.resource_settings)
|
|
|
|
# Save debug mode to config
|
|
self.config_handler.set('debug_mode', self.debug_checkbox.isChecked())
|
|
# OAuth disabled for v0.1.8 - no fallback setting needed
|
|
# Save API key
|
|
api_key = self.api_key_edit.text().strip()
|
|
self.config_handler.save_api_key(api_key)
|
|
# 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)
|
|
|
|
# Initialize with existing config values as fallback (prevents UnboundLocalError if auto-detection fails)
|
|
resolved_install_path = self.config_handler.get("proton_path", "")
|
|
resolved_install_version = self.config_handler.get("proton_version", "")
|
|
|
|
# Save Install Proton selection - resolve "auto" to actual path
|
|
selected_install_proton_path = self.install_proton_dropdown.currentData()
|
|
if selected_install_proton_path == "none":
|
|
# No Proton detected - warn user but allow saving other settings
|
|
MessageService.warning(
|
|
self,
|
|
"No Compatible Proton Installed",
|
|
"Jackify requires Proton 9.0+, Proton Experimental, or GE-Proton 10+ to install modlists.\n\n"
|
|
"To install Proton:\n"
|
|
"1. Install any Windows game in Steam (Proton downloads automatically), OR\n"
|
|
"2. Install GE-Proton using ProtonPlus or ProtonUp-Qt, OR\n"
|
|
"3. Download GE-Proton manually from:\n"
|
|
" https://github.com/GloriousEggroll/proton-ge-custom/releases\n\n"
|
|
"Your other settings will be saved, but modlist installation may not work without Proton.",
|
|
safety_level="medium"
|
|
)
|
|
logger.warning("No Proton detected - user warned, allowing save to proceed for other settings")
|
|
# Don't modify Proton config, but continue to save other settings
|
|
elif selected_install_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_install_path = str(best_proton['path'])
|
|
resolved_install_version = best_proton['name']
|
|
self.config_handler.set("proton_path", resolved_install_path)
|
|
self.config_handler.set("proton_version", resolved_install_version)
|
|
else:
|
|
# No Proton found - clear persisted selection so startup normalization
|
|
# can auto-heal once a compatible Proton is installed.
|
|
logger.warning("Auto Proton selection failed: No Proton versions found")
|
|
resolved_install_path = None
|
|
resolved_install_version = None
|
|
self.config_handler.set("proton_path", None)
|
|
self.config_handler.set("proton_version", None)
|
|
except Exception as e:
|
|
# Exception during detection - clear persisted selection to avoid stale path usage.
|
|
logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True)
|
|
resolved_install_path = None
|
|
resolved_install_version = None
|
|
self.config_handler.set("proton_path", None)
|
|
self.config_handler.set("proton_version", None)
|
|
else:
|
|
# User selected specific Proton version
|
|
resolved_install_path = selected_install_proton_path
|
|
resolved_install_version = self.install_proton_dropdown.currentText()
|
|
self.config_handler.set("proton_path", resolved_install_path)
|
|
self.config_handler.set("proton_version", resolved_install_version)
|
|
|
|
# Save Game Proton selection
|
|
selected_game_proton_path = self.game_proton_dropdown.currentData()
|
|
if selected_game_proton_path == "same_as_install":
|
|
# Use same as install proton
|
|
resolved_game_path = resolved_install_path
|
|
resolved_game_version = resolved_install_version
|
|
else:
|
|
# User selected specific game Proton version
|
|
resolved_game_path = selected_game_proton_path
|
|
resolved_game_version = self.game_proton_dropdown.currentText()
|
|
|
|
self.config_handler.set("game_proton_path", resolved_game_path)
|
|
self.config_handler.set("game_proton_version", resolved_game_version)
|
|
|
|
# Save component installation method preference
|
|
if self.winetricks_radio.isChecked():
|
|
method = 'winetricks'
|
|
else: # protontricks_radio (alternative)
|
|
method = 'system_protontricks'
|
|
|
|
old_method = self.config_handler.get('component_installation_method', 'winetricks')
|
|
method_changed = (old_method != method)
|
|
|
|
self.config_handler.set("component_installation_method", method)
|
|
self.config_handler.set("use_winetricks_for_components", method == 'winetricks')
|
|
|
|
# Force immediate save and verify
|
|
save_result = self.config_handler.save_config()
|
|
if not save_result:
|
|
self.logger.error("Failed to save Proton configuration")
|
|
else:
|
|
self.logger.info(f"Saved Proton config: install_path={resolved_install_path}, game_path={resolved_game_path}")
|
|
# Verify the save worked by reading it back
|
|
saved_path = self.config_handler.get("proton_path")
|
|
if saved_path != resolved_install_path:
|
|
self.logger.error(f"Config save verification failed: expected {resolved_install_path}, got {saved_path}")
|
|
else:
|
|
self.logger.debug("Config save verified successfully")
|
|
|
|
# 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:
|
|
reply = MessageService.question(self, "Restart Required", "Debug mode change requires a restart. Restart Jackify now?", safety_level="medium")
|
|
if reply == QMessageBox.Yes:
|
|
import os, sys
|
|
# User requested restart - do it regardless of execution environment
|
|
self.accept()
|
|
|
|
# Check if running from AppImage
|
|
if os.environ.get('APPIMAGE'):
|
|
# AppImage: restart the AppImage
|
|
os.execv(os.environ['APPIMAGE'], [os.environ['APPIMAGE']] + sys.argv[1:])
|
|
else:
|
|
# Dev mode: restart the Python module
|
|
os.execv(sys.executable, [sys.executable, '-m', 'jackify.frontends.gui'] + sys.argv[1:])
|
|
return
|
|
|
|
# If we get here, no restart was needed
|
|
# Check protontricks if user just switched to it
|
|
if method_changed and method == 'system_protontricks':
|
|
main_window = self.parent()
|
|
if main_window and hasattr(main_window, 'protontricks_service'):
|
|
is_installed, installation_type, details = main_window.protontricks_service.detect_protontricks(use_cache=False)
|
|
if not is_installed:
|
|
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
|
|
dialog = ProtontricksErrorDialog(main_window.protontricks_service, main_window)
|
|
dialog.exec()
|
|
|
|
MessageService.information(self, "Settings Saved", "Settings have been saved successfully.", safety_level="low")
|
|
self.accept()
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error saving settings: {e}")
|
|
MessageService.warning(self, "Save Error", f"Failed to save settings: {e}", safety_level="medium")
|
|
|
|
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),
|
|
]
|
|
|
|
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;")
|
|
return label
|