mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:17:58 +02:00
483 lines
23 KiB
Python
483 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Proton scanning and selection mixin for WineUtils.
|
|
Extracted from wine_utils for file-size and domain separation.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple, List, Dict, Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VALVE_PROTON_APPID_MAP = {
|
|
'2805730': 'proton_9',
|
|
'3658110': 'proton_10',
|
|
'1493710': 'proton_experimental',
|
|
'2180100': 'proton_hotfix',
|
|
'1887720': 'proton_8',
|
|
}
|
|
|
|
|
|
class WineUtilsProtonMixin:
|
|
"""Mixin providing Proton scanning, selection, and path resolution."""
|
|
|
|
@staticmethod
|
|
def get_proton_version(compat_data_path: str) -> str:
|
|
"""
|
|
Detect the Proton version used by a Steam game/shortcut.
|
|
|
|
Args:
|
|
compat_data_path: Path to the compatibility data directory.
|
|
|
|
Returns:
|
|
Detected Proton version or 'Unknown' if not found.
|
|
"""
|
|
logger.info("Detecting Proton version...")
|
|
if not os.path.isdir(compat_data_path):
|
|
logger.warning(f"Compatdata directory not found at '{compat_data_path}'")
|
|
return "Unknown"
|
|
system_reg_path = os.path.join(compat_data_path, "pfx", "system.reg")
|
|
if os.path.isfile(system_reg_path):
|
|
try:
|
|
with open(system_reg_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
content = f.read()
|
|
match = re.search(r'"SteamClientProtonVersion"="([^"]+)"', content)
|
|
if match:
|
|
version = match.group(1).strip()
|
|
proton_ver = version if "GE" in version else f"Proton {version}"
|
|
logger.debug(f"Detected Proton version from registry: {proton_ver}")
|
|
return proton_ver
|
|
except Exception as e:
|
|
logger.debug(f"Error reading system.reg: {e}")
|
|
config_info_path = os.path.join(compat_data_path, "config_info")
|
|
if os.path.isfile(config_info_path):
|
|
try:
|
|
with open(config_info_path, "r") as f:
|
|
config_ver = f.readline().strip()
|
|
if config_ver:
|
|
proton_ver = config_ver if "GE" in config_ver else f"Proton {config_ver}"
|
|
logger.debug(f"Detected Proton version from config_info: {proton_ver}")
|
|
return proton_ver
|
|
except Exception as e:
|
|
logger.debug(f"Error reading config_info: {e}")
|
|
logger.warning("Could not detect Proton version")
|
|
return "Unknown"
|
|
|
|
@staticmethod
|
|
def find_proton_binary(proton_version: str) -> Optional[str]:
|
|
"""
|
|
Find the full path to the Proton binary given a version string.
|
|
Returns the path to 'files/bin/wine', or None if not found.
|
|
"""
|
|
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
|
|
steam_common_paths = []
|
|
compatibility_paths = []
|
|
try:
|
|
from .path_handler import PathHandler
|
|
root_steam_libs = PathHandler.get_all_steam_library_paths()
|
|
for lib_path in root_steam_libs:
|
|
lib = Path(lib_path)
|
|
if lib.exists():
|
|
common_path = lib / "steamapps/common"
|
|
if common_path.exists():
|
|
steam_common_paths.append(common_path)
|
|
compatibility_paths.append(lib / "compatibilitytools.d")
|
|
except Exception as e:
|
|
logger.warning(f"Could not detect Steam libraries from libraryfolders.vdf: {e}")
|
|
if not steam_common_paths:
|
|
steam_common_paths = [
|
|
Path.home() / ".steam/steam/steamapps/common",
|
|
Path.home() / ".local/share/Steam/steamapps/common",
|
|
Path.home() / ".steam/root/steamapps/common"
|
|
]
|
|
if not compatibility_paths:
|
|
compatibility_paths = [
|
|
Path.home() / ".steam/steam/compatibilitytools.d",
|
|
Path.home() / ".local/share/Steam/compatibilitytools.d"
|
|
]
|
|
compatibility_paths.extend([
|
|
Path.home() / ".steam/root/compatibilitytools.d",
|
|
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
|
|
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
|
|
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
|
|
])
|
|
if proton_version.strip().startswith("Proton 9"):
|
|
proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"]
|
|
for base_path in steam_common_paths:
|
|
for name in proton9_candidates:
|
|
candidate = base_path / name / "files/bin/wine"
|
|
if candidate.is_file():
|
|
return str(candidate)
|
|
for subdir in base_path.glob("Proton 9*"):
|
|
wine_bin = subdir / "files/bin/wine"
|
|
if wine_bin.is_file():
|
|
return str(wine_bin)
|
|
all_paths = steam_common_paths + compatibility_paths
|
|
for base_path in all_paths:
|
|
if not base_path.is_dir():
|
|
continue
|
|
for pattern in version_patterns:
|
|
proton_dir = base_path / pattern
|
|
wine_bin = proton_dir / "files/bin/wine"
|
|
if wine_bin.is_file():
|
|
return str(wine_bin)
|
|
for subdir in base_path.glob(f"*{pattern}*"):
|
|
wine_bin = subdir / "files/bin/wine"
|
|
if wine_bin.is_file():
|
|
return str(wine_bin)
|
|
try:
|
|
from .config_handler import ConfigHandler
|
|
config = ConfigHandler()
|
|
fallback_path = config.get_proton_path()
|
|
if fallback_path != 'auto':
|
|
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
|
|
if fallback_wine_bin.is_file():
|
|
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
|
|
return str(fallback_wine_bin)
|
|
except Exception:
|
|
pass
|
|
for base_path in steam_common_paths:
|
|
wine_bin = base_path / "Proton - Experimental" / "files/bin/wine"
|
|
if wine_bin.is_file():
|
|
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to 'Proton - Experimental'.")
|
|
return str(wine_bin)
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_proton_paths(appid: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
"""
|
|
Get the Proton paths for a given AppID.
|
|
Returns (compatdata_path, proton_path, wine_bin) or (None, None, None) if not found.
|
|
"""
|
|
logger.info(f"Getting Proton paths for AppID {appid}")
|
|
possible_compat_bases = [
|
|
Path.home() / ".steam/steam/steamapps/compatdata",
|
|
Path.home() / ".local/share/Steam/steamapps/compatdata"
|
|
]
|
|
compatdata_path = None
|
|
for base_path in possible_compat_bases:
|
|
potential_compat_path = base_path / appid
|
|
if potential_compat_path.is_dir():
|
|
compatdata_path = str(potential_compat_path)
|
|
logger.debug(f"Found compatdata directory: {compatdata_path}")
|
|
break
|
|
if not compatdata_path:
|
|
logger.error(f"Could not find compatdata directory for AppID {appid}")
|
|
return None, None, None
|
|
proton_version = WineUtilsProtonMixin.get_proton_version(compatdata_path)
|
|
if proton_version == "Unknown":
|
|
logger.error(f"Could not determine Proton version for AppID {appid}")
|
|
return None, None, None
|
|
wine_bin = WineUtilsProtonMixin.find_proton_binary(proton_version)
|
|
if not wine_bin:
|
|
logger.error(f"Could not find Proton binary for version {proton_version}")
|
|
return None, None, None
|
|
proton_path = str(Path(wine_bin).parent.parent)
|
|
logger.debug(f"Found Proton path: {proton_path}")
|
|
return compatdata_path, proton_path, wine_bin
|
|
|
|
@staticmethod
|
|
def get_steam_library_paths() -> List[Path]:
|
|
"""Get all Steam library paths from libraryfolders.vdf."""
|
|
steam_common_paths = []
|
|
try:
|
|
from .path_handler import PathHandler
|
|
library_paths = PathHandler.get_all_steam_library_paths()
|
|
logger.info(f"PathHandler found Steam libraries: {library_paths}")
|
|
for lib_path in library_paths:
|
|
common_path = lib_path / "steamapps" / "common"
|
|
if common_path.exists():
|
|
steam_common_paths.append(common_path)
|
|
logger.debug(f"Added Steam library: {common_path}")
|
|
else:
|
|
logger.debug(f"Steam library path doesn't exist: {common_path}")
|
|
except Exception as e:
|
|
logger.error(f"PathHandler failed to read libraryfolders.vdf: {e}")
|
|
fallback_paths = [
|
|
Path.home() / ".steam/steam/steamapps/common",
|
|
Path.home() / ".local/share/Steam/steamapps/common",
|
|
Path.home() / ".steam/root/steamapps/common"
|
|
]
|
|
for fallback_path in fallback_paths:
|
|
if fallback_path.exists() and fallback_path not in steam_common_paths:
|
|
steam_common_paths.append(fallback_path)
|
|
logger.debug(f"Added fallback Steam library: {fallback_path}")
|
|
logger.info(f"Final Steam library paths for Proton scanning: {steam_common_paths}")
|
|
return steam_common_paths
|
|
|
|
@staticmethod
|
|
def get_compatibility_tool_paths() -> List[Path]:
|
|
"""Get all compatibility tool paths for GE-Proton and other custom Proton versions."""
|
|
compat_paths = [
|
|
Path.home() / ".steam/steam/compatibilitytools.d",
|
|
Path.home() / ".local/share/Steam/compatibilitytools.d",
|
|
Path.home() / ".steam/root/compatibilitytools.d",
|
|
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
|
|
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
|
|
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
|
|
]
|
|
return [path for path in compat_paths if path.exists()]
|
|
|
|
@staticmethod
|
|
def _parse_compat_tool_name(proton_dir: Path) -> Optional[str]:
|
|
"""Parse the Steam internal name from a compatibilitytool.vdf file."""
|
|
vdf_path = proton_dir / "compatibilitytool.vdf"
|
|
if not vdf_path.exists():
|
|
return None
|
|
try:
|
|
with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
content = f.read()
|
|
match = re.search(r'"compat_tools"\s*\{[^{]*"([^"]+)"\s*(?://[^\n]*)?\s*\{', content, re.DOTALL)
|
|
if match:
|
|
return match.group(1)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse {vdf_path}: {e}")
|
|
return None
|
|
|
|
@staticmethod
|
|
def _find_valve_proton_appid(proton_dir_name: str) -> Optional[str]:
|
|
"""Find the Steam App ID for a Valve Proton by matching appmanifest installdir."""
|
|
steam_libs = WineUtilsProtonMixin.get_steam_library_paths()
|
|
for lib_path in steam_libs:
|
|
steamapps_dir = lib_path.parent
|
|
for manifest in steamapps_dir.glob("appmanifest_*.acf"):
|
|
try:
|
|
with open(manifest, 'r', encoding='utf-8', errors='ignore') as f:
|
|
content = f.read()
|
|
installdir_match = re.search(r'"installdir"\s+"([^"]+)"', content)
|
|
appid_match = re.search(r'"appid"\s+"(\d+)"', content)
|
|
if installdir_match and appid_match and installdir_match.group(1) == proton_dir_name:
|
|
return appid_match.group(1)
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
@staticmethod
|
|
def resolve_steam_compat_name(proton_path: Any) -> Optional[str]:
|
|
"""
|
|
Resolve the correct Steam config.vdf internal name for a Proton installation.
|
|
Returns internal name for CompatToolMapping, or None if unresolvable.
|
|
"""
|
|
proton_path = Path(proton_path)
|
|
if not proton_path.is_dir():
|
|
logger.warning(f"Proton path not found: {proton_path}")
|
|
return None
|
|
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_path)
|
|
if compat_name:
|
|
logger.debug(f"Resolved compat name from vdf: {proton_path.name} -> {compat_name}")
|
|
return compat_name
|
|
dir_name = proton_path.name
|
|
appid = WineUtilsProtonMixin._find_valve_proton_appid(dir_name)
|
|
if appid and appid in VALVE_PROTON_APPID_MAP:
|
|
name = VALVE_PROTON_APPID_MAP[appid]
|
|
logger.debug(f"Resolved Valve Proton: {dir_name} (AppID {appid}) -> {name}")
|
|
return name
|
|
if dir_name.startswith('GE-Proton'):
|
|
return dir_name
|
|
logger.warning(f"Could not resolve Steam compat name for: {proton_path}")
|
|
return None
|
|
|
|
@staticmethod
|
|
def scan_thirdparty_proton_versions() -> List[Dict[str, Any]]:
|
|
"""Scan for non-GE third-party Proton versions in compatibilitytools.d directories."""
|
|
logger.info("Scanning for third-party Proton versions...")
|
|
found_versions = []
|
|
seen_names = set()
|
|
compat_paths = WineUtilsProtonMixin.get_compatibility_tool_paths()
|
|
if not compat_paths:
|
|
return []
|
|
for compat_path in compat_paths:
|
|
try:
|
|
for proton_dir in compat_path.iterdir():
|
|
if not proton_dir.is_dir():
|
|
continue
|
|
dir_name = proton_dir.name
|
|
if dir_name.startswith("GE-Proton"):
|
|
continue
|
|
wine_bin = proton_dir / "files" / "bin" / "wine"
|
|
if not wine_bin.exists():
|
|
continue
|
|
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir)
|
|
if not compat_name:
|
|
continue
|
|
vdf_path = proton_dir / "compatibilitytool.vdf"
|
|
try:
|
|
with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
vdf_content = f.read()
|
|
if '"from_oslist" "linux"' in vdf_content:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
if 'hotfix' in compat_name.lower():
|
|
continue
|
|
if compat_name in seen_names:
|
|
continue
|
|
seen_names.add(compat_name)
|
|
found_versions.append({
|
|
'name': dir_name,
|
|
'path': proton_dir,
|
|
'wine_bin': wine_bin,
|
|
'priority': 175,
|
|
'type': 'ThirdParty-Proton',
|
|
'steam_compat_name': compat_name,
|
|
})
|
|
logger.debug(f"Found third-party Proton: {dir_name} (compat name: {compat_name})")
|
|
except Exception as e:
|
|
logger.warning(f"Error scanning {compat_path}: {e}")
|
|
logger.info(f"Found {len(found_versions)} third-party Proton version(s)")
|
|
return found_versions
|
|
|
|
@staticmethod
|
|
def scan_ge_proton_versions() -> List[Dict[str, Any]]:
|
|
"""Scan for available GE-Proton versions in compatibilitytools.d directories."""
|
|
logger.info("Scanning for available GE-Proton versions...")
|
|
found_versions = []
|
|
compat_paths = WineUtilsProtonMixin.get_compatibility_tool_paths()
|
|
if not compat_paths:
|
|
logger.warning("No compatibility tool paths found")
|
|
return []
|
|
for compat_path in compat_paths:
|
|
logger.debug(f"Scanning compatibility tools: {compat_path}")
|
|
try:
|
|
for proton_dir in compat_path.iterdir():
|
|
if not proton_dir.is_dir():
|
|
continue
|
|
dir_name = proton_dir.name
|
|
if not dir_name.startswith("GE-Proton"):
|
|
continue
|
|
wine_bin = proton_dir / "files" / "bin" / "wine"
|
|
if not wine_bin.exists() or not wine_bin.is_file():
|
|
logger.debug(f"Skipping {dir_name} - no wine binary found")
|
|
continue
|
|
version_match = re.match(r'GE-Proton(\d+)-(\d+)', dir_name)
|
|
if version_match:
|
|
major_ver = int(version_match.group(1))
|
|
minor_ver = int(version_match.group(2))
|
|
priority = 200 + (major_ver * 10) + minor_ver
|
|
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir) or dir_name
|
|
found_versions.append({
|
|
'name': dir_name,
|
|
'path': proton_dir,
|
|
'wine_bin': wine_bin,
|
|
'priority': priority,
|
|
'major_version': major_ver,
|
|
'minor_version': minor_ver,
|
|
'type': 'GE-Proton',
|
|
'steam_compat_name': compat_name,
|
|
})
|
|
logger.debug(f"Found {dir_name} at {proton_dir} (priority: {priority})")
|
|
else:
|
|
logger.debug(f"Skipping {dir_name} - unknown GE-Proton version format")
|
|
except Exception as e:
|
|
logger.warning(f"Error scanning {compat_path}: {e}")
|
|
found_versions.sort(key=lambda x: x['priority'], reverse=True)
|
|
logger.info(f"Found {len(found_versions)} GE-Proton version(s)")
|
|
return found_versions
|
|
|
|
@staticmethod
|
|
def scan_valve_proton_versions() -> List[Dict[str, Any]]:
|
|
"""Scan for available Valve Proton versions with fallback priority."""
|
|
logger.info("Scanning for available Valve Proton versions...")
|
|
found_versions = []
|
|
steam_libs = WineUtilsProtonMixin.get_steam_library_paths()
|
|
if not steam_libs:
|
|
logger.warning("No Steam library paths found")
|
|
return []
|
|
preferred_versions = [
|
|
("Proton - Experimental", 150),
|
|
("Proton 10.0", 140),
|
|
("Proton 9.0", 130),
|
|
("Proton 9.0 (Beta)", 125)
|
|
]
|
|
for steam_path in steam_libs:
|
|
logger.debug(f"Scanning Steam library: {steam_path}")
|
|
for version_name, priority in preferred_versions:
|
|
proton_path = steam_path / version_name
|
|
wine_bin = proton_path / "files" / "bin" / "wine"
|
|
if wine_bin.exists() and wine_bin.is_file():
|
|
compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(proton_path)
|
|
found_versions.append({
|
|
'name': version_name,
|
|
'path': proton_path,
|
|
'wine_bin': wine_bin,
|
|
'priority': priority,
|
|
'type': 'Valve-Proton',
|
|
'steam_compat_name': compat_name,
|
|
})
|
|
logger.debug(f"Found {version_name} at {proton_path}")
|
|
found_versions.sort(key=lambda x: x['priority'], reverse=True)
|
|
unique_versions = []
|
|
seen_names = set()
|
|
for version in found_versions:
|
|
if version['name'] not in seen_names:
|
|
unique_versions.append(version)
|
|
seen_names.add(version['name'])
|
|
logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)")
|
|
return unique_versions
|
|
|
|
@staticmethod
|
|
def scan_all_proton_versions() -> List[Dict[str, Any]]:
|
|
"""Scan for all available Proton versions (GE + third-party + Valve) with unified priority."""
|
|
logger.info("Scanning for all available Proton versions...")
|
|
all_versions = []
|
|
all_versions.extend(WineUtilsProtonMixin.scan_ge_proton_versions())
|
|
all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions())
|
|
all_versions.extend(WineUtilsProtonMixin.scan_valve_proton_versions())
|
|
all_versions.sort(key=lambda x: x['priority'], reverse=True)
|
|
unique_versions = []
|
|
seen_names = set()
|
|
for version in all_versions:
|
|
if version['name'] not in seen_names:
|
|
unique_versions.append(version)
|
|
seen_names.add(version['name'])
|
|
if unique_versions:
|
|
logger.debug(f"Found {len(unique_versions)} total Proton version(s)")
|
|
logger.debug(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})")
|
|
else:
|
|
logger.warning("No Proton versions found")
|
|
return unique_versions
|
|
|
|
@staticmethod
|
|
def select_best_proton() -> Optional[Dict[str, Any]]:
|
|
"""Select the best available Proton (GE or Valve). Excludes third-party builds."""
|
|
available_versions = WineUtilsProtonMixin.scan_all_proton_versions()
|
|
if not available_versions:
|
|
logger.warning("No compatible Proton versions found")
|
|
return None
|
|
compatible_versions = [v for v in available_versions if v.get('type') in ('GE-Proton', 'Valve-Proton')]
|
|
if not compatible_versions:
|
|
logger.warning("No compatible Proton versions found (only third-party builds available)")
|
|
return None
|
|
best_version = compatible_versions[0]
|
|
logger.info(f"Selected best Proton version: {best_version['name']} ({best_version['type']})")
|
|
return best_version
|
|
|
|
@staticmethod
|
|
def select_best_valve_proton() -> Optional[Dict[str, Any]]:
|
|
"""Select the best available Valve Proton. Kept for backward compatibility."""
|
|
available_versions = WineUtilsProtonMixin.scan_valve_proton_versions()
|
|
if not available_versions:
|
|
logger.warning("No compatible Valve Proton versions found")
|
|
return None
|
|
best_version = available_versions[0]
|
|
logger.info(f"Selected Valve Proton version: {best_version['name']}")
|
|
return best_version
|
|
|
|
@staticmethod
|
|
def check_proton_requirements() -> Tuple[bool, str, Optional[Dict[str, Any]]]:
|
|
"""Check if a compatible Proton version is available for workflows."""
|
|
logger.info("Checking Proton requirements for workflow...")
|
|
best_proton = WineUtilsProtonMixin.select_best_proton()
|
|
if best_proton:
|
|
proton_type = best_proton.get('type', 'Unknown')
|
|
status_msg = f"[OK] Using {best_proton['name']} ({proton_type}) for this workflow"
|
|
logger.info(f"Proton requirements satisfied: {best_proton['name']} ({proton_type})")
|
|
return True, status_msg, best_proton
|
|
status_msg = "[FAIL] No compatible Proton version found (GE-Proton 10+, Proton 9+, 10, or Experimental required)"
|
|
logger.warning("Proton requirements not met - no compatible version found")
|
|
return False, status_msg, None
|