Sync from development - prepare for v0.3.0

This commit is contained in:
Omni
2026-02-07 18:26:54 +00:00
parent b55e1cf768
commit 12294d3186
169 changed files with 31749 additions and 33649 deletions

View File

@@ -0,0 +1,386 @@
"""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

View File

@@ -0,0 +1,114 @@
"""
Settings dialog Proton dropdown population and refresh.
"""
import logging
logger = logging.getLogger(__name__)
class SettingsDialogProtonMixin:
"""Mixin providing Proton dropdown population and refresh for SettingsDialog."""
def _get_proton_10_path(self):
try:
from jackify.backend.handlers.wine_utils import WineUtils
available_protons = WineUtils.scan_valve_proton_versions()
for proton in available_protons:
if proton['version'].startswith('10.'):
return proton['path']
return 'auto'
except Exception:
return 'auto'
def _populate_install_proton_dropdown(self):
try:
from jackify.backend.handlers.wine_utils import WineUtils
available_protons = WineUtils.scan_all_proton_versions()
has_proton = len(available_protons) > 0
if has_proton:
self.install_proton_dropdown.addItem("Auto (Recommended)", "auto")
else:
self.install_proton_dropdown.addItem("No Proton Versions Detected", "none")
fast_protons = []
slow_protons = []
for proton in available_protons:
proton_name = proton.get('name', 'Unknown Proton')
proton_type = proton.get('type', 'Unknown')
if proton_type not in ('GE-Proton', 'Valve-Proton'):
logger.debug(
"Skipping %s (%s) from Install Proton dropdown - third-party builds may have compatibility issues",
proton_name, proton_type
)
continue
slow_warning = False
is_fast_proton = False
display_name = proton_name
if proton_name == "Proton - Experimental":
is_fast_proton = True
elif proton_type == 'GE-Proton':
major_version = proton.get('major_version')
if major_version is not None and isinstance(major_version, int) and major_version >= 10:
is_fast_proton = True
elif 'GE-Proton9' in proton_name or 'GE-Proton8' in proton_name:
slow_warning = True
display_name = f"{proton_name} (GE)"
elif proton_type == 'Valve-Proton':
if proton_name.startswith("Proton 9") or "9.0" in proton_name:
slow_warning = True
if slow_warning:
display_name = f"{display_name} (Slow texture processing)"
slow_protons.append((display_name, str(proton['path'])))
else:
fast_protons.append((display_name, str(proton['path'])))
for display_name, path in fast_protons:
self.install_proton_dropdown.addItem(display_name, path)
if slow_protons:
self.install_proton_dropdown.insertSeparator(self.install_proton_dropdown.count())
for display_name, path in slow_protons:
self.install_proton_dropdown.addItem(display_name, path)
saved_proton = self.config_handler.get('proton_path', self._get_proton_10_path())
self._set_dropdown_selection(self.install_proton_dropdown, saved_proton)
except Exception as e:
logger.error("Failed to populate install Proton dropdown: %s", e)
self.install_proton_dropdown.addItem("Auto (Recommended)", "auto")
def _populate_game_proton_dropdown(self):
try:
from jackify.backend.handlers.wine_utils import WineUtils
available_protons = WineUtils.scan_all_proton_versions()
self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install")
for proton in available_protons:
proton_name = proton.get('name', 'Unknown Proton')
proton_type = proton.get('type', 'Unknown')
display_name = f"{proton_name} (GE)" if proton_type == 'GE-Proton' else proton_name
self.game_proton_dropdown.addItem(display_name, str(proton['path']))
saved_game_proton = self.config_handler.get('game_proton_path', 'same_as_install')
self._set_dropdown_selection(self.game_proton_dropdown, saved_game_proton)
except Exception as e:
logger.error("Failed to populate game Proton dropdown: %s", e)
self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install")
def _set_dropdown_selection(self, dropdown, saved_value):
found_match = False
for i in range(dropdown.count()):
if dropdown.itemData(i) == saved_value:
dropdown.setCurrentIndex(i)
found_match = True
break
if not found_match and saved_value not in ["auto", "same_as_install"]:
dropdown.setCurrentIndex(0)
def _refresh_install_proton_dropdown(self):
current_selection = self.install_proton_dropdown.currentData()
self.install_proton_dropdown.clear()
self._populate_install_proton_dropdown()
self._set_dropdown_selection(self.install_proton_dropdown, current_selection)
def _refresh_game_proton_dropdown(self):
current_selection = self.game_proton_dropdown.currentData()
self.game_proton_dropdown.clear()
self._populate_game_proton_dropdown()
self._set_dropdown_selection(self.game_proton_dropdown, current_selection)

View File

@@ -0,0 +1,280 @@
"""
Settings dialog tab creation: General and Advanced tabs.
"""
import os
import logging
from pathlib import Path
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QCheckBox,
QComboBox, QGroupBox, QFormLayout, QGridLayout, QSpinBox, QRadioButton, QButtonGroup,
QToolButton
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
logger = logging.getLogger(__name__)
class SettingsDialogTabsMixin:
"""Mixin providing _create_general_tab and _create_advanced_tab for SettingsDialog."""
def _create_general_tab(self):
general_tab = QWidget()
general_layout = QVBoxLayout(general_tab)
dir_group = QGroupBox("Directory 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; }")
dir_layout = QFormLayout()
dir_group.setLayout(dir_layout)
self.install_dir_edit = QLineEdit(self.config_handler.get("modlist_install_base_dir", ""))
self.install_dir_edit.setToolTip("Default directory for modlist installations.")
self.install_dir_btn = QPushButton()
self.install_dir_btn.setIcon(QIcon.fromTheme("folder-open"))
self.install_dir_btn.setToolTip("Browse for directory")
self.install_dir_btn.setFixedWidth(32)
self.install_dir_btn.clicked.connect(lambda: self._pick_directory(self.install_dir_edit))
install_dir_row = QHBoxLayout()
install_dir_row.addWidget(self.install_dir_edit)
install_dir_row.addWidget(self.install_dir_btn)
dir_layout.addRow(QLabel("Install Base Dir:"), install_dir_row)
self.download_dir_edit = QLineEdit(self.config_handler.get("modlist_downloads_base_dir", ""))
self.download_dir_edit.setToolTip("Default directory for modlist downloads.")
self.download_dir_btn = QPushButton()
self.download_dir_btn.setIcon(QIcon.fromTheme("folder-open"))
self.download_dir_btn.setToolTip("Browse for directory")
self.download_dir_btn.setFixedWidth(32)
self.download_dir_btn.clicked.connect(lambda: self._pick_directory(self.download_dir_edit))
download_dir_row = QHBoxLayout()
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)
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_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)
general_layout.addWidget(dir_group)
general_layout.addSpacing(12)
proton_group = QGroupBox("Proton Version Settings")
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 = QVBoxLayout()
proton_group.setLayout(proton_layout)
install_proton_layout = QHBoxLayout()
self.install_proton_dropdown = QComboBox()
self.install_proton_dropdown.setToolTip("Proton version for modlist installation and texture processing (requires fast Proton)")
self.install_proton_dropdown.setMinimumWidth(200)
install_refresh_btn = QPushButton("\u21BB")
install_refresh_btn.setFixedSize(30, 30)
install_refresh_btn.setToolTip("Refresh install Proton version list")
install_refresh_btn.clicked.connect(self._refresh_install_proton_dropdown)
install_proton_layout.addWidget(QLabel("Install Proton:"))
install_proton_layout.addWidget(self.install_proton_dropdown)
install_proton_layout.addWidget(install_refresh_btn)
install_proton_layout.addStretch()
game_proton_layout = QHBoxLayout()
self.game_proton_dropdown = QComboBox()
self.game_proton_dropdown.setToolTip("Proton version for game shortcuts (can be any Proton 9+)")
self.game_proton_dropdown.setMinimumWidth(200)
game_refresh_btn = QPushButton("\u21BB")
game_refresh_btn.setFixedSize(30, 30)
game_refresh_btn.setToolTip("Refresh game Proton version list")
game_refresh_btn.clicked.connect(self._refresh_game_proton_dropdown)
game_proton_layout.addWidget(QLabel("Game Proton:"))
game_proton_layout.addWidget(self.game_proton_dropdown)
game_proton_layout.addWidget(game_refresh_btn)
game_proton_layout.addStretch()
proton_layout.addLayout(install_proton_layout)
proton_layout.addLayout(game_proton_layout)
self._populate_install_proton_dropdown()
self._populate_game_proton_dropdown()
general_layout.addWidget(proton_group)
general_layout.addSpacing(12)
from jackify.frontends.gui.services.message_service import MessageService
oauth_group = QGroupBox("Nexus Authentication")
oauth_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; }")
oauth_layout = QVBoxLayout()
oauth_group.setLayout(oauth_layout)
oauth_status_layout = QHBoxLayout()
self.oauth_status_label = QLabel("Checking...")
self.oauth_status_label.setStyleSheet("color: #ccc;")
self.oauth_btn = QPushButton("Authorise")
self.oauth_btn.setMaximumWidth(100)
self.oauth_btn.clicked.connect(self._handle_oauth_click)
oauth_status_layout.addWidget(QLabel("Status:"))
oauth_status_layout.addWidget(self.oauth_status_label)
oauth_status_layout.addWidget(self.oauth_btn)
oauth_status_layout.addStretch()
oauth_layout.addLayout(oauth_status_layout)
self._update_oauth_status()
general_layout.addWidget(oauth_group)
general_layout.addSpacing(12)
debug_group = QGroupBox("Enable Debug")
debug_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; }")
debug_layout = QVBoxLayout()
debug_group.setLayout(debug_layout)
self.debug_checkbox = QCheckBox("Enable debug mode (requires restart)")
self.debug_checkbox.setChecked(self.config_handler.get('debug_mode', False))
self.debug_checkbox.setToolTip("Enable verbose debug logging. Requires Jackify restart to take effect.")
self.debug_checkbox.setStyleSheet("color: #fff;")
debug_layout.addWidget(self.debug_checkbox)
general_layout.addWidget(debug_group)
general_layout.addStretch()
self.tab_widget.addTab(general_tab, "General")
def _create_advanced_tab(self):
advanced_tab = QWidget()
advanced_layout = QVBoxLayout(advanced_tab)
auth_group = QGroupBox("Nexus Authentication")
auth_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; }")
auth_layout = QVBoxLayout()
auth_group.setLayout(auth_layout)
api_layout = QHBoxLayout()
self.api_key_edit = QLineEdit()
self.api_key_edit.setEchoMode(QLineEdit.Password)
api_key = self.config_handler.get_api_key()
self.api_key_edit.setText(api_key if api_key else "")
self.api_key_edit.setToolTip("Your Nexus API Key (legacy authentication method)")
self.api_key_edit.textChanged.connect(self._on_api_key_changed)
self.api_show_btn = QToolButton()
self.api_show_btn.setCheckable(True)
self.api_show_btn.setIcon(QIcon.fromTheme("view-visible"))
self.api_show_btn.setToolTip("Show or hide your API key")
self.api_show_btn.toggled.connect(self._toggle_api_key_visibility)
clear_api_btn = QPushButton("Clear")
clear_api_btn.clicked.connect(self._clear_api_key)
clear_api_btn.setMaximumWidth(60)
api_layout.addWidget(QLabel("API Key:"))
api_layout.addWidget(self.api_key_edit)
api_layout.addWidget(self.api_show_btn)
api_layout.addWidget(clear_api_btn)
auth_layout.addLayout(api_layout)
advanced_layout.addWidget(auth_group)
advanced_layout.addSpacing(12)
self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json")
self.resource_settings = self._load_json(self.resource_settings_path)
self.resource_edits = {}
resource_group = QGroupBox("Resource Limits")
resource_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; }")
resource_outer_layout = QVBoxLayout()
resource_group.setLayout(resource_outer_layout)
if not self.resource_settings:
info_label = QLabel("Resource Limit settings will be generated once a modlist install action is performed")
info_label.setStyleSheet("color: #aaa; font-style: italic; padding: 20px; font-size: 11pt;")
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter)
info_label.setMinimumHeight(60)
resource_outer_layout.addWidget(info_label)
else:
resource_grid = QGridLayout()
resource_grid.setVerticalSpacing(4)
resource_grid.setHorizontalSpacing(8)
resource_grid.setColumnMinimumWidth(2, 40)
resource_grid.addWidget(self._bold_label("Resource"), 0, 0, 1, 1, Qt.AlignLeft)
resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 1, 1, 1, Qt.AlignLeft)
resource_grid.addWidget(self._bold_label("Resource"), 0, 3, 1, 1, Qt.AlignLeft)
resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 4, 1, 1, Qt.AlignLeft)
resource_items = list(self.resource_settings.items())
bandwidth_kb = 0
if "Downloads" in self.resource_settings:
bandwidth_kb = self.resource_settings["Downloads"].get("MaxThroughput", 0) // 1024 or 0
left_row = 1
for k, v in resource_items[:4]:
try:
resource_grid.addWidget(QLabel(f"{k}:", parent=self), left_row, 0, 1, 1, Qt.AlignLeft)
max_tasks_spin = QSpinBox()
max_tasks_spin.setMinimum(1)
max_tasks_spin.setMaximum(128)
max_tasks_spin.setValue(v.get('MaxTasks', 16))
max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.")
max_tasks_spin.setFixedWidth(100)
resource_grid.addWidget(max_tasks_spin, left_row, 1)
self.resource_edits[k] = (None, max_tasks_spin)
left_row += 1
except Exception as e:
self.logger.error("Failed to create widgets for resource '%s': %s", k, e)
continue
right_row = 1
for k, v in resource_items[4:]:
try:
resource_grid.addWidget(QLabel(f"{k}:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft)
max_tasks_spin = QSpinBox()
max_tasks_spin.setMinimum(1)
max_tasks_spin.setMaximum(128)
max_tasks_spin.setValue(v.get('MaxTasks', 16))
max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.")
max_tasks_spin.setFixedWidth(100)
resource_grid.addWidget(max_tasks_spin, right_row, 4)
self.resource_edits[k] = (None, max_tasks_spin)
right_row += 1
except Exception as e:
self.logger.error("Failed to create widgets for resource '%s': %s", k, e)
continue
if "Downloads" in self.resource_settings:
resource_grid.addWidget(QLabel("Bandwidth Limit:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft)
self.bandwidth_spin = QSpinBox()
self.bandwidth_spin.setMinimum(0)
self.bandwidth_spin.setMaximum(1000000)
self.bandwidth_spin.setValue(bandwidth_kb)
self.bandwidth_spin.setSuffix(" KB/s")
self.bandwidth_spin.setFixedWidth(100)
self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.")
bandwidth_widget_layout = QHBoxLayout()
bandwidth_widget_layout.setContentsMargins(0, 0, 0, 0)
bandwidth_widget_layout.addWidget(self.bandwidth_spin)
bandwidth_note = QLabel("(0 = unlimited)")
bandwidth_note.setStyleSheet("color: #aaa; font-size: 9pt;")
bandwidth_widget_layout.addWidget(bandwidth_note)
bandwidth_widget_layout.addStretch()
bandwidth_container = QWidget()
bandwidth_container.setLayout(bandwidth_widget_layout)
resource_grid.addWidget(bandwidth_container, right_row, 4, 1, 1, Qt.AlignLeft)
else:
self.bandwidth_spin = None
resource_grid.setColumnStretch(5, 1)
resource_outer_layout.addLayout(resource_grid)
advanced_layout.addWidget(resource_group)
component_group = QGroupBox("Advanced Tool Options")
component_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; }")
component_layout = QVBoxLayout()
component_group.setLayout(component_layout)
component_layout.addWidget(QLabel("Wine Components Installation:"))
self.component_method_group = QButtonGroup()
component_method_layout = QVBoxLayout()
current_method = self.config_handler.get('component_installation_method', 'winetricks')
if current_method == 'bundled_protontricks':
current_method = 'system_protontricks'
self.winetricks_radio = QRadioButton("Winetricks (Default)")
self.winetricks_radio.setChecked(current_method == 'winetricks')
self.winetricks_radio.setToolTip("Use bundled winetricks for component installation. Faster and more reliable.")
self.component_method_group.addButton(self.winetricks_radio, 0)
component_method_layout.addWidget(self.winetricks_radio)
self.protontricks_radio = QRadioButton("Protontricks (Alternative)")
self.protontricks_radio.setChecked(current_method == 'system_protontricks')
self.protontricks_radio.setToolTip("Use system-installed protontricks (flatpak or native). Fallback option if winetricks fails.")
self.component_method_group.addButton(self.protontricks_radio, 1)
component_method_layout.addWidget(self.protontricks_radio)
component_layout.addLayout(component_method_layout)
advanced_layout.addWidget(component_group)
advanced_layout.addStretch()
self.tab_widget.addTab(advanced_tab, "Advanced")

View File

@@ -244,7 +244,7 @@ class SuccessDialog(QDialog):
else:
base_message = f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!"
# Note: ENB-specific Proton warning is now shown in a separate dialog when ENB is detected
# ENB Proton warning shown in separate dialog
return base_message
def _update_countdown(self):