Files
Jackify/jackify/frontends/gui/dialogs/settings_dialog.py
2026-02-07 18:26:54 +00:00

387 lines
19 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(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':
self.oauth_status_label.setText(f"Authorised as {username}" if username else "Authorised")
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 - don't write anything, let engine auto-detect
logger.warning("Auto Proton selection failed: No Proton versions found")
# Don't modify existing config values
except Exception as e:
# Exception during detection - log it and don't write anything
logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True)
# Don't modify existing config values
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="low")
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