"""Cross-distribution dependency and host diagnostics.""" from __future__ import annotations from dataclasses import asdict, dataclass from importlib.util import find_spec import os from pathlib import Path import platform import shutil from typing import Mapping from .container_host import ContainerHostReport, inspect_container_host from .launcher import find_wine_tools from .paths import AppPaths from .runtime import find_steam_proton7 from .umu import managed_umu_ready ARCH_FAMILY_DISTRIBUTIONS = frozenset( {"arch", "cachyos", "endeavouros", "garuda", "manjaro"} ) @dataclass(frozen=True) class ToolStatus: name: str path: str | None required: bool purpose: str @dataclass(frozen=True) class DoctorReport: ok: bool distribution: str version: str architecture: str display_backend: str tools: list[ToolStatus] container_host: ContainerHostReport package_hint: str | None warnings: list[str] def to_dict(self) -> dict[str, object]: return asdict(self) def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport: env = os.environ if environment is None else environment os_release = _read_os_release() distribution = os_release.get("ID", "unknown") version = os_release.get("VERSION_ID", "unknown") wine_tools = find_wine_tools() umu_path = _umu_path(env) container_host = inspect_container_host(environment=env) container_required = umu_path is not None tools = [ ToolStatus( "python3", shutil.which("python3"), True, "native installer and command-line interface", ), ToolStatus( "python-tk", _python_tk_path(), True, "native dashboard", ), ToolStatus( "wine", str(wine_tools.wine) if wine_tools else None, True, "legacy updater support and game compatibility utilities", ), ToolStatus( "winetricks", str(wine_tools.winetricks) if wine_tools else shutil.which("winetricks"), True, "native D3D helper DLLs for the Proton game prefix", ), ToolStatus( "gamescope", shutil.which("gamescope"), False, "optional fixed-surface scaling for a stable resizable game window", ), ToolStatus( "cabextract", shutil.which("cabextract"), True, "Microsoft runtime extraction through Winetricks", ), ToolStatus( "openssl", shutil.which("openssl"), True, "signed native-launcher update verification", ), ToolStatus( "umu-run", umu_path, distribution in ARCH_FAMILY_DISTRIBUTIONS, "Steam Runtime container; managed automatically when needed", ), ToolStatus( "bubblewrap", container_host.bwrap_path, container_required, "user-namespace sandbox required by UMU and pressure-vessel", ), ToolStatus( "vulkaninfo", shutil.which("vulkaninfo"), False, "Vulkan diagnostics", ), ] tools.extend(_platform_optional_tools(distribution, architecture=platform.machine())) warnings: list[str] = [] if platform.machine().casefold() not in {"x86_64", "amd64"}: warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.") if not env.get("WAYLAND_DISPLAY") and not env.get("DISPLAY"): warnings.append("No graphical X11 or Wayland session is visible.") if umu_path is None and not _has_steam_proton7(env): warnings.append( "No local game runtime is installed yet; run runtime-bootstrap to fetch the " "pinned official UMU helper. Startup and Play also retry automatically." ) if container_required and not container_host.ready: warnings.append( container_host.recommendation or "The Steam Runtime Bubblewrap sandbox is unavailable." ) if container_host.broken_umu_profile_link: warnings.append( "UMU's AppArmor profile link is broken and may prevent profiles from " "loading after reboot; run `pristontale-eu container-host --repair`." ) warnings.extend(_arch_runtime_warnings(distribution, platform.machine())) required_ok = all(item.path is not None for item in tools if item.required) return DoctorReport( ok=( required_ok and (not container_required or container_host.ready) and not container_host.broken_umu_profile_link ), distribution=distribution, version=version, architecture=platform.machine(), display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11", tools=tools, container_host=container_host, package_hint=_package_hint(distribution), warnings=warnings, ) def _read_os_release() -> dict[str, str]: result: dict[str, str] = {} try: lines = Path("/etc/os-release").read_text(encoding="utf-8").splitlines() except OSError: return result for line in lines: if "=" not in line or line.startswith("#"): continue key, value = line.split("=", 1) result[key] = value.strip().strip('"') return result def _python_tk_path() -> str | None: try: specification = find_spec("_tkinter") except (ImportError, ValueError): return None if specification is None: return None return specification.origin or "available" def _package_hint(distribution: str) -> str | None: if distribution in {"ubuntu", "debian", "linuxmint", "pop"}: return ( "Install Python/Tk, Wine (64- and 32-bit), Winetricks, " "Cabextract, OpenSSL, Bubblewrap, and both 64- and 32-bit Vulkan " "drivers with APT. Ubuntu also needs its AppArmor tools." ) if distribution in {"fedora", "nobara"}: return ( "Install python3-tkinter, wine, winetricks, bubblewrap, cabextract, openssl, " "and the matching 32-bit Vulkan driver with DNF." ) if distribution in ARCH_FAMILY_DISTRIBUTIONS: return ( "Install python, tk, wine, winetricks, umu-launcher, bubblewrap, cabextract, " "openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman." ) if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}: return ( "Install Python/Tk, Wine, Winetricks, Bubblewrap, Cabextract, OpenSSL, and " "32-bit Vulkan support with Zypper." ) return None def _has_steam_proton7(environment: Mapping[str, str]) -> bool: return find_steam_proton7(environment) is not None def _umu_path(environment: Mapping[str, str]) -> str | None: system = shutil.which("umu-run", path=environment.get("PATH")) if system: return system try: paths = AppPaths.from_environment(environment) except RuntimeError: return None if managed_umu_ready(paths.managed_umu_executable): return str(paths.managed_umu_executable.resolve()) return None def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]: if architecture.casefold() not in {"x86_64", "amd64"}: return [] if distribution in ARCH_FAMILY_DISTRIBUTIONS: return [ _optional_tool_status( "lib32-vulkan", "/usr/lib32/libvulkan.so.1", "32-bit Vulkan runtime compatibility layer", ), _optional_tool_status( "lib32-gstreamer", "/usr/lib32/gstreamer-1.0", "32-bit gstreamer runtime compatibility modules", ), ] return [] def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]: if distribution not in ARCH_FAMILY_DISTRIBUTIONS: return [] if architecture.casefold() not in {"x86_64", "amd64"}: return [ "Non-x86_64 Linux hosts are not a supported runtime architecture for this game." ] warnings: list[str] = [] if not _path_exists("/usr/lib32/libvulkan.so.1"): warnings.append( "Install lib32-vulkan-icd-loader and the GPU vendor's matching 32-bit " "Vulkan driver; this 32-bit D3D9 client otherwise cannot initialize DXVK." ) if not _path_exists("/usr/lib32/gstreamer-1.0"): warnings.append( "Install lib32-gstreamer and lib32-gst-plugins-base if the login video or " "other media does not play. Missing media plugins are not evidence of an " "authentication failure." ) return warnings def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus: if _path_exists(path): return ToolStatus(name, path, required=False, purpose=purpose) return ToolStatus(name, None, required=False, purpose=purpose) def _path_exists(path: str) -> bool: entry = Path(path) return entry.is_file() or entry.is_dir()