"""Shortcut discovery and AppID methods for ShortcutHandler (Mixin).""" import logging import os import re from pathlib import Path from typing import List, Dict, Optional from .vdf_handler import VDFHandler logger = logging.getLogger(__name__) class ShortcutDiscoveryMixin: """Mixin providing shortcut discovery and AppID resolution methods.""" # DEAD CODE - Commented out 2026-01-29 # These methods were never completed. create_shortcut() requires arguments # and returns tuple(bool, str), not dict. Kept for reference if CLI shortcut # creation feature is implemented later. # # def create_shortcut_workflow(self): # """Run the complete shortcut creation workflow""" # shortcut_data = self.create_shortcut() # if not shortcut_data: # return False # return True # # def create_new_modlist_shortcut(self): # """Create a new modlist shortcut in Steam""" # print("\nShortcut Creation") # ... # modlist_data = self.create_shortcut() # BUG: needs args, returns tuple not dict # ... def get_selected_modlist(self): """ Get the selected modlist string in the format expected by ModlistHandler.configure_modlist Returns: str: Selected modlist string in the format "Non-Steam shortcut: Name (AppID)" or None if no modlist was selected """ return getattr(self, 'selected_modlist', None) def get_appid_for_shortcut(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]: """ Find the current AppID for a given shortcut name and (optionally) executable path. Primary method: Read directly from shortcuts.vdf (reliable, no external dependencies) Fallback method: Use protontricks (if available) Args: shortcut_name (str): The name of the Steam shortcut. exe_path (Optional[str]): The path to the executable (for robust matching after Steam restart). Returns: Optional[str]: The found AppID string, or None if not found or error occurs. """ self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')") try: appid = self.get_appid_from_vdf(shortcut_name, exe_path) if appid: self.logger.info(f"Successfully found AppID {appid} from shortcuts.vdf") return appid self.logger.info("AppID not found in shortcuts.vdf, trying protontricks as fallback...") from .protontricks_handler import ProtontricksHandler pt_handler = ProtontricksHandler(self.steamdeck) if not pt_handler.detect_protontricks(): self.logger.warning("Protontricks not detected - cannot use as fallback") return None result = pt_handler.run_protontricks("-l") if not result or result.returncode != 0: self.logger.warning(f"Protontricks fallback failed: {result.stderr if result else 'No result'}") return None found_shortcuts = [] for line in result.stdout.splitlines(): m = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line) if m: pt_name = m.group(1).strip() pt_appid = m.group(2) found_shortcuts.append((pt_name, pt_appid)) vdf_shortcuts = [] shortcuts_vdf_path = self.shortcuts_path if shortcuts_vdf_path and os.path.isfile(shortcuts_vdf_path): try: shortcuts_data = VDFHandler.load(shortcuts_vdf_path, binary=True) if shortcuts_data and 'shortcuts' in shortcuts_data: for idx, shortcut in shortcuts_data['shortcuts'].items(): app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip() exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip() vdf_shortcuts.append((app_name, exe, idx)) except Exception as e: self.logger.error(f"Error parsing shortcuts.vdf for exe path matching: {e}") if exe_path: exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower() shortcut_name_clean = shortcut_name.strip().lower() for pt_name, pt_appid in found_shortcuts: for vdf_name, vdf_exe, vdf_idx in vdf_shortcuts: if vdf_name.strip().lower() == pt_name.strip().lower() == shortcut_name_clean: vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower() if vdf_exe_norm == exe_path_norm: self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' with exe '{vdf_exe}' (input: '{exe_path}')") return pt_appid self.logger.error(f"No shortcut found matching both name '{shortcut_name}' and exe_path '{exe_path}'.") return None shortcut_name_clean = shortcut_name.strip().lower() for pt_name, pt_appid in found_shortcuts: if pt_name.strip().lower() == shortcut_name_clean: self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' (input: '{shortcut_name}')") return pt_appid self.logger.error(f"Could not find an AppID for shortcut named '{shortcut_name}' via protontricks.") return None except Exception as e: self.logger.error(f"Error getting AppID for shortcut '{shortcut_name}': {e}") self.logger.exception("Traceback:") return None def get_appid_from_vdf(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]: """ Get AppID directly from shortcuts.vdf by reading the file and matching shortcut name/exe. This is more reliable than using protontricks since it doesn't depend on external tools. Args: shortcut_name (str): The name of the Steam shortcut. exe_path (Optional[str]): The path to the executable for additional validation. Returns: Optional[str]: The AppID as a string, or None if not found. """ self.logger.info(f"Looking up AppID from shortcuts.vdf for shortcut: '{shortcut_name}' (exe: '{exe_path}')") if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): self.logger.warning(f"Shortcuts.vdf not found at {self.shortcuts_path}") return None try: shortcuts_data = VDFHandler.load(self.shortcuts_path, binary=True) if not shortcuts_data or 'shortcuts' not in shortcuts_data: self.logger.warning("No shortcuts found in shortcuts.vdf") return None shortcut_name_clean = shortcut_name.strip().lower() for idx, shortcut in shortcuts_data['shortcuts'].items(): name = shortcut.get('AppName', shortcut.get('appname', '')).strip() if name.lower() == shortcut_name_clean: appid = shortcut.get('appid') if appid: if exe_path: vdf_exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip() exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower() vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower() if vdf_exe_norm == exe_path_norm: self.logger.info(f"Found AppID {appid} for shortcut '{name}' with matching exe '{vdf_exe}'") return str(int(appid) & 0xFFFFFFFF) else: self.logger.debug(f"Found shortcut '{name}' but exe doesn't match: '{vdf_exe}' vs '{exe_path}'") continue else: self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)") return str(int(appid) & 0xFFFFFFFF) self.logger.debug(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'") return None except Exception as e: self.logger.error(f"Error reading shortcuts.vdf: {e}") self.logger.exception("Traceback:") return None def _scan_shortcuts_for_executable(self, executable_name: str) -> List[Dict[str, str]]: """ Scans the user's shortcuts.vdf file for entries pointing to a specific executable. Args: executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe") Returns: List[Dict[str, str]]: A list of dictionaries, each containing {'name': AppName, 'path': StartDir} for shortcuts matching the executable name. """ self.logger.info(f"Scanning {self.shortcuts_path} for executable '{executable_name}'...") matched_shortcuts = [] if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations") return [] shortcuts_file = self.shortcuts_path try: shortcuts_data = VDFHandler.load(shortcuts_file, binary=True) if shortcuts_data is None or 'shortcuts' not in shortcuts_data: self.logger.warning(f"Could not load or parse data from {shortcuts_file}") return [] for shortcut_id, shortcut in shortcuts_data['shortcuts'].items(): if not isinstance(shortcut, dict): self.logger.warning(f"Skipping invalid shortcut entry (not a dict) at index {shortcut_id} in {shortcuts_file}") continue app_name = shortcut.get('AppName', shortcut.get('appname')) exe_path = shortcut.get('Exe', shortcut.get('exe', '')).strip('"') start_dir = shortcut.get('StartDir', shortcut.get('startdir', '')).strip('"') if app_name and start_dir and os.path.basename(exe_path) == executable_name: is_valid = True if executable_name == "ModOrganizer.exe": if not (Path(start_dir) / 'ModOrganizer.ini').exists(): self.logger.warning(f"Found MO2 shortcut '{app_name}' but ModOrganizer.ini missing in '{start_dir}'") is_valid = False if is_valid: matched_shortcuts.append({'name': app_name, 'path': start_dir}) self.logger.debug(f"Found '{executable_name}' shortcut in VDF: {app_name} -> {start_dir}") except Exception as e: self.logger.error(f"Error processing {shortcuts_file}: {e}") return [] self.logger.info(f"Scan complete. Found {len(matched_shortcuts)} potential '{executable_name}' shortcuts in VDF file.") return matched_shortcuts def discover_executable_shortcuts(self, executable_name: str) -> List[str]: """ Discovers non-Steam shortcuts for a specific executable, cross-referencing VDF files with the Protontricks runtime list. Args: executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe") Returns: List[str]: A list of strings in the format "Non-Steam shortcut: Name (AppID)" for valid, matched shortcuts. """ self.logger.info(f"Discovering configured shortcuts for '{executable_name}'...") vdf_shortcuts = self._scan_shortcuts_for_executable(executable_name) if not vdf_shortcuts: self.logger.warning(f"No '{executable_name}' shortcuts found in VDF files.") pt_result = self.protontricks_handler.run_protontricks("-l") if not pt_result or pt_result.returncode != 0: self.logger.error(f"Protontricks failed to list applications: {pt_result.stderr if pt_result else 'No result'}") return [] pt_shortcuts = {} for line in pt_result.stdout.splitlines(): line = line.strip() if "Non-Steam shortcut:" in line: match = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line) if match: pt_name = match.group(1).strip() pt_appid = match.group(2) pt_shortcuts[pt_name] = pt_appid if not pt_shortcuts: self.logger.warning("No Non-Steam shortcuts listed by Protontricks.") return [] final_list = [] for vdf_shortcut in vdf_shortcuts: vdf_name = vdf_shortcut['name'] if vdf_name in pt_shortcuts: runtime_appid = pt_shortcuts[vdf_name] modlist_string = f"Non-Steam shortcut: {vdf_name} ({runtime_appid})" final_list.append(modlist_string) self.logger.debug(f"Validated shortcut: {modlist_string}") if not final_list: self.logger.warning(f"No shortcuts for '{executable_name}' found in VDF matched the Protontricks list.") self.logger.info(f"Discovery complete. Found {len(final_list)} validated shortcuts for '{executable_name}'.") return final_list def find_shortcuts_by_exe(self, executable_name: str) -> List[Dict]: """Finds shortcuts in shortcuts.vdf that point to a specific executable. Args: executable_name: The name of the executable (e.g., "ModOrganizer.exe") to search for within the 'Exe' path. Returns: A list of dictionaries, each representing a matching shortcut and containing keys like 'AppName', 'Exe', 'StartDir'. Returns an empty list if no matches are found or an error occurs. """ self.logger.info(f"Scanning {self.shortcuts_path} for executable: {executable_name}") matching_shortcuts = [] if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path): self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations") return [] vdf_path = self.shortcuts_path try: self.logger.debug(f"Parsing shortcuts file: {vdf_path}") shortcuts_data = VDFHandler.load(vdf_path, binary=True) if not shortcuts_data or 'shortcuts' not in shortcuts_data: self.logger.warning(f"Shortcuts data is empty or invalid in {vdf_path}") return [] shortcuts_dict = shortcuts_data.get('shortcuts', {}) for index, shortcut_details in shortcuts_dict.items(): if not isinstance(shortcut_details, dict): self.logger.warning(f"Skipping invalid shortcut entry at index {index} in {vdf_path}") continue exe_path = shortcut_details.get('Exe', shortcut_details.get('exe', '')).strip('"') app_name = shortcut_details.get('AppName', shortcut_details.get('appname', 'Unknown Shortcut')) if executable_name in os.path.basename(exe_path): self.logger.info(f"Found matching shortcut '{app_name}' in {vdf_path}") app_id = shortcut_details.get('appid', shortcut_details.get('AppID', shortcut_details.get('appId', None))) start_dir = shortcut_details.get('StartDir', shortcut_details.get('startdir', '')).strip('"') match = { 'AppName': app_name, 'Exe': exe_path, 'StartDir': start_dir, 'appid': app_id } matching_shortcuts.append(match) else: self.logger.debug(f"Skipping shortcut '{app_name}': Exe path '{exe_path}' does not contain '{executable_name}'") except Exception as e: self.logger.error(f"Error processing shortcuts file {vdf_path}: {e}", exc_info=True) return [] if not matching_shortcuts: self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in {vdf_path}.") return matching_shortcuts