276 lines
7.8 KiB
Python
276 lines
7.8 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]
|
|
|
|
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 manual game-container diagnostics",
|
|
),
|
|
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()
|