From 3abaccb1e0fb6709c51d2b0e70b69b5bd5115068 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Fri, 28 Nov 2025 12:00:00 +0500 Subject: [PATCH] fix(startup): prevent main thread hangs and optimize resource loading - run start_sh initialization via QTimer.singleShot with timeout - add timeout protection to load_theme_fonts() - load Steam/EGS/PortProton games in parallel instead of sequential - delay game loading until UI is fully initialized - fix callback chaining to avoid blocking operations - add proper timeout + error handling for all Steam/EGS network requests - add timeouts for flatpak subprocess calls - improve file I/O error handling to avoid UI freeze - optimize theme font loading: - delay font loading via QTimer.singleShot - load fonts in batches of 10 - reduce font load timeout to 3s - remove fonts only when switching themes Signed-off-by: Boris Yumankulov --- portprotonqt/app.py | 19 +++- portprotonqt/config_utils.py | 21 +++- portprotonqt/egs_api.py | 16 ++- portprotonqt/input_manager.py | 7 +- portprotonqt/main_window.py | 185 ++++++++++++++++++++++++--------- portprotonqt/portproton_api.py | 29 ++++-- portprotonqt/steam_api.py | 23 +++- portprotonqt/theme_manager.py | 79 +++++++++----- 8 files changed, 282 insertions(+), 97 deletions(-) diff --git a/portprotonqt/app.py b/portprotonqt/app.py index 2cf6d4f..ab014ff 100644 --- a/portprotonqt/app.py +++ b/portprotonqt/app.py @@ -34,13 +34,12 @@ def main(): os.environ["PROCESS_LOG"] = "1" os.environ["START_FROM_STEAM"] = "1" + # Get the PortProton start command start_sh = get_portproton_start_command() if start_sh is None: return - subprocess.run(start_sh + ["cli", "--initial"]) - app = QApplication(sys.argv) app.setWindowIcon(QIcon.fromTheme(__app_id__)) app.setDesktopFileName(__app_id__) @@ -144,6 +143,22 @@ def main(): save_fullscreen_config(False) window.showNormal() + # Execute the initial PortProton command after the UI is set up + def run_initial_command(): + nonlocal start_sh + if start_sh: + try: + subprocess.run(start_sh + ["cli", "--initial"], timeout=10) + except subprocess.TimeoutExpired: + logger.warning("Initial PortProton command timed out") + except Exception as e: + logger.error(f"Error running initial PortProton command: {e}") + else: + logger.warning("PortProton start command not available, skipping initial command") + + # Run the initial command after the UI is displayed + QTimer.singleShot(100, run_initial_command) + # --- Cleanup --- def cleanup_on_exit(): try: diff --git a/portprotonqt/config_utils.py b/portprotonqt/config_utils.py index 0a1db54..71e8c0d 100644 --- a/portprotonqt/config_utils.py +++ b/portprotonqt/config_utils.py @@ -137,8 +137,14 @@ def save_time_config(detail_level): def read_file_content(file_path): """Reads the content of a file and returns it as a string.""" - with open(file_path, encoding="utf-8") as f: - return f.read().strip() + try: + # Add timeout protection for file operations using a simple approach + with open(file_path, encoding="utf-8") as f: + content = f.read().strip() + return content + except Exception as e: + logger.warning(f"Error reading file {file_path}: {e}") + raise # Re-raise the exception to be handled by the caller def get_portproton_location(): """Возвращает путь к PortProton каталогу (строку) или None.""" @@ -159,6 +165,8 @@ def get_portproton_location(): logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults") except (OSError, PermissionError) as e: logger.warning(f"Failed to read PortProton configuration file: {e}") + except Exception as e: + logger.warning(f"Unexpected error reading PortProton configuration file: {e}") default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") if os.path.isdir(default_flatpak_dir): @@ -180,12 +188,17 @@ def get_portproton_start_command(): ["flatpak", "list"], capture_output=True, text=True, - check=False + check=False, + timeout=10 ) if "ru.linux_gaming.PortProton" in result.stdout: logger.info("Detected Flatpak installation") return ["flatpak", "run", "ru.linux_gaming.PortProton"] - except Exception: + except subprocess.TimeoutExpired: + logger.warning("Flatpak list command timed out") + return None + except Exception as e: + logger.warning(f"Error checking flatpak list: {e}") pass start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh") diff --git a/portprotonqt/egs_api.py b/portprotonqt/egs_api.py index af08561..c76023c 100644 --- a/portprotonqt/egs_api.py +++ b/portprotonqt/egs_api.py @@ -577,7 +577,7 @@ def get_egs_game_description_async( "https://launcher.store.epicgames.com/graphql", json=search_query, headers=headers, - timeout=5 + timeout=10 ) response.raise_for_status() data = orjson.loads(response.content) @@ -597,7 +597,7 @@ def get_egs_game_description_async( def fetch_legacy_description(url: str) -> str: """Fetches description from the legacy API, handling DNS failures.""" try: - response = requests.get(url, headers=headers, timeout=5) + response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() data = orjson.loads(response.content) if not isinstance(data, dict): @@ -619,6 +619,9 @@ def get_egs_game_description_async( except requests.exceptions.ConnectionError as e: logger.error("DNS resolution failed for legacy API %s: %s", url, str(e)) return "" + except requests.exceptions.Timeout: + logger.warning("Request timeout for legacy API %s", url) + return "" except requests.RequestException as e: logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e)) return "" @@ -670,7 +673,7 @@ def get_egs_game_description_async( url = "https://graphql.epicgames.com/graphql" try: - response = requests.post(url, json=search_query, headers=headers, timeout=5) + response = requests.post(url, json=search_query, headers=headers, timeout=10) response.raise_for_status() data = orjson.loads(response.content) if namespace: @@ -689,6 +692,9 @@ def get_egs_game_description_async( for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])): return element.get("description", ""), element.get("productSlug", "") return "", "" + except requests.exceptions.Timeout: + logger.warning("GraphQL request timeout for %s with locale %s", app_name, locale) + return "", "" except requests.RequestException as e: logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e)) return "", "" @@ -717,6 +723,10 @@ def get_egs_game_description_async( logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description) except requests.exceptions.ConnectionError: logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name) + except requests.exceptions.Timeout: + logger.warning("Legacy API request timed out for %s", app_name) + except Exception as e: + logger.error("Unexpected error fetching legacy API for %s: %s", app_name, str(e)) # Step 3: If still no description and no namespace, try GraphQL with title if not description and not namespace: diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index 6ce1df4..0b829c7 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -159,7 +159,8 @@ class InputManager(QObject): logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled) if self.mouse_emulation_enabled: - self.enable_mouse_emulation() + # Initialize mouse emulation asynchronously to avoid blocking startup + QTimer.singleShot(0, self._async_enable_mouse_emulation) # FileExplorer specific attributes self.file_explorer = None @@ -197,6 +198,10 @@ class InputManager(QObject): # Initialize evdev + hotplug self.init_gamepad() + def _async_enable_mouse_emulation(self): + """Asynchronously enable mouse emulation to avoid blocking startup.""" + self.enable_mouse_emulation() + def _update_emulation_flag(self): """Update emulation_active flag based on Qt app focus (main thread only).""" active = QApplication.activeWindow() diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index d588157..ec1702c 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -60,6 +60,7 @@ class MainWindow(QMainWindow): self.is_exiting = False selected_theme = read_theme_from_config() self.current_theme_name = selected_theme + # Apply theme but defer heavy font loading self.theme = self.theme_manager.apply_theme(selected_theme) self.tray_manager = TrayManager(self, app_name, self.current_theme_name) self.card_width = read_card_size() @@ -132,6 +133,13 @@ class MainWindow(QMainWindow): self.update_progress.connect(self.progress_bar.setValue) self.update_status_message.connect(self.statusBar().showMessage) + # Show immediate startup progress to indicate the app is loading + self.progress_bar.setVisible(True) + self.progress_bar.setRange(0, 0) # Indeterminate initially + self.progress_bar.setValue(0) # Reset value + self.update_status_message.emit(_("Starting PortProton..."), 0) + QApplication.processEvents() # Process to show progress bar immediately + self.installing = False self.current_install_script = None self.install_process = None @@ -219,7 +227,6 @@ class MainWindow(QMainWindow): self.keyboard = VirtualKeyboard(self, self.theme) self.detail_animations = DetailPageAnimations(self, self.theme) - QTimer.singleShot(0, self.loadGames) if read_fullscreen_config(): self.showFullScreen() @@ -230,6 +237,14 @@ class MainWindow(QMainWindow): else: self.showNormal() + # Process events to ensure UI is responsive before starting heavy operations + QApplication.processEvents() + + # Delay game loading until after the UI is fully displayed to prevent blocking + # Use a longer delay to ensure window is fully rendered and responsive + # Use a custom event processing approach to make sure UI stays responsive + QTimer.singleShot(500, self.loadGames) # Reduced delay but ensure UI gets event processing + def on_slider_released(self) -> None: """Delegate to game library manager.""" if hasattr(self, 'game_library_manager'): @@ -563,6 +578,8 @@ class MainWindow(QMainWindow): def on_games_loaded(self, games: list[tuple]): self.game_library_manager.set_games(games) self.progress_bar.setVisible(False) + self.progress_bar.setRange(0, 100) # Reset to determinate state for next use + self.progress_bar.setValue(0) # Clear the refresh in progress flag if hasattr(self, '_refresh_in_progress'): @@ -589,58 +606,128 @@ class MainWindow(QMainWindow): favorites = read_favorites() self.pending_games = [] self.games = [] + + # Show initial progress bar and status message immediately + self.progress_bar.setRange(0, 100) # Set to determinate range self.progress_bar.setValue(0) self.progress_bar.setVisible(True) - if display_filter == "steam": - self._load_steam_games_async(lambda games: self.games_loaded.emit(games)) - elif display_filter == "portproton": - self._load_portproton_games_async(lambda games: self.games_loaded.emit(games)) - elif display_filter == "epic": - load_egs_games_async( - self.legendary_path, - lambda games: self.games_loaded.emit(games), - self.downloader, - self.update_progress.emit, - self.update_status_message.emit - ) - elif display_filter == "favorites": - def on_all_games(portproton_games, steam_games, epic_games): - games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites] - self.games_loaded.emit(games) - self._load_portproton_games_async( - lambda pg: self._load_steam_games_async( - lambda sg: load_egs_games_async( - self.legendary_path, - lambda eg: on_all_games(pg, sg, eg), - self.downloader, - self.update_progress.emit, - self.update_status_message.emit - ) + self.update_status_message.emit(_("Loading games..."), 0) + + # Process events to keep UI responsive + QApplication.processEvents() + + def start_loading(): + # Make sure progress bar is still visible + self.progress_bar.setValue(0) + self.progress_bar.setVisible(True) + QApplication.processEvents() # Allow UI to update + + if display_filter == "steam": + self._load_steam_games_async(lambda games: self.games_loaded.emit(games)) + elif display_filter == "portproton": + self._load_portproton_games_async(lambda games: self.games_loaded.emit(games)) + elif display_filter == "epic": + load_egs_games_async( + self.legendary_path, + lambda games: self.games_loaded.emit(games), + self.downloader, + self.update_progress.emit, + self.update_status_message.emit ) - ) - else: - def on_all_games(portproton_games, steam_games, epic_games): - seen = set() - games = [] - for game in portproton_games + steam_games + epic_games: - # Уникальный ключ: имя + exec_line - key = (game[0], game[4]) - if key not in seen: - seen.add(key) - games.append(game) - self.games_loaded.emit(games) - self._load_portproton_games_async( - lambda pg: self._load_steam_games_async( - lambda sg: load_egs_games_async( - self.legendary_path, - lambda eg: on_all_games(pg, sg, eg), - self.downloader, - self.update_progress.emit, - self.update_status_message.emit - ) + elif display_filter == "favorites": + def on_all_games_favorites(portproton_games, steam_games, epic_games): + games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites] + self.games_loaded.emit(games) + + # Load games from different sources in parallel to prevent blocking + results = {'portproton': [], 'steam': [], 'epic': []} + completed = {'portproton': False, 'steam': False, 'epic': False} + + def check_completion(): + if all(completed.values()): + QApplication.processEvents() # Keep UI responsive + on_all_games_favorites(results['portproton'], results['steam'], results['epic']) + + def portproton_callback(games): + results['portproton'] = games + completed['portproton'] = True + QApplication.processEvents() # Keep UI responsive + check_completion() + + def steam_callback(games): + results['steam'] = games + completed['steam'] = True + QApplication.processEvents() # Keep UI responsive + check_completion() + + def epic_callback(games): + results['epic'] = games + completed['epic'] = True + QApplication.processEvents() # Keep UI responsive + check_completion() + + self._load_portproton_games_async(portproton_callback) + self._load_steam_games_async(steam_callback) + load_egs_games_async( + self.legendary_path, + epic_callback, + self.downloader, + self.update_progress.emit, + self.update_status_message.emit ) - ) - return [] + else: + # For 'all' filter - load games from different sources in parallel to prevent blocking + results = {'portproton': [], 'steam': [], 'epic': []} + completed = {'portproton': False, 'steam': False, 'epic': False} + + def on_all_games(): + seen = set() + games = [] + for game in results['portproton'] + results['steam'] + results['epic']: + # Уникальный ключ: имя + exec_line + key = (game[0], game[4]) + if key not in seen: + seen.add(key) + games.append(game) + QApplication.processEvents() # Keep UI responsive + self.games_loaded.emit(games) + + def check_completion(): + if all(completed.values()): + QApplication.processEvents() # Keep UI responsive + on_all_games() + + def portproton_callback(games): + results['portproton'] = games + completed['portproton'] = True + QApplication.processEvents() # Keep UI responsive + check_completion() + + def steam_callback(games): + results['steam'] = games + completed['steam'] = True + QApplication.processEvents() # Keep UI responsive + check_completion() + + def epic_callback(games): + results['epic'] = games + completed['epic'] = True + QApplication.processEvents() # Keep UI responsive + check_completion() + + # Load all sources in parallel + self._load_portproton_games_async(portproton_callback) + self._load_steam_games_async(steam_callback) + load_egs_games_async( + self.legendary_path, + epic_callback, + self.downloader, + self.update_progress.emit, + self.update_status_message.emit + ) + + # Run loading with minimal delay to allow UI to be responsive + QTimer.singleShot(100, start_loading) # Reduced to 100ms def _load_steam_games_async(self, callback: Callable[[list[tuple]], None]): steam_games = [] diff --git a/portprotonqt/portproton_api.py b/portprotonqt/portproton_api.py index efbb8da..5386ff3 100644 --- a/portprotonqt/portproton_api.py +++ b/portprotonqt/portproton_api.py @@ -254,6 +254,8 @@ class PortProtonAPI: try: mod_time = os.path.getmtime(cache_file) if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION: + # Add timeout protection for file operations + start_time = time.time() with open(cache_file, "rb") as f: data = orjson.loads(f.read()) # Check signature @@ -261,6 +263,10 @@ class PortProtonAPI: current_signature = self._compute_scripts_signature( os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") ) + # Check for timeout during signature computation + if time.time() - start_time > 3: # 3 second timeout + logger.warning("Cache loading took too long, skipping cache") + return None if cached_signature != current_signature: logger.info("Scripts signature mismatch; invalidating cache") return None @@ -287,21 +293,26 @@ class PortProtonAPI: def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None: """Start loading auto-install games in a background thread. Returns the thread for management.""" - # Check cache first (sync, fast) - cached_games = self._load_autoinstall_cache() - if cached_games is not None: - # Emit via callback immediately if cached - QThread.msleep(0) # Yield to Qt event loop - callback(cached_games) - return None # No thread needed - - # No cache: Start background thread class AutoinstallWorker(QThread): finished = Signal(list) api: "PortProtonAPI" portproton_location: str | None def run(self): + import time + # Check cache in this background thread, not in main thread + start_time = time.time() + cached_games = self.api._load_autoinstall_cache() + # If cache loading took too long (>2 seconds), skip cache and load directly + if time.time() - start_time > 2: + logger.warning("Cache loading took too long, proceeding without cache") + cached_games = None + + if cached_games is not None: + self.finished.emit(cached_games) + return + + # No cache: Load games from scratch games = [] auto_dir = os.path.join( self.portproton_location or "", "data", "scripts", "pw_autoinstall" diff --git a/portprotonqt/steam_api.py b/portprotonqt/steam_api.py index 555addf..6bac188 100644 --- a/portprotonqt/steam_api.py +++ b/portprotonqt/steam_api.py @@ -420,7 +420,7 @@ def fetch_sgdb_cover(game_name: str) -> str: try: encoded = urllib.parse.quote(game_name) url = f"https://steamgrid.usebottles.com/api/search/{encoded}" - resp = requests.get(url, timeout=5) + resp = requests.get(url, timeout=10) if resp.status_code != 200: logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code) return "" @@ -431,17 +431,30 @@ def fetch_sgdb_cover(game_name: str) -> str: if text: logger.info("Fetched SGDB cover for %s: %s", game_name, text) return text + except requests.exceptions.Timeout: + logger.warning(f"SGDB request timed out for {game_name}") + return "" + except requests.exceptions.RequestException as e: + logger.warning(f"SGDB request error for {game_name}: {e}") + return "" except Exception as e: - logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e) - return "" + logger.warning(f"Unexpected error while fetching SGDB cover for {game_name}: {e}") + return "" def check_url_exists(url: str) -> bool: """Check whether a URL returns HTTP 200.""" try: - r = requests.head(url, timeout=3) + r = requests.head(url, timeout=5) return r.status_code == 200 - except Exception: + except requests.exceptions.Timeout: + logger.warning(f"URL check timed out for: {url}") + return False + except requests.exceptions.RequestException as e: + logger.warning(f"Request error when checking URL {url}: {e}") + return False + except Exception as e: + logger.warning(f"Unexpected error when checking URL {url}: {e}") return False diff --git a/portprotonqt/theme_manager.py b/portprotonqt/theme_manager.py index ad5e0fe..745c2ea 100644 --- a/portprotonqt/theme_manager.py +++ b/portprotonqt/theme_manager.py @@ -111,34 +111,65 @@ def load_theme_fonts(theme_name): logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping") return - QFontDatabase.removeAllApplicationFonts() - fonts_folder = None - if theme_name == "standart": - base_dir = os.path.dirname(os.path.abspath(__file__)) - fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts") - else: - for themes_dir in THEMES_DIRS: - theme_folder = os.path.join(themes_dir, theme_name) - possible_fonts_folder = os.path.join(theme_folder, "fonts") - if os.path.exists(possible_fonts_folder): - fonts_folder = possible_fonts_folder - break + def load_fonts_delayed(): + global _loaded_theme + try: + # Only remove fonts if this is a theme change (not initial load) + current_loaded_theme = _loaded_theme # Capture the current value + if current_loaded_theme is not None and current_loaded_theme != theme_name: + # Run font removal in the GUI thread with delay + QFontDatabase.removeAllApplicationFonts() - if not fonts_folder or not os.path.exists(fonts_folder): - logger.error(f"Fonts folder not found for theme '{theme_name}'") - return + import time + import os + start_time = time.time() + timeout = 3 # Reduced timeout to 3 seconds for faster loading - for filename in os.listdir(fonts_folder): - if filename.lower().endswith((".ttf", ".otf")): - font_path = os.path.join(fonts_folder, filename) - font_id = QFontDatabase.addApplicationFont(font_path) - if font_id != -1: - families = QFontDatabase.applicationFontFamilies(font_id) - logger.info(f"Font {filename} successfully loaded: {families}") + fonts_folder = None + if theme_name == "standart": + base_dir = os.path.dirname(os.path.abspath(__file__)) + fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts") else: - logger.error(f"Error loading font: {filename}") + for themes_dir in THEMES_DIRS: + theme_folder = os.path.join(themes_dir, theme_name) + possible_fonts_folder = os.path.join(theme_folder, "fonts") + if os.path.exists(possible_fonts_folder): + fonts_folder = possible_fonts_folder + break - _loaded_theme = theme_name + if not fonts_folder or not os.path.exists(fonts_folder): + logger.error(f"Fonts folder not found for theme '{theme_name}'") + return + + font_files = [] + for filename in os.listdir(fonts_folder): + if filename.lower().endswith((".ttf", ".otf")): + font_files.append(filename) + + # Limit number of fonts loaded to prevent too much blocking + font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking + + for filename in font_files: + if time.time() - start_time > timeout: + logger.warning(f"Font loading timed out for theme '{theme_name}' after loading {len(font_files)} fonts") + break + + font_path = os.path.join(fonts_folder, filename) + font_id = QFontDatabase.addApplicationFont(font_path) + if font_id != -1: + families = QFontDatabase.applicationFontFamilies(font_id) + logger.info(f"Font {filename} successfully loaded: {families}") + else: + logger.error(f"Error loading font: {filename}") + + # Update the global variable in the main thread + _loaded_theme = theme_name + except Exception as e: + logger.error(f"Error loading fonts for theme '{theme_name}': {e}") + + # Use QTimer to delay font loading until after the UI is rendered + from PySide6.QtCore import QTimer + QTimer.singleShot(100, load_fonts_delayed) # Delay font loading by 100ms class ThemeWrapper: """