mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-13 23:33:43 +02:00
285 lines
13 KiB
Python
285 lines
13 KiB
Python
"""Launch options and icon methods for ShortcutHandler (Mixin)."""
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import time
|
|
import vdf
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ShortcutLaunchOptionsMixin:
|
|
"""Mixin providing launch options and icon methods."""
|
|
|
|
def get_shortcut_launch_options(self, app_name: str, exe_path: str) -> 'Optional[str]':
|
|
"""Return current LaunchOptions for a shortcut, or None if the shortcut is not found."""
|
|
shortcuts_file = self.path_handler._find_shortcuts_vdf()
|
|
if not shortcuts_file or not os.path.exists(shortcuts_file):
|
|
return None
|
|
try:
|
|
with open(shortcuts_file, 'rb') as f:
|
|
data = vdf.binary_loads(f.read())
|
|
except Exception as e:
|
|
self.logger.debug(f"Could not read shortcuts.vdf: {e}")
|
|
return None
|
|
|
|
def _norm(p: str) -> str:
|
|
try:
|
|
return os.path.normpath(os.path.abspath(p.strip().strip('"'))).lower()
|
|
except Exception:
|
|
return p.strip().strip('"').lower()
|
|
|
|
exe_norm = _norm(exe_path)
|
|
for shortcut_data in data.get('shortcuts', {}).values():
|
|
if (shortcut_data.get('AppName', '').strip() == app_name and
|
|
_norm(shortcut_data.get('Exe', '')) == exe_norm):
|
|
return shortcut_data.get('LaunchOptions', '')
|
|
return None
|
|
|
|
def ensure_mounts_in_steam_compat(self, app_name: str, exe_path: str, *paths: str) -> str:
|
|
"""Add mountpoints of any supplied paths to STEAM_COMPAT_MOUNTS if not already present.
|
|
|
|
Reads existing launch options and appends only what is missing — never overwrites
|
|
unrelated options. Adds the top-level directory of each path so Proton's container
|
|
can bind-mount the subtree into the prefix.
|
|
|
|
When Steam is running, the write is deferred: returns "steam_running" so the caller
|
|
can stop Steam first, call apply_pending_mounts_update(), then restart Steam.
|
|
|
|
Returns:
|
|
"unchanged" — mounts already correct, no action needed
|
|
"updated" — Steam was not running; write succeeded
|
|
"steam_running" — changes needed but deferred; call apply_pending_mounts_update()
|
|
after stopping Steam
|
|
"failed" — shortcut not found or write error
|
|
"""
|
|
import re
|
|
from pathlib import Path as _Path
|
|
|
|
def _is_covered(path: str, mounts: list) -> bool:
|
|
"""Return True if path is already reachable via an existing mount entry.
|
|
|
|
A path is covered if an existing mount entry is equal to it or is a
|
|
parent of it. Root '/' is excluded as a catch-all.
|
|
"""
|
|
p = _Path(path)
|
|
for mount in mounts:
|
|
if mount == '/':
|
|
continue
|
|
try:
|
|
p.relative_to(mount)
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
return False
|
|
|
|
current = self.get_shortcut_launch_options(app_name, exe_path)
|
|
if current is None:
|
|
self.logger.warning(f"Shortcut '{app_name}' not found in shortcuts.vdf; cannot update STEAM_COMPAT_MOUNTS")
|
|
return "failed"
|
|
|
|
compat_re = re.compile(r'STEAM_COMPAT_MOUNTS="([^"]*)"')
|
|
m = compat_re.search(current)
|
|
existing = [p for p in m.group(1).split(':') if p] if m else []
|
|
|
|
mounts_to_add = []
|
|
for p in paths:
|
|
if not p:
|
|
continue
|
|
if not _is_covered(p, existing) and p not in mounts_to_add:
|
|
mounts_to_add.append(p)
|
|
|
|
if not mounts_to_add:
|
|
self.logger.debug(f"STEAM_COMPAT_MOUNTS for '{app_name}' already covers required paths")
|
|
return "unchanged"
|
|
|
|
if m:
|
|
updated_val = ':'.join(existing + mounts_to_add)
|
|
updated = compat_re.sub(f'STEAM_COMPAT_MOUNTS="{updated_val}"', current)
|
|
else:
|
|
val = ':'.join(mounts_to_add)
|
|
prefix = f'STEAM_COMPAT_MOUNTS="{val}"'
|
|
updated = f'{prefix} {current}' if current.strip() else f'{prefix} %command%'
|
|
|
|
self.logger.info(f"STEAM_COMPAT_MOUNTS update needed for '{app_name}': adding {mounts_to_add}")
|
|
|
|
try:
|
|
from jackify.backend.services.steam_restart_service import get_steam_processes
|
|
steam_running = bool(get_steam_processes())
|
|
except Exception:
|
|
steam_running = False
|
|
|
|
if steam_running:
|
|
# Defer the write — Steam holds shortcuts.vdf in memory and would clobber it.
|
|
# Store the pending options so the GUI can stop Steam, apply, then restart.
|
|
self._pending_mounts_app_name = app_name
|
|
self._pending_mounts_exe_path = exe_path
|
|
self._pending_mounts_options = updated
|
|
return "steam_running"
|
|
|
|
success = self.update_shortcut_launch_options(app_name, exe_path, updated)
|
|
return "updated" if success else "failed"
|
|
|
|
def apply_pending_mounts_update(self) -> bool:
|
|
"""Write a deferred STEAM_COMPAT_MOUNTS update. Call only after Steam has stopped."""
|
|
app_name = getattr(self, '_pending_mounts_app_name', None)
|
|
exe_path = getattr(self, '_pending_mounts_exe_path', None)
|
|
options = getattr(self, '_pending_mounts_options', None)
|
|
if not (app_name and exe_path and options):
|
|
self.logger.warning("apply_pending_mounts_update called with no pending update")
|
|
return False
|
|
self._pending_mounts_app_name = None
|
|
self._pending_mounts_exe_path = None
|
|
self._pending_mounts_options = None
|
|
return self.update_shortcut_launch_options(app_name, exe_path, options)
|
|
|
|
def update_shortcut_launch_options(self, app_name, exe_path, new_launch_options):
|
|
"""
|
|
Updates the LaunchOptions for a specific existing shortcut in shortcuts.vdf by matching AppName and Exe.
|
|
|
|
Args:
|
|
app_name (str): The AppName of the shortcut to update (from config summary).
|
|
exe_path (str): The Exe path of the shortcut to update (from config summary, including quotes if present in VDF).
|
|
new_launch_options (str): The new string to set for LaunchOptions.
|
|
|
|
Returns:
|
|
bool: True if the update was successful, False otherwise.
|
|
"""
|
|
self.logger.info(f"Attempting to update launch options for shortcut with AppName '{app_name}' and Exe '{exe_path}' (no AppID matching)...")
|
|
|
|
shortcuts_file = self.path_handler._find_shortcuts_vdf()
|
|
if not shortcuts_file:
|
|
self.logger.error("Could not find shortcuts.vdf to update.")
|
|
return False
|
|
|
|
data = {'shortcuts': {}}
|
|
try:
|
|
if os.path.exists(shortcuts_file):
|
|
with open(shortcuts_file, 'rb') as f:
|
|
file_data = f.read()
|
|
if file_data:
|
|
data = vdf.binary_loads(file_data)
|
|
if 'shortcuts' not in data:
|
|
data['shortcuts'] = {}
|
|
else:
|
|
self.logger.error(f"shortcuts.vdf does not exist at {shortcuts_file}. Cannot update.")
|
|
return False
|
|
except Exception as e:
|
|
self.logger.error(f"Error reading or parsing shortcuts.vdf: {e}")
|
|
return False
|
|
|
|
def _normalize_path(p: str) -> str:
|
|
try:
|
|
p_clean = os.path.abspath(os.path.expanduser(p.strip().strip('"')))
|
|
return os.path.normpath(p_clean).lower()
|
|
except Exception:
|
|
return p.strip().strip('"').lower()
|
|
|
|
exe_norm = _normalize_path(exe_path)
|
|
target_index = None
|
|
for index, shortcut_data in data.get('shortcuts', {}).items():
|
|
shortcut_name = (shortcut_data.get('AppName', '') or '').strip()
|
|
shortcut_exe_raw = shortcut_data.get('Exe', '')
|
|
shortcut_exe_norm = _normalize_path(shortcut_exe_raw)
|
|
if shortcut_name == app_name and shortcut_exe_norm == exe_norm:
|
|
target_index = index
|
|
break
|
|
|
|
if target_index is None:
|
|
self.logger.error(f"Could not find shortcut with AppName '{app_name}' and Exe '{exe_path}' in shortcuts.vdf.")
|
|
for index, shortcut_data in data.get('shortcuts', {}).items():
|
|
shortcut_name = shortcut_data.get('AppName', '')
|
|
shortcut_exe = shortcut_data.get('Exe', '')
|
|
self.logger.error(f"Found shortcut: AppName='{shortcut_name}', Exe='{shortcut_exe}' -> norm='{_normalize_path(shortcut_exe)}'")
|
|
return False
|
|
|
|
if target_index in data['shortcuts']:
|
|
self.logger.info(f"Found shortcut at index {target_index}. Updating LaunchOptions...")
|
|
data['shortcuts'][target_index]['LaunchOptions'] = new_launch_options
|
|
else:
|
|
self.logger.error(f"Target index {target_index} not found in shortcuts dictionary after identification.")
|
|
return False
|
|
|
|
try:
|
|
temp_file = f"{shortcuts_file}.temp"
|
|
with open(temp_file, 'wb') as f:
|
|
vdf_data = vdf.binary_dumps(data)
|
|
f.write(vdf_data)
|
|
|
|
backup_dir = os.path.join(os.path.dirname(shortcuts_file), "backups")
|
|
os.makedirs(backup_dir, exist_ok=True)
|
|
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
backup_path = os.path.join(backup_dir, f"shortcuts_update_{app_name}_{timestamp}.bak")
|
|
if os.path.exists(shortcuts_file):
|
|
shutil.copy2(shortcuts_file, backup_path)
|
|
self.logger.info(f"Created backup before update at {backup_path}")
|
|
|
|
shutil.move(temp_file, shortcuts_file)
|
|
self.logger.info(f"Successfully updated LaunchOptions for shortcut '{app_name}' in {shortcuts_file}.")
|
|
return True
|
|
except Exception as e:
|
|
self.logger.error(f"Error writing updated shortcuts.vdf: {e}")
|
|
if 'backup_path' in locals() and os.path.exists(backup_path):
|
|
try:
|
|
shutil.copy2(backup_path, shortcuts_file)
|
|
self.logger.warning(f"Restored shortcuts.vdf from backup {backup_path} after update failure.")
|
|
except Exception as restore_e:
|
|
self.logger.critical(f"CRITICAL: Failed to write updated shortcuts.vdf AND failed to restore backup! Error: {restore_e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def get_steam_shortcut_icon_path(exe_path, steamicons_dir=None, logger=None):
|
|
"""
|
|
Select the best icon for a Steam shortcut given an executable path and optional SteamIcons directory.
|
|
Prefers grid-tall.png, else any .png, else returns ''.
|
|
Logs selection steps if logger is provided.
|
|
"""
|
|
exe_dir = os.path.dirname(exe_path)
|
|
if not steamicons_dir:
|
|
steamicons_dir = os.path.join(exe_dir, "SteamIcons")
|
|
if logger:
|
|
logger.debug(f"[DEBUG] Looking for Steam shortcut icon in: {steamicons_dir}")
|
|
if os.path.isdir(steamicons_dir):
|
|
preferred_icon = os.path.join(steamicons_dir, "grid-tall.png")
|
|
if os.path.isfile(preferred_icon):
|
|
if logger:
|
|
logger.debug(f"[DEBUG] Using grid-tall.png as shortcut icon: {preferred_icon}")
|
|
return preferred_icon
|
|
pngs = [f for f in os.listdir(steamicons_dir) if f.lower().endswith('.png')]
|
|
if pngs:
|
|
icon_path = os.path.join(steamicons_dir, pngs[0])
|
|
if logger:
|
|
logger.debug(f"[DEBUG] Using fallback icon for shortcut: {icon_path}")
|
|
return icon_path
|
|
if logger:
|
|
logger.debug("[DEBUG] No .png icon found in SteamIcons directory.")
|
|
return ""
|
|
if logger:
|
|
logger.debug("[DEBUG] No SteamIcons directory found; shortcut will have no icon.")
|
|
return ""
|
|
|
|
def write_nxmhandler_ini(self, modlist_dir, mo2_exe_path):
|
|
"""
|
|
Create nxmhandler.ini in the modlist directory to suppress the NXM Handling popup on first MO2 launch.
|
|
If the file already exists, do nothing.
|
|
The executable path will be written as Z:\\<absolute path with double backslashes>, matching MO2's format.
|
|
"""
|
|
ini_path = os.path.join(modlist_dir, "nxmhandler.ini")
|
|
if os.path.exists(ini_path):
|
|
self.logger.info(f"nxmhandler.ini already exists at {ini_path}")
|
|
return
|
|
abs_path = os.path.abspath(mo2_exe_path)
|
|
z_path = f"Z:{abs_path}"
|
|
win_path = z_path.replace('/', '\\')
|
|
win_path = win_path.replace('\\', '\\\\')
|
|
content = (
|
|
"[handlers]\n"
|
|
"size=1\n"
|
|
"1\\games=\"skyrimse,skyrim,fallout4,falloutnv,fallout3,oblivion,enderal,starfield\"\n"
|
|
f"1\\executable={win_path}\n"
|
|
"1\\arguments=\n"
|
|
)
|
|
with open(ini_path, "w") as f:
|
|
f.write(content)
|
|
self.logger.info(f"[SUCCESS] nxmhandler.ini written to {ini_path}")
|