Sync from development - prepare for v0.2.0.9

This commit is contained in:
Omni
2025-12-31 20:56:47 +00:00
parent 2511c9334c
commit 0d84d2f2fe
14 changed files with 373 additions and 178 deletions

View File

@@ -6,6 +6,7 @@ Handles application settings and configuration
"""
import os
import sys
import json
import logging
import shutil
@@ -211,7 +212,8 @@ class ConfigHandler:
config.update(saved_config)
return config
except Exception as e:
logger.error(f"Error reading configuration from disk: {e}")
# Don't use logger here - can cause recursion if logger tries to access config
print(f"Warning: Error reading configuration from disk: {e}", file=sys.stderr)
return self.settings.copy()
def reload_config(self):

View File

@@ -571,15 +571,19 @@ class ModlistMenuHandler:
self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}")
set_modlist_result = self.modlist_handler.set_modlist(context)
self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}")
# Check GUI mode early to avoid input() calls in GUI context
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if not set_modlist_result:
print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}")
self.logger.error(f"set_modlist failed for {context.get('name')}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
if not gui_mode:
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return False
# --- Resolution selection logic for GUI mode ---
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
selected_resolution = context.get('resolution', None)
if gui_mode:
# If resolution is provided, set it and do not prompt
@@ -654,7 +658,8 @@ class ModlistMenuHandler:
print("NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of")
print(" Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).")
print("")
print("Detailed log available at: ~/Jackify/logs/Configure_New_Modlist_workflow.log")
from jackify.shared.paths import get_jackify_logs_dir
print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log")
# Only wait for input in CLI mode, not GUI mode
if not gui_mode:
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")

View File

@@ -777,6 +777,56 @@ class ProtontricksHandler:
self.logger.error(f"Error running protontricks-launch: {e}")
return None
def _ensure_flatpak_cache_access(self, cache_path: Path) -> bool:
"""
Ensure flatpak protontricks has filesystem access to the winetricks cache.
Args:
cache_path: Path to winetricks cache directory
Returns:
True if access granted or already exists, False on failure
"""
if self.which_protontricks != 'flatpak':
return True # Not flatpak, no action needed
try:
# Check if flatpak already has access to this path
result = subprocess.run(
['flatpak', 'override', '--user', '--show', 'com.github.Matoking.protontricks'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Check if cache path is already in filesystem overrides
cache_str = str(cache_path.resolve())
if f'filesystems=' in result.stdout and cache_str in result.stdout:
self.logger.debug(f"Flatpak protontricks already has access to cache: {cache_str}")
return True
# Grant access to cache directory
self.logger.info(f"Granting flatpak protontricks access to winetricks cache: {cache_path}")
result = subprocess.run(
['flatpak', 'override', '--user', 'com.github.Matoking.protontricks',
f'--filesystem={cache_path.resolve()}'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
self.logger.info("Successfully granted flatpak protontricks cache access")
return True
else:
self.logger.warning(f"Failed to grant flatpak cache access: {result.stderr}")
return False
except Exception as e:
self.logger.warning(f"Could not configure flatpak cache access: {e}")
return False
def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None):
"""
Install the specified Wine components into the given prefix using protontricks.
@@ -820,6 +870,10 @@ class ProtontricksHandler:
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)
# Ensure flatpak protontricks has access to cache (no-op for native)
self._ensure_flatpak_cache_access(jackify_cache_dir)
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
self.logger.info(f"Using winetricks cache: {jackify_cache_dir}")
if specific_components is not None:

View File

