Files
Jackify/jackify/backend/services/automated_prefix_shortcuts.py
2026-04-20 20:57:23 +01:00

345 lines
15 KiB
Python

"""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