"""Shortcut operation methods for AutomatedPrefixService (Mixin).""" from pathlib import Path from typing import Optional, Tuple, List, Dict import logging import os import time import vdf import subprocess from .automated_prefix_shortcuts_cleanup import AutomatedPrefixShortcutsCleanupMixin logger = logging.getLogger(__name__) class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin): """Mixin providing shortcut operation methods for AutomatedPrefixService.""" def create_shortcut_with_native_service(self, shortcut_name: str, exe_path: str, modlist_install_dir: str, custom_launch_options: str = None, download_dir=None) -> Tuple[bool, Optional[int]]: """ Create a Steam shortcut using the native Steam service (no STL). Args: shortcut_name: Name for the shortcut exe_path: Path to the executable modlist_install_dir: Directory where the modlist is installed custom_launch_options: Pre-generated launch options (overrides default generation) download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS Returns: (success, unsigned_app_id) """ logger.info(f"Creating shortcut with native service: {shortcut_name}") try: from ..services.native_steam_service import NativeSteamService # Initialize native Steam service steam_service = NativeSteamService() # Always compute STEAM_COMPAT_MOUNTS; custom_launch_options replaces %command% but # still needs mounts so game assets on other drives are reachable inside the prefix. mounts_prefix = "" try: from ..handlers.path_handler import PathHandler path_handler = PathHandler() mount_paths = path_handler.get_steam_compat_mount_paths( install_dir=modlist_install_dir, download_dir=download_dir ) if mount_paths: mounts_prefix = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}"' logger.info(f"Generated STEAM_COMPAT_MOUNTS: {mounts_prefix}") except Exception as e: logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS: {e}") if custom_launch_options: launch_options = f"{mounts_prefix} {custom_launch_options}".strip() if mounts_prefix else custom_launch_options logger.info(f"Launch options (custom + mounts): {launch_options}") elif mounts_prefix: launch_options = f'{mounts_prefix} %command%' logger.info(f"Launch options (mounts only): {launch_options}") else: launch_options = "%command%" # Get user's preferred Proton version (with Lorerim-specific override) proton_version = self._get_user_proton_version(shortcut_name) # Create shortcut with Proton using native service success, app_id = steam_service.create_shortcut_with_proton( app_name=shortcut_name, exe_path=exe_path, start_dir=modlist_install_dir, launch_options=launch_options, tags=["Jackify"], proton_version=proton_version ) if success and app_id: logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}") return True, app_id else: logger.error("Native Steam service failed to create shortcut") return False, None except Exception as e: logger.error(f"Error creating shortcut with native service: {e}") return False, None def verify_shortcut_created(self, shortcut_name: str) -> Optional[int]: """ Verify the shortcut was created and get its AppID. Args: shortcut_name: Name of the shortcut to look for Returns: AppID if found, None otherwise """ try: shortcuts_path = self._get_shortcuts_path() if not shortcuts_path: return None with open(shortcuts_path, 'rb') as f: shortcuts_data = vdf.binary_load(f) shortcuts = shortcuts_data.get('shortcuts', {}) # Look for our shortcut by name for i in range(len(shortcuts)): shortcut = shortcuts[str(i)] name = shortcut.get('AppName', '') if shortcut_name in name: appid = shortcut.get('appid') exe_path = shortcut.get('Exe', '').strip('"') logger.info(f"Found shortcut: {name}") logger.info(f" AppID: {appid}") logger.info(f" Exe: {exe_path}") logger.info(f" CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}") return appid logger.error(f"Shortcut '{shortcut_name}' not found") return None except Exception as e: logger.error(f"Error reading shortcuts: {e}") return None def create_shortcut_directly(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool: """ Create a Steam shortcut directly by modifying shortcuts.vdf. This is a fallback when STL fails. Args: shortcut_name: Name for the shortcut exe_path: Path to the executable modlist_install_dir: Directory where the modlist is installed Returns: True if successful, False otherwise """ try: logger.debug(f"[DEBUG] create_shortcut_directly called for '{shortcut_name}' - this is the fallback method") shortcuts_path = self._get_shortcuts_path() if not shortcuts_path: logger.debug("[DEBUG] No shortcuts path found") return False # Read current shortcuts with open(shortcuts_path, 'rb') as f: shortcuts_data = vdf.binary_load(f) shortcuts = shortcuts_data.get('shortcuts', {}) # Find the next available index next_index = str(len(shortcuts)) # Calculate AppID for the new shortcut (negative for non-Steam shortcuts) import hashlib app_name_bytes = shortcut_name.encode('utf-8') exe_bytes = exe_path.encode('utf-8') combined = app_name_bytes + exe_bytes hash_value = int(hashlib.md5(combined).hexdigest()[:8], 16) appid = -(hash_value & 0x7FFFFFFF) # Make it negative and within 32-bit range # Create new shortcut entry new_shortcut = { 'AppName': shortcut_name, 'Exe': f'"{exe_path}"', 'StartDir': f'"{modlist_install_dir}"', 'appid': appid, 'icon': '', 'ShortcutPath': '', 'LaunchOptions': '', 'IsHidden': 0, 'AllowDesktopConfig': 1, 'AllowOverlay': 1, 'openvr': 0, 'Devkit': 0, 'DevkitGameID': '', 'LastPlayTime': 0, 'FlatpakAppID': '', 'tags': {}, 'CompatTool': 'proton_experimental', # Set Proton Experimental 'IsInstalled': 1 # Make it appear in "Locally Installed" filter } # Add the new shortcut shortcuts[next_index] = new_shortcut # Write back to file with open(shortcuts_path, 'wb') as f: vdf.binary_dump(shortcuts_data, f) logger.info(f"Created shortcut directly: {shortcut_name}") return True except Exception as e: logger.error(f"Error creating shortcut directly: {e}") return False def modify_shortcut_to_final_exe(self, shortcut_name: str, final_exe_path: str, final_start_dir: str) -> bool: """ Update the existing batch file shortcut to point to the final executable. This preserves the AppID and prefix association while changing the target. Args: shortcut_name: Name of the shortcut to modify final_exe_path: Path to the final executable (e.g., ModOrganizer.exe) final_start_dir: Start directory for the executable Returns: True if successful, False otherwise """ try: shortcuts_path = self._get_shortcuts_path() if not shortcuts_path: return False # Read current shortcuts with open(shortcuts_path, 'rb') as f: shortcuts_data = vdf.binary_load(f) shortcuts = shortcuts_data.get('shortcuts', {}) # Find the batch file shortcut that created the prefix logger.info(f"Looking for batch file shortcut '{shortcut_name}' among {len(shortcuts)} shortcuts...") target_shortcut = None target_index = None for i in range(len(shortcuts)): shortcut = shortcuts[str(i)] name = shortcut.get('AppName', '') exe = shortcut.get('Exe', '') # Find the specific shortcut that points to our batch file (handle quoted paths) if (name == shortcut_name and exe and 'prefix_creation_' in exe and (exe.endswith('.bat') or exe.endswith('.bat"'))): target_shortcut = shortcut target_index = str(i) logger.info(f"Found batch file shortcut '{shortcut_name}' at index {i}") logger.info(f" Current Exe: {exe}") logger.info(f" Current StartDir: {shortcut.get('StartDir', '')}") logger.info(f" Current CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}") logger.info(f" AppID: {shortcut.get('appid', 'NOT_SET')}") break if target_shortcut is None: logger.error(f"No batch file shortcut found with name '{shortcut_name}'") # Debug: show all available shortcuts logger.debug("Available shortcuts:") for i in range(len(shortcuts)): shortcut = shortcuts[str(i)] name = shortcut.get('AppName', '') exe = shortcut.get('Exe', '') logger.debug(f" [{i}] {name} -> {exe}") return False # Update the existing shortcut IN-PLACE (preserves AppID and all other fields) logger.info(f"Updating shortcut at index {target_index} IN-PLACE...") # Only change Exe and StartDir - preserve everything else including AppID old_exe = target_shortcut.get('Exe', '') old_start_dir = target_shortcut.get('StartDir', '') target_shortcut['Exe'] = f'"{final_exe_path}"' target_shortcut['StartDir'] = f'"{final_start_dir}"' # Ensure CompatTool is set (STL should have set this, but make sure) if not target_shortcut.get('CompatTool', '').strip(): target_shortcut['CompatTool'] = 'proton_experimental' logger.info("Set CompatTool to proton_experimental (was not set)") logger.info(f" Updated shortcut '{shortcut_name}' at index {target_index}:") logger.info(f" Exe: {old_exe} → {target_shortcut['Exe']}") logger.info(f" StartDir: {old_start_dir} → {target_shortcut['StartDir']}") logger.info(f" AppID: {target_shortcut.get('appid', 'NOT_SET')} (preserved)") logger.info(f" CompatTool: {target_shortcut.get('CompatTool', 'NOT_SET')} (preserved)") # Write back to file with open(shortcuts_path, 'wb') as f: vdf.binary_dump(shortcuts_data, f) logger.info(" Shortcut updated successfully - no duplicates created") return True except Exception as e: logger.error(f"Error modifying shortcut: {e}") return False def verify_final_shortcut(self, shortcut_name: str, expected_exe_path: str) -> bool: """ Verify the shortcut now points to the final executable. Args: shortcut_name: Name of the shortcut to verify expected_exe_path: Expected executable path Returns: True if shortcut is correct, False otherwise """ try: shortcuts_path = self._get_shortcuts_path() if not shortcuts_path: return False with open(shortcuts_path, 'rb') as f: shortcuts_data = vdf.binary_load(f) shortcuts = shortcuts_data.get('shortcuts', {}) # Find our shortcut for i in range(len(shortcuts)): shortcut = shortcuts[str(i)] name = shortcut.get('AppName', '') if shortcut_name in name: exe_path = shortcut.get('Exe', '') start_dir = shortcut.get('StartDir', '') logger.info(f"Final shortcut configuration:") logger.info(f" Name: {name}") logger.info(f" Exe: {exe_path}") logger.info(f" StartDir: {start_dir}") # Verify it points to the final executable if expected_exe_path in exe_path: logger.info("Shortcut correctly points to final executable") return True else: logger.error("Shortcut does not point to final executable") return False logger.error(f"Shortcut '{shortcut_name}' not found") return False except Exception as e: logger.error(f"Error reading shortcuts: {e}") return False