@@ -955,7 +955,10 @@ class ShortcutHandler:
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 using protontricks.
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.
@@ -965,15 +968,22 @@ class ShortcutHandler:
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:
from .protontricks_handler import ProtontricksHandler # Local import
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.error("Protontricks not detected")
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.error(f"Protontricks failed to list applications: {result.stderr if result else 'No result'}")
self.logger.warning(f"Protontricks fallback failed: {result.stderr if result else 'No result'}")
return None
# Build a list of all shortcuts
found_shortcuts = []
@@ -1022,8 +1032,64 @@ class ShortcutHandler:
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.warning(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
# --- Discovery Methods Moved from ModlistHandler ---
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.

View File

@@ -468,7 +468,7 @@ class WinetricksHandler:
from .protontricks_handler import ProtontricksHandler
steamdeck = os.path.exists('/home/deck')
protontricks_handler = ProtontricksHandler(steamdeck)
protontricks_available = protontricks_handler.is_available()
protontricks_available = protontricks_handler.detect_protontricks()
if protontricks_available:
self.logger.warning("=" * 80)

View File

@@ -493,54 +493,54 @@ exit"""
def detect_actual_prefix_appid(self, initial_appid: int, shortcut_name: str) -> Optional[int]:
"""
After Steam restart, detect the actual prefix AppID that was created.
Use protontricks -l to find the actual positive AppID.
Uses direct VDF file reading to find the actual AppID.
Args:
initial_appid: The initial (negative) AppID from shortcuts.vdf
shortcut_name: Name of the shortcut for logging
Returns:
The actual (positive) AppID of the created prefix, or None if not found
"""
try:
logger.info(f"Using protontricks -l to detect actual AppID for shortcut: {shortcut_name}")
# Wait up to 30 seconds for the shortcut to appear in protontricks
logger.info(f"Using VDF to detect actual AppID for shortcut: {shortcut_name}")
# Wait up to 30 seconds for Steam to process the shortcut
for i in range(30):
try:
# Use the existing protontricks handler
from jackify.backend.handlers.protontricks_handler import ProtontricksHandler
protontricks_handler = ProtontricksHandler(steamdeck or False)
result = protontricks_handler.run_protontricks('-l')
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
# Look for our shortcut name in the protontricks output
for line in lines:
if shortcut_name in line and 'Non-Steam shortcut:' in line:
# Extract AppID from line like "Non-Steam shortcut: Tuxborn (3106560878)"
if '(' in line and ')' in line:
appid_str = line.split('(')[1].split(')')[0]
actual_appid = int(appid_str)
logger.info(f" Found shortcut in protontricks: {line.strip()}")
logger.info(f" Initial AppID: {initial_appid}")
logger.info(f" Actual AppID: {actual_appid}")
return actual_appid
logger.debug(f"Shortcut '{shortcut_name}' not found in protontricks yet (attempt {i+1}/30)")
time.sleep(1)
except subprocess.TimeoutExpired:
logger.warning(f"protontricks -l timed out on attempt {i+1}")
from ..handlers.shortcut_handler import ShortcutHandler
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
shortcuts_path = path_handler._find_shortcuts_vdf()
if shortcuts_path:
from ..handlers.vdf_handler import VDFHandler
shortcuts_data = VDFHandler.load(shortcuts_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()
if app_name.lower() == shortcut_name.lower():
appid = shortcut.get('appid')
if appid:
actual_appid = int(appid) & 0xFFFFFFFF
logger.info(f"Found shortcut '{app_name}' in shortcuts.vdf")
logger.info(f" Initial AppID (signed): {initial_appid}")
logger.info(f" Actual AppID (unsigned): {actual_appid}")
return actual_appid
logger.debug(f"Shortcut '{shortcut_name}' not found in VDF yet (attempt {i+1}/30)")
time.sleep(1)
except Exception as e:
logger.warning(f"Error running protontricks -l on attempt {i+1}: {e}")
logger.warning(f"Error reading shortcuts.vdf on attempt {i+1}: {e}")
time.sleep(1)
logger.error(f"Shortcut '{shortcut_name}' not found in protontricks after 30 seconds")
logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf after 30 seconds")
return None
except Exception as e:
logger.error(f"Error detecting actual prefix AppID: {e}")
return None

View File

@@ -15,29 +15,32 @@ _KEYWORD_PHRASES = (
)
def is_non_premium_indicator(line: str) -> bool:
def is_non_premium_indicator(line: str) -> tuple[bool, str | None]:
"""
Return True if the engine output line indicates a Nexus non-premium scenario.
Args:
line: Raw line emitted from the jackify-engine process.
Returns:
Tuple of (is_premium_error: bool, matched_pattern: str | None)
"""
if not line:
return False
return False, None
normalized = line.strip().lower()
if not normalized:
return False
return False, None
# Direct phrase detection
for phrase in _KEYWORD_PHRASES[:6]:
if phrase in normalized:
return True
return True, phrase
# Manual download + Nexus URL implies premium requirement in current workflows.
if "manual download" in normalized and ("nexusmods.com" in normalized or "nexus mods" in normalized):
return True
return True, "manual download + nexusmods.com"
return False
return False, None