424 lines
11 KiB
Python
424 lines
11 KiB
Python
"""Pinned, user-level bootstrap for the official UMU launcher zipapp."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import secrets
|
|
import shutil
|
|
import sys
|
|
import tarfile
|
|
import threading
|
|
import time
|
|
from typing import Callable, Mapping, Protocol
|
|
from urllib.error import HTTPError
|
|
from urllib.request import Request, urlopen
|
|
import zipfile
|
|
|
|
from . import __version__
|
|
from .paths import AppPaths
|
|
from .telemetry import (
|
|
classify_network_failure,
|
|
current_telemetry,
|
|
network_http_status,
|
|
)
|
|
|
|
|
|
UMU_LAUNCHER_VERSION = "1.4.4"
|
|
UMU_LAUNCHER_URL = (
|
|
"https://github.com/Open-Wine-Components/umu-launcher/releases/download/"
|
|
f"{UMU_LAUNCHER_VERSION}/umu-launcher-{UMU_LAUNCHER_VERSION}-zipapp.tar"
|
|
)
|
|
UMU_ARCHIVE_SHA256 = "eb590691841f7fad3fc3ad8fd5db4ccb87849fe7948e62b28ece7a4ee48cc851"
|
|
UMU_EXECUTABLE_SHA256 = "d0005a58602041229cc467dab03dc0c0b9e8cce09a8145b16b7683244cf17804"
|
|
UMU_ARCHIVE_MEMBER = "umu/umu-run"
|
|
MAX_UMU_ARCHIVE_BYTES = 4 * 1024 * 1024
|
|
MAX_UMU_EXECUTABLE_BYTES = 2 * 1024 * 1024
|
|
READ_SIZE = 256 * 1024
|
|
USER_AGENT = f"PT-Linux/{__version__} (+https://pristontale.eu/)"
|
|
|
|
|
|
class UmuBootstrapError(RuntimeError):
|
|
"""Raised when the pinned portable UMU launcher cannot be prepared."""
|
|
|
|
|
|
class _HttpResponse(Protocol):
|
|
headers: Mapping[str, str]
|
|
|
|
def read(self, size: int = -1) -> bytes:
|
|
...
|
|
|
|
def __enter__(self) -> _HttpResponse:
|
|
...
|
|
|
|
def __exit__(
|
|
self,
|
|
exception_type: object,
|
|
exception: object,
|
|
traceback: object,
|
|
) -> object:
|
|
...
|
|
|
|
|
|
UmuOpener = Callable[..., _HttpResponse]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UmuBootstrapResult:
|
|
path: str
|
|
version: str
|
|
source_url: str
|
|
archive_sha256: str
|
|
executable_sha256: str
|
|
changed: bool
|
|
|
|
|
|
def find_umu_launcher(
|
|
paths: AppPaths | None = None,
|
|
environment: Mapping[str, str] | None = None,
|
|
) -> str | None:
|
|
"""Find a system UMU first, then the launcher's verified portable copy."""
|
|
|
|
env = os.environ if environment is None else environment
|
|
located = shutil.which("umu-run", path=env.get("PATH"))
|
|
if located:
|
|
candidate = Path(located).expanduser().resolve()
|
|
if (
|
|
paths is None
|
|
or candidate != paths.managed_umu_executable.resolve()
|
|
or managed_umu_ready(candidate)
|
|
):
|
|
return str(candidate)
|
|
if paths is not None and managed_umu_ready(paths.managed_umu_executable):
|
|
return str(paths.managed_umu_executable.resolve())
|
|
return None
|
|
|
|
|
|
def managed_umu_ready(path: Path) -> bool:
|
|
"""Accept only the exact launcher zipapp pinned by this release."""
|
|
|
|
try:
|
|
return (
|
|
path.is_file()
|
|
and not path.is_symlink()
|
|
and os.access(path, os.X_OK)
|
|
and _sha256(path) == UMU_EXECUTABLE_SHA256
|
|
)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def provision_umu(
|
|
paths: AppPaths,
|
|
*,
|
|
environment: Mapping[str, str] | None = None,
|
|
timeout_seconds: float = 60.0,
|
|
opener: UmuOpener = urlopen,
|
|
) -> UmuBootstrapResult | None:
|
|
"""Ensure that either a system or launcher-managed UMU is available."""
|
|
|
|
if find_umu_launcher(paths, environment) is not None:
|
|
return None
|
|
return ensure_managed_umu(
|
|
paths,
|
|
timeout_seconds=timeout_seconds,
|
|
opener=opener,
|
|
)
|
|
|
|
|
|
def provision_umu_for_startup(
|
|
paths: AppPaths,
|
|
*,
|
|
timeout_seconds: float = 15.0,
|
|
) -> UmuBootstrapResult | None:
|
|
"""Repair a missing helper without preventing dashboard startup on failure."""
|
|
|
|
try:
|
|
return provision_umu(paths, timeout_seconds=timeout_seconds)
|
|
except (OSError, RuntimeError) as exc:
|
|
print(
|
|
"Warning: the Steam Runtime helper could not be prepared during launcher "
|
|
f"startup; Play will retry ({exc}).",
|
|
file=sys.stderr,
|
|
)
|
|
return None
|
|
|
|
|
|
def ensure_managed_umu(
|
|
paths: AppPaths,
|
|
*,
|
|
timeout_seconds: float = 60.0,
|
|
opener: UmuOpener = urlopen,
|
|
) -> UmuBootstrapResult:
|
|
"""Install the pinned official zipapp without modifying the host Python."""
|
|
|
|
destination = paths.managed_umu_executable
|
|
if managed_umu_ready(destination):
|
|
return _result(destination, changed=False)
|
|
|
|
archive = paths.managed_umu_archive(UMU_LAUNCHER_VERSION)
|
|
if not _verified_archive(archive):
|
|
telemetry = current_telemetry()
|
|
download_started_at = time.monotonic()
|
|
if telemetry is not None:
|
|
telemetry.send_event(
|
|
"download_started",
|
|
metadata={
|
|
"resource_type": "runtime",
|
|
"mirror_id": "github.com",
|
|
},
|
|
context={"operation": "runtime_install", "stage": "download"},
|
|
)
|
|
try:
|
|
_download_archive(
|
|
archive,
|
|
timeout_seconds=timeout_seconds,
|
|
opener=opener,
|
|
)
|
|
except BaseException as exc:
|
|
if telemetry is not None:
|
|
telemetry.send_event(
|
|
"download_failed",
|
|
severity="error",
|
|
message=str(exc),
|
|
metadata={
|
|
"resource_type": "runtime",
|
|
"duration_ms": round(
|
|
(time.monotonic() - download_started_at) * 1000
|
|
),
|
|
"failure_type": classify_network_failure(exc),
|
|
"http_status": network_http_status(exc),
|
|
"retry_count": 0,
|
|
"resume_used": False,
|
|
"mirror_id": "github.com",
|
|
},
|
|
context={"operation": "runtime_install", "stage": "download"},
|
|
)
|
|
raise
|
|
download_duration = max(0.0, time.monotonic() - download_started_at)
|
|
download_bytes = archive.stat().st_size
|
|
if telemetry is not None:
|
|
telemetry.send_event(
|
|
"download_completed",
|
|
metadata={
|
|
"resource_type": "runtime",
|
|
"download_bytes": download_bytes,
|
|
"content_bytes": download_bytes,
|
|
"duration_ms": round(download_duration * 1000),
|
|
"average_mbps": _average_mbps(
|
|
download_bytes,
|
|
download_duration,
|
|
),
|
|
"retry_count": 0,
|
|
"resume_used": False,
|
|
"http_status": 200,
|
|
"mirror_id": "github.com",
|
|
},
|
|
context={"operation": "runtime_install", "stage": "download"},
|
|
)
|
|
_install_zipapp(archive, destination)
|
|
result = _result(destination, changed=True)
|
|
_write_json(
|
|
paths.managed_umu_metadata,
|
|
{
|
|
**asdict(result),
|
|
"installed_at": datetime.now().astimezone().isoformat(),
|
|
},
|
|
)
|
|
return result
|
|
|
|
|
|
def _download_archive(
|
|
destination: Path,
|
|
*,
|
|
timeout_seconds: float,
|
|
opener: UmuOpener,
|
|
) -> None:
|
|
request = Request(
|
|
UMU_LAUNCHER_URL,
|
|
headers={"User-Agent": USER_AGENT},
|
|
method="GET",
|
|
)
|
|
try:
|
|
response = opener(request, timeout=timeout_seconds)
|
|
except HTTPError as exc:
|
|
status = exc.code
|
|
exc.close()
|
|
raise UmuBootstrapError(
|
|
f"portable UMU request failed: HTTP {status}"
|
|
) from exc
|
|
except OSError as exc:
|
|
raise UmuBootstrapError(f"portable UMU request failed: {exc}") from exc
|
|
|
|
with response:
|
|
length = _content_length(response.headers.get("Content-Length"))
|
|
if length is not None and length > MAX_UMU_ARCHIVE_BYTES:
|
|
raise UmuBootstrapError(
|
|
f"portable UMU response is unexpectedly large: {length} bytes"
|
|
)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = _temporary_path(destination, "part")
|
|
digest = hashlib.sha256()
|
|
written = 0
|
|
try:
|
|
with temporary.open("xb") as handle:
|
|
while True:
|
|
chunk = response.read(READ_SIZE)
|
|
if not chunk:
|
|
break
|
|
written += len(chunk)
|
|
if written > MAX_UMU_ARCHIVE_BYTES:
|
|
raise UmuBootstrapError(
|
|
"portable UMU response exceeded the 4 MiB safety limit"
|
|
)
|
|
digest.update(chunk)
|
|
handle.write(chunk)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
if length is not None and written != length:
|
|
raise UmuBootstrapError(
|
|
f"incomplete portable UMU response: {written}/{length} bytes"
|
|
)
|
|
actual_hash = digest.hexdigest()
|
|
if actual_hash != UMU_ARCHIVE_SHA256:
|
|
raise UmuBootstrapError(
|
|
"portable UMU archive failed SHA-256 verification: "
|
|
f"expected {UMU_ARCHIVE_SHA256}, received {actual_hash}"
|
|
)
|
|
temporary.replace(destination)
|
|
except BaseException:
|
|
temporary.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def _average_mbps(download_bytes: int, duration_seconds: float) -> float | None:
|
|
if download_bytes <= 0 or duration_seconds <= 0:
|
|
return None
|
|
return round((download_bytes * 8) / duration_seconds / 1_000_000, 3)
|
|
|
|
|
|
def _install_zipapp(archive: Path, destination: Path) -> None:
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = _temporary_path(destination, "tmp")
|
|
try:
|
|
try:
|
|
with tarfile.open(archive, "r:") as bundle:
|
|
member = bundle.getmember(UMU_ARCHIVE_MEMBER)
|
|
if not member.isfile():
|
|
raise UmuBootstrapError(
|
|
"portable UMU archive member is not a regular file"
|
|
)
|
|
if member.size <= 0 or member.size > MAX_UMU_EXECUTABLE_BYTES:
|
|
raise UmuBootstrapError(
|
|
f"portable UMU executable has invalid size: {member.size} bytes"
|
|
)
|
|
source = bundle.extractfile(member)
|
|
if source is None:
|
|
raise UmuBootstrapError(
|
|
"portable UMU executable could not be read from its archive"
|
|
)
|
|
with source, temporary.open("xb") as handle:
|
|
digest = hashlib.sha256()
|
|
written = 0
|
|
while True:
|
|
chunk = source.read(READ_SIZE)
|
|
if not chunk:
|
|
break
|
|
written += len(chunk)
|
|
digest.update(chunk)
|
|
handle.write(chunk)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
except (KeyError, OSError, tarfile.TarError) as exc:
|
|
raise UmuBootstrapError(f"invalid portable UMU archive: {exc}") from exc
|
|
if written != member.size or digest.hexdigest() != UMU_EXECUTABLE_SHA256:
|
|
raise UmuBootstrapError(
|
|
"portable UMU executable failed content verification"
|
|
)
|
|
if not zipfile.is_zipfile(temporary):
|
|
raise UmuBootstrapError("portable UMU executable is not a valid zipapp")
|
|
with zipfile.ZipFile(temporary) as application:
|
|
if "__main__.py" not in application.namelist():
|
|
raise UmuBootstrapError(
|
|
"portable UMU zipapp has no __main__.py entry point"
|
|
)
|
|
bad_member = application.testzip()
|
|
if bad_member is not None:
|
|
raise UmuBootstrapError(
|
|
f"portable UMU zipapp contains a corrupt member: {bad_member}"
|
|
)
|
|
temporary.chmod(0o755)
|
|
temporary.replace(destination)
|
|
except BaseException:
|
|
temporary.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def _verified_archive(path: Path) -> bool:
|
|
try:
|
|
return (
|
|
path.is_file()
|
|
and not path.is_symlink()
|
|
and path.stat().st_size <= MAX_UMU_ARCHIVE_BYTES
|
|
and _sha256(path) == UMU_ARCHIVE_SHA256
|
|
)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _result(path: Path, *, changed: bool) -> UmuBootstrapResult:
|
|
return UmuBootstrapResult(
|
|
path=str(path),
|
|
version=UMU_LAUNCHER_VERSION,
|
|
source_url=UMU_LAUNCHER_URL,
|
|
archive_sha256=UMU_ARCHIVE_SHA256,
|
|
executable_sha256=UMU_EXECUTABLE_SHA256,
|
|
changed=changed,
|
|
)
|
|
|
|
|
|
def _content_length(value: str | None) -> int | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
length = int(value)
|
|
except ValueError as exc:
|
|
raise UmuBootstrapError(f"invalid portable UMU Content-Length: {value!r}") from exc
|
|
if length < 0:
|
|
raise UmuBootstrapError(f"invalid portable UMU Content-Length: {value!r}")
|
|
return length
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(READ_SIZE), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _temporary_path(path: Path, suffix: str) -> Path:
|
|
return path.with_name(
|
|
f".{path.name}.{os.getpid()}.{threading.get_ident()}."
|
|
f"{secrets.token_hex(8)}.{suffix}"
|
|
)
|
|
|
|
|
|
def _write_json(path: Path, value: object) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = _temporary_path(path, "tmp")
|
|
try:
|
|
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
|
|
json.dump(value, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
temporary.replace(path)
|
|
except BaseException:
|
|
temporary.unlink(missing_ok=True)
|
|
raise
|