diff --git a/ptlinux/doctor.py b/ptlinux/doctor.py index 4fa2db7..5245d32 100644 --- a/ptlinux/doctor.py +++ b/ptlinux/doctor.py @@ -11,6 +11,7 @@ import shutil from typing import Mapping from .launcher import find_wine_tools +from .runtime import find_steam_proton7 ARCH_FAMILY_DISTRIBUTIONS = frozenset( @@ -176,19 +177,7 @@ def _package_hint(distribution: str) -> str | None: def _has_steam_proton7(environment: Mapping[str, str]) -> bool: - home_value = environment.get("HOME") - if not home_value: - return False - home = Path(home_value) - for steam_root in ( - home / ".local/share/Steam", - home / ".steam/steam", - home / "snap/steam/common/.local/share/Steam", - home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", - ): - if (steam_root / "steamapps/common/Proton 7.0/proton").is_file(): - return True - return False + return find_steam_proton7(environment) is not None def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]: diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index f8176ac..777ce12 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path, PurePosixPath import platform +import re import shlex import shutil import sys @@ -36,6 +37,12 @@ CURRENT_UMU_DISTRIBUTIONS = frozenset( {"arch", "cachyos", "endeavouros", "garuda", "manjaro"} ) GAME_RUNTIME_CONFIGURATION_VERSION = 1 +PROTON7_RELATIVE_PATH = Path("steamapps/common/Proton 7.0/proton") +STEAM_LIBRARY_FOLDERS_RELATIVE_PATH = Path("steamapps/libraryfolders.vdf") +STEAM_LIBRARY_FOLDERS_MAX_BYTES = 4 * 1024 * 1024 +_VDF_QUOTED_PAIR = re.compile( + r'"((?:\\.|[^"\\])*)"\s*"((?:\\.|[^"\\])*)"' +) class RuntimeErrorPT(RuntimeError): @@ -80,7 +87,7 @@ def select_runtime( proton = Path(settings.proton_path).expanduser().resolve() if not proton.is_file(): raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}") - steam_root = _steam_root_for_proton(proton) + steam_root = _steam_root_for_proton(proton, env) if settings.runtime == "proton7-container": if not umu: raise RuntimeErrorPT( @@ -137,33 +144,108 @@ def select_runtime( def find_steam_proton7( environment: Mapping[str, str] | None = None, ) -> tuple[Path, Path] | None: + """Find Proton 7 in a Steam client root or one of its registered libraries. + + The returned Steam root is always the client installation, even when the + Proton tool itself lives in a secondary library. Proton 7 uses that client + root for Steam's bundled media compatibility libraries. + """ + env = os.environ if environment is None else environment - home_value = env.get("HOME") - if not home_value: - return None - home = Path(home_value).expanduser() - roots: list[Path] = [] - explicit = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") + for steam_root in _steam_client_roots(env): + for library_root in _steam_library_roots(steam_root): + proton = library_root / PROTON7_RELATIVE_PATH + if proton.is_file(): + return proton.resolve(), steam_root + return None + + +def _steam_client_roots(environment: Mapping[str, str]) -> tuple[Path, ...]: + candidates: list[Path] = [] + explicit = environment.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") if explicit: - roots.append(Path(explicit).expanduser()) - roots.extend( - ( - home / ".local/share/Steam", - home / ".steam/steam", - home / "snap/steam/common/.local/share/Steam", - home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", + candidates.append(Path(explicit).expanduser()) + home_value = environment.get("HOME") + if home_value: + home = Path(home_value).expanduser() + candidates.extend( + ( + home / ".local/share/Steam", + home / ".steam/steam", + home / ".steam/root", + home / ".steam/debian-installation", + home / "snap/steam/common/.local/share/Steam", + home / "snap/steam/common/.steam/steam", + home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", + ) ) + return _unique_resolved_paths(candidates) + + +def _steam_library_roots(steam_root: Path) -> tuple[Path, ...]: + registered = _read_steam_library_folders( + steam_root / STEAM_LIBRARY_FOLDERS_RELATIVE_PATH ) + return _unique_resolved_paths((steam_root, *registered)) + + +def _read_steam_library_folders(path: Path) -> tuple[Path, ...]: + """Read current and legacy Steam libraryfolders.vdf path records.""" + + try: + with path.open("rb") as handle: + content = handle.read(STEAM_LIBRARY_FOLDERS_MAX_BYTES + 1) + except OSError: + return () + if len(content) > STEAM_LIBRARY_FOLDERS_MAX_BYTES: + return () + text = content.decode("utf-8-sig", errors="replace") + result: list[Path] = [] + for raw_key, raw_value in _VDF_QUOTED_PAIR.findall(text): + key = _decode_vdf_string(raw_key) + value = _decode_vdf_string(raw_value) + # Current files use a named "path" property. Older Steam clients used + # numeric top-level keys, which are distinguishable from app-size pairs + # because a Linux library path must be absolute. + if key.casefold() != "path" and not key.isdecimal(): + continue + if not value or "\x00" in value: + continue + library = Path(value).expanduser() + if library.is_absolute(): + result.append(library) + return tuple(result) + + +def _decode_vdf_string(value: str) -> str: + result: list[str] = [] + index = 0 + while index < len(value): + character = value[index] + if character == "\\" and index + 1 < len(value): + next_character = value[index + 1] + if next_character in {'"', "\\"}: + result.append(next_character) + index += 2 + continue + result.append(character) + index += 1 + return "".join(result) + + +def _unique_resolved_paths(paths: Sequence[Path]) -> tuple[Path, ...]: + result: list[Path] = [] seen: set[Path] = set() - for root in roots: - resolved = root.resolve() + for path in paths: + try: + resolved = path.resolve() + except (OSError, RuntimeError): + continue if resolved in seen: continue seen.add(resolved) - proton = resolved / "steamapps/common/Proton 7.0/proton" - if proton.is_file(): - return proton, resolved - return None + result.append(resolved) + return tuple(result) def prepare_game_runtime( @@ -653,15 +735,27 @@ def _ensure_proton_game_link(paths: AppPaths) -> None: link.symlink_to(paths.runtime_view, target_is_directory=True) -def _steam_root_for_proton(proton: Path) -> Path: - parts = proton.parts - try: - index = parts.index("steamapps") - except ValueError as exc: +def _steam_root_for_proton( + proton: Path, + environment: Mapping[str, str] | None = None, +) -> Path: + steamapps = next( + (parent for parent in proton.parents if parent.name.casefold() == "steamapps"), + None, + ) + if steamapps is None: raise RuntimeErrorPT( "configured Proton path must be inside a Steam steamapps directory" - ) from exc - return Path(*parts[:index]) + ) + library_root = steamapps.parent.resolve() + env = os.environ if environment is None else environment + for steam_root in _steam_client_roots(env): + if library_root in _steam_library_roots(steam_root): + return steam_root + # Preserve support for an otherwise valid, manually managed Steam layout. + # Without a registered client root, the library root is the best available + # compatibility value and matches the launcher's historical behaviour. + return library_root def native_d3d_ready(prefix: Path) -> bool: