Files
pristontale-eu-linux/ptlinux/doctor.py

391 lines
11 KiB
Python

"""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]
@property
def blockers(self) -> tuple[str, ...]:
return host_readiness_blockers(self)
def to_dict(self) -> dict[str, object]:
payload = asdict(self)
payload["blockers"] = list(self.blockers)
return payload
def host_readiness_blockers(report: DoctorReport) -> tuple[str, ...]:
"""Explain every condition that makes a host report fail readiness."""
blockers = [
f"{tool.name} is missing ({tool.purpose})."
for tool in report.tools
if tool.required and tool.path is None
]
bubblewrap = next(
(tool for tool in report.tools if tool.name == "bubblewrap"),
None,
)
if (
bubblewrap is not None
and bubblewrap.required
and bubblewrap.path is not None
and not report.container_host.ready
):
issue = report.container_host.issue or "probe failed"
message = f"Bubblewrap cannot create the Steam Runtime sandbox ({issue})."
if report.container_host.recommendation:
message += f" {report.container_host.recommendation}"
blockers.append(message)
if report.container_host.broken_umu_profile_link:
blockers.append(
"UMU's AppArmor profile link is broken; run "
"`pristontale-eu container-host --repair`."
)
if not report.ok and not blockers:
blockers.append(
"The host readiness check failed for an unclassified reason; "
"run `pristontale-eu --json doctor` and report its output."
)
return tuple(blockers)
def inspect_host(
environment: Mapping[str, str] | None = None,
*,
vulkan_support: Mapping[str, object] | 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")
architecture = platform.machine()
wine_tools = find_wine_tools()
umu_path = _umu_path(env)
container_host = inspect_container_host(environment=env)
container_required = umu_path is not None
detected_vulkan = (
dict(vulkan_support)
if vulkan_support is not None
else _inspect_vulkan_support(env)
)
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",
),
]
if architecture.casefold() in {"x86_64", "amd64"}:
tools.extend(_vulkan_tool_statuses(detected_vulkan, distribution))
tools.extend(_platform_optional_tools(distribution, architecture=architecture))
warnings: list[str] = []
if architecture.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, architecture))
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=architecture,
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-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/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 _inspect_vulkan_support(environment: Mapping[str, str]) -> dict[str, object]:
try:
from .telemetry_environment import inspect_vulkan_support
return inspect_vulkan_support(environment)
except Exception:
return {}
def _vulkan_tool_statuses(
support: Mapping[str, object],
distribution: str,
) -> list[ToolStatus]:
return [
_vulkan_tool_status(64, support, distribution),
_vulkan_tool_status(32, support, distribution),
]
def _vulkan_tool_status(
bits: int,
support: Mapping[str, object],
distribution: str,
) -> ToolStatus:
available = support.get(f"vulkan_{bits}bit") is True
loader_available = support.get(f"vulkan_{bits}bit_loader_available") is True
icd_available = support.get(f"vulkan_{bits}bit_icd_available") is True
purpose = f"{bits}-bit Vulkan loader and GPU ICD required by DXVK"
if not available:
missing_parts: list[str] = []
if not loader_available:
missing_parts.append("loader")
if not icd_available:
missing_parts.append("GPU driver/ICD")
purpose = (
f"{bits}-bit Vulkan {' and '.join(missing_parts)} required by DXVK; "
+ _vulkan_package_guidance(distribution, bits)
)
return ToolStatus(
f"vulkan-{bits}bit",
"loader+ICD" if available else None,
True,
purpose,
)
def _vulkan_package_guidance(distribution: str, bits: int) -> str:
suffix = ":i386" if bits == 32 else ""
if distribution == "ubuntu":
return (
"on Ubuntu/Kubuntu install "
f"libvulkan1{suffix} and the matching GPU package "
f"(mesa-vulkan-drivers{suffix} or libnvidia-gl-<version>{suffix})"
)
if distribution in {"debian", "linuxmint", "pop", "elementary", "zorin"}:
return (
f"with APT install libvulkan1{suffix} and the matching {bits}-bit GPU ICD"
)
if distribution in {"fedora", "nobara"}:
return f"install the matching {bits}-bit Vulkan loader and GPU driver with DNF"
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return (
"install vulkan-icd-loader and the GPU vendor driver"
if bits == 64
else "install lib32-vulkan-icd-loader and the matching lib32 GPU driver"
)
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return f"install the matching {bits}-bit Vulkan loader and GPU driver with Zypper"
return f"install the matching {bits}-bit Vulkan loader and GPU driver"
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()