""" Jackify GUI Frontend Main Application Main entry point for the Jackify GUI application using PySide6. This replaces the legacy jackify_gui implementation with a refactored architecture. """ import sys import os import logging from pathlib import Path # Suppress xkbcommon locale errors (harmless but annoying) os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.qpa.*=false;*.warning=false' os.environ['QT_ENABLE_GLYPH_CACHE_WORKAROUND'] = '1' # Hidden diagnostic flag for debugging AppImage/bundled environment issues - must be first if '--env-diagnostic' in sys.argv: import json from datetime import datetime print("Bundled Environment Diagnostic") print("=" * 50) # Check if we're running from a frozen bundle is_frozen = getattr(sys, 'frozen', False) meipass = getattr(sys, '_MEIPASS', None) print(f"Frozen: {is_frozen}") print(f"_MEIPASS: {meipass}") # Capture environment data env_data = { 'timestamp': datetime.now().isoformat(), 'context': 'appimage_runtime', 'frozen': is_frozen, 'meipass': meipass, 'python_executable': sys.executable, 'working_directory': os.getcwd(), 'sys_path': sys.path, } # Bundle-specific environment variables bundle_vars = {} for key, value in os.environ.items(): if any(term in key.lower() for term in ['mei', 'appimage', 'tmp']): bundle_vars[key] = value env_data['bundle_vars'] = bundle_vars # Check LD_LIBRARY_PATH ld_path = os.environ.get('LD_LIBRARY_PATH', '') if ld_path: suspicious = [p for p in ld_path.split(':') if 'mei' in p.lower() or 'tmp' in p.lower()] env_data['ld_library_path'] = ld_path env_data['ld_library_path_suspicious'] = suspicious # Try to find jackify-engine from bundled context engine_paths = [] if meipass: meipass_path = Path(meipass) potential_engine = meipass_path / "jackify" / "engine" / "jackify-engine" if potential_engine.exists(): engine_paths.append(str(potential_engine)) env_data['engine_paths_found'] = engine_paths # Output the results print("\nEnvironment Data:") print(json.dumps(env_data, indent=2)) # Save to file try: output_file = Path.cwd() / "bundle_env_capture.json" with open(output_file, 'w') as f: json.dump(env_data, f, indent=2) print(f"\nData saved to: {output_file}") except Exception as e: print(f"\nCould not save data: {e}") sys.exit(0) 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) if '-v' in sys.argv or '--version' in sys.argv or '-V' in sys.argv: print(f"Jackify version {jackify_version}") sys.exit(0) from jackify import __version__ # Add src directory to Python path src_dir = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(src_dir)) from PySide6.QtWidgets import ( QSizePolicy, QScrollArea, QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QPushButton, QStackedWidget, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QCheckBox, QSpinBox, QMessageBox, QGroupBox, QGridLayout, QFileDialog, QToolButton, QStyle, QComboBox, QTabWidget, QRadioButton, QButtonGroup ) from PySide6.QtCore import Qt, QEvent, QTimer from PySide6.QtGui import QIcon import json # Import backend services and models from jackify.backend.models.configuration import SystemInfo from jackify.backend.services.modlist_service import ModlistService from jackify.frontends.gui.services.message_service import MessageService from jackify.frontends.gui.shared_theme import DEBUG_BORDERS from jackify.frontends.gui.utils import get_screen_geometry, set_responsive_minimum ENABLE_WINDOW_HEIGHT_ANIMATION = False 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) # Constants for styling and disclaimer DISCLAIMER_TEXT = ( "Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, " "without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. " "The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. " "Please back up your data and use caution." ) MENU_ITEMS = [ ("Modlist Tasks", "modlist_tasks"), ("Hoolamike Tasks", "hoolamike_tasks"), ("Additional Tasks", "additional_tasks"), ("Exit Jackify", "exit_jackify"), ] class FeaturePlaceholder(QWidget): """Placeholder widget for features not yet implemented""" def __init__(self, stacked_widget=None): super().__init__() layout = QVBoxLayout() label = QLabel("[Feature screen placeholder]") label.setAlignment(Qt.AlignCenter) layout.addWidget(label) back_btn = QPushButton("Back to Main Menu") if stacked_widget: back_btn.clicked.connect(lambda: stacked_widget.setCurrentIndex(0)) layout.addWidget(back_btn) self.setLayout(layout) class SettingsDialog(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 _create_general_tab(self): """Create the General settings tab""" general_tab = QWidget() general_layout = QVBoxLayout(general_tab) # --- Directory Paths Section (moved to top as most essential) --- 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) # Jackify Data Directory 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 to default button 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 Version Settings Section --- 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 Version (for jackify-engine texture processing) 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("↻") 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 Version (for game shortcuts) 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("↻") 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) # Populate both Proton dropdowns self._populate_install_proton_dropdown() self._populate_game_proton_dropdown() general_layout.addWidget(proton_group) general_layout.addSpacing(12) # --- Nexus OAuth Section --- 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 and button 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) # Update OAuth status on init self._update_oauth_status() general_layout.addWidget(oauth_group) general_layout.addSpacing(12) # --- Enable Debug Section (moved to bottom as advanced option) --- 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)") # Load debug_mode from config 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() # Add stretch to push content to top self.tab_widget.addTab(general_tab, "General") def _create_advanced_tab(self): """Create the Advanced settings tab""" advanced_tab = QWidget() advanced_layout = QVBoxLayout(advanced_tab) # --- Nexus Authentication Section --- 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) # OAuth temporarily disabled for v0.1.8 - API key is primary auth method # API Key Fallback Checkbox (hidden until OAuth re-enabled) # self.api_key_fallback_checkbox = QCheckBox("Enable API Key Fallback (Legacy)") # self.api_key_fallback_checkbox.setChecked(self.config_handler.get("api_key_fallback_enabled", False)) # self.api_key_fallback_checkbox.setToolTip("Allow using API key if OAuth fails or is unavailable (not recommended)") # auth_layout.addWidget(self.api_key_fallback_checkbox) # API Key Section api_layout = QHBoxLayout() self.api_key_edit = QLineEdit() self.api_key_edit.setEchoMode(QLineEdit.Password) api_key = self.config_handler.get_api_key() if api_key: self.api_key_edit.setText(api_key) else: self.api_key_edit.setText("") 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) 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) 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 = {} # If no resources exist, show helpful message 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: # Two-column layout for better space usage # Use a single grid with proper column spacing resource_grid = QGridLayout() resource_grid.setVerticalSpacing(4) resource_grid.setHorizontalSpacing(8) resource_grid.setColumnMinimumWidth(2, 40) # Spacing between columns # Headers for left column (columns 0-1) 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) # Headers for right column (columns 3-4, skip column 2 for spacing) 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) # Split resources between left and right columns (4 + 4) resource_items = list(self.resource_settings.items()) # Find Bandwidth info from Downloads resource if it exists bandwidth_kb = 0 if "Downloads" in self.resource_settings: downloads_throughput_bytes = self.resource_settings["Downloads"].get("MaxThroughput", 0) bandwidth_kb = downloads_throughput_bytes // 1024 if downloads_throughput_bytes > 0 else 0 # Left column gets first 4 resources (columns 0-1) 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: print(f"[ERROR] Failed to create widgets for resource '{k}': {e}") continue # Right column gets next 4 resources (columns 3-4, skip column 2 for spacing) 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: print(f"[ERROR] Failed to create widgets for resource '{k}': {e}") continue # Add Bandwidth Limit at the bottom of right column 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.") # Create a layout for the spinbox and note 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() # Create container widget for the layout 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 # Add stretch column at the end to push content left resource_grid.setColumnStretch(5, 1) resource_outer_layout.addLayout(resource_grid) advanced_layout.addWidget(resource_group) # Advanced Tool Options Section 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) # Label for the radio buttons method_label = QLabel("Wine Components Installation:") component_layout.addWidget(method_label) # Radio button group for component installation method self.component_method_group = QButtonGroup() component_method_layout = QVBoxLayout() # Get current setting current_method = self.config_handler.get('component_installation_method', 'system_protontricks') # Migrate old bundled_protontricks users to system_protontricks if current_method == 'bundled_protontricks': current_method = 'system_protontricks' # Protontricks (default) self.protontricks_radio = QRadioButton("Protontricks (Default)") self.protontricks_radio.setChecked(current_method == 'system_protontricks') self.protontricks_radio.setToolTip( "Use system-installed protontricks (flatpak or native). Required for component installation." ) self.component_method_group.addButton(self.protontricks_radio, 0) component_method_layout.addWidget(self.protontricks_radio) # Winetricks (alternative) self.winetricks_radio = QRadioButton("Winetricks (Alternative)") self.winetricks_radio.setChecked(current_method == 'winetricks') self.winetricks_radio.setToolTip( "Use bundled winetricks instead. May work when protontricks unavailable." ) self.component_method_group.addButton(self.winetricks_radio, 1) component_method_layout.addWidget(self.winetricks_radio) component_layout.addLayout(component_method_layout) advanced_layout.addWidget(component_group) advanced_layout.addStretch() # Add stretch to push content to top self.tab_widget.addTab(advanced_tab, "Advanced") def _toggle_api_key_visibility(self, checked): # Always use the same eyeball icon, only change color when toggled 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; }") # Jackify blue 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): from jackify.frontends.gui.services.message_service import MessageService 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): """Handle immediate API key saving when text changes""" api_key = text.strip() self.config_handler.save_api_key(api_key) def _update_oauth_status(self): """Update OAuth status label and button""" 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): """Handle OAuth button click (Authorise or Revoke)""" from jackify.backend.services.nexus_auth_service import NexusAuthService from jackify.frontends.gui.services.message_service import MessageService from PySide6.QtWidgets import QMessageBox, QProgressDialog, QApplication from PySide6.QtCore import Qt auth_service = NexusAuthService() authenticated, method, _ = auth_service.get_auth_status() if authenticated and method == 'oauth': # Revoke 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: # Authorise with OAuth 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 _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_install_proton_dropdown(self): """Populate Install Proton dropdown (Experimental/GE-Proton 10+ only for fast texture processing)""" try: from jackify.backend.handlers.wine_utils import WineUtils # Get all available Proton versions available_protons = WineUtils.scan_all_proton_versions() # Add "Auto" option first self.install_proton_dropdown.addItem("Auto (Recommended)", "auto") # Filter for fast Proton versions only fast_protons = [] slow_protons = [] for proton in available_protons: proton_name = proton.get('name', 'Unknown Proton') proton_type = proton.get('type', 'Unknown') is_fast_proton = False # Fast Protons: Experimental, GE-Proton 10+ if proton_name == "Proton - Experimental": is_fast_proton = True elif proton_type == 'GE-Proton': # For GE-Proton, check major_version field major_version = proton.get('major_version', 0) if major_version >= 10: is_fast_proton = True if is_fast_proton: if proton_type == 'GE-Proton': display_name = f"{proton_name} (GE)" else: display_name = proton_name fast_protons.append((display_name, str(proton['path']))) else: # Slow Protons: Valve 9, 10 beta, older GE-Proton, etc. if proton_type == 'GE-Proton': display_name = f"{proton_name} (GE) (Slow texture processing)" else: display_name = f"{proton_name} (Slow texture processing)" slow_protons.append((display_name, str(proton['path']))) # Add fast Protons first for display_name, path in fast_protons: self.install_proton_dropdown.addItem(display_name, path) # Add separator and slow Protons with warnings 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) # Load saved preference 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(f"Failed to populate install Proton dropdown: {e}") self.install_proton_dropdown.addItem("Auto (Recommended)", "auto") def _populate_game_proton_dropdown(self): """Populate Game Proton dropdown (any Proton 9+ for game compatibility)""" try: from jackify.backend.handlers.wine_utils import WineUtils # Get all available Proton versions available_protons = WineUtils.scan_all_proton_versions() # Add "Same as Install" option first self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install") # Add all Proton 9+ versions for proton in available_protons: proton_name = proton.get('name', 'Unknown Proton') proton_type = proton.get('type', 'Unknown') # Add type indicator for clarity if proton_type == 'GE-Proton': display_name = f"{proton_name} (GE)" else: display_name = proton_name self.game_proton_dropdown.addItem(display_name, str(proton['path'])) # Load saved preference 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(f"Failed to populate game Proton dropdown: {e}") self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install") def _set_dropdown_selection(self, dropdown, saved_value): """Helper to set dropdown selection based on 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 no exact match and not auto/same_as_install, select first option if not found_match and saved_value not in ["auto", "same_as_install"]: dropdown.setCurrentIndex(0) def _refresh_install_proton_dropdown(self): """Refresh Install Proton dropdown""" 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): """Refresh Game Proton dropdown""" 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) 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) # Save Install Proton selection - resolve "auto" to actual path selected_install_proton_path = self.install_proton_dropdown.currentData() if 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'] else: resolved_install_path = "auto" resolved_install_version = "auto" except: resolved_install_path = "auto" resolved_install_version = "auto" else: # User selected specific Proton version resolved_install_path = selected_install_proton_path # Extract version from dropdown text 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 (default) 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 class JackifyMainWindow(QMainWindow): """Main window for Jackify GUI application""" def __init__(self, dev_mode=False): super().__init__() self.setWindowTitle("Jackify") self._window_margin = 32 self._base_min_width = 900 self._base_min_height = 520 self._compact_height = 640 self._details_extra_height = 360 self._initial_show_adjusted = False # Ensure GNOME/Ubuntu exposes full set of window controls (avoid hidden buttons) self._apply_standard_window_flags() try: self.setSizeGripEnabled(True) except AttributeError: pass # Set default responsive minimum constraints before restoring geometry self.apply_responsive_minimum(self._base_min_width, self._base_min_height) # Restore window geometry from QSettings (standard Qt approach) self._restore_geometry() self.apply_responsive_minimum(self._base_min_width, self._base_min_height) # Initialize backend services self._initialize_backend() # Set up UI self._setup_ui(dev_mode=dev_mode) # Start background preload of gallery cache for instant gallery opening self._start_gallery_cache_preload() # DISABLED: Window geometry saving causes issues with expanded state being memorized # QApplication.instance().aboutToQuit.connect(self._save_geometry_on_quit) # self.resizeEvent = self._on_resize_event_geometry def _apply_standard_window_flags(self): window_flags = self.windowFlags() window_flags |= ( Qt.Window | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.WindowMinimizeButtonHint | Qt.WindowMaximizeButtonHint | Qt.WindowCloseButtonHint ) window_flags &= ~Qt.CustomizeWindowHint self.setWindowFlags(window_flags) def _restore_geometry(self): """Restore window geometry from QSettings (standard Qt approach)""" # DISABLED: Don't restore saved geometry to avoid expanded state issues # Always start with fresh calculated size width, height = self._calculate_initial_window_size() # Ensure we use compact height, not expanded height = min(height, self._compact_height) self.resize(width, height) self._center_on_screen(width, height) def _save_geometry_on_quit(self): """Save window geometry on application quit (only if in compact mode)""" # Only save if window is in compact mode (not expanded with "Show Details") # Also ensure we don't save expanded geometry - always start collapsed if self._is_compact_mode(): self._save_geometry() else: # If Show Details is enabled, clear saved geometry so we start collapsed next time from PySide6.QtCore import QSettings settings = QSettings("Jackify", "Jackify") settings.remove("windowGeometry") def _is_compact_mode(self) -> bool: """Check if window is in compact mode (not expanded with Show Details)""" # Check if any child screen has "Show Details" checked try: if hasattr(self, 'install_modlist_screen'): if hasattr(self.install_modlist_screen, 'show_details_checkbox'): if self.install_modlist_screen.show_details_checkbox.isChecked(): return False if hasattr(self, 'install_ttw_screen'): if hasattr(self.install_ttw_screen, 'show_details_checkbox'): if self.install_ttw_screen.show_details_checkbox.isChecked(): return False if hasattr(self, 'configure_new_modlist_screen'): if hasattr(self.configure_new_modlist_screen, 'show_details_checkbox'): if self.configure_new_modlist_screen.show_details_checkbox.isChecked(): return False if hasattr(self, 'configure_existing_modlist_screen'): if hasattr(self.configure_existing_modlist_screen, 'show_details_checkbox'): if self.configure_existing_modlist_screen.show_details_checkbox.isChecked(): return False except Exception: pass return True def _save_geometry(self): """Save window geometry to QSettings""" from PySide6.QtCore import QSettings settings = QSettings("Jackify", "Jackify") settings.setValue("windowGeometry", self.saveGeometry()) def apply_responsive_minimum(self, min_width: int = 1100, min_height: int = 600): """Apply minimum size that respects current screen bounds.""" set_responsive_minimum(self, min_width=min_width, min_height=min_height, margin=self._window_margin) def _calculate_initial_window_size(self): """Determine initial window size that fits within available screen space.""" _, _, screen_width, screen_height = get_screen_geometry(self) if not screen_width or not screen_height: return (self._base_min_width, self._base_min_height) width = min( max(self._base_min_width, int(screen_width * 0.85)), screen_width - self._window_margin ) height = min( max(self._base_min_height, int(screen_height * 0.75)), screen_height - self._window_margin ) return (width, height) def _center_on_screen(self, width: int, height: int): """Center window on the current screen.""" _, _, screen_width, screen_height = get_screen_geometry(self) if not screen_width or not screen_height: return x = max(0, (screen_width - width) // 2) y = max(0, (screen_height - height) // 2) self.move(x, y) def _ensure_within_available_geometry(self): """Ensure restored geometry fits on the visible screen.""" from PySide6.QtCore import QRect _, _, screen_width, screen_height = get_screen_geometry(self) if not screen_width or not screen_height: return current_geometry: QRect = self.geometry() new_width = min(current_geometry.width(), screen_width - self._window_margin) new_height = min(current_geometry.height(), screen_height - self._window_margin) new_width = max(new_width, self.minimumWidth()) new_height = max(new_height, self.minimumHeight()) new_x = min(max(current_geometry.x(), 0), screen_width - new_width) new_y = min(max(current_geometry.y(), 0), screen_height - new_height) self.setGeometry(new_x, new_y, new_width, new_height) def _on_resize_event_geometry(self, event): """Handle window resize - save geometry if in compact mode""" super().resizeEvent(event) # Save geometry with a delay to avoid excessive writes # Only save if in compact mode if self._is_compact_mode(): from PySide6.QtCore import QTimer if not hasattr(self, '_geometry_save_timer'): self._geometry_save_timer = QTimer() self._geometry_save_timer.setSingleShot(True) self._geometry_save_timer.timeout.connect(self._save_geometry) self._geometry_save_timer.stop() self._geometry_save_timer.start(500) # Save after 500ms of no resizing def showEvent(self, event): super().showEvent(event) if not self._initial_show_adjusted: self._initial_show_adjusted = True # On Steam Deck, keep maximized state; on other systems, set normal window state if not (hasattr(self, 'system_info') and self.system_info.is_steamdeck): self.setWindowState(Qt.WindowNoState) self.apply_responsive_minimum(self._base_min_width, self._base_min_height) self._ensure_within_available_geometry() def _initialize_backend(self): """Initialize backend services for direct use (no subprocess)""" # Detect Steam installation types once at startup from ...shared.steam_utils import detect_steam_installation_types is_flatpak, is_native = detect_steam_installation_types() # Determine system info with Steam detection self.system_info = SystemInfo( is_steamdeck=self._is_steamdeck(), is_flatpak_steam=is_flatpak, is_native_steam=is_native ) # Apply resource limits for optimal operation self._apply_resource_limits() # Initialize config handler from jackify.backend.handlers.config_handler import ConfigHandler self.config_handler = ConfigHandler() # Initialize backend services self.backend_services = { 'modlist_service': ModlistService(self.system_info) } # Initialize GUI services self.gui_services = {} # Initialize protontricks detection service from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService self.protontricks_service = ProtontricksDetectionService(steamdeck=self.system_info.is_steamdeck) # Initialize update service from jackify.backend.services.update_service import UpdateService self.update_service = UpdateService(__version__) debug_print(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}") def _is_steamdeck(self): """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 _apply_resource_limits(self): """Apply recommended resource limits for optimal Jackify operation""" try: from jackify.backend.services.resource_manager import ResourceManager resource_manager = ResourceManager() success = resource_manager.apply_recommended_limits() if success: status = resource_manager.get_limit_status() if status['target_achieved']: debug_print(f"Resource limits optimized: file descriptors set to {status['current_soft']}") else: print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})") else: # Log the issue but don't block startup status = resource_manager.get_limit_status() print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}") # Check if debug mode is enabled for additional info from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() if config_handler.get('debug_mode', False): instructions = resource_manager.get_manual_increase_instructions() print(f"Manual increase instructions available for {instructions['distribution']}") except Exception as e: # Don't block startup on resource management errors print(f"Warning: Error applying resource limits: {e}") def _setup_ui(self, dev_mode=False): """Set up the user interface""" # Create stacked widget for screen navigation self.stacked_widget = QStackedWidget() # Create screens using refactored codebase from jackify.frontends.gui.screens import ( MainMenu, ModlistTasksScreen, AdditionalTasksScreen, InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen ) from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode) self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget) self.modlist_tasks_screen = ModlistTasksScreen( stacked_widget=self.stacked_widget, main_menu_index=0, dev_mode=dev_mode ) self.additional_tasks_screen = AdditionalTasksScreen( stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info ) self.install_modlist_screen = InstallModlistScreen( stacked_widget=self.stacked_widget, main_menu_index=0 ) self.configure_new_modlist_screen = ConfigureNewModlistScreen( stacked_widget=self.stacked_widget, main_menu_index=0 ) self.configure_existing_modlist_screen = ConfigureExistingModlistScreen( stacked_widget=self.stacked_widget, main_menu_index=0 ) self.install_ttw_screen = InstallTTWScreen( stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info ) # Let TTW screen request window resize for expand/collapse try: self.install_ttw_screen.resize_request.connect(self._on_child_resize_request) except Exception: pass # Let Install Modlist screen request window resize for expand/collapse try: self.install_modlist_screen.resize_request.connect(self._on_child_resize_request) except Exception: pass # Add screens to stacked widget self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu self.stacked_widget.addWidget(self.feature_placeholder) # Index 1: Placeholder self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 2: Modlist Tasks self.stacked_widget.addWidget(self.additional_tasks_screen) # Index 3: Additional Tasks self.stacked_widget.addWidget(self.install_modlist_screen) # Index 4: Install Modlist self.stacked_widget.addWidget(self.install_ttw_screen) # Index 5: Install TTW self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 6: Configure New self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 7: Configure Existing # Add debug tracking for screen changes self.stacked_widget.currentChanged.connect(self._debug_screen_change) # Ensure fullscreen is maintained on Steam Deck when switching screens self.stacked_widget.currentChanged.connect(self._maintain_fullscreen_on_deck) # --- Persistent Bottom Bar --- bottom_bar = QWidget() bottom_bar_layout = QHBoxLayout() bottom_bar_layout.setContentsMargins(10, 2, 10, 2) bottom_bar_layout.setSpacing(0) bottom_bar.setLayout(bottom_bar_layout) bottom_bar.setFixedHeight(32) bottom_bar_style = "background-color: #181818; border-top: 1px solid #222;" if DEBUG_BORDERS: bottom_bar_style += " border: 2px solid lime;" bottom_bar.setStyleSheet(bottom_bar_style) # Version label (left) version_label = QLabel(f"Jackify v{__version__}") version_label.setStyleSheet("color: #bbb; font-size: 13px;") bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft) # Spacer bottom_bar_layout.addStretch(1) # Ko-Fi support link (center) kofi_link = QLabel('♥ Support on Ko-fi') kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;") kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction) kofi_link.setOpenExternalLinks(False) kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1")) kofi_link.setToolTip("Support Jackify development") bottom_bar_layout.addWidget(kofi_link, alignment=Qt.AlignCenter) # Spacer bottom_bar_layout.addStretch(1) # Settings button (right side) settings_btn = QLabel('Settings') settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;") settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction) settings_btn.setOpenExternalLinks(False) settings_btn.linkActivated.connect(self.open_settings_dialog) bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight) # About button (right side) about_btn = QLabel('About') about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;") about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction) about_btn.setOpenExternalLinks(False) about_btn.linkActivated.connect(self.open_about_dialog) bottom_bar_layout.addWidget(about_btn, alignment=Qt.AlignRight) # --- Main Layout --- central_widget = QWidget() main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # Don't use stretch - let screens size to their content main_layout.addWidget(self.stacked_widget) # Screen sizes to content main_layout.addWidget(bottom_bar) # Bottom bar stays at bottom # Set stacked widget to not expand unnecessarily self.stacked_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) central_widget.setLayout(main_layout) self.setCentralWidget(central_widget) # Start with main menu self.stacked_widget.setCurrentIndex(0) # Check for protontricks after UI is set up self._check_protontricks_on_startup() def _maintain_fullscreen_on_deck(self, index): """Maintain maximized state on Steam Deck when switching screens.""" if hasattr(self, 'system_info') and self.system_info.is_steamdeck: # Ensure window stays maximized on Steam Deck if not self.isMaximized(): self.showMaximized() def _debug_screen_change(self, index): """Handle screen changes - debug logging and state reset""" # Reset screen state when switching to workflow screens widget = self.stacked_widget.widget(index) if widget and hasattr(widget, 'reset_screen_to_defaults'): widget.reset_screen_to_defaults() # Only show debug info if debug mode is enabled from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() if not config_handler.get('debug_mode', False): return screen_names = { 0: "Main Menu", 1: "Feature Placeholder", 2: "Modlist Tasks Menu", 3: "Additional Tasks Menu", 4: "Install Modlist Screen", 5: "Install TTW Screen", 6: "Configure New Modlist", 7: "Configure Existing Modlist", } screen_name = screen_names.get(index, f"Unknown Screen (Index {index})") widget = self.stacked_widget.widget(index) widget_class = widget.__class__.__name__ if widget else "None" # Only print screen change debug to stderr to avoid workflow log pollution import sys print(f"[DEBUG] Screen changed to Index {index}: {screen_name} (Widget: {widget_class})", file=sys.stderr) # Additional debug for the install modlist screen if index == 4: print(f" Install Modlist Screen details:", file=sys.stderr) print(f" - Widget type: {type(widget)}", file=sys.stderr) print(f" - Widget file: {widget.__class__.__module__}", file=sys.stderr) if hasattr(widget, 'windowTitle'): print(f" - Window title: {widget.windowTitle()}", file=sys.stderr) if hasattr(widget, 'layout'): layout = widget.layout() if layout: print(f" - Layout type: {type(layout)}", file=sys.stderr) print(f" - Layout children count: {layout.count()}", file=sys.stderr) def _start_gallery_cache_preload(self): """Start background preloading of modlist metadata for instant gallery opening""" from PySide6.QtCore import QThread, Signal # Create background thread to preload gallery cache class GalleryCachePreloadThread(QThread): finished_signal = Signal(bool, str) def run(self): try: from jackify.backend.services.modlist_gallery_service import ModlistGalleryService service = ModlistGalleryService() # Fetch with search index to build cache (invisible background operation) metadata = service.fetch_modlist_metadata( include_validation=False, # Skip validation for speed include_search_index=True, # Include mods for search sort_by="title", force_refresh=False # Use cache if valid ) if metadata: modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods) if modlists_with_mods > 0: debug_print(f"Gallery cache ready ({modlists_with_mods} modlists with mods)") else: debug_print("Gallery cache updated") else: debug_print("Failed to load gallery cache") except Exception as e: debug_print(f"Gallery cache preload error: {str(e)}") # Start thread (non-blocking, runs in background) self._gallery_cache_preload_thread = GalleryCachePreloadThread() self._gallery_cache_preload_thread.start() debug_print("Started background gallery cache preload") def _check_protontricks_on_startup(self): """Check for protontricks installation on startup""" try: # Only check for protontricks if user has selected it in settings method = self.config_handler.get('component_installation_method', 'winetricks') if method != 'system_protontricks': debug_print(f"Skipping protontricks check (current method: {method}).") return is_installed, installation_type, details = self.protontricks_service.detect_protontricks() if not is_installed: print(f"Protontricks not found: {details}") # Show error dialog from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog dialog = ProtontricksErrorDialog(self.protontricks_service, self) result = dialog.exec() if result == QDialog.Rejected: # User chose to exit print("User chose to exit due to missing protontricks") sys.exit(1) else: debug_print(f"Protontricks detected: {details}") except Exception as e: print(f"Error checking protontricks: {e}") # Continue anyway - don't block startup on detection errors def _check_for_updates_on_startup(self): """Check for updates on startup - SIMPLE VERSION""" try: debug_print("Checking for updates on startup...") # Do it synchronously and simply update_info = self.update_service.check_for_updates() if update_info: debug_print(f"Update available: v{update_info.version}") # Simple QMessageBox - no complex dialogs from PySide6.QtWidgets import QMessageBox from PySide6.QtCore import QTimer def show_update_dialog(): try: debug_print("Creating UpdateDialog...") from jackify.frontends.gui.dialogs.update_dialog import UpdateDialog dialog = UpdateDialog(update_info, self.update_service, self) debug_print("UpdateDialog created, showing...") dialog.show() # Non-blocking debug_print("UpdateDialog shown successfully") except Exception as e: debug_print(f"UpdateDialog failed: {e}, falling back to simple dialog") # Fallback to simple dialog reply = QMessageBox.question( self, "Update Available", f"Jackify v{update_info.version} is available.\n\nDownload and install now?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes ) if reply == QMessageBox.Yes: # Simple download and replace try: new_appimage = self.update_service.download_update(update_info) if new_appimage: if self.update_service.apply_update(new_appimage): debug_print("Update applied successfully") else: QMessageBox.warning(self, "Update Failed", "Failed to apply update.") else: QMessageBox.warning(self, "Update Failed", "Failed to download update.") except Exception as e: QMessageBox.warning(self, "Update Failed", f"Update failed: {e}") # Use QTimer to show dialog after GUI is fully loaded QTimer.singleShot(1000, show_update_dialog) else: debug_print("No updates available") except Exception as e: debug_print(f"Error checking for updates on startup: {e}") # Continue anyway - don't block startup on update check errors def cleanup_processes(self): """Clean up any running processes before closing""" try: # Clean up GUI services for service in self.gui_services.values(): if hasattr(service, 'cleanup'): service.cleanup() # Clean up screen processes screens = [ self.modlist_tasks_screen, self.install_modlist_screen, self.configure_new_modlist_screen, self.configure_existing_modlist_screen ] for screen in screens: if hasattr(screen, 'cleanup_processes'): screen.cleanup_processes() elif hasattr(screen, 'cleanup'): screen.cleanup() # Final safety net: kill any remaining jackify-engine processes try: import subprocess subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True) except Exception: pass # pkill might fail if no processes found, which is fine except Exception as e: print(f"Error during cleanup: {e}") def closeEvent(self, event): """Handle window close event""" self.cleanup_processes() event.accept() def open_settings_dialog(self): try: dlg = SettingsDialog(self) dlg.exec() except Exception as e: print(f"[ERROR] Exception in open_settings_dialog: {e}") import traceback traceback.print_exc() def open_about_dialog(self): try: from jackify.frontends.gui.dialogs.about_dialog import AboutDialog dlg = AboutDialog(self.system_info, self) dlg.exec() except Exception as e: print(f"[ERROR] Exception in open_about_dialog: {e}") import traceback traceback.print_exc() def _open_url(self, url: str): """Open URL with clean environment to avoid AppImage library conflicts.""" import subprocess import os env = os.environ.copy() # Remove AppImage-specific environment variables appimage_vars = [ 'LD_LIBRARY_PATH', 'PYTHONPATH', 'PYTHONHOME', 'QT_PLUGIN_PATH', 'QML2_IMPORT_PATH', ] if 'APPIMAGE' in env or 'APPDIR' in env: for var in appimage_vars: if var in env: del env[var] subprocess.Popen( ['xdg-open', url], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True ) def _on_child_resize_request(self, mode: str): """ Handle child screen resize requests (expand/collapse console). Allow window expansion/collapse for Show Details toggle, but keep fixed sizing for navigation. """ debug_print(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}") # On Steam Deck we keep the stable, full-size layout and ignore child resize try: if self.system_info and self.system_info.is_steamdeck: debug_print("DEBUG: Steam Deck detected, ignoring resize request") # Hide the checkbox if present (Deck uses full layout) try: if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox: self.install_ttw_screen.show_details_checkbox.setVisible(False) except Exception: pass return except Exception: pass # Allow expansion/collapse for Show Details toggle # This is different from navigation resizing - we want this to work if mode == "expand": # Expand window to accommodate console current_size = self.size() current_pos = self.pos() # Calculate target height and clamp to available space target_height = self._compact_height + self._details_extra_height self._resize_height(target_height) elif mode == "collapse": # Collapse window back to compact size self._resize_height(self._compact_height) else: # Unknown mode - just ensure minimums self.apply_responsive_minimum(self._base_min_width, self._base_min_height) def _resize_height(self, requested_height: int): """Resize the window to a given height while keeping it on-screen.""" target_height = self._clamp_height_to_screen(requested_height) self.apply_responsive_minimum(self._base_min_width, self._base_min_height) if ENABLE_WINDOW_HEIGHT_ANIMATION: self._animate_height(target_height) return geom = self.geometry() new_y = geom.y() _, _, _, screen_height = get_screen_geometry(self) max_bottom = max(self._base_min_height, screen_height - self._window_margin) if new_y + target_height > max_bottom: new_y = max(0, max_bottom - target_height) self._programmatic_resize = True self.setGeometry(geom.x(), new_y, geom.width(), target_height) QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False)) def _clamp_height_to_screen(self, requested_height: int) -> int: """Clamp requested height to available screen space.""" _, _, _, screen_height = get_screen_geometry(self) available = max(self._base_min_height, screen_height - self._window_margin) return max(self._base_min_height, min(requested_height, available)) def _animate_height(self, target_height: int, duration_ms: int = 180): """Smoothly animate the window height to target_height. Kept local imports to minimize global impact and avoid touching module headers. """ try: from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QRect except Exception: # Fallback to immediate resize if animation types are unavailable before = self.size() self._programmatic_resize = True self.resize(self.size().width(), target_height) debug_print(f"DEBUG: Animated fallback resize from {before} to {self.size()}") from PySide6.QtCore import QTimer QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False)) return # Build end rect with same x/y/width and target height start_rect = self.geometry() end_rect = QRect(start_rect.x(), start_rect.y(), start_rect.width(), self._clamp_height_to_screen(target_height)) # Check if expanded window would go off-screen and adjust position if needed screen = QApplication.primaryScreen() if screen: screen_geometry = screen.availableGeometry() # Calculate where bottom would be with target_height would_be_bottom = start_rect.y() + target_height if would_be_bottom > screen_geometry.bottom(): # Window would go off bottom - move it up new_y = screen_geometry.bottom() - target_height if new_y < screen_geometry.top(): new_y = screen_geometry.top() end_rect.moveTop(new_y) # Hold reference to avoid GC stopping the animation self._resize_anim = QPropertyAnimation(self, b"geometry") self._resize_anim.setDuration(duration_ms) self._resize_anim.setEasingCurve(QEasingCurve.OutCubic) self._resize_anim.setStartValue(start_rect) self._resize_anim.setEndValue(end_rect) # Mark as programmatic during animation self._programmatic_resize = True self._resize_anim.finished.connect(lambda: setattr(self, '_programmatic_resize', False)) self._resize_anim.start() def resource_path(relative_path): """Get path to resource file, handling both AppImage and dev modes.""" # PyInstaller frozen mode if hasattr(sys, '_MEIPASS'): return os.path.join(sys._MEIPASS, relative_path) # AppImage mode - use APPDIR if available appdir = os.environ.get('APPDIR') if appdir: # In AppImage, resources are in opt/jackify/ relative to APPDIR # __file__ is at opt/jackify/frontends/gui/main.py, so go up to opt/jackify/ appimage_path = os.path.join(appdir, 'opt', 'jackify', relative_path) if os.path.exists(appimage_path): return appimage_path # Dev mode or fallback - go up from frontends/gui to jackify, then to assets # __file__ is at src/jackify/frontends/gui/main.py, so go up to src/jackify/ current_dir = os.path.abspath(os.path.dirname(__file__)) # Go up from frontends/gui to jackify jackify_dir = os.path.dirname(os.path.dirname(current_dir)) return os.path.join(jackify_dir, relative_path) def main(): """Main entry point for the GUI application""" # CRITICAL: Enable faulthandler for segfault debugging # This will print Python stack traces on segfault import faulthandler import signal # Enable faulthandler to both stderr and file try: log_dir = Path.home() / '.local' / 'share' / 'jackify' / 'logs' log_dir.mkdir(parents=True, exist_ok=True) trace_file = open(log_dir / 'segfault_trace.txt', 'w') faulthandler.enable(file=trace_file, all_threads=True) except Exception: # Fallback to stderr only if file can't be opened faulthandler.enable(all_threads=True) # Check for CLI mode argument if len(sys.argv) > 1 and '--cli' in sys.argv: # Launch CLI frontend instead of GUI try: from jackify.frontends.cli.__main__ import main as cli_main print("CLI mode detected - switching to CLI frontend") return cli_main() except ImportError as e: print(f"Error importing CLI frontend: {e}") print("CLI mode not available. Falling back to GUI mode.") # Load config and set debug mode if needed from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) # Command-line --debug always takes precedence if '--debug' in sys.argv or '-d' in sys.argv: debug_mode = True # Temporarily save CLI debug flag to config so engine can see it config_handler.set('debug_mode', True) print("[DEBUG] CLI --debug flag detected, saved debug_mode=True to config") import logging # Initialize file logging on root logger so all modules inherit it from jackify.shared.logging import LoggingHandler logging_handler = LoggingHandler() # Rotate log file before setting up new logger logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log') root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True) # Empty name = root logger if debug_mode: logging.getLogger().setLevel(logging.DEBUG) print("[Jackify] Debug mode enabled (from config or CLI)") else: logging.getLogger().setLevel(logging.WARNING) dev_mode = '--dev' in sys.argv # Launch GUI application app = QApplication(sys.argv) # CRITICAL: Set application name before desktop file name to ensure proper window title/icon on PopOS/Ubuntu app.setApplicationName("Jackify") app.setApplicationDisplayName("Jackify") app.setDesktopFileName("jackify.desktop") # Global cleanup function for signal handling def emergency_cleanup(): debug_print("Cleanup: terminating jackify-engine processes") try: import subprocess subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True) except Exception: pass # Set up signal handlers for graceful shutdown import signal def signal_handler(sig, frame): print(f"Received signal {sig}, cleaning up...") emergency_cleanup() app.quit() signal.signal(signal.SIGINT, signal_handler) # Ctrl+C signal.signal(signal.SIGTERM, signal_handler) # System shutdown # Set the application icon # Try multiple locations - AppImage build script places icon in standard locations icon_path = None icon = QIcon() # Priority 1: Try resource_path (works in dev mode and if assets are in AppImage) try_path = resource_path('assets/JackifyLogo_256.png') if os.path.exists(try_path): icon_path = try_path icon = QIcon(try_path) # Priority 2: Try standard AppImage icon locations (where build script actually places it) if icon.isNull(): appdir = os.environ.get('APPDIR') if appdir: appimage_icon_paths = [ os.path.join(appdir, 'com.jackify.app.png'), # Root of AppDir os.path.join(appdir, 'usr', 'share', 'icons', 'hicolor', '256x256', 'apps', 'com.jackify.app.png'), # Standard location os.path.join(appdir, 'opt', 'jackify', 'assets', 'JackifyLogo_256.png'), # If assets are copied ] for path in appimage_icon_paths: if os.path.exists(path): icon_path = path icon = QIcon(path) if not icon.isNull(): if debug_mode: print(f"[DEBUG] Using AppImage icon: {path}") break # Priority 3: Fallback to any PNG in assets directory if icon.isNull(): try_path = resource_path('assets/JackifyLogo_256.png') if os.path.exists(try_path): icon_path = try_path icon = QIcon(try_path) if debug_mode: print(f"[DEBUG] Final icon path: {icon_path}") print(f"[DEBUG] Icon is null: {icon.isNull()}") app.setWindowIcon(icon) window = JackifyMainWindow(dev_mode=dev_mode) window.setWindowIcon(icon) window.show() # On Steam Deck, set window to maximized to prevent button overlap with Show Details console if hasattr(window, 'system_info') and window.system_info.is_steamdeck: window.showMaximized() else: # Position window after showing (so size is finalized) # Center horizontally, position near top (10% from top) to leave room for expansion screen = QApplication.primaryScreen() if screen: screen_geometry = screen.availableGeometry() window_size = window.size() x = (screen_geometry.width() - window_size.width()) // 2 y = int(screen_geometry.top() + (screen_geometry.height() * 0.1)) # 10% from top window.move(x, y) # Start background update check after window is shown window._check_for_updates_on_startup() # Ensure cleanup on exit import atexit atexit.register(emergency_cleanup) return app.exec() if __name__ == "__main__": sys.exit(main())