From 7212a58480be1a0200b71168591a24ac5ffdea8d Mon Sep 17 00:00:00 2001 From: Omni Date: Thu, 2 Oct 2025 21:59:01 +0100 Subject: [PATCH] Sync from development - prepare for v0.1.5.3 --- CHANGELOG.md | 13 + jackify/__init__.py | 2 +- jackify/backend/handlers/modlist_handler.py | 10 +- jackify/backend/handlers/path_handler.py | 36 +-- jackify/backend/handlers/wine_utils.py | 12 +- .../backend/handlers/winetricks_handler.py | 225 +++++++++++++++++- .../backend/services/native_steam_service.py | 184 +++++++------- jackify/frontends/gui/main.py | 19 +- 8 files changed, 352 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c49730..2a15c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Jackify Changelog +## v0.1.5.3 - Critical Bug Fixes +**Release Date:** October 2, 2025 + +### Critical Bug Fixes +- **Fixed Multi-User Steam Detection**: Properly reads loginusers.vdf and converts SteamID64 to SteamID3 for accurate user identification +- **Fixed dotnet40 Installation Failures**: Hybrid approach uses protontricks for dotnet40 (reliable), winetricks for other components (fast) +- **Fixed dotnet8 Installation**: Now properly handled by winetricks instead of unimplemented pass statement +- **Fixed D: Drive Detection**: SD card detection now only applies to Steam Deck systems, not regular Linux systems +- **Fixed SD Card Mount Patterns**: Replaced hardcoded mmcblk0p1 references with dynamic path detection +- **Fixed Debug Restart UX**: Replaced PyInstaller detection with AppImage detection for proper restart behavior + +--- + ## v0.1.5.2 - Proton Configuration & Engine Updates **Release Date:** September 30, 2025 diff --git a/jackify/__init__.py b/jackify/__init__.py index 05b3f0b..ea8d3fb 100644 --- a/jackify/__init__.py +++ b/jackify/__init__.py @@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing Wabbajack modlists natively on Linux systems. """ -__version__ = "0.1.5.2" +__version__ = "0.1.5.3" diff --git a/jackify/backend/handlers/modlist_handler.py b/jackify/backend/handlers/modlist_handler.py index d687efb..63f316b 100644 --- a/jackify/backend/handlers/modlist_handler.py +++ b/jackify/backend/handlers/modlist_handler.py @@ -315,13 +315,15 @@ class ModlistHandler: self.modlist_dir = Path(modlist_dir_path_str) self.modlist_ini = modlist_ini_path - # Determine if modlist is on SD card - # Use str() for startswith check - if str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media"): + # Determine if modlist is on SD card (Steam Deck only) + # On non-Steam Deck systems, /media mounts should use Z: drive, not D: drive + if (str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media")) and self.steamdeck: self.modlist_sdcard = True - self.logger.info("Modlist appears to be on an SD card.") + self.logger.info("Modlist appears to be on an SD card (Steam Deck).") else: self.modlist_sdcard = False + if (str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media")) and not self.steamdeck: + self.logger.info("Modlist on /media mount detected on non-Steam Deck system - using Z: drive mapping.") # Find and set compatdata path now that we have appid # Ensure PathHandler is available (should be initialized in __init__) diff --git a/jackify/backend/handlers/path_handler.py b/jackify/backend/handlers/path_handler.py index fb37a25..63f461a 100644 --- a/jackify/backend/handlers/path_handler.py +++ b/jackify/backend/handlers/path_handler.py @@ -653,41 +653,7 @@ class PathHandler: except Exception as e: logger.error(f"Error using multi-user detection for shortcuts.vdf: {e}") - - # Fallback to legacy behavior if multi-user detection fails - logger.warning("Falling back to legacy shortcuts.vdf detection (first-found user)") - userdata_base_paths = [ - os.path.expanduser("~/.steam/steam/userdata"), - os.path.expanduser("~/.local/share/Steam/userdata"), - os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata") - ] - found_vdf_path = None - for base_path in userdata_base_paths: - if not os.path.isdir(base_path): - logger.debug(f"Userdata base path not found or not a directory: {base_path}") - continue - logger.debug(f"Searching for user IDs in: {base_path}") - try: - for item in os.listdir(base_path): - user_path = os.path.join(base_path, item) - if os.path.isdir(user_path) and item.isdigit(): - logger.debug(f"Checking user directory: {user_path}") - config_path = os.path.join(user_path, "config") - shortcuts_file = os.path.join(config_path, "shortcuts.vdf") - if os.path.isfile(shortcuts_file): - logger.info(f"Found shortcuts.vdf at: {shortcuts_file}") - found_vdf_path = shortcuts_file - break # Found it for this base path - else: - logger.debug(f"shortcuts.vdf not found in {config_path}") - except OSError as e: - logger.warning(f"Could not access directory {base_path}: {e}") - continue # Try next base path - if found_vdf_path: - break # Found it in this base path - if not found_vdf_path: - logger.error("Could not find any shortcuts.vdf file in common Steam locations.") - return found_vdf_path + return None @staticmethod def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]: diff --git a/jackify/backend/handlers/wine_utils.py b/jackify/backend/handlers/wine_utils.py index 6056657..0413147 100644 --- a/jackify/backend/handlers/wine_utils.py +++ b/jackify/backend/handlers/wine_utils.py @@ -537,10 +537,7 @@ class WineUtils: if "mods" in binary_path: # mods path type found if modlist_sdcard: - path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir - # Strip /run/media/deck/UUID if present - if '/run/media/' in path_middle: - path_middle = '/' + path_middle.split('/run/media/', 1)[1].split('/', 2)[2] + path_middle = WineUtils._strip_sdcard_path(modlist_dir) else: path_middle = modlist_dir @@ -550,10 +547,7 @@ class WineUtils: elif any(x in binary_path for x in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]): # Stock/Game Root found if modlist_sdcard: - path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir - # Strip /run/media/deck/UUID if present - if '/run/media/' in path_middle: - path_middle = '/' + path_middle.split('/run/media/', 1)[1].split('/', 2)[2] + path_middle = WineUtils._strip_sdcard_path(modlist_dir) else: path_middle = modlist_dir @@ -589,7 +583,7 @@ class WineUtils: elif "steamapps" in binary_path: # Steamapps found if basegame_sdcard: - path_middle = steam_library.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in steam_library else steam_library + path_middle = WineUtils._strip_sdcard_path(steam_library) drive_letter = "D:" else: path_middle = steam_library.split('steamapps', 1)[0] if 'steamapps' in steam_library else steam_library diff --git a/jackify/backend/handlers/winetricks_handler.py b/jackify/backend/handlers/winetricks_handler.py index a9fc7de..8000e13 100644 --- a/jackify/backend/handlers/winetricks_handler.py +++ b/jackify/backend/handlers/winetricks_handler.py @@ -257,10 +257,10 @@ class WinetricksHandler: components_to_install = self._reorder_components_for_installation(all_components) self.logger.info(f"WINEPREFIX: {wineprefix}, Game: {game_var}, Ordered Components: {components_to_install}") - # Install components separately if dotnet40 is present (mimics protontricks behavior) + # Hybrid approach: Use protontricks for dotnet40 only, winetricks for everything else if "dotnet40" in components_to_install: - self.logger.info("dotnet40 detected - installing components separately like protontricks") - return self._install_components_separately(components_to_install, wineprefix, wine_binary, env) + self.logger.info("dotnet40 detected - using hybrid approach: protontricks for dotnet40, winetricks for others") + return self._install_components_hybrid_approach(components_to_install, wineprefix, game_var) # For non-dotnet40 installations, install all components together (faster) max_attempts = 3 @@ -482,6 +482,225 @@ class WinetricksHandler: self.logger.info("✓ All components installed successfully using separate sessions") return True + def _install_components_hybrid_approach(self, components: list, wineprefix: str, game_var: str) -> bool: + """ + Hybrid approach: Install dotnet40 with protontricks (known to work), + then install remaining components with winetricks (faster for other components). + + Args: + components: List of all components to install + wineprefix: Wine prefix path + game_var: Game variable for AppID detection + + Returns: + bool: True if all installations succeeded, False otherwise + """ + self.logger.info("Starting hybrid installation approach") + + # Separate dotnet40 (protontricks) from other components (winetricks) + protontricks_components = [comp for comp in components if comp == "dotnet40"] + other_components = [comp for comp in components if comp != "dotnet40"] + + self.logger.info(f"Protontricks components: {protontricks_components}") + self.logger.info(f"Other components: {other_components}") + + # Step 1: Install dotnet40 with protontricks if present + if protontricks_components: + self.logger.info(f"Installing {protontricks_components} using protontricks...") + if not self._install_dotnet40_with_protontricks(wineprefix, game_var): + self.logger.error(f"Failed to install {protontricks_components} with protontricks") + return False + self.logger.info(f"✓ {protontricks_components} installation completed successfully with protontricks") + + # Step 2: Install remaining components with winetricks if any + if other_components: + self.logger.info(f"Installing remaining components with winetricks: {other_components}") + + # Use existing winetricks logic for other components + env = self._prepare_winetricks_environment(wineprefix) + if not env: + return False + + return self._install_components_with_winetricks(other_components, wineprefix, env) + + self.logger.info("✓ Hybrid component installation completed successfully") + return True + + def _install_dotnet40_with_protontricks(self, wineprefix: str, game_var: str) -> bool: + """ + Install dotnet40 using protontricks (known to work reliably). + + Args: + wineprefix: Wine prefix path + game_var: Game variable for AppID detection + + Returns: + bool: True if installation succeeded, False otherwise + """ + try: + # Extract AppID from wineprefix path (e.g., /path/to/compatdata/123456789/pfx -> 123456789) + appid = None + if 'compatdata' in wineprefix: + # Standard Steam compatdata structure + path_parts = Path(wineprefix).parts + for i, part in enumerate(path_parts): + if part == 'compatdata' and i + 1 < len(path_parts): + potential_appid = path_parts[i + 1] + if potential_appid.isdigit(): + appid = potential_appid + break + + if not appid: + self.logger.error(f"Could not extract AppID from wineprefix path: {wineprefix}") + return False + + self.logger.info(f"Using AppID {appid} for protontricks dotnet40 installation") + + # Import and use protontricks handler + from .protontricks_handler import ProtontricksHandler + + # Determine if we're on Steam Deck (for protontricks handler) + steamdeck = os.path.exists('/home/deck') + + protontricks_handler = ProtontricksHandler(steamdeck=steamdeck, logger=self.logger) + + # Detect protontricks availability + if not protontricks_handler.detect_protontricks(): + self.logger.error("Protontricks not available for dotnet40 installation") + return False + + # Install dotnet40 using protontricks + success = protontricks_handler.install_wine_components(appid, game_var, ["dotnet40"]) + + if success: + self.logger.info("✓ dotnet40 installed successfully with protontricks") + return True + else: + self.logger.error("✗ dotnet40 installation failed with protontricks") + return False + + except Exception as e: + self.logger.error(f"Error installing dotnet40 with protontricks: {e}", exc_info=True) + return False + + def _prepare_winetricks_environment(self, wineprefix: str) -> Optional[dict]: + """ + Prepare the environment for winetricks installation. + This reuses the existing environment setup logic. + + Args: + wineprefix: Wine prefix path + + Returns: + dict: Environment variables for winetricks, or None if failed + """ + try: + env = os.environ.copy() + env['WINEDEBUG'] = '-all' + env['WINEPREFIX'] = wineprefix + env['WINETRICKS_GUI'] = 'none' + + # Existing Proton detection logic + from ..handlers.config_handler import ConfigHandler + from ..handlers.wine_utils import WineUtils + + config = ConfigHandler() + user_proton_path = config.get_proton_path() + + wine_binary = None + if user_proton_path != 'auto': + if os.path.exists(user_proton_path): + resolved_proton_path = os.path.realpath(user_proton_path) + valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine') + ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine') + + if os.path.exists(valve_proton_wine): + wine_binary = valve_proton_wine + elif os.path.exists(ge_proton_wine): + wine_binary = ge_proton_wine + + if not wine_binary: + best_proton = WineUtils.select_best_proton() + if best_proton: + wine_binary = WineUtils.find_proton_binary(best_proton['name']) + + if not wine_binary or not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)): + self.logger.error(f"Cannot prepare winetricks environment: No compatible Proton found") + return None + + env['WINE'] = str(wine_binary) + + # Set up protontricks-compatible environment (existing logic) + proton_dist_path = os.path.dirname(os.path.dirname(wine_binary)) + env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine" + env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}" + + # Existing DLL overrides + dll_overrides = { + "beclient": "b,n", "beclient_x64": "b,n", "dxgi": "n", "d3d9": "n", + "d3d10core": "n", "d3d11": "n", "d3d12": "n", "d3d12core": "n", + "nvapi": "n", "nvapi64": "n", "nvofapi64": "n", "nvcuda": "b" + } + + env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items()) + env['WINE_LARGE_ADDRESS_AWARE'] = '1' + env['DXVK_ENABLE_NVAPI'] = '1' + + # Set up winetricks cache + from jackify.shared.paths import get_jackify_data_dir + jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' + jackify_cache_dir.mkdir(parents=True, exist_ok=True) + env['WINETRICKS_CACHE'] = str(jackify_cache_dir) + + return env + + except Exception as e: + self.logger.error(f"Failed to prepare winetricks environment: {e}") + return None + + def _install_components_with_winetricks(self, components: list, wineprefix: str, env: dict) -> bool: + """ + Install components using winetricks with the prepared environment. + + Args: + components: List of components to install + wineprefix: Wine prefix path + env: Prepared environment variables + + Returns: + bool: True if installation succeeded, False otherwise + """ + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + if attempt > 1: + self.logger.warning(f"Retrying winetricks installation (attempt {attempt}/{max_attempts})") + self._cleanup_wine_processes() + + try: + cmd = [self.winetricks_path, '--unattended'] + components + self.logger.debug(f"Running winetricks: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=600 + ) + + if result.returncode == 0: + self.logger.info(f"✓ Winetricks components installed successfully: {components}") + return True + else: + self.logger.error(f"✗ Winetricks failed (attempt {attempt}): {result.stderr.strip()}") + + except Exception as e: + self.logger.error(f"Error during winetricks run (attempt {attempt}): {e}") + + self.logger.error(f"Failed to install components with winetricks after {max_attempts} attempts") + return False + def _cleanup_wine_processes(self): """ Internal method to clean up wine processes during component installation diff --git a/jackify/backend/services/native_steam_service.py b/jackify/backend/services/native_steam_service.py index 82634d8..8120062 100644 --- a/jackify/backend/services/native_steam_service.py +++ b/jackify/backend/services/native_steam_service.py @@ -30,100 +30,83 @@ class NativeSteamService: """ def __init__(self): - self.steam_path = Path.home() / ".steam" / "steam" - self.userdata_path = self.steam_path / "userdata" + self.steam_paths = [ + Path.home() / ".steam" / "steam", + Path.home() / ".local" / "share" / "Steam", + Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam" + ] + self.steam_path = None + self.userdata_path = None self.user_id = None self.user_config_path = None def find_steam_user(self) -> bool: - """Find the active Steam user directory""" + """ + Find the active Steam user directory using Steam's own configuration files. + No more guessing - uses loginusers.vdf to get the most recent user and converts SteamID64 to SteamID3. + """ try: - if not self.userdata_path.exists(): - logger.error("Steam userdata directory not found") + # Step 1: Find Steam installation using Steam's own file structure + if not self._find_steam_installation(): + logger.error("No Steam installation found") return False - # Find user directories (excluding user 0 which is a system account) - user_dirs = [d for d in self.userdata_path.iterdir() if d.is_dir() and d.name.isdigit() and d.name != "0"] - if not user_dirs: - logger.error("No valid Steam user directories found (user 0 is not valid for shortcuts)") + # Step 2: Parse loginusers.vdf to get the most recent user (SteamID64) + steamid64 = self._get_most_recent_user_from_loginusers() + if not steamid64: + logger.error("Could not determine most recent Steam user from loginusers.vdf") return False - # Detect the correct Steam user - user_dir = self._detect_active_steam_user(user_dirs) - if not user_dir: - logger.error("Could not determine active Steam user") + # Step 3: Convert SteamID64 to SteamID3 (userdata directory format) + steamid3 = self._convert_steamid64_to_steamid3(steamid64) + logger.info(f"Most recent Steam user: SteamID64={steamid64}, SteamID3={steamid3}") + + # Step 4: Verify the userdata directory exists + user_dir = self.userdata_path / str(steamid3) + if not user_dir.exists(): + logger.error(f"Userdata directory does not exist: {user_dir}") return False - self.user_id = user_dir.name - self.user_config_path = user_dir / "config" + config_dir = user_dir / "config" + if not config_dir.exists(): + logger.error(f"User config directory does not exist: {config_dir}") + return False - logger.info(f"Found Steam user: {self.user_id}") + # Step 5: Set up the service state + self.user_id = str(steamid3) + self.user_config_path = config_dir + + logger.info(f"VERIFIED Steam user: {self.user_id}") logger.info(f"User config path: {self.user_config_path}") + logger.info(f"Shortcuts.vdf will be at: {self.user_config_path / 'shortcuts.vdf'}") + return True - + except Exception as e: - logger.error(f"Error finding Steam user: {e}") + logger.error(f"Error finding Steam user: {e}", exc_info=True) return False - def _detect_active_steam_user(self, user_dirs: List[Path]) -> Optional[Path]: + def _find_steam_installation(self) -> bool: + """Find Steam installation by checking for config/loginusers.vdf""" + for steam_path in self.steam_paths: + loginusers_path = steam_path / "config" / "loginusers.vdf" + userdata_path = steam_path / "userdata" + + if loginusers_path.exists() and userdata_path.exists(): + self.steam_path = steam_path + self.userdata_path = userdata_path + logger.info(f"Found Steam installation at: {steam_path}") + return True + + return False + + def _get_most_recent_user_from_loginusers(self) -> Optional[str]: """ - Detect the active Steam user from available user directories. - - Priority: - 1. Single non-0 user: Use automatically - 2. Multiple users: Parse loginusers.vdf to find logged-in user - 3. Fallback: Most recently active user directory - - Args: - user_dirs: List of valid user directories - - Returns: - Path to the active user directory, or None if detection fails - """ - if len(user_dirs) == 1: - logger.info(f"Single Steam user found: {user_dirs[0].name}") - return user_dirs[0] - - logger.info(f"Multiple Steam users found: {[d.name for d in user_dirs]}") - - # Try to parse loginusers.vdf to find logged-in user - loginusers_path = self.steam_path / "loginusers.vdf" - active_user = self._parse_loginusers_vdf(loginusers_path) - - if active_user: - # Find matching user directory - for user_dir in user_dirs: - if user_dir.name == active_user: - logger.info(f"Found logged-in Steam user from loginusers.vdf: {active_user}") - return user_dir - - logger.warning(f"Logged-in user {active_user} from loginusers.vdf not found in user directories") - - # Fallback: Use most recently modified user directory - try: - most_recent = max(user_dirs, key=lambda d: d.stat().st_mtime) - logger.info(f"Using most recently active Steam user directory: {most_recent.name}") - return most_recent - except Exception as e: - logger.error(f"Error determining most recent user directory: {e}") - # Final fallback: Use first user - logger.warning(f"Using first user directory as final fallback: {user_dirs[0].name}") - return user_dirs[0] - - def _parse_loginusers_vdf(self, loginusers_path: Path) -> Optional[str]: - """ - Parse loginusers.vdf to find the currently logged-in Steam user. - - Args: - loginusers_path: Path to loginusers.vdf - - Returns: - Steam user ID as string, or None if parsing fails + Parse loginusers.vdf to get the SteamID64 of the most recent user. + Uses Steam's own MostRecent flag and Timestamp. """ try: - if not loginusers_path.exists(): - logger.debug(f"loginusers.vdf not found at {loginusers_path}") - return None + loginusers_path = self.steam_path / "config" / "loginusers.vdf" # Load VDF data vdf_data = VDFHandler.load(str(loginusers_path), binary=False) @@ -133,27 +116,52 @@ class NativeSteamService: users_section = vdf_data.get("users", {}) if not users_section: - logger.debug("No users section found in loginusers.vdf") + logger.error("No users section found in loginusers.vdf") return None - # Find user marked as logged in - for user_id, user_data in users_section.items(): - if isinstance(user_data, dict): - # Check for indicators of logged-in status - if user_data.get("MostRecent") == "1" or user_data.get("WantsOfflineMode") == "0": - logger.debug(f"Found most recent/logged-in user: {user_id}") - return user_id + most_recent_user = None + most_recent_timestamp = 0 - # If no specific user found, try to get the first valid user - if users_section: - first_user = next(iter(users_section.keys())) - logger.debug(f"No specific logged-in user found, using first user: {first_user}") - return first_user + # Find user with MostRecent=1 or highest timestamp + for steamid64, user_data in users_section.items(): + if isinstance(user_data, dict): + # Check for MostRecent flag first + if user_data.get("MostRecent") == "1": + logger.info(f"Found user marked as MostRecent: {steamid64}") + return steamid64 + + # Also track highest timestamp as fallback + timestamp = int(user_data.get("Timestamp", "0")) + if timestamp > most_recent_timestamp: + most_recent_timestamp = timestamp + most_recent_user = steamid64 + + # Return user with highest timestamp if no MostRecent flag found + if most_recent_user: + logger.info(f"Found most recent user by timestamp: {most_recent_user}") + return most_recent_user + + logger.error("No valid users found in loginusers.vdf") + return None except Exception as e: logger.error(f"Error parsing loginusers.vdf: {e}") + return None + + def _convert_steamid64_to_steamid3(self, steamid64: str) -> int: + """ + Convert SteamID64 to SteamID3 (used in userdata directory names). + Formula: SteamID3 = SteamID64 - 76561197960265728 + """ + try: + steamid64_int = int(steamid64) + steamid3 = steamid64_int - 76561197960265728 + logger.debug(f"Converted SteamID64 {steamid64} to SteamID3 {steamid3}") + return steamid3 + except ValueError as e: + logger.error(f"Invalid SteamID64 format: {steamid64}") + raise - return None def get_shortcuts_vdf_path(self) -> Optional[Path]: """Get the path to shortcuts.vdf""" diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py index f1b9d17..fd61821 100644 --- a/jackify/frontends/gui/main.py +++ b/jackify/frontends/gui/main.py @@ -636,16 +636,17 @@ class SettingsDialog(QDialog): 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 - if getattr(sys, 'frozen', False): - # PyInstaller bundle: safe to restart - self.accept() - os.execv(sys.executable, [sys.executable] + sys.argv) - return + # 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: show message instead of auto-restart - MessageService.information(self, "Manual Restart Required", "Please restart Jackify manually to apply debug mode changes.", safety_level="low") - self.accept() - return + # Dev mode: restart the Python module + os.execv(sys.executable, [sys.executable, '-m', 'jackify.frontends.gui'] + sys.argv[1:]) + return MessageService.information(self, "Settings Saved", "Settings have been saved successfully.", safety_level="low") self.accept()