Added more folders and restructured classes inside them.
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
"""Portable PristonTale EU installer and Linux launcher."""
|
||||
|
||||
from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.9.11"
|
||||
@@ -1,75 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Dispatch the common GUI path without importing every CLI operation."""
|
||||
|
||||
arguments = sys.argv[1:]
|
||||
if not arguments or arguments == ["gui"]:
|
||||
from .container_host import (
|
||||
container_host_error,
|
||||
repair_container_host_after_update,
|
||||
)
|
||||
from .paths import AppPaths
|
||||
from .telemetry import initialize_shared_telemetry, shutdown_shared_telemetry
|
||||
from .umu import provision_umu_for_startup
|
||||
|
||||
telemetry = None
|
||||
telemetry_owner = False
|
||||
try:
|
||||
paths = AppPaths.from_environment()
|
||||
telemetry, telemetry_owner = initialize_shared_telemetry(
|
||||
paths,
|
||||
session_metadata={"interface": "gui", "command": "gui"},
|
||||
)
|
||||
# Older installed releases cannot run a bootstrap step that only exists
|
||||
# in their update payload. Their automatic restart enters here instead.
|
||||
provision_umu_for_startup(paths)
|
||||
container_host = repair_container_host_after_update(paths)
|
||||
if container_host.repair_attempted and (
|
||||
not container_host.ready
|
||||
or container_host.broken_umu_profile_link
|
||||
or container_host.repair_error is not None
|
||||
):
|
||||
print(
|
||||
f"Warning: {container_host_error(container_host)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
from .ui import run_dashboard
|
||||
|
||||
run_dashboard(paths)
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except (ValueError, OSError, RuntimeError) as exc:
|
||||
if telemetry is not None:
|
||||
telemetry.report_exception(
|
||||
"launcher_exception",
|
||||
exc,
|
||||
metadata={"command": "gui"},
|
||||
context={"operation": "launcher", "stage": "startup"},
|
||||
)
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except BaseException as exc:
|
||||
if telemetry is not None:
|
||||
telemetry.report_exception(
|
||||
"launcher_exception",
|
||||
exc,
|
||||
metadata={"command": "gui"},
|
||||
context={"operation": "launcher", "stage": "startup"},
|
||||
severity="critical",
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if telemetry is not None and telemetry_owner:
|
||||
shutdown_shared_telemetry(telemetry)
|
||||
return 0
|
||||
|
||||
from .cli import main as cli_main
|
||||
|
||||
return cli_main(arguments, _manage_telemetry=True)
|
||||
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -1,332 +0,0 @@
|
||||
"""Official patch changelog support for the native Linux dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Callable, Protocol
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
PATCHES_ENDPOINT = "https://pristontale.eu/api/changelog/patches"
|
||||
PATCH_NEWS_URL = "https://pristontale.eu/news#"
|
||||
MAX_PATCHES = 10
|
||||
MAX_PATCH_BYTES = 2 * 1024 * 1024
|
||||
MAX_PATCH_SECTIONS = 32
|
||||
MAX_PATCH_CHANGES = 200
|
||||
_MAX_PATCH_CANDIDATES = 100
|
||||
_SLUG_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
||||
|
||||
|
||||
class ChangelogError(RuntimeError):
|
||||
"""Raised when the official patch feed cannot be parsed safely."""
|
||||
|
||||
|
||||
class ChangelogResponse(Protocol):
|
||||
"""The minimal response interface used by the changelog client."""
|
||||
|
||||
def read(self, size: int = -1) -> bytes: ...
|
||||
|
||||
def __enter__(self) -> ChangelogResponse: ...
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exception_type: type[BaseException] | None,
|
||||
exception: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> bool | None: ...
|
||||
|
||||
|
||||
ChangelogOpener = Callable[..., ChangelogResponse]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchChange:
|
||||
text: str
|
||||
type_key: str
|
||||
|
||||
@property
|
||||
def tag(self) -> str:
|
||||
return patch_change_tag(self.type_key)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchSection:
|
||||
title: str
|
||||
items: tuple[PatchChange, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchNote:
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
version: str | None
|
||||
tags: tuple[str, ...]
|
||||
summary: str
|
||||
sections: tuple[PatchSection, ...]
|
||||
published_at: str | None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"{PATCH_NEWS_URL}{self.slug}"
|
||||
|
||||
|
||||
def patch_change_tag(type_key: str) -> str:
|
||||
"""Turn API keys such as ``new-feature`` into compact UI tags."""
|
||||
|
||||
label = " ".join(type_key.replace("_", "-").split("-"))
|
||||
return label.upper() if label else "CHANGE"
|
||||
|
||||
|
||||
def fetch_patch_notes(
|
||||
cache_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: ChangelogOpener = urlopen,
|
||||
) -> tuple[PatchNote, ...]:
|
||||
"""Fetch the ten latest patches, falling back to the last valid cache."""
|
||||
|
||||
request = Request(
|
||||
PATCHES_ENDPOINT,
|
||||
headers={"User-Agent": "PT-Linux/0.6 (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_PATCH_BYTES + 1)
|
||||
if len(payload) > MAX_PATCH_BYTES:
|
||||
raise ChangelogError("official patch response exceeds the 2 MiB limit")
|
||||
patches = _parse_patch_notes(payload)
|
||||
_write_patch_cache(cache_path, patches)
|
||||
return patches
|
||||
except (OSError, ChangelogError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return read_cached_patch_notes(cache_path)
|
||||
|
||||
|
||||
def read_cached_patch_notes(cache_path: Path) -> tuple[PatchNote, ...]:
|
||||
"""Read the last valid patch feed without performing network I/O."""
|
||||
|
||||
try:
|
||||
value = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return ()
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
patches: list[PatchNote] = []
|
||||
for item in value[:MAX_PATCHES]:
|
||||
patch = _patch_note_from_cache(item)
|
||||
if patch is not None:
|
||||
patches.append(patch)
|
||||
return tuple(patches)
|
||||
|
||||
|
||||
def _parse_patch_notes(payload: bytes) -> tuple[PatchNote, ...]:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
if isinstance(value, dict):
|
||||
value = value.get("data")
|
||||
if not isinstance(value, list):
|
||||
raise ChangelogError("official patch response is not a patch list")
|
||||
parsed: list[tuple[float, int, PatchNote]] = []
|
||||
for item in value[:_MAX_PATCH_CANDIDATES]:
|
||||
result = _parse_patch_note(item)
|
||||
if result is not None:
|
||||
timestamp, patch = result
|
||||
parsed.append((timestamp, patch.id, patch))
|
||||
parsed.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
|
||||
patches = tuple(entry[2] for entry in parsed[:MAX_PATCHES])
|
||||
if not patches:
|
||||
raise ChangelogError("official patch response contains no usable patches")
|
||||
return patches
|
||||
|
||||
|
||||
def _parse_patch_note(value: object) -> tuple[float, PatchNote] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
patch_id = value.get("id")
|
||||
slug = _clean_text(value.get("slug"), 180)
|
||||
title = _clean_text(value.get("title"), 200)
|
||||
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
|
||||
if (
|
||||
isinstance(patch_id, bool)
|
||||
or not isinstance(patch_id, int)
|
||||
or slug is None
|
||||
or _SLUG_PATTERN.fullmatch(slug) is None
|
||||
or title is None
|
||||
or summary is None
|
||||
):
|
||||
return None
|
||||
version = _clean_text(value.get("version"), 64, allow_none=True)
|
||||
if value.get("version") is not None and version is None:
|
||||
return None
|
||||
timestamp = _timestamp(value.get("date"))
|
||||
patch = PatchNote(
|
||||
id=patch_id,
|
||||
slug=slug,
|
||||
title=title,
|
||||
version=version,
|
||||
tags=_parse_tags(value.get("tags")),
|
||||
summary=summary,
|
||||
sections=_parse_sections(value.get("changes")),
|
||||
published_at=_format_timestamp(timestamp),
|
||||
)
|
||||
return timestamp if timestamp is not None else -1.0, patch
|
||||
|
||||
|
||||
def _parse_tags(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
tags: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value[:16]:
|
||||
tag = _clean_text(item, 48)
|
||||
if tag is None or tag.casefold() in seen:
|
||||
continue
|
||||
seen.add(tag.casefold())
|
||||
tags.append(tag)
|
||||
return tuple(tags)
|
||||
|
||||
|
||||
def _parse_sections(value: object) -> tuple[PatchSection, ...]:
|
||||
if not isinstance(value, dict):
|
||||
return ()
|
||||
sections_value = value.get("sections")
|
||||
if not isinstance(sections_value, list):
|
||||
return ()
|
||||
sections: list[PatchSection] = []
|
||||
changes_seen = 0
|
||||
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
|
||||
if not isinstance(section_value, dict):
|
||||
continue
|
||||
title = _clean_text(section_value.get("title"), 100) or "Changes"
|
||||
items_value = section_value.get("items")
|
||||
if not isinstance(items_value, list):
|
||||
continue
|
||||
items: list[PatchChange] = []
|
||||
for item in items_value:
|
||||
if changes_seen >= MAX_PATCH_CHANGES:
|
||||
break
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = _clean_text(item.get("text"), 4000)
|
||||
type_key = _clean_text(item.get("type_key"), 64)
|
||||
if text is None:
|
||||
continue
|
||||
items.append(
|
||||
PatchChange(
|
||||
text=text,
|
||||
type_key=type_key or _type_key_from_title(title),
|
||||
)
|
||||
)
|
||||
changes_seen += 1
|
||||
if items:
|
||||
sections.append(PatchSection(title=title, items=tuple(items)))
|
||||
if changes_seen >= MAX_PATCH_CHANGES:
|
||||
break
|
||||
return tuple(sections)
|
||||
|
||||
|
||||
def _patch_note_from_cache(value: object) -> PatchNote | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
patch_id = value.get("id")
|
||||
slug = _clean_text(value.get("slug"), 180)
|
||||
title = _clean_text(value.get("title"), 200)
|
||||
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
|
||||
version = _clean_text(value.get("version"), 64, allow_none=True)
|
||||
published_at = _clean_text(value.get("published_at"), 64, allow_none=True)
|
||||
if (
|
||||
isinstance(patch_id, bool)
|
||||
or not isinstance(patch_id, int)
|
||||
or slug is None
|
||||
or _SLUG_PATTERN.fullmatch(slug) is None
|
||||
or title is None
|
||||
or summary is None
|
||||
):
|
||||
return None
|
||||
sections_value = value.get("sections")
|
||||
sections: list[PatchSection] = []
|
||||
if isinstance(sections_value, list):
|
||||
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
|
||||
if not isinstance(section_value, dict):
|
||||
continue
|
||||
section_title = _clean_text(section_value.get("title"), 100)
|
||||
items_value = section_value.get("items")
|
||||
if section_title is None or not isinstance(items_value, list):
|
||||
continue
|
||||
items: list[PatchChange] = []
|
||||
for item in items_value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = _clean_text(item.get("text"), 4000)
|
||||
type_key = _clean_text(item.get("type_key"), 64)
|
||||
if text is not None and type_key is not None:
|
||||
items.append(PatchChange(text=text, type_key=type_key))
|
||||
if items:
|
||||
sections.append(PatchSection(section_title, tuple(items)))
|
||||
return PatchNote(
|
||||
id=patch_id,
|
||||
slug=slug,
|
||||
title=title,
|
||||
version=version,
|
||||
tags=_parse_tags(value.get("tags")),
|
||||
summary=summary,
|
||||
sections=tuple(sections),
|
||||
published_at=published_at,
|
||||
)
|
||||
|
||||
|
||||
def _clean_text(
|
||||
value: object,
|
||||
maximum_length: int,
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
allow_none: bool = False,
|
||||
) -> str | None:
|
||||
if value is None and allow_none:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = " ".join(value.split())
|
||||
if len(text) > maximum_length or (not text and not allow_empty):
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _timestamp(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
timestamp = float(value)
|
||||
return timestamp if math.isfinite(timestamp) else None
|
||||
|
||||
|
||||
def _format_timestamp(value: float | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _type_key_from_title(title: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "change"
|
||||
|
||||
|
||||
def _write_patch_cache(path: Path, patches: tuple[PatchNote, ...]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps([asdict(patch) for patch in patches], indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -1,356 +0,0 @@
|
||||
"""Inspectable subprocess execution for installer operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Mapping, Sequence, TextIO
|
||||
|
||||
from .telemetry import current_telemetry
|
||||
|
||||
|
||||
OutputLineFilter = Callable[[str], str | None]
|
||||
MAX_CAPTURE_CHARACTERS = 1024 * 1024
|
||||
SUBPROCESS_ENCODING = "utf-8"
|
||||
SUBPROCESS_DECODE_ERRORS = "replace"
|
||||
|
||||
|
||||
class BoundedTextCapture:
|
||||
"""Retain the newest subprocess output without unbounded memory growth."""
|
||||
|
||||
def __init__(self, max_characters: int = MAX_CAPTURE_CHARACTERS) -> None:
|
||||
if max_characters <= 0:
|
||||
raise ValueError("capture size must be positive")
|
||||
self._max_characters = max_characters
|
||||
self._parts: deque[str] = deque()
|
||||
self._characters = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def append(self, value: str) -> None:
|
||||
if not value:
|
||||
return
|
||||
with self._lock:
|
||||
if len(value) >= self._max_characters:
|
||||
self._parts.clear()
|
||||
self._parts.append(value[-self._max_characters:])
|
||||
self._characters = self._max_characters
|
||||
return
|
||||
self._parts.append(value)
|
||||
self._characters += len(value)
|
||||
while self._characters > self._max_characters:
|
||||
overflow = self._characters - self._max_characters
|
||||
oldest = self._parts[0]
|
||||
if len(oldest) <= overflow:
|
||||
self._parts.popleft()
|
||||
self._characters -= len(oldest)
|
||||
else:
|
||||
self._parts[0] = oldest[overflow:]
|
||||
self._characters -= overflow
|
||||
|
||||
def getvalue(self) -> str:
|
||||
with self._lock:
|
||||
return "".join(self._parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandOutcome:
|
||||
argv: list[str]
|
||||
returncode: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_seconds: float
|
||||
timed_out: bool
|
||||
dry_run: bool
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
"""Raised when a required installer command fails."""
|
||||
|
||||
def __init__(self, outcome: CommandOutcome) -> None:
|
||||
self.outcome = outcome
|
||||
returncode = "unknown" if outcome.returncode is None else str(outcome.returncode)
|
||||
message = f"command failed ({returncode}): {outcome.argv!r}"
|
||||
if outcome.timed_out:
|
||||
message = f"command timed out: {outcome.argv!r}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
"""Run argument arrays with timeouts and append-only JSONL logging."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
log_path: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
operation: str | None = None,
|
||||
) -> None:
|
||||
self.log_path = log_path
|
||||
self.dry_run = dry_run
|
||||
self.operation = operation
|
||||
|
||||
def run(
|
||||
self,
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
env_overrides: Mapping[str, str] | None = None,
|
||||
timeout_seconds: float = 300.0,
|
||||
check: bool = True,
|
||||
stream_output: bool = False,
|
||||
output_line_filter: OutputLineFilter | None = None,
|
||||
pass_fds: Sequence[int] = (),
|
||||
telemetry_stage: str | None = None,
|
||||
report_failure_telemetry: bool = True,
|
||||
) -> CommandOutcome:
|
||||
arguments = [str(value) for value in argv]
|
||||
if not arguments:
|
||||
raise ValueError("command argv cannot be empty")
|
||||
inherited_fds = tuple(dict.fromkeys(pass_fds))
|
||||
if any(not isinstance(value, int) or value < 0 for value in inherited_fds):
|
||||
raise ValueError("inherited file descriptors must be non-negative integers")
|
||||
overrides = dict(env_overrides or {})
|
||||
started = time.monotonic()
|
||||
if self.dry_run:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
duration_seconds=0.0,
|
||||
timed_out=False,
|
||||
dry_run=True,
|
||||
)
|
||||
self._record(outcome, cwd, overrides)
|
||||
return outcome
|
||||
|
||||
environment = os.environ.copy()
|
||||
environment.update(overrides)
|
||||
if stream_output:
|
||||
outcome = self._run_streamed(
|
||||
arguments,
|
||||
cwd=cwd,
|
||||
environment=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
started=started,
|
||||
output_line_filter=output_line_filter,
|
||||
pass_fds=inherited_fds,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
arguments,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=environment,
|
||||
text=True,
|
||||
encoding=SUBPROCESS_ENCODING,
|
||||
errors=SUBPROCESS_DECODE_ERRORS,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
pass_fds=inherited_fds,
|
||||
)
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=completed.returncode,
|
||||
stdout=_bounded_text(completed.stdout),
|
||||
stderr=_bounded_text(completed.stderr),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=False,
|
||||
dry_run=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None,
|
||||
stdout=_bounded_text(_as_text(exc.stdout)),
|
||||
stderr=_bounded_text(_as_text(exc.stderr)),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=True,
|
||||
dry_run=False,
|
||||
)
|
||||
self._record(outcome, cwd, overrides)
|
||||
if report_failure_telemetry and (
|
||||
outcome.timed_out or outcome.returncode != 0
|
||||
):
|
||||
telemetry = current_telemetry()
|
||||
if telemetry is not None:
|
||||
telemetry.report_subprocess_failure(
|
||||
"subprocess_failed",
|
||||
outcome,
|
||||
command=outcome.argv[:1],
|
||||
metadata={
|
||||
"operation": self.operation or "subprocess",
|
||||
**({"stage": telemetry_stage} if telemetry_stage else {}),
|
||||
},
|
||||
)
|
||||
if check and (outcome.timed_out or outcome.returncode != 0):
|
||||
raise CommandError(outcome)
|
||||
return outcome
|
||||
|
||||
@staticmethod
|
||||
def _run_streamed(
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None,
|
||||
environment: Mapping[str, str],
|
||||
timeout_seconds: float,
|
||||
started: float,
|
||||
output_line_filter: OutputLineFilter | None,
|
||||
pass_fds: tuple[int, ...],
|
||||
) -> CommandOutcome:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=dict(environment),
|
||||
text=True,
|
||||
encoding=SUBPROCESS_ENCODING,
|
||||
errors=SUBPROCESS_DECODE_ERRORS,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
pass_fds=pass_fds,
|
||||
)
|
||||
stdout = BoundedTextCapture()
|
||||
stderr = BoundedTextCapture()
|
||||
|
||||
def relay(
|
||||
stream: TextIO,
|
||||
capture: BoundedTextCapture,
|
||||
destination: TextIO,
|
||||
) -> None:
|
||||
try:
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return
|
||||
if output_line_filter is not None:
|
||||
line = output_line_filter(line)
|
||||
if line is None:
|
||||
continue
|
||||
capture.append(line)
|
||||
destination.write(line)
|
||||
destination.flush()
|
||||
except (OSError, ValueError):
|
||||
# Shutdown can close a pipe while its relay thread is leaving readline().
|
||||
return
|
||||
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
stdout_thread = threading.Thread(
|
||||
target=relay,
|
||||
args=(process.stdout, stdout, sys.stdout),
|
||||
daemon=True,
|
||||
)
|
||||
stderr_thread = threading.Thread(
|
||||
target=relay,
|
||||
args=(process.stderr, stderr, sys.stderr),
|
||||
daemon=True,
|
||||
)
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
timed_out = False
|
||||
try:
|
||||
returncode = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
returncode = _terminate_process_group(process)
|
||||
except BaseException:
|
||||
_terminate_process_group(process)
|
||||
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||
raise
|
||||
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||
return CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None if timed_out else returncode,
|
||||
stdout=stdout.getvalue(),
|
||||
stderr=stderr.getvalue(),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=timed_out,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
def _record(
|
||||
self,
|
||||
outcome: CommandOutcome,
|
||||
cwd: Path | None,
|
||||
env_overrides: Mapping[str, str],
|
||||
) -> None:
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
entry = {
|
||||
"timestamp": datetime.now().astimezone().isoformat(),
|
||||
"cwd": str(cwd) if cwd is not None else None,
|
||||
"env_overrides": dict(sorted(env_overrides.items())),
|
||||
**asdict(outcome),
|
||||
}
|
||||
with self.log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _as_text(value: bytes | str | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_text(value: str) -> str:
|
||||
return value[-MAX_CAPTURE_CHARACTERS:]
|
||||
|
||||
|
||||
def _terminate_process_group(
|
||||
process: subprocess.Popen[str],
|
||||
*,
|
||||
grace_seconds: float = 5.0,
|
||||
) -> int | None:
|
||||
"""Stop the complete child session, allowing Proton a short clean exit."""
|
||||
|
||||
if process.poll() is not None:
|
||||
return process.returncode
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return process.poll()
|
||||
try:
|
||||
return process.wait(timeout=grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return process.poll()
|
||||
try:
|
||||
return process.wait(timeout=grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
return process.poll()
|
||||
|
||||
|
||||
def _finish_stream_relays(
|
||||
process: subprocess.Popen[str],
|
||||
stdout_thread: threading.Thread,
|
||||
stderr_thread: threading.Thread,
|
||||
) -> None:
|
||||
"""Bound relay cleanup so a second interrupt can never be required."""
|
||||
|
||||
stdout_thread.join(timeout=2.0)
|
||||
stderr_thread.join(timeout=2.0)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
if stream is not None:
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
stdout_thread.join(timeout=0.25)
|
||||
stderr_thread.join(timeout=0.25)
|
||||
@@ -1,889 +0,0 @@
|
||||
"""Diagnose and narrowly repair the Bubblewrap host sandbox used by UMU."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Callable, Mapping, Sequence
|
||||
|
||||
from . import __version__
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
UID_MAP_PERMISSION_DENIED = "bwrap: setting up uid map: permission denied"
|
||||
_BWRAP_ATTACHMENT_PATTERN = re.compile(
|
||||
r"(?m)^[ \t]*(?:profile[ \t]+[^\s{]+[ \t]+)?/usr/bin/bwrap(?=[ \t{])"
|
||||
)
|
||||
_MAX_APPARMOR_PROFILE_BYTES = 1024 * 1024
|
||||
|
||||
# AppArmor upstream's restrictive Bubblewrap profile, pinned to a commit and
|
||||
# its AppArmor 4.0 ABI so it works on Ubuntu 24.04. It grants Bubblewrap the
|
||||
# setup capabilities it needs, then stacks children under unpriv_bwrap, where
|
||||
# capabilities are explicitly denied. This is intentionally not an unconfined
|
||||
# profile and does not disable Ubuntu's global user-namespace restriction.
|
||||
BWRAP_APPARMOR_PROFILE = """# Installed by the PristonTale EU Linux launcher.
|
||||
# Upstream source:
|
||||
# https://gitlab.com/apparmor/apparmor/-/blob/8e431ebcd915216a03ebc8d01e72b1741bb2f855/profiles/apparmor/profiles/extras/bwrap-userns-restrict
|
||||
#
|
||||
# This profile allows almost everything and only exists to allow bwrap
|
||||
# to work on a system with user namespace restrictions being enforced.
|
||||
# bwrap is allowed access to user namespaces and capabilities within
|
||||
# the user namespace, but its children do not have capabilities,
|
||||
# blocking bwrap from being able to be used to arbitrarily by-pass the
|
||||
# user namespace restrictions.
|
||||
|
||||
# Note: the bwrap child is stacked against the bwrap profile due to
|
||||
# bwraps use of no-new-privs.
|
||||
|
||||
abi <abi/4.0>,
|
||||
|
||||
include <tunables/global>
|
||||
|
||||
profile bwrap /usr/bin/bwrap flags=(attach_disconnected,mediate_deleted) {
|
||||
allow capability,
|
||||
# not allow all, to allow for pix stack on systems that don't support
|
||||
# rule priority.
|
||||
#
|
||||
# sadly we have to allow 'm' every where to allow children to work under
|
||||
# profile stacking atm.
|
||||
allow file rwlkm /{**,},
|
||||
allow network,
|
||||
allow unix,
|
||||
allow ptrace,
|
||||
allow signal,
|
||||
allow mqueue,
|
||||
allow io_uring,
|
||||
allow userns,
|
||||
allow mount,
|
||||
allow umount,
|
||||
allow pivot_root,
|
||||
allow dbus,
|
||||
|
||||
# stacked like this due to no-new-privs restriction
|
||||
# this will stack a target profile against bwrap and unpriv_bwrap
|
||||
# Ideally
|
||||
# - there would be a transition at userns creation first. This would allow
|
||||
# for the bwrap profile to be tighter, and looser within the user
|
||||
# ns. bwrap will still have to fairly loose until a transition at
|
||||
# namespacing in general (not just user ns) is available.
|
||||
# - there would be an independent second target as fallback
|
||||
# This would allow for select target profiles to be used, and not
|
||||
# necessarily stack the unpriv_bwrap in cases where this is desired
|
||||
#
|
||||
# the ix works here because stack will apply to ix fallback
|
||||
# Ideally we would sanitize the environment across a privilege boundry
|
||||
# (leaving bwarp into application) but flatpak etc use environment glibc
|
||||
# sanitized environment variables as part of the sandbox setup.
|
||||
allow pix /** -> &bwrap//&unpriv_bwrap,
|
||||
|
||||
# the local include should not be used without understanding the userns
|
||||
# restriction.
|
||||
# Site-specific additions and overrides. See local/README for details.
|
||||
include if exists <local/bwrap-userns-restrict>
|
||||
}
|
||||
|
||||
# The unpriv_bwrap profile is used to strip capabilities within the userns
|
||||
profile unpriv_bwrap flags=(attach_disconnected,mediate_deleted) {
|
||||
# not allow all, to allow for pix stack
|
||||
allow file rwlkm /{**,},
|
||||
allow network,
|
||||
allow unix,
|
||||
allow ptrace,
|
||||
allow signal,
|
||||
allow mqueue,
|
||||
allow io_uring,
|
||||
allow userns,
|
||||
allow mount,
|
||||
allow umount,
|
||||
allow pivot_root,
|
||||
allow dbus,
|
||||
|
||||
# bwrap profile does stacking against itself this will keep the target
|
||||
# profile from having elevated privileges in the container.
|
||||
# If done recursively the stack will remove any duplicate
|
||||
allow pix /** -> &unpriv_bwrap,
|
||||
|
||||
audit deny capability,
|
||||
|
||||
# the local include should not be used without understanding the userns
|
||||
# restriction.
|
||||
# Site-specific additions and overrides. See local/README for details.
|
||||
include if exists <local/unpriv_bwrap>
|
||||
}
|
||||
"""
|
||||
BWRAP_APPARMOR_PROFILE_SHA256 = hashlib.sha256(
|
||||
BWRAP_APPARMOR_PROFILE.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContainerHostFiles:
|
||||
"""Fixed host paths, injectable so repair logic can be tested safely."""
|
||||
|
||||
bwrap: Path = Path("/usr/bin/bwrap")
|
||||
probe_program: Path = Path("/usr/bin/true")
|
||||
os_release: Path = Path("/etc/os-release")
|
||||
apparmor_parser: Path = Path("/usr/sbin/apparmor_parser")
|
||||
apparmor_directory: Path = Path("/etc/apparmor.d")
|
||||
install: Path = Path("/usr/bin/install")
|
||||
link: Path = Path("/usr/bin/ln")
|
||||
remove: Path = Path("/usr/bin/rm")
|
||||
obsolete_umu_profile_target: Path = Path(
|
||||
"/usr/share/apparmor/extra-profiles/bwrap-userns-restrict"
|
||||
)
|
||||
apparmor_restrict_userns: Path = Path(
|
||||
"/proc/sys/kernel/apparmor_restrict_unprivileged_userns"
|
||||
)
|
||||
unprivileged_userns_clone: Path = Path(
|
||||
"/proc/sys/kernel/unprivileged_userns_clone"
|
||||
)
|
||||
max_user_namespaces: Path = Path("/proc/sys/user/max_user_namespaces")
|
||||
|
||||
@property
|
||||
def distribution_profile(self) -> Path:
|
||||
return self.apparmor_directory / "bwrap-userns-restrict"
|
||||
|
||||
@property
|
||||
def managed_profile(self) -> Path:
|
||||
return self.apparmor_directory / "bwrap-userns-restrict-ptlinux"
|
||||
|
||||
@property
|
||||
def umu_profile_link(self) -> Path:
|
||||
return self.apparmor_directory / "bwrap-userns-restrict-umu"
|
||||
|
||||
|
||||
DEFAULT_CONTAINER_HOST_FILES = ContainerHostFiles()
|
||||
CommandExecutor = Callable[[Sequence[str], float], subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContainerHostReport:
|
||||
ready: bool
|
||||
issue: str | None
|
||||
distribution: str
|
||||
bwrap_path: str | None
|
||||
probe_returncode: int | None
|
||||
probe_stderr: str
|
||||
apparmor_restrict_unprivileged_userns: str | None
|
||||
unprivileged_userns_clone: str | None
|
||||
max_user_namespaces: str | None
|
||||
active_profile_source: str | None
|
||||
conflicting_bwrap_profile: str | None
|
||||
broken_umu_profile_link: bool
|
||||
repairable: bool
|
||||
recommendation: str | None
|
||||
repair_attempted: bool = False
|
||||
repaired: bool = False
|
||||
repair_actions: tuple[str, ...] = ()
|
||||
repair_error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def inspect_container_host(
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
files: ContainerHostFiles = DEFAULT_CONTAINER_HOST_FILES,
|
||||
command_executor: CommandExecutor | None = None,
|
||||
timeout_seconds: float = 5.0,
|
||||
) -> ContainerHostReport:
|
||||
"""Run a harmless user-namespace probe and classify the exact failure."""
|
||||
|
||||
_ = environment
|
||||
distribution_data = _read_os_release(files.os_release)
|
||||
distribution = distribution_data.get("ID", "unknown")
|
||||
bwrap_available = files.bwrap.is_file() and os.access(files.bwrap, os.X_OK)
|
||||
restrict_userns = _read_setting(files.apparmor_restrict_userns)
|
||||
userns_clone = _read_setting(files.unprivileged_userns_clone)
|
||||
max_user_namespaces = _read_setting(files.max_user_namespaces)
|
||||
profile = _active_profile_source(files)
|
||||
broken_link = _known_broken_umu_profile_link(files)
|
||||
returncode: int | None = None
|
||||
stderr = ""
|
||||
issue: str | None = None
|
||||
if not bwrap_available:
|
||||
issue = "bubblewrap-missing"
|
||||
else:
|
||||
command = (
|
||||
str(files.bwrap),
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--unshare-user",
|
||||
"--",
|
||||
str(files.probe_program),
|
||||
)
|
||||
try:
|
||||
result = _run(command, timeout_seconds, command_executor)
|
||||
returncode = result.returncode
|
||||
stderr = _bounded_output(result.stderr)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
issue = "bubblewrap-probe-timeout"
|
||||
stderr = _bounded_output(_timeout_stderr(exc))
|
||||
except OSError as exc:
|
||||
issue = "bubblewrap-probe-error"
|
||||
stderr = str(exc)
|
||||
if issue is None and returncode != 0:
|
||||
folded = stderr.casefold()
|
||||
if UID_MAP_PERMISSION_DENIED in folded:
|
||||
issue = "uid-map-permission-denied"
|
||||
elif userns_clone == "0":
|
||||
issue = "unprivileged-userns-disabled"
|
||||
elif max_user_namespaces == "0":
|
||||
issue = "user-namespace-limit-zero"
|
||||
else:
|
||||
issue = "bubblewrap-probe-failed"
|
||||
|
||||
conflicting_profile = (
|
||||
_find_conflicting_bwrap_profile(files)
|
||||
if issue == "uid-map-permission-denied" or broken_link
|
||||
else None
|
||||
)
|
||||
ubuntu_family = _is_ubuntu_family(distribution_data)
|
||||
managed_collision = _managed_profile_collision(files)
|
||||
repairable = (
|
||||
ubuntu_family
|
||||
and restrict_userns == "1"
|
||||
and files.apparmor_parser.is_file()
|
||||
and os.access(files.apparmor_parser, os.X_OK)
|
||||
and bwrap_available
|
||||
and not managed_collision
|
||||
and conflicting_profile is None
|
||||
and (issue == "uid-map-permission-denied" or broken_link)
|
||||
)
|
||||
recommendation = _recommendation(
|
||||
issue,
|
||||
repairable=repairable,
|
||||
broken_link=broken_link,
|
||||
conflicting_profile=conflicting_profile,
|
||||
stderr=stderr,
|
||||
)
|
||||
return ContainerHostReport(
|
||||
ready=issue is None,
|
||||
issue=issue,
|
||||
distribution=distribution,
|
||||
bwrap_path=str(files.bwrap) if bwrap_available else None,
|
||||
probe_returncode=returncode,
|
||||
probe_stderr=stderr,
|
||||
apparmor_restrict_unprivileged_userns=restrict_userns,
|
||||
unprivileged_userns_clone=userns_clone,
|
||||
max_user_namespaces=max_user_namespaces,
|
||||
active_profile_source=str(profile) if profile else None,
|
||||
conflicting_bwrap_profile=(
|
||||
str(conflicting_profile) if conflicting_profile else None
|
||||
),
|
||||
broken_umu_profile_link=broken_link,
|
||||
repairable=repairable,
|
||||
recommendation=recommendation,
|
||||
)
|
||||
|
||||
|
||||
def repair_container_host(
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
files: ContainerHostFiles = DEFAULT_CONTAINER_HOST_FILES,
|
||||
command_executor: CommandExecutor | None = None,
|
||||
privileged_executor: CommandExecutor | None = None,
|
||||
) -> ContainerHostReport:
|
||||
"""Repair only known Ubuntu AppArmor states, then repeat the probe."""
|
||||
|
||||
before = inspect_container_host(
|
||||
environment=environment,
|
||||
files=files,
|
||||
command_executor=command_executor,
|
||||
)
|
||||
started_at = time.monotonic()
|
||||
_send_dependency_fix_event(
|
||||
"dependency_fix_requested",
|
||||
metadata={"reason": "container_host", "issue": before.issue},
|
||||
stage="requested",
|
||||
)
|
||||
_send_dependency_fix_event(
|
||||
"dependency_fix_started",
|
||||
metadata={"issue": before.issue, "repairable": before.repairable},
|
||||
stage="started",
|
||||
)
|
||||
if before.ready and not before.broken_umu_profile_link:
|
||||
_send_dependency_fix_outcome(before, started_at)
|
||||
return before
|
||||
if not before.repairable:
|
||||
result = replace(
|
||||
before,
|
||||
repair_attempted=True,
|
||||
repair_error=(
|
||||
before.recommendation or "No safe automatic repair is available."
|
||||
),
|
||||
)
|
||||
_send_dependency_fix_outcome(result, started_at)
|
||||
return result
|
||||
actions: list[str] = []
|
||||
created_managed_profile = False
|
||||
profile_loaded = False
|
||||
selected_profile = _active_profile_source(files)
|
||||
try:
|
||||
if selected_profile is None:
|
||||
selected_profile = files.managed_profile
|
||||
_validate_and_install_managed_profile(
|
||||
files,
|
||||
command_executor=command_executor,
|
||||
privileged_executor=privileged_executor,
|
||||
)
|
||||
created_managed_profile = True
|
||||
actions.append(f"installed {selected_profile}")
|
||||
else:
|
||||
_validate_profile(
|
||||
selected_profile,
|
||||
files,
|
||||
command_executor=command_executor,
|
||||
)
|
||||
|
||||
loaded = _run_privileged(
|
||||
(str(files.apparmor_parser), "-r", str(selected_profile)),
|
||||
120.0,
|
||||
privileged_executor,
|
||||
)
|
||||
_require_success(loaded, f"could not load AppArmor profile {selected_profile}")
|
||||
profile_loaded = True
|
||||
actions.append(f"loaded {selected_profile}")
|
||||
|
||||
if _known_broken_umu_profile_link(files):
|
||||
linked = _run_privileged(
|
||||
(
|
||||
str(files.link),
|
||||
"-sfn",
|
||||
"--",
|
||||
str(selected_profile),
|
||||
str(files.umu_profile_link),
|
||||
),
|
||||
120.0,
|
||||
privileged_executor,
|
||||
)
|
||||
_require_success(
|
||||
linked,
|
||||
"could not repair UMU's broken AppArmor profile link",
|
||||
)
|
||||
actions.append(f"repaired {files.umu_profile_link}")
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
||||
rollback_error = None
|
||||
if created_managed_profile and not profile_loaded:
|
||||
rollback_error = _rollback_managed_profile(files, privileged_executor)
|
||||
if rollback_error is None:
|
||||
actions.append(f"rolled back {files.managed_profile}")
|
||||
after_failure = inspect_container_host(
|
||||
environment=environment,
|
||||
files=files,
|
||||
command_executor=command_executor,
|
||||
)
|
||||
error = str(exc)
|
||||
if rollback_error:
|
||||
error = f"{error}; rollback also failed: {rollback_error}"
|
||||
result = replace(
|
||||
after_failure,
|
||||
repair_attempted=True,
|
||||
repair_actions=tuple(actions),
|
||||
repair_error=error,
|
||||
)
|
||||
_send_dependency_fix_outcome(result, started_at)
|
||||
return result
|
||||
|
||||
after = inspect_container_host(
|
||||
environment=environment,
|
||||
files=files,
|
||||
command_executor=command_executor,
|
||||
)
|
||||
error = None
|
||||
if not after.ready:
|
||||
error = (
|
||||
"The AppArmor profile loaded, but the Bubblewrap user-namespace probe "
|
||||
"still fails. "
|
||||
+ (after.recommendation or "Inspect the host security policy.")
|
||||
)
|
||||
result = replace(
|
||||
after,
|
||||
repair_attempted=True,
|
||||
repaired=after.ready,
|
||||
repair_actions=tuple(actions),
|
||||
repair_error=error,
|
||||
)
|
||||
_send_dependency_fix_outcome(result, started_at)
|
||||
return result
|
||||
|
||||
|
||||
def _send_dependency_fix_outcome(
|
||||
report: ContainerHostReport,
|
||||
started_at: float,
|
||||
) -> None:
|
||||
success = report.ready and not report.broken_umu_profile_link and (
|
||||
report.repair_error is None
|
||||
)
|
||||
_send_dependency_fix_event(
|
||||
"dependency_fix_success" if success else "dependency_fix_failed",
|
||||
severity="info" if success else "error",
|
||||
message=None if success else report.repair_error,
|
||||
metadata={
|
||||
"duration_ms": round((time.monotonic() - started_at) * 1000),
|
||||
"ready": report.ready,
|
||||
"repaired": report.repaired,
|
||||
"repair_attempted": report.repair_attempted,
|
||||
"action_count": len(report.repair_actions),
|
||||
"issue": report.issue,
|
||||
},
|
||||
stage="completed" if success else "repair",
|
||||
)
|
||||
|
||||
|
||||
def _send_dependency_fix_event(
|
||||
event: str,
|
||||
*,
|
||||
severity: str = "info",
|
||||
message: str | None = None,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
stage: str,
|
||||
) -> None:
|
||||
try:
|
||||
from .telemetry import current_telemetry
|
||||
|
||||
telemetry = current_telemetry()
|
||||
if telemetry is not None:
|
||||
telemetry.send_event(
|
||||
event,
|
||||
severity=severity,
|
||||
message=message,
|
||||
metadata=metadata,
|
||||
context={"operation": "dependency_fix", "stage": stage},
|
||||
)
|
||||
except Exception:
|
||||
# Host recovery must remain independent from optional telemetry.
|
||||
pass
|
||||
|
||||
|
||||
def repair_container_host_after_update(paths: AppPaths) -> ContainerHostReport:
|
||||
"""Run the repair once after activation, including upgrades by older code."""
|
||||
|
||||
status = inspect_container_host()
|
||||
if status.ready and not status.broken_umu_profile_link:
|
||||
return status
|
||||
if not _launcher_was_updated_to_current_version(paths):
|
||||
return status
|
||||
marker = paths.container_host_repair_state
|
||||
if _repair_was_attempted(marker, status):
|
||||
return status
|
||||
print(
|
||||
"Launcher update: checking Steam Runtime sandbox permissions; "
|
||||
"authentication may be requested for a narrow AppArmor repair.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
report = repair_container_host()
|
||||
try:
|
||||
_write_repair_marker(marker, report)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"Warning: could not record the container-host repair attempt: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def container_host_error(report: ContainerHostReport) -> str:
|
||||
if report.ready and report.broken_umu_profile_link:
|
||||
return report.repair_error or report.recommendation or (
|
||||
"UMU's AppArmor profile link remains broken."
|
||||
)
|
||||
message = (
|
||||
"The Steam Runtime container cannot create its Bubblewrap user namespace."
|
||||
)
|
||||
if report.repair_error:
|
||||
return f"{message} Host repair failed: {report.repair_error}"
|
||||
if report.recommendation:
|
||||
return f"{message} {report.recommendation}"
|
||||
if report.probe_stderr:
|
||||
return f"{message} Bubblewrap reported: {report.probe_stderr}"
|
||||
return message
|
||||
|
||||
|
||||
def _validate_and_install_managed_profile(
|
||||
files: ContainerHostFiles,
|
||||
*,
|
||||
command_executor: CommandExecutor | None,
|
||||
privileged_executor: CommandExecutor | None,
|
||||
) -> None:
|
||||
if files.managed_profile.exists() or files.managed_profile.is_symlink():
|
||||
if _is_managed_profile(files.managed_profile):
|
||||
_validate_profile(
|
||||
files.managed_profile,
|
||||
files,
|
||||
command_executor=command_executor,
|
||||
)
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"refusing to overwrite an unmanaged AppArmor file: {files.managed_profile}"
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="ptlinux-bwrap-profile-") as temporary:
|
||||
candidate = Path(temporary) / "bwrap-userns-restrict-ptlinux"
|
||||
candidate.write_text(BWRAP_APPARMOR_PROFILE, encoding="utf-8")
|
||||
candidate.chmod(0o600)
|
||||
_validate_profile(candidate, files, command_executor=command_executor)
|
||||
installed = _run_privileged(
|
||||
(
|
||||
str(files.install),
|
||||
"-m",
|
||||
"0644",
|
||||
"--",
|
||||
str(candidate),
|
||||
str(files.managed_profile),
|
||||
),
|
||||
120.0,
|
||||
privileged_executor,
|
||||
)
|
||||
_require_success(installed, "could not install the managed AppArmor profile")
|
||||
|
||||
|
||||
def _validate_profile(
|
||||
profile: Path,
|
||||
files: ContainerHostFiles,
|
||||
*,
|
||||
command_executor: CommandExecutor | None,
|
||||
) -> None:
|
||||
validated = _run(
|
||||
(str(files.apparmor_parser), "-Q", "-K", str(profile)),
|
||||
30.0,
|
||||
command_executor,
|
||||
)
|
||||
_require_success(validated, f"AppArmor rejected profile {profile}")
|
||||
|
||||
|
||||
def _rollback_managed_profile(
|
||||
files: ContainerHostFiles,
|
||||
privileged_executor: CommandExecutor | None,
|
||||
) -> str | None:
|
||||
try:
|
||||
removed = _run_privileged(
|
||||
(str(files.remove), "--", str(files.managed_profile)),
|
||||
120.0,
|
||||
privileged_executor,
|
||||
)
|
||||
_require_success(removed, "could not remove the newly installed profile")
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def _run(
|
||||
command: Sequence[str],
|
||||
timeout_seconds: float,
|
||||
executor: CommandExecutor | None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if executor is not None:
|
||||
return executor(tuple(command), timeout_seconds)
|
||||
return subprocess.run(
|
||||
list(command),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _run_privileged(
|
||||
command: Sequence[str],
|
||||
timeout_seconds: float,
|
||||
executor: CommandExecutor | None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if executor is not None:
|
||||
return executor(tuple(command), timeout_seconds)
|
||||
prefix = _privilege_prefix()
|
||||
return subprocess.run(
|
||||
[*prefix, *command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _privilege_prefix() -> tuple[str, ...]:
|
||||
if os.geteuid() == 0:
|
||||
return ()
|
||||
if sys.stdin.isatty():
|
||||
for candidate in (Path("/usr/bin/sudo"), Path("/usr/bin/doas")):
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return (str(candidate),)
|
||||
pkexec = Path("/usr/bin/pkexec")
|
||||
if pkexec.is_file() and os.access(pkexec, os.X_OK):
|
||||
return (str(pkexec),)
|
||||
sudo = Path("/usr/bin/sudo")
|
||||
if sudo.is_file() and os.access(sudo, os.X_OK):
|
||||
return str(sudo), "-n"
|
||||
raise RuntimeError(
|
||||
"root access is required for the AppArmor repair, but no usable "
|
||||
"sudo, doas, or pkexec command was found"
|
||||
)
|
||||
|
||||
|
||||
def _require_success(result: subprocess.CompletedProcess[str], message: str) -> None:
|
||||
if result.returncode == 0:
|
||||
return
|
||||
detail = _bounded_output(result.stderr) or _bounded_output(result.stdout)
|
||||
if detail:
|
||||
raise RuntimeError(f"{message}: {detail}")
|
||||
raise RuntimeError(f"{message} (exit status {result.returncode})")
|
||||
|
||||
|
||||
def _active_profile_source(files: ContainerHostFiles) -> Path | None:
|
||||
if files.distribution_profile.is_file():
|
||||
return files.distribution_profile
|
||||
if _trusted_umu_profile_link(files):
|
||||
return files.umu_profile_link
|
||||
if _is_managed_profile(files.managed_profile):
|
||||
return files.managed_profile
|
||||
return None
|
||||
|
||||
|
||||
def _is_managed_profile(path: Path) -> bool:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return False
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
return hashlib.sha256(content).hexdigest() == BWRAP_APPARMOR_PROFILE_SHA256
|
||||
|
||||
|
||||
def _managed_profile_collision(files: ContainerHostFiles) -> bool:
|
||||
path = files.managed_profile
|
||||
return (path.exists() or path.is_symlink()) and not _is_managed_profile(path)
|
||||
|
||||
|
||||
def _find_conflicting_bwrap_profile(files: ContainerHostFiles) -> Path | None:
|
||||
known_names = {
|
||||
files.distribution_profile.name,
|
||||
files.managed_profile.name,
|
||||
}
|
||||
if _trusted_umu_profile_link(files):
|
||||
known_names.add(files.umu_profile_link.name)
|
||||
try:
|
||||
candidates = sorted(
|
||||
files.apparmor_directory.iterdir(),
|
||||
key=lambda path: path.name,
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
for candidate in candidates:
|
||||
if candidate.name in known_names or not candidate.is_file():
|
||||
continue
|
||||
try:
|
||||
if candidate.stat().st_size > _MAX_APPARMOR_PROFILE_BYTES:
|
||||
continue
|
||||
content = candidate.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError):
|
||||
continue
|
||||
policy = "\n".join(line.split("#", 1)[0] for line in content.splitlines())
|
||||
if _BWRAP_ATTACHMENT_PATTERN.search(policy):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _trusted_umu_profile_link(files: ContainerHostFiles) -> bool:
|
||||
link = files.umu_profile_link
|
||||
if not link.is_symlink() or not link.is_file():
|
||||
return False
|
||||
try:
|
||||
target_text = os.readlink(link)
|
||||
target = Path(target_text)
|
||||
if not target.is_absolute():
|
||||
target = link.parent / target
|
||||
target = target.resolve()
|
||||
allowed = {
|
||||
files.distribution_profile.resolve(),
|
||||
files.managed_profile.resolve(),
|
||||
files.obsolete_umu_profile_target.resolve(),
|
||||
}
|
||||
except OSError:
|
||||
return False
|
||||
return target in allowed
|
||||
|
||||
|
||||
def _known_broken_umu_profile_link(files: ContainerHostFiles) -> bool:
|
||||
link = files.umu_profile_link
|
||||
if not link.is_symlink():
|
||||
return False
|
||||
try:
|
||||
target_text = os.readlink(link)
|
||||
except OSError:
|
||||
return False
|
||||
if target_text != str(files.obsolete_umu_profile_target):
|
||||
return False
|
||||
return not files.obsolete_umu_profile_target.exists()
|
||||
|
||||
|
||||
def _read_os_release(path: Path) -> dict[str, str]:
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return {}
|
||||
result: dict[str, str] = {}
|
||||
for line in lines:
|
||||
if "=" not in line or line.startswith("#"):
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
result[key] = value.strip().strip('"').strip("'")
|
||||
return result
|
||||
|
||||
|
||||
def _is_ubuntu_family(os_release: Mapping[str, str]) -> bool:
|
||||
distribution = os_release.get("ID", "").casefold()
|
||||
like = set(os_release.get("ID_LIKE", "").casefold().split())
|
||||
return distribution == "ubuntu" or "ubuntu" in like
|
||||
|
||||
|
||||
def _read_setting(path: Path) -> str | None:
|
||||
try:
|
||||
return path.read_text(encoding="ascii").strip()[:64]
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _recommendation(
|
||||
issue: str | None,
|
||||
*,
|
||||
repairable: bool,
|
||||
broken_link: bool,
|
||||
conflicting_profile: Path | None,
|
||||
stderr: str,
|
||||
) -> str | None:
|
||||
if issue is None:
|
||||
if broken_link:
|
||||
return (
|
||||
"UMU's AppArmor profile link is broken; run "
|
||||
"`pristontale-eu container-host --repair` before rebooting."
|
||||
)
|
||||
return None
|
||||
if issue == "bubblewrap-missing":
|
||||
return (
|
||||
"Install the distribution's bubblewrap package and run the check again."
|
||||
)
|
||||
if issue == "uid-map-permission-denied":
|
||||
if repairable:
|
||||
return (
|
||||
"Ubuntu AppArmor denied Bubblewrap's UID map. Run "
|
||||
"`pristontale-eu container-host --repair`; authentication is "
|
||||
"required."
|
||||
)
|
||||
if conflicting_profile is not None:
|
||||
return (
|
||||
"Bubblewrap's UID map was denied, but an unrelated AppArmor profile "
|
||||
f"already attaches to /usr/bin/bwrap: {conflicting_profile}. The "
|
||||
"launcher will not install a competing profile automatically."
|
||||
)
|
||||
return (
|
||||
"Bubblewrap's UID map was denied, but this host does not match the "
|
||||
"narrow Ubuntu AppArmor repair. Inspect the loaded bwrap policy."
|
||||
)
|
||||
if issue == "unprivileged-userns-disabled":
|
||||
return (
|
||||
"kernel.unprivileged_userns_clone is disabled. The launcher will not "
|
||||
"weaken this global security setting automatically."
|
||||
)
|
||||
if issue == "user-namespace-limit-zero":
|
||||
return (
|
||||
"user.max_user_namespaces is zero. The launcher will not change this "
|
||||
"global limit automatically."
|
||||
)
|
||||
if issue == "bubblewrap-probe-timeout":
|
||||
return (
|
||||
"The Bubblewrap sandbox probe timed out; inspect the host sandbox policy."
|
||||
)
|
||||
if stderr:
|
||||
return f"Bubblewrap's sandbox probe failed: {stderr}"
|
||||
return "Bubblewrap's sandbox probe failed without a diagnostic message."
|
||||
|
||||
|
||||
def _timeout_stderr(error: subprocess.TimeoutExpired) -> str:
|
||||
value = error.stderr
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value or ""
|
||||
|
||||
|
||||
def _bounded_output(value: str | None, limit: int = 2048) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
cleaned = value.strip()
|
||||
if len(cleaned) <= limit:
|
||||
return cleaned
|
||||
return cleaned[:limit] + "…"
|
||||
|
||||
|
||||
def _launcher_was_updated_to_current_version(paths: AppPaths) -> bool:
|
||||
try:
|
||||
data = paths.launcher_update_state.read_bytes()
|
||||
if len(data) > 64 * 1024:
|
||||
return False
|
||||
payload = json.loads(data)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return False
|
||||
return isinstance(payload, dict) and payload.get("to_version") == __version__
|
||||
|
||||
|
||||
def _repair_was_attempted(path: Path, status: ContainerHostReport) -> bool:
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
if len(data) > 64 * 1024:
|
||||
return False
|
||||
payload = json.loads(data)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return False
|
||||
return (
|
||||
isinstance(payload, dict)
|
||||
and payload.get("schema") == 1
|
||||
and payload.get("launcher_version") == __version__
|
||||
and payload.get("profile_sha256") == BWRAP_APPARMOR_PROFILE_SHA256
|
||||
and payload.get("issue") == status.issue
|
||||
and payload.get("broken_umu_profile_link") == status.broken_umu_profile_link
|
||||
)
|
||||
|
||||
|
||||
def _write_repair_marker(path: Path, report: ContainerHostReport) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"schema": 1,
|
||||
"attempted_at": datetime.now().astimezone().isoformat(),
|
||||
"launcher_version": __version__,
|
||||
"profile_sha256": BWRAP_APPARMOR_PROFILE_SHA256,
|
||||
"issue": report.issue,
|
||||
"broken_umu_profile_link": report.broken_umu_profile_link,
|
||||
"ready": report.ready,
|
||||
"repair_error": report.repair_error,
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
delete=False,
|
||||
) as handle:
|
||||
temporary = Path(handle.name)
|
||||
try:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
try:
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(path)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -1,217 +0,0 @@
|
||||
"""Read-only state snapshots for the launcher's hidden debug view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from .import_state import game_import_requires_verification
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
RuntimeErrorPT,
|
||||
RuntimeSelection,
|
||||
resolve_proton_sync_mode,
|
||||
select_runtime,
|
||||
)
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeDebugInfo:
|
||||
"""Configured, selected, and last-prepared runtime state."""
|
||||
|
||||
requested_kind: str
|
||||
selected_kind: str | None
|
||||
executable: str | None
|
||||
proton_path: str | None
|
||||
prefix: str | None
|
||||
sync_configured: str
|
||||
sync_effective: str | None
|
||||
prepared_kind: str | None
|
||||
prepared_proton_path: str | None
|
||||
prepared_at: str | None
|
||||
marker_matches_selection: bool | None
|
||||
dxvk_d3d9_override: str | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationDebugInfo:
|
||||
"""Cached trust evidence from completed depot and import operations."""
|
||||
|
||||
import_state: str
|
||||
import_verified_at: str | None
|
||||
import_build: int | None
|
||||
last_sync_at: str | None
|
||||
last_full_verification_at: str | None
|
||||
build: int | None
|
||||
version: str | None
|
||||
mode: str | None
|
||||
files_total: int | None
|
||||
files_downloaded: int | None
|
||||
files_deleted: int | None
|
||||
files_created: int | None
|
||||
files_quarantined: int | None
|
||||
|
||||
|
||||
RuntimeSelector = Callable[[Settings], RuntimeSelection]
|
||||
SyncResolver = Callable[[str], str]
|
||||
|
||||
|
||||
def inspect_runtime_debug(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
*,
|
||||
selector: RuntimeSelector | None = None,
|
||||
sync_resolver: SyncResolver = resolve_proton_sync_mode,
|
||||
) -> RuntimeDebugInfo:
|
||||
"""Resolve the next runtime and compare it with the preparation marker."""
|
||||
|
||||
marker = _read_json_object(paths.game_runtime_marker)
|
||||
prepared_runtime: dict[str, object] | None = None
|
||||
if marker is not None:
|
||||
raw_prepared_runtime = marker.get("runtime")
|
||||
if isinstance(raw_prepared_runtime, dict):
|
||||
prepared_runtime = raw_prepared_runtime
|
||||
prepared_kind = _text(prepared_runtime.get("kind")) if prepared_runtime else None
|
||||
prepared_proton_path = (
|
||||
_text(prepared_runtime.get("proton_path")) if prepared_runtime else None
|
||||
)
|
||||
prepared_at = _text(marker.get("configured_at")) if marker else None
|
||||
dxvk_override = _text(marker.get("dxvk_d3d9_override")) if marker else None
|
||||
selection: RuntimeSelection | None = None
|
||||
error: str | None = None
|
||||
try:
|
||||
selection = (
|
||||
select_runtime(settings, paths=paths)
|
||||
if selector is None
|
||||
else selector(settings)
|
||||
)
|
||||
except (OSError, RuntimeErrorPT, ValueError) as exc:
|
||||
error = str(exc).strip() or type(exc).__name__
|
||||
selected_kind = selection.kind if selection is not None else None
|
||||
prefix_kind = selected_kind or prepared_kind
|
||||
prefix = None
|
||||
if prefix_kind == "umu":
|
||||
prefix = str(paths.umu_prefix)
|
||||
elif prefix_kind in {"proton7", "proton7-container"}:
|
||||
prefix = str(paths.proton_prefix)
|
||||
sync_effective = None
|
||||
if prefix_kind in {"proton7", "proton7-container"}:
|
||||
try:
|
||||
sync_effective = sync_resolver(settings.proton_sync)
|
||||
except ValueError as exc:
|
||||
error = error or (str(exc).strip() or type(exc).__name__)
|
||||
marker_matches = (
|
||||
prepared_runtime == asdict(selection)
|
||||
if prepared_runtime is not None and selection is not None
|
||||
else None
|
||||
)
|
||||
return RuntimeDebugInfo(
|
||||
requested_kind=settings.runtime,
|
||||
selected_kind=selected_kind,
|
||||
executable=selection.executable if selection is not None else None,
|
||||
proton_path=selection.proton_path if selection is not None else None,
|
||||
prefix=prefix,
|
||||
sync_configured=settings.proton_sync,
|
||||
sync_effective=sync_effective,
|
||||
prepared_kind=prepared_kind,
|
||||
prepared_proton_path=prepared_proton_path,
|
||||
prepared_at=prepared_at,
|
||||
marker_matches_selection=marker_matches,
|
||||
dxvk_d3d9_override=dxvk_override,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def read_verification_debug(paths: AppPaths) -> VerificationDebugInfo:
|
||||
"""Read cached verification state without hashing or changing game files."""
|
||||
|
||||
depot = _read_json_object(paths.native_depot_state)
|
||||
if depot is not None and depot.get("dry_run") is not False:
|
||||
depot = None
|
||||
import_manifest_exists = paths.game_import_manifest.exists()
|
||||
import_manifest = _read_json_object(paths.game_import_manifest)
|
||||
if not import_manifest_exists:
|
||||
import_state = "not applicable"
|
||||
elif game_import_requires_verification(paths):
|
||||
import_state = "pending"
|
||||
else:
|
||||
import_state = "verified"
|
||||
import_verified_at = (
|
||||
_text(import_manifest.get("verification_completed_at"))
|
||||
if import_state == "verified" and import_manifest is not None
|
||||
else None
|
||||
)
|
||||
import_build = (
|
||||
_non_negative_int(import_manifest.get("verification_build"))
|
||||
if import_state == "verified" and import_manifest is not None
|
||||
else None
|
||||
)
|
||||
last_sync_at = _text(depot.get("completed_at")) if depot else None
|
||||
full_verifications = [
|
||||
value
|
||||
for value in (
|
||||
last_sync_at if depot is not None and depot.get("repair") is True else None,
|
||||
import_verified_at,
|
||||
)
|
||||
if value is not None
|
||||
]
|
||||
last_full_verification_at = (
|
||||
max(full_verifications, key=_iso_timestamp)
|
||||
if full_verifications
|
||||
else None
|
||||
)
|
||||
return VerificationDebugInfo(
|
||||
import_state=import_state,
|
||||
import_verified_at=import_verified_at,
|
||||
import_build=import_build,
|
||||
last_sync_at=last_sync_at,
|
||||
last_full_verification_at=last_full_verification_at,
|
||||
build=_non_negative_int(depot.get("build")) if depot else None,
|
||||
version=_text(depot.get("version")) if depot else None,
|
||||
mode=(
|
||||
"full repair"
|
||||
if depot is not None and depot.get("repair") is True
|
||||
else "update"
|
||||
if depot is not None and depot.get("repair") is False
|
||||
else None
|
||||
),
|
||||
files_total=_non_negative_int(depot.get("files_total")) if depot else None,
|
||||
files_downloaded=(
|
||||
_non_negative_int(depot.get("files_downloaded")) if depot else None
|
||||
),
|
||||
files_deleted=_non_negative_int(depot.get("files_deleted")) if depot else None,
|
||||
files_created=_non_negative_int(depot.get("files_created")) if depot else None,
|
||||
files_quarantined=(
|
||||
_non_negative_int(depot.get("files_quarantined")) if depot else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _text(value: object) -> str | None:
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def _non_negative_int(value: object) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _iso_timestamp(value: str) -> float:
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||||
except (OverflowError, ValueError):
|
||||
return float("-inf")
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Install a desktop entry that does not depend on the GUI session's PATH."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
EXECUTABLE_TOKEN = "@PTLINUX_EXECUTABLE@"
|
||||
ICON_TOKEN = "@PTLINUX_ICON@"
|
||||
|
||||
|
||||
class DesktopEntryError(ValueError):
|
||||
"""Raised when a safe desktop entry cannot be rendered."""
|
||||
|
||||
|
||||
def quote_exec_argument(value: str) -> str:
|
||||
"""Quote one Desktop Entry ``Exec`` argument without field-code expansion."""
|
||||
|
||||
if not value or any(character in value for character in ("\x00", "\n", "\r")):
|
||||
raise DesktopEntryError("desktop executable path contains invalid characters")
|
||||
escaped = value.replace("\\", "\\\\\\\\")
|
||||
escaped = escaped.replace('"', '\\\\"')
|
||||
escaped = escaped.replace("`", "\\\\`")
|
||||
escaped = escaped.replace("$", "\\\\$")
|
||||
escaped = escaped.replace("%", "%%")
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def escape_icon_path(value: str) -> str:
|
||||
"""Escape one absolute Desktop Entry ``Icon`` path as a string value."""
|
||||
|
||||
if not value or any(character in value for character in ("\x00", "\n", "\r")):
|
||||
raise DesktopEntryError("desktop icon path contains invalid characters")
|
||||
return value.replace("\\", "\\\\")
|
||||
|
||||
|
||||
def render_desktop_entry(template: str, executable: Path, icon: Path) -> str:
|
||||
"""Replace launcher and icon tokens with validated absolute paths."""
|
||||
|
||||
if template.count(EXECUTABLE_TOKEN) != 1:
|
||||
raise DesktopEntryError("desktop template must contain exactly one executable token")
|
||||
if template.count(ICON_TOKEN) != 1:
|
||||
raise DesktopEntryError("desktop template must contain exactly one icon token")
|
||||
launcher = executable.expanduser()
|
||||
if not launcher.is_absolute():
|
||||
raise DesktopEntryError("desktop launcher path must be absolute")
|
||||
if not launcher.is_file() or not os.access(launcher, os.X_OK):
|
||||
raise DesktopEntryError(f"desktop launcher is missing or not executable: {launcher}")
|
||||
icon_path = icon.expanduser()
|
||||
if not icon_path.is_absolute():
|
||||
raise DesktopEntryError("desktop icon path must be absolute")
|
||||
if not icon_path.is_file():
|
||||
raise DesktopEntryError(f"desktop icon is missing: {icon_path}")
|
||||
rendered = template.replace(EXECUTABLE_TOKEN, quote_exec_argument(str(launcher)))
|
||||
rendered = rendered.replace(ICON_TOKEN, escape_icon_path(str(icon_path)))
|
||||
return rendered if rendered.endswith("\n") else rendered + "\n"
|
||||
|
||||
|
||||
def install_desktop_entry(
|
||||
template: Path,
|
||||
executable: Path,
|
||||
icon: Path,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
"""Render and atomically install one user-level desktop entry."""
|
||||
|
||||
rendered = render_desktop_entry(
|
||||
template.read_text(encoding="utf-8"),
|
||||
executable,
|
||||
icon,
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(rendered, encoding="utf-8")
|
||||
temporary.chmod(0o644)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="install the PristonTale EU desktop entry")
|
||||
parser.add_argument("template", type=Path)
|
||||
parser.add_argument("executable", type=Path)
|
||||
parser.add_argument("icon", type=Path)
|
||||
parser.add_argument("destination", type=Path)
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
install_desktop_entry(
|
||||
arguments.template,
|
||||
arguments.executable,
|
||||
arguments.icon,
|
||||
arguments.destination,
|
||||
)
|
||||
except (DesktopEntryError, OSError) as exc:
|
||||
print(f"Could not install desktop shortcut: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Stable desktop identity shared by the launcher and Wine game window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
APPLICATION_NAME = "PristonTale EU"
|
||||
APPLICATION_WM_CLASS = "PristonTaleEU"
|
||||
APPLICATION_WM_INSTANCE = "pristontale-eu"
|
||||
APPLICATION_ICON_FILENAME = "eu.pristontale.PTLinux.png"
|
||||
APPLICATION_ICON_VARIABLE = "PTLINUX_ICON_PATH"
|
||||
|
||||
|
||||
def find_application_icon(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> Path | None:
|
||||
"""Find the installed icon, with source-tree fallbacks for development."""
|
||||
|
||||
env = os.environ if environment is None else environment
|
||||
candidates: list[Path] = []
|
||||
explicit = env.get(APPLICATION_ICON_VARIABLE)
|
||||
if explicit:
|
||||
candidates.append(Path(explicit).expanduser())
|
||||
|
||||
# Some distributions bundle the icon beside this module. The installer
|
||||
# publishes the same asset through the user's XDG data directory.
|
||||
candidates.append(Path(__file__).with_name(APPLICATION_ICON_FILENAME))
|
||||
home_value = env.get("HOME")
|
||||
if home_value:
|
||||
home = Path(home_value).expanduser()
|
||||
data_home_value = env.get("XDG_DATA_HOME")
|
||||
data_home = (
|
||||
Path(data_home_value).expanduser()
|
||||
if data_home_value
|
||||
else home / ".local/share"
|
||||
)
|
||||
candidates.append(data_home / "pixmaps" / APPLICATION_ICON_FILENAME)
|
||||
default_icon = home / ".local/share/pixmaps" / APPLICATION_ICON_FILENAME
|
||||
if default_icon not in candidates:
|
||||
candidates.append(default_icon)
|
||||
|
||||
# The source checkout keeps installer assets under packaging/.
|
||||
candidates.append(
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "packaging"
|
||||
/ APPLICATION_ICON_FILENAME
|
||||
)
|
||||
seen: set[Path] = set()
|
||||
for candidate in candidates:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
if resolved.is_file():
|
||||
return resolved
|
||||
return None
|
||||
@@ -1,390 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,212 +0,0 @@
|
||||
"""Secure, conditional download of the official PristonTale EU launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
from typing import Callable
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from . import __version__
|
||||
|
||||
|
||||
OFFICIAL_LAUNCHER_URL = (
|
||||
"https://pristontale.eu/storage/uploads/Pristontale%20EU%20Launcher.exe"
|
||||
)
|
||||
MAX_LAUNCHER_BYTES = 64 * 1024 * 1024
|
||||
READ_SIZE = 256 * 1024
|
||||
USER_AGENT = f"PT-Linux/{__version__} (+https://pristontale.eu/)"
|
||||
|
||||
|
||||
class DownloadError(RuntimeError):
|
||||
"""Raised when the official launcher cannot be safely downloaded."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherDownload:
|
||||
path: str
|
||||
url: str
|
||||
sha256: str
|
||||
size: int
|
||||
etag: str | None
|
||||
last_modified: str | None
|
||||
fetched_at: str
|
||||
changed: bool
|
||||
|
||||
|
||||
def fetch_official_launcher(
|
||||
destination: Path,
|
||||
metadata_path: Path,
|
||||
*,
|
||||
url: str = OFFICIAL_LAUNCHER_URL,
|
||||
timeout_seconds: float = 60.0,
|
||||
progress: Callable[[int, int | None], None] | None = None,
|
||||
) -> LauncherDownload:
|
||||
"""Download the current official launcher and atomically replace the cache."""
|
||||
|
||||
metadata = _read_metadata(metadata_path)
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if destination.is_file() and _looks_like_pe(destination):
|
||||
etag = metadata.get("etag")
|
||||
last_modified = metadata.get("last_modified")
|
||||
if isinstance(etag, str) and etag:
|
||||
headers["If-None-Match"] = etag
|
||||
if isinstance(last_modified, str) and last_modified:
|
||||
headers["If-Modified-Since"] = last_modified
|
||||
|
||||
request = Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
response = urlopen(request, timeout=timeout_seconds)
|
||||
except HTTPError as exc:
|
||||
status = exc.code
|
||||
exc.close()
|
||||
if status == 304 and destination.is_file():
|
||||
return _existing_download(destination, metadata, url)
|
||||
raise DownloadError(f"official launcher request failed: HTTP {status}") from exc
|
||||
except OSError as exc:
|
||||
raise DownloadError(f"official launcher request failed: {exc}") from exc
|
||||
|
||||
with response:
|
||||
length = _content_length(response.headers.get("Content-Length"))
|
||||
if length is not None and length > MAX_LAUNCHER_BYTES:
|
||||
raise DownloadError(
|
||||
f"launcher response is unexpectedly large: {length} bytes"
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.part")
|
||||
digest = hashlib.sha256()
|
||||
written = 0
|
||||
try:
|
||||
with temporary.open("wb") as handle:
|
||||
while True:
|
||||
chunk = response.read(READ_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > MAX_LAUNCHER_BYTES:
|
||||
raise DownloadError(
|
||||
"launcher response exceeded the 64 MiB safety limit"
|
||||
)
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
if progress is not None:
|
||||
progress(written, length)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if length is not None and written != length:
|
||||
raise DownloadError(
|
||||
f"incomplete launcher response: {written}/{length} bytes"
|
||||
)
|
||||
validate_pe_launcher(temporary)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
result = LauncherDownload(
|
||||
path=str(destination),
|
||||
url=url,
|
||||
sha256=digest.hexdigest(),
|
||||
size=written,
|
||||
etag=response.headers.get("ETag"),
|
||||
last_modified=response.headers.get("Last-Modified"),
|
||||
fetched_at=datetime.now().astimezone().isoformat(),
|
||||
changed=True,
|
||||
)
|
||||
_write_json(metadata_path, asdict(result))
|
||||
return result
|
||||
|
||||
|
||||
def validate_pe_launcher(path: Path) -> None:
|
||||
"""Validate the minimum PE structure and require a 64-bit GUI executable."""
|
||||
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
dos = handle.read(64)
|
||||
if len(dos) < 64 or dos[:2] != b"MZ":
|
||||
raise DownloadError("download is not a DOS/PE executable")
|
||||
pe_offset = struct.unpack_from("<I", dos, 0x3C)[0]
|
||||
if pe_offset < 64 or pe_offset > 8 * 1024 * 1024:
|
||||
raise DownloadError("launcher has an invalid PE header offset")
|
||||
handle.seek(pe_offset)
|
||||
pe_header = handle.read(24)
|
||||
if len(pe_header) != 24 or pe_header[:4] != b"PE\x00\x00":
|
||||
raise DownloadError("launcher has no valid PE signature")
|
||||
machine = struct.unpack_from("<H", pe_header, 4)[0]
|
||||
if machine != 0x8664:
|
||||
raise DownloadError(
|
||||
f"launcher has unexpected PE machine type 0x{machine:04x}"
|
||||
)
|
||||
except OSError as exc:
|
||||
raise DownloadError(f"cannot validate launcher: {exc}") from exc
|
||||
|
||||
|
||||
def _looks_like_pe(path: Path) -> bool:
|
||||
try:
|
||||
validate_pe_launcher(path)
|
||||
except DownloadError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _existing_download(
|
||||
path: Path,
|
||||
metadata: dict[str, object],
|
||||
url: str,
|
||||
) -> LauncherDownload:
|
||||
validate_pe_launcher(path)
|
||||
return LauncherDownload(
|
||||
path=str(path),
|
||||
url=url,
|
||||
sha256=_sha256(path),
|
||||
size=path.stat().st_size,
|
||||
etag=_optional_string(metadata.get("etag")),
|
||||
last_modified=_optional_string(metadata.get("last_modified")),
|
||||
fetched_at=datetime.now().astimezone().isoformat(),
|
||||
changed=False,
|
||||
)
|
||||
|
||||
|
||||
def _read_metadata(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
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 _content_length(value: str | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
length = int(value)
|
||||
except ValueError as exc:
|
||||
raise DownloadError(f"invalid Content-Length: {value!r}") from exc
|
||||
if length < 0:
|
||||
raise DownloadError(f"invalid Content-Length: {value!r}")
|
||||
return length
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
@@ -1,165 +0,0 @@
|
||||
"""Command construction for the managed game window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
MIN_GAME_CANVAS_DIMENSION = 320
|
||||
MAX_GAME_CANVAS_DIMENSION = 16384
|
||||
MAX_GAME_CONFIG_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class GameboxConfigurationError(ValueError):
|
||||
"""Raised when stable outer scaling cannot use the game configuration."""
|
||||
|
||||
|
||||
def find_gamescope(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> str | None:
|
||||
"""Find Gamescope using the environment inherited by the game launch."""
|
||||
|
||||
env = os.environ if environment is None else environment
|
||||
return shutil.which("gamescope", path=env.get("PATH"))
|
||||
|
||||
|
||||
def read_game_canvas_size(game_config: Path) -> tuple[int, int]:
|
||||
"""Read the exact fixed render size advertised in ``game.ini``."""
|
||||
|
||||
try:
|
||||
content = game_config.read_bytes()
|
||||
except OSError as exc:
|
||||
raise GameboxConfigurationError(
|
||||
f"could not read game window configuration {game_config}: {exc}"
|
||||
) from exc
|
||||
if len(content) > MAX_GAME_CONFIG_BYTES:
|
||||
raise GameboxConfigurationError(
|
||||
f"game window configuration is unexpectedly large: {game_config}"
|
||||
)
|
||||
if b"\x00" in content:
|
||||
raise GameboxConfigurationError(
|
||||
f"game window configuration uses an unsupported encoding: {game_config}"
|
||||
)
|
||||
try:
|
||||
text = content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as utf8_error:
|
||||
try:
|
||||
text = content.decode("cp1252")
|
||||
except UnicodeDecodeError:
|
||||
raise GameboxConfigurationError(
|
||||
f"game window configuration uses an unsupported encoding: {game_config}"
|
||||
) from utf8_error
|
||||
|
||||
values: dict[str, int] = {}
|
||||
section: str | None = None
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
section = stripped[1:-1].strip().casefold()
|
||||
continue
|
||||
if section != "screen" or not stripped or stripped.startswith((";", "#")):
|
||||
continue
|
||||
left, separator, right = line.partition("=")
|
||||
if not separator:
|
||||
continue
|
||||
key = left.strip().casefold()
|
||||
if key not in {"width", "height"}:
|
||||
continue
|
||||
if key in values:
|
||||
raise GameboxConfigurationError(
|
||||
f"duplicate [Screen] {key.title()} in {game_config}"
|
||||
)
|
||||
try:
|
||||
value = int(right.strip(), 10)
|
||||
except ValueError as exc:
|
||||
raise GameboxConfigurationError(
|
||||
f"invalid [Screen] {key.title()} on line {line_number} of {game_config}"
|
||||
) from exc
|
||||
if value < MIN_GAME_CANVAS_DIMENSION or value > MAX_GAME_CANVAS_DIMENSION:
|
||||
raise GameboxConfigurationError(
|
||||
f"[Screen] {key.title()} must be between "
|
||||
f"{MIN_GAME_CANVAS_DIMENSION} and {MAX_GAME_CANVAS_DIMENSION}"
|
||||
)
|
||||
values[key] = value
|
||||
if set(values) != {"width", "height"}:
|
||||
raise GameboxConfigurationError(
|
||||
f"game window configuration has no complete [Screen] Width/Height pair: "
|
||||
f"{game_config}"
|
||||
)
|
||||
return values["width"], values["height"]
|
||||
|
||||
|
||||
def build_gamebox_command(
|
||||
game_command: Sequence[str],
|
||||
settings: Settings,
|
||||
*,
|
||||
game_size: tuple[int, int] | None = None,
|
||||
gamescope_path: str | None = None,
|
||||
) -> list[str]:
|
||||
if not game_command:
|
||||
raise ValueError("game command cannot be empty")
|
||||
use_gamescope = (
|
||||
settings.allow_window_resizing
|
||||
and gamescope_path is not None
|
||||
and game_size is not None
|
||||
)
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.gamebox_runtime",
|
||||
"--window-title",
|
||||
"PristonTale EU",
|
||||
]
|
||||
if settings.borderless:
|
||||
command.append("--borderless")
|
||||
if use_gamescope:
|
||||
command.append("--gamescope-scaling")
|
||||
if settings.suppress_unresponsive_warnings:
|
||||
command.append("--suppress-wm-ping")
|
||||
command.extend(("--", *[str(value) for value in game_command]))
|
||||
if not use_gamescope:
|
||||
return command
|
||||
|
||||
assert gamescope_path is not None
|
||||
assert game_size is not None
|
||||
width, height = game_size
|
||||
if not (
|
||||
MIN_GAME_CANVAS_DIMENSION <= width <= MAX_GAME_CANVAS_DIMENSION
|
||||
and MIN_GAME_CANVAS_DIMENSION <= height <= MAX_GAME_CANVAS_DIMENSION
|
||||
):
|
||||
raise ValueError("game size is outside the supported Gamescope range")
|
||||
gamescope_command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.gamescope_runtime",
|
||||
"--width",
|
||||
str(width),
|
||||
"--height",
|
||||
str(height),
|
||||
"--",
|
||||
gamescope_path,
|
||||
"-w",
|
||||
str(width),
|
||||
"-h",
|
||||
str(height),
|
||||
"-W",
|
||||
str(width),
|
||||
"-H",
|
||||
str(height),
|
||||
# Gamescope recalculates ``fit`` from each committed game buffer, so an
|
||||
# in-game resolution change keeps its own aspect ratio without cropping.
|
||||
"-S",
|
||||
"fit",
|
||||
"-F",
|
||||
"linear",
|
||||
]
|
||||
if settings.borderless:
|
||||
gamescope_command.append("--borderless")
|
||||
gamescope_command.extend(("--", *command))
|
||||
return gamescope_command
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
"""Report the nested client status to the outer Gamescope supervisor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def _process_exit_status(returncode: int) -> int:
|
||||
if returncode < 0:
|
||||
return 128 + min(-returncode, 127)
|
||||
return min(returncode, 255)
|
||||
|
||||
|
||||
def run_and_record(command: Sequence[str], status_file: Path) -> int:
|
||||
if not command:
|
||||
raise ValueError("nested game command cannot be empty")
|
||||
try:
|
||||
process = subprocess.Popen([str(value) for value in command])
|
||||
except OSError as exc:
|
||||
print(f"Could not start the nested game command: {exc}", file=sys.stderr)
|
||||
returncode = 127
|
||||
else:
|
||||
try:
|
||||
returncode = int(process.wait())
|
||||
except BaseException:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise
|
||||
|
||||
temporary = status_file.with_name(f".{status_file.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(f"{returncode}\n", encoding="ascii")
|
||||
os.replace(temporary, status_file)
|
||||
except OSError as exc:
|
||||
print(f"Could not report the nested game status: {exc}", file=sys.stderr)
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return _process_exit_status(returncode)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--status-file", required=True, type=Path)
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
try:
|
||||
return run_and_record(command, arguments.status_file)
|
||||
except ValueError as exc:
|
||||
print(f"Nested game launch failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,948 +0,0 @@
|
||||
"""Launch Gamescope with low-overhead host-window resize behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from .gamebox_runtime import (
|
||||
GAMESCOPE_HOST_FOCUS_STATE_ENV,
|
||||
GameboxRuntimeError,
|
||||
_X11,
|
||||
_X_WM_STATE_ICONIC,
|
||||
_X_WM_STATE_NORMAL,
|
||||
_sample_window_diagnostic_details,
|
||||
_wm_state_name,
|
||||
)
|
||||
from .window_diagnostics import WindowDiagnosticRecorder
|
||||
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 10.0
|
||||
WINDOW_DISCOVERY_POLL_SECONDS = 0.05
|
||||
RESIZE_STEPS_PER_GAME_CANVAS = 20
|
||||
MIN_RESIZE_INCREMENT = 8
|
||||
MAX_RESIZE_INCREMENT = 96
|
||||
CHILD_STATUS_POLL_SECONDS = 0.05
|
||||
HOST_FOCUS_HEARTBEAT_SECONDS = 0.2
|
||||
HOST_REICONIFY_INTERVAL_SECONDS = 0.2
|
||||
GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0
|
||||
WAYLAND_BACKING_FIX_VERSION = (3, 16, 28)
|
||||
BUNDLED_GAMESCOPE_VERSION = (3, 16, 20)
|
||||
BUNDLED_GAMESCOPE_FILENAME = "gamescope-3.16.20-ubuntu26.04-amd64"
|
||||
BUNDLED_GAMESCOPE_SHA256 = (
|
||||
"3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639"
|
||||
)
|
||||
BUNDLED_GAMESCOPE_OS_ID = "ubuntu"
|
||||
BUNDLED_GAMESCOPE_OS_VERSION = "26.04"
|
||||
KEEP_NESTED_WINDOWS_VISIBLE_ENV = "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE"
|
||||
HASH_READ_SIZE = 256 * 1024
|
||||
_GAMESCOPE_VERSION_PATTERN = re.compile(
|
||||
r"\bgamescope\s+version\s+(\d+)\.(\d+)\.(\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class GamescopeRuntimeError(RuntimeError):
|
||||
"""Raised when the Gamescope process cannot be started."""
|
||||
|
||||
|
||||
class _HostFocusPublisher:
|
||||
"""Publish the host WM's focus decision to the nested game supervisor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_descriptor: int,
|
||||
path: Path,
|
||||
diagnostics: WindowDiagnosticRecorder | None = None,
|
||||
) -> None:
|
||||
self.file_descriptor = file_descriptor
|
||||
self.path = path
|
||||
self._diagnostics = diagnostics
|
||||
self._last_active: bool | None = None
|
||||
self._last_published_at = 0.0
|
||||
self._last_reiconify_at = 0.0
|
||||
self._seen_normal_state = False
|
||||
self._minimized_latched = False
|
||||
self._updates = 0
|
||||
self._monitor_errors = 0
|
||||
self._iconic_observations = 0
|
||||
self._inactive_remaps = 0
|
||||
self._hidden_observations = 0
|
||||
self._hidden_state_clears = 0
|
||||
self._reiconify_attempts = 0
|
||||
self._reiconify_accepted = 0
|
||||
self._last_state: int | None = None
|
||||
self._last_hidden: bool | None = None
|
||||
self._last_diagnostic_state: tuple[object, ...] | None = None
|
||||
self._publish(False, force=True)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
diagnostics: WindowDiagnosticRecorder | None = None,
|
||||
) -> _HostFocusPublisher:
|
||||
runtime_directory = os.environ.get("XDG_RUNTIME_DIR")
|
||||
try:
|
||||
file_descriptor, path = tempfile.mkstemp(
|
||||
prefix="ptlinux-gamescope-focus-",
|
||||
dir=runtime_directory,
|
||||
)
|
||||
except OSError:
|
||||
file_descriptor, path = tempfile.mkstemp(
|
||||
prefix="ptlinux-gamescope-focus-",
|
||||
)
|
||||
try:
|
||||
return cls(file_descriptor, Path(path), diagnostics)
|
||||
except BaseException:
|
||||
os.close(file_descriptor)
|
||||
Path(path).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def update(self, mapper: _X11, window: int) -> None:
|
||||
now = time.monotonic()
|
||||
self._updates += 1
|
||||
try:
|
||||
state = mapper.window_state(window)
|
||||
active = state == _X_WM_STATE_NORMAL and mapper.window_is_active(window)
|
||||
except Exception as exc:
|
||||
state = None
|
||||
active = False
|
||||
self._monitor_errors += 1
|
||||
if self._diagnostics is not None:
|
||||
self._diagnostics.transition(
|
||||
"host_monitor_error",
|
||||
type(exc).__name__,
|
||||
"host_monitor_error",
|
||||
window_id=window,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
net_wm_state: dict[str, bool | None] = {}
|
||||
if self._diagnostics is not None:
|
||||
try:
|
||||
net_wm_state = mapper.window_net_wm_state_flags(window)
|
||||
except Exception as exc:
|
||||
self._monitor_errors += 1
|
||||
self._diagnostics.transition(
|
||||
"host_ewmh_monitor_error",
|
||||
type(exc).__name__,
|
||||
"host_ewmh_monitor_error",
|
||||
window_id=window,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
hidden = net_wm_state.get("hidden")
|
||||
if hidden is True:
|
||||
self._hidden_observations += 1
|
||||
if self._last_hidden is True and hidden is False and not active:
|
||||
self._hidden_state_clears += 1
|
||||
if self._diagnostics is not None:
|
||||
self._diagnostics.record(
|
||||
"host_hidden_state_cleared",
|
||||
window_id=window,
|
||||
observed_active=active,
|
||||
minimized_latched=self._minimized_latched,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
self._last_hidden = hidden
|
||||
self._last_state = state
|
||||
if state == _X_WM_STATE_ICONIC and self._seen_normal_state:
|
||||
self._iconic_observations += 1
|
||||
if not self._minimized_latched:
|
||||
self._minimized_latched = True
|
||||
if self._diagnostics is not None:
|
||||
self._diagnostics.record(
|
||||
"host_minimize_latched",
|
||||
window_id=window,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
elif state == _X_WM_STATE_NORMAL:
|
||||
self._seen_normal_state = True
|
||||
if self._minimized_latched:
|
||||
if active:
|
||||
# A normal, active window was explicitly restored by the
|
||||
# desktop. Resume nested focus repair for it.
|
||||
self._minimized_latched = False
|
||||
if self._diagnostics is not None:
|
||||
self._diagnostics.record(
|
||||
"host_restore_observed",
|
||||
window_id=window,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
elif now - self._last_reiconify_at >= HOST_REICONIFY_INTERVAL_SECONDS:
|
||||
# Gamescope's SDL visibility handoff uses XMapRaised. If it
|
||||
# remaps an inactive window after a user minimize, preserve
|
||||
# the user's state instead of letting it cover other apps.
|
||||
self._inactive_remaps += 1
|
||||
self._reiconify_attempts += 1
|
||||
error_type: str | None = None
|
||||
try:
|
||||
accepted = mapper.minimize_window(window)
|
||||
except Exception as exc:
|
||||
accepted = False
|
||||
error_type = type(exc).__name__
|
||||
if accepted:
|
||||
self._reiconify_accepted += 1
|
||||
self._last_reiconify_at = now
|
||||
active = False
|
||||
if self._diagnostics is not None:
|
||||
details: dict[str, object] = {
|
||||
"window_id": window,
|
||||
"observed_state": "normal",
|
||||
"active": False,
|
||||
"minimize_request_accepted": accepted,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
}
|
||||
if error_type is not None:
|
||||
details["error_type"] = error_type
|
||||
self._diagnostics.record(
|
||||
"host_inactive_remap",
|
||||
**details,
|
||||
)
|
||||
diagnostic_state = (
|
||||
state,
|
||||
active,
|
||||
self._minimized_latched,
|
||||
hidden,
|
||||
net_wm_state.get("above"),
|
||||
net_wm_state.get("demands_attention"),
|
||||
net_wm_state.get("maximized_horizontal"),
|
||||
net_wm_state.get("maximized_vertical"),
|
||||
)
|
||||
if (
|
||||
self._diagnostics is not None
|
||||
and diagnostic_state != self._last_diagnostic_state
|
||||
):
|
||||
self._last_diagnostic_state = diagnostic_state
|
||||
self._diagnostics.record(
|
||||
"host_state_changed",
|
||||
window_id=window,
|
||||
observed_wm_state=_wm_state_name(state),
|
||||
observed_active=active,
|
||||
minimized_latched=self._minimized_latched,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
self._publish(active, now=now)
|
||||
|
||||
def summary_details(self) -> dict[str, object]:
|
||||
return {
|
||||
"focus_updates": self._updates,
|
||||
"monitor_errors": self._monitor_errors,
|
||||
"iconic_observations": self._iconic_observations,
|
||||
"inactive_remaps": self._inactive_remaps,
|
||||
"hidden_observations": self._hidden_observations,
|
||||
"hidden_state_clears": self._hidden_state_clears,
|
||||
"reiconify_attempts": self._reiconify_attempts,
|
||||
"reiconify_accepted": self._reiconify_accepted,
|
||||
"seen_normal_state": self._seen_normal_state,
|
||||
"minimized_latched": self._minimized_latched,
|
||||
"last_wm_state": _wm_state_name(self._last_state),
|
||||
"last_active": self._last_active,
|
||||
}
|
||||
|
||||
def _publish(
|
||||
self,
|
||||
active: bool,
|
||||
*,
|
||||
force: bool = False,
|
||||
now: float | None = None,
|
||||
) -> None:
|
||||
published_at = time.monotonic() if now is None else now
|
||||
if (
|
||||
not force
|
||||
and active == self._last_active
|
||||
and published_at - self._last_published_at < HOST_FOCUS_HEARTBEAT_SECONDS
|
||||
):
|
||||
return
|
||||
payload = f"{int(active)} {published_at:.9f}\n".encode("ascii")
|
||||
try:
|
||||
os.lseek(self.file_descriptor, 0, os.SEEK_SET)
|
||||
# Truncating first makes an interrupted update fail closed in the
|
||||
# nested reader instead of leaving a stale active value behind.
|
||||
os.ftruncate(self.file_descriptor, 0)
|
||||
os.write(self.file_descriptor, payload)
|
||||
except OSError:
|
||||
self._last_active = None
|
||||
return
|
||||
self._last_active = active
|
||||
self._last_published_at = published_at
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
os.close(self.file_descriptor)
|
||||
finally:
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def resize_increments(width: int, height: int) -> tuple[int, int]:
|
||||
"""Return coarse-enough size steps to avoid a swapchain rebuild storm."""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("game canvas dimensions must be positive")
|
||||
return (
|
||||
min(
|
||||
MAX_RESIZE_INCREMENT,
|
||||
max(MIN_RESIZE_INCREMENT, width // RESIZE_STEPS_PER_GAME_CANVAS),
|
||||
),
|
||||
min(
|
||||
MAX_RESIZE_INCREMENT,
|
||||
max(MIN_RESIZE_INCREMENT, height // RESIZE_STEPS_PER_GAME_CANVAS),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _supervised_command(
|
||||
command: Sequence[str],
|
||||
status_file: Path,
|
||||
backend: str,
|
||||
*,
|
||||
force_composition: bool = False,
|
||||
) -> list[str]:
|
||||
"""Select a presentation backend and wrap the primary child."""
|
||||
|
||||
if not command:
|
||||
raise ValueError("Gamescope command cannot be empty")
|
||||
if backend not in {"sdl", "wayland"}:
|
||||
raise ValueError(f"unsupported Gamescope backend: {backend}")
|
||||
result = [
|
||||
str(command[0]),
|
||||
"--backend",
|
||||
backend,
|
||||
"--keep-alive",
|
||||
]
|
||||
if backend == "sdl" or force_composition:
|
||||
result.append("--force-composition")
|
||||
result.extend(map(str, command[1:]))
|
||||
try:
|
||||
separator = result.index("--")
|
||||
except ValueError as exc:
|
||||
raise ValueError("Gamescope command has no nested command separator") from exc
|
||||
if separator == len(result) - 1:
|
||||
raise ValueError("Gamescope command has no nested command")
|
||||
result[separator + 1:separator + 1] = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.gamescope_child_runtime",
|
||||
"--status-file",
|
||||
str(status_file),
|
||||
"--",
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _read_gamescope_version(executable: str) -> tuple[int, int, int] | None:
|
||||
"""Read Gamescope's release tuple without delaying a normal launch."""
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[executable, "--version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=GAMESCOPE_VERSION_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
match = _GAMESCOPE_VERSION_PATTERN.search(completed.stdout)
|
||||
if match is None:
|
||||
return None
|
||||
major, minor, patch = match.groups()
|
||||
return int(major), int(minor), int(patch)
|
||||
|
||||
|
||||
def _bundled_gamescope_path() -> Path:
|
||||
return Path(__file__).resolve().parent / "native" / BUNDLED_GAMESCOPE_FILENAME
|
||||
|
||||
|
||||
def _bundled_gamescope_host_supported() -> bool:
|
||||
"""Limit the distribution-specific executable to its build target."""
|
||||
|
||||
if sys.platform != "linux" or platform.machine().casefold() not in {
|
||||
"amd64",
|
||||
"x86_64",
|
||||
}:
|
||||
return False
|
||||
try:
|
||||
release = platform.freedesktop_os_release()
|
||||
except OSError:
|
||||
return False
|
||||
return (
|
||||
release.get("ID", "").casefold() == BUNDLED_GAMESCOPE_OS_ID
|
||||
and release.get("VERSION_ID") == BUNDLED_GAMESCOPE_OS_VERSION
|
||||
)
|
||||
|
||||
|
||||
def _bundled_gamescope_is_usable() -> bool:
|
||||
"""Verify the launcher-local executable and restore its safe archive mode."""
|
||||
|
||||
if not _bundled_gamescope_host_supported():
|
||||
return False
|
||||
path = _bundled_gamescope_path()
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return False
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(HASH_READ_SIZE), b""):
|
||||
digest.update(chunk)
|
||||
except OSError:
|
||||
return False
|
||||
if digest.hexdigest() != BUNDLED_GAMESCOPE_SHA256:
|
||||
return False
|
||||
if not os.access(path, os.X_OK):
|
||||
try:
|
||||
path.chmod(0o755)
|
||||
except OSError:
|
||||
return False
|
||||
return os.access(path, os.X_OK)
|
||||
|
||||
|
||||
def _is_kde_session(environment: Mapping[str, str]) -> bool:
|
||||
"""Recognize Plasma sessions without relying on an Ubuntu flavor name."""
|
||||
|
||||
if environment.get("KDE_FULL_SESSION", "").strip().casefold() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}:
|
||||
return True
|
||||
if environment.get("KDE_SESSION_VERSION", "").strip():
|
||||
return True
|
||||
for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
|
||||
tokens = re.split(
|
||||
r"[^a-z0-9]+",
|
||||
environment.get(key, "").strip().casefold(),
|
||||
)
|
||||
if "kde" in tokens or any(token.startswith("plasma") for token in tokens):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_wayland_gamescope(
|
||||
executable: str,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> tuple[str, bool, bool]:
|
||||
"""Choose a direct-safe Gamescope or request the stable composed path."""
|
||||
|
||||
version = _read_gamescope_version(executable)
|
||||
env = os.environ if environment is None else environment
|
||||
if _is_kde_session(env):
|
||||
# This is reached only when Plasma has no usable XWayland display. Keep
|
||||
# native Wayland fully composed because newer Gamescope builds still have
|
||||
# KWin-specific presentation failures outside our backing-surface fixes.
|
||||
return executable, True, False
|
||||
if version is not None and version >= WAYLAND_BACKING_FIX_VERSION:
|
||||
return executable, False, False
|
||||
if version == BUNDLED_GAMESCOPE_VERSION and _bundled_gamescope_is_usable():
|
||||
return str(_bundled_gamescope_path()), False, True
|
||||
return executable, True, False
|
||||
|
||||
|
||||
def _wayland_socket_available(environment: Mapping[str, str]) -> bool:
|
||||
"""Return whether the launch environment names an accessible Wayland socket."""
|
||||
|
||||
display = environment.get("WAYLAND_DISPLAY", "").strip()
|
||||
if not display:
|
||||
return False
|
||||
display_path = Path(display)
|
||||
if not display_path.is_absolute():
|
||||
runtime_directory = environment.get("XDG_RUNTIME_DIR", "").strip()
|
||||
if not runtime_directory:
|
||||
return False
|
||||
display_path = Path(runtime_directory) / display_path
|
||||
try:
|
||||
return display_path.is_socket()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _select_host_backend(environment: Mapping[str, str]) -> str:
|
||||
"""Choose the host backend with a Plasma-safe XWayland fallback."""
|
||||
|
||||
if _wayland_socket_available(environment):
|
||||
# Gamescope's native Wayland backend still has KWin-specific presentation,
|
||||
# fullscreen, and fractional-scaling failures. Plasma normally exposes an
|
||||
# XWayland display, so keep the resizable host window on the mature SDL/X11
|
||||
# path while leaving non-Plasma Wayland sessions on direct presentation.
|
||||
if _is_kde_session(environment) and environment.get("DISPLAY", "").strip():
|
||||
return "sdl"
|
||||
return "wayland"
|
||||
return "sdl"
|
||||
|
||||
|
||||
def _create_child_status_file() -> Path:
|
||||
"""Create a private rendezvous file inherited by the nested child command."""
|
||||
|
||||
runtime_directory = os.environ.get("XDG_RUNTIME_DIR")
|
||||
try:
|
||||
file_descriptor, path = tempfile.mkstemp(
|
||||
prefix="ptlinux-gamescope-status-",
|
||||
dir=runtime_directory,
|
||||
)
|
||||
except OSError:
|
||||
file_descriptor, path = tempfile.mkstemp(prefix="ptlinux-gamescope-status-")
|
||||
os.close(file_descriptor)
|
||||
return Path(path)
|
||||
|
||||
|
||||
def _read_child_status(status_file: Path) -> int | None:
|
||||
try:
|
||||
value = status_file.read_text(encoding="ascii").strip()
|
||||
except (FileNotFoundError, OSError, UnicodeError):
|
||||
return None
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
returncode = int(value, 10)
|
||||
except ValueError:
|
||||
return None
|
||||
if returncode < -255 or returncode > 255:
|
||||
return None
|
||||
return returncode
|
||||
|
||||
|
||||
def _process_exit_status(returncode: int) -> int:
|
||||
if returncode < 0:
|
||||
return 128 + min(-returncode, 127)
|
||||
return min(returncode, 255)
|
||||
|
||||
|
||||
def _wait_for_optimized_gamescope(
|
||||
process: subprocess.Popen[bytes],
|
||||
status_file: Path | None,
|
||||
*,
|
||||
host_focus_publisher: _HostFocusPublisher | None = None,
|
||||
host_mapper: _X11 | None = None,
|
||||
host_window: int | None = None,
|
||||
) -> int:
|
||||
"""Monitor host focus and preserve the nested child's real exit status."""
|
||||
|
||||
while True:
|
||||
if host_focus_publisher is not None and host_mapper is not None:
|
||||
if host_window is None:
|
||||
try:
|
||||
host_window = host_mapper.find_window_by_pid(process.pid)
|
||||
except Exception:
|
||||
host_window = None
|
||||
if host_window is not None:
|
||||
host_focus_publisher.update(host_mapper, host_window)
|
||||
child_status = (
|
||||
_read_child_status(status_file) if status_file is not None else None
|
||||
)
|
||||
if child_status is not None:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
return _process_exit_status(child_status)
|
||||
gamescope_status = process.poll()
|
||||
if gamescope_status is not None:
|
||||
child_status = (
|
||||
_read_child_status(status_file) if status_file is not None else None
|
||||
)
|
||||
return _process_exit_status(
|
||||
gamescope_status if child_status is None else child_status
|
||||
)
|
||||
time.sleep(CHILD_STATUS_POLL_SECONDS)
|
||||
|
||||
|
||||
def _open_host_x11() -> _X11 | None:
|
||||
"""Open the host X11 connection used by Gamescope's SDL window."""
|
||||
|
||||
if not os.environ.get("DISPLAY"):
|
||||
return None
|
||||
try:
|
||||
return _X11()
|
||||
except GameboxRuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def _tune_process_window(
|
||||
mapper: _X11,
|
||||
process: subprocess.Popen[bytes],
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
host_focus_publisher: _HostFocusPublisher | None = None,
|
||||
diagnostics: WindowDiagnosticRecorder | None = None,
|
||||
) -> tuple[int, int] | None:
|
||||
"""Stabilize resizing once the SDL host window becomes available."""
|
||||
|
||||
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
|
||||
reported_window: int | None = None
|
||||
last_configuration: tuple[bool, bool, bool] | None = None
|
||||
while process.poll() is None and time.monotonic() < deadline:
|
||||
window = mapper.find_window_by_pid(process.pid)
|
||||
if window is not None:
|
||||
if diagnostics is not None and window != reported_window:
|
||||
reported_window = window
|
||||
diagnostics.record(
|
||||
"host_window_discovered",
|
||||
window_id=window,
|
||||
**_sample_window_diagnostic_details(
|
||||
diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
if host_focus_publisher is not None:
|
||||
host_focus_publisher.update(mapper, window)
|
||||
increments = resize_increments(width, height)
|
||||
increments_applied = mapper.set_resize_increments(
|
||||
window,
|
||||
(width, height),
|
||||
increments,
|
||||
)
|
||||
# Apply every client-owned hint first, then wait for the window
|
||||
# manager to publish resize and maximize actions from those hints.
|
||||
scaling_enabled = mapper.enable_window_scaling(window)
|
||||
if host_focus_publisher is not None:
|
||||
host_focus_publisher.update(mapper, window)
|
||||
compositing_preferred = mapper.prefer_composited_presentation(window)
|
||||
configuration = (
|
||||
increments_applied,
|
||||
scaling_enabled,
|
||||
compositing_preferred,
|
||||
)
|
||||
if diagnostics is not None and configuration != last_configuration:
|
||||
last_configuration = configuration
|
||||
diagnostics.record(
|
||||
"host_scaling_configured",
|
||||
window_id=window,
|
||||
resize_increments_applied=increments_applied,
|
||||
scaling_enabled=scaling_enabled,
|
||||
compositing_preferred=compositing_preferred,
|
||||
width_increment=increments[0],
|
||||
height_increment=increments[1],
|
||||
**_sample_window_diagnostic_details(
|
||||
diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
if scaling_enabled and increments_applied and compositing_preferred:
|
||||
return increments
|
||||
time.sleep(WINDOW_DISCOVERY_POLL_SECONDS)
|
||||
return None
|
||||
|
||||
|
||||
def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
"""Run Gamescope with stable host presentation for the current session."""
|
||||
|
||||
if not command:
|
||||
raise GamescopeRuntimeError("Gamescope command cannot be empty")
|
||||
resize_increments(width, height)
|
||||
diagnostics = WindowDiagnosticRecorder("gamescope_host")
|
||||
environment = os.environ.copy()
|
||||
backend = _select_host_backend(environment)
|
||||
kde_session = _is_kde_session(environment)
|
||||
kde_wayland_xwayland_fallback = (
|
||||
backend == "sdl"
|
||||
and kde_session
|
||||
and bool(environment.get("WAYLAND_DISPLAY", "").strip())
|
||||
and bool(environment.get("DISPLAY", "").strip())
|
||||
)
|
||||
selected_command = list(map(str, command))
|
||||
force_composition = backend == "sdl"
|
||||
using_bundled_gamescope = False
|
||||
if backend == "wayland":
|
||||
(
|
||||
selected_command[0],
|
||||
force_composition,
|
||||
using_bundled_gamescope,
|
||||
) = _select_wayland_gamescope(
|
||||
selected_command[0],
|
||||
environment=environment,
|
||||
)
|
||||
mapper = _open_host_x11() if backend == "sdl" else None
|
||||
diagnostics.record(
|
||||
"runtime_configured",
|
||||
backend=backend,
|
||||
force_composition=force_composition,
|
||||
using_bundled_gamescope=using_bundled_gamescope,
|
||||
display_available=bool(environment.get("DISPLAY")),
|
||||
wayland_display_available=bool(environment.get("WAYLAND_DISPLAY")),
|
||||
host_x11_monitor_available=mapper is not None,
|
||||
kde_session=kde_session,
|
||||
kde_wayland_compatibility_mode=(
|
||||
kde_wayland_xwayland_fallback
|
||||
or (backend == "wayland" and kde_session and force_composition)
|
||||
),
|
||||
kde_wayland_xwayland_fallback=kde_wayland_xwayland_fallback,
|
||||
backend_reason=(
|
||||
"kde_wayland_xwayland_compatibility"
|
||||
if kde_wayland_xwayland_fallback
|
||||
else "wayland_socket"
|
||||
if backend == "wayland"
|
||||
else "x11_or_unavailable_wayland"
|
||||
),
|
||||
presentation_mode=(
|
||||
f"{backend}_{'composed' if force_composition else 'direct'}"
|
||||
),
|
||||
canvas_width=width,
|
||||
canvas_height=height,
|
||||
)
|
||||
diagnostic_sink = diagnostics if diagnostics.enabled else None
|
||||
status_file: Path | None = None
|
||||
host_focus_publisher: _HostFocusPublisher | None = None
|
||||
try:
|
||||
candidate_status_file = _create_child_status_file()
|
||||
try:
|
||||
launch_command = _supervised_command(
|
||||
selected_command,
|
||||
candidate_status_file,
|
||||
backend,
|
||||
force_composition=force_composition,
|
||||
)
|
||||
except ValueError:
|
||||
candidate_status_file.unlink(missing_ok=True)
|
||||
if mapper is not None:
|
||||
mapper.close()
|
||||
diagnostics.close(process_started=False, backend=backend)
|
||||
raise
|
||||
status_file = candidate_status_file
|
||||
except OSError as exc:
|
||||
diagnostics.record(
|
||||
"child_supervision_unavailable",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
launch_command = [
|
||||
selected_command[0],
|
||||
"--backend",
|
||||
backend,
|
||||
]
|
||||
if force_composition:
|
||||
launch_command.append("--force-composition")
|
||||
launch_command.extend(selected_command[1:])
|
||||
print(
|
||||
f"Warning: Gamescope child-status supervision is unavailable: {exc}; "
|
||||
"continuing with stable presentation.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if backend == "sdl" and environment.get("DISPLAY"):
|
||||
environment["SDL_VIDEODRIVER"] = "x11"
|
||||
# Keep Mutter on one composited presentation path at every window size.
|
||||
# SDL otherwise requests compositor bypass, which can switch near
|
||||
# screen-filling sizes and visibly flicker while Gamescope is scaling.
|
||||
environment["SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"] = "0"
|
||||
# If host X11 monitoring cannot start, make the nested focus repair
|
||||
# fail closed. An absent bridge retains legacy behavior for direct
|
||||
# gamebox invocations and native Wayland Gamescope only.
|
||||
environment[GAMESCOPE_HOST_FOCUS_STATE_ENV] = "unavailable"
|
||||
if mapper is not None:
|
||||
try:
|
||||
publisher = (
|
||||
_HostFocusPublisher.create()
|
||||
if diagnostic_sink is None
|
||||
else _HostFocusPublisher.create(diagnostics=diagnostic_sink)
|
||||
)
|
||||
except OSError as exc:
|
||||
diagnostics.record(
|
||||
"host_focus_monitor_unavailable",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
print(
|
||||
f"Warning: Gamescope host-focus monitoring is unavailable: "
|
||||
f"{exc}; nested focus repair will remain disabled.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
host_focus_publisher = publisher
|
||||
environment[GAMESCOPE_HOST_FOCUS_STATE_ENV] = str(publisher.path)
|
||||
if using_bundled_gamescope:
|
||||
# The game asks its X11 WM to iconify the first client while entering
|
||||
# the login screen. Our scoped Gamescope patch keeps that nested surface
|
||||
# visible without activating or raising the host window.
|
||||
environment[KEEP_NESTED_WINDOWS_VISIBLE_ENV] = "1"
|
||||
try:
|
||||
process = subprocess.Popen(launch_command, env=environment)
|
||||
except OSError as exc:
|
||||
diagnostics.record(
|
||||
"process_start_failed",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
if mapper is not None:
|
||||
mapper.close()
|
||||
if host_focus_publisher is not None:
|
||||
host_focus_publisher.close()
|
||||
if status_file is not None:
|
||||
status_file.unlink(missing_ok=True)
|
||||
diagnostics.close(process_started=False, backend=backend)
|
||||
raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc
|
||||
|
||||
if kde_wayland_xwayland_fallback:
|
||||
print(
|
||||
"KDE Wayland compatibility mode active: Gamescope is using its "
|
||||
"SDL host window through XWayland to avoid KWin presentation, "
|
||||
"fullscreen, and fractional-scaling failures.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if backend == "wayland" and not force_composition:
|
||||
message = (
|
||||
"Gamescope resize optimization active: native Wayland direct "
|
||||
"presentation keeps live host-window changes off the game surface."
|
||||
)
|
||||
if using_bundled_gamescope:
|
||||
message += (
|
||||
" Launcher-local lazy-output, live-resize, letterbox backing, "
|
||||
"decoration-input, and login-window visibility fixes are active."
|
||||
)
|
||||
print(message, file=sys.stderr, flush=True)
|
||||
elif backend == "wayland" and kde_session:
|
||||
print(
|
||||
"KDE Wayland compatibility mode active: Gamescope is using its "
|
||||
"composed native fallback because no XWayland display is available.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif backend == "wayland":
|
||||
print(
|
||||
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
|
||||
"backing. Forced composition prevents flicker, but live resizing may "
|
||||
"pause; install Gamescope 3.16.28 or newer for direct presentation.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif mapper is not None:
|
||||
try:
|
||||
if diagnostic_sink is None:
|
||||
increments = _tune_process_window(
|
||||
mapper,
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
)
|
||||
else:
|
||||
increments = _tune_process_window(
|
||||
mapper,
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
diagnostics=diagnostic_sink,
|
||||
)
|
||||
except Exception as exc:
|
||||
increments = None
|
||||
diagnostics.record(
|
||||
"host_scaling_configuration_failed",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
print(
|
||||
f"Warning: Gamescope resize tuning failed: {exc}; "
|
||||
"continuing normally.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if increments is not None:
|
||||
print(
|
||||
"Gamescope resize optimization active: the host window remains "
|
||||
"composited and size changes use "
|
||||
f"{increments[0]}x{increments[1]} pixel steps.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif process.poll() is None:
|
||||
diagnostics.record("host_scaling_unavailable")
|
||||
print(
|
||||
"Warning: Gamescope's host window could not be resize-tuned; "
|
||||
"continuing normally.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
diagnostics.record("host_x11_monitor_unavailable")
|
||||
print(
|
||||
"Gamescope is using its SDL presentation fallback; host X11 resize "
|
||||
"tuning is unavailable.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
return _wait_for_optimized_gamescope(
|
||||
process,
|
||||
status_file,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
host_mapper=mapper,
|
||||
)
|
||||
except BaseException:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise
|
||||
finally:
|
||||
host_summary = (
|
||||
host_focus_publisher.summary_details()
|
||||
if host_focus_publisher is not None
|
||||
else {}
|
||||
)
|
||||
diagnostics.close(
|
||||
process_started=True,
|
||||
backend=backend,
|
||||
host_focus_monitor_available=host_focus_publisher is not None,
|
||||
**host_summary,
|
||||
)
|
||||
if mapper is not None:
|
||||
mapper.close()
|
||||
if host_focus_publisher is not None:
|
||||
host_focus_publisher.close()
|
||||
if status_file is not None:
|
||||
status_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--width", required=True, type=int)
|
||||
parser.add_argument("--height", required=True, type=int)
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
try:
|
||||
return run_gamescope(command, arguments.width, arguments.height)
|
||||
except (GamescopeRuntimeError, ValueError) as exc:
|
||||
print(f"Gamescope launch failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,393 +0,0 @@
|
||||
"""Privacy-safe checks for the Windows identity inputs used by the game client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .command import CommandRunner
|
||||
from .paths import AppPaths
|
||||
from .runtime import RuntimeSelection, run_runtime_prefix_command
|
||||
|
||||
|
||||
IDENTITY_PROBE_SHA256 = "677cb5aa19f8dac65b982462bb30974feed05399fdd759765c3b6e802d4c838b"
|
||||
IDENTITY_PROBE_TIMEOUT_SECONDS = 45.0
|
||||
IDENTITY_PROBE_PRIVACY = (
|
||||
"Only success flags, row counts, value lengths, VARIANT types, adapter type, "
|
||||
"and generic-value quality flags are collected. Computer names, serial numbers, "
|
||||
"MAC addresses, and WMI values never leave the Windows helper."
|
||||
)
|
||||
WMI_PROPERTY_NAMES = (
|
||||
"processor_id",
|
||||
"processor_unique_id",
|
||||
"processor_manufacturer",
|
||||
"processor_max_clock_speed",
|
||||
"bios_release_date",
|
||||
"bios_serial_number",
|
||||
"bios_identification_code",
|
||||
"bios_smbios_version",
|
||||
"bios_manufacturer",
|
||||
"baseboard_serial_number",
|
||||
"baseboard_name",
|
||||
"baseboard_manufacturer",
|
||||
"baseboard_model",
|
||||
"video_name",
|
||||
"video_driver_version",
|
||||
)
|
||||
HRESULT_PATTERN = re.compile(r"0x[0-9A-F]{8}\Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityProbeResult:
|
||||
helper: str
|
||||
helper_sha256: str | None
|
||||
helper_verified: bool
|
||||
ran: bool
|
||||
ready: bool | None
|
||||
error: str | None
|
||||
report: dict[str, object] | None
|
||||
observations: tuple[str, ...]
|
||||
warnings: tuple[str, ...]
|
||||
|
||||
|
||||
def packaged_identity_probe() -> Path:
|
||||
return Path(__file__).resolve().parent / "native" / "pt-identity-probe.exe"
|
||||
|
||||
|
||||
def probe_client_identity(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
timeout_seconds: float = IDENTITY_PROBE_TIMEOUT_SECONDS,
|
||||
) -> IdentityProbeResult:
|
||||
"""Check identity API availability without returning the underlying values."""
|
||||
|
||||
helper = packaged_identity_probe()
|
||||
helper_sha256 = _sha256_if_file(helper)
|
||||
helper_verified = helper_sha256 == IDENTITY_PROBE_SHA256
|
||||
if not helper_verified:
|
||||
error = (
|
||||
"the packaged privacy-safe identity helper is missing"
|
||||
if helper_sha256 is None
|
||||
else "the packaged privacy-safe identity helper failed verification"
|
||||
)
|
||||
return IdentityProbeResult(
|
||||
helper=str(helper),
|
||||
helper_sha256=helper_sha256,
|
||||
helper_verified=False,
|
||||
ran=False,
|
||||
ready=None,
|
||||
error=error,
|
||||
report=None,
|
||||
observations=(),
|
||||
warnings=(error,),
|
||||
)
|
||||
try:
|
||||
outcome = run_runtime_prefix_command(
|
||||
paths,
|
||||
selection,
|
||||
runner,
|
||||
[str(helper)],
|
||||
check=False,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
error = f"the client-identity helper could not start: {exc}"
|
||||
return IdentityProbeResult(
|
||||
helper=str(helper),
|
||||
helper_sha256=helper_sha256,
|
||||
helper_verified=True,
|
||||
ran=False,
|
||||
ready=None,
|
||||
error=error,
|
||||
report=None,
|
||||
observations=(),
|
||||
warnings=(error,),
|
||||
)
|
||||
if outcome.timed_out:
|
||||
error = "the client-identity helper timed out"
|
||||
return _failed_result(helper, helper_sha256, error, ran=True)
|
||||
if outcome.returncode != 0:
|
||||
status = "unknown" if outcome.returncode is None else str(outcome.returncode)
|
||||
error = f"the client-identity helper exited with status {status}"
|
||||
return _failed_result(helper, helper_sha256, error, ran=True)
|
||||
try:
|
||||
report = _parse_privacy_safe_report(outcome.stdout)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
error = f"the client-identity helper returned invalid data: {exc}"
|
||||
return _failed_result(helper, helper_sha256, error, ran=True)
|
||||
warnings = _identity_warnings(report)
|
||||
return IdentityProbeResult(
|
||||
helper=str(helper),
|
||||
helper_sha256=helper_sha256,
|
||||
helper_verified=True,
|
||||
ran=True,
|
||||
ready=not warnings,
|
||||
error=None,
|
||||
report=report,
|
||||
observations=_identity_observations(report),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _failed_result(
|
||||
helper: Path,
|
||||
helper_sha256: str | None,
|
||||
error: str,
|
||||
*,
|
||||
ran: bool,
|
||||
) -> IdentityProbeResult:
|
||||
return IdentityProbeResult(
|
||||
helper=str(helper),
|
||||
helper_sha256=helper_sha256,
|
||||
helper_verified=True,
|
||||
ran=ran,
|
||||
ready=None,
|
||||
error=error,
|
||||
report=None,
|
||||
observations=(),
|
||||
warnings=(error,),
|
||||
)
|
||||
|
||||
|
||||
def _parse_privacy_safe_report(stdout: str) -> dict[str, object]:
|
||||
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
raise ValueError("empty output")
|
||||
value = json.loads(lines[-1])
|
||||
root = _object(value, "root")
|
||||
if _integer(root.get("schema"), "schema") != 2:
|
||||
raise ValueError("unsupported schema")
|
||||
computer = _object(root.get("computer_name"), "computer_name")
|
||||
volume = _object(root.get("volume"), "volume")
|
||||
adapters = _object(root.get("adapters"), "adapters")
|
||||
firmware = _object(root.get("firmware"), "firmware")
|
||||
wmi = _object(root.get("wmi"), "wmi")
|
||||
properties = _object(wmi.get("properties"), "wmi.properties")
|
||||
safe_properties: dict[str, object] = {}
|
||||
for name in WMI_PROPERTY_NAMES:
|
||||
entry = _object(properties.get(name), f"wmi.properties.{name}")
|
||||
safe_properties[name] = {
|
||||
"result": _hresult(entry.get("result"), f"{name}.result"),
|
||||
"variant_type": _integer(entry.get("variant_type"), f"{name}.variant_type"),
|
||||
"rows": _integer(entry.get("rows"), f"{name}.rows"),
|
||||
"nonempty_rows": _integer(
|
||||
entry.get("nonempty_rows"),
|
||||
f"{name}.nonempty_rows",
|
||||
),
|
||||
"maximum_length": _integer(
|
||||
entry.get("maximum_length"),
|
||||
f"{name}.maximum_length",
|
||||
),
|
||||
"placeholder_rows": _integer(
|
||||
entry.get("placeholder_rows"),
|
||||
f"{name}.placeholder_rows",
|
||||
),
|
||||
"wine_rows": _integer(
|
||||
entry.get("wine_rows"),
|
||||
f"{name}.wine_rows",
|
||||
),
|
||||
}
|
||||
return {
|
||||
"schema": 2,
|
||||
"computer_name": {
|
||||
"success": _boolean(computer.get("success"), "computer_name.success"),
|
||||
"nonempty": _boolean(computer.get("nonempty"), "computer_name.nonempty"),
|
||||
"length": _integer(computer.get("length"), "computer_name.length"),
|
||||
},
|
||||
"volume": {
|
||||
"success": _boolean(volume.get("success"), "volume.success"),
|
||||
"serial_nonzero": _boolean(
|
||||
volume.get("serial_nonzero"),
|
||||
"volume.serial_nonzero",
|
||||
),
|
||||
},
|
||||
"adapters": {
|
||||
"status": _integer(adapters.get("status"), "adapters.status"),
|
||||
"count": _integer(adapters.get("count"), "adapters.count"),
|
||||
"first_address_length": _integer(
|
||||
adapters.get("first_address_length"),
|
||||
"adapters.first_address_length",
|
||||
),
|
||||
"first_address_nonzero": _boolean(
|
||||
adapters.get("first_address_nonzero"),
|
||||
"adapters.first_address_nonzero",
|
||||
),
|
||||
"first_address_locally_administered": _boolean(
|
||||
adapters.get("first_address_locally_administered"),
|
||||
"adapters.first_address_locally_administered",
|
||||
),
|
||||
"first_address_multicast": _boolean(
|
||||
adapters.get("first_address_multicast"),
|
||||
"adapters.first_address_multicast",
|
||||
),
|
||||
"first_type": _integer(adapters.get("first_type"), "adapters.first_type"),
|
||||
},
|
||||
"firmware": {
|
||||
"raw_smbios_bytes": _integer(
|
||||
firmware.get("raw_smbios_bytes"),
|
||||
"firmware.raw_smbios_bytes",
|
||||
),
|
||||
},
|
||||
"wmi": {
|
||||
"com_initialize": _hresult(wmi.get("com_initialize"), "wmi.com_initialize"),
|
||||
"com_security": _hresult(wmi.get("com_security"), "wmi.com_security"),
|
||||
"connect": _hresult(wmi.get("connect"), "wmi.connect"),
|
||||
"properties": safe_properties,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _identity_warnings(report: dict[str, object]) -> tuple[str, ...]:
|
||||
warnings: list[str] = []
|
||||
computer = _object(report["computer_name"], "computer_name")
|
||||
volume = _object(report["volume"], "volume")
|
||||
adapters = _object(report["adapters"], "adapters")
|
||||
wmi = _object(report["wmi"], "wmi")
|
||||
properties = _object(wmi["properties"], "wmi.properties")
|
||||
computer_success = _boolean(computer["success"], "computer_name.success")
|
||||
computer_nonempty = _boolean(computer["nonempty"], "computer_name.nonempty")
|
||||
volume_success = _boolean(volume["success"], "volume.success")
|
||||
volume_serial_nonzero = _boolean(volume["serial_nonzero"], "volume.serial_nonzero")
|
||||
adapter_status = _integer(adapters["status"], "adapters.status")
|
||||
adapter_count = _integer(adapters["count"], "adapters.count")
|
||||
first_address_length = _integer(
|
||||
adapters["first_address_length"],
|
||||
"adapters.first_address_length",
|
||||
)
|
||||
first_address_nonzero = _boolean(
|
||||
adapters["first_address_nonzero"],
|
||||
"adapters.first_address_nonzero",
|
||||
)
|
||||
if not computer_success or not computer_nonempty:
|
||||
warnings.append("Wine did not provide the computer name used by the login request.")
|
||||
if not volume_success or not volume_serial_nonzero:
|
||||
warnings.append("Wine did not provide a usable C: volume identity.")
|
||||
if (
|
||||
adapter_status != 0
|
||||
or adapter_count == 0
|
||||
or first_address_length < 6
|
||||
or not first_address_nonzero
|
||||
):
|
||||
warnings.append(
|
||||
"Wine did not provide a usable first network-adapter identity; the game "
|
||||
"would send its UUID fallback instead."
|
||||
)
|
||||
if _hresult(wmi["connect"], "wmi.connect") != "0x00000000":
|
||||
warnings.append("Wine could not connect to ROOT\\CIMV2 for the login identity query.")
|
||||
else:
|
||||
if not _any_nonempty(properties, ("processor_id", "processor_unique_id")):
|
||||
warnings.append("Wine returned no processor identifier through WMI.")
|
||||
if not _any_nonempty(
|
||||
properties,
|
||||
(
|
||||
"bios_release_date",
|
||||
"bios_serial_number",
|
||||
"bios_identification_code",
|
||||
"bios_smbios_version",
|
||||
"bios_manufacturer",
|
||||
),
|
||||
):
|
||||
warnings.append("Wine returned no BIOS identity fields through WMI.")
|
||||
if not _any_nonempty(
|
||||
properties,
|
||||
(
|
||||
"baseboard_serial_number",
|
||||
"baseboard_name",
|
||||
"baseboard_manufacturer",
|
||||
"baseboard_model",
|
||||
),
|
||||
):
|
||||
warnings.append("Wine returned no baseboard identity fields through WMI.")
|
||||
if not _any_nonempty(properties, ("video_name",)):
|
||||
warnings.append("Wine returned no video-controller name through WMI.")
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def _identity_observations(report: dict[str, object]) -> tuple[str, ...]:
|
||||
observations: list[str] = []
|
||||
adapters = _object(report["adapters"], "adapters")
|
||||
if _boolean(
|
||||
adapters["first_address_locally_administered"],
|
||||
"adapters.first_address_locally_administered",
|
||||
):
|
||||
observations.append(
|
||||
"The first network-adapter address is locally administered."
|
||||
)
|
||||
if _boolean(
|
||||
adapters["first_address_multicast"],
|
||||
"adapters.first_address_multicast",
|
||||
):
|
||||
observations.append("The first network-adapter address has the multicast bit set.")
|
||||
wmi = _object(report["wmi"], "wmi")
|
||||
properties = _object(wmi["properties"], "wmi.properties")
|
||||
placeholder_names: list[str] = []
|
||||
wine_names: list[str] = []
|
||||
for name in WMI_PROPERTY_NAMES:
|
||||
entry = _object(properties[name], name)
|
||||
if _integer(entry["placeholder_rows"], f"{name}.placeholder_rows") > 0:
|
||||
placeholder_names.append(name)
|
||||
if _integer(entry["wine_rows"], f"{name}.wine_rows") > 0:
|
||||
wine_names.append(name)
|
||||
if placeholder_names:
|
||||
observations.append(
|
||||
"Common placeholder WMI values were observed in: "
|
||||
+ ", ".join(placeholder_names)
|
||||
)
|
||||
if wine_names:
|
||||
observations.append(
|
||||
"Wine-labelled WMI values were observed in: " + ", ".join(wine_names)
|
||||
)
|
||||
return tuple(observations)
|
||||
|
||||
|
||||
def _any_nonempty(properties: dict[str, object], names: tuple[str, ...]) -> bool:
|
||||
for name in names:
|
||||
entry = _object(properties[name], name)
|
||||
if (
|
||||
_hresult(entry["result"], f"{name}.result") == "0x00000000"
|
||||
and _integer(entry["nonempty_rows"], f"{name}.nonempty_rows") > 0
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _boolean(value: object, label: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{label} must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 0xFFFFFFFF:
|
||||
raise ValueError(f"{label} must be an unsigned 32-bit integer")
|
||||
return value
|
||||
|
||||
|
||||
def _hresult(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or HRESULT_PATTERN.fullmatch(value) is None:
|
||||
raise ValueError(f"{label} must be a hexadecimal HRESULT")
|
||||
return value
|
||||
|
||||
|
||||
def _sha256_if_file(path: Path) -> str | None:
|
||||
try:
|
||||
if not path.is_file():
|
||||
return None
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Persistent trust state for game trees seeded from another installation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
IMPORT_STATE_SCHEMA = 2
|
||||
|
||||
|
||||
def game_import_requires_verification(paths: AppPaths) -> bool:
|
||||
"""Return whether a managed imported tree still needs a full depot repair."""
|
||||
|
||||
manifest = paths.game_import_manifest
|
||||
if not manifest.exists():
|
||||
return False
|
||||
try:
|
||||
value = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return True
|
||||
if not isinstance(value, dict):
|
||||
return True
|
||||
build = value.get("verification_build")
|
||||
completed_at = value.get("verification_completed_at")
|
||||
return not (
|
||||
value.get("verification_required") is False
|
||||
and isinstance(build, int)
|
||||
and not isinstance(build, bool)
|
||||
and build > 0
|
||||
and isinstance(completed_at, str)
|
||||
and bool(completed_at.strip())
|
||||
)
|
||||
|
||||
|
||||
def mark_game_import_verified(
|
||||
paths: AppPaths,
|
||||
*,
|
||||
build: int,
|
||||
files_quarantined: int,
|
||||
quarantine_directory: str | None,
|
||||
) -> None:
|
||||
"""Record a completed full-manifest verification of an imported tree."""
|
||||
|
||||
if isinstance(build, bool) or build <= 0:
|
||||
raise ValueError("verified import build must be positive")
|
||||
if files_quarantined < 0:
|
||||
raise ValueError("quarantined file count cannot be negative")
|
||||
try:
|
||||
value = json.loads(paths.game_import_manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
value = {}
|
||||
if not isinstance(value, dict):
|
||||
value = {}
|
||||
value.update(
|
||||
{
|
||||
"schema": IMPORT_STATE_SCHEMA,
|
||||
"verification_required": False,
|
||||
"verification_build": build,
|
||||
"verification_completed_at": datetime.now().astimezone().isoformat(),
|
||||
"files_quarantined": files_quarantined,
|
||||
"quarantine_directory": quarantine_directory,
|
||||
}
|
||||
)
|
||||
_write_json(paths.game_import_manifest, value)
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid4().hex}.tmp")
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8") 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
|
||||
@@ -1,403 +0,0 @@
|
||||
"""Safe local bootstrap from an existing PristonTale EU installation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Mapping
|
||||
import uuid
|
||||
|
||||
from .command import CommandRunner
|
||||
from .install_status import REQUIRED_GAME_FILES
|
||||
from .overlay import COMPATIBILITY_BINARY_HASHES, cache_compatibility_binaries
|
||||
from .paths import AppPaths
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
class GameImportError(RuntimeError):
|
||||
"""Raised when a source cannot be safely imported."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameTreeInventory:
|
||||
files: int
|
||||
bytes: int
|
||||
core_sha256: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameImportResult:
|
||||
source: str
|
||||
destination: str
|
||||
copy_mode: str
|
||||
files: int
|
||||
bytes: int
|
||||
core_sha256: dict[str, str]
|
||||
depot_metadata_source: str | None
|
||||
depot_metadata_destination: str | None
|
||||
installed_build: int | None
|
||||
started_at: str
|
||||
completed_at: str
|
||||
verification_required: bool
|
||||
|
||||
|
||||
def import_existing_game(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> GameImportResult:
|
||||
"""Clone a complete source without ever writing through to it."""
|
||||
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
destination = paths.source_game
|
||||
_validate_destination(paths, destination)
|
||||
if source == destination.resolve(strict=False):
|
||||
raise GameImportError("source and managed destination are the same directory")
|
||||
if destination.resolve(strict=False).is_relative_to(source):
|
||||
raise GameImportError("managed destination cannot be inside the source game tree")
|
||||
if destination.exists() or destination.is_symlink():
|
||||
raise GameImportError(
|
||||
f"managed game directory already exists: {destination}; "
|
||||
"finish or remove the existing updater installation first"
|
||||
)
|
||||
|
||||
started_at = datetime.now().astimezone().isoformat()
|
||||
source_before = inventory_game_tree(source)
|
||||
depot_metadata = discover_depot_metadata(source)
|
||||
if runner.dry_run:
|
||||
return GameImportResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
copy_mode="reflink-or-copy-dry-run",
|
||||
files=source_before.files,
|
||||
bytes=source_before.bytes,
|
||||
core_sha256=source_before.core_sha256,
|
||||
depot_metadata_source=(
|
||||
str(depot_metadata.path) if depot_metadata is not None else None
|
||||
),
|
||||
depot_metadata_destination=None,
|
||||
installed_build=(
|
||||
depot_metadata.installed_build if depot_metadata is not None else None
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=started_at,
|
||||
verification_required=True,
|
||||
)
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.import-{uuid.uuid4().hex}")
|
||||
committed = False
|
||||
pending_recorded = False
|
||||
try:
|
||||
temporary.mkdir(parents=False, exist_ok=False)
|
||||
runner.run(
|
||||
["cp", "-a", "--reflink=auto", "--", f"{source}/.", str(temporary)],
|
||||
timeout_seconds=1800.0,
|
||||
telemetry_stage="copy",
|
||||
)
|
||||
source_after = inventory_game_tree(source)
|
||||
copied = inventory_game_tree(temporary)
|
||||
if source_after != source_before:
|
||||
raise GameImportError("source game changed during import; staged copy discarded")
|
||||
if copied != source_before:
|
||||
raise GameImportError(
|
||||
"staged game inventory does not match the source; staged copy discarded"
|
||||
)
|
||||
if all(
|
||||
source_before.core_sha256.get(name) == expected
|
||||
for name, expected in COMPATIBILITY_BINARY_HASHES.items()
|
||||
):
|
||||
cache_compatibility_binaries(paths, source)
|
||||
temporary.replace(destination)
|
||||
committed = True
|
||||
provisional = GameImportResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
copy_mode="reflink-or-copy",
|
||||
files=source_before.files,
|
||||
bytes=source_before.bytes,
|
||||
core_sha256=source_before.core_sha256,
|
||||
depot_metadata_source=(
|
||||
str(depot_metadata.path) if depot_metadata is not None else None
|
||||
),
|
||||
depot_metadata_destination=None,
|
||||
installed_build=(
|
||||
depot_metadata.installed_build if depot_metadata is not None else None
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
verification_required=True,
|
||||
)
|
||||
_write_import_manifest(paths, provisional)
|
||||
pending_recorded = True
|
||||
except BaseException:
|
||||
_remove_staging_tree(temporary, destination.parent)
|
||||
if committed and not pending_recorded:
|
||||
_remove_staging_tree(destination, destination.parent)
|
||||
raise
|
||||
|
||||
metadata_destination = None
|
||||
if depot_metadata is not None:
|
||||
metadata_destination = _install_depot_metadata(
|
||||
paths,
|
||||
depot_metadata,
|
||||
environment,
|
||||
)
|
||||
result = GameImportResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
copy_mode="reflink-or-copy",
|
||||
files=source_before.files,
|
||||
bytes=source_before.bytes,
|
||||
core_sha256=source_before.core_sha256,
|
||||
depot_metadata_source=(
|
||||
str(depot_metadata.path) if depot_metadata is not None else None
|
||||
),
|
||||
depot_metadata_destination=(
|
||||
str(metadata_destination) if metadata_destination is not None else None
|
||||
),
|
||||
installed_build=(
|
||||
depot_metadata.installed_build if depot_metadata is not None else None
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
verification_required=True,
|
||||
)
|
||||
_write_import_manifest(paths, result)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotMetadata:
|
||||
path: Path
|
||||
installed_build: int
|
||||
sha256: str
|
||||
|
||||
|
||||
def discover_depot_metadata(source_game: Path) -> DepotMetadata | None:
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
prefix = None
|
||||
for parent in source.parents:
|
||||
if parent.name.casefold() == "drive_c":
|
||||
prefix = parent.parent
|
||||
break
|
||||
if prefix is None:
|
||||
return None
|
||||
users = prefix / "drive_c/users"
|
||||
try:
|
||||
user_directories = sorted(path for path in users.iterdir() if path.is_dir())
|
||||
except OSError:
|
||||
return None
|
||||
for user_directory in user_directories:
|
||||
candidate = (
|
||||
user_directory
|
||||
/ "AppData/Roaming/Pristontale EU Launcher/Depots/depot_1.json"
|
||||
)
|
||||
try:
|
||||
value = json.loads(candidate.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
installed_build = value.get("InstalledBuild")
|
||||
if (
|
||||
isinstance(installed_build, bool)
|
||||
or not isinstance(installed_build, int)
|
||||
or installed_build <= 0
|
||||
or value.get("UpdateComplete") is not True
|
||||
):
|
||||
continue
|
||||
install_folder = value.get("InstallFolder")
|
||||
if not isinstance(install_folder, str) or not install_folder.casefold().endswith(
|
||||
r"\program files (x86)\pristontale eu".casefold()
|
||||
):
|
||||
continue
|
||||
return DepotMetadata(
|
||||
path=candidate,
|
||||
installed_build=installed_build,
|
||||
sha256=_sha256(candidate),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def inventory_game_tree(source_game: Path) -> GameTreeInventory:
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
for relative in REQUIRED_GAME_FILES:
|
||||
path = source / relative
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError:
|
||||
raise GameImportError(f"game installation is incomplete: missing {relative}")
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {relative}")
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a non-file: {relative}")
|
||||
|
||||
files = 0
|
||||
total_bytes = 0
|
||||
|
||||
def walk_error(error: OSError) -> None:
|
||||
raise GameImportError(f"cannot inventory game tree: {error}") from error
|
||||
|
||||
for root, directories, filenames in os.walk(
|
||||
source,
|
||||
followlinks=False,
|
||||
onerror=walk_error,
|
||||
):
|
||||
root_path = Path(root)
|
||||
for directory in directories:
|
||||
path = root_path / directory
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise GameImportError(f"cannot inventory game directory {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {path}")
|
||||
if not stat.S_ISDIR(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a special entry: {path}")
|
||||
for filename in filenames:
|
||||
path = root_path / filename
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise GameImportError(f"cannot inventory game file {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {path}")
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a special entry: {path}")
|
||||
total_bytes += status.st_size
|
||||
files += 1
|
||||
return GameTreeInventory(
|
||||
files=files,
|
||||
bytes=total_bytes,
|
||||
core_sha256={str(relative): _sha256(source / relative) for relative in REQUIRED_GAME_FILES},
|
||||
)
|
||||
|
||||
|
||||
def discover_existing_game(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> Path | None:
|
||||
env = os.environ if environment is None else environment
|
||||
candidates: list[Path] = []
|
||||
source_override = settings.source_override
|
||||
if source_override is not None:
|
||||
candidates.append(source_override)
|
||||
home_value = env.get("HOME")
|
||||
if home_value:
|
||||
home = Path(home_value).expanduser()
|
||||
for root_name in ("PristonTale-EU", "pristontale-eu"):
|
||||
root = home / "Games" / root_name
|
||||
candidates.extend(
|
||||
(
|
||||
root / "wine/drive_c/Program Files (x86)/Pristontale EU",
|
||||
root / "drive_c/Program Files (x86)/Pristontale EU",
|
||||
root / "Pristontale EU",
|
||||
)
|
||||
)
|
||||
|
||||
destination = paths.source_game.resolve(strict=False)
|
||||
seen: set[Path] = set()
|
||||
for candidate in candidates:
|
||||
resolved = candidate.expanduser().resolve(strict=False)
|
||||
if resolved in seen or resolved == destination:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
try:
|
||||
inventory_game_tree(resolved)
|
||||
except (FileNotFoundError, GameImportError, OSError):
|
||||
continue
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def _validate_destination(paths: AppPaths, destination: Path) -> None:
|
||||
expected = (
|
||||
paths.launcher_prefix
|
||||
/ "drive_c/Program Files (x86)/Pristontale EU"
|
||||
).resolve(strict=False)
|
||||
if destination.resolve(strict=False) != expected:
|
||||
raise GameImportError(f"unexpected managed game destination: {destination}")
|
||||
|
||||
|
||||
def _install_depot_metadata(
|
||||
paths: AppPaths,
|
||||
metadata: DepotMetadata,
|
||||
environment: Mapping[str, str] | None,
|
||||
) -> Path:
|
||||
env = os.environ if environment is None else environment
|
||||
users_root = paths.launcher_prefix / "drive_c/users"
|
||||
preferred_names = [env.get("USER"), Path(env.get("HOME", "")).name]
|
||||
user_home = None
|
||||
for name in preferred_names:
|
||||
if not name:
|
||||
continue
|
||||
candidate = users_root / name
|
||||
if candidate.is_dir():
|
||||
user_home = candidate
|
||||
break
|
||||
if user_home is None:
|
||||
name = next((value for value in preferred_names if value), None)
|
||||
if name is None:
|
||||
raise GameImportError("cannot determine the Wine user for depot metadata")
|
||||
user_home = users_root / name
|
||||
target = user_home / "AppData/Roaming/Pristontale EU Launcher/Depots/depot_1.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
shutil.copy2(metadata.path, temporary)
|
||||
if _sha256(temporary) != metadata.sha256:
|
||||
raise GameImportError("copied depot metadata hash mismatch")
|
||||
temporary.replace(target)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def _remove_staging_tree(path: Path, expected_parent: Path) -> None:
|
||||
if path.parent.resolve() != expected_parent.resolve():
|
||||
raise GameImportError(f"refusing to remove staging path outside destination: {path}")
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
elif path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8") 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
|
||||
|
||||
|
||||
def _write_import_manifest(paths: AppPaths, result: GameImportResult) -> None:
|
||||
_write_json(
|
||||
paths.game_import_manifest,
|
||||
{"schema": 2, **asdict(result)},
|
||||
)
|
||||
@@ -1,105 +0,0 @@
|
||||
"""Read-only installation state used by the native launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .import_state import game_import_requires_verification
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
REQUIRED_GAME_FILES = (
|
||||
Path("Game.exe"),
|
||||
Path("game.dll"),
|
||||
Path("game.ini"),
|
||||
Path("game/maps/WhisperingVale.rovug"),
|
||||
)
|
||||
|
||||
|
||||
class PrimaryAction(str, Enum):
|
||||
"""Action presented by the launcher's large primary button."""
|
||||
|
||||
INSTALL = "INSTALL"
|
||||
UPDATE = "UPDATE"
|
||||
PLAY = "PLAY"
|
||||
CHECKING = "CHECKING…"
|
||||
LAUNCHER_UPDATE = "UPDATE LAUNCHER"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameInstallationStatus:
|
||||
"""Local game completeness and its relationship to the official depot."""
|
||||
|
||||
action: PrimaryAction
|
||||
installed: bool
|
||||
local_build: int | None
|
||||
official_build: int | None
|
||||
missing_files: tuple[Path, ...]
|
||||
|
||||
|
||||
def inspect_game_installation(
|
||||
paths: AppPaths,
|
||||
official_build: int | None = None,
|
||||
*,
|
||||
compatibility_ready: bool | None = True,
|
||||
) -> GameInstallationStatus:
|
||||
"""Select the primary launcher action without modifying the installation.
|
||||
|
||||
A completed native sync is the verified source for the local depot build.
|
||||
When the official build cannot be checked, a complete local game remains
|
||||
playable so temporary network failures do not lock users out.
|
||||
"""
|
||||
|
||||
if official_build is not None and (
|
||||
isinstance(official_build, bool)
|
||||
or not isinstance(official_build, int)
|
||||
or official_build < 0
|
||||
):
|
||||
raise ValueError("official build must be a non-negative integer")
|
||||
if compatibility_ready is not None and not isinstance(compatibility_ready, bool):
|
||||
raise ValueError("compatibility readiness must be a boolean or null")
|
||||
|
||||
missing_files = tuple(
|
||||
relative
|
||||
for relative in REQUIRED_GAME_FILES
|
||||
if not (paths.source_game / relative).is_file()
|
||||
)
|
||||
installed = not missing_files
|
||||
local_build = _read_verified_local_build(paths.native_depot_state)
|
||||
if not installed:
|
||||
action = PrimaryAction.INSTALL
|
||||
elif game_import_requires_verification(paths):
|
||||
action = PrimaryAction.UPDATE
|
||||
elif official_build is not None and (
|
||||
local_build is None or local_build < official_build
|
||||
):
|
||||
action = PrimaryAction.UPDATE
|
||||
elif compatibility_ready is None:
|
||||
action = PrimaryAction.CHECKING
|
||||
elif not compatibility_ready:
|
||||
action = PrimaryAction.LAUNCHER_UPDATE
|
||||
else:
|
||||
action = PrimaryAction.PLAY
|
||||
return GameInstallationStatus(
|
||||
action=action,
|
||||
installed=installed,
|
||||
local_build=local_build,
|
||||
official_build=official_build,
|
||||
missing_files=missing_files,
|
||||
)
|
||||
|
||||
|
||||
def _read_verified_local_build(path: Path) -> int | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(value, dict) or value.get("dry_run") is not False:
|
||||
return None
|
||||
build = value.get("build")
|
||||
if isinstance(build, bool) or not isinstance(build, int) or build < 0:
|
||||
return None
|
||||
return build
|
||||
@@ -1,11 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAt7MwE0ZLWR6uy0aEW2b/
|
||||
Hr/txx9mSuWxKUxQAE07Gl7A5ZTgivhZ9w0pbCcVtcirhg9lzi7OKcc3VrERcHTq
|
||||
lK1+h3NENPNSVfhQeTjIEIC9W8hXK1/mHnz1IEk5nziGarnGTiSaIaI23TXZ5QtY
|
||||
vbpj+gTkSZswsLUlBCENFaS/1k6YZLGJdJ9l4luTkV47vqD6lvPYY8Mob3AxCwTj
|
||||
PuEZYMW53NfZx/Lrk7MVzTrTa66L9cFnLUGHwguiA9SbjzppDOw6YJw3sTZalC+a
|
||||
xseEle0dymCjCyEU1fVCizvW88/ZV+a2kpvfMWUVBl4MAzyH8Ja+FFqAkzjd2YKx
|
||||
7fI1X5Cr9rTG8obmYfH/Co+gfiU2jDeQZL6ELB9/zukWYxJp1jztlPxB4/nHoJgG
|
||||
1QCM7i0QGne4kIgWn6Qm2uBu/ucwaDlm/842VHKQEtcKBeYpkxO9B5xcRBJ59qMu
|
||||
OSUuFAEiX4TN56wS7F1XTogpFTUJs5PX42d9Rd6hkW3ZAgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,327 +0,0 @@
|
||||
"""Wine launcher preparation and exact-profile WPF compatibility patching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Iterable
|
||||
|
||||
from .command import CommandOutcome, CommandRunner
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
KNOWN_PRISTINE_LAUNCHER_SHA256 = (
|
||||
"64e46469d7de675e52741cabf71a8c4b7852769b96bd59ef9a15032b3d61c4d7"
|
||||
)
|
||||
KNOWN_PATCHED_LAUNCHER_SHA256 = (
|
||||
"aa18a8d74b1c5a644251babe15ae6828a221bdb65f970cdef275276ca750f1e8"
|
||||
)
|
||||
KNOWN_COMPLETE_PATCHED_LAUNCHER_SHA256 = (
|
||||
"fb89f2e8a7f68775c4c1a09ea7f88f1799b8f36355985d26b30f56701d11f677"
|
||||
)
|
||||
LAUNCHER_PREFIX_CONFIGURATION_VERSION = 1
|
||||
_NEWS_FEED_FONT_OFFSET = 269118
|
||||
|
||||
_PATCHES = (
|
||||
(b"/Resources/Fonts/#FontAwesome", b"FontAwesome,Lato,Arial,Tahoma", 1),
|
||||
(b"/Resources/Fonts/#Lato Regular", b"Lato Regular,Lato,Arial,Tahoma", 1),
|
||||
)
|
||||
|
||||
|
||||
class LauncherError(RuntimeError):
|
||||
"""Raised when the launcher cannot be safely prepared."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WineTools:
|
||||
wine: Path
|
||||
wineboot: Path
|
||||
winetricks: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherPatchResult:
|
||||
source: str
|
||||
destination: str
|
||||
source_sha256: str
|
||||
destination_sha256: str
|
||||
patched: bool
|
||||
supported_profile: bool
|
||||
replacements: dict[str, int]
|
||||
|
||||
|
||||
def find_wine_tools() -> WineTools | None:
|
||||
wine_candidates = (
|
||||
Path("/opt/wine-stable/bin/wine"),
|
||||
Path("/opt/wine-staging/bin/wine"),
|
||||
Path("/opt/wine/bin/wine"),
|
||||
Path("/usr/local/bin/wine"),
|
||||
Path("/usr/bin/wine"),
|
||||
Path("/usr/bin/wine64"),
|
||||
)
|
||||
wine = next((path for path in wine_candidates if path.is_file()), None)
|
||||
if wine is None:
|
||||
located = shutil.which("wine") or shutil.which("wine64")
|
||||
wine = Path(located) if located else None
|
||||
if wine is None:
|
||||
return None
|
||||
wineboot = wine.with_name("wineboot")
|
||||
if not wineboot.is_file():
|
||||
located_boot = shutil.which("wineboot")
|
||||
if not located_boot:
|
||||
return None
|
||||
wineboot = Path(located_boot)
|
||||
located_winetricks = shutil.which("winetricks")
|
||||
if not located_winetricks:
|
||||
return None
|
||||
return WineTools(
|
||||
wine=wine.resolve(),
|
||||
# Preserve the wineboot basename when it is a dispatcher symlink to Wine.
|
||||
wineboot=wineboot.absolute(),
|
||||
winetricks=Path(located_winetricks).resolve(),
|
||||
)
|
||||
|
||||
|
||||
def prepare_launcher_binary(source: Path, destination: Path) -> LauncherPatchResult:
|
||||
"""Patch only the exact known launcher profile; retain unknown builds intact."""
|
||||
|
||||
source_hash = _sha256(source)
|
||||
data = source.read_bytes()
|
||||
replacements: dict[str, int] = {}
|
||||
supported = source_hash == KNOWN_PRISTINE_LAUNCHER_SHA256
|
||||
if supported:
|
||||
for old, new, expected_count in _PATCHES:
|
||||
if len(old) != len(new):
|
||||
raise AssertionError("launcher replacement lengths must match")
|
||||
actual_count = data.count(old)
|
||||
if actual_count != expected_count:
|
||||
raise LauncherError(
|
||||
f"known launcher profile changed for {old!r}: "
|
||||
f"expected {expected_count}, found {actual_count}"
|
||||
)
|
||||
data = data.replace(old, new)
|
||||
replacements[old.decode("ascii")] = actual_count
|
||||
intermediate_hash = hashlib.sha256(data).hexdigest()
|
||||
if intermediate_hash != KNOWN_PATCHED_LAUNCHER_SHA256:
|
||||
raise LauncherError(
|
||||
"known launcher font fallback patch produced an unexpected SHA-256: "
|
||||
f"{intermediate_hash}"
|
||||
)
|
||||
news_old = b"FontAwesome#"
|
||||
news_new = b"LatoRegular#"
|
||||
news_end = _NEWS_FEED_FONT_OFFSET + len(news_old)
|
||||
if data[_NEWS_FEED_FONT_OFFSET:news_end] != news_old:
|
||||
raise LauncherError("known NewsFeed font offset no longer matches")
|
||||
data = (
|
||||
data[:_NEWS_FEED_FONT_OFFSET]
|
||||
+ news_new
|
||||
+ data[news_end:]
|
||||
)
|
||||
replacements[f"FontAwesome#@{_NEWS_FEED_FONT_OFFSET}"] = 1
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(data)
|
||||
shutil.copymode(source, temporary)
|
||||
destination_hash = _sha256(temporary)
|
||||
if supported and destination_hash != KNOWN_COMPLETE_PATCHED_LAUNCHER_SHA256:
|
||||
raise LauncherError(
|
||||
"known launcher patch produced an unexpected SHA-256: "
|
||||
f"{destination_hash}"
|
||||
)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
return LauncherPatchResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
source_sha256=source_hash,
|
||||
destination_sha256=destination_hash,
|
||||
patched=supported,
|
||||
supported_profile=supported,
|
||||
replacements=replacements,
|
||||
)
|
||||
|
||||
|
||||
def launcher_environment(paths: AppPaths, tools: WineTools) -> dict[str, str]:
|
||||
return {
|
||||
"WINEPREFIX": str(paths.launcher_prefix),
|
||||
"WINE": str(tools.wine),
|
||||
"WINEDEBUG": "-all",
|
||||
"WINEESYNC": "1",
|
||||
"DOTNET_SYSTEM_GLOBALIZATION_USENLS": "1",
|
||||
}
|
||||
|
||||
|
||||
def configure_launcher_prefix(
|
||||
paths: AppPaths,
|
||||
tools: WineTools,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
force_runtime: bool = False,
|
||||
) -> list[CommandOutcome]:
|
||||
"""Create the launcher prefix, install .NET Desktop 6, and apply WPF fixes."""
|
||||
|
||||
paths.create_base_directories()
|
||||
if not runner.dry_run:
|
||||
paths.launcher_prefix.mkdir(parents=True, exist_ok=True)
|
||||
environment = launcher_environment(paths, tools)
|
||||
marker = _read_json(paths.launcher_prefix_marker)
|
||||
if not force_runtime and _launcher_prefix_ready(paths, tools, marker):
|
||||
if not runner.dry_run and "configuration_version" not in marker:
|
||||
_write_json(
|
||||
paths.launcher_prefix_marker,
|
||||
{
|
||||
**marker,
|
||||
"configuration_version": LAUNCHER_PREFIX_CONFIGURATION_VERSION,
|
||||
},
|
||||
)
|
||||
return []
|
||||
results = [
|
||||
runner.run(
|
||||
[str(tools.wineboot), "-u"],
|
||||
env_overrides={**environment, "WINEARCH": "win64"},
|
||||
timeout_seconds=300.0,
|
||||
telemetry_stage="prefix_create",
|
||||
)
|
||||
]
|
||||
if force_runtime or not _dotnet_desktop_ready(paths, marker):
|
||||
results.append(
|
||||
runner.run(
|
||||
[str(tools.winetricks), "-q", "dotnetdesktop6"],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=1800.0,
|
||||
telemetry_stage="runtime_install",
|
||||
)
|
||||
)
|
||||
|
||||
registry_commands: Iterable[list[str]] = (
|
||||
[
|
||||
str(tools.wine), "reg", "add",
|
||||
r"HKCU\Software\Microsoft\Avalon.Graphics",
|
||||
"/v", "DisableHWAcceleration", "/t", "REG_DWORD", "/d", "1", "/f",
|
||||
],
|
||||
[
|
||||
str(tools.wine), "reg", "add",
|
||||
r"HKCU\Software\Microsoft\Avalon.Graphics",
|
||||
"/v", "MaxMultisampleType", "/t", "REG_DWORD", "/d", "0", "/f",
|
||||
],
|
||||
)
|
||||
for command in registry_commands:
|
||||
results.append(
|
||||
runner.run(
|
||||
command,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=60.0,
|
||||
telemetry_stage="prefix_configuration",
|
||||
)
|
||||
)
|
||||
for requested, replacement in (
|
||||
("Segoe UI", "Carlito"),
|
||||
("Segoe UI Regular", "Carlito"),
|
||||
("Segoe UI Light", "Carlito"),
|
||||
("Segoe UI Semilight", "Carlito"),
|
||||
("Segoe UI Semibold", "Carlito"),
|
||||
("Segoe UI Bold", "Carlito"),
|
||||
("Segoe UI Black", "Carlito"),
|
||||
("Segoe UI Symbol", "Noto Sans Symbols 2"),
|
||||
("Lato Regular", "Lato"),
|
||||
):
|
||||
results.append(
|
||||
runner.run(
|
||||
[
|
||||
str(tools.wine), "reg", "add",
|
||||
r"HKLM\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes",
|
||||
"/v", requested, "/t", "REG_SZ", "/d", replacement, "/f",
|
||||
],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=60.0,
|
||||
telemetry_stage="font_configuration",
|
||||
)
|
||||
)
|
||||
if not runner.dry_run:
|
||||
_write_json(
|
||||
paths.launcher_prefix_marker,
|
||||
{
|
||||
"configuration_version": LAUNCHER_PREFIX_CONFIGURATION_VERSION,
|
||||
"configured_at": datetime.now().astimezone().isoformat(),
|
||||
"dotnetdesktop6": True,
|
||||
"wine": str(tools.wine),
|
||||
},
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def launch_official_updater(
|
||||
paths: AppPaths,
|
||||
tools: WineTools,
|
||||
runner: CommandRunner,
|
||||
) -> CommandOutcome:
|
||||
if not paths.launcher_patched.is_file() and not runner.dry_run:
|
||||
raise LauncherError("prepared launcher is missing; run fetch first")
|
||||
return runner.run(
|
||||
[str(tools.wine), str(paths.launcher_patched)],
|
||||
cwd=paths.launcher_patched.parent,
|
||||
env_overrides=launcher_environment(paths, tools),
|
||||
timeout_seconds=4 * 60 * 60,
|
||||
telemetry_stage="launcher_start",
|
||||
)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _dotnet_desktop_ready(paths: AppPaths, marker: dict[str, object]) -> bool:
|
||||
if not bool(marker.get("dotnetdesktop6")):
|
||||
return False
|
||||
desktop_root = (
|
||||
paths.launcher_prefix
|
||||
/ "drive_c/Program Files/dotnet/shared/Microsoft.WindowsDesktop.App"
|
||||
)
|
||||
try:
|
||||
return any(path.is_dir() and path.name.startswith("6.") for path in desktop_root.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _launcher_prefix_ready(
|
||||
paths: AppPaths,
|
||||
tools: WineTools,
|
||||
marker: dict[str, object],
|
||||
) -> bool:
|
||||
configuration_version = marker.get(
|
||||
"configuration_version",
|
||||
1,
|
||||
)
|
||||
return (
|
||||
configuration_version == LAUNCHER_PREFIX_CONFIGURATION_VERSION
|
||||
and marker.get("wine") == str(tools.wine)
|
||||
and _dotnet_desktop_ready(paths, marker)
|
||||
)
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Render the installed command wrapper with persistent application paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
APP_ROOT_TOKEN = "@PTLINUX_APP_ROOT@"
|
||||
INSTALL_DIRECTORY_TOKEN = "@PTLINUX_INSTALL_DIR@"
|
||||
|
||||
|
||||
class LauncherWrapperError(ValueError):
|
||||
"""Raised when a safe launcher wrapper cannot be rendered."""
|
||||
|
||||
|
||||
def quote_shell_word(value: str) -> str:
|
||||
"""Quote one value as a literal POSIX shell word."""
|
||||
|
||||
if not value or any(character in value for character in ("\x00", "\n", "\r")):
|
||||
raise LauncherWrapperError("launcher path contains invalid characters")
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _absolute_directory(path: Path, label: str) -> Path:
|
||||
directory = path.expanduser()
|
||||
if not directory.is_absolute():
|
||||
raise LauncherWrapperError(f"{label} must be absolute")
|
||||
return directory
|
||||
|
||||
|
||||
def render_launcher_wrapper(
|
||||
template: str,
|
||||
app_root: Path,
|
||||
install_directory: Path,
|
||||
) -> str:
|
||||
"""Embed the selected install layout in one command-wrapper template."""
|
||||
|
||||
for token in (APP_ROOT_TOKEN, INSTALL_DIRECTORY_TOKEN):
|
||||
if template.count(token) != 1:
|
||||
raise LauncherWrapperError(
|
||||
f"launcher template must contain exactly one {token} token"
|
||||
)
|
||||
application = _absolute_directory(app_root, "application directory")
|
||||
installation = _absolute_directory(install_directory, "install directory")
|
||||
if not any(
|
||||
(candidate / "ptlinux").is_dir()
|
||||
for candidate in (
|
||||
application / "current",
|
||||
application / "previous",
|
||||
application,
|
||||
)
|
||||
):
|
||||
raise LauncherWrapperError(f"application files are missing: {application}")
|
||||
rendered = template.replace(APP_ROOT_TOKEN, quote_shell_word(str(application)))
|
||||
rendered = rendered.replace(
|
||||
INSTALL_DIRECTORY_TOKEN,
|
||||
quote_shell_word(str(installation)),
|
||||
)
|
||||
return rendered if rendered.endswith("\n") else rendered + "\n"
|
||||
|
||||
|
||||
def install_launcher_wrapper(
|
||||
template: Path,
|
||||
app_root: Path,
|
||||
install_directory: Path,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
"""Render and atomically install one executable command wrapper."""
|
||||
|
||||
rendered = render_launcher_wrapper(
|
||||
template.read_text(encoding="utf-8"),
|
||||
app_root,
|
||||
install_directory,
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(rendered, encoding="utf-8")
|
||||
temporary.chmod(0o755)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="install the PristonTale EU command wrapper"
|
||||
)
|
||||
parser.add_argument("template", type=Path)
|
||||
parser.add_argument("app_root", type=Path)
|
||||
parser.add_argument("install_directory", type=Path)
|
||||
parser.add_argument("destination", type=Path)
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
install_launcher_wrapper(
|
||||
arguments.template,
|
||||
arguments.app_root,
|
||||
arguments.install_directory,
|
||||
arguments.destination,
|
||||
)
|
||||
except (LauncherWrapperError, OSError) as exc:
|
||||
print(f"Could not install command wrapper: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,248 +0,0 @@
|
||||
"""Cross-process locks for launcher-prefix mutations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator, Sequence, TextIO
|
||||
|
||||
|
||||
ActiveGameProbe = Callable[[], bool]
|
||||
MAX_PROCESS_ENVIRONMENT_BYTES = 1024 * 1024
|
||||
MAX_PROCESS_COMMAND_BYTES = 128 * 1024
|
||||
|
||||
|
||||
class OperationBusyError(RuntimeError):
|
||||
"""Raised when another launcher-prefix operation is still active."""
|
||||
|
||||
|
||||
class LauncherPlayLease:
|
||||
"""Exclusive preparation lease that can become a shared game lease."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handle: TextIO | None,
|
||||
*,
|
||||
preparation_allowed: bool,
|
||||
record_owned: bool,
|
||||
) -> None:
|
||||
self._handle = handle
|
||||
self.preparation_allowed = preparation_allowed
|
||||
self._record_owned = record_owned
|
||||
self._shared = not preparation_allowed
|
||||
|
||||
@classmethod
|
||||
def dry_run(cls) -> LauncherPlayLease:
|
||||
return cls(None, preparation_allowed=True, record_owned=False)
|
||||
|
||||
@property
|
||||
def shared(self) -> bool:
|
||||
return self._shared
|
||||
|
||||
def allow_shared_clients(self) -> None:
|
||||
"""Downgrade after all runtime mutations have completed."""
|
||||
|
||||
if self._shared:
|
||||
return
|
||||
if self._handle is not None:
|
||||
_write_owner(self._handle, "play")
|
||||
fcntl.flock(self._handle.fileno(), fcntl.LOCK_SH)
|
||||
self._shared = True
|
||||
|
||||
@property
|
||||
def record_owned(self) -> bool:
|
||||
return self._record_owned
|
||||
|
||||
@property
|
||||
def inherited_fds(self) -> tuple[int, ...]:
|
||||
"""Keep the shared lease alive in the launched game supervisor."""
|
||||
|
||||
if self._handle is None:
|
||||
return ()
|
||||
return (self._handle.fileno(),)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launcher_operation_lock(
|
||||
lock_path: Path,
|
||||
operation: str,
|
||||
*,
|
||||
active_game_probe: ActiveGameProbe | None = None,
|
||||
) -> Iterator[None]:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path.open("a+", encoding="utf-8") as handle:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
handle.seek(0)
|
||||
owner = handle.read().strip() or "unknown operation"
|
||||
raise OperationBusyError(
|
||||
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||
) from exc
|
||||
if active_game_probe is not None and active_game_probe():
|
||||
raise OperationBusyError(
|
||||
"launcher files are busy (active managed game client detected); "
|
||||
"close every game client and try again"
|
||||
)
|
||||
_write_owner(handle, operation)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launcher_play_lock(
|
||||
lock_path: Path,
|
||||
operation: str = "play",
|
||||
*,
|
||||
active_game_probe: ActiveGameProbe | None = None,
|
||||
) -> Iterator[LauncherPlayLease]:
|
||||
"""Serialize preparation, then allow multiple active game clients.
|
||||
|
||||
The first play process acquires an exclusive lease so it may safely rebuild
|
||||
the runtime view or prefix. It downgrades that lease immediately before
|
||||
starting Game.exe. Later play processes then receive shared, read-only leases,
|
||||
while updates and repairs remain excluded until every client exits.
|
||||
"""
|
||||
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path.open("a+", encoding="utf-8") as handle:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
owner = _read_owner(handle)
|
||||
raise OperationBusyError(
|
||||
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||
) from exc
|
||||
lease = LauncherPlayLease(
|
||||
handle,
|
||||
preparation_allowed=False,
|
||||
record_owned=False,
|
||||
)
|
||||
else:
|
||||
if active_game_probe is not None and active_game_probe():
|
||||
# A game from an older launcher may have outlived its Python
|
||||
# supervisor and therefore its inherited lock. Rejoin that live
|
||||
# prefix without mutating its runtime view or waiting for it to exit.
|
||||
_write_owner(handle, operation)
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_SH)
|
||||
lease = LauncherPlayLease(
|
||||
handle,
|
||||
preparation_allowed=False,
|
||||
record_owned=True,
|
||||
)
|
||||
else:
|
||||
_write_owner(handle, f"{operation} preparation")
|
||||
lease = LauncherPlayLease(
|
||||
handle,
|
||||
preparation_allowed=True,
|
||||
record_owned=True,
|
||||
)
|
||||
try:
|
||||
yield lease
|
||||
finally:
|
||||
# A downgraded owner record remains useful while any secondary shared
|
||||
# lease is active. The next exclusive operation atomically replaces it.
|
||||
if lease.record_owned and not lease.shared:
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def managed_game_process_is_active(
|
||||
runtime_view: Path,
|
||||
wine_prefixes: Sequence[Path],
|
||||
*,
|
||||
process_root: Path = Path("/proc"),
|
||||
) -> bool:
|
||||
"""Detect a managed Wine game that outlived its launcher supervisor."""
|
||||
|
||||
prefixes = {_normalized_path(prefix) for prefix in wine_prefixes}
|
||||
prefixes.discard("")
|
||||
if not prefixes:
|
||||
return False
|
||||
runtime_marker = _normalized_path(runtime_view).casefold().replace("\\", "/")
|
||||
if not runtime_marker:
|
||||
return False
|
||||
runtime_marker = runtime_marker.rstrip("/") + "/"
|
||||
try:
|
||||
processes = tuple(process_root.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
for process in processes:
|
||||
if not process.name.isdecimal():
|
||||
continue
|
||||
try:
|
||||
name = (process / "comm").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).strip().casefold()
|
||||
except OSError:
|
||||
continue
|
||||
if not name.endswith(".exe"):
|
||||
continue
|
||||
environment = _read_bounded(process / "environ", MAX_PROCESS_ENVIRONMENT_BYTES)
|
||||
if environment is None:
|
||||
continue
|
||||
wine_prefix = next(
|
||||
(
|
||||
entry.removeprefix(b"WINEPREFIX=")
|
||||
for entry in environment.split(b"\0")
|
||||
if entry.startswith(b"WINEPREFIX=")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
wine_prefix is None
|
||||
or _normalized_path(os.fsdecode(wine_prefix)) not in prefixes
|
||||
):
|
||||
continue
|
||||
command = _read_bounded(process / "cmdline", MAX_PROCESS_COMMAND_BYTES)
|
||||
if command is None:
|
||||
continue
|
||||
normalized_command = os.fsdecode(command).casefold().replace("\\", "/")
|
||||
if runtime_marker in normalized_command:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _read_bounded(path: Path, maximum_bytes: int) -> bytes | None:
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
value = handle.read(maximum_bytes + 1)
|
||||
except OSError:
|
||||
return None
|
||||
return value if len(value) <= maximum_bytes else None
|
||||
|
||||
|
||||
def _normalized_path(path: Path | str) -> str:
|
||||
value = str(path)
|
||||
if not value:
|
||||
return ""
|
||||
return str(Path(value).expanduser().resolve(strict=False))
|
||||
|
||||
|
||||
def _read_owner(handle: TextIO) -> str:
|
||||
handle.seek(0)
|
||||
return handle.read().strip() or "unknown operation"
|
||||
|
||||
|
||||
def _write_owner(handle: TextIO, operation: str) -> None:
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.write(
|
||||
f"{operation}, pid={os.getpid()}, "
|
||||
f"started={datetime.now().astimezone().isoformat()}\n"
|
||||
)
|
||||
handle.flush()
|
||||
@@ -1,604 +0,0 @@
|
||||
"""Credential-free diagnostics for the PristonTale EU login path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from . import __version__
|
||||
from .command import CommandRunner
|
||||
from .identity_probe import (
|
||||
IDENTITY_PROBE_PRIVACY,
|
||||
IdentityProbeResult,
|
||||
probe_client_identity,
|
||||
)
|
||||
from .login_trace import LOGIN_TRACE_DISABLED_REASON, LOGIN_TRACE_ENABLED
|
||||
from .overlay import (
|
||||
COMPATIBILITY_BINARY_HASHES,
|
||||
CURRENT_V21_BINARY_HASHES,
|
||||
PATCHED_V21_GAME_DLL_SHA256,
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
||||
)
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
RuntimeSelection,
|
||||
detect_distribution_id,
|
||||
native_d3d_ready,
|
||||
resolve_proton_sync_mode,
|
||||
select_runtime,
|
||||
)
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
LOGIN_SERVER_HOST = "5.135.35.188"
|
||||
LOGIN_SERVER_PORT = 10019
|
||||
DEFAULT_LOGIN_TIMEOUT_SECONDS = 5.0
|
||||
VERIFIED_GAME_PROFILES = frozenset(
|
||||
{"v21.75.83", "verified-compatibility"}
|
||||
)
|
||||
SERVER_LOGIN_RESPONSE_OWNERSHIP = (
|
||||
"Banned Code 1 and Banned Code 3 are server-provided login response text; "
|
||||
"neither string exists in the verified V21.75.83 Game.exe/game.dll pair"
|
||||
)
|
||||
CLIENT_IDENTITY_INPUTS = (
|
||||
"Win32_Processor",
|
||||
"Win32_BIOS",
|
||||
"Win32_BaseBoard",
|
||||
"Win32_VideoController",
|
||||
"network-adapter identity",
|
||||
"C: volume information",
|
||||
"computer name",
|
||||
)
|
||||
PUBLISHER_COMPARISON_COMMAND = "pristontale-eu wine-update"
|
||||
RUNTIME_COMPONENT_PRIVACY = (
|
||||
"Only SHA-256 hashes of public runtime executables and DLLs plus expected DLL-"
|
||||
"override flags are reported. Registry contents and machine identifiers are not "
|
||||
"hashed or returned."
|
||||
)
|
||||
PREFIX_COMPONENT_PATHS = (
|
||||
"drive_c/windows/syswow64/d3d9.dll",
|
||||
"drive_c/windows/syswow64/d3dx9_43.dll",
|
||||
"drive_c/windows/syswow64/d3dcompiler_43.dll",
|
||||
"drive_c/windows/syswow64/iphlpapi.dll",
|
||||
"drive_c/windows/syswow64/kernel32.dll",
|
||||
"drive_c/windows/syswow64/ole32.dll",
|
||||
"drive_c/windows/syswow64/oleaut32.dll",
|
||||
"drive_c/windows/syswow64/wbemprox.dll",
|
||||
"drive_c/windows/syswow64/wbem/wbemprox.dll",
|
||||
)
|
||||
|
||||
|
||||
class _Closable(Protocol):
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
LoginConnector = Callable[[tuple[str, int], float], _Closable]
|
||||
|
||||
|
||||
def _create_login_connection(address: tuple[str, int], timeout: float) -> _Closable:
|
||||
return socket.create_connection(address, timeout)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginServerProbe:
|
||||
host: str
|
||||
port: int
|
||||
reachable: bool
|
||||
latency_ms: float | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeComponentFingerprint:
|
||||
privacy: str
|
||||
runtime_wrapper_sha256: str | None
|
||||
proton_script_sha256: str | None
|
||||
proton_version_sha256: str | None
|
||||
proton_toolmanifest_sha256: str | None
|
||||
proton_wine_sha256: str | None
|
||||
proton_wineserver_sha256: str | None
|
||||
prefix_dll_sha256: dict[str, str | None]
|
||||
registry_overrides: dict[str, bool]
|
||||
runtime_marker_matches_selection: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginDiagnosticReport:
|
||||
preflight_ok: bool
|
||||
checked_at: str
|
||||
launcher_version: str
|
||||
launcher_module: str
|
||||
python_executable: str
|
||||
distribution_id: str
|
||||
kernel_release: str
|
||||
login_server: LoginServerProbe
|
||||
authentication_tested: bool
|
||||
login_response_ownership: str
|
||||
client_identity_inputs: tuple[str, ...]
|
||||
client_identity_values_collected: bool
|
||||
client_identity_shape_collected: bool
|
||||
client_identity_ready: bool | None
|
||||
client_identity_privacy: str
|
||||
client_identity_probe: IdentityProbeResult | None
|
||||
login_trace_enabled: bool
|
||||
login_trace_disabled_reason: str | None
|
||||
publisher_comparison_command: str
|
||||
install_directory: str
|
||||
game_exe: str
|
||||
game_exe_sha256: str | None
|
||||
game_dll: str
|
||||
game_dll_sha256: str | None
|
||||
source_game_exe_sha256: str | None
|
||||
source_game_dll_sha256: str | None
|
||||
runtime_matches_managed_source: bool
|
||||
game_profile: str
|
||||
depot_build: int | None
|
||||
depot_version: str | None
|
||||
depot_completed_at: str | None
|
||||
runtime_configured: str
|
||||
runtime_requested: str
|
||||
runtime_override_applied: bool
|
||||
runtime_kind: str | None
|
||||
runtime_path: str | None
|
||||
runtime_error: str | None
|
||||
runtime_components: RuntimeComponentFingerprint | None
|
||||
proton_sync_configured: str
|
||||
proton_sync_effective: str | None
|
||||
prefix: str | None
|
||||
prefix_ready: bool
|
||||
directx_ready: bool
|
||||
latest_game_log: str | None
|
||||
latest_game_log_modified_at: str | None
|
||||
warnings: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def probe_login_server(
|
||||
*,
|
||||
host: str = LOGIN_SERVER_HOST,
|
||||
port: int = LOGIN_SERVER_PORT,
|
||||
timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
connector: LoginConnector = _create_login_connection,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> LoginServerProbe:
|
||||
"""Test only the TCP handshake; never send credentials or protocol data."""
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("login diagnostic timeout must be greater than zero")
|
||||
started = clock()
|
||||
try:
|
||||
connection = connector((host, port), timeout_seconds)
|
||||
connection.close()
|
||||
except OSError as exc:
|
||||
return LoginServerProbe(
|
||||
host=host,
|
||||
port=port,
|
||||
reachable=False,
|
||||
latency_ms=None,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
return LoginServerProbe(
|
||||
host=host,
|
||||
port=port,
|
||||
reachable=True,
|
||||
latency_ms=round((clock() - started) * 1000.0, 1),
|
||||
error=None,
|
||||
)
|
||||
|
||||
|
||||
def inspect_login_environment(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
*,
|
||||
timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
connector: LoginConnector = _create_login_connection,
|
||||
runner: CommandRunner | None = None,
|
||||
runtime_override: str | None = None,
|
||||
) -> LoginDiagnosticReport:
|
||||
"""Inspect the installed client and its network route without authenticating."""
|
||||
|
||||
probe = probe_login_server(
|
||||
timeout_seconds=timeout_seconds,
|
||||
connector=connector,
|
||||
)
|
||||
runtime_requested = runtime_override or settings.runtime
|
||||
diagnostic_settings = replace(settings, runtime=runtime_requested)
|
||||
selection, runtime_error = _runtime_selection(paths, diagnostic_settings)
|
||||
distribution_id = detect_distribution_id() or "unknown"
|
||||
kernel_release = platform.release()
|
||||
proton_sync_effective = (
|
||||
resolve_proton_sync_mode(
|
||||
settings.proton_sync,
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
if selection is not None
|
||||
and selection.kind in {"proton7", "proton7-container"}
|
||||
else None
|
||||
)
|
||||
game_root = _active_game_root(paths)
|
||||
game_exe = game_root / "Game.exe"
|
||||
game_dll = game_root / "game.dll"
|
||||
game_exe_sha256 = _sha256_if_file(game_exe)
|
||||
game_dll_sha256 = _sha256_if_file(game_dll)
|
||||
source_game_exe_sha256 = _sha256_if_file(paths.source_game / "Game.exe")
|
||||
source_game_dll_sha256 = _sha256_if_file(paths.source_game / "game.dll")
|
||||
runtime_matches_managed_source = (
|
||||
game_exe_sha256 is not None
|
||||
and game_dll_sha256 is not None
|
||||
and game_exe_sha256 == source_game_exe_sha256
|
||||
and game_dll_sha256 == source_game_dll_sha256
|
||||
)
|
||||
game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256)
|
||||
depot_state = _read_json_object(paths.native_depot_state)
|
||||
prefix = _runtime_prefix(paths, selection)
|
||||
prefix_ready = _prefix_ready(prefix)
|
||||
directx_ready = native_d3d_ready(prefix) if prefix is not None else False
|
||||
runtime_components = (
|
||||
inspect_runtime_components(paths, selection, prefix)
|
||||
if selection is not None and prefix is not None
|
||||
else None
|
||||
)
|
||||
identity_probe = (
|
||||
probe_client_identity(paths, selection, runner)
|
||||
if selection is not None and prefix_ready and runner is not None
|
||||
else None
|
||||
)
|
||||
latest_log = latest_native_game_log(paths)
|
||||
warnings = _diagnostic_warnings(
|
||||
probe,
|
||||
game_profile,
|
||||
runtime_error,
|
||||
prefix_ready,
|
||||
directx_ready,
|
||||
latest_log,
|
||||
runtime_matches_managed_source,
|
||||
identity_probe,
|
||||
)
|
||||
identity_preflight_ok = (
|
||||
True
|
||||
if runner is None
|
||||
else identity_probe is not None and identity_probe.ready is True
|
||||
)
|
||||
return LoginDiagnosticReport(
|
||||
preflight_ok=(
|
||||
probe.reachable
|
||||
and game_profile in VERIFIED_GAME_PROFILES
|
||||
and runtime_matches_managed_source
|
||||
and selection is not None
|
||||
and prefix_ready
|
||||
and directx_ready
|
||||
and identity_preflight_ok
|
||||
),
|
||||
checked_at=datetime.now().astimezone().isoformat(),
|
||||
launcher_version=__version__,
|
||||
launcher_module=str(Path(__file__).resolve()),
|
||||
python_executable=sys.executable,
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
login_server=probe,
|
||||
authentication_tested=False,
|
||||
login_response_ownership=SERVER_LOGIN_RESPONSE_OWNERSHIP,
|
||||
client_identity_inputs=CLIENT_IDENTITY_INPUTS,
|
||||
client_identity_values_collected=False,
|
||||
client_identity_shape_collected=(
|
||||
identity_probe is not None and identity_probe.report is not None
|
||||
),
|
||||
client_identity_ready=(
|
||||
identity_probe.ready if identity_probe is not None else None
|
||||
),
|
||||
client_identity_privacy=IDENTITY_PROBE_PRIVACY,
|
||||
client_identity_probe=identity_probe,
|
||||
login_trace_enabled=LOGIN_TRACE_ENABLED,
|
||||
login_trace_disabled_reason=(
|
||||
None if LOGIN_TRACE_ENABLED else LOGIN_TRACE_DISABLED_REASON
|
||||
),
|
||||
publisher_comparison_command=PUBLISHER_COMPARISON_COMMAND,
|
||||
install_directory=str(paths.data),
|
||||
game_exe=str(game_exe),
|
||||
game_exe_sha256=game_exe_sha256,
|
||||
game_dll=str(game_dll),
|
||||
game_dll_sha256=game_dll_sha256,
|
||||
source_game_exe_sha256=source_game_exe_sha256,
|
||||
source_game_dll_sha256=source_game_dll_sha256,
|
||||
runtime_matches_managed_source=runtime_matches_managed_source,
|
||||
game_profile=game_profile,
|
||||
depot_build=_integer_or_none(depot_state.get("build")),
|
||||
depot_version=_string_or_none(depot_state.get("version")),
|
||||
depot_completed_at=_string_or_none(depot_state.get("completed_at")),
|
||||
runtime_configured=settings.runtime,
|
||||
runtime_requested=runtime_requested,
|
||||
runtime_override_applied=runtime_override is not None,
|
||||
runtime_kind=selection.kind if selection is not None else None,
|
||||
runtime_path=_runtime_path(selection),
|
||||
runtime_error=runtime_error,
|
||||
runtime_components=runtime_components,
|
||||
proton_sync_configured=settings.proton_sync,
|
||||
proton_sync_effective=proton_sync_effective,
|
||||
prefix=str(prefix) if prefix is not None else None,
|
||||
prefix_ready=prefix_ready,
|
||||
directx_ready=directx_ready,
|
||||
latest_game_log=str(latest_log) if latest_log is not None else None,
|
||||
latest_game_log_modified_at=_modified_at(latest_log),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def inspect_runtime_components(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
prefix: Path,
|
||||
) -> RuntimeComponentFingerprint:
|
||||
"""Fingerprint non-private runtime components for side-by-side reports."""
|
||||
|
||||
proton_script_sha256: str | None = None
|
||||
proton_version_sha256: str | None = None
|
||||
proton_toolmanifest_sha256: str | None = None
|
||||
proton_wine_sha256: str | None = None
|
||||
proton_wineserver_sha256: str | None = None
|
||||
if selection.proton_path:
|
||||
proton = Path(selection.proton_path)
|
||||
proton_script_sha256 = _sha256_if_file(proton)
|
||||
proton_version_sha256 = _sha256_if_file(proton.parent / "version")
|
||||
proton_toolmanifest_sha256 = _sha256_if_file(
|
||||
proton.parent / "toolmanifest.vdf"
|
||||
)
|
||||
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
|
||||
wine = directory / "wine"
|
||||
wineserver = directory / "wineserver"
|
||||
if wine.is_file() and wineserver.is_file():
|
||||
proton_wine_sha256 = _sha256_if_file(wine)
|
||||
proton_wineserver_sha256 = _sha256_if_file(wineserver)
|
||||
break
|
||||
prefix_dll_sha256 = {
|
||||
relative: _sha256_if_file(prefix / relative)
|
||||
for relative in PREFIX_COMPONENT_PATHS
|
||||
}
|
||||
runtime_marker = _read_json_object(paths.game_runtime_marker)
|
||||
marker_matches = (
|
||||
runtime_marker.get("runtime") == asdict(selection)
|
||||
if runtime_marker
|
||||
else None
|
||||
)
|
||||
return RuntimeComponentFingerprint(
|
||||
privacy=RUNTIME_COMPONENT_PRIVACY,
|
||||
runtime_wrapper_sha256=_sha256_if_file(Path(selection.executable)),
|
||||
proton_script_sha256=proton_script_sha256,
|
||||
proton_version_sha256=proton_version_sha256,
|
||||
proton_toolmanifest_sha256=proton_toolmanifest_sha256,
|
||||
proton_wine_sha256=proton_wine_sha256,
|
||||
proton_wineserver_sha256=proton_wineserver_sha256,
|
||||
prefix_dll_sha256=prefix_dll_sha256,
|
||||
registry_overrides=_runtime_registry_override_flags(prefix),
|
||||
runtime_marker_matches_selection=marker_matches,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_registry_override_flags(prefix: Path) -> dict[str, bool]:
|
||||
try:
|
||||
registry = (prefix / "user.reg").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).casefold()
|
||||
except OSError:
|
||||
registry = ""
|
||||
return {
|
||||
"d3d9_native_builtin": '"d3d9"="native,builtin"' in registry,
|
||||
"d3dx9_43_native": _native_override_present(registry, "d3dx9_43"),
|
||||
"d3dcompiler_43_native": _native_override_present(
|
||||
registry,
|
||||
"d3dcompiler_43",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _native_override_present(registry: str, name: str) -> bool:
|
||||
return any(
|
||||
entry in registry
|
||||
for entry in (
|
||||
f'"{name}"="native,builtin"',
|
||||
f'"{name}"="native"',
|
||||
f'"*{name}"="native"',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def classify_game_profile(
|
||||
game_exe_sha256: str | None,
|
||||
game_dll_sha256: str | None,
|
||||
) -> str:
|
||||
if game_exe_sha256 is None or game_dll_sha256 is None:
|
||||
return "missing"
|
||||
pair = {"Game.exe": game_exe_sha256, "game.dll": game_dll_sha256}
|
||||
if pair == CURRENT_V21_BINARY_HASHES:
|
||||
return "v21.75.83"
|
||||
if (
|
||||
game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"]
|
||||
and game_dll_sha256 == RETIRED_POST_AUTH_GAME_DLL_SHA256
|
||||
):
|
||||
return "retired-post-auth-disk-patch"
|
||||
if (
|
||||
game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"]
|
||||
and game_dll_sha256 == PATCHED_V21_GAME_DLL_SHA256
|
||||
):
|
||||
return "retired-texture-reclaim"
|
||||
if pair == COMPATIBILITY_BINARY_HASHES:
|
||||
return "verified-compatibility"
|
||||
known_game_exe_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["Game.exe"],
|
||||
COMPATIBILITY_BINARY_HASHES["Game.exe"],
|
||||
}
|
||||
known_game_dll_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["game.dll"],
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
||||
PATCHED_V21_GAME_DLL_SHA256,
|
||||
COMPATIBILITY_BINARY_HASHES["game.dll"],
|
||||
}
|
||||
if game_exe_sha256 in known_game_exe_hashes or game_dll_sha256 in known_game_dll_hashes:
|
||||
return "mismatched"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def latest_native_game_log(paths: AppPaths) -> Path | None:
|
||||
"""Return the newest native log from the active managed source or runtime view."""
|
||||
|
||||
directories = (paths.runtime_view / "save/logs", paths.source_game / "save/logs")
|
||||
logs: dict[Path, Path] = {}
|
||||
for directory in directories:
|
||||
try:
|
||||
candidates = directory.glob("log_*.txt")
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
logs[candidate.resolve()] = candidate
|
||||
except OSError:
|
||||
continue
|
||||
if not logs:
|
||||
return None
|
||||
try:
|
||||
return max(logs.values(), key=lambda item: item.stat().st_mtime_ns)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _active_game_root(paths: AppPaths) -> Path:
|
||||
if paths.runtime_view.is_dir():
|
||||
return paths.runtime_view
|
||||
return paths.source_game
|
||||
|
||||
|
||||
def _runtime_selection(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
) -> tuple[RuntimeSelection | None, str | None]:
|
||||
try:
|
||||
return select_runtime(settings, paths=paths), None
|
||||
except RuntimeError as exc:
|
||||
return None, str(exc)
|
||||
|
||||
|
||||
def _runtime_path(selection: RuntimeSelection | None) -> str | None:
|
||||
if selection is None:
|
||||
return None
|
||||
return selection.proton_path or selection.executable
|
||||
|
||||
|
||||
def _runtime_prefix(paths: AppPaths, selection: RuntimeSelection | None) -> Path | None:
|
||||
if selection is None:
|
||||
return None
|
||||
return (
|
||||
paths.proton_prefix
|
||||
if selection.kind in {"proton7", "proton7-container"}
|
||||
else paths.umu_prefix
|
||||
)
|
||||
|
||||
|
||||
def _prefix_ready(prefix: Path | None) -> bool:
|
||||
if prefix is None:
|
||||
return False
|
||||
return (prefix / "user.reg").is_file() and (
|
||||
prefix / "drive_c/windows/system32/cmd.exe"
|
||||
).is_file()
|
||||
|
||||
|
||||
def _diagnostic_warnings(
|
||||
probe: LoginServerProbe,
|
||||
game_profile: str,
|
||||
runtime_error: str | None,
|
||||
prefix_ready: bool,
|
||||
directx_ready: bool,
|
||||
latest_log: Path | None,
|
||||
runtime_matches_managed_source: bool,
|
||||
identity_probe: IdentityProbeResult | None,
|
||||
) -> tuple[str, ...]:
|
||||
warnings: list[str] = []
|
||||
if not probe.reachable:
|
||||
warnings.append(
|
||||
"The publisher login port is unreachable from this Linux host; check the "
|
||||
"network, firewall, VPN, or server status."
|
||||
)
|
||||
if game_profile == "missing":
|
||||
warnings.append("Game.exe or game.dll is missing; install or update the game first.")
|
||||
elif game_profile == "mismatched":
|
||||
warnings.append(
|
||||
"Game.exe and game.dll are from different verified releases; run update --repair."
|
||||
)
|
||||
elif game_profile == "unknown":
|
||||
warnings.append("Game.exe/game.dll do not match a launcher-verified client profile.")
|
||||
elif game_profile == "retired-texture-reclaim":
|
||||
warnings.append(
|
||||
"A retired experimental game.dll patch is active and can stall login; "
|
||||
"run prepare or launch the game once to restore the publisher DLL."
|
||||
)
|
||||
elif game_profile == "retired-post-auth-disk-patch":
|
||||
warnings.append(
|
||||
"The retired 0.5.8 disk-patched game.dll is active and can stall login; "
|
||||
"run prepare or launch with the current launcher to restore the publisher DLL."
|
||||
)
|
||||
if not runtime_matches_managed_source:
|
||||
warnings.append(
|
||||
"The runtime Game.exe/game.dll pair does not match the managed updater "
|
||||
"source; run prepare after update --repair before testing login again."
|
||||
)
|
||||
if runtime_error is not None:
|
||||
warnings.append(runtime_error)
|
||||
elif not prefix_ready:
|
||||
warnings.append("The selected game prefix is not initialized; run play once to prepare it.")
|
||||
elif not directx_ready:
|
||||
warnings.append("The native DirectX helper DLLs are not ready; run play to repair them.")
|
||||
if latest_log is None:
|
||||
warnings.append("No native game log was found yet.")
|
||||
if identity_probe is not None:
|
||||
warnings.extend(identity_probe.warnings)
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _integer_or_none(value: object) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _string_or_none(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_if_file(path: Path) -> str | None:
|
||||
try:
|
||||
return _sha256(path) if path.is_file() else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _modified_at(path: Path | None) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat()
|
||||
except OSError:
|
||||
return None
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Retirement state for the disabled login-trace command."""
|
||||
|
||||
DEFAULT_TRACE_TIMEOUT_SECONDS = 10 * 60.0
|
||||
LOGIN_TRACE_ENABLED = False
|
||||
LOGIN_TRACE_DISABLED_REASON = (
|
||||
"login-trace is retired and will not start the game: it enabled Wine relay "
|
||||
"instrumentation during authentication, while the current client performs "
|
||||
"anti-debug and module-integrity checks. Use login-diagnostics for a "
|
||||
"credential-free check, then use wine-update for one controlled comparison "
|
||||
"through the unmodified publisher launcher."
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
406
ptlinux/news.py
406
ptlinux/news.py
@@ -1,406 +0,0 @@
|
||||
"""Official news feed support for the native Linux dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Callable, Protocol
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
NEWS_ENDPOINT = "https://pristontale.eu/api/news/articles"
|
||||
NEWS_ARTICLE_URL = "https://pristontale.eu/news/"
|
||||
NEWS_ORIGIN = "https://pristontale.eu"
|
||||
OFFICIAL_LOGO_URL = "https://pristontale.eu/public/img/pristontale/logo/logo_small.png"
|
||||
BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts"
|
||||
MAX_NEWS_BYTES = 2 * 1024 * 1024
|
||||
MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024
|
||||
MAX_BROADCAST_AUTHOR_CHARACTERS = 100
|
||||
MAX_BROADCAST_CONTENT_CHARACTERS = 2000
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
class NewsError(RuntimeError):
|
||||
"""Raised when the official news feed cannot be parsed safely."""
|
||||
|
||||
|
||||
class NewsResponse(Protocol):
|
||||
"""The minimal response interface used by the news client."""
|
||||
|
||||
def read(self, size: int = -1) -> bytes: ...
|
||||
|
||||
def __enter__(self) -> NewsResponse: ...
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exception_type: type[BaseException] | None,
|
||||
exception: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> bool | None: ...
|
||||
|
||||
|
||||
NewsOpener = Callable[..., NewsResponse]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsArticle:
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
summary: str
|
||||
author: str | None
|
||||
published_at: str | None
|
||||
image_url: str | None = None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"{NEWS_ARTICLE_URL}{self.slug}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Broadcast:
|
||||
"""The latest announcement from the official broadcast API."""
|
||||
|
||||
author: str
|
||||
content: str
|
||||
published_at: str | None
|
||||
url: str
|
||||
|
||||
|
||||
def fetch_news_image(
|
||||
article: NewsArticle,
|
||||
cache_directory: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: NewsOpener = urlopen,
|
||||
) -> bytes | None:
|
||||
"""Return one verified PNG thumbnail, preferring the local image cache.
|
||||
|
||||
The official feed currently serves PNG article art. Deliberately accepting
|
||||
only PNG keeps the Tk UI dependency-free and avoids treating arbitrary remote
|
||||
data as a displayable image format.
|
||||
"""
|
||||
|
||||
if article.image_url is None:
|
||||
return None
|
||||
cache_path = _image_cache_path(cache_directory, article)
|
||||
cached = _read_png(cache_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return _fetch_png_image(article.image_url, cache_path, timeout_seconds, opener)
|
||||
|
||||
|
||||
def read_cached_news_image(
|
||||
article: NewsArticle,
|
||||
cache_directory: Path,
|
||||
) -> bytes | None:
|
||||
"""Read article artwork without contacting the network."""
|
||||
|
||||
if article.image_url is None:
|
||||
return None
|
||||
return _read_png(_image_cache_path(cache_directory, article))
|
||||
|
||||
|
||||
def fetch_official_logo(
|
||||
cache_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: NewsOpener = urlopen,
|
||||
) -> bytes | None:
|
||||
"""Return the official PristonTale EU PNG logo, using its local cache first."""
|
||||
|
||||
return _fetch_png_image(OFFICIAL_LOGO_URL, cache_path, timeout_seconds, opener)
|
||||
|
||||
|
||||
def read_cached_official_logo(cache_path: Path) -> bytes | None:
|
||||
"""Read the official logo cache without contacting the network."""
|
||||
|
||||
return _read_png(cache_path)
|
||||
|
||||
|
||||
def _fetch_png_image(
|
||||
url: str,
|
||||
cache_path: Path,
|
||||
timeout_seconds: float,
|
||||
opener: NewsOpener,
|
||||
) -> bytes | None:
|
||||
cached = _read_png(cache_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
request = Request(
|
||||
url,
|
||||
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_NEWS_IMAGE_BYTES + 1)
|
||||
except OSError:
|
||||
return None
|
||||
if len(payload) > MAX_NEWS_IMAGE_BYTES or not _is_png(payload):
|
||||
return None
|
||||
_write_image_cache(cache_path, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_news(
|
||||
cache_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: NewsOpener = urlopen,
|
||||
) -> tuple[NewsArticle, ...]:
|
||||
"""Fetch fresh official news, falling back to the last valid local cache."""
|
||||
|
||||
request = Request(
|
||||
NEWS_ENDPOINT,
|
||||
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_NEWS_BYTES + 1)
|
||||
if len(payload) > MAX_NEWS_BYTES:
|
||||
raise NewsError("official news response exceeds the 2 MiB limit")
|
||||
articles = _parse_news(payload)
|
||||
_write_cache(cache_path, articles)
|
||||
return articles
|
||||
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return read_cached_news(cache_path)
|
||||
|
||||
|
||||
def read_cached_news(cache_path: Path) -> tuple[NewsArticle, ...]:
|
||||
"""Read the last valid news feed without performing network I/O."""
|
||||
|
||||
return _read_cache(cache_path)
|
||||
|
||||
|
||||
def fetch_latest_broadcast(
|
||||
cache_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: NewsOpener = urlopen,
|
||||
) -> Broadcast | None:
|
||||
"""Fetch the first broadcast supplied by the official broadcast service."""
|
||||
|
||||
request = Request(
|
||||
BROADCAST_ENDPOINT,
|
||||
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_NEWS_BYTES + 1)
|
||||
if len(payload) > MAX_NEWS_BYTES:
|
||||
raise NewsError("broadcast response exceeds the 2 MiB limit")
|
||||
broadcast = _parse_latest_broadcast(payload)
|
||||
_write_broadcast_cache(cache_path, broadcast)
|
||||
return broadcast
|
||||
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return read_cached_broadcast(cache_path)
|
||||
|
||||
|
||||
def read_cached_broadcast(cache_path: Path) -> Broadcast | None:
|
||||
"""Read the last valid broadcast without performing network I/O."""
|
||||
|
||||
return _read_broadcast_cache(cache_path)
|
||||
|
||||
|
||||
def _parse_news(payload: bytes) -> tuple[NewsArticle, ...]:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
if isinstance(value, dict):
|
||||
value = value.get("data")
|
||||
if not isinstance(value, list):
|
||||
raise NewsError("official news response is not an article list")
|
||||
articles: list[NewsArticle] = []
|
||||
for item in value[:20]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
article_id = item.get("id")
|
||||
slug = item.get("slug")
|
||||
title = item.get("title")
|
||||
summary = item.get("summary")
|
||||
if (
|
||||
isinstance(article_id, bool)
|
||||
or not isinstance(article_id, int)
|
||||
or not isinstance(slug, str)
|
||||
or not slug.strip()
|
||||
or "/" in slug
|
||||
or not isinstance(title, str)
|
||||
or not title.strip()
|
||||
or not isinstance(summary, str)
|
||||
):
|
||||
continue
|
||||
author = item.get("author")
|
||||
date = item.get("date")
|
||||
articles.append(
|
||||
NewsArticle(
|
||||
id=article_id,
|
||||
slug=slug.strip(),
|
||||
title=title.strip(),
|
||||
summary=summary.strip(),
|
||||
author=author.strip() if isinstance(author, str) and author.strip() else None,
|
||||
published_at=_format_timestamp(date),
|
||||
image_url=_image_url(item.get("image")),
|
||||
)
|
||||
)
|
||||
if not articles:
|
||||
raise NewsError("official news response contains no usable articles")
|
||||
return tuple(articles)
|
||||
|
||||
|
||||
def _parse_latest_broadcast(payload: bytes) -> Broadcast:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
if not isinstance(value, list) or not value or not isinstance(value[0], dict):
|
||||
raise NewsError("broadcast response does not contain a latest broadcast")
|
||||
return _broadcast_from_mapping(value[0])
|
||||
|
||||
|
||||
def _broadcast_from_mapping(latest: dict[object, object]) -> Broadcast:
|
||||
author = _bounded_text(latest.get("author"), MAX_BROADCAST_AUTHOR_CHARACTERS)
|
||||
content = _bounded_text(latest.get("content"), MAX_BROADCAST_CONTENT_CHARACTERS)
|
||||
url = _broadcast_url(latest.get("url"))
|
||||
if (
|
||||
author is None
|
||||
or content is None
|
||||
or url is None
|
||||
):
|
||||
raise NewsError("latest broadcast is incomplete")
|
||||
published_at = (
|
||||
_format_timestamp(latest.get("timestamp"))
|
||||
if "timestamp" in latest
|
||||
else _bounded_text(latest.get("published_at"), 64)
|
||||
)
|
||||
return Broadcast(
|
||||
author=author,
|
||||
content=content,
|
||||
published_at=published_at,
|
||||
url=url,
|
||||
)
|
||||
|
||||
|
||||
def _bounded_text(value: object, maximum_characters: int) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text or len(text) > maximum_characters:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _format_timestamp(value: object) -> str | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _image_url(value: object) -> str | None:
|
||||
"""Accept only an official, relative article-art path from the feed."""
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme or parsed.netloc or not value.startswith("/"):
|
||||
return None
|
||||
if not parsed.path.startswith("/storage/media/images/"):
|
||||
return None
|
||||
if any(part in ("", ".", "..") for part in parsed.path.split("/")[1:]):
|
||||
return None
|
||||
return urljoin(NEWS_ORIGIN, value)
|
||||
|
||||
|
||||
def _broadcast_url(value: object) -> str | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
url = value.strip()
|
||||
parsed = urlparse(url)
|
||||
if (
|
||||
parsed.scheme.casefold() != "https"
|
||||
or not parsed.netloc
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
return None
|
||||
return url
|
||||
|
||||
|
||||
def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(NewsArticle(**item) for item in value if isinstance(item, dict))
|
||||
except (OSError, UnicodeError, TypeError, json.JSONDecodeError):
|
||||
return ()
|
||||
|
||||
|
||||
def _write_cache(path: Path, articles: tuple[NewsArticle, ...]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps([asdict(article) for article in articles], indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _read_broadcast_cache(path: Path) -> Broadcast | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
return _broadcast_from_mapping(value) if isinstance(value, dict) else None
|
||||
except (OSError, UnicodeError, NewsError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_broadcast_cache(path: Path, broadcast: Broadcast) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps(asdict(broadcast), indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _image_cache_path(cache_directory: Path, article: NewsArticle) -> Path:
|
||||
assert article.image_url is not None
|
||||
digest = hashlib.sha256(article.image_url.encode("utf-8")).hexdigest()[:16]
|
||||
return cache_directory / f"{article.id}-{digest}.png"
|
||||
|
||||
|
||||
def _read_png(path: Path) -> bytes | None:
|
||||
try:
|
||||
payload = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
return payload if _is_png(payload) else None
|
||||
|
||||
|
||||
def _is_png(payload: bytes) -> bool:
|
||||
return PNG_SIGNATURE == payload[:len(PNG_SIGNATURE)] and len(payload) <= MAX_NEWS_IMAGE_BYTES
|
||||
|
||||
|
||||
def _write_image_cache(path: Path, payload: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(payload)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
1209
ptlinux/overlay.py
1209
ptlinux/overlay.py
File diff suppressed because it is too large
Load Diff
322
ptlinux/paths.py
322
ptlinux/paths.py
@@ -1,322 +0,0 @@
|
||||
"""Per-user game data and XDG support paths used by the installer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
APP_DIRECTORY_NAME = "pristontale-eu"
|
||||
DEFAULT_INSTALL_DIRECTORY_NAME = "PristontaleEU"
|
||||
INSTALL_DIRECTORY_VARIABLE = "PTLINUX_INSTALL_DIR"
|
||||
GAME_RELATIVE_PATH = Path("drive_c/Program Files (x86)/Pristontale EU")
|
||||
|
||||
|
||||
def _xdg_path(
|
||||
environment: Mapping[str, str],
|
||||
variable: str,
|
||||
fallback: Path,
|
||||
) -> Path:
|
||||
value = environment.get(variable)
|
||||
if not value:
|
||||
return fallback
|
||||
path = Path(value).expanduser()
|
||||
# The XDG Base Directory specification requires absolute paths. Ignoring an
|
||||
# invalid relative value keeps settings anchored to HOME instead of making
|
||||
# their location depend on the launcher's current working directory.
|
||||
return path if path.is_absolute() else fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppPaths:
|
||||
"""All persistent paths for one user's installation."""
|
||||
|
||||
data: Path
|
||||
config: Path
|
||||
state: Path
|
||||
cache: Path
|
||||
|
||||
@classmethod
|
||||
def from_environment(
|
||||
cls,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> AppPaths:
|
||||
env = os.environ if environment is None else environment
|
||||
home_value = env.get("HOME")
|
||||
if not home_value:
|
||||
raise RuntimeError("HOME is not set")
|
||||
home = Path(home_value).expanduser()
|
||||
config_home = _xdg_path(env, "XDG_CONFIG_HOME", home / ".config")
|
||||
state_home = _xdg_path(env, "XDG_STATE_HOME", home / ".local/state")
|
||||
cache_home = _xdg_path(env, "XDG_CACHE_HOME", home / ".cache")
|
||||
install_value = env.get(INSTALL_DIRECTORY_VARIABLE)
|
||||
if install_value:
|
||||
data = Path(install_value).expanduser()
|
||||
if not data.is_absolute():
|
||||
raise RuntimeError(
|
||||
f"{INSTALL_DIRECTORY_VARIABLE} must be an absolute path"
|
||||
)
|
||||
else:
|
||||
data = home / "Games" / DEFAULT_INSTALL_DIRECTORY_NAME
|
||||
return cls(
|
||||
data=data.resolve(),
|
||||
config=(config_home / APP_DIRECTORY_NAME).resolve(),
|
||||
state=(state_home / APP_DIRECTORY_NAME).resolve(),
|
||||
cache=(cache_home / APP_DIRECTORY_NAME).resolve(),
|
||||
)
|
||||
|
||||
@property
|
||||
def downloads(self) -> Path:
|
||||
return self.cache / "downloads"
|
||||
|
||||
@property
|
||||
def runtime_tools(self) -> Path:
|
||||
"""Launcher-managed helpers that are independent of app releases."""
|
||||
|
||||
return self.data / "runtime-tools"
|
||||
|
||||
@property
|
||||
def managed_umu_executable(self) -> Path:
|
||||
return self.runtime_tools / "bin/umu-run"
|
||||
|
||||
@property
|
||||
def managed_umu_metadata(self) -> Path:
|
||||
return self.state / "umu-launcher.json"
|
||||
|
||||
def managed_umu_archive(self, version: str) -> Path:
|
||||
return self.downloads / f"umu-launcher-{version}-zipapp.tar"
|
||||
|
||||
@property
|
||||
def app_home(self) -> Path:
|
||||
"""Stable home containing versioned native-launcher releases."""
|
||||
|
||||
return self.data / "app"
|
||||
|
||||
@property
|
||||
def launcher_releases(self) -> Path:
|
||||
return self.app_home / "releases"
|
||||
|
||||
@property
|
||||
def launcher_current(self) -> Path:
|
||||
return self.app_home / "current"
|
||||
|
||||
@property
|
||||
def launcher_previous(self) -> Path:
|
||||
return self.app_home / "previous"
|
||||
|
||||
@property
|
||||
def launcher_update_state(self) -> Path:
|
||||
return self.state / "launcher-update.json"
|
||||
|
||||
@property
|
||||
def launcher_update_lock(self) -> Path:
|
||||
return self.state / "launcher-update.lock"
|
||||
|
||||
@property
|
||||
def launcher_update_check_cache(self) -> Path:
|
||||
"""Display-only cache of the last authenticated update check."""
|
||||
|
||||
return self.cache / "launcher-update-check.json"
|
||||
|
||||
@property
|
||||
def launcher_release_history_cache(self) -> Path:
|
||||
"""Display-only cache of the last authenticated release history."""
|
||||
|
||||
return self.cache / "launcher-release-history.json"
|
||||
|
||||
@property
|
||||
def launcher_update_operations(self) -> Path:
|
||||
"""Bounded local audit trail for launcher updates and rollbacks."""
|
||||
|
||||
return self.state / "launcher-update-history.json"
|
||||
|
||||
@property
|
||||
def launcher_update_transaction(self) -> Path:
|
||||
"""Crash-recovery journal for launcher release-link changes."""
|
||||
|
||||
return self.state / "launcher-update-transaction.json"
|
||||
|
||||
@property
|
||||
def launcher_release_integrity(self) -> Path:
|
||||
"""Authenticated file inventories retained for installed releases."""
|
||||
|
||||
return self.state / "launcher-release-integrity"
|
||||
|
||||
@property
|
||||
def container_host_repair_state(self) -> Path:
|
||||
"""One-shot migration state for the Ubuntu Bubblewrap host repair."""
|
||||
|
||||
return self.state / "container-host-repair-v1.json"
|
||||
|
||||
def launcher_update_archive(self, version: str) -> Path:
|
||||
return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz"
|
||||
|
||||
@property
|
||||
def launcher_pristine(self) -> Path:
|
||||
return self.downloads / "Pristontale EU Launcher.exe"
|
||||
|
||||
@property
|
||||
def launcher_patched(self) -> Path:
|
||||
return self.data / "launcher" / "Pristontale EU Launcher.exe"
|
||||
|
||||
@property
|
||||
def launcher_metadata(self) -> Path:
|
||||
return self.state / "launcher-download.json"
|
||||
|
||||
@property
|
||||
def launcher_prefix(self) -> Path:
|
||||
return self.data / "launcher-prefix"
|
||||
|
||||
@property
|
||||
def launcher_prefix_marker(self) -> Path:
|
||||
return self.state / "launcher-prefix.json"
|
||||
|
||||
@property
|
||||
def launcher_operation_lock(self) -> Path:
|
||||
return self.state / "launcher-operation.lock"
|
||||
|
||||
@property
|
||||
def game_runtime_marker(self) -> Path:
|
||||
"""Verified Proton/D3D preparation state for the game prefix."""
|
||||
|
||||
return self.state / "game-runtime.json"
|
||||
|
||||
@property
|
||||
def source_game(self) -> Path:
|
||||
return self.launcher_prefix / GAME_RELATIVE_PATH
|
||||
|
||||
@property
|
||||
def game_import_manifest(self) -> Path:
|
||||
return self.state / "game-import.json"
|
||||
|
||||
@property
|
||||
def game_import_quarantine(self) -> Path:
|
||||
"""Recoverable files excluded while authenticating an imported game."""
|
||||
|
||||
return self.data / "import-quarantine"
|
||||
|
||||
@property
|
||||
def native_depot_state(self) -> Path:
|
||||
"""Last completed native depot synchronization."""
|
||||
|
||||
return self.state / "native-depot.json"
|
||||
|
||||
@property
|
||||
def depot_launch_catalog_cache(self) -> Path:
|
||||
"""Last official realm choices used for network-free game launches."""
|
||||
|
||||
return self.cache / "depot-launch-catalog.json"
|
||||
|
||||
@property
|
||||
def depot_file_catalog(self) -> Path:
|
||||
"""Verified manifest paths and cheap identities used before Play."""
|
||||
|
||||
return self.state / "native-depot-files.json"
|
||||
|
||||
@property
|
||||
def news_cache(self) -> Path:
|
||||
return self.cache / "news.json"
|
||||
|
||||
@property
|
||||
def patches_cache(self) -> Path:
|
||||
"""Cached official patch changelog shown beside the news feed."""
|
||||
|
||||
return self.cache / "patches.json"
|
||||
|
||||
@property
|
||||
def broadcast_cache(self) -> Path:
|
||||
"""Cached latest announcement from the broadcast service."""
|
||||
|
||||
return self.cache / "broadcast.json"
|
||||
|
||||
@property
|
||||
def news_image_cache(self) -> Path:
|
||||
"""Cached official PNG thumbnails used by the native news cards."""
|
||||
|
||||
return self.cache / "news-images"
|
||||
|
||||
@property
|
||||
def logo_cache(self) -> Path:
|
||||
"""Cached PNG asset for the official PristonTale EU logo."""
|
||||
|
||||
return self.cache / "logo-small.png"
|
||||
|
||||
@property
|
||||
def runtime_view(self) -> Path:
|
||||
return self.data / "runtime-view"
|
||||
|
||||
@property
|
||||
def runtime_manifest(self) -> Path:
|
||||
return self.state / "runtime-view.json"
|
||||
|
||||
@property
|
||||
def game_user_data(self) -> Path:
|
||||
"""Writable game-created data kept outside the replaceable runtime view."""
|
||||
|
||||
return self.data / "user-data"
|
||||
|
||||
@property
|
||||
def game_save(self) -> Path:
|
||||
return self.game_user_data / "save"
|
||||
|
||||
@property
|
||||
def game_config(self) -> Path:
|
||||
"""Writable game settings kept outside the launcher-managed source tree."""
|
||||
|
||||
return self.game_user_data / "game.ini"
|
||||
|
||||
@property
|
||||
def compatibility_binaries(self) -> Path:
|
||||
return self.data / "compatibility-binaries"
|
||||
|
||||
@property
|
||||
def compatibility_manifest(self) -> Path:
|
||||
return self.state / "compatibility-binaries.json"
|
||||
|
||||
@property
|
||||
def compat_data(self) -> Path:
|
||||
return self.data / "game-compat"
|
||||
|
||||
@property
|
||||
def proton_prefix(self) -> Path:
|
||||
return self.compat_data / "pfx"
|
||||
|
||||
@property
|
||||
def umu_prefix(self) -> Path:
|
||||
return self.data / "umu-prefix"
|
||||
|
||||
@property
|
||||
def dxvk_cache(self) -> Path:
|
||||
return self.cache / "dxvk-state"
|
||||
|
||||
@property
|
||||
def logs(self) -> Path:
|
||||
return self.state / "logs"
|
||||
|
||||
@property
|
||||
def settings_file(self) -> Path:
|
||||
return self.config / "settings.json"
|
||||
|
||||
@property
|
||||
def settings_lock(self) -> Path:
|
||||
return self.config / "settings.lock"
|
||||
|
||||
@property
|
||||
def installation_id_file(self) -> Path:
|
||||
"""Anonymous telemetry identity stored separately from user preferences."""
|
||||
|
||||
return self.config / "install-id"
|
||||
|
||||
def create_base_directories(self) -> None:
|
||||
for path in (
|
||||
self.data,
|
||||
self.config,
|
||||
self.state,
|
||||
self.cache,
|
||||
self.downloads,
|
||||
self.logs,
|
||||
self.dxvk_cache,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Compatibility entry point for retained Valve Proton 7 scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import tarfile
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> None:
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if not arguments:
|
||||
raise SystemExit("usage: proton_compat.py /path/to/proton [arguments...]")
|
||||
proton = Path(arguments[0]).expanduser().resolve(strict=True)
|
||||
proton_directory = str(proton.parent)
|
||||
if proton_directory not in sys.path:
|
||||
sys.path.insert(0, proton_directory)
|
||||
if hasattr(tarfile, "fully_trusted_filter"):
|
||||
setattr(
|
||||
tarfile.TarFile,
|
||||
"extraction_filter",
|
||||
staticmethod(tarfile.fully_trusted_filter),
|
||||
)
|
||||
sys.argv = [str(proton), *arguments[1:]]
|
||||
runpy.run_path(str(proton), run_name="__main__")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,999 +0,0 @@
|
||||
"""Portable game runtime selection and Proton/UMU preparation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||
from .container_host import container_host_error, inspect_container_host
|
||||
from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
|
||||
from .gamebox import (
|
||||
GameboxConfigurationError,
|
||||
build_gamebox_command,
|
||||
find_gamescope,
|
||||
read_game_canvas_size,
|
||||
)
|
||||
from .paths import AppPaths
|
||||
from .settings import Settings
|
||||
from .system_identity import (
|
||||
detect_distribution_id as _detect_distribution_id,
|
||||
is_running_inside_steam_runtime as _is_running_inside_steam_runtime,
|
||||
)
|
||||
from .umu import find_umu_launcher
|
||||
|
||||
|
||||
WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
|
||||
WINDOWS_GAME_EXE = rf"{WINDOWS_GAME_DIRECTORY}\Game.exe"
|
||||
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
|
||||
DXVK_D3D9_OVERRIDE = "native,builtin"
|
||||
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
|
||||
WINETRICKS_CODEPAGE_PROBE = (
|
||||
'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"'
|
||||
)
|
||||
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
|
||||
RUNTIME_DEFAULT_SYNC = "runtime-default"
|
||||
CACHYOS_MARKER = "cachyos"
|
||||
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):
|
||||
"""Raised when a supported game runtime cannot be prepared."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeSelection:
|
||||
kind: str
|
||||
executable: str
|
||||
proton_path: str | None
|
||||
steam_root: str | None
|
||||
|
||||
|
||||
def select_runtime(
|
||||
settings: Settings,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
*,
|
||||
paths: AppPaths | None = None,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> RuntimeSelection:
|
||||
env = os.environ if environment is None else environment
|
||||
umu = find_umu_launcher(paths, env)
|
||||
current_umu_host = prefers_current_umu_host(
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
auto_container = (
|
||||
settings.runtime == "auto"
|
||||
and bool(umu)
|
||||
and not current_umu_host
|
||||
)
|
||||
if settings.runtime == "umu":
|
||||
if umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
raise RuntimeErrorPT("UMU was requested but umu-run was not found")
|
||||
if settings.proton_path and settings.runtime in {
|
||||
"auto",
|
||||
"proton7",
|
||||
"proton7-container",
|
||||
}:
|
||||
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, env)
|
||||
if settings.runtime == "proton7-container":
|
||||
if not umu:
|
||||
raise RuntimeErrorPT(
|
||||
"containerized Proton 7 was requested but umu-run was not found"
|
||||
)
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
if auto_container:
|
||||
assert umu is not None
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||
# Current UMU avoids several rolling-distribution container/media failures and
|
||||
# remains the broad Arch-family compatibility default. Direct Proton 7 is also
|
||||
# known to authenticate on CachyOS, so this is a compatibility policy rather
|
||||
# than an assertion that Proton 7 itself causes server-side Code 1/Code 3.
|
||||
if settings.runtime == "auto" and current_umu_host and umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
if settings.runtime in {"auto", "proton7", "proton7-container"}:
|
||||
proton_result = find_steam_proton7(env)
|
||||
if proton_result is not None:
|
||||
proton, steam_root = proton_result
|
||||
if settings.runtime == "proton7-container" or auto_container:
|
||||
if not umu:
|
||||
raise RuntimeErrorPT(
|
||||
"containerized Proton 7 was requested but umu-run was not found"
|
||||
)
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||
if settings.runtime in {"proton7", "proton7-container"}:
|
||||
raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
|
||||
if settings.runtime in {"auto", "umu"}:
|
||||
if umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
raise RuntimeErrorPT(
|
||||
"no supported game runtime found; install umu-launcher or Steam Proton 7.0"
|
||||
)
|
||||
raise RuntimeErrorPT(f"unsupported runtime selection: {settings.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
|
||||
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:
|
||||
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 path in paths:
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
continue
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
result.append(resolved)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def prepare_game_runtime(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
) -> list[CommandOutcome]:
|
||||
_require_container_host(selection, runner)
|
||||
if not runner.dry_run:
|
||||
paths.create_base_directories()
|
||||
results: list[CommandOutcome] = []
|
||||
if selection.kind == "umu":
|
||||
environment = _umu_environment(paths, selection)
|
||||
if not native_d3d_ready(paths.umu_prefix):
|
||||
results.append(
|
||||
runner.run(
|
||||
[selection.executable, "winetricks", "-q", "d3dx9_43", "d3dcompiler_43"],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=1800.0,
|
||||
telemetry_stage="directx_install",
|
||||
)
|
||||
)
|
||||
elif selection.kind in {"proton7", "proton7-container"}:
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
if not runner.dry_run and _game_runtime_ready(paths, selection):
|
||||
return results
|
||||
if not runner.dry_run:
|
||||
paths.compat_data.mkdir(parents=True, exist_ok=True)
|
||||
proton = Path(selection.proton_path)
|
||||
environment = _proton_environment(paths, selection)
|
||||
prefix_verb = "runinprefix" if _proton_prefix_ready(paths.proton_prefix) else "run"
|
||||
results.append(
|
||||
runner.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
str(proton),
|
||||
prefix_verb,
|
||||
"cmd",
|
||||
"/c",
|
||||
"exit",
|
||||
],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=600.0,
|
||||
telemetry_stage="prefix_create",
|
||||
)
|
||||
)
|
||||
if not runner.dry_run:
|
||||
_ensure_proton_game_link(paths)
|
||||
results.append(
|
||||
runner.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
str(proton),
|
||||
"runinprefix",
|
||||
"reg.exe",
|
||||
"add",
|
||||
DLL_OVERRIDES_KEY,
|
||||
"/v",
|
||||
"d3d9",
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
DXVK_D3D9_OVERRIDE,
|
||||
"/f",
|
||||
],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=60.0,
|
||||
telemetry_stage="dxvk_configuration",
|
||||
)
|
||||
)
|
||||
if not native_d3d_ready(paths.proton_prefix):
|
||||
wine_tools = _find_proton_wine_tools(proton)
|
||||
winetricks = shutil.which("winetricks")
|
||||
if wine_tools is None or not winetricks:
|
||||
raise RuntimeErrorPT("Proton Winetricks helpers are unavailable")
|
||||
wine, wineserver = wine_tools
|
||||
compatible_winetricks = prepare_proton7_winetricks(
|
||||
Path(winetricks),
|
||||
paths.cache / "compat/winetricks-proton7",
|
||||
dry_run=runner.dry_run,
|
||||
)
|
||||
results.append(
|
||||
runner.run(
|
||||
[
|
||||
str(compatible_winetricks),
|
||||
"-q",
|
||||
"d3dx9_43",
|
||||
"d3dcompiler_43",
|
||||
],
|
||||
env_overrides={
|
||||
"WINEPREFIX": str(paths.proton_prefix),
|
||||
"WINE": str(wine),
|
||||
"WINESERVER": str(wineserver),
|
||||
"WINEDEBUG": "-all",
|
||||
},
|
||||
timeout_seconds=1800.0,
|
||||
telemetry_stage="directx_install",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
|
||||
if not runner.dry_run:
|
||||
_write_json(
|
||||
paths.game_runtime_marker,
|
||||
{
|
||||
"configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION,
|
||||
"configured_at": datetime.now().astimezone().isoformat(),
|
||||
"native_d3d43": True,
|
||||
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
|
||||
"proton_identity": _proton_identity(selection),
|
||||
"runtime": asdict(selection),
|
||||
},
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
|
||||
"""Return whether another client may reuse the prepared runtime read-only."""
|
||||
|
||||
if selection.kind == "umu":
|
||||
return native_d3d_ready(paths.umu_prefix)
|
||||
if selection.kind in {"proton7", "proton7-container"}:
|
||||
return _game_runtime_ready(paths, selection)
|
||||
return False
|
||||
|
||||
|
||||
def _read_game_canvas_size_or_warn(game_config: Path) -> tuple[int, int] | None:
|
||||
try:
|
||||
return read_game_canvas_size(game_config)
|
||||
except GameboxConfigurationError as exc:
|
||||
print(
|
||||
f"Warning: {exc}; using a fixed game window.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def run_game(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
start_option: DepotStartOption = DEFAULT_DEPOT_START_OPTION,
|
||||
use_gamebox: bool = True,
|
||||
concurrent_prefix_launch: bool = False,
|
||||
timeout_seconds: float = 12 * 60 * 60,
|
||||
check: bool = True,
|
||||
extra_environment: Mapping[str, str] | None = None,
|
||||
output_line_filter: OutputLineFilter | None = None,
|
||||
pass_fds: Sequence[int] = (),
|
||||
) -> CommandOutcome:
|
||||
_require_container_host(selection, runner)
|
||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
||||
native_executable, windows_executable, game_arguments = _game_start_command(
|
||||
paths,
|
||||
start_option,
|
||||
)
|
||||
if selection.kind == "umu":
|
||||
game_command = [
|
||||
selection.executable,
|
||||
native_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _umu_environment(paths, selection)
|
||||
elif selection.kind == "proton7-container":
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
game_command = [
|
||||
selection.executable,
|
||||
native_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _umu_proton7_environment(
|
||||
paths,
|
||||
selection,
|
||||
synchronization=resolve_proton_sync_mode(settings.proton_sync),
|
||||
)
|
||||
else:
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
game_command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
windows_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _proton_environment(
|
||||
paths,
|
||||
selection,
|
||||
synchronization=resolve_proton_sync_mode(settings.proton_sync),
|
||||
)
|
||||
if selection.kind in {"umu", "proton7-container"}:
|
||||
# Keep every UMU launch identical except for the verb, as required when
|
||||
# several games share one prefix. The first client owns prefix startup;
|
||||
# every later shared-lease client joins the active wineserver directly.
|
||||
environment["PROTON_VERB"] = (
|
||||
"runinprefix" if concurrent_prefix_launch else "waitforexitandrun"
|
||||
)
|
||||
if extra_environment:
|
||||
environment.update(extra_environment)
|
||||
if use_gamebox:
|
||||
gamescope_path = None
|
||||
game_size = None
|
||||
if settings.allow_window_resizing and launcher_inside_steam_runtime():
|
||||
print(
|
||||
"Warning: resizable Gamescope was disabled because the launcher "
|
||||
"is already inside Steam Runtime; using a fixed game window.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif settings.allow_window_resizing:
|
||||
gamescope_environment = os.environ.copy()
|
||||
gamescope_environment.update(environment)
|
||||
gamescope_path = find_gamescope(gamescope_environment)
|
||||
if gamescope_path is None:
|
||||
print(
|
||||
"Warning: a resizable Gamescope window was requested, but "
|
||||
"Gamescope was not found; using a fixed game window.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
game_size = _read_game_canvas_size_or_warn(paths.game_config)
|
||||
if game_size is not None:
|
||||
width, height = game_size
|
||||
print(
|
||||
"Optimized stable resize enabled through Gamescope: the game "
|
||||
f"starts at {width}x{height} and keeps its aspect ratio while "
|
||||
"the outer window scales.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
game_command = build_gamebox_command(
|
||||
game_command,
|
||||
settings,
|
||||
game_size=game_size,
|
||||
gamescope_path=gamescope_path,
|
||||
)
|
||||
environment["PYTHONPATH"] = _runtime_python_path(
|
||||
os.environ.get("PYTHONPATH"),
|
||||
)
|
||||
return runner.run(
|
||||
game_command,
|
||||
cwd=paths.runtime_view,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
stream_output=True,
|
||||
output_line_filter=output_line_filter,
|
||||
pass_fds=pass_fds,
|
||||
telemetry_stage="game_start",
|
||||
# Play distinguishes a failed launch from a nonzero status returned after
|
||||
# a visible game session. Its game-specific event retains that context.
|
||||
report_failure_telemetry=False,
|
||||
)
|
||||
|
||||
|
||||
def _game_start_command(
|
||||
paths: AppPaths,
|
||||
option: DepotStartOption,
|
||||
) -> tuple[str, str, tuple[str, ...]]:
|
||||
relative = PurePosixPath(option.path)
|
||||
if (
|
||||
relative.is_absolute()
|
||||
or not relative.parts
|
||||
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||
or relative.suffix.casefold() != ".exe"
|
||||
):
|
||||
raise RuntimeErrorPT(f"invalid realm executable path: {option.path!r}")
|
||||
try:
|
||||
arguments = tuple(shlex.split(option.arguments))
|
||||
except ValueError as exc:
|
||||
raise RuntimeErrorPT(
|
||||
f"invalid launch arguments for realm {option.name}: {exc}"
|
||||
) from exc
|
||||
if not arguments:
|
||||
raise RuntimeErrorPT(f"realm {option.name} has no launch arguments")
|
||||
native = str(_resolve_runtime_path_case(paths.runtime_view, relative))
|
||||
windows_relative = "\\".join(relative.parts)
|
||||
return native, rf"{WINDOWS_GAME_DIRECTORY}\{windows_relative}", arguments
|
||||
|
||||
|
||||
def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path:
|
||||
"""Match Windows manifest casing against the case-sensitive runtime view."""
|
||||
|
||||
direct = root.joinpath(*relative.parts)
|
||||
if direct.exists():
|
||||
return direct
|
||||
cursor = root
|
||||
for part in relative.parts:
|
||||
if not cursor.is_dir():
|
||||
return direct
|
||||
try:
|
||||
matches = tuple(
|
||||
entry for entry in cursor.iterdir() if entry.name.casefold() == part.casefold()
|
||||
)
|
||||
except OSError:
|
||||
return direct
|
||||
if not matches:
|
||||
return direct
|
||||
if len(matches) > 1:
|
||||
raise RuntimeErrorPT(
|
||||
f"realm executable path has ambiguous casing: {relative.as_posix()}"
|
||||
)
|
||||
cursor = matches[0]
|
||||
return cursor
|
||||
|
||||
|
||||
def run_runtime_prefix_command(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> CommandOutcome:
|
||||
"""Run a credential-free helper inside the selected game's Wine prefix."""
|
||||
|
||||
if not arguments:
|
||||
raise ValueError("runtime-prefix command arguments cannot be empty")
|
||||
_require_container_host(selection, runner)
|
||||
if selection.kind == "umu":
|
||||
command = [selection.executable, *arguments]
|
||||
environment = _umu_environment(paths, selection)
|
||||
elif selection.kind == "proton7-container":
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
command = [selection.executable, *arguments]
|
||||
environment = _umu_proton7_environment(paths, selection)
|
||||
elif selection.kind == "proton7":
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
*arguments,
|
||||
]
|
||||
environment = _proton_environment(paths, selection)
|
||||
else:
|
||||
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
|
||||
return runner.run(
|
||||
command,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
telemetry_stage="runtime_check",
|
||||
)
|
||||
|
||||
|
||||
def _require_container_host(
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
) -> None:
|
||||
if runner.dry_run or selection.kind not in {"umu", "proton7-container"}:
|
||||
return
|
||||
report = inspect_container_host()
|
||||
if not report.ready:
|
||||
raise RuntimeErrorPT(container_host_error(report))
|
||||
|
||||
|
||||
def _runtime_python_path(existing: str | None) -> str:
|
||||
"""Make the installed package importable after changing into runtime_view."""
|
||||
package_root = str(Path(__file__).resolve().parent.parent)
|
||||
if not existing:
|
||||
return package_root
|
||||
return os.pathsep.join((package_root, existing))
|
||||
|
||||
|
||||
def _umu_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
) -> dict[str, str]:
|
||||
environment = {
|
||||
"WINEPREFIX": str(paths.umu_prefix),
|
||||
"GAMEID": "umu-pristontale-eu",
|
||||
"STORE": "none",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
"WINEDEBUG": "-all",
|
||||
}
|
||||
environment.update(_portable_umu_environment(paths, selection))
|
||||
return environment
|
||||
|
||||
|
||||
def _umu_proton7_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
*,
|
||||
synchronization: str = RUNTIME_DEFAULT_SYNC,
|
||||
) -> dict[str, str]:
|
||||
"""Run the retained Proton 7 build inside its Steam Runtime container."""
|
||||
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
environment = {
|
||||
# UMU expects the Proton compatibility-data root and manages its pfx child.
|
||||
# Pointing it at proton_prefix would create a nested pfx -> . compatibility
|
||||
# symlink; compat_data preserves the exact prefix prepared by Proton 7.
|
||||
"WINEPREFIX": str(paths.compat_data),
|
||||
"GAMEID": "umu-pristontale-eu",
|
||||
"STORE": "none",
|
||||
"PROTONPATH": str(Path(selection.proton_path).parent),
|
||||
"PROTON_VERB": "waitforexitandrun",
|
||||
"PROTONFIXES_DISABLE": "1",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
"WINEDEBUG": "-all",
|
||||
}
|
||||
environment.update(_portable_umu_environment(paths, selection))
|
||||
environment.update(_proton_sync_environment(synchronization))
|
||||
return environment
|
||||
|
||||
|
||||
def _portable_umu_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
) -> dict[str, str]:
|
||||
"""Keep downloads made by our zipapp inside the selected installation."""
|
||||
|
||||
try:
|
||||
selected = Path(selection.executable).expanduser().resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return {}
|
||||
if selected != paths.managed_umu_executable.resolve():
|
||||
return {}
|
||||
return {"UMU_FOLDERS_PATH": str(paths.runtime_tools)}
|
||||
|
||||
|
||||
def _proton_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
*,
|
||||
synchronization: str = RUNTIME_DEFAULT_SYNC,
|
||||
) -> dict[str, str]:
|
||||
if not selection.steam_root:
|
||||
raise RuntimeErrorPT("Steam root is missing for Proton")
|
||||
environment = {
|
||||
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
|
||||
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
|
||||
"STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view),
|
||||
"SteamAppId": "0",
|
||||
"SteamGameId": "0",
|
||||
"WINEPREFIX": str(paths.proton_prefix),
|
||||
"WINEDEBUG": "-all",
|
||||
"PROTON_LOG": "0",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
}
|
||||
environment.update(_proton_sync_environment(synchronization))
|
||||
return environment
|
||||
|
||||
|
||||
def detect_distribution_id() -> str:
|
||||
"""Return the freedesktop distribution ID without invoking distro tools."""
|
||||
|
||||
return _detect_distribution_id()
|
||||
|
||||
|
||||
def launcher_inside_steam_runtime() -> bool:
|
||||
"""Return whether this launcher process is nested in Steam Runtime."""
|
||||
|
||||
return _is_running_inside_steam_runtime()
|
||||
|
||||
|
||||
def is_cachyos_host(
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> bool:
|
||||
"""Identify CachyOS narrowly for runtime compatibility policy."""
|
||||
|
||||
distribution = detect_distribution_id() if distribution_id is None else distribution_id
|
||||
kernel = platform.release() if kernel_release is None else kernel_release
|
||||
return (
|
||||
CACHYOS_MARKER in distribution.casefold()
|
||||
or CACHYOS_MARKER in kernel.casefold()
|
||||
)
|
||||
|
||||
|
||||
def prefers_current_umu_host(
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> bool:
|
||||
"""Use current UMU automatically on rolling Arch-family hosts."""
|
||||
|
||||
distribution = detect_distribution_id() if distribution_id is None else distribution_id
|
||||
return (
|
||||
distribution.strip().casefold() in CURRENT_UMU_DISTRIBUTIONS
|
||||
or is_cachyos_host(
|
||||
distribution_id=distribution,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resolve_proton_sync_mode(
|
||||
configured: str,
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the configured mode, applying the narrow CachyOS compatibility fallback."""
|
||||
|
||||
if configured not in {"auto", "fsync", "esync"}:
|
||||
raise ValueError(f"unsupported Proton synchronization mode: {configured}")
|
||||
if configured != "auto":
|
||||
return configured
|
||||
if is_cachyos_host(
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
):
|
||||
return "esync"
|
||||
return RUNTIME_DEFAULT_SYNC
|
||||
|
||||
|
||||
def _proton_sync_environment(synchronization: str) -> dict[str, str]:
|
||||
if synchronization == RUNTIME_DEFAULT_SYNC:
|
||||
return {}
|
||||
if synchronization == "esync":
|
||||
return {
|
||||
"PROTON_NO_FSYNC": "1",
|
||||
"WINEFSYNC": "0",
|
||||
"PROTON_NO_ESYNC": "0",
|
||||
"WINEESYNC": "1",
|
||||
}
|
||||
if synchronization == "fsync":
|
||||
return {
|
||||
"PROTON_NO_FSYNC": "0",
|
||||
"WINEFSYNC": "1",
|
||||
"PROTON_NO_ESYNC": "0",
|
||||
"WINEESYNC": "1",
|
||||
}
|
||||
raise ValueError(f"unsupported resolved Proton synchronization mode: {synchronization}")
|
||||
|
||||
|
||||
def _ensure_proton_game_link(paths: AppPaths) -> None:
|
||||
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
if link.is_symlink():
|
||||
if link.resolve() == paths.runtime_view.resolve():
|
||||
return
|
||||
raise RuntimeErrorPT(f"Proton game link points somewhere unexpected: {link}")
|
||||
if link.exists():
|
||||
if link.is_dir() and not any(link.iterdir()):
|
||||
link.rmdir()
|
||||
else:
|
||||
raise RuntimeErrorPT(f"refusing to replace existing Proton game path: {link}")
|
||||
link.symlink_to(paths.runtime_view, target_is_directory=True)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
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:
|
||||
directory = prefix / "drive_c/windows/syswow64"
|
||||
if not all((directory / f"{name}.dll").is_file() for name in NATIVE_D3D43_DLLS):
|
||||
return False
|
||||
try:
|
||||
registry = (prefix / "user.reg").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).casefold()
|
||||
except OSError:
|
||||
return False
|
||||
return all(_native_override_ready(registry, name) for name in NATIVE_D3D43_DLLS)
|
||||
|
||||
|
||||
def _native_override_ready(registry: str, name: str) -> bool:
|
||||
return any(
|
||||
entry in registry
|
||||
for entry in (
|
||||
f'"{name}"="native,builtin"',
|
||||
f'"{name}"="native"',
|
||||
f'"*{name}"="native"',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def prepare_proton7_winetricks(
|
||||
source: Path,
|
||||
destination: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> Path:
|
||||
try:
|
||||
content = source.read_bytes()
|
||||
except OSError as exc:
|
||||
raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from exc
|
||||
probe = WINETRICKS_CODEPAGE_PROBE.encode("utf-8")
|
||||
replacement = WINETRICKS_PROTON7_PROBE.encode("utf-8")
|
||||
if probe not in content:
|
||||
return source
|
||||
if dry_run:
|
||||
return destination
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
patched = content.replace(
|
||||
probe,
|
||||
replacement,
|
||||
1,
|
||||
)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
temporary.write_bytes(patched)
|
||||
temporary.chmod(source.stat().st_mode & 0o777)
|
||||
temporary.replace(destination)
|
||||
return destination
|
||||
|
||||
|
||||
def _proton_prefix_ready(prefix: Path) -> bool:
|
||||
return (prefix / "user.reg").is_file() and (
|
||||
prefix / "drive_c/windows/system32/cmd.exe"
|
||||
).exists()
|
||||
|
||||
|
||||
def _game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
|
||||
marker = _read_json(paths.game_runtime_marker)
|
||||
identity = _proton_identity(selection)
|
||||
if marker is None or identity is None:
|
||||
return False
|
||||
if marker.get("configuration_version") != GAME_RUNTIME_CONFIGURATION_VERSION:
|
||||
return False
|
||||
if marker.get("runtime") != asdict(selection):
|
||||
return False
|
||||
if marker.get("proton_identity") != identity:
|
||||
return False
|
||||
if marker.get("native_d3d43") is not True:
|
||||
return False
|
||||
if marker.get("dxvk_d3d9_override") != DXVK_D3D9_OVERRIDE:
|
||||
return False
|
||||
if not _proton_prefix_ready(paths.proton_prefix):
|
||||
return False
|
||||
if not native_d3d_ready(paths.proton_prefix):
|
||||
return False
|
||||
if not _d3d9_override_ready(paths.proton_prefix):
|
||||
return False
|
||||
return _proton_game_link_ready(paths)
|
||||
|
||||
|
||||
def _d3d9_override_ready(prefix: Path) -> bool:
|
||||
try:
|
||||
registry = (prefix / "user.reg").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).casefold()
|
||||
except OSError:
|
||||
return False
|
||||
return f'"d3d9"="{DXVK_D3D9_OVERRIDE}"' in registry
|
||||
|
||||
|
||||
def _proton_game_link_ready(paths: AppPaths) -> bool:
|
||||
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
|
||||
if not link.is_symlink():
|
||||
return False
|
||||
try:
|
||||
return link.resolve(strict=True) == paths.runtime_view.resolve(strict=True)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def _proton_identity(selection: RuntimeSelection) -> dict[str, str] | None:
|
||||
if not selection.proton_path:
|
||||
return None
|
||||
proton = Path(selection.proton_path)
|
||||
identity_paths = {
|
||||
"proton": proton,
|
||||
"version": proton.parent / "version",
|
||||
"toolmanifest": proton.parent / "toolmanifest.vdf",
|
||||
}
|
||||
identity: dict[str, str] = {}
|
||||
for name, path in identity_paths.items():
|
||||
try:
|
||||
identity[name] = _sha256(path)
|
||||
except OSError:
|
||||
return None
|
||||
return identity
|
||||
|
||||
|
||||
def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
|
||||
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
|
||||
wine = directory / "wine"
|
||||
wineserver = directory / "wineserver"
|
||||
if wine.is_file() and wineserver.is_file():
|
||||
return wine, wineserver
|
||||
return None
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Official realm health used by the native launcher dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Callable, Mapping, Protocol
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from . import __version__
|
||||
|
||||
|
||||
# This is the same publisher endpoint embedded in Windows launcher 1.0.0.64.
|
||||
SERVER_STATUS_ENDPOINT = (
|
||||
"https://pristontale.eu/api/pristontale/developer"
|
||||
"?api_key=d9OlDSvworitU4ajDiH6dU6cxXApc4M0"
|
||||
)
|
||||
MAX_SERVER_STATUS_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class ServerStatusError(RuntimeError):
|
||||
"""Raised when the official status response cannot be interpreted safely."""
|
||||
|
||||
|
||||
class ServerStatusResponse(Protocol):
|
||||
def read(self, size: int = -1) -> bytes: ...
|
||||
|
||||
def __enter__(self) -> ServerStatusResponse: ...
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exception_type: type[BaseException] | None,
|
||||
exception: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> bool | None: ...
|
||||
|
||||
|
||||
ServerStatusOpener = Callable[..., ServerStatusResponse]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealmServerStatus:
|
||||
name: str
|
||||
online: bool | None
|
||||
players_online: int | None
|
||||
login_online: bool | None = None
|
||||
game_online: bool | None = None
|
||||
|
||||
|
||||
def fetch_server_status(
|
||||
*,
|
||||
timeout_seconds: float = 10.0,
|
||||
opener: ServerStatusOpener = urlopen,
|
||||
) -> tuple[RealmServerStatus, ...]:
|
||||
request = Request(
|
||||
SERVER_STATUS_ENDPOINT,
|
||||
headers={"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_SERVER_STATUS_BYTES + 1)
|
||||
except OSError as exc:
|
||||
raise ServerStatusError("official server status is unavailable") from exc
|
||||
if len(payload) > MAX_SERVER_STATUS_BYTES:
|
||||
raise ServerStatusError("official server status exceeds the 1 MiB limit")
|
||||
return parse_server_status(payload)
|
||||
|
||||
|
||||
def parse_server_status(payload: bytes) -> tuple[RealmServerStatus, ...]:
|
||||
try:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ServerStatusError("official server status is not valid JSON") from exc
|
||||
# Historical versions returned an array containing the status object. The
|
||||
# current endpoint returns that object directly.
|
||||
if isinstance(value, list) and value:
|
||||
value = value[0]
|
||||
if not isinstance(value, dict):
|
||||
raise ServerStatusError("official server status is not a realm object")
|
||||
|
||||
statuses: list[RealmServerStatus] = []
|
||||
for raw_name, raw_components in value.items():
|
||||
if not isinstance(raw_name, str) or raw_name.casefold() == "website":
|
||||
continue
|
||||
name = raw_name.strip()
|
||||
if not name or len(name) > 64 or not isinstance(raw_components, Mapping):
|
||||
continue
|
||||
status = _parse_realm(name, raw_components)
|
||||
if status is not None:
|
||||
statuses.append(status)
|
||||
if not statuses:
|
||||
raise ServerStatusError("official server status contains no realms")
|
||||
return tuple(statuses)
|
||||
|
||||
|
||||
def _parse_realm(
|
||||
name: str,
|
||||
components: Mapping[object, object],
|
||||
) -> RealmServerStatus | None:
|
||||
service_states: list[bool] = []
|
||||
login_online: bool | None = None
|
||||
game_online: bool | None = None
|
||||
players_online: int | None = None
|
||||
for raw_key, raw_value in components.items():
|
||||
if not isinstance(raw_key, str):
|
||||
continue
|
||||
key = raw_key.casefold()
|
||||
if key == "users.online":
|
||||
players_online = _non_negative_integer(raw_value)
|
||||
continue
|
||||
if not key.startswith("server."):
|
||||
continue
|
||||
state = _online_state(raw_value)
|
||||
if state is None:
|
||||
continue
|
||||
service_states.append(state)
|
||||
if "login" in key:
|
||||
login_online = state
|
||||
elif "game" in key:
|
||||
game_online = state
|
||||
if not service_states and players_online is None:
|
||||
return None
|
||||
online = all(service_states) if service_states else None
|
||||
display_name = name[0].upper() + name[1:]
|
||||
return RealmServerStatus(
|
||||
name=display_name,
|
||||
online=online,
|
||||
players_online=players_online,
|
||||
login_online=login_online,
|
||||
game_online=game_online,
|
||||
)
|
||||
|
||||
|
||||
def _online_state(value: object) -> bool | None:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return value > 0
|
||||
if isinstance(value, str):
|
||||
text = value.strip().casefold()
|
||||
if text in {"online", "up", "true", "1"}:
|
||||
return True
|
||||
if text in {"offline", "down", "false", "0"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _non_negative_integer(value: object) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int) and value >= 0:
|
||||
return value
|
||||
if isinstance(value, str) and value.isascii() and value.isdecimal():
|
||||
return int(value)
|
||||
return None
|
||||
@@ -1,328 +0,0 @@
|
||||
"""User-editable launcher settings with conservative defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from .locking import launcher_operation_lock
|
||||
from .paths import AppPaths
|
||||
from .update_resolver import SUPPORTED_CHANNELS
|
||||
|
||||
|
||||
SETTINGS_SCHEMA = 1
|
||||
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
|
||||
UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"})
|
||||
_RETIRED_SETTINGS = frozenset(
|
||||
{
|
||||
"width",
|
||||
"height",
|
||||
"output_width",
|
||||
"output_height",
|
||||
"refresh_rate",
|
||||
"fullscreen",
|
||||
"scaler",
|
||||
"filter",
|
||||
"maximize_button",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
realm: str = "Babel"
|
||||
ui_scale: str = "auto"
|
||||
telemetry_enabled: bool = True
|
||||
borderless: bool = False
|
||||
suppress_unresponsive_warnings: bool = True
|
||||
runtime: str = "auto"
|
||||
proton_sync: str = "auto"
|
||||
proton_path: str | None = None
|
||||
game_source: str | None = None
|
||||
download_speed_limit_mbps: float | None = None
|
||||
low_bandwidth_mode: bool = False
|
||||
allow_window_resizing: bool = False
|
||||
update_channel: str = "stable"
|
||||
|
||||
@property
|
||||
def source_override(self) -> Path | None:
|
||||
return Path(self.game_source).expanduser().resolve() if self.game_source else None
|
||||
|
||||
@property
|
||||
def download_speed_limit_bytes_per_second(self) -> int | None:
|
||||
if self.download_speed_limit_mbps is None:
|
||||
return None
|
||||
return round(self.download_speed_limit_mbps * 1_000_000)
|
||||
|
||||
|
||||
def load_settings(paths: AppPaths) -> Settings:
|
||||
value = _read_settings_object(paths.settings_file)
|
||||
if value is None:
|
||||
return Settings()
|
||||
return _settings_from_object(value)
|
||||
|
||||
|
||||
def save_settings(paths: AppPaths, settings: Settings) -> Settings:
|
||||
"""Atomically save a complete settings snapshot.
|
||||
|
||||
Unknown fields from a newer launcher are retained when the existing file is
|
||||
valid. Callers editing only part of the configuration should use
|
||||
``update_settings`` so an old in-memory snapshot cannot undo another save.
|
||||
"""
|
||||
|
||||
_validate(settings)
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-save"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
_write_settings(paths, settings, existing)
|
||||
return settings
|
||||
|
||||
|
||||
def update_settings(paths: AppPaths, **changes: Any) -> Settings:
|
||||
"""Merge selected settings into the newest on-disk configuration."""
|
||||
|
||||
unknown = set(changes).difference(_SETTINGS_FIELD_NAMES)
|
||||
if unknown:
|
||||
names = ", ".join(sorted(unknown))
|
||||
raise ValueError(f"unsupported launcher setting: {names}")
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-update"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
current = (
|
||||
_settings_from_object(existing)
|
||||
if existing is not None
|
||||
else Settings()
|
||||
)
|
||||
updated = replace(current, **changes)
|
||||
_validate(updated)
|
||||
_write_settings(paths, updated, existing)
|
||||
return updated
|
||||
|
||||
|
||||
def ensure_settings(paths: AppPaths) -> Settings:
|
||||
"""Create settings once and materialize defaults added by newer releases."""
|
||||
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-migrate"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
if existing is None:
|
||||
settings = Settings()
|
||||
_write_settings(paths, settings, None)
|
||||
return settings
|
||||
settings = _settings_from_object(existing)
|
||||
if _settings_need_migration(existing):
|
||||
_write_settings(paths, settings, existing)
|
||||
return settings
|
||||
|
||||
|
||||
def reset_settings(paths: AppPaths) -> Settings:
|
||||
"""Replace all launcher settings with the documented defaults."""
|
||||
|
||||
settings = Settings()
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-reset"):
|
||||
_write_settings(paths, settings, None)
|
||||
return settings
|
||||
|
||||
|
||||
def _read_settings_object(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
raise ValueError(f"invalid settings file {path}: {exc}") from exc
|
||||
try:
|
||||
value = json.loads(_decode_settings(content))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid settings file {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"settings file must contain an object: {path}")
|
||||
schema = value.get("schema")
|
||||
if schema is not None:
|
||||
if isinstance(schema, bool) or not isinstance(schema, int):
|
||||
raise ValueError(f"settings schema must be an integer: {path}")
|
||||
if schema != SETTINGS_SCHEMA:
|
||||
raise ValueError(f"unsupported settings schema {schema}: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _settings_from_object(value: dict[str, object]) -> Settings:
|
||||
settings = Settings(
|
||||
realm=_string(value, "realm", "Babel").strip(),
|
||||
ui_scale=_string(value, "ui_scale", "auto"),
|
||||
telemetry_enabled=_boolean(value, "telemetry_enabled", True),
|
||||
borderless=_migrated_borderless(value),
|
||||
allow_window_resizing=_boolean(
|
||||
value,
|
||||
"allow_window_resizing",
|
||||
False,
|
||||
),
|
||||
suppress_unresponsive_warnings=_boolean(
|
||||
value,
|
||||
"suppress_unresponsive_warnings",
|
||||
True,
|
||||
),
|
||||
runtime=_string(value, "runtime", "auto"),
|
||||
proton_sync=_string(value, "proton_sync", "auto"),
|
||||
proton_path=_optional_string(value, "proton_path"),
|
||||
game_source=_optional_string(value, "game_source"),
|
||||
download_speed_limit_mbps=_optional_positive_number(
|
||||
value,
|
||||
"download_speed_limit_mbps",
|
||||
),
|
||||
low_bandwidth_mode=_boolean(value, "low_bandwidth_mode", False),
|
||||
update_channel=_string(value, "update_channel", "stable"),
|
||||
)
|
||||
_validate(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def _migrated_borderless(value: dict[str, object]) -> bool:
|
||||
"""Retire the old implicit borderless default exactly once.
|
||||
|
||||
Older releases wrote ``borderless=true`` for every default installation, so
|
||||
they cannot distinguish an inherited value from an explicit choice. The
|
||||
migration marker keeps schema 1 backward compatible while letting users opt
|
||||
back into Borderless after the decorated default has been applied.
|
||||
"""
|
||||
|
||||
borderless = _boolean(value, "borderless", False)
|
||||
migrated = _boolean(value, _TITLE_FRAME_DEFAULT_MIGRATION_KEY, False)
|
||||
if not migrated:
|
||||
return False
|
||||
return borderless
|
||||
|
||||
|
||||
def _decode_settings(content: bytes) -> str:
|
||||
"""Accept native UTF-8 plus settings saved by common Windows editors."""
|
||||
|
||||
try:
|
||||
return content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as utf8_error:
|
||||
try:
|
||||
return content.decode("cp1252")
|
||||
except UnicodeDecodeError:
|
||||
raise utf8_error
|
||||
|
||||
|
||||
def _settings_need_migration(value: dict[str, object]) -> bool:
|
||||
return (
|
||||
value.get("schema") != SETTINGS_SCHEMA
|
||||
or not _SETTINGS_FIELD_NAMES.issubset(value)
|
||||
or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True
|
||||
or bool(_RETIRED_SETTINGS.intersection(value))
|
||||
)
|
||||
|
||||
|
||||
def _write_settings(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
existing: dict[str, object] | None,
|
||||
) -> None:
|
||||
payload = dict(existing or {})
|
||||
for name in _RETIRED_SETTINGS:
|
||||
payload.pop(name, None)
|
||||
payload.update(asdict(settings))
|
||||
payload["schema"] = SETTINGS_SCHEMA
|
||||
payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True
|
||||
temporary = paths.settings_file.with_name(
|
||||
f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}."
|
||||
f"{secrets.token_hex(8)}.tmp"
|
||||
)
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, paths.settings_file)
|
||||
_fsync_directory(paths.config)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _validate(settings: Settings) -> None:
|
||||
if (
|
||||
len(settings.realm) > 64
|
||||
or not settings.realm.strip()
|
||||
or any(ord(character) < 32 for character in settings.realm)
|
||||
):
|
||||
raise ValueError("realm must be a non-empty display name")
|
||||
if settings.ui_scale not in UI_SCALE_CHOICES:
|
||||
raise ValueError(f"unsupported launcher UI scale: {settings.ui_scale}")
|
||||
if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}:
|
||||
raise ValueError(f"unsupported runtime selection: {settings.runtime}")
|
||||
if settings.proton_sync not in {"auto", "fsync", "esync"}:
|
||||
raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}")
|
||||
if settings.update_channel not in SUPPORTED_CHANNELS:
|
||||
raise ValueError(f"unsupported launcher update channel: {settings.update_channel}")
|
||||
if (
|
||||
settings.download_speed_limit_mbps is not None
|
||||
and (
|
||||
not math.isfinite(settings.download_speed_limit_mbps)
|
||||
or settings.download_speed_limit_mbps <= 0
|
||||
or settings.download_speed_limit_mbps > 10_000
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"download_speed_limit_mbps must be greater than 0 and at most 10000, or null"
|
||||
)
|
||||
|
||||
def _boolean(value: dict[str, object], key: str, default: bool) -> bool:
|
||||
item = value.get(key, default)
|
||||
if not isinstance(item, bool):
|
||||
raise ValueError(f"{key} must be a boolean")
|
||||
return item
|
||||
|
||||
|
||||
def _string(value: dict[str, object], key: str, default: str) -> str:
|
||||
item = value.get(key, default)
|
||||
if not isinstance(item, str) or not item:
|
||||
raise ValueError(f"{key} must be a non-empty string")
|
||||
return item
|
||||
|
||||
|
||||
def _optional_string(value: dict[str, object], key: str) -> str | None:
|
||||
item = value.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
if not isinstance(item, str) or not item:
|
||||
raise ValueError(f"{key} must be null or a non-empty string")
|
||||
return item
|
||||
|
||||
|
||||
def _optional_positive_number(value: dict[str, object], key: str) -> float | None:
|
||||
item = value.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
if isinstance(item, bool) or not isinstance(item, (int, float)):
|
||||
raise ValueError(f"{key} must be null or a positive number")
|
||||
number = float(item)
|
||||
if not math.isfinite(number) or number <= 0:
|
||||
raise ValueError(f"{key} must be null or a positive number")
|
||||
return number
|
||||
|
||||
|
||||
_SETTINGS_FIELD_NAMES = frozenset(field.name for field in fields(Settings))
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Identify the host OS without losing the launcher's execution runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
EXECUTION_OS_RELEASE = Path("/etc/os-release")
|
||||
CONTAINER_HOST_OS_RELEASE = Path("/run/host/os-release")
|
||||
STEAM_RUNTIME_OS_ID = "steamrt"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SystemIdentity:
|
||||
"""OS releases visible to the launcher process and its container host."""
|
||||
|
||||
execution_release: dict[str, str]
|
||||
host_release: dict[str, str] | None
|
||||
|
||||
@property
|
||||
def operating_system_release(self) -> dict[str, str]:
|
||||
return self.host_release or self.execution_release
|
||||
|
||||
@property
|
||||
def running_inside_steam_runtime(self) -> bool:
|
||||
return (
|
||||
self.execution_release.get("ID", "").strip().casefold()
|
||||
== STEAM_RUNTIME_OS_ID
|
||||
)
|
||||
|
||||
|
||||
def detect_system_identity(
|
||||
*,
|
||||
execution_os_release: Path = EXECUTION_OS_RELEASE,
|
||||
host_os_release: Path = CONTAINER_HOST_OS_RELEASE,
|
||||
) -> SystemIdentity:
|
||||
"""Read the runtime OS and the standard container-host identity, if present."""
|
||||
|
||||
execution = read_os_release(execution_os_release)
|
||||
host: dict[str, str] | None = None
|
||||
if execution.get("ID", "").strip().casefold() == STEAM_RUNTIME_OS_ID:
|
||||
candidate = read_os_release(host_os_release)
|
||||
candidate_id = candidate.get("ID", "").strip().casefold()
|
||||
if candidate_id and candidate_id != STEAM_RUNTIME_OS_ID:
|
||||
host = candidate
|
||||
return SystemIdentity(execution_release=execution, host_release=host)
|
||||
|
||||
|
||||
def detect_distribution_id() -> str:
|
||||
"""Return the host distribution ID when a container exposes it."""
|
||||
|
||||
release = detect_system_identity().operating_system_release
|
||||
return release.get("ID", "").strip().casefold()
|
||||
|
||||
|
||||
def is_running_inside_steam_runtime() -> bool:
|
||||
"""Return whether the launcher process itself is inside Steam Runtime."""
|
||||
|
||||
return detect_system_identity().running_inside_steam_runtime
|
||||
|
||||
|
||||
def read_os_release(path: Path) -> dict[str, str]:
|
||||
"""Read bounded freedesktop os-release fields without invoking host tools."""
|
||||
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return {}
|
||||
result: dict[str, str] = {}
|
||||
for line in lines:
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, raw = line.split("=", 1)
|
||||
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key):
|
||||
continue
|
||||
value = raw.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
value = value.replace(r"\n", " ").replace(r"\"", '"').replace(r"\\", "\\")
|
||||
result[key] = value[:255]
|
||||
return result
|
||||
1639
ptlinux/telemetry.py
1639
ptlinux/telemetry.py
File diff suppressed because it is too large
Load Diff
@@ -1,735 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
from .desktop_identity import APPLICATION_WM_CLASS, APPLICATION_WM_INSTANCE
|
||||
|
||||
|
||||
DialogKind = Literal["info", "success", "warning", "error", "question"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DialogPalette:
|
||||
"""Colors and glyph used by one launcher dialog intent."""
|
||||
|
||||
accent: str
|
||||
accent_soft: str
|
||||
glyph: str
|
||||
|
||||
|
||||
_DIALOG_PALETTES: dict[str, DialogPalette] = {
|
||||
"info": DialogPalette("#d98927", "#3a2819", "i"),
|
||||
"success": DialogPalette("#55b878", "#173122", "✓"),
|
||||
"warning": DialogPalette("#e0a248", "#3b2a18", "!"),
|
||||
"error": DialogPalette("#cf604c", "#3a1d19", "×"),
|
||||
"question": DialogPalette("#d98927", "#3a2819", "?"),
|
||||
}
|
||||
_BASE_TK_SCALING = 96.0 / 72.0
|
||||
|
||||
|
||||
def dialog_palette(kind: DialogKind) -> DialogPalette:
|
||||
"""Return the stable visual palette for a themed launcher dialog."""
|
||||
|
||||
try:
|
||||
return _DIALOG_PALETTES[kind]
|
||||
except KeyError as error:
|
||||
raise ValueError(f"unsupported launcher dialog kind: {kind}") from error
|
||||
|
||||
|
||||
def square_icon_layout(width: int, height: int) -> tuple[int, int, int]:
|
||||
"""Return square canvas size and centered offsets for a window icon."""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("icon dimensions must be positive")
|
||||
size = max(width, height)
|
||||
return size, (size - width) // 2, (size - height) // 2
|
||||
|
||||
|
||||
def icon_subsample_factor(width: int, height: int, max_dimension: int) -> int:
|
||||
"""Return the integer Tk subsample needed to fit an image within a square."""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("icon dimensions must be positive")
|
||||
if max_dimension <= 0:
|
||||
raise ValueError("maximum icon dimension must be positive")
|
||||
largest_dimension = max(width, height)
|
||||
return max(1, (largest_dimension + max_dimension - 1) // max_dimension)
|
||||
|
||||
|
||||
def load_square_window_icon(master: Any, path: Path) -> Any:
|
||||
"""Load a PNG into a square transparent Tk image for desktop shells."""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
source = tk.PhotoImage(master=master, file=str(path))
|
||||
size, offset_x, offset_y = square_icon_layout(source.width(), source.height())
|
||||
if source.width() == source.height():
|
||||
return source
|
||||
icon = tk.PhotoImage(master=master, width=size, height=size)
|
||||
icon.tk.call(
|
||||
str(icon),
|
||||
"copy",
|
||||
str(source),
|
||||
"-to",
|
||||
offset_x,
|
||||
offset_y,
|
||||
)
|
||||
return icon
|
||||
|
||||
|
||||
def centered_window_geometry(
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
parent_x: int,
|
||||
parent_y: int,
|
||||
parent_width: int,
|
||||
parent_height: int,
|
||||
) -> str:
|
||||
"""Return Tk geometry centered over a parent window."""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("window dimensions must be positive")
|
||||
x = parent_x + (max(1, parent_width) - width) // 2
|
||||
y = parent_y + (max(1, parent_height) - height) // 2
|
||||
return f"{width}x{height}{_geometry_offset(x)}{_geometry_offset(y)}"
|
||||
|
||||
|
||||
def dragged_window_geometry(
|
||||
pointer_x: int,
|
||||
pointer_y: int,
|
||||
*,
|
||||
offset_x: int,
|
||||
offset_y: int,
|
||||
) -> str:
|
||||
"""Return the Tk position for a pointer-driven custom title bar."""
|
||||
|
||||
return (
|
||||
f"{_geometry_offset(pointer_x - offset_x)}"
|
||||
f"{_geometry_offset(pointer_y - offset_y)}"
|
||||
)
|
||||
|
||||
|
||||
def widget_ui_scale(widget: Any) -> float:
|
||||
"""Return the launcher scale already configured on a Tk interpreter."""
|
||||
|
||||
try:
|
||||
scaling = float(widget.tk.call("tk", "scaling"))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return 1.0
|
||||
return min(2.0, max(1.0, scaling / _BASE_TK_SCALING))
|
||||
|
||||
|
||||
def _geometry_offset(value: int) -> str:
|
||||
return f"+{value}" if value >= 0 else str(value)
|
||||
|
||||
|
||||
def _bind_window_drag(handle: Any, *widgets: Any) -> None:
|
||||
position = [0, 0]
|
||||
|
||||
def begin(event: Any) -> None:
|
||||
position[0] = int(event.x_root) - int(handle.winfo_x())
|
||||
position[1] = int(event.y_root) - int(handle.winfo_y())
|
||||
|
||||
def move(event: Any) -> None:
|
||||
handle.geometry(
|
||||
dragged_window_geometry(
|
||||
int(event.x_root),
|
||||
int(event.y_root),
|
||||
offset_x=position[0],
|
||||
offset_y=position[1],
|
||||
)
|
||||
)
|
||||
|
||||
for widget in widgets:
|
||||
widget.bind("<ButtonPress-1>", begin, add="+")
|
||||
widget.bind("<B1-Motion>", move, add="+")
|
||||
|
||||
|
||||
class DashboardChrome:
|
||||
"""Controller for one launcher window and its custom controls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Any,
|
||||
content: Any,
|
||||
close_button: Any,
|
||||
minimize_button: Any,
|
||||
*,
|
||||
managed: bool,
|
||||
identity_configured: bool,
|
||||
allow_close: bool,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.content = content
|
||||
self.managed = managed
|
||||
self.identity_configured = identity_configured
|
||||
self._close_button = close_button
|
||||
self._minimize_button = minimize_button
|
||||
self._allow_close = allow_close
|
||||
self._close_command: Callable[[], None] = root.destroy
|
||||
if close_button is not None:
|
||||
close_button.configure(command=self.close)
|
||||
if minimize_button is not None:
|
||||
minimize_button.configure(command=self.minimize)
|
||||
root.bind("<Alt-F4>", self._close_from_event, add="+")
|
||||
|
||||
def set_close_command(self, command: Callable[[], None]) -> None:
|
||||
self._close_command = command
|
||||
|
||||
def close(self) -> None:
|
||||
if self._allow_close:
|
||||
self._close_command()
|
||||
|
||||
def minimize(self) -> None:
|
||||
"""Ask the window manager to iconify the managed dashboard."""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
try:
|
||||
self.root.iconify()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _close_from_event(self, _event: Any) -> str:
|
||||
self.close()
|
||||
return "break"
|
||||
|
||||
|
||||
|
||||
def _configure_x11_window(
|
||||
root: Any,
|
||||
*,
|
||||
resource_name: str,
|
||||
resource_class: str,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Hide the X11 frame and give desktop shells a stable window identity."""
|
||||
|
||||
import ctypes.util
|
||||
|
||||
library_path = ctypes.util.find_library("X11")
|
||||
if library_path is None:
|
||||
return False, False
|
||||
try:
|
||||
x11 = ctypes.CDLL(library_path)
|
||||
except OSError:
|
||||
return False, False
|
||||
|
||||
class XClassHint(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("res_name", ctypes.c_char_p),
|
||||
("res_class", ctypes.c_char_p),
|
||||
]
|
||||
|
||||
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
|
||||
x11.XOpenDisplay.restype = ctypes.c_void_p
|
||||
x11.XQueryTree.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
|
||||
ctypes.POINTER(ctypes.c_uint),
|
||||
]
|
||||
x11.XQueryTree.restype = ctypes.c_int
|
||||
x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
|
||||
x11.XInternAtom.restype = ctypes.c_ulong
|
||||
x11.XChangeProperty.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.POINTER(ctypes.c_ubyte),
|
||||
ctypes.c_int,
|
||||
]
|
||||
x11.XChangeProperty.restype = ctypes.c_int
|
||||
x11.XSetClassHint.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(XClassHint),
|
||||
]
|
||||
x11.XSetClassHint.restype = ctypes.c_int
|
||||
x11.XFlush.argtypes = [ctypes.c_void_p]
|
||||
x11.XFlush.restype = ctypes.c_int
|
||||
x11.XFree.argtypes = [ctypes.c_void_p]
|
||||
x11.XFree.restype = ctypes.c_int
|
||||
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
|
||||
x11.XCloseDisplay.restype = ctypes.c_int
|
||||
root.update_idletasks()
|
||||
display = x11.XOpenDisplay(None)
|
||||
if display is None:
|
||||
return False, False
|
||||
children = ctypes.POINTER(ctypes.c_ulong)()
|
||||
try:
|
||||
root_window = ctypes.c_ulong()
|
||||
wrapper_window = ctypes.c_ulong()
|
||||
child_count = ctypes.c_uint()
|
||||
queried = x11.XQueryTree(
|
||||
display,
|
||||
root.winfo_id(),
|
||||
ctypes.byref(root_window),
|
||||
ctypes.byref(wrapper_window),
|
||||
ctypes.byref(children),
|
||||
ctypes.byref(child_count),
|
||||
)
|
||||
if not queried or not wrapper_window.value:
|
||||
return False, False
|
||||
atom = x11.XInternAtom(display, b"_MOTIF_WM_HINTS", 0)
|
||||
if not atom:
|
||||
return False, False
|
||||
motif_hints_decorations = 1 << 1
|
||||
hints = (ctypes.c_ulong * 5)(motif_hints_decorations, 0, 0, 0, 0)
|
||||
changed = x11.XChangeProperty(
|
||||
display,
|
||||
wrapper_window.value,
|
||||
atom,
|
||||
atom,
|
||||
32,
|
||||
0,
|
||||
ctypes.cast(hints, ctypes.POINTER(ctypes.c_ubyte)),
|
||||
len(hints),
|
||||
)
|
||||
identity = XClassHint(
|
||||
res_name=resource_name.encode("utf-8"),
|
||||
res_class=resource_class.encode("utf-8"),
|
||||
)
|
||||
identity_changed = x11.XSetClassHint(
|
||||
display,
|
||||
wrapper_window.value,
|
||||
ctypes.byref(identity),
|
||||
)
|
||||
x11.XFlush(display)
|
||||
return bool(changed), bool(identity_changed)
|
||||
finally:
|
||||
if children:
|
||||
x11.XFree(children)
|
||||
x11.XCloseDisplay(display)
|
||||
|
||||
|
||||
def create_window_chrome(
|
||||
root: Any,
|
||||
*,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
window_icon: Any | None = None,
|
||||
show_title_bar: bool = True,
|
||||
show_close: bool = True,
|
||||
show_minimize: bool = False,
|
||||
allow_close: bool = True,
|
||||
) -> DashboardChrome:
|
||||
"""Install the launcher's custom frame and window controls."""
|
||||
|
||||
import tkinter as tk
|
||||
ui_scale = widget_ui_scale(root)
|
||||
|
||||
managed, identity_configured = _configure_x11_window(
|
||||
root,
|
||||
resource_name=APPLICATION_WM_INSTANCE,
|
||||
resource_class=APPLICATION_WM_CLASS,
|
||||
)
|
||||
if not managed:
|
||||
root.overrideredirect(True)
|
||||
border = tk.Frame(
|
||||
root,
|
||||
background="#6a482d",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
border.pack(fill="both", expand=True)
|
||||
surface = tk.Frame(
|
||||
border,
|
||||
background="#0c0908",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
surface.pack(fill="both", expand=True, padx=1, pady=1)
|
||||
title_bar = tk.Frame(
|
||||
surface,
|
||||
background="#17100d",
|
||||
height=round(32 * ui_scale),
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
cursor="fleur",
|
||||
)
|
||||
if show_title_bar:
|
||||
title_bar.pack(fill="x")
|
||||
title_bar.pack_propagate(False)
|
||||
tk_accent = tk.Frame(title_bar, background="#d98927", width=3)
|
||||
tk_accent.pack(side="left", fill="y")
|
||||
title_icon: Any | None = None
|
||||
if window_icon is not None:
|
||||
try:
|
||||
factor = icon_subsample_factor(
|
||||
window_icon.width(),
|
||||
window_icon.height(),
|
||||
round(20 * ui_scale),
|
||||
)
|
||||
title_icon = window_icon.subsample(factor, factor)
|
||||
except (tk.TclError, ValueError):
|
||||
title_icon = None
|
||||
if title_icon is not None:
|
||||
mark = tk.Label(
|
||||
title_bar,
|
||||
image=title_icon,
|
||||
width=round(22 * ui_scale),
|
||||
height=round(22 * ui_scale),
|
||||
background="#17100d",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
cursor="fleur",
|
||||
)
|
||||
mark.image = title_icon
|
||||
else:
|
||||
mark = tk.Frame(
|
||||
title_bar,
|
||||
width=round(22 * ui_scale),
|
||||
height=round(22 * ui_scale),
|
||||
background="#17100d",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
cursor="fleur",
|
||||
)
|
||||
mark.pack(side="left", padx=(9, 7))
|
||||
identity = tk.Frame(title_bar, background="#17100d", cursor="fleur")
|
||||
identity.pack(side="left", fill="y")
|
||||
title_label = tk.Label(
|
||||
identity,
|
||||
text=title,
|
||||
background="#17100d",
|
||||
foreground="#f2dfbd",
|
||||
font=("Sans", 8, "bold"),
|
||||
cursor="fleur",
|
||||
)
|
||||
title_label.pack(side="left", pady=(0, 1))
|
||||
subtitle_label = tk.Label(
|
||||
identity,
|
||||
text=f" · {subtitle}",
|
||||
background="#17100d",
|
||||
foreground="#8f7b69",
|
||||
font=("Sans", 7),
|
||||
cursor="fleur",
|
||||
)
|
||||
subtitle_label.pack(side="left", pady=(0, 1))
|
||||
close_button: Any = None
|
||||
if show_close:
|
||||
close_button = tk.Button(
|
||||
title_bar,
|
||||
text="×",
|
||||
background="#17100d",
|
||||
foreground="#c9b7a4",
|
||||
activebackground="#a63f2f",
|
||||
activeforeground="#ffffff",
|
||||
font=("Sans", 12),
|
||||
width=4,
|
||||
borderwidth=0,
|
||||
highlightthickness=2,
|
||||
highlightbackground="#17100d",
|
||||
highlightcolor="#f4bd61",
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
)
|
||||
close_button.pack(side="right", fill="y")
|
||||
minimize_button: Any = None
|
||||
if show_minimize:
|
||||
minimize_button = tk.Button(
|
||||
title_bar,
|
||||
text="—",
|
||||
background="#17100d",
|
||||
foreground="#c9b7a4",
|
||||
activebackground="#312219",
|
||||
activeforeground="#ffffff",
|
||||
font=("Sans", 10, "bold"),
|
||||
width=4,
|
||||
borderwidth=0,
|
||||
highlightthickness=2,
|
||||
highlightbackground="#17100d",
|
||||
highlightcolor="#f4bd61",
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
)
|
||||
minimize_button.pack(side="right", fill="y")
|
||||
content = tk.Frame(
|
||||
surface,
|
||||
background="#0c0908",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
content.pack(fill="both", expand=True)
|
||||
if show_title_bar:
|
||||
_bind_window_drag(root, title_bar, mark, identity, title_label, subtitle_label)
|
||||
return DashboardChrome(
|
||||
root,
|
||||
content,
|
||||
close_button,
|
||||
minimize_button,
|
||||
managed=managed,
|
||||
identity_configured=identity_configured,
|
||||
allow_close=allow_close,
|
||||
)
|
||||
|
||||
|
||||
def create_dashboard_chrome(
|
||||
root: Any,
|
||||
*,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
window_icon: Any | None = None,
|
||||
) -> DashboardChrome:
|
||||
"""Install the draggable frame and full controls used by the dashboard."""
|
||||
|
||||
return create_window_chrome(
|
||||
root,
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
window_icon=window_icon,
|
||||
show_close=True,
|
||||
show_minimize=True,
|
||||
allow_close=True,
|
||||
)
|
||||
|
||||
|
||||
def show_themed_dialog(
|
||||
parent: Any,
|
||||
*,
|
||||
title: str,
|
||||
message: str,
|
||||
kind: DialogKind = "info",
|
||||
confirm: bool = False,
|
||||
accept_text: str = "OK",
|
||||
cancel_text: str = "CANCEL",
|
||||
window_icon: Any | None = None,
|
||||
) -> bool:
|
||||
"""Show one custom-framed modal dialog matching the launcher palette."""
|
||||
|
||||
import tkinter as tk
|
||||
ui_scale = widget_ui_scale(parent)
|
||||
|
||||
palette = dialog_palette(kind)
|
||||
dialog = tk.Toplevel(parent)
|
||||
dialog.withdraw()
|
||||
dialog.title(title)
|
||||
dialog.configure(background=palette.accent)
|
||||
dialog.resizable(False, False)
|
||||
dialog.overrideredirect(True)
|
||||
if window_icon is not None:
|
||||
try:
|
||||
dialog.iconphoto(False, window_icon)
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
dialog.transient(parent)
|
||||
except tk.TclError:
|
||||
pass
|
||||
result = False
|
||||
|
||||
def finish(value: bool) -> None:
|
||||
nonlocal result
|
||||
result = value
|
||||
try:
|
||||
dialog.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
dialog.destroy()
|
||||
|
||||
border = tk.Frame(dialog, background=palette.accent)
|
||||
border.pack(fill="both", expand=True)
|
||||
surface = tk.Frame(border, background="#191311")
|
||||
surface.pack(fill="both", expand=True, padx=1, pady=1)
|
||||
header = tk.Frame(
|
||||
surface,
|
||||
background="#24170f",
|
||||
height=round(66 * ui_scale),
|
||||
)
|
||||
header.pack(fill="x")
|
||||
header.pack_propagate(False)
|
||||
tk_accent = tk.Frame(header, background=palette.accent, width=5)
|
||||
tk_accent.pack(side="left", fill="y")
|
||||
header_copy = tk.Frame(header, background="#24170f")
|
||||
header_copy.pack(side="left", fill="both", expand=True, padx=16, pady=10)
|
||||
eyebrow = tk.Label(
|
||||
header_copy,
|
||||
text="PRISTONTALE EU · LINUX LAUNCHER",
|
||||
background="#24170f",
|
||||
foreground=palette.accent,
|
||||
font=("Sans", 7, "bold"),
|
||||
anchor="w",
|
||||
)
|
||||
eyebrow.pack(fill="x")
|
||||
title_label = tk.Label(
|
||||
header_copy,
|
||||
text=title,
|
||||
background="#24170f",
|
||||
foreground="#fff0d2",
|
||||
font=("Georgia", 15, "bold"),
|
||||
anchor="w",
|
||||
)
|
||||
title_label.pack(fill="x", pady=(1, 0))
|
||||
close_button = tk.Button(
|
||||
header,
|
||||
text="×",
|
||||
command=lambda: finish(False),
|
||||
background="#24170f",
|
||||
foreground="#c9b7a4",
|
||||
activebackground="#a63f2f",
|
||||
activeforeground="#ffffff",
|
||||
font=("Sans", 12),
|
||||
width=4,
|
||||
borderwidth=0,
|
||||
highlightthickness=2,
|
||||
highlightbackground="#24170f",
|
||||
highlightcolor="#f4bd61",
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
)
|
||||
close_button.pack(side="right", fill="y")
|
||||
body = tk.Frame(surface, background="#191311", padx=20, pady=20)
|
||||
body.pack(fill="both", expand=True)
|
||||
modal_icon_size = round(46 * ui_scale)
|
||||
modal_icon: Any | None = None
|
||||
if window_icon is not None:
|
||||
try:
|
||||
factor = icon_subsample_factor(
|
||||
window_icon.width(),
|
||||
window_icon.height(),
|
||||
modal_icon_size,
|
||||
)
|
||||
modal_icon = window_icon.subsample(factor, factor)
|
||||
except (tk.TclError, ValueError):
|
||||
modal_icon = None
|
||||
if modal_icon is not None:
|
||||
icon_host = tk.Frame(
|
||||
body,
|
||||
width=modal_icon_size,
|
||||
height=modal_icon_size,
|
||||
background="#191311",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
icon_host.pack(side="left", anchor="n", padx=(0, 16))
|
||||
icon_host.pack_propagate(False)
|
||||
icon = tk.Label(
|
||||
icon_host,
|
||||
image=modal_icon,
|
||||
background="#191311",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
icon.place(relx=0.5, rely=0.5, anchor="center")
|
||||
else:
|
||||
icon = tk.Canvas(
|
||||
body,
|
||||
width=modal_icon_size,
|
||||
height=modal_icon_size,
|
||||
background="#191311",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
icon.create_oval(
|
||||
round(3 * ui_scale),
|
||||
round(3 * ui_scale),
|
||||
modal_icon_size - round(3 * ui_scale),
|
||||
modal_icon_size - round(3 * ui_scale),
|
||||
fill=palette.accent_soft,
|
||||
outline=palette.accent,
|
||||
width=2,
|
||||
)
|
||||
icon.create_text(
|
||||
modal_icon_size // 2,
|
||||
modal_icon_size // 2,
|
||||
text=palette.glyph,
|
||||
fill=palette.accent,
|
||||
font=("Sans", 17, "bold"),
|
||||
)
|
||||
icon.pack(side="left", anchor="n", padx=(0, 16))
|
||||
message_label = tk.Label(
|
||||
body,
|
||||
text=message,
|
||||
background="#191311",
|
||||
foreground="#ded1c4",
|
||||
font=("Sans", 10),
|
||||
justify="left",
|
||||
anchor="w",
|
||||
wraplength=round(420 * ui_scale),
|
||||
)
|
||||
message_label.pack(side="left", fill="both", expand=True, anchor="n")
|
||||
footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12)
|
||||
footer.pack(fill="x")
|
||||
|
||||
def button(
|
||||
container: Any,
|
||||
*,
|
||||
label: str,
|
||||
command: Callable[[], None],
|
||||
primary: bool,
|
||||
) -> Any:
|
||||
background = palette.accent if primary else "#2a201c"
|
||||
foreground = "#17100d" if primary else "#ead8c2"
|
||||
active_background = "#efae57" if primary else "#3a2b25"
|
||||
return tk.Button(
|
||||
container,
|
||||
text=label,
|
||||
command=command,
|
||||
background=background,
|
||||
foreground=foreground,
|
||||
activebackground=active_background,
|
||||
activeforeground="#ffffff" if not primary else "#17100d",
|
||||
font=("Sans", 8, "bold"),
|
||||
padx=16,
|
||||
pady=7,
|
||||
borderwidth=0,
|
||||
highlightthickness=2,
|
||||
highlightbackground="#100d0c",
|
||||
highlightcolor="#f4bd61",
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
)
|
||||
|
||||
accept_button = button(
|
||||
footer,
|
||||
label=accept_text,
|
||||
command=lambda: finish(True),
|
||||
primary=True,
|
||||
)
|
||||
accept_button.pack(side="right")
|
||||
if confirm:
|
||||
cancel_button = button(
|
||||
footer,
|
||||
label=cancel_text,
|
||||
command=lambda: finish(False),
|
||||
primary=False,
|
||||
)
|
||||
cancel_button.pack(side="right", padx=(0, 8))
|
||||
_bind_window_drag(dialog, header, header_copy, eyebrow, title_label)
|
||||
dialog.protocol("WM_DELETE_WINDOW", lambda: finish(False))
|
||||
dialog.bind("<Escape>", lambda _event: finish(False))
|
||||
dialog.bind("<Return>", lambda _event: finish(True))
|
||||
parent.update_idletasks()
|
||||
dialog.update_idletasks()
|
||||
width = min(round(560 * ui_scale), max(1, dialog.winfo_screenwidth() - 64))
|
||||
height = min(
|
||||
max(round(218 * ui_scale), dialog.winfo_reqheight()),
|
||||
max(1, dialog.winfo_screenheight() - 64),
|
||||
)
|
||||
dialog.geometry(
|
||||
centered_window_geometry(
|
||||
width,
|
||||
height,
|
||||
parent_x=parent.winfo_rootx(),
|
||||
parent_y=parent.winfo_rooty(),
|
||||
parent_width=parent.winfo_width(),
|
||||
parent_height=parent.winfo_height(),
|
||||
)
|
||||
)
|
||||
dialog.deiconify()
|
||||
dialog.lift()
|
||||
dialog.grab_set()
|
||||
accept_button.focus_set()
|
||||
parent.wait_window(dialog)
|
||||
return result
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Public interface for the native PristonTale EU dashboard."""
|
||||
|
||||
from .dashboard import launch_external_url, patch_change_colors, run_dashboard
|
||||
from .viewmodels.dashboard import (
|
||||
ACTIVE_POLL_INTERVAL_MILLISECONDS,
|
||||
BASE_DISPLAY_DPI,
|
||||
BASE_TK_SCALING,
|
||||
DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS,
|
||||
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS,
|
||||
DASHBOARD_STARTUP_COMPONENTS,
|
||||
DASHBOARD_STARTUP_REQUIRED_COMPONENTS,
|
||||
DASHBOARD_TRANSITION_MINIMUM_OPACITY,
|
||||
DASHBOARD_TRANSITION_STEP_MILLISECONDS,
|
||||
DASHBOARD_TRANSITION_STEPS,
|
||||
IDLE_POLL_INTERVAL_MILLISECONDS,
|
||||
POLL_INTERVAL_MILLISECONDS,
|
||||
PRELOADER_STATUS_CHARACTERS,
|
||||
STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS,
|
||||
UI_SCALE_FACTORS,
|
||||
UI_SCALE_LABELS,
|
||||
WINDOW_HEIGHT,
|
||||
WINDOW_WIDTH,
|
||||
DashboardError,
|
||||
GameClientDebugState,
|
||||
GameClientTracker,
|
||||
GameLaunchSupervisorRegistry,
|
||||
LauncherUpdatePresentation,
|
||||
dashboard_content_ready,
|
||||
dashboard_controls_stacked,
|
||||
dashboard_poll_interval,
|
||||
dashboard_preloader_delay,
|
||||
dashboard_transition_opacity,
|
||||
debug_elapsed_text,
|
||||
fitted_window_size,
|
||||
freeze_preloader_geometry,
|
||||
game_client_activity_text,
|
||||
interrupt_game_supervisor,
|
||||
launcher_child_command,
|
||||
launcher_child_environment,
|
||||
launcher_restart_spec,
|
||||
launcher_update_presentation,
|
||||
news_card_columns,
|
||||
preloader_logo_subsample_factor,
|
||||
preloader_status_text,
|
||||
resolve_ui_scale,
|
||||
setting_label,
|
||||
setting_value,
|
||||
startup_progress_animation_value,
|
||||
)
|
||||
from .viewmodels.downloader import (
|
||||
DOWNLOAD_SPEED_CUSTOM_LABEL,
|
||||
DOWNLOAD_SPEED_PRESETS_MBPS,
|
||||
TRANSFER_RATE_IDLE_SECONDS,
|
||||
TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS,
|
||||
TRANSFER_RATE_WINDOW_SECONDS,
|
||||
ProgressCoalescer,
|
||||
TransferRateEstimator,
|
||||
download_activity_metadata,
|
||||
download_speed_setting_label,
|
||||
download_speed_setting_value,
|
||||
format_eta,
|
||||
format_transfer_rate,
|
||||
operation_activity_text,
|
||||
should_refresh_operation_elapsed,
|
||||
)
|
||||
from .viewmodels.health import (
|
||||
SERVER_STATUS_STALE_SECONDS,
|
||||
ServerStatusCheck,
|
||||
ServerStatusPresentation,
|
||||
perform_server_status_check,
|
||||
server_status_check_text,
|
||||
server_status_presentation,
|
||||
)
|
||||
from .viewmodels.installation import (
|
||||
GameSyncPresentation,
|
||||
game_file_progress_status,
|
||||
game_sync_presentation,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACTIVE_POLL_INTERVAL_MILLISECONDS",
|
||||
"BASE_DISPLAY_DPI",
|
||||
"BASE_TK_SCALING",
|
||||
"DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS",
|
||||
"DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS",
|
||||
"DASHBOARD_STARTUP_COMPONENTS",
|
||||
"DASHBOARD_STARTUP_REQUIRED_COMPONENTS",
|
||||
"DASHBOARD_TRANSITION_MINIMUM_OPACITY",
|
||||
"DASHBOARD_TRANSITION_STEP_MILLISECONDS",
|
||||
"DASHBOARD_TRANSITION_STEPS",
|
||||
"DOWNLOAD_SPEED_CUSTOM_LABEL",
|
||||
"DOWNLOAD_SPEED_PRESETS_MBPS",
|
||||
"DashboardError",
|
||||
"GameClientDebugState",
|
||||
"GameClientTracker",
|
||||
"GameLaunchSupervisorRegistry",
|
||||
"GameSyncPresentation",
|
||||
"IDLE_POLL_INTERVAL_MILLISECONDS",
|
||||
"LauncherUpdatePresentation",
|
||||
"POLL_INTERVAL_MILLISECONDS",
|
||||
"PRELOADER_STATUS_CHARACTERS",
|
||||
"ProgressCoalescer",
|
||||
"SERVER_STATUS_STALE_SECONDS",
|
||||
"STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS",
|
||||
"ServerStatusCheck",
|
||||
"ServerStatusPresentation",
|
||||
"TRANSFER_RATE_IDLE_SECONDS",
|
||||
"TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS",
|
||||
"TRANSFER_RATE_WINDOW_SECONDS",
|
||||
"TransferRateEstimator",
|
||||
"UI_SCALE_FACTORS",
|
||||
"UI_SCALE_LABELS",
|
||||
"WINDOW_HEIGHT",
|
||||
"WINDOW_WIDTH",
|
||||
"dashboard_content_ready",
|
||||
"dashboard_controls_stacked",
|
||||
"dashboard_poll_interval",
|
||||
"dashboard_preloader_delay",
|
||||
"dashboard_transition_opacity",
|
||||
"debug_elapsed_text",
|
||||
"download_activity_metadata",
|
||||
"download_speed_setting_label",
|
||||
"download_speed_setting_value",
|
||||
"fitted_window_size",
|
||||
"format_eta",
|
||||
"format_transfer_rate",
|
||||
"freeze_preloader_geometry",
|
||||
"game_client_activity_text",
|
||||
"game_file_progress_status",
|
||||
"game_sync_presentation",
|
||||
"interrupt_game_supervisor",
|
||||
"launch_external_url",
|
||||
"launcher_child_command",
|
||||
"launcher_child_environment",
|
||||
"launcher_restart_spec",
|
||||
"launcher_update_presentation",
|
||||
"news_card_columns",
|
||||
"operation_activity_text",
|
||||
"patch_change_colors",
|
||||
"perform_server_status_check",
|
||||
"preloader_logo_subsample_factor",
|
||||
"preloader_status_text",
|
||||
"resolve_ui_scale",
|
||||
"run_dashboard",
|
||||
"server_status_check_text",
|
||||
"server_status_presentation",
|
||||
"setting_label",
|
||||
"setting_value",
|
||||
"should_refresh_operation_elapsed",
|
||||
"startup_progress_animation_value",
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
"""Reusable Tk dashboard components."""
|
||||
@@ -1,119 +0,0 @@
|
||||
"""Primary game and launcher action controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionPanel:
|
||||
container: Any
|
||||
game_actions: Any
|
||||
system_actions: Any
|
||||
update_button: Any
|
||||
repair_button: Any
|
||||
import_button: Any
|
||||
doctor_button: Any
|
||||
settings_button: Any
|
||||
launcher_update_button: Any
|
||||
launcher_update_badge: Any
|
||||
|
||||
@property
|
||||
def operation_buttons(self) -> tuple[Any, ...]:
|
||||
return (
|
||||
self.update_button,
|
||||
self.repair_button,
|
||||
self.doctor_button,
|
||||
self.settings_button,
|
||||
self.import_button,
|
||||
self.launcher_update_button,
|
||||
)
|
||||
|
||||
@property
|
||||
def mutation_buttons(self) -> tuple[Any, ...]:
|
||||
return (
|
||||
self.update_button,
|
||||
self.repair_button,
|
||||
self.import_button,
|
||||
self.launcher_update_button,
|
||||
)
|
||||
|
||||
|
||||
def build_action_panel(parent: Any, *, ttk: Any, launcher_version: str) -> ActionPanel:
|
||||
controls = ttk.Frame(parent, style="Root.TFrame")
|
||||
controls.pack(fill="x", pady=(8, 8))
|
||||
controls.columnconfigure(0, weight=1, uniform="actions")
|
||||
controls.columnconfigure(1, weight=1, uniform="actions")
|
||||
game_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12)
|
||||
game_actions.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
|
||||
ttk.Label(game_actions, text="GAME FILES", style="Section.TLabel").pack(anchor="w")
|
||||
game_button_row = ttk.Frame(game_actions, style="Surface.TFrame")
|
||||
game_button_row.pack(anchor="w", fill="x", pady=(6, 0))
|
||||
update_button = ttk.Button(
|
||||
game_button_row,
|
||||
text="UPDATE",
|
||||
style="Secondary.TButton",
|
||||
underline=0,
|
||||
)
|
||||
update_button.pack(side="left")
|
||||
repair_button = ttk.Button(
|
||||
game_button_row,
|
||||
text="REPAIR",
|
||||
style="Secondary.TButton",
|
||||
underline=0,
|
||||
)
|
||||
repair_button.pack(side="left", padx=(8, 0))
|
||||
import_button = ttk.Button(
|
||||
game_button_row,
|
||||
text="IMPORT GAME",
|
||||
style="Secondary.TButton",
|
||||
underline=0,
|
||||
)
|
||||
import_button.pack(side="left", padx=(8, 0))
|
||||
|
||||
system_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12)
|
||||
system_actions.grid(row=0, column=1, sticky="nsew", padx=(4, 0))
|
||||
system_heading = ttk.Frame(system_actions, style="Surface.TFrame")
|
||||
system_heading.pack(fill="x")
|
||||
ttk.Label(system_heading, text="LAUNCHER", style="Section.TLabel").pack(side="left")
|
||||
launcher_update_badge = ttk.Label(
|
||||
system_heading,
|
||||
text="",
|
||||
style="UpdateBadge.TLabel",
|
||||
)
|
||||
system_button_row = ttk.Frame(system_actions, style="Surface.TFrame")
|
||||
system_button_row.pack(anchor="w", fill="x", pady=(6, 0))
|
||||
doctor_button = ttk.Button(
|
||||
system_button_row,
|
||||
text="SYSTEM CHECK",
|
||||
style="Secondary.TButton",
|
||||
underline=7,
|
||||
)
|
||||
doctor_button.pack(side="left")
|
||||
settings_button = ttk.Button(
|
||||
system_button_row,
|
||||
text="OPEN SETTINGS",
|
||||
style="Secondary.TButton",
|
||||
underline=5,
|
||||
)
|
||||
settings_button.pack(side="left", padx=(8, 0))
|
||||
launcher_update_button = ttk.Button(
|
||||
system_button_row,
|
||||
text=f"LAUNCHER {launcher_version}",
|
||||
style="Secondary.TButton",
|
||||
underline=0,
|
||||
)
|
||||
launcher_update_button.pack(side="left", padx=(8, 0))
|
||||
return ActionPanel(
|
||||
container=controls,
|
||||
game_actions=game_actions,
|
||||
system_actions=system_actions,
|
||||
update_button=update_button,
|
||||
repair_button=repair_button,
|
||||
import_button=import_button,
|
||||
doctor_button=doctor_button,
|
||||
settings_button=settings_button,
|
||||
launcher_update_button=launcher_update_button,
|
||||
launcher_update_badge=launcher_update_badge,
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Latest official broadcast panel construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BroadcastsPanel:
|
||||
container: Any
|
||||
content: Any
|
||||
|
||||
|
||||
def build_broadcasts_panel(
|
||||
parent: Any,
|
||||
*,
|
||||
ttk: Any,
|
||||
before: Any,
|
||||
) -> BroadcastsPanel:
|
||||
panel = ttk.Frame(parent, style="Surface.TFrame", padding=(10, 8))
|
||||
panel.pack(fill="x", pady=(0, 8), before=before)
|
||||
heading = ttk.Frame(panel, style="Surface.TFrame")
|
||||
heading.pack(fill="x")
|
||||
ttk.Label(heading, text="LATEST BROADCAST", style="Section.TLabel").pack(side="left")
|
||||
ttk.Label(
|
||||
heading,
|
||||
text="OFFICIAL DISCORD",
|
||||
style="Muted.TLabel",
|
||||
).pack(side="right")
|
||||
content = ttk.Frame(panel, style="Surface.TFrame")
|
||||
content.pack(fill="x", pady=(6, 0))
|
||||
return BroadcastsPanel(container=panel, content=content)
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Dashboard header construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..viewmodels.dashboard import preloader_logo_subsample_factor
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardHeader:
|
||||
frame: Any
|
||||
version: Any
|
||||
logo: Any
|
||||
|
||||
|
||||
def build_dashboard_header(
|
||||
parent: Any,
|
||||
*,
|
||||
widgets: Any,
|
||||
tk_module: Any,
|
||||
launcher_version: str,
|
||||
window_icon: Any | None,
|
||||
) -> DashboardHeader:
|
||||
"""Build the shared content frame and branded dashboard heading."""
|
||||
|
||||
frame = widgets.Frame(parent, style="Root.TFrame", padding=12)
|
||||
frame.pack(fill="both", expand=True)
|
||||
header = widgets.Frame(frame, style="Root.TFrame")
|
||||
header.pack(fill="x", pady=(0, 8))
|
||||
logo = tk_module.Canvas(
|
||||
header,
|
||||
width=42,
|
||||
height=42,
|
||||
background="#0c0908",
|
||||
highlightthickness=0,
|
||||
borderwidth=0,
|
||||
)
|
||||
dashboard_header_icon = None
|
||||
icon: Any = window_icon
|
||||
if icon is not None:
|
||||
try:
|
||||
header_icon_factor = preloader_logo_subsample_factor(
|
||||
icon.width(),
|
||||
icon.height(),
|
||||
max_dimension=40,
|
||||
)
|
||||
dashboard_header_icon = icon.subsample(
|
||||
header_icon_factor,
|
||||
header_icon_factor,
|
||||
)
|
||||
except tk_module.TclError:
|
||||
dashboard_header_icon = None
|
||||
if dashboard_header_icon is not None:
|
||||
logo.create_image(21, 21, image=dashboard_header_icon)
|
||||
logo.image = dashboard_header_icon
|
||||
logo.pack(side="left", padx=(0, 9))
|
||||
brand = widgets.Frame(header, style="Root.TFrame")
|
||||
brand.pack(side="left", fill="x", expand=True)
|
||||
widgets.Label(brand, text="PRISTONTALE EU", style="Brand.TLabel").pack(anchor="w")
|
||||
widgets.Label(
|
||||
brand,
|
||||
text="PRISTONTALE EU · NATIVE LINUX LAUNCHER",
|
||||
style="Tagline.TLabel",
|
||||
).pack(anchor="w", pady=(1, 0))
|
||||
version = tk_module.StringVar(
|
||||
value=f"Launcher {launcher_version} · checking official game build…"
|
||||
)
|
||||
widgets.Label(header, textvariable=version, style="Tagline.TLabel").pack(
|
||||
side="right",
|
||||
anchor="e",
|
||||
)
|
||||
return DashboardHeader(frame=frame, version=version, logo=logo)
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Official news and patch-note viewport construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
MAX_NEWS_CARDS = 3
|
||||
NEWS_CARD_HEIGHT = 242
|
||||
NEWS_IMAGE_WIDTH = 292
|
||||
NEWS_IMAGE_HEIGHT = 118
|
||||
PATCH_CARD_WRAP_LENGTH = 900
|
||||
PATCH_CHANGE_WRAP_LENGTH = 790
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsPanel:
|
||||
container: Any
|
||||
news_tab_button: Any
|
||||
patches_tab_button: Any
|
||||
canvas: Any
|
||||
scrollbar: Any
|
||||
content_host: Any
|
||||
news_content: Any
|
||||
patches_content: Any
|
||||
canvas_window: int
|
||||
|
||||
|
||||
def build_news_panel(
|
||||
parent: Any,
|
||||
*,
|
||||
ttk_module: Any,
|
||||
tk_module: Any,
|
||||
controls: Any,
|
||||
ui_scale: float,
|
||||
) -> NewsPanel:
|
||||
news_panel = ttk_module.Frame(parent, style="Surface.TFrame", padding=(10, 8))
|
||||
news_panel.pack(fill="both", expand=True, pady=(0, 8), before=controls)
|
||||
news_heading = ttk_module.Frame(news_panel, style="Surface.TFrame")
|
||||
news_heading.pack(fill="x")
|
||||
ttk_module.Label(news_heading, text="LATEST NEWS", style="Section.TLabel").pack(side="left")
|
||||
news_tabs = ttk_module.Frame(news_heading, style="Surface.TFrame")
|
||||
news_tabs.pack(side="left", padx=(14, 0))
|
||||
news_tab_button = ttk_module.Button(
|
||||
news_tabs,
|
||||
text="NEWS",
|
||||
style="NewsTabActive.TButton",
|
||||
underline=0,
|
||||
)
|
||||
news_tab_button.pack(side="left")
|
||||
patches_tab_button = ttk_module.Button(
|
||||
news_tabs,
|
||||
text="PATCHES",
|
||||
style="NewsTab.TButton",
|
||||
underline=0,
|
||||
)
|
||||
patches_tab_button.pack(side="left", padx=(5, 0))
|
||||
ttk_module.Label(
|
||||
news_heading,
|
||||
text="OFFICIAL FEED",
|
||||
style="Muted.TLabel",
|
||||
).pack(side="right")
|
||||
news_viewport = ttk_module.Frame(news_panel, style="Surface.TFrame")
|
||||
news_viewport.pack(fill="both", expand=True, pady=(6, 0))
|
||||
news_viewport.grid_columnconfigure(0, weight=1)
|
||||
news_viewport.grid_rowconfigure(0, weight=1)
|
||||
news_canvas = tk_module.Canvas(
|
||||
news_viewport,
|
||||
background="#191311",
|
||||
highlightthickness=0,
|
||||
borderwidth=0,
|
||||
height=round(252 * ui_scale),
|
||||
takefocus=True,
|
||||
)
|
||||
news_canvas.grid(row=0, column=0, sticky="nsew")
|
||||
news_scrollbar = ttk_module.Scrollbar(
|
||||
news_viewport,
|
||||
orient="vertical",
|
||||
style="News.Vertical.TScrollbar",
|
||||
command=news_canvas.yview,
|
||||
)
|
||||
news_scrollbar.grid(row=0, column=1, sticky="ns", padx=(5, 0))
|
||||
news_scrollbar.grid_remove()
|
||||
news_canvas.configure(yscrollcommand=news_scrollbar.set)
|
||||
news_content_host = ttk_module.Frame(news_canvas, style="Surface.TFrame")
|
||||
news_content_host.grid_columnconfigure(0, weight=1)
|
||||
news_content = ttk_module.Frame(news_content_host, style="Surface.TFrame")
|
||||
patches_content = ttk_module.Frame(news_content_host, style="Surface.TFrame")
|
||||
news_content.grid(row=0, column=0, sticky="nsew")
|
||||
patches_content.grid(row=0, column=0, sticky="nsew")
|
||||
patches_content.grid_remove()
|
||||
news_window = news_canvas.create_window(
|
||||
(0, 0),
|
||||
window=news_content_host,
|
||||
anchor="nw",
|
||||
)
|
||||
return NewsPanel(
|
||||
container=news_panel,
|
||||
news_tab_button=news_tab_button,
|
||||
patches_tab_button=patches_tab_button,
|
||||
canvas=news_canvas,
|
||||
scrollbar=news_scrollbar,
|
||||
content_host=news_content_host,
|
||||
news_content=news_content,
|
||||
patches_content=patches_content,
|
||||
canvas_window=news_window,
|
||||
)
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Operation progress, realm choice, and server-summary controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProgressPanel:
|
||||
container: Any
|
||||
realm_value: Any
|
||||
realm_selector: Any
|
||||
play_button: Any
|
||||
status: Any
|
||||
progress_text: Any
|
||||
speed_text: Any
|
||||
client_activity_text: Any
|
||||
download_context_text: Any
|
||||
activity_meta_text: Any
|
||||
progress_bar: Any
|
||||
server_status_content: Any
|
||||
server_status_debug_text: Any
|
||||
log_toggle: Any
|
||||
|
||||
def refresh_activity_metadata(self) -> None:
|
||||
self.activity_meta_text.set(
|
||||
self.download_context_text.get() or self.client_activity_text.get()
|
||||
)
|
||||
|
||||
def set_download_context(self, value: str) -> None:
|
||||
self.download_context_text.set(value)
|
||||
self.refresh_activity_metadata()
|
||||
|
||||
|
||||
def build_progress_panel(
|
||||
parent: Any,
|
||||
*,
|
||||
widgets: Any,
|
||||
tk_module: Any,
|
||||
default_realm: str,
|
||||
) -> ProgressPanel:
|
||||
activity_panel = widgets.Frame(parent, style="Surface.TFrame", padding=(10, 7))
|
||||
activity_panel.pack(fill="x", pady=(0, 8))
|
||||
realm_value = tk_module.StringVar(value=default_realm)
|
||||
activity_action = widgets.Frame(activity_panel, style="Surface.TFrame")
|
||||
activity_action.pack(side="right", fill="y", padx=(12, 0))
|
||||
play_button = widgets.Button(
|
||||
activity_action,
|
||||
text="PLAY",
|
||||
style="Play.TButton",
|
||||
underline=0,
|
||||
)
|
||||
play_button.pack(fill="both", expand=True)
|
||||
realm_selector = widgets.Combobox(
|
||||
activity_action,
|
||||
name="realm_selector",
|
||||
textvariable=realm_value,
|
||||
values=(default_realm,),
|
||||
state="readonly",
|
||||
style="Realm.TCombobox",
|
||||
font=("Sans", 9, "bold"),
|
||||
justify="center",
|
||||
width=20,
|
||||
)
|
||||
realm_selector.pack(fill="x", pady=(6, 5))
|
||||
activity_copy = widgets.Frame(activity_panel, style="Surface.TFrame")
|
||||
activity_copy.pack(side="left", fill="both", expand=True)
|
||||
status = tk_module.StringVar(value="Ready")
|
||||
progress_text = tk_module.StringVar(value="Click Play to start your journey")
|
||||
speed_text = tk_module.StringVar(value="")
|
||||
client_activity_text = tk_module.StringVar(value="")
|
||||
download_context_text = tk_module.StringVar(value="")
|
||||
activity_meta_text = tk_module.StringVar(value="")
|
||||
activity_heading = widgets.Frame(activity_copy, style="Surface.TFrame")
|
||||
activity_heading.pack(fill="x")
|
||||
widgets.Label(
|
||||
activity_heading,
|
||||
textvariable=status,
|
||||
style="Status.TLabel",
|
||||
).pack(side="left", anchor="w")
|
||||
widgets.Label(
|
||||
activity_heading,
|
||||
textvariable=activity_meta_text,
|
||||
style="Muted.TLabel",
|
||||
).pack(side="right", anchor="e")
|
||||
progress_bar = widgets.Progressbar(
|
||||
activity_copy,
|
||||
style="Depot.Horizontal.TProgressbar",
|
||||
mode="determinate",
|
||||
maximum=1,
|
||||
value=0,
|
||||
)
|
||||
progress_bar.pack(fill="x", pady=(5, 2))
|
||||
progress_details = widgets.Frame(activity_copy, style="Surface.TFrame")
|
||||
progress_details.pack(fill="x")
|
||||
widgets.Label(
|
||||
progress_details,
|
||||
textvariable=speed_text,
|
||||
style="Speed.TLabel",
|
||||
width=28,
|
||||
anchor="e",
|
||||
).pack(side="right")
|
||||
widgets.Label(
|
||||
progress_details,
|
||||
textvariable=progress_text,
|
||||
style="Muted.TLabel",
|
||||
anchor="w",
|
||||
).pack(side="left", fill="x", expand=True)
|
||||
server_status_row = widgets.Frame(activity_copy, style="Surface.TFrame")
|
||||
server_status_row.pack(fill="x", pady=(5, 1))
|
||||
widgets.Label(
|
||||
server_status_row,
|
||||
text="SERVERS",
|
||||
style="ServerHeading.TLabel",
|
||||
).pack(side="left", fill="y", padx=(0, 8))
|
||||
server_status_content = widgets.Frame(server_status_row, style="Surface.TFrame")
|
||||
server_status_content.pack(side="left", fill="y", anchor="w")
|
||||
log_toggle = tk_module.Button(
|
||||
server_status_row,
|
||||
text=">_",
|
||||
background="#191311",
|
||||
foreground="#f8fafc",
|
||||
activebackground="#24170f",
|
||||
activeforeground="#ffffff",
|
||||
font=("Monospace", 8, "bold"),
|
||||
padx=3,
|
||||
pady=1,
|
||||
borderwidth=0,
|
||||
highlightthickness=1,
|
||||
highlightbackground="#191311",
|
||||
highlightcolor="#f4bd61",
|
||||
relief="flat",
|
||||
takefocus=True,
|
||||
)
|
||||
log_toggle.pack(side="right", anchor="e", padx=(8, 0))
|
||||
server_status_debug_text = tk_module.StringVar(value="")
|
||||
widgets.Label(
|
||||
server_status_row,
|
||||
textvariable=server_status_debug_text,
|
||||
style="Muted.TLabel",
|
||||
).pack(side="right", anchor="e", padx=(8, 0))
|
||||
widgets.Label(
|
||||
server_status_content,
|
||||
text="Checking official server status…",
|
||||
style="ServerUnknown.TLabel",
|
||||
).pack(side="left", anchor="w")
|
||||
return ProgressPanel(
|
||||
container=activity_panel,
|
||||
realm_value=realm_value,
|
||||
realm_selector=realm_selector,
|
||||
play_button=play_button,
|
||||
status=status,
|
||||
progress_text=progress_text,
|
||||
speed_text=speed_text,
|
||||
client_activity_text=client_activity_text,
|
||||
download_context_text=download_context_text,
|
||||
activity_meta_text=activity_meta_text,
|
||||
progress_bar=progress_bar,
|
||||
server_status_content=server_status_content,
|
||||
server_status_debug_text=server_status_debug_text,
|
||||
log_toggle=log_toggle,
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Rendering for the compact official-realm status strip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
from ...depot import DepotStartOption
|
||||
from ...server_status import RealmServerStatus
|
||||
from ..viewmodels.health import server_status_presentation
|
||||
|
||||
|
||||
def render_server_status(
|
||||
container: Any,
|
||||
*,
|
||||
ttk: Any,
|
||||
statuses: tuple[RealmServerStatus, ...] | None,
|
||||
start_options: Iterable[DepotStartOption],
|
||||
placeholder: str,
|
||||
show_debug_details: bool,
|
||||
) -> None:
|
||||
"""Replace the status strip contents from one immutable state snapshot."""
|
||||
|
||||
for child in container.winfo_children():
|
||||
child.destroy()
|
||||
if statuses is None:
|
||||
ttk.Label(
|
||||
container,
|
||||
text=placeholder,
|
||||
style="ServerUnknown.TLabel",
|
||||
).pack(side="left", anchor="w")
|
||||
return
|
||||
statuses_by_name = {item.name.casefold(): item for item in statuses}
|
||||
visible = [
|
||||
statuses_by_name[option.name.casefold()]
|
||||
for option in start_options
|
||||
if option.name.casefold() in statuses_by_name
|
||||
]
|
||||
if not visible:
|
||||
ttk.Label(
|
||||
container,
|
||||
text="Official status is unavailable for this realm.",
|
||||
style="ServerUnknown.TLabel",
|
||||
).pack(side="left", anchor="w")
|
||||
return
|
||||
for index, server in enumerate(visible):
|
||||
presentation = server_status_presentation(
|
||||
server,
|
||||
show_debug_details=show_debug_details,
|
||||
)
|
||||
ttk.Label(
|
||||
container,
|
||||
text=presentation.text,
|
||||
style=presentation.style,
|
||||
).pack(
|
||||
side="left",
|
||||
anchor="w",
|
||||
padx=(0 if index == 0 else 6, 0),
|
||||
)
|
||||
@@ -1,677 +0,0 @@
|
||||
"""Central Tk style configuration for the native dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _settings_checkbox_image(
|
||||
root: Any,
|
||||
*,
|
||||
size: int,
|
||||
gap: int,
|
||||
fill: str,
|
||||
border: str,
|
||||
mark: str | None = None,
|
||||
) -> Any:
|
||||
"""Create one scaled checkbox indicator from the launcher palette."""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
image = tk.PhotoImage(master=root, width=size + gap, height=size)
|
||||
border_width = max(1, round(size / 16))
|
||||
image.put(fill, to=(0, 0, size, size))
|
||||
image.put(border, to=(0, 0, size, border_width))
|
||||
image.put(border, to=(0, size - border_width, size, size))
|
||||
image.put(border, to=(0, 0, border_width, size))
|
||||
image.put(border, to=(size - border_width, 0, size, size))
|
||||
if mark is not None:
|
||||
stroke = max(2, round(size / 8))
|
||||
for source_x, source_y in (
|
||||
(4, 8),
|
||||
(5, 9),
|
||||
(6, 10),
|
||||
(7, 11),
|
||||
(8, 10),
|
||||
(9, 9),
|
||||
(10, 8),
|
||||
(11, 7),
|
||||
(12, 6),
|
||||
):
|
||||
x = round(source_x * size / 16)
|
||||
y = round(source_y * size / 16)
|
||||
image.put(
|
||||
mark,
|
||||
to=(
|
||||
x,
|
||||
y,
|
||||
min(size - border_width, x + stroke),
|
||||
min(size - border_width, y + stroke),
|
||||
),
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def _configure_settings_checkbox_indicator(
|
||||
root: Any,
|
||||
style: Any,
|
||||
launcher_ui_scale: float,
|
||||
) -> None:
|
||||
"""Replace the platform X indicator with the launcher's checkmark."""
|
||||
|
||||
element_name = "Settings.Checkbutton.indicator"
|
||||
if element_name in style.element_names():
|
||||
return
|
||||
size = max(16, round(16 * launcher_ui_scale))
|
||||
gap = max(9, round(9 * launcher_ui_scale))
|
||||
palettes = {
|
||||
"unchecked": ("#100d0c", "#6a4d38", None),
|
||||
"unchecked_active": ("#211916", "#d98927", None),
|
||||
"unchecked_focus": ("#100d0c", "#f4bd61", None),
|
||||
"checked": ("#d98927", "#f4bd61", "#24170f"),
|
||||
"checked_active": ("#efaa4c", "#fff0c2", "#24170f"),
|
||||
"checked_focus": ("#d98927", "#fff0c2", "#24170f"),
|
||||
"disabled": ("#171412", "#30251f", None),
|
||||
"checked_disabled": ("#6c4826", "#776b62", "#30251f"),
|
||||
}
|
||||
images = {
|
||||
name: _settings_checkbox_image(
|
||||
root,
|
||||
size=size,
|
||||
gap=gap,
|
||||
fill=fill,
|
||||
border=border,
|
||||
mark=mark,
|
||||
)
|
||||
for name, (fill, border, mark) in palettes.items()
|
||||
}
|
||||
style.element_create(
|
||||
element_name,
|
||||
"image",
|
||||
images["unchecked"],
|
||||
("disabled selected", images["checked_disabled"]),
|
||||
("disabled", images["disabled"]),
|
||||
("pressed selected", images["checked_active"]),
|
||||
("active selected", images["checked_active"]),
|
||||
("focus selected", images["checked_focus"]),
|
||||
("selected", images["checked"]),
|
||||
("pressed", images["unchecked_active"]),
|
||||
("active", images["unchecked_active"]),
|
||||
("focus", images["unchecked_focus"]),
|
||||
)
|
||||
style.layout(
|
||||
"Settings.TCheckbutton",
|
||||
[
|
||||
(
|
||||
"Checkbutton.padding",
|
||||
{
|
||||
"sticky": "nswe",
|
||||
"children": [
|
||||
(element_name, {"side": "left", "sticky": ""}),
|
||||
(
|
||||
"Checkbutton.focus",
|
||||
{
|
||||
"side": "left",
|
||||
"sticky": "w",
|
||||
"children": [
|
||||
(
|
||||
"Checkbutton.label",
|
||||
{"sticky": "nswe"},
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
setattr(root, "_ptlinux_settings_checkbox_images", tuple(images.values()))
|
||||
|
||||
|
||||
def configure_dashboard_style(root: Any, ttk: Any, launcher_ui_scale: float) -> None:
|
||||
style = ttk.Style(root)
|
||||
style.theme_use("clam")
|
||||
style.configure("Root.TFrame", background="#0c0908")
|
||||
style.configure("Surface.TFrame", background="#191311")
|
||||
style.configure("Hero.TFrame", background="#24170f")
|
||||
style.configure("Card.TFrame", background="#211916")
|
||||
style.configure(
|
||||
"Brand.TLabel",
|
||||
background="#0c0908",
|
||||
foreground="#f5d18b",
|
||||
font=("Georgia", 21, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"Tagline.TLabel",
|
||||
background="#0c0908",
|
||||
foreground="#a8998a",
|
||||
font=("Sans", 8),
|
||||
)
|
||||
style.configure(
|
||||
"Section.TLabel",
|
||||
background="#191311",
|
||||
foreground="#f0d7a2",
|
||||
font=("Sans", 9, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"HeroTitle.TLabel",
|
||||
background="#24170f",
|
||||
foreground="#fff2d3",
|
||||
font=("Georgia", 17, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"HeroText.TLabel",
|
||||
background="#24170f",
|
||||
foreground="#d6c6b3",
|
||||
font=("Sans", 10),
|
||||
)
|
||||
style.configure(
|
||||
"Status.TLabel",
|
||||
background="#191311",
|
||||
foreground="#dfd1c1",
|
||||
font=("Sans", 9),
|
||||
)
|
||||
style.configure(
|
||||
"Muted.TLabel",
|
||||
background="#191311",
|
||||
foreground="#aa9a8b",
|
||||
font=("Sans", 8),
|
||||
)
|
||||
style.configure(
|
||||
"ServerHeading.TLabel",
|
||||
background="#191311",
|
||||
foreground="#d8aa61",
|
||||
font=("Sans", 8, "bold"),
|
||||
padding=(0, 3),
|
||||
)
|
||||
style.configure(
|
||||
"ServerOnline.TLabel",
|
||||
background="#102719",
|
||||
foreground="#58e27a",
|
||||
font=("Sans", 8, "bold"),
|
||||
padding=(8, 3),
|
||||
anchor="w",
|
||||
)
|
||||
style.configure(
|
||||
"ServerOffline.TLabel",
|
||||
background="#301514",
|
||||
foreground="#ff6258",
|
||||
font=("Sans", 8, "bold"),
|
||||
padding=(8, 3),
|
||||
anchor="w",
|
||||
)
|
||||
style.configure(
|
||||
"ServerUnknown.TLabel",
|
||||
background="#211916",
|
||||
foreground="#b8a99a",
|
||||
font=("Sans", 8),
|
||||
padding=(8, 3),
|
||||
anchor="w",
|
||||
)
|
||||
style.configure(
|
||||
"Speed.TLabel",
|
||||
background="#191311",
|
||||
foreground="#e5b66c",
|
||||
font=("Monospace", 8, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"SettingsTitle.TLabel",
|
||||
background="#191311",
|
||||
foreground="#fff0d2",
|
||||
font=("Georgia", 16, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"SettingsText.TLabel",
|
||||
background="#191311",
|
||||
foreground="#e5b66c",
|
||||
font=("Sans", 8, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"SettingsHint.TLabel",
|
||||
background="#191311",
|
||||
foreground="#aa9a8b",
|
||||
font=("Sans", 8),
|
||||
)
|
||||
style.configure(
|
||||
"SettingsCard.TLabelframe",
|
||||
background="#191311",
|
||||
bordercolor="#4a3528",
|
||||
lightcolor="#4a3528",
|
||||
darkcolor="#120e0c",
|
||||
borderwidth=1,
|
||||
relief="solid",
|
||||
)
|
||||
style.configure(
|
||||
"SettingsCard.TLabelframe.Label",
|
||||
background="#191311",
|
||||
foreground="#f0d7a2",
|
||||
font=("Sans", 9, "bold"),
|
||||
)
|
||||
style.configure("SettingsCard.TFrame", background="#191311")
|
||||
style.configure(
|
||||
"Update.Treeview",
|
||||
background="#100d0c",
|
||||
fieldbackground="#100d0c",
|
||||
foreground="#ded1c4",
|
||||
rowheight=max(24, round(24 * launcher_ui_scale)),
|
||||
bordercolor="#4a3528",
|
||||
)
|
||||
style.configure(
|
||||
"Update.Treeview.Heading",
|
||||
background="#2a201c",
|
||||
foreground="#f0d7a2",
|
||||
font=("Sans", 8, "bold"),
|
||||
bordercolor="#4a3528",
|
||||
lightcolor="#4a3528",
|
||||
darkcolor="#120e0c",
|
||||
relief="flat",
|
||||
)
|
||||
style.map(
|
||||
"Update.Treeview.Heading",
|
||||
background=[("active", "#2a201c"), ("pressed", "#2a201c")],
|
||||
foreground=[("active", "#f0d7a2"), ("pressed", "#f0d7a2")],
|
||||
relief=[("active", "flat"), ("pressed", "flat")],
|
||||
)
|
||||
style.map(
|
||||
"Update.Treeview",
|
||||
background=[("selected", "#6a482d")],
|
||||
foreground=[("selected", "#ffffff")],
|
||||
)
|
||||
style.configure("SettingsFooter.TFrame", background="#100d0c")
|
||||
style.configure(
|
||||
"SettingsFooter.TLabel",
|
||||
background="#100d0c",
|
||||
foreground="#aa9a8b",
|
||||
font=("Sans", 8),
|
||||
)
|
||||
style.configure(
|
||||
"Settings.TEntry",
|
||||
fieldbackground="#100d0c",
|
||||
foreground="#f8ead2",
|
||||
insertcolor="#f4bd61",
|
||||
selectbackground="#6a482d",
|
||||
selectforeground="#fff8e9",
|
||||
font=("Sans", 9),
|
||||
padding=(10, 7),
|
||||
bordercolor="#4a3528",
|
||||
lightcolor="#4a3528",
|
||||
darkcolor="#4a3528",
|
||||
borderwidth=1,
|
||||
relief="flat",
|
||||
focuscolor="#f4bd61",
|
||||
focusthickness=1,
|
||||
)
|
||||
style.map(
|
||||
"Settings.TEntry",
|
||||
fieldbackground=[
|
||||
("disabled", "#171412"),
|
||||
("!disabled", "#100d0c"),
|
||||
],
|
||||
foreground=[
|
||||
("disabled", "#776b62"),
|
||||
("!disabled", "#f8ead2"),
|
||||
],
|
||||
bordercolor=[
|
||||
("disabled", "#30251f"),
|
||||
("focus", "#f4bd61"),
|
||||
("!disabled", "#4a3528"),
|
||||
],
|
||||
lightcolor=[
|
||||
("focus", "#f4bd61"),
|
||||
("!focus", "#4a3528"),
|
||||
],
|
||||
darkcolor=[
|
||||
("focus", "#f4bd61"),
|
||||
("!focus", "#4a3528"),
|
||||
],
|
||||
)
|
||||
style.configure(
|
||||
"Settings.TCombobox",
|
||||
fieldbackground="#100d0c",
|
||||
background="#191311",
|
||||
foreground="#f8ead2",
|
||||
arrowcolor="#e5b66c",
|
||||
selectbackground="#6a482d",
|
||||
selectforeground="#fff8e9",
|
||||
font=("Sans", 9),
|
||||
padding=(10, 7),
|
||||
arrowsize=max(12, round(12 * launcher_ui_scale)),
|
||||
bordercolor="#4a3528",
|
||||
lightcolor="#4a3528",
|
||||
darkcolor="#4a3528",
|
||||
borderwidth=1,
|
||||
relief="flat",
|
||||
focuscolor="#f4bd61",
|
||||
focusthickness=1,
|
||||
)
|
||||
style.map(
|
||||
"Settings.TCombobox",
|
||||
fieldbackground=[
|
||||
("disabled", "#171412"),
|
||||
("active", "#100d0c"),
|
||||
("readonly", "#100d0c"),
|
||||
],
|
||||
background=[
|
||||
("disabled", "#171412"),
|
||||
("pressed", "#32251f"),
|
||||
("active", "#191311"),
|
||||
("readonly", "#191311"),
|
||||
],
|
||||
foreground=[
|
||||
("disabled", "#776b62"),
|
||||
("readonly", "#f8ead2"),
|
||||
],
|
||||
arrowcolor=[
|
||||
("disabled", "#776b62"),
|
||||
("pressed", "#fff0c2"),
|
||||
("active", "#f4bd61"),
|
||||
("readonly", "#e5b66c"),
|
||||
],
|
||||
bordercolor=[
|
||||
("disabled", "#30251f"),
|
||||
("focus", "#f4bd61"),
|
||||
("active", "#6a4d38"),
|
||||
("readonly", "#4a3528"),
|
||||
],
|
||||
lightcolor=[
|
||||
("focus", "#f4bd61"),
|
||||
("!focus", "#4a3528"),
|
||||
],
|
||||
darkcolor=[
|
||||
("focus", "#f4bd61"),
|
||||
("!focus", "#4a3528"),
|
||||
],
|
||||
)
|
||||
root.option_add("*TCombobox*Listbox.background", "#100d0c")
|
||||
root.option_add("*TCombobox*Listbox.foreground", "#f8ead2")
|
||||
root.option_add("*TCombobox*Listbox.selectBackground", "#6a482d")
|
||||
root.option_add("*TCombobox*Listbox.selectForeground", "#fff8e9")
|
||||
root.option_add("*TCombobox*Listbox.font", "Sans 9")
|
||||
root.option_add("*TCombobox*Listbox.borderWidth", 1)
|
||||
root.option_add("*TCombobox*Listbox.highlightThickness", 1)
|
||||
root.option_add("*TCombobox*Listbox.highlightBackground", "#4a3528")
|
||||
root.option_add("*TCombobox*Listbox.highlightColor", "#f4bd61")
|
||||
root.option_add("*TCombobox*Listbox.relief", "flat")
|
||||
root.option_add("*TCombobox*Listbox.activeStyle", "none")
|
||||
style.configure(
|
||||
"Realm.TCombobox",
|
||||
fieldbackground="#211916",
|
||||
background="#211916",
|
||||
foreground="#fff0d2",
|
||||
arrowcolor="#d8aa61",
|
||||
bordercolor="#4a3528",
|
||||
lightcolor="#4a3528",
|
||||
darkcolor="#4a3528",
|
||||
font=("Sans", 9, "bold"),
|
||||
padding=(10, 7),
|
||||
relief="flat",
|
||||
)
|
||||
style.map(
|
||||
"Realm.TCombobox",
|
||||
fieldbackground=[
|
||||
("disabled", "#171412"),
|
||||
("readonly", "#211916"),
|
||||
],
|
||||
background=[
|
||||
("disabled", "#171412"),
|
||||
("active", "#2a201c"),
|
||||
("readonly", "#211916"),
|
||||
],
|
||||
foreground=[
|
||||
("disabled", "#887a70"),
|
||||
("readonly", "#fff0d2"),
|
||||
],
|
||||
arrowcolor=[
|
||||
("disabled", "#887a70"),
|
||||
("active", "#f0d7a2"),
|
||||
("readonly", "#d8aa61"),
|
||||
],
|
||||
bordercolor=[
|
||||
("disabled", "#4a3528"),
|
||||
("focus", "#6a4d38"),
|
||||
("readonly", "#4a3528"),
|
||||
],
|
||||
)
|
||||
root.option_add("*realm_selector*Listbox.background", "#211916")
|
||||
root.option_add("*realm_selector*Listbox.foreground", "#fff0d2")
|
||||
root.option_add("*realm_selector*Listbox.selectBackground", "#2a201c")
|
||||
root.option_add("*realm_selector*Listbox.selectForeground", "#f0d7a2")
|
||||
root.option_add("*realm_selector*Listbox.font", "Sans 9 bold")
|
||||
root.option_add("*realm_selector*Listbox.justify", "center")
|
||||
root.option_add("*realm_selector*Listbox.selectBorderWidth", 4)
|
||||
root.option_add("*realm_selector*Listbox.borderWidth", 1)
|
||||
root.option_add("*realm_selector*Listbox.highlightThickness", 0)
|
||||
root.option_add("*realm_selector*Listbox.relief", "flat")
|
||||
root.option_add("*realm_selector*Listbox.activeStyle", "none")
|
||||
_configure_settings_checkbox_indicator(root, style, launcher_ui_scale)
|
||||
style.configure(
|
||||
"Settings.TCheckbutton",
|
||||
background="#171210",
|
||||
foreground="#d8cbbd",
|
||||
font=("Sans", 9),
|
||||
padding=(9, 7),
|
||||
relief="flat",
|
||||
focuscolor="#f4bd61",
|
||||
focusthickness=1,
|
||||
)
|
||||
style.map(
|
||||
"Settings.TCheckbutton",
|
||||
background=[
|
||||
("disabled", "#15110f"),
|
||||
("pressed", "#2a201c"),
|
||||
("active", "#241b17"),
|
||||
("selected", "#211916"),
|
||||
("!selected", "#171210"),
|
||||
],
|
||||
foreground=[
|
||||
("disabled", "#776b62"),
|
||||
("active", "#fff0d2"),
|
||||
("selected", "#f8ead2"),
|
||||
],
|
||||
)
|
||||
style.configure(
|
||||
"PreloaderStatus.TLabel",
|
||||
background="#0c0908",
|
||||
foreground="#dfd1c1",
|
||||
font=("Sans", 10),
|
||||
)
|
||||
style.configure(
|
||||
"PreloaderMuted.TLabel",
|
||||
background="#0c0908",
|
||||
foreground="#aa9a8b",
|
||||
font=("Sans", 9),
|
||||
)
|
||||
style.configure(
|
||||
"NewsTitle.TLabel",
|
||||
background="#211916",
|
||||
foreground="#fff0d2",
|
||||
font=("Sans", 9, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"NewsMeta.TLabel",
|
||||
background="#211916",
|
||||
foreground="#d8aa61",
|
||||
font=("Sans", 9, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"NewsBody.TLabel",
|
||||
background="#211916",
|
||||
foreground="#c9baaa",
|
||||
font=("Sans", 8),
|
||||
)
|
||||
style.configure(
|
||||
"NewsTab.TButton",
|
||||
background="#191311",
|
||||
foreground="#9f8d7c",
|
||||
font=("Sans", 8, "bold"),
|
||||
padding=(10, 4),
|
||||
bordercolor="#32251f",
|
||||
borderwidth=1,
|
||||
relief="flat",
|
||||
focusthickness=0,
|
||||
)
|
||||
style.map(
|
||||
"NewsTab.TButton",
|
||||
background=[("active", "#2a201c")],
|
||||
foreground=[("active", "#f0d7a2")],
|
||||
bordercolor=[("focus", "#f4bd61"), ("active", "#4a3528")],
|
||||
)
|
||||
style.configure(
|
||||
"NewsTabActive.TButton",
|
||||
background="#2a201c",
|
||||
foreground="#f0d7a2",
|
||||
font=("Sans", 8, "bold"),
|
||||
padding=(10, 4),
|
||||
bordercolor="#6a4d38",
|
||||
borderwidth=1,
|
||||
relief="flat",
|
||||
focusthickness=0,
|
||||
)
|
||||
style.map(
|
||||
"NewsTabActive.TButton",
|
||||
background=[("active", "#32251f")],
|
||||
foreground=[("active", "#fff0d2")],
|
||||
bordercolor=[("focus", "#f4bd61")],
|
||||
)
|
||||
style.configure(
|
||||
"News.Vertical.TScrollbar",
|
||||
background="#4a3528",
|
||||
troughcolor="#191311",
|
||||
arrowcolor="#d8aa61",
|
||||
bordercolor="#191311",
|
||||
lightcolor="#4a3528",
|
||||
darkcolor="#32251f",
|
||||
)
|
||||
style.map(
|
||||
"News.Vertical.TScrollbar",
|
||||
background=[
|
||||
("pressed", "#765034"),
|
||||
("active", "#62452f"),
|
||||
("!disabled", "#4a3528"),
|
||||
],
|
||||
arrowcolor=[
|
||||
("pressed", "#fff0c2"),
|
||||
("active", "#f0c77f"),
|
||||
("!disabled", "#d8aa61"),
|
||||
],
|
||||
lightcolor=[
|
||||
("pressed", "#765034"),
|
||||
("active", "#62452f"),
|
||||
("!disabled", "#4a3528"),
|
||||
],
|
||||
darkcolor=[
|
||||
("pressed", "#4b3021"),
|
||||
("active", "#3d2a21"),
|
||||
("!disabled", "#32251f"),
|
||||
],
|
||||
)
|
||||
style.configure(
|
||||
"Play.TButton",
|
||||
background="#d98927",
|
||||
foreground="#fff8e9",
|
||||
font=("Sans", 10, "bold"),
|
||||
padding=(20, 10),
|
||||
bordercolor="#f4bd61",
|
||||
borderwidth=1,
|
||||
relief="flat",
|
||||
focuscolor="#fff0c2",
|
||||
focusthickness=2,
|
||||
)
|
||||
style.map(
|
||||
"Play.TButton",
|
||||
background=[("active", "#efaa4c"), ("disabled", "#6c4826")],
|
||||
foreground=[("disabled", "#bca889")],
|
||||
bordercolor=[("focus", "#fff0c2")],
|
||||
)
|
||||
style.configure(
|
||||
"Secondary.TButton",
|
||||
background="#0b0a09",
|
||||
foreground="#f2b657",
|
||||
font=("Sans", 8, "bold"),
|
||||
padding=(12, 8),
|
||||
bordercolor="#d89332",
|
||||
borderwidth=1,
|
||||
relief="flat",
|
||||
focuscolor="#f4bd61",
|
||||
focusthickness=2,
|
||||
)
|
||||
style.map(
|
||||
"Secondary.TButton",
|
||||
background=[("active", "#271b10"), ("disabled", "#171412")],
|
||||
foreground=[("active", "#ffd17c"), ("disabled", "#887a70")],
|
||||
bordercolor=[("focus", "#f4bd61")],
|
||||
)
|
||||
style.configure(
|
||||
"LauncherUpdate.TButton",
|
||||
background="#f4bd61",
|
||||
foreground="#24170f",
|
||||
font=("Sans", 8, "bold"),
|
||||
padding=(12, 8),
|
||||
bordercolor="#ffe3a3",
|
||||
borderwidth=2,
|
||||
relief="flat",
|
||||
focuscolor="#24170f",
|
||||
focusthickness=2,
|
||||
)
|
||||
style.map(
|
||||
"LauncherUpdate.TButton",
|
||||
background=[("active", "#ffd47f"), ("disabled", "#7b5d37")],
|
||||
foreground=[("active", "#120b06"), ("disabled", "#d0bea2")],
|
||||
bordercolor=[("focus", "#ffffff")],
|
||||
)
|
||||
style.configure(
|
||||
"LauncherManualUpdate.TButton",
|
||||
background="#a63f2f",
|
||||
foreground="#fff4e9",
|
||||
font=("Sans", 8, "bold"),
|
||||
padding=(12, 8),
|
||||
bordercolor="#e98769",
|
||||
borderwidth=2,
|
||||
relief="flat",
|
||||
focuscolor="#ffffff",
|
||||
focusthickness=2,
|
||||
)
|
||||
style.map(
|
||||
"LauncherManualUpdate.TButton",
|
||||
background=[("active", "#c95842"), ("disabled", "#60352e")],
|
||||
foreground=[("active", "#ffffff"), ("disabled", "#c9ada5")],
|
||||
bordercolor=[("focus", "#ffffff")],
|
||||
)
|
||||
style.configure(
|
||||
"UpdateBadge.TLabel",
|
||||
background="#d98927",
|
||||
foreground="#1a1008",
|
||||
font=("Sans", 7, "bold"),
|
||||
padding=(7, 3),
|
||||
)
|
||||
style.configure(
|
||||
"Activity.TButton",
|
||||
background="#0c0908",
|
||||
foreground="#f8fafc",
|
||||
font=("Monospace", 8, "bold"),
|
||||
padding=(3, 2),
|
||||
borderwidth=0,
|
||||
relief="flat",
|
||||
)
|
||||
style.map(
|
||||
"Activity.TButton",
|
||||
background=[("active", "#24170f")],
|
||||
foreground=[("active", "#ffffff")],
|
||||
)
|
||||
style.configure(
|
||||
"News.TButton",
|
||||
background="#211916",
|
||||
foreground="#e5b66c",
|
||||
font=("Sans", 9, "bold"),
|
||||
padding=(0, 3),
|
||||
)
|
||||
style.map("News.TButton", foreground=[("active", "#ffe0a0")])
|
||||
style.configure(
|
||||
"Depot.Horizontal.TProgressbar",
|
||||
background="#d68c34",
|
||||
troughcolor="#34251f",
|
||||
bordercolor="#34251f",
|
||||
lightcolor="#e4a34c",
|
||||
darkcolor="#b66c25",
|
||||
)
|
||||
@@ -1,867 +0,0 @@
|
||||
"""Launcher update history, install, and rollback dialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from ...paths import AppPaths
|
||||
from ...self_update import (
|
||||
LauncherRemoteHistory,
|
||||
LauncherUpdateCheck,
|
||||
LauncherUpdateHistory,
|
||||
inspect_launcher_update_history,
|
||||
)
|
||||
from ...update_resolver import PRERELEASE_CHANNEL, ReleaseModelError, SemanticVersion
|
||||
from ...tk_chrome import (
|
||||
centered_window_geometry,
|
||||
create_window_chrome,
|
||||
show_themed_dialog,
|
||||
)
|
||||
from ..viewmodels.dashboard import fitted_window_size
|
||||
from ..viewmodels.downloader import format_bytes
|
||||
|
||||
|
||||
MAX_RELEASE_NOTES_PREVIEW_LINES = 6
|
||||
MAX_RELEASE_NOTES_PREVIEW_CHARACTERS = 900
|
||||
|
||||
|
||||
def release_notes_preview(markdown: str | None) -> str:
|
||||
"""Return a compact, readable changelog excerpt for the update dialog."""
|
||||
|
||||
if not markdown:
|
||||
return ""
|
||||
lines: list[str] = []
|
||||
truncated = False
|
||||
for raw_line in markdown.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or (not lines and line.startswith("# ")):
|
||||
continue
|
||||
if line == "---":
|
||||
break
|
||||
if len(lines) >= MAX_RELEASE_NOTES_PREVIEW_LINES:
|
||||
truncated = True
|
||||
break
|
||||
lines.append(line)
|
||||
preview = "\n".join(lines)
|
||||
if len(preview) > MAX_RELEASE_NOTES_PREVIEW_CHARACTERS:
|
||||
preview = preview[:MAX_RELEASE_NOTES_PREVIEW_CHARACTERS].rstrip()
|
||||
truncated = True
|
||||
if truncated:
|
||||
preview = preview.rstrip() + "\n…"
|
||||
return preview
|
||||
|
||||
|
||||
def update_version_heading(
|
||||
running_version: str,
|
||||
latest_version: str,
|
||||
channel: str,
|
||||
*,
|
||||
update_available: bool,
|
||||
) -> str:
|
||||
"""Describe update versions without confusing an older channel head as current."""
|
||||
|
||||
if update_available:
|
||||
return f"Update: {running_version} → {latest_version}"
|
||||
return (
|
||||
f"Current version: {running_version}\n"
|
||||
f"Latest {channel.title()} release: {latest_version}"
|
||||
)
|
||||
|
||||
|
||||
def release_history_state(
|
||||
release_status: str,
|
||||
*,
|
||||
is_running: bool,
|
||||
local_active: bool,
|
||||
local_retained: bool,
|
||||
is_older: bool,
|
||||
) -> str:
|
||||
"""Label running and installed releases without treating them as synonymous."""
|
||||
|
||||
if is_running:
|
||||
return "Current"
|
||||
if local_active:
|
||||
return "Installed"
|
||||
if release_status != "active":
|
||||
return release_status.title()
|
||||
if local_retained:
|
||||
return "Retained locally"
|
||||
if is_older:
|
||||
return "Available rollback"
|
||||
return "Published"
|
||||
|
||||
|
||||
class LauncherUpdateDialog:
|
||||
"""Own the update window and expose small refresh/error entry points."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Any,
|
||||
*,
|
||||
paths: AppPaths,
|
||||
launcher_version: str,
|
||||
ui_scale: float,
|
||||
window_icon: Any | None,
|
||||
apply_window_icon: Callable[[Any], None],
|
||||
get_check: Callable[[], LauncherUpdateCheck | None],
|
||||
get_remote_history: Callable[[], LauncherRemoteHistory | None],
|
||||
get_selected_channel: Callable[[], str],
|
||||
is_busy: Callable[[], bool],
|
||||
request_check: Callable[[], None],
|
||||
install_and_restart: Callable[[str], None],
|
||||
rollback_and_restart: Callable[[str], None],
|
||||
open_article: Callable[[str], None],
|
||||
append_log: Callable[[str], None],
|
||||
) -> None:
|
||||
self._root = root
|
||||
self._paths = paths
|
||||
self._launcher_version = launcher_version
|
||||
self._ui_scale = ui_scale
|
||||
self._window_icon = window_icon
|
||||
self._apply_window_icon = apply_window_icon
|
||||
self._get_check = get_check
|
||||
self._get_remote_history = get_remote_history
|
||||
self._get_selected_channel = get_selected_channel
|
||||
self._is_busy = is_busy
|
||||
self._request_check = request_check
|
||||
self._install_and_restart = install_and_restart
|
||||
self._rollback_and_restart = rollback_and_restart
|
||||
self._open_article = open_article
|
||||
self._append_log = append_log
|
||||
self._window: Any = None
|
||||
self._required_reason: str | None = None
|
||||
self._installed_history: LauncherUpdateHistory | None = None
|
||||
self._status_value: Any = None
|
||||
self._details_value: Any = None
|
||||
self._history: Any = None
|
||||
self._history_filter_value: Any = None
|
||||
self._history_items: dict[str, tuple[str, str, str | None, bool]] = {}
|
||||
self._release_notes_frame: Any = None
|
||||
self._release_notes_text: Any = None
|
||||
self._release_notes_button: Any = None
|
||||
self._check_button: Any = None
|
||||
self._rollback_button: Any = None
|
||||
self._install_button: Any = None
|
||||
|
||||
def open(self, required_reason: str | None = None) -> None:
|
||||
if self._window is not None and self._window.winfo_exists():
|
||||
if required_reason is not None:
|
||||
self._required_reason = required_reason
|
||||
self._window.deiconify()
|
||||
self._window.lift()
|
||||
self._window.focus_set()
|
||||
self.refresh()
|
||||
return
|
||||
self._required_reason = required_reason
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
dialog = tk.Toplevel(self._root)
|
||||
dialog.withdraw()
|
||||
self._window = dialog
|
||||
dialog.title("PristonTale EU — Launcher Updates")
|
||||
dialog.resizable(False, True)
|
||||
dialog.configure(background="#0c0908")
|
||||
dialog.transient(self._root)
|
||||
self._apply_window_icon(dialog)
|
||||
updates_chrome = create_window_chrome(
|
||||
dialog,
|
||||
title="PRISTONTALE EU",
|
||||
subtitle="LAUNCHER UPDATES",
|
||||
window_icon=self._window_icon,
|
||||
show_close=True,
|
||||
show_minimize=False,
|
||||
allow_close=True,
|
||||
)
|
||||
|
||||
updates_header = tk.Frame(updates_chrome.content, background="#24170f")
|
||||
updates_header.pack(fill="x")
|
||||
tk_accent = tk.Frame(updates_header, background="#d98927", width=5)
|
||||
tk_accent.pack(side="left", fill="y")
|
||||
header_copy = tk.Frame(updates_header, background="#24170f")
|
||||
header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14)
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="SIGNED RELEASE CHANNEL",
|
||||
background="#24170f",
|
||||
foreground="#d8aa61",
|
||||
font=("Sans", 8, "bold"),
|
||||
).pack(anchor="w")
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="Launcher Updates",
|
||||
background="#24170f",
|
||||
foreground="#fff0d2",
|
||||
font=("Georgia", 18, "bold"),
|
||||
).pack(anchor="w", pady=(2, 0))
|
||||
|
||||
content = ttk.Frame(
|
||||
updates_chrome.content,
|
||||
style="Root.TFrame",
|
||||
padding=(16, 14, 16, 16),
|
||||
)
|
||||
content.pack(fill="both", expand=True)
|
||||
content.columnconfigure(0, weight=1)
|
||||
content.rowconfigure(1, weight=1)
|
||||
self._status_value = tk.StringVar(value="Checking the signed update channel…")
|
||||
self._details_value = tk.StringVar(value="")
|
||||
overview = ttk.LabelFrame(
|
||||
content,
|
||||
text="UPDATE STATUS",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
overview.grid(row=0, column=0, sticky="ew")
|
||||
overview.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
overview,
|
||||
textvariable=self._status_value,
|
||||
style="SettingsText.TLabel",
|
||||
wraplength=round(480 * self._ui_scale),
|
||||
justify="left",
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
ttk.Label(
|
||||
overview,
|
||||
textvariable=self._details_value,
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=round(480 * self._ui_scale),
|
||||
justify="left",
|
||||
).grid(row=1, column=0, sticky="w", pady=(6, 0))
|
||||
self._release_notes_button = ttk.Button(
|
||||
overview,
|
||||
text="VIEW RELEASE NOTES",
|
||||
style="Secondary.TButton",
|
||||
state="disabled",
|
||||
command=self._view_release_notes,
|
||||
)
|
||||
self._release_notes_button.grid(
|
||||
row=0,
|
||||
column=1,
|
||||
rowspan=2,
|
||||
sticky="e",
|
||||
padx=(12, 0),
|
||||
)
|
||||
self._release_notes_frame = ttk.Frame(
|
||||
overview,
|
||||
style="SettingsCard.TFrame",
|
||||
)
|
||||
self._release_notes_frame.grid(
|
||||
row=2,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="ew",
|
||||
pady=(10, 0),
|
||||
)
|
||||
self._release_notes_frame.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
self._release_notes_frame,
|
||||
text="WHAT'S NEW",
|
||||
style="SettingsText.TLabel",
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 4))
|
||||
self._release_notes_text = tk.Text(
|
||||
self._release_notes_frame,
|
||||
name="launcher_update_release_notes",
|
||||
width=1,
|
||||
height=3,
|
||||
wrap="word",
|
||||
background="#100d0c",
|
||||
foreground="#c8b8a8",
|
||||
selectbackground="#6a482d",
|
||||
selectforeground="#ffffff",
|
||||
font=("Sans", 8),
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
padx=8,
|
||||
pady=6,
|
||||
state="disabled",
|
||||
takefocus=False,
|
||||
)
|
||||
release_notes_scrollbar = ttk.Scrollbar(
|
||||
self._release_notes_frame,
|
||||
orient="vertical",
|
||||
style="News.Vertical.TScrollbar",
|
||||
command=self._release_notes_text.yview,
|
||||
)
|
||||
self._release_notes_text.configure(
|
||||
yscrollcommand=release_notes_scrollbar.set,
|
||||
)
|
||||
self._release_notes_text.grid(row=1, column=0, sticky="ew")
|
||||
release_notes_scrollbar.grid(row=1, column=1, sticky="ns", padx=(6, 0))
|
||||
self._release_notes_frame.grid_remove()
|
||||
|
||||
history_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="VERSION HISTORY",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=12,
|
||||
)
|
||||
history_card.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
|
||||
history_card.columnconfigure(0, weight=1)
|
||||
history_card.rowconfigure(1, weight=1)
|
||||
filter_row = ttk.Frame(history_card, style="Root.TFrame")
|
||||
filter_row.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||||
filter_row.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
filter_row,
|
||||
text="Signed Gitea releases and retained local versions",
|
||||
style="SettingsHint.TLabel",
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
self._history_filter_value = tk.StringVar(
|
||||
value=(
|
||||
"All"
|
||||
if self._get_selected_channel() == PRERELEASE_CHANNEL
|
||||
else "Stable"
|
||||
)
|
||||
)
|
||||
history_filter = ttk.Combobox(
|
||||
filter_row,
|
||||
name="launcher_update_channel_filter",
|
||||
textvariable=self._history_filter_value,
|
||||
values=("All", "Stable", "Prerelease"),
|
||||
state="readonly",
|
||||
style="Settings.TCombobox",
|
||||
cursor="hand2",
|
||||
exportselection=False,
|
||||
takefocus=True,
|
||||
width=12,
|
||||
)
|
||||
history_filter.grid(row=0, column=1, sticky="e", padx=(12, 0))
|
||||
history_filter.bind("<<ComboboxSelected>>", lambda _event: self.refresh())
|
||||
self._history = ttk.Treeview(
|
||||
history_card,
|
||||
columns=("version", "channel", "state", "installed"),
|
||||
show="headings",
|
||||
height=4,
|
||||
style="Update.Treeview",
|
||||
selectmode="browse",
|
||||
)
|
||||
self._history.heading("version", text="VERSION", anchor="w")
|
||||
self._history.heading("channel", text="CHANNEL", anchor="w")
|
||||
self._history.heading("state", text="STATE", anchor="w")
|
||||
self._history.heading("installed", text="DATE", anchor="w")
|
||||
self._history.column(
|
||||
"version",
|
||||
width=round(120 * self._ui_scale),
|
||||
minwidth=round(80 * self._ui_scale),
|
||||
stretch=False,
|
||||
anchor="w",
|
||||
)
|
||||
self._history.column(
|
||||
"channel",
|
||||
width=round(105 * self._ui_scale),
|
||||
minwidth=round(80 * self._ui_scale),
|
||||
stretch=False,
|
||||
anchor="w",
|
||||
)
|
||||
self._history.column(
|
||||
"state",
|
||||
width=round(150 * self._ui_scale),
|
||||
minwidth=round(100 * self._ui_scale),
|
||||
stretch=False,
|
||||
anchor="w",
|
||||
)
|
||||
self._history.column(
|
||||
"installed",
|
||||
width=round(360 * self._ui_scale),
|
||||
minwidth=round(180 * self._ui_scale),
|
||||
stretch=True,
|
||||
anchor="w",
|
||||
)
|
||||
self._history.grid(row=1, column=0, sticky="nsew")
|
||||
self._history.bind("<<TreeviewSelect>>", self._history_selected)
|
||||
history_scrollbar = ttk.Scrollbar(
|
||||
history_card,
|
||||
orient="vertical",
|
||||
style="News.Vertical.TScrollbar",
|
||||
command=self._history.yview,
|
||||
)
|
||||
history_scrollbar.grid(row=1, column=1, sticky="ns", padx=(6, 0))
|
||||
self._history.configure(yscrollcommand=history_scrollbar.set)
|
||||
|
||||
footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9))
|
||||
footer.grid(row=2, column=0, sticky="ew", pady=(12, 0))
|
||||
footer.columnconfigure(0, weight=1)
|
||||
self._check_button = ttk.Button(
|
||||
footer,
|
||||
text="CHECK AGAIN",
|
||||
style="Secondary.TButton",
|
||||
command=self._check_again,
|
||||
)
|
||||
self._check_button.grid(row=0, column=0, sticky="w")
|
||||
self._rollback_button = ttk.Button(
|
||||
footer,
|
||||
text="ROLL BACK & RESTART",
|
||||
style="Secondary.TButton",
|
||||
state="disabled",
|
||||
command=self._rollback_update,
|
||||
)
|
||||
self._rollback_button.grid(row=0, column=1, sticky="e", padx=(8, 0))
|
||||
self._install_button = ttk.Button(
|
||||
footer,
|
||||
text="INSTALL & RESTART",
|
||||
style="Play.TButton",
|
||||
state="disabled",
|
||||
command=self._install_update,
|
||||
)
|
||||
self._install_button.grid(row=0, column=2, sticky="e", padx=(8, 0))
|
||||
close_button = ttk.Button(
|
||||
footer,
|
||||
text="CLOSE",
|
||||
style="Secondary.TButton",
|
||||
command=self.close,
|
||||
)
|
||||
close_button.grid(row=0, column=3, sticky="e", padx=(8, 0))
|
||||
|
||||
updates_chrome.set_close_command(self.close)
|
||||
dialog.protocol("WM_DELETE_WINDOW", self.close)
|
||||
dialog.bind("<Escape>", lambda _event: self.close())
|
||||
self.refresh()
|
||||
if self._get_check() is None:
|
||||
self._check_again()
|
||||
dialog.update_idletasks()
|
||||
updates_width, updates_height = fitted_window_size(
|
||||
820,
|
||||
680,
|
||||
scale=self._ui_scale,
|
||||
screen_width=dialog.winfo_screenwidth(),
|
||||
screen_height=dialog.winfo_screenheight(),
|
||||
)
|
||||
dialog.geometry(
|
||||
centered_window_geometry(
|
||||
updates_width,
|
||||
updates_height,
|
||||
parent_x=self._root.winfo_rootx(),
|
||||
parent_y=self._root.winfo_rooty(),
|
||||
parent_width=self._root.winfo_width(),
|
||||
parent_height=self._root.winfo_height(),
|
||||
)
|
||||
)
|
||||
dialog.deiconify()
|
||||
dialog.lift()
|
||||
self._history.focus_set()
|
||||
|
||||
def close(self) -> None:
|
||||
window = self._window
|
||||
self._window = None
|
||||
if window is not None and window.winfo_exists():
|
||||
window.destroy()
|
||||
|
||||
def refresh(self) -> None:
|
||||
if self._window is None or not self._window.winfo_exists():
|
||||
return
|
||||
assert self._status_value is not None
|
||||
assert self._details_value is not None
|
||||
assert self._history is not None
|
||||
assert self._release_notes_frame is not None
|
||||
assert self._release_notes_text is not None
|
||||
assert self._release_notes_button is not None
|
||||
assert self._check_button is not None
|
||||
assert self._rollback_button is not None
|
||||
assert self._install_button is not None
|
||||
check = self._get_check()
|
||||
try:
|
||||
self._installed_history = inspect_launcher_update_history(self._paths)
|
||||
except RuntimeError as error:
|
||||
self._installed_history = None
|
||||
self._append_log(f"Could not inspect launcher update history: {error}")
|
||||
busy = self._is_busy()
|
||||
self._populate_history(busy=busy)
|
||||
self._check_button.configure(state="disabled" if busy else "normal")
|
||||
if check is None:
|
||||
self._status_value.set("Checking the signed update channel…")
|
||||
self._details_value.set(
|
||||
"Running launcher "
|
||||
+ self._launcher_version
|
||||
+ " · release information pending"
|
||||
)
|
||||
self._set_release_notes_preview("")
|
||||
self._install_button.configure(state="disabled")
|
||||
self._refresh_history_actions(busy=busy)
|
||||
return
|
||||
status_prefix = (
|
||||
f"Compatibility update required: {self._required_reason}\n"
|
||||
if self._required_reason is not None
|
||||
else ""
|
||||
)
|
||||
remote_history = self._get_remote_history()
|
||||
installed_status = next(
|
||||
(
|
||||
release.status
|
||||
for release in (
|
||||
remote_history.releases if remote_history is not None else ()
|
||||
)
|
||||
if release.version == self._launcher_version
|
||||
and release.status != "active"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if installed_status is not None:
|
||||
status_prefix += (
|
||||
f"Warning: running launcher {self._launcher_version} is "
|
||||
f"{installed_status}; install the next active eligible release.\n"
|
||||
)
|
||||
self._status_value.set(status_prefix + check.reason)
|
||||
release_title = check.manifest.title or f"Launcher {check.latest_version}"
|
||||
release_summary = check.manifest.summary or "No release summary was provided."
|
||||
notes_preview = release_notes_preview(check.manifest.notes_markdown)
|
||||
prerelease_label = (
|
||||
" · PRERELEASE BUILD"
|
||||
if check.manifest.channel == "prerelease"
|
||||
else ""
|
||||
)
|
||||
summary_line = f"{release_summary}\n" if not notes_preview else ""
|
||||
version_line = update_version_heading(
|
||||
self._launcher_version,
|
||||
check.latest_version,
|
||||
check.manifest.channel,
|
||||
update_available=check.available,
|
||||
)
|
||||
details = (
|
||||
f"{version_line}\n"
|
||||
f"{release_title}{prerelease_label}\n"
|
||||
f"{summary_line}"
|
||||
f"Channel: {check.manifest.channel.title()} · "
|
||||
f"{format_bytes(check.manifest.archive_size)} · "
|
||||
f"published {check.manifest.published_at} · "
|
||||
f"checked {check.checked_at or 'time unavailable'}"
|
||||
)
|
||||
self._details_value.set(details)
|
||||
self._set_release_notes_preview(notes_preview)
|
||||
self._install_button.configure(
|
||||
state=(
|
||||
"normal"
|
||||
if check.available and check.supported and not busy
|
||||
else "disabled"
|
||||
)
|
||||
)
|
||||
self._release_notes_button.configure(
|
||||
state="normal" if check.manifest.notes_url is not None else "disabled"
|
||||
)
|
||||
self._refresh_history_actions(busy=busy)
|
||||
|
||||
def _set_release_notes_preview(self, preview: str) -> None:
|
||||
assert self._release_notes_frame is not None
|
||||
assert self._release_notes_text is not None
|
||||
self._release_notes_text.configure(state="normal")
|
||||
self._release_notes_text.delete("1.0", "end")
|
||||
if preview:
|
||||
self._release_notes_text.insert("1.0", preview)
|
||||
self._release_notes_text.configure(state="disabled")
|
||||
self._release_notes_text.yview_moveto(0.0)
|
||||
if preview:
|
||||
self._release_notes_frame.grid()
|
||||
else:
|
||||
self._release_notes_frame.grid_remove()
|
||||
|
||||
def _populate_history(self, *, busy: bool) -> None:
|
||||
assert self._history is not None
|
||||
assert self._history_filter_value is not None
|
||||
selected_version = None
|
||||
selection = self._history.selection()
|
||||
if selection:
|
||||
selected = self._history_items.get(selection[0])
|
||||
selected_version = selected[0] if selected is not None else None
|
||||
for item in self._history.get_children():
|
||||
self._history.delete(item)
|
||||
self._history_items.clear()
|
||||
installed = {
|
||||
release.version: release
|
||||
for release in (
|
||||
self._installed_history.releases
|
||||
if self._installed_history is not None
|
||||
else ()
|
||||
)
|
||||
}
|
||||
installed_current_version = (
|
||||
self._installed_history.current_version
|
||||
if self._installed_history is not None
|
||||
and self._installed_history.current_version is not None
|
||||
else self._launcher_version
|
||||
)
|
||||
try:
|
||||
installed_current_semantic = SemanticVersion.parse(
|
||||
installed_current_version
|
||||
)
|
||||
except ReleaseModelError:
|
||||
installed_current_semantic = None
|
||||
try:
|
||||
running_channel = SemanticVersion.parse(self._launcher_version).channel
|
||||
except ReleaseModelError:
|
||||
running_channel = "unknown"
|
||||
filter_name = str(self._history_filter_value.get()).casefold()
|
||||
|
||||
def channel_visible(channel: str) -> bool:
|
||||
return filter_name == "all" or filter_name == channel
|
||||
|
||||
remote_history = self._get_remote_history()
|
||||
remote_versions: set[str] = set()
|
||||
displayed_versions: set[str] = set()
|
||||
if remote_history is not None:
|
||||
for index, release in enumerate(remote_history.releases):
|
||||
if not channel_visible(release.channel):
|
||||
continue
|
||||
remote_versions.add(release.version)
|
||||
displayed_versions.add(release.version)
|
||||
local = installed.get(release.version)
|
||||
is_older = (
|
||||
installed_current_semantic is not None
|
||||
and SemanticVersion.parse(release.version)
|
||||
< installed_current_semantic
|
||||
)
|
||||
local_active = bool(local is not None and local.active)
|
||||
can_rollback = (
|
||||
release.status == "active" and is_older and not local_active
|
||||
)
|
||||
state = release_history_state(
|
||||
release.status,
|
||||
is_running=release.version == self._launcher_version,
|
||||
local_active=local_active,
|
||||
local_retained=local is not None,
|
||||
is_older=is_older,
|
||||
)
|
||||
iid = f"remote:{index}:{release.version}"
|
||||
self._history.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=iid,
|
||||
values=(
|
||||
release.version,
|
||||
release.channel.title(),
|
||||
state,
|
||||
release.release_date,
|
||||
),
|
||||
)
|
||||
self._history_items[iid] = (
|
||||
release.version,
|
||||
release.channel,
|
||||
release.release_notes_url,
|
||||
can_rollback,
|
||||
)
|
||||
if self._installed_history is not None:
|
||||
for index, release in enumerate(self._installed_history.releases):
|
||||
if release.version in remote_versions or not channel_visible(release.channel):
|
||||
continue
|
||||
displayed_versions.add(release.version)
|
||||
try:
|
||||
is_older = (
|
||||
installed_current_semantic is not None
|
||||
and SemanticVersion.parse(release.version)
|
||||
< installed_current_semantic
|
||||
)
|
||||
except ReleaseModelError:
|
||||
is_older = False
|
||||
state = release_history_state(
|
||||
"active",
|
||||
is_running=release.version == self._launcher_version,
|
||||
local_active=release.active,
|
||||
local_retained=True,
|
||||
is_older=is_older,
|
||||
)
|
||||
can_rollback = is_older and not release.active
|
||||
iid = f"local:{index}:{release.version}"
|
||||
self._history.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=iid,
|
||||
values=(
|
||||
release.version,
|
||||
release.channel.title(),
|
||||
state,
|
||||
release.installed_at,
|
||||
),
|
||||
)
|
||||
self._history_items[iid] = (
|
||||
release.version,
|
||||
release.channel,
|
||||
None,
|
||||
can_rollback,
|
||||
)
|
||||
if (
|
||||
self._launcher_version not in displayed_versions
|
||||
and channel_visible(running_channel)
|
||||
):
|
||||
iid = f"running:{self._launcher_version}"
|
||||
self._history.insert(
|
||||
"",
|
||||
0,
|
||||
iid=iid,
|
||||
values=(
|
||||
self._launcher_version,
|
||||
running_channel.title(),
|
||||
"Current",
|
||||
"Running now",
|
||||
),
|
||||
)
|
||||
self._history_items[iid] = (
|
||||
self._launcher_version,
|
||||
running_channel,
|
||||
None,
|
||||
False,
|
||||
)
|
||||
if not self._history.get_children():
|
||||
iid = "placeholder"
|
||||
self._history.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=iid,
|
||||
values=(self._launcher_version, "Unknown", "Running", "Not recorded"),
|
||||
)
|
||||
default_version = selected_version
|
||||
if default_version is None and self._installed_history is not None:
|
||||
default_version = self._installed_history.previous_version
|
||||
selected_iid = next(
|
||||
(
|
||||
iid
|
||||
for iid, item in self._history_items.items()
|
||||
if item[0] == default_version
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected_iid is not None:
|
||||
self._history.selection_set(selected_iid)
|
||||
self._history.focus(selected_iid)
|
||||
self._refresh_history_actions(busy=busy)
|
||||
|
||||
def _history_selected(self, _event: object | None = None) -> None:
|
||||
self._refresh_history_actions(busy=self._is_busy())
|
||||
|
||||
def _selected_history_item(self) -> tuple[str, str, str | None, bool] | None:
|
||||
assert self._history is not None
|
||||
selection = self._history.selection()
|
||||
return self._history_items.get(selection[0]) if selection else None
|
||||
|
||||
def _refresh_history_actions(self, *, busy: bool) -> None:
|
||||
assert self._rollback_button is not None
|
||||
assert self._release_notes_button is not None
|
||||
selected = self._selected_history_item()
|
||||
self._rollback_button.configure(
|
||||
state=(
|
||||
"normal"
|
||||
if selected is not None and selected[3] and not busy
|
||||
else "disabled"
|
||||
)
|
||||
)
|
||||
if selected is None:
|
||||
check = self._get_check()
|
||||
notes_url = check.manifest.notes_url if check is not None else None
|
||||
else:
|
||||
notes_url = selected[2]
|
||||
self._release_notes_button.configure(
|
||||
state="normal" if notes_url is not None else "disabled"
|
||||
)
|
||||
|
||||
def show_error(self, message: str) -> None:
|
||||
if self._window is None or not self._window.winfo_exists():
|
||||
return
|
||||
assert self._status_value is not None
|
||||
assert self._details_value is not None
|
||||
assert self._check_button is not None
|
||||
self._status_value.set("The signed update channel could not be checked.")
|
||||
self._details_value.set(message)
|
||||
self._set_release_notes_preview("")
|
||||
self._check_button.configure(state="normal")
|
||||
|
||||
def _check_again(self) -> None:
|
||||
assert self._status_value is not None
|
||||
assert self._details_value is not None
|
||||
assert self._check_button is not None
|
||||
assert self._install_button is not None
|
||||
self._status_value.set("Checking the signed update channel…")
|
||||
self._details_value.set("Authenticating the selected channel manifests and signatures.")
|
||||
self._set_release_notes_preview("")
|
||||
self._check_button.configure(state="disabled")
|
||||
self._install_button.configure(state="disabled")
|
||||
self._request_check()
|
||||
|
||||
def _view_release_notes(self) -> None:
|
||||
selected = self._selected_history_item()
|
||||
if selected is not None:
|
||||
notes_url = selected[2]
|
||||
if notes_url is not None:
|
||||
self._open_article(notes_url)
|
||||
return
|
||||
check = self._get_check()
|
||||
if check is not None and check.manifest.notes_url is not None:
|
||||
self._open_article(check.manifest.notes_url)
|
||||
|
||||
def _install_update(self) -> None:
|
||||
check = self._get_check()
|
||||
if check is None or not check.available or not check.supported:
|
||||
return
|
||||
if not show_themed_dialog(
|
||||
self._window,
|
||||
title="Update launcher",
|
||||
message=(
|
||||
f"Install signed launcher {check.latest_version} "
|
||||
f"from the {check.manifest.channel} channel "
|
||||
f"({format_bytes(check.manifest.archive_size)}) and restart now?"
|
||||
),
|
||||
kind="question",
|
||||
confirm=True,
|
||||
accept_text="INSTALL & RESTART",
|
||||
window_icon=self._window_icon,
|
||||
):
|
||||
return
|
||||
version_to_install = check.latest_version
|
||||
self.close()
|
||||
self._install_and_restart(version_to_install)
|
||||
|
||||
def _rollback_update(self) -> None:
|
||||
history = self._installed_history
|
||||
selected = self._selected_history_item()
|
||||
if selected is not None and selected[3]:
|
||||
target_version, target_channel, _notes_url, _can_rollback = selected
|
||||
elif history is not None and history.previous_version is not None:
|
||||
target_version = history.previous_version
|
||||
target = next(
|
||||
(
|
||||
release
|
||||
for release in history.releases
|
||||
if release.version == target_version
|
||||
),
|
||||
None,
|
||||
)
|
||||
target_channel = target.channel if target is not None else "stable"
|
||||
else:
|
||||
return
|
||||
current_release = next(
|
||||
(
|
||||
release
|
||||
for release in (history.releases if history is not None else ())
|
||||
if release.active
|
||||
),
|
||||
None,
|
||||
)
|
||||
current_channel = (
|
||||
current_release.channel
|
||||
if current_release is not None
|
||||
else SemanticVersion.parse(self._launcher_version).channel
|
||||
)
|
||||
channel_warning = ""
|
||||
if current_channel != target_channel:
|
||||
channel_warning = (
|
||||
f"\n\nThis crosses from {current_channel.title()} to "
|
||||
f"{target_channel.title()}. Older releases may use different "
|
||||
"configuration schemas."
|
||||
)
|
||||
if not show_themed_dialog(
|
||||
self._window,
|
||||
title="Roll back launcher",
|
||||
message=(
|
||||
f"Switch from launcher "
|
||||
f"{(history.current_version if history is not None else None) or self._launcher_version} "
|
||||
f"to {target_version} and restart?\n\n"
|
||||
"The target manifest, signature, archive, and file hashes will be "
|
||||
"authenticated before activation.\n\n"
|
||||
"The current release remains available as the next rollback target."
|
||||
+ channel_warning
|
||||
),
|
||||
kind="warning",
|
||||
confirm=True,
|
||||
accept_text="ROLL BACK & RESTART",
|
||||
window_icon=self._window_icon,
|
||||
):
|
||||
return
|
||||
self.close()
|
||||
self._rollback_and_restart(target_version)
|
||||
@@ -1 +0,0 @@
|
||||
"""Modal and auxiliary windows owned by the native dashboard."""
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Activity-log auxiliary window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from ..viewmodels.dashboard import fitted_window_size
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActivityWindow:
|
||||
window: Any
|
||||
log: Any
|
||||
|
||||
|
||||
def build_activity_window(
|
||||
root: Any,
|
||||
*,
|
||||
tk_module: Any,
|
||||
ui_scale: float,
|
||||
apply_window_icon: Callable[[Any], None],
|
||||
) -> ActivityWindow:
|
||||
window = tk_module.Toplevel(root)
|
||||
window.title("PristonTale EU — Activity")
|
||||
width, height = fitted_window_size(
|
||||
800,
|
||||
400,
|
||||
scale=ui_scale,
|
||||
screen_width=window.winfo_screenwidth(),
|
||||
screen_height=window.winfo_screenheight(),
|
||||
)
|
||||
window.geometry(f"{width}x{height}")
|
||||
window.resizable(False, False)
|
||||
window.configure(background="#0c0908")
|
||||
apply_window_icon(window)
|
||||
log = tk_module.Text(
|
||||
window,
|
||||
background="#100d0c",
|
||||
foreground="#d8cbbd",
|
||||
insertbackground="#f8fafc",
|
||||
relief="flat",
|
||||
font=("Monospace", 9),
|
||||
padx=10,
|
||||
pady=8,
|
||||
state="disabled",
|
||||
)
|
||||
log.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
window.withdraw()
|
||||
return ActivityWindow(window=window, log=log)
|
||||
@@ -1,874 +0,0 @@
|
||||
"""Launcher settings dialog, isolated from dashboard orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from ...paths import AppPaths
|
||||
from ...settings import Settings, ensure_settings, update_settings
|
||||
from ...tk_chrome import (
|
||||
centered_window_geometry,
|
||||
create_window_chrome,
|
||||
show_themed_dialog,
|
||||
)
|
||||
from ..viewmodels.dashboard import (
|
||||
UI_SCALE_LABELS,
|
||||
fitted_window_size,
|
||||
setting_label,
|
||||
setting_value,
|
||||
)
|
||||
from ..viewmodels.downloader import (
|
||||
DOWNLOAD_SPEED_CUSTOM_LABEL,
|
||||
DOWNLOAD_SPEED_PRESETS_MBPS,
|
||||
download_speed_setting_label,
|
||||
download_speed_setting_value,
|
||||
)
|
||||
|
||||
|
||||
RUNTIME_LABELS: dict[str, str] = {
|
||||
"Automatic (recommended)": "auto",
|
||||
"Proton 7 — Steam Runtime container": "proton7-container",
|
||||
"Proton 7 — Direct": "proton7",
|
||||
"UMU Launcher": "umu",
|
||||
}
|
||||
PROTON_SYNC_LABELS: dict[str, str] = {
|
||||
"Automatic (recommended)": "auto",
|
||||
"Fsync": "fsync",
|
||||
"Esync": "esync",
|
||||
}
|
||||
UPDATE_CHANNEL_LABELS: dict[str, str] = {
|
||||
"Stable — Recommended for normal use": "stable",
|
||||
"Prerelease — Beta and release candidate builds": "prerelease",
|
||||
}
|
||||
|
||||
SETTINGS_STACKED_LAYOUT_WIDTH = 720
|
||||
SETTINGS_STACKED_FOOTER_WIDTH = 640
|
||||
SETTINGS_MINIMUM_WIDTH = 620
|
||||
SETTINGS_MINIMUM_HEIGHT = 520
|
||||
|
||||
|
||||
def open_settings_dialog(
|
||||
root: Any,
|
||||
*,
|
||||
paths: AppPaths,
|
||||
launcher_ui_scale: float,
|
||||
window_icon: Any | None,
|
||||
apply_window_icon: Callable[[Any], None],
|
||||
on_saved: Callable[[Settings], None],
|
||||
existing: Any = None,
|
||||
) -> Any | None:
|
||||
"""Create, or focus, the single settings window owned by a dashboard."""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, ttk
|
||||
|
||||
active_window: Any = existing
|
||||
if active_window is not None and active_window.winfo_exists():
|
||||
active_window.deiconify()
|
||||
active_window.lift()
|
||||
active_window.focus_set()
|
||||
return active_window
|
||||
|
||||
try:
|
||||
current_settings = ensure_settings(paths)
|
||||
except (OSError, ValueError) as settings_error:
|
||||
show_themed_dialog(
|
||||
root,
|
||||
title="Settings",
|
||||
message=f"The launcher could not load its settings:\n\n{settings_error}",
|
||||
kind="error",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
return None
|
||||
dialog = tk.Toplevel(root)
|
||||
dialog.withdraw()
|
||||
dialog.title("PristonTale EU — Settings")
|
||||
dialog.resizable(True, True)
|
||||
dialog.configure(background="#0c0908")
|
||||
dialog.transient(root)
|
||||
apply_window_icon(dialog)
|
||||
settings_chrome = create_window_chrome(
|
||||
dialog,
|
||||
title="PRISTONTALE EU",
|
||||
subtitle="LAUNCHER SETTINGS",
|
||||
window_icon=window_icon,
|
||||
show_close=True,
|
||||
show_minimize=False,
|
||||
allow_close=True,
|
||||
)
|
||||
|
||||
settings_header = tk.Frame(
|
||||
settings_chrome.content,
|
||||
background="#24170f",
|
||||
)
|
||||
settings_header.pack(fill="x")
|
||||
tk.Frame(settings_header, background="#d98927", width=5).pack(side="left", fill="y")
|
||||
header_copy = tk.Frame(settings_header, background="#24170f")
|
||||
header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14)
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="PRISTONTALE EU",
|
||||
background="#24170f",
|
||||
foreground="#e5b66c",
|
||||
font=("Sans", 9, "bold"),
|
||||
).pack(anchor="w")
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="LAUNCHER SETTINGS",
|
||||
background="#24170f",
|
||||
foreground="#fff0d2",
|
||||
font=("Georgia", 18, "bold"),
|
||||
).pack(anchor="w", pady=(1, 0))
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text=(
|
||||
"Tune launcher accessibility, downloads, the game window, and runtime "
|
||||
"preferences."
|
||||
),
|
||||
background="#24170f",
|
||||
foreground="#c9baaa",
|
||||
font=("Sans", 9),
|
||||
).pack(anchor="w", pady=(2, 0))
|
||||
|
||||
body = ttk.Frame(settings_chrome.content, style="Root.TFrame")
|
||||
body.pack(fill="both", expand=True)
|
||||
body.columnconfigure(0, weight=1)
|
||||
body.rowconfigure(0, weight=1)
|
||||
settings_viewport = ttk.Frame(body, name="settings_viewport", style="Root.TFrame")
|
||||
settings_viewport.grid(
|
||||
row=0,
|
||||
column=0,
|
||||
sticky="nsew",
|
||||
padx=(16, 10),
|
||||
pady=(14, 8),
|
||||
)
|
||||
settings_viewport.columnconfigure(0, weight=1)
|
||||
settings_viewport.rowconfigure(0, weight=1)
|
||||
settings_canvas = tk.Canvas(
|
||||
settings_viewport,
|
||||
name="settings_canvas",
|
||||
background="#0c0908",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
takefocus=True,
|
||||
yscrollincrement=max(1, round(24 * launcher_ui_scale)),
|
||||
)
|
||||
settings_canvas.grid(row=0, column=0, sticky="nsew")
|
||||
settings_scrollbar = ttk.Scrollbar(
|
||||
settings_viewport,
|
||||
name="settings_scrollbar",
|
||||
orient="vertical",
|
||||
style="News.Vertical.TScrollbar",
|
||||
command=settings_canvas.yview,
|
||||
)
|
||||
settings_scrollbar.grid(row=0, column=1, sticky="ns", padx=(6, 0))
|
||||
settings_canvas.configure(yscrollcommand=settings_scrollbar.set)
|
||||
content = ttk.Frame(
|
||||
settings_canvas,
|
||||
name="settings_content",
|
||||
style="Root.TFrame",
|
||||
padding=(0, 0, 0, 8),
|
||||
)
|
||||
content_window = settings_canvas.create_window(
|
||||
(0, 0),
|
||||
window=content,
|
||||
anchor="nw",
|
||||
)
|
||||
content.columnconfigure(0, weight=1, uniform="settings-card")
|
||||
content.columnconfigure(1, weight=1, uniform="settings-card")
|
||||
|
||||
borderless_value = tk.BooleanVar(value=current_settings.borderless)
|
||||
allow_window_resizing_value = tk.BooleanVar(
|
||||
value=current_settings.allow_window_resizing
|
||||
)
|
||||
ui_scale_value = tk.StringVar(
|
||||
value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS)
|
||||
)
|
||||
telemetry_value = tk.BooleanVar(value=current_settings.telemetry_enabled)
|
||||
suppress_unresponsive_value = tk.BooleanVar(
|
||||
value=current_settings.suppress_unresponsive_warnings
|
||||
)
|
||||
runtime_value = tk.StringVar(
|
||||
value=setting_label(current_settings.runtime, RUNTIME_LABELS)
|
||||
)
|
||||
proton_sync_value = tk.StringVar(
|
||||
value=setting_label(current_settings.proton_sync, PROTON_SYNC_LABELS)
|
||||
)
|
||||
proton_path_value = tk.StringVar(value=current_settings.proton_path or "")
|
||||
game_source_value = tk.StringVar(value=current_settings.game_source or "")
|
||||
download_speed_value = tk.StringVar(
|
||||
value=download_speed_setting_label(
|
||||
current_settings.download_speed_limit_mbps
|
||||
)
|
||||
)
|
||||
custom_download_speed_value = tk.StringVar(
|
||||
value=(
|
||||
f"{current_settings.download_speed_limit_mbps:g}"
|
||||
if current_settings.download_speed_limit_mbps is not None
|
||||
else "5"
|
||||
)
|
||||
)
|
||||
low_bandwidth_value = tk.BooleanVar(
|
||||
value=current_settings.low_bandwidth_mode
|
||||
)
|
||||
update_channel_value = tk.StringVar(
|
||||
value=setting_label(current_settings.update_channel, UPDATE_CHANNEL_LABELS)
|
||||
)
|
||||
|
||||
def add_choice(
|
||||
parent: ttk.LabelFrame,
|
||||
row: int,
|
||||
name: str,
|
||||
label: str,
|
||||
description: str,
|
||||
variable: tk.StringVar,
|
||||
choices: tuple[str, ...],
|
||||
) -> None:
|
||||
ttk.Label(parent, text=label, style="SettingsText.TLabel").grid(
|
||||
row=row,
|
||||
column=0,
|
||||
sticky="w",
|
||||
)
|
||||
ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid(
|
||||
row=row,
|
||||
column=1,
|
||||
sticky="e",
|
||||
)
|
||||
ttk.Combobox(
|
||||
parent,
|
||||
name=name,
|
||||
textvariable=variable,
|
||||
values=choices,
|
||||
state="readonly",
|
||||
style="Settings.TCombobox",
|
||||
cursor="hand2",
|
||||
exportselection=False,
|
||||
takefocus=True,
|
||||
).grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9))
|
||||
|
||||
def add_path(
|
||||
parent: ttk.LabelFrame,
|
||||
row: int,
|
||||
name: str,
|
||||
label: str,
|
||||
description: str,
|
||||
variable: tk.StringVar,
|
||||
browse: Callable[[], None],
|
||||
) -> None:
|
||||
ttk.Label(parent, text=label, style="SettingsText.TLabel").grid(
|
||||
row=row,
|
||||
column=0,
|
||||
sticky="w",
|
||||
)
|
||||
ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid(
|
||||
row=row,
|
||||
column=1,
|
||||
sticky="e",
|
||||
)
|
||||
ttk.Entry(
|
||||
parent,
|
||||
name=name,
|
||||
textvariable=variable,
|
||||
style="Settings.TEntry",
|
||||
exportselection=False,
|
||||
takefocus=True,
|
||||
).grid(
|
||||
row=row + 1,
|
||||
column=0,
|
||||
columnspan=1,
|
||||
sticky="ew",
|
||||
pady=(3, 8),
|
||||
)
|
||||
ttk.Button(parent, text="BROWSE", style="Secondary.TButton", command=browse).grid(
|
||||
row=row + 1,
|
||||
column=1,
|
||||
sticky="e",
|
||||
padx=(8, 0),
|
||||
pady=(3, 8),
|
||||
)
|
||||
|
||||
def choose_proton_path() -> None:
|
||||
selected = filedialog.askopenfilename(
|
||||
parent=dialog,
|
||||
title="Choose the Proton script",
|
||||
)
|
||||
if selected:
|
||||
proton_path_value.set(selected)
|
||||
|
||||
def choose_game_source() -> None:
|
||||
selected = filedialog.askdirectory(
|
||||
parent=dialog,
|
||||
title="Choose an existing PristonTale EU game directory",
|
||||
mustexist=True,
|
||||
)
|
||||
if selected:
|
||||
game_source_value.set(selected)
|
||||
|
||||
def close_settings() -> None:
|
||||
if dialog.winfo_exists():
|
||||
dialog.destroy()
|
||||
|
||||
settings_chrome.set_close_command(close_settings)
|
||||
|
||||
def save_current_settings() -> None:
|
||||
try:
|
||||
download_speed_limit = download_speed_setting_value(
|
||||
download_speed_value.get(),
|
||||
custom_download_speed_value.get(),
|
||||
)
|
||||
updated_settings = update_settings(
|
||||
paths,
|
||||
borderless=borderless_value.get(),
|
||||
allow_window_resizing=allow_window_resizing_value.get(),
|
||||
ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS),
|
||||
telemetry_enabled=telemetry_value.get(),
|
||||
suppress_unresponsive_warnings=suppress_unresponsive_value.get(),
|
||||
runtime=setting_value(runtime_value.get(), RUNTIME_LABELS),
|
||||
proton_sync=setting_value(
|
||||
proton_sync_value.get(),
|
||||
PROTON_SYNC_LABELS,
|
||||
),
|
||||
proton_path=proton_path_value.get().strip() or None,
|
||||
game_source=game_source_value.get().strip() or None,
|
||||
download_speed_limit_mbps=download_speed_limit,
|
||||
low_bandwidth_mode=low_bandwidth_value.get(),
|
||||
update_channel=setting_value(
|
||||
update_channel_value.get(),
|
||||
UPDATE_CHANNEL_LABELS,
|
||||
),
|
||||
)
|
||||
except (OSError, ValueError) as save_error:
|
||||
show_themed_dialog(
|
||||
dialog,
|
||||
title="Settings",
|
||||
message=str(save_error),
|
||||
kind="error",
|
||||
window_icon=window_icon,
|
||||
)
|
||||
return
|
||||
on_saved(updated_settings)
|
||||
close_settings()
|
||||
|
||||
display_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="GAME WINDOW",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
display_card.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
||||
display_card.columnconfigure(0, weight=1)
|
||||
display_card.columnconfigure(1, weight=1)
|
||||
display_hint = ttk.Label(
|
||||
display_card,
|
||||
text=(
|
||||
"Gamescope can resize the desktop window while keeping the game's 32-bit "
|
||||
"Direct3D surface fixed. The game keeps its aspect ratio as the window "
|
||||
"resizes, with black bars when the window has a different shape. Maximize "
|
||||
"the window or press Super+F to toggle fullscreen. "
|
||||
"If Gamescope is unavailable, the launcher keeps the native game window "
|
||||
"at its configured size."
|
||||
),
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=360,
|
||||
)
|
||||
display_hint.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
||||
presentation = ttk.Frame(display_card, style="SettingsCard.TFrame")
|
||||
presentation.grid(row=1, column=0, columnspan=2, sticky="ew")
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Remove title bar and borders (Borderless)",
|
||||
variable=borderless_value,
|
||||
style="Settings.TCheckbutton",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
).pack(fill="x")
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Use a resizable scaled window (Gamescope)",
|
||||
variable=allow_window_resizing_value,
|
||||
style="Settings.TCheckbutton",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
).pack(fill="x", pady=(7, 0))
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Suppress false 'not responding' warnings",
|
||||
variable=suppress_unresponsive_value,
|
||||
style="Settings.TCheckbutton",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
).pack(fill="x", pady=(7, 0))
|
||||
add_choice(
|
||||
display_card,
|
||||
2,
|
||||
"settings_selector_ui_scale",
|
||||
"Launcher size",
|
||||
"Applies after launcher restart",
|
||||
ui_scale_value,
|
||||
tuple(UI_SCALE_LABELS),
|
||||
)
|
||||
|
||||
rendering_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="RUNTIME",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
rendering_card.grid(row=0, column=1, sticky="nsew", padx=(6, 0))
|
||||
rendering_card.columnconfigure(0, weight=1)
|
||||
rendering_card.columnconfigure(1, weight=1)
|
||||
rendering_hint = ttk.Label(
|
||||
rendering_card,
|
||||
text="Choose which compatibility runtime launches the proven game setup.",
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=360,
|
||||
)
|
||||
rendering_hint.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
||||
add_choice(
|
||||
rendering_card,
|
||||
1,
|
||||
"settings_selector_runtime",
|
||||
"Runtime",
|
||||
"Compatibility layer",
|
||||
runtime_value,
|
||||
tuple(RUNTIME_LABELS),
|
||||
)
|
||||
add_choice(
|
||||
rendering_card,
|
||||
3,
|
||||
"settings_selector_proton_sync",
|
||||
"Proton synchronization",
|
||||
"Automatic selects the compatible mode",
|
||||
proton_sync_value,
|
||||
tuple(PROTON_SYNC_LABELS),
|
||||
)
|
||||
|
||||
downloads_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="DOWNLOADS",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
downloads_card.grid(
|
||||
row=1,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="ew",
|
||||
pady=(12, 0),
|
||||
)
|
||||
downloads_card.columnconfigure(0, weight=1)
|
||||
downloads_card.columnconfigure(1, weight=1)
|
||||
downloads_card.columnconfigure(2, weight=1)
|
||||
ttk.Label(
|
||||
downloads_card,
|
||||
text=(
|
||||
"Concurrency adapts to CDN latency, retries, transfer and disk speed, "
|
||||
"CPU load, and the remaining files."
|
||||
),
|
||||
style="SettingsHint.TLabel",
|
||||
).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 9))
|
||||
ttk.Checkbutton(
|
||||
downloads_card,
|
||||
text="Low-bandwidth mode (cached news, up to 2 workers and 5 MB/s)",
|
||||
variable=low_bandwidth_value,
|
||||
style="Settings.TCheckbutton",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
).grid(row=1, column=0, rowspan=2, sticky="ew", padx=(0, 16))
|
||||
ttk.Label(
|
||||
downloads_card,
|
||||
text="Speed limit",
|
||||
style="SettingsText.TLabel",
|
||||
).grid(row=1, column=1, sticky="w")
|
||||
ttk.Label(
|
||||
downloads_card,
|
||||
text="Custom MB/s",
|
||||
style="SettingsText.TLabel",
|
||||
).grid(row=1, column=2, sticky="w", padx=(8, 0))
|
||||
download_speed_selector = ttk.Combobox(
|
||||
downloads_card,
|
||||
name="settings_selector_download_speed",
|
||||
textvariable=download_speed_value,
|
||||
values=tuple(DOWNLOAD_SPEED_PRESETS_MBPS) + (
|
||||
DOWNLOAD_SPEED_CUSTOM_LABEL,
|
||||
),
|
||||
state="readonly",
|
||||
style="Settings.TCombobox",
|
||||
cursor="hand2",
|
||||
exportselection=False,
|
||||
takefocus=True,
|
||||
)
|
||||
download_speed_selector.grid(
|
||||
row=2,
|
||||
column=1,
|
||||
sticky="ew",
|
||||
pady=(3, 0),
|
||||
)
|
||||
custom_download_speed = ttk.Entry(
|
||||
downloads_card,
|
||||
name="settings_download_speed",
|
||||
textvariable=custom_download_speed_value,
|
||||
style="Settings.TEntry",
|
||||
width=12,
|
||||
justify="right",
|
||||
exportselection=False,
|
||||
takefocus=True,
|
||||
)
|
||||
custom_download_speed.grid(
|
||||
row=2,
|
||||
column=2,
|
||||
sticky="ew",
|
||||
padx=(8, 0),
|
||||
pady=(3, 0),
|
||||
)
|
||||
|
||||
def update_custom_download_state(_event: object | None = None) -> None:
|
||||
custom_download_speed.configure(
|
||||
state=(
|
||||
"normal"
|
||||
if download_speed_value.get() == DOWNLOAD_SPEED_CUSTOM_LABEL
|
||||
else "disabled"
|
||||
)
|
||||
)
|
||||
|
||||
download_speed_selector.bind(
|
||||
"<<ComboboxSelected>>",
|
||||
update_custom_download_state,
|
||||
)
|
||||
update_custom_download_state()
|
||||
|
||||
updates_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="LAUNCHER UPDATES",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
updates_card.grid(
|
||||
row=2,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="ew",
|
||||
pady=(12, 0),
|
||||
)
|
||||
updates_card.columnconfigure(0, weight=1)
|
||||
updates_card.columnconfigure(1, weight=1)
|
||||
add_choice(
|
||||
updates_card,
|
||||
0,
|
||||
"settings_selector_update_channel",
|
||||
"Update channel",
|
||||
"Existing installations default to Stable",
|
||||
update_channel_value,
|
||||
tuple(UPDATE_CHANNEL_LABELS),
|
||||
)
|
||||
updates_hint = ttk.Label(
|
||||
updates_card,
|
||||
text=(
|
||||
"Stable receives production releases. Prerelease also receives beta and "
|
||||
"release candidate builds. Returning to Stable never downgrades the "
|
||||
"installed launcher automatically."
|
||||
),
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=720,
|
||||
)
|
||||
updates_hint.grid(row=2, column=0, columnspan=2, sticky="w")
|
||||
|
||||
diagnostics_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="PRIVACY & DIAGNOSTICS",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
diagnostics_card.grid(
|
||||
row=3,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="ew",
|
||||
pady=(12, 0),
|
||||
)
|
||||
diagnostics_card.columnconfigure(0, weight=1)
|
||||
ttk.Checkbutton(
|
||||
diagnostics_card,
|
||||
name="settings_telemetry_enabled",
|
||||
text="Send anonymous diagnostic and usage information",
|
||||
variable=telemetry_value,
|
||||
style="Settings.TCheckbutton",
|
||||
cursor="hand2",
|
||||
takefocus=True,
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
diagnostics_hint = ttk.Label(
|
||||
diagnostics_card,
|
||||
text=(
|
||||
"Helps improve PristonTale EU Linux compatibility by sending anonymous "
|
||||
"launcher errors, Linux and display versions, graphics/runtime compatibility, "
|
||||
"and installation or update reliability. Game accounts, personal files, "
|
||||
"hostnames, and hardware serial numbers are never collected."
|
||||
),
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=720,
|
||||
)
|
||||
diagnostics_hint.grid(row=1, column=0, sticky="w", pady=(7, 0))
|
||||
|
||||
overrides_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="OPTIONAL OVERRIDES",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
overrides_card.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||
overrides_card.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
overrides_card,
|
||||
text="Leave these blank for automatic runtime and managed-game detection.",
|
||||
style="SettingsHint.TLabel",
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
||||
add_path(
|
||||
overrides_card,
|
||||
1,
|
||||
"settings_proton_path",
|
||||
"Proton script",
|
||||
"Use a specific Proton executable",
|
||||
proton_path_value,
|
||||
choose_proton_path,
|
||||
)
|
||||
add_path(
|
||||
overrides_card,
|
||||
3,
|
||||
"settings_game_source",
|
||||
"Import source",
|
||||
"Seed a new managed installation only",
|
||||
game_source_value,
|
||||
choose_game_source,
|
||||
)
|
||||
|
||||
footer = ttk.Frame(
|
||||
body,
|
||||
name="settings_footer",
|
||||
style="SettingsFooter.TFrame",
|
||||
padding=(12, 9),
|
||||
)
|
||||
footer.grid(row=1, column=0, sticky="ew", padx=16, pady=(0, 16))
|
||||
footer.columnconfigure(0, weight=1)
|
||||
footer_note = ttk.Label(
|
||||
footer,
|
||||
text="Only the settings shown here affect the next launcher operation.",
|
||||
style="SettingsFooter.TLabel",
|
||||
)
|
||||
footer_note.grid(row=0, column=0, sticky="w")
|
||||
buttons_row = ttk.Frame(
|
||||
footer,
|
||||
name="settings_buttons",
|
||||
style="SettingsFooter.TFrame",
|
||||
)
|
||||
buttons_row.grid(row=0, column=1, sticky="e")
|
||||
ttk.Button(
|
||||
buttons_row,
|
||||
name="settings_cancel",
|
||||
text="CANCEL",
|
||||
style="Secondary.TButton",
|
||||
command=close_settings,
|
||||
).pack(side="left")
|
||||
ttk.Button(
|
||||
buttons_row,
|
||||
name="settings_save",
|
||||
text="SAVE SETTINGS",
|
||||
style="Play.TButton",
|
||||
command=save_current_settings,
|
||||
).pack(side="left", padx=(8, 0))
|
||||
|
||||
settings_layout_mode: tuple[bool, bool] | None = None
|
||||
settings_layout_width: int | None = None
|
||||
pending_settings_width = 1
|
||||
settings_layout_after: str | None = None
|
||||
|
||||
def arrange_settings(viewport_width: int) -> None:
|
||||
"""Reflow cards and footer controls for the available logical width."""
|
||||
|
||||
nonlocal settings_layout_mode, settings_layout_width
|
||||
if settings_layout_width == viewport_width:
|
||||
return
|
||||
settings_layout_width = viewport_width
|
||||
logical_width = viewport_width / max(launcher_ui_scale, 0.01)
|
||||
stacked = logical_width < SETTINGS_STACKED_LAYOUT_WIDTH
|
||||
footer_stacked = logical_width < SETTINGS_STACKED_FOOTER_WIDTH
|
||||
card_width = (
|
||||
viewport_width
|
||||
if stacked
|
||||
else (viewport_width - round(12 * launcher_ui_scale)) // 2
|
||||
)
|
||||
card_wraplength = max(140, card_width - round(42 * launcher_ui_scale))
|
||||
display_hint.configure(wraplength=card_wraplength)
|
||||
rendering_hint.configure(wraplength=card_wraplength)
|
||||
updates_hint.configure(
|
||||
wraplength=max(140, viewport_width - round(42 * launcher_ui_scale))
|
||||
)
|
||||
diagnostics_hint.configure(
|
||||
wraplength=max(140, viewport_width - round(42 * launcher_ui_scale))
|
||||
)
|
||||
footer_note.configure(
|
||||
wraplength=(
|
||||
max(140, viewport_width - round(42 * launcher_ui_scale))
|
||||
if footer_stacked
|
||||
else max(140, viewport_width - round(300 * launcher_ui_scale))
|
||||
)
|
||||
)
|
||||
|
||||
mode = (stacked, footer_stacked)
|
||||
if settings_layout_mode == mode:
|
||||
return
|
||||
settings_layout_mode = mode
|
||||
if stacked:
|
||||
display_card.grid_configure(
|
||||
row=0,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="ew",
|
||||
padx=0,
|
||||
pady=0,
|
||||
)
|
||||
rendering_card.grid_configure(
|
||||
row=1,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="ew",
|
||||
padx=0,
|
||||
pady=(12, 0),
|
||||
)
|
||||
downloads_card.grid_configure(row=2, pady=(12, 0))
|
||||
updates_card.grid_configure(row=3, pady=(12, 0))
|
||||
diagnostics_card.grid_configure(row=4, pady=(12, 0))
|
||||
overrides_card.grid_configure(row=5, pady=(12, 0))
|
||||
else:
|
||||
display_card.grid_configure(
|
||||
row=0,
|
||||
column=0,
|
||||
columnspan=1,
|
||||
sticky="nsew",
|
||||
padx=(0, 6),
|
||||
pady=0,
|
||||
)
|
||||
rendering_card.grid_configure(
|
||||
row=0,
|
||||
column=1,
|
||||
columnspan=1,
|
||||
sticky="nsew",
|
||||
padx=(6, 0),
|
||||
pady=0,
|
||||
)
|
||||
downloads_card.grid_configure(row=1, pady=(12, 0))
|
||||
updates_card.grid_configure(row=2, pady=(12, 0))
|
||||
diagnostics_card.grid_configure(row=3, pady=(12, 0))
|
||||
overrides_card.grid_configure(row=4, pady=(12, 0))
|
||||
|
||||
if footer_stacked:
|
||||
footer_note.grid_configure(row=0, column=0, columnspan=2)
|
||||
buttons_row.grid_configure(
|
||||
row=1,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
pady=(8, 0),
|
||||
)
|
||||
else:
|
||||
footer_note.grid_configure(row=0, column=0, columnspan=1)
|
||||
buttons_row.grid_configure(
|
||||
row=0,
|
||||
column=1,
|
||||
columnspan=1,
|
||||
pady=0,
|
||||
)
|
||||
|
||||
def refresh_scroll_region(_event: object | None = None) -> None:
|
||||
bounds = settings_canvas.bbox("all")
|
||||
if bounds is not None:
|
||||
settings_canvas.configure(scrollregion=bounds)
|
||||
|
||||
def apply_pending_settings_layout() -> None:
|
||||
nonlocal settings_layout_after
|
||||
settings_layout_after = None
|
||||
arrange_settings(pending_settings_width)
|
||||
|
||||
def resize_settings_content(event: Any) -> None:
|
||||
nonlocal pending_settings_width, settings_layout_after
|
||||
pending_settings_width = max(1, int(event.width))
|
||||
settings_canvas.itemconfigure(content_window, width=pending_settings_width)
|
||||
if settings_layout_after is None:
|
||||
settings_layout_after = dialog.after_idle(apply_pending_settings_layout)
|
||||
|
||||
def scroll_settings(event: Any) -> str | None:
|
||||
canvas_left = settings_canvas.winfo_rootx()
|
||||
canvas_top = settings_canvas.winfo_rooty()
|
||||
pointer_x = dialog.winfo_pointerx()
|
||||
pointer_y = dialog.winfo_pointery()
|
||||
if not (
|
||||
canvas_left <= pointer_x < canvas_left + settings_canvas.winfo_width()
|
||||
and canvas_top <= pointer_y < canvas_top + settings_canvas.winfo_height()
|
||||
):
|
||||
return None
|
||||
if getattr(event.widget, "winfo_class", lambda: "")() == "TCombobox":
|
||||
return None
|
||||
first, last = settings_canvas.yview()
|
||||
if last - first >= 0.999:
|
||||
return None
|
||||
if getattr(event, "num", None) == 4:
|
||||
units = -3
|
||||
elif getattr(event, "num", None) == 5:
|
||||
units = 3
|
||||
else:
|
||||
delta = int(getattr(event, "delta", 0))
|
||||
if delta == 0:
|
||||
return None
|
||||
steps = max(1, min(4, abs(delta) // 120 or 1))
|
||||
units = -steps if delta > 0 else steps
|
||||
settings_canvas.yview_scroll(units, "units")
|
||||
return "break"
|
||||
|
||||
content.bind("<Configure>", refresh_scroll_region)
|
||||
settings_canvas.bind("<Configure>", resize_settings_content)
|
||||
dialog.bind("<MouseWheel>", scroll_settings, add="+")
|
||||
dialog.bind("<Button-4>", scroll_settings, add="+")
|
||||
dialog.bind("<Button-5>", scroll_settings, add="+")
|
||||
settings_canvas.bind("<Home>", lambda _event: settings_canvas.yview_moveto(0.0))
|
||||
settings_canvas.bind("<End>", lambda _event: settings_canvas.yview_moveto(1.0))
|
||||
settings_canvas.bind(
|
||||
"<Prior>",
|
||||
lambda _event: settings_canvas.yview_scroll(-1, "pages"),
|
||||
)
|
||||
settings_canvas.bind(
|
||||
"<Next>",
|
||||
lambda _event: settings_canvas.yview_scroll(1, "pages"),
|
||||
)
|
||||
|
||||
dialog.protocol("WM_DELETE_WINDOW", close_settings)
|
||||
dialog.bind("<Escape>", lambda _event: close_settings())
|
||||
dialog.bind("<Control-s>", lambda _event: save_current_settings())
|
||||
dialog.update_idletasks()
|
||||
settings_width, settings_height = fitted_window_size(
|
||||
842,
|
||||
880,
|
||||
scale=launcher_ui_scale,
|
||||
screen_width=dialog.winfo_screenwidth(),
|
||||
screen_height=dialog.winfo_screenheight(),
|
||||
)
|
||||
minimum_width, minimum_height = fitted_window_size(
|
||||
SETTINGS_MINIMUM_WIDTH,
|
||||
SETTINGS_MINIMUM_HEIGHT,
|
||||
scale=launcher_ui_scale,
|
||||
screen_width=dialog.winfo_screenwidth(),
|
||||
screen_height=dialog.winfo_screenheight(),
|
||||
)
|
||||
dialog.minsize(minimum_width, minimum_height)
|
||||
dialog.geometry(
|
||||
centered_window_geometry(
|
||||
settings_width,
|
||||
settings_height,
|
||||
parent_x=root.winfo_rootx(),
|
||||
parent_y=root.winfo_rooty(),
|
||||
parent_width=root.winfo_width(),
|
||||
parent_height=root.winfo_height(),
|
||||
)
|
||||
)
|
||||
dialog.deiconify()
|
||||
dialog.lift()
|
||||
dialog.grab_set()
|
||||
dialog.focus_set()
|
||||
return dialog
|
||||
@@ -1 +0,0 @@
|
||||
"""Pure state and presentation models used by the native dashboard."""
|
||||
@@ -1,496 +0,0 @@
|
||||
"""Dashboard state and presentation helpers independent of Tk widget layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Callable, Mapping
|
||||
|
||||
from ...paths import AppPaths
|
||||
from ...self_update import LauncherUpdateCheck
|
||||
from ...telemetry import (
|
||||
TELEMETRY_OPERATION_PROCESS_ROLE,
|
||||
TELEMETRY_PROCESS_ROLE_VARIABLE,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tkinter import Frame
|
||||
|
||||
|
||||
WINDOW_WIDTH = 1002
|
||||
WINDOW_HEIGHT = 784
|
||||
BASE_DISPLAY_DPI = 96.0
|
||||
BASE_TK_SCALING = BASE_DISPLAY_DPI / 72.0
|
||||
UI_SCALE_FACTORS: dict[str, float] = {
|
||||
"100": 1.0,
|
||||
"125": 1.25,
|
||||
"150": 1.5,
|
||||
"175": 1.75,
|
||||
"200": 2.0,
|
||||
}
|
||||
UI_SCALE_LABELS: dict[str, str] = {
|
||||
"Automatic (system DPI)": "auto",
|
||||
"100% — Standard": "100",
|
||||
"125% — Large": "125",
|
||||
"150% — Larger": "150",
|
||||
"175% — Extra large": "175",
|
||||
"200% — Maximum": "200",
|
||||
}
|
||||
NEWS_THREE_COLUMN_WIDTH = 900
|
||||
NEWS_TWO_COLUMN_WIDTH = 620
|
||||
STACKED_CONTROLS_WIDTH = 860
|
||||
POLL_INTERVAL_MILLISECONDS = 33
|
||||
ACTIVE_POLL_INTERVAL_MILLISECONDS = 75
|
||||
IDLE_POLL_INTERVAL_MILLISECONDS = 500
|
||||
DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 300
|
||||
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000
|
||||
DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
|
||||
DASHBOARD_TRANSITION_STEPS = 9
|
||||
DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18
|
||||
STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS = 16
|
||||
STARTUP_PROGRESS_ANIMATION_EASING = 0.28
|
||||
STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP = 0.08
|
||||
PRELOADER_STATUS_CHARACTERS = 64
|
||||
DASHBOARD_STARTUP_COMPONENTS = frozenset(
|
||||
{
|
||||
"broadcast",
|
||||
"cached_content",
|
||||
"compatibility",
|
||||
"launcher_update",
|
||||
"logo",
|
||||
"news",
|
||||
"news_artwork",
|
||||
"patches",
|
||||
"server_status",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
DASHBOARD_STARTUP_REQUIRED_COMPONENTS = frozenset({"cached_content"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherUpdatePresentation:
|
||||
"""Visual treatment for one authenticated launcher-update result."""
|
||||
|
||||
button_text: str
|
||||
button_style: str
|
||||
badge_text: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameClientDebugState:
|
||||
"""Live process state exposed only in the hidden dashboard debug view."""
|
||||
|
||||
client_id: int
|
||||
phase: str
|
||||
supervisor_pid: int | None
|
||||
elapsed_seconds: float
|
||||
|
||||
|
||||
class DashboardError(RuntimeError):
|
||||
"""Raised when the native dashboard cannot be displayed."""
|
||||
|
||||
|
||||
def resolve_ui_scale(preference: str, pixels_per_inch: float) -> float:
|
||||
"""Resolve a saved launcher scale while respecting the detected display DPI."""
|
||||
|
||||
if preference == "auto":
|
||||
if not math.isfinite(pixels_per_inch) or pixels_per_inch <= 0:
|
||||
return 1.0
|
||||
return min(2.0, max(1.0, pixels_per_inch / BASE_DISPLAY_DPI))
|
||||
try:
|
||||
return UI_SCALE_FACTORS[preference]
|
||||
except KeyError as error:
|
||||
raise ValueError(f"unsupported launcher UI scale: {preference}") from error
|
||||
|
||||
|
||||
def fitted_window_size(
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
scale: float,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
margin: int = 64,
|
||||
) -> tuple[int, int]:
|
||||
"""Scale an initial window without placing it beyond the available display."""
|
||||
|
||||
if width <= 0 or height <= 0 or scale <= 0:
|
||||
raise ValueError("window dimensions and scale must be positive")
|
||||
if screen_width <= 0 or screen_height <= 0 or margin < 0:
|
||||
raise ValueError("screen dimensions must be positive and margin non-negative")
|
||||
available_width = max(1, screen_width - margin)
|
||||
available_height = max(1, screen_height - margin)
|
||||
return (
|
||||
min(max(1, round(width * scale)), available_width),
|
||||
min(max(1, round(height * scale)), available_height),
|
||||
)
|
||||
|
||||
|
||||
def news_card_columns(width: int, scale: float) -> int:
|
||||
"""Choose a readable card grid for the current physical dashboard width."""
|
||||
|
||||
if width <= 0 or scale <= 0:
|
||||
return 1
|
||||
logical_width = width / scale
|
||||
if logical_width >= NEWS_THREE_COLUMN_WIDTH:
|
||||
return 3
|
||||
if logical_width >= NEWS_TWO_COLUMN_WIDTH:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def dashboard_controls_stacked(width: int, scale: float) -> bool:
|
||||
return width <= 0 or scale <= 0 or width / scale < STACKED_CONTROLS_WIDTH
|
||||
|
||||
|
||||
def setting_label(value: str, choices: Mapping[str, str]) -> str:
|
||||
for label, choice in choices.items():
|
||||
if choice == value:
|
||||
return label
|
||||
raise ValueError(f"unsupported saved setting value: {value}")
|
||||
|
||||
|
||||
def setting_value(label: str, choices: Mapping[str, str]) -> str:
|
||||
try:
|
||||
return choices[label]
|
||||
except KeyError as error:
|
||||
raise ValueError(f"unsupported setting choice: {label}") from error
|
||||
|
||||
|
||||
class GameLaunchSupervisorRegistry:
|
||||
"""Bridge Tk cancellation requests to game launch worker processes safely."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._processes: dict[int, subprocess.Popen[str]] = {}
|
||||
self._cancel_requested: set[int] = set()
|
||||
|
||||
def attach(self, client_id: int, process: subprocess.Popen[str]) -> bool:
|
||||
"""Register a supervisor and report whether cancellation raced its start."""
|
||||
|
||||
with self._lock:
|
||||
self._processes[client_id] = process
|
||||
return client_id in self._cancel_requested
|
||||
|
||||
def request_cancel(self, client_id: int) -> subprocess.Popen[str] | None:
|
||||
"""Record cancellation and return an already-started supervisor, if any."""
|
||||
|
||||
with self._lock:
|
||||
self._cancel_requested.add(client_id)
|
||||
return self._processes.get(client_id)
|
||||
|
||||
def detach(self, client_id: int, process: subprocess.Popen[str]) -> None:
|
||||
with self._lock:
|
||||
if self._processes.get(client_id) is process:
|
||||
self._processes.pop(client_id, None)
|
||||
|
||||
def is_cancel_requested(self, client_id: int) -> bool:
|
||||
with self._lock:
|
||||
return client_id in self._cancel_requested
|
||||
|
||||
def finish(self, client_id: int) -> bool:
|
||||
"""Forget one launch and return whether the user cancelled it."""
|
||||
|
||||
with self._lock:
|
||||
self._processes.pop(client_id, None)
|
||||
if client_id not in self._cancel_requested:
|
||||
return False
|
||||
self._cancel_requested.remove(client_id)
|
||||
return True
|
||||
|
||||
|
||||
def interrupt_game_supervisor(process: subprocess.Popen[str]) -> bool:
|
||||
"""Ask the play CLI to stop its complete Proton/game process group."""
|
||||
|
||||
if process.poll() is not None:
|
||||
return False
|
||||
try:
|
||||
process.send_signal(signal.SIGINT)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class GameClientTracker:
|
||||
"""Track dashboard-launched clients without retaining process objects in Tk."""
|
||||
|
||||
def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None:
|
||||
self._clock = clock
|
||||
self._next_client_id = 1
|
||||
self._starting: set[int] = set()
|
||||
self._running: set[int] = set()
|
||||
self._started_at: dict[int, float] = {}
|
||||
self._running_at: dict[int, float] = {}
|
||||
self._supervisor_pids: dict[int, int] = {}
|
||||
|
||||
def begin(self) -> int:
|
||||
client_id = self._next_client_id
|
||||
self._next_client_id += 1
|
||||
self._starting.add(client_id)
|
||||
self._started_at[client_id] = self._clock()
|
||||
return client_id
|
||||
|
||||
def attach_supervisor(self, client_id: int, pid: int) -> bool:
|
||||
if pid <= 0 or (
|
||||
client_id not in self._starting and client_id not in self._running
|
||||
):
|
||||
return False
|
||||
self._supervisor_pids[client_id] = pid
|
||||
return True
|
||||
|
||||
def mark_running(self, client_id: int) -> bool:
|
||||
if client_id in self._running or client_id not in self._starting:
|
||||
return False
|
||||
self._starting.remove(client_id)
|
||||
self._running.add(client_id)
|
||||
self._running_at[client_id] = self._clock()
|
||||
return True
|
||||
|
||||
def finish(self, client_id: int) -> str | None:
|
||||
if client_id in self._starting:
|
||||
self._starting.remove(client_id)
|
||||
phase = "starting"
|
||||
elif client_id in self._running:
|
||||
self._running.remove(client_id)
|
||||
phase = "running"
|
||||
else:
|
||||
return None
|
||||
self._started_at.pop(client_id, None)
|
||||
self._running_at.pop(client_id, None)
|
||||
self._supervisor_pids.pop(client_id, None)
|
||||
return phase
|
||||
|
||||
def debug_states(
|
||||
self,
|
||||
*,
|
||||
timestamp: float | None = None,
|
||||
) -> tuple[GameClientDebugState, ...]:
|
||||
now = self._clock() if timestamp is None else timestamp
|
||||
return tuple(
|
||||
GameClientDebugState(
|
||||
client_id=client_id,
|
||||
phase="running" if client_id in self._running else "starting",
|
||||
supervisor_pid=self._supervisor_pids.get(client_id),
|
||||
elapsed_seconds=max(
|
||||
0.0,
|
||||
now
|
||||
- (
|
||||
self._running_at.get(client_id, now)
|
||||
if client_id in self._running
|
||||
else self._started_at.get(client_id, now)
|
||||
),
|
||||
),
|
||||
)
|
||||
for client_id in sorted(self._starting | self._running)
|
||||
)
|
||||
|
||||
@property
|
||||
def starting_count(self) -> int:
|
||||
return len(self._starting)
|
||||
|
||||
@property
|
||||
def running_count(self) -> int:
|
||||
return len(self._running)
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
return self.starting_count + self.running_count
|
||||
|
||||
|
||||
def game_client_activity_text(running_count: int) -> str:
|
||||
if running_count < 0:
|
||||
raise ValueError("running client count must not be negative")
|
||||
if running_count == 0:
|
||||
return ""
|
||||
label = "client" if running_count == 1 else "clients"
|
||||
return f"{running_count} {label} running"
|
||||
|
||||
|
||||
def debug_elapsed_text(elapsed_seconds: float) -> str:
|
||||
"""Format a live debug duration without sub-second UI churn."""
|
||||
|
||||
seconds = max(0, int(elapsed_seconds))
|
||||
hours, remainder = divmod(seconds, 60 * 60)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m {seconds}s"
|
||||
if minutes:
|
||||
return f"{minutes}m {seconds}s"
|
||||
return f"{seconds}s"
|
||||
|
||||
|
||||
def launcher_update_presentation(
|
||||
check: LauncherUpdateCheck,
|
||||
) -> LauncherUpdatePresentation:
|
||||
"""Make authenticated updates visually distinct from the normal version button."""
|
||||
|
||||
if check.available and check.supported:
|
||||
return LauncherUpdatePresentation(
|
||||
button_text=f"UPDATE LAUNCHER · {check.latest_version}",
|
||||
button_style="LauncherUpdate.TButton",
|
||||
badge_text="SIGNED UPDATE AVAILABLE",
|
||||
)
|
||||
if check.available:
|
||||
return LauncherUpdatePresentation(
|
||||
button_text=f"MANUAL UPDATE · {check.latest_version}",
|
||||
button_style="LauncherManualUpdate.TButton",
|
||||
badge_text="MANUAL UPDATE REQUIRED",
|
||||
)
|
||||
return LauncherUpdatePresentation(
|
||||
button_text=f"LAUNCHER {check.current_version}",
|
||||
button_style="Secondary.TButton",
|
||||
badge_text=None,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_preloader_delay(
|
||||
started_at: float,
|
||||
*,
|
||||
timestamp: float | None = None,
|
||||
) -> int:
|
||||
"""Keep the startup preloader visible long enough to avoid a brief flash."""
|
||||
|
||||
now = time.monotonic() if timestamp is None else timestamp
|
||||
elapsed_milliseconds = round(max(0.0, now - started_at) * 1000)
|
||||
return max(0, DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS - elapsed_milliseconds)
|
||||
|
||||
|
||||
def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool:
|
||||
"""Return whether cached content is ready for an interactive dashboard."""
|
||||
|
||||
return DASHBOARD_STARTUP_REQUIRED_COMPONENTS.issubset(ready_components)
|
||||
|
||||
|
||||
def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float:
|
||||
"""Return one clamped opacity step for the loader/dashboard cross-fade."""
|
||||
|
||||
progress = min(max(step, 0), DASHBOARD_TRANSITION_STEPS) / DASHBOARD_TRANSITION_STEPS
|
||||
opacity_range = 1.0 - DASHBOARD_TRANSITION_MINIMUM_OPACITY
|
||||
if fading_in:
|
||||
return DASHBOARD_TRANSITION_MINIMUM_OPACITY + opacity_range * progress
|
||||
return 1.0 - opacity_range * progress
|
||||
|
||||
|
||||
def preloader_status_text(
|
||||
value: str,
|
||||
step: int | None = None,
|
||||
total: int | None = None,
|
||||
) -> str:
|
||||
"""Render one launch stage without letting its widget change shape."""
|
||||
|
||||
if step is None or total is None:
|
||||
return value
|
||||
return f"Step {step} of {total} · {value}"
|
||||
|
||||
|
||||
def preloader_logo_subsample_factor(
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
max_dimension: int = 88,
|
||||
) -> int:
|
||||
"""Return an integer Tk subsample factor for a compact preloader logo."""
|
||||
|
||||
if width <= 0 or height <= 0 or max_dimension <= 0:
|
||||
raise ValueError("preloader logo dimensions must be positive")
|
||||
return max(
|
||||
1,
|
||||
math.ceil(width / max_dimension),
|
||||
math.ceil(height / max_dimension),
|
||||
)
|
||||
|
||||
|
||||
def startup_progress_animation_value(current: float, target: float) -> float:
|
||||
"""Ease one determinate startup-progress frame without overshooting."""
|
||||
|
||||
distance = abs(target - current)
|
||||
if distance == 0:
|
||||
return target
|
||||
step = max(
|
||||
STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP,
|
||||
distance * STARTUP_PROGRESS_ANIMATION_EASING,
|
||||
)
|
||||
if step >= distance:
|
||||
return target
|
||||
direction = 1.0 if target > current else -1.0
|
||||
return current + (direction * step)
|
||||
|
||||
|
||||
def freeze_preloader_geometry(frame: Frame) -> tuple[int, int]:
|
||||
"""Lock a measured preloader frame so later status text cannot reflow it."""
|
||||
|
||||
frame.update_idletasks()
|
||||
width = max(1, frame.winfo_reqwidth())
|
||||
height = max(1, frame.winfo_reqheight())
|
||||
frame.configure(width=width, height=height)
|
||||
frame.pack_propagate(False)
|
||||
return width, height
|
||||
|
||||
|
||||
def launcher_restart_spec(
|
||||
paths: AppPaths,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> tuple[list[str], dict[str, str]]:
|
||||
"""Build an isolated restart that imports the newly activated release."""
|
||||
|
||||
current = paths.launcher_current
|
||||
if not current.is_symlink():
|
||||
raise DashboardError(f"launcher current path is not a symbolic link: {current}")
|
||||
try:
|
||||
release = current.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise DashboardError(f"launcher current release is unavailable: {exc}") from exc
|
||||
releases = paths.launcher_releases.resolve(strict=False)
|
||||
if release.parent != releases or not (release / "ptlinux").is_dir():
|
||||
raise DashboardError(f"launcher current release is outside the release store: {release}")
|
||||
env = dict(os.environ if environment is None else environment)
|
||||
env.update(
|
||||
{
|
||||
"PTLINUX_INSTALL_DIR": str(paths.data),
|
||||
"PTLINUX_APP_HOME": str(paths.app_home),
|
||||
"PYTHONSAFEPATH": "1",
|
||||
"PYTHONPATH": str(release),
|
||||
}
|
||||
)
|
||||
return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env
|
||||
|
||||
|
||||
def launcher_child_command(*arguments: str) -> list[str]:
|
||||
"""Build a child command without writing bytecode into the active release."""
|
||||
|
||||
return [sys.executable, "-B", "-m", "ptlinux", *arguments]
|
||||
|
||||
|
||||
def launcher_child_environment(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Mark an internal CLI process as part of the current dashboard session."""
|
||||
|
||||
child_environment = dict(os.environ if environment is None else environment)
|
||||
child_environment[TELEMETRY_PROCESS_ROLE_VARIABLE] = (
|
||||
TELEMETRY_OPERATION_PROCESS_ROLE
|
||||
)
|
||||
return child_environment
|
||||
|
||||
|
||||
def dashboard_poll_interval(
|
||||
*,
|
||||
operation_active: bool,
|
||||
game_window_active: bool,
|
||||
pending_events: bool,
|
||||
) -> int:
|
||||
"""Choose a responsive poll rate while avoiding busy idle/game polling."""
|
||||
|
||||
if pending_events:
|
||||
return POLL_INTERVAL_MILLISECONDS
|
||||
if operation_active and not game_window_active:
|
||||
return ACTIVE_POLL_INTERVAL_MILLISECONDS
|
||||
return IDLE_POLL_INTERVAL_MILLISECONDS
|
||||
@@ -1,240 +0,0 @@
|
||||
"""Download and long-running-operation presentation state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
DOWNLOAD_SPEED_PRESETS_MBPS: dict[str, float | None] = {
|
||||
"Unlimited": None,
|
||||
"5 MB/s": 5.0,
|
||||
"10 MB/s": 10.0,
|
||||
"25 MB/s": 25.0,
|
||||
"50 MB/s": 50.0,
|
||||
}
|
||||
DOWNLOAD_SPEED_CUSTOM_LABEL = "Custom…"
|
||||
TRANSFER_RATE_WINDOW_SECONDS = 3.0
|
||||
TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2
|
||||
TRANSFER_RATE_IDLE_SECONDS = 2.0
|
||||
|
||||
|
||||
def download_speed_setting_label(limit_mbps: float | None) -> str:
|
||||
for label, preset in DOWNLOAD_SPEED_PRESETS_MBPS.items():
|
||||
if preset == limit_mbps:
|
||||
return label
|
||||
return DOWNLOAD_SPEED_CUSTOM_LABEL
|
||||
|
||||
|
||||
def download_speed_setting_value(label: str, custom_value: str) -> float | None:
|
||||
if label in DOWNLOAD_SPEED_PRESETS_MBPS:
|
||||
return DOWNLOAD_SPEED_PRESETS_MBPS[label]
|
||||
if label != DOWNLOAD_SPEED_CUSTOM_LABEL:
|
||||
raise ValueError(f"unsupported download speed choice: {label}")
|
||||
try:
|
||||
value = float(custom_value.strip())
|
||||
except ValueError as error:
|
||||
raise ValueError("custom download speed must be a number in MB/s") from error
|
||||
if not math.isfinite(value) or value <= 0 or value > 10_000:
|
||||
raise ValueError(
|
||||
"custom download speed must be greater than 0 and at most 10000 MB/s"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def download_activity_metadata(
|
||||
*,
|
||||
current_server: str | None,
|
||||
retry_count: int,
|
||||
retry_reason: str | None,
|
||||
active_workers: int,
|
||||
target_workers: int,
|
||||
) -> str:
|
||||
"""Render compact download details for the existing activity heading."""
|
||||
|
||||
if retry_count < 0 or active_workers < 0 or target_workers < 0:
|
||||
raise ValueError("download activity counts must not be negative")
|
||||
parts: list[str] = []
|
||||
if current_server:
|
||||
parts.append(f"CDN: {_ellipsize(current_server, 28)}")
|
||||
if target_workers:
|
||||
parts.append(f"Workers: {active_workers}/{target_workers}")
|
||||
retry_text = f"Retries: {retry_count}"
|
||||
if retry_reason:
|
||||
retry_text += f" ({_ellipsize(retry_reason, 26, keep_tail=True)})"
|
||||
parts.append(retry_text)
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _ellipsize(value: str, limit: int, *, keep_tail: bool = False) -> str:
|
||||
normalized = " ".join(value.split())
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
if keep_tail:
|
||||
return f"…{normalized[-(limit - 1):]}"
|
||||
return f"{normalized[:limit - 1]}…"
|
||||
|
||||
|
||||
def operation_activity_text(
|
||||
message: str,
|
||||
*,
|
||||
started_at: float | None,
|
||||
step: int = 0,
|
||||
total: int = 0,
|
||||
detail: str | None = None,
|
||||
timestamp: float | None = None,
|
||||
) -> str:
|
||||
"""Render live operation progress without dropping its elapsed timer."""
|
||||
|
||||
if detail is not None:
|
||||
base = detail
|
||||
elif total > 0:
|
||||
base = f"Step {step}/{total} · {message}"
|
||||
else:
|
||||
return message
|
||||
if started_at is None:
|
||||
return base
|
||||
now = time.monotonic() if timestamp is None else timestamp
|
||||
elapsed = int(max(0.0, now - started_at))
|
||||
return f"{base} · {elapsed}s elapsed"
|
||||
|
||||
|
||||
def should_refresh_operation_elapsed(
|
||||
*,
|
||||
active: bool,
|
||||
started_at: float | None,
|
||||
stage_total: int,
|
||||
detail: str | None,
|
||||
) -> bool:
|
||||
"""Return whether the dashboard has operation text with a live timer."""
|
||||
|
||||
return (
|
||||
active
|
||||
and started_at is not None
|
||||
and (stage_total > 0 or detail is not None)
|
||||
)
|
||||
|
||||
|
||||
class ProgressCoalescer:
|
||||
"""Keep only the latest cross-thread progress event for the Tk loop."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._latest: dict[str, object] | None = None
|
||||
|
||||
def publish(self, event: Mapping[str, object]) -> None:
|
||||
with self._lock:
|
||||
self._latest = dict(event)
|
||||
|
||||
def take(self) -> dict[str, object] | None:
|
||||
with self._lock:
|
||||
latest = self._latest
|
||||
self._latest = None
|
||||
return latest
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._latest = None
|
||||
|
||||
|
||||
class TransferRateEstimator:
|
||||
"""Estimate a stable byte rate from cumulative network-transfer samples."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window_seconds: float = TRANSFER_RATE_WINDOW_SECONDS,
|
||||
minimum_interval_seconds: float = TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
if window_seconds <= 0:
|
||||
raise ValueError("transfer-rate window must be positive")
|
||||
if minimum_interval_seconds < 0:
|
||||
raise ValueError("transfer-rate minimum interval must not be negative")
|
||||
self._window_seconds = window_seconds
|
||||
self._minimum_interval_seconds = minimum_interval_seconds
|
||||
self._samples: deque[tuple[float, int]] = deque()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._samples.clear()
|
||||
|
||||
def update(
|
||||
self,
|
||||
transferred_bytes: int,
|
||||
*,
|
||||
timestamp: float | None = None,
|
||||
) -> float | None:
|
||||
"""Record one cumulative sample and return bytes per second when stable."""
|
||||
|
||||
if transferred_bytes < 0:
|
||||
raise ValueError("transferred bytes must not be negative")
|
||||
now = time.monotonic() if timestamp is None else timestamp
|
||||
if self._samples:
|
||||
last_time, last_bytes = self._samples[-1]
|
||||
if now <= last_time or transferred_bytes < last_bytes:
|
||||
self.reset()
|
||||
self._samples.append((now, transferred_bytes))
|
||||
cutoff = now - self._window_seconds
|
||||
while len(self._samples) > 2 and self._samples[1][0] <= cutoff:
|
||||
self._samples.popleft()
|
||||
if len(self._samples) < 2:
|
||||
return None
|
||||
first_time, first_bytes = self._samples[0]
|
||||
elapsed = now - first_time
|
||||
if elapsed < self._minimum_interval_seconds:
|
||||
return None
|
||||
return max(0.0, (transferred_bytes - first_bytes) / elapsed)
|
||||
|
||||
def is_idle(
|
||||
self,
|
||||
threshold_seconds: float = TRANSFER_RATE_IDLE_SECONDS,
|
||||
*,
|
||||
timestamp: float | None = None,
|
||||
) -> bool:
|
||||
"""Return whether no cumulative sample arrived within the threshold."""
|
||||
|
||||
if threshold_seconds < 0:
|
||||
raise ValueError("idle threshold must not be negative")
|
||||
if not self._samples:
|
||||
return False
|
||||
now = time.monotonic() if timestamp is None else timestamp
|
||||
return now - self._samples[-1][0] >= threshold_seconds
|
||||
|
||||
|
||||
def as_non_negative_int(value: object) -> int:
|
||||
return value if isinstance(value, int) and value >= 0 else 0
|
||||
|
||||
|
||||
def as_non_negative_float(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
number = float(value)
|
||||
return number if math.isfinite(number) and number >= 0 else None
|
||||
|
||||
|
||||
def format_bytes(value: int) -> str:
|
||||
units = ("B", "KiB", "MiB", "GiB")
|
||||
amount = float(value)
|
||||
for unit in units:
|
||||
if amount < 1024 or unit == units[-1]:
|
||||
return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} {unit}"
|
||||
amount /= 1024
|
||||
return f"{value} B"
|
||||
|
||||
|
||||
def format_transfer_rate(bytes_per_second: float) -> str:
|
||||
return f"{format_bytes(max(0, round(bytes_per_second)))}/s"
|
||||
|
||||
|
||||
def format_eta(seconds: float | None) -> str:
|
||||
if seconds is None or not math.isfinite(seconds) or seconds < 0:
|
||||
return "calculating…"
|
||||
total_seconds = max(0, math.ceil(seconds))
|
||||
hours, remainder = divmod(total_seconds, 60 * 60)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if hours:
|
||||
return f"{hours}h {minutes:02d}m"
|
||||
if minutes:
|
||||
return f"{minutes}m {seconds:02d}s"
|
||||
return f"{seconds}s"
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Official server-health presentation and request state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from ...server_status import RealmServerStatus, ServerStatusError, fetch_server_status
|
||||
|
||||
|
||||
SERVER_STATUS_STALE_SECONDS = 5 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerStatusPresentation:
|
||||
"""Text and Tk style for one official realm-health result."""
|
||||
|
||||
text: str
|
||||
style: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerStatusCheck:
|
||||
"""One measured attempt to retrieve official realm health."""
|
||||
|
||||
statuses: tuple[RealmServerStatus, ...] | None
|
||||
checked_at: float
|
||||
duration_seconds: float
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def perform_server_status_check(
|
||||
*,
|
||||
fetcher: Callable[[], tuple[RealmServerStatus, ...]] = fetch_server_status,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
wall_clock: Callable[[], float] = time.time,
|
||||
) -> ServerStatusCheck:
|
||||
"""Measure one official status request without leaking its endpoint."""
|
||||
|
||||
started_at = monotonic()
|
||||
try:
|
||||
statuses = fetcher()
|
||||
except ServerStatusError as error:
|
||||
duration_seconds = max(0.0, monotonic() - started_at)
|
||||
return ServerStatusCheck(
|
||||
statuses=None,
|
||||
checked_at=wall_clock(),
|
||||
duration_seconds=duration_seconds,
|
||||
error=str(error),
|
||||
)
|
||||
duration_seconds = max(0.0, monotonic() - started_at)
|
||||
return ServerStatusCheck(
|
||||
statuses=statuses,
|
||||
checked_at=wall_clock(),
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
|
||||
|
||||
def server_status_check_text(
|
||||
check: ServerStatusCheck,
|
||||
*,
|
||||
timestamp: float | None = None,
|
||||
) -> str:
|
||||
"""Render fetch age, latency, and freshness for the hidden debug view."""
|
||||
|
||||
now = time.time() if timestamp is None else timestamp
|
||||
age_seconds = max(0.0, now - check.checked_at)
|
||||
whole_seconds = int(age_seconds)
|
||||
if whole_seconds < 60:
|
||||
age = f"{whole_seconds}s"
|
||||
elif whole_seconds < 60 * 60:
|
||||
age = f"{whole_seconds // 60}m"
|
||||
elif whole_seconds < 24 * 60 * 60:
|
||||
age = f"{whole_seconds // (60 * 60)}h"
|
||||
else:
|
||||
age = f"{whole_seconds // (24 * 60 * 60)}d"
|
||||
latency_milliseconds = max(0, round(check.duration_seconds * 1000))
|
||||
timing = f"checked {age} ago · {latency_milliseconds} ms"
|
||||
if check.error is not None:
|
||||
return f"failed · {timing} · {check.error}"
|
||||
freshness = "stale" if age_seconds >= SERVER_STATUS_STALE_SECONDS else "fresh"
|
||||
return f"{freshness} · {timing}"
|
||||
|
||||
|
||||
def _server_component_text(name: str, online: bool | None) -> str:
|
||||
if online is True:
|
||||
state = "up"
|
||||
elif online is False:
|
||||
state = "down"
|
||||
else:
|
||||
state = "unknown"
|
||||
return f"{name} {state}"
|
||||
|
||||
|
||||
def server_status_presentation(
|
||||
status: RealmServerStatus,
|
||||
*,
|
||||
show_debug_details: bool = False,
|
||||
) -> ServerStatusPresentation:
|
||||
if status.online is True:
|
||||
text = f"● {status.name}"
|
||||
style = "ServerOnline.TLabel"
|
||||
elif status.online is False:
|
||||
text = f"● {status.name} · offline"
|
||||
style = "ServerOffline.TLabel"
|
||||
else:
|
||||
text = f"○ {status.name} · status unknown"
|
||||
style = "ServerUnknown.TLabel"
|
||||
if show_debug_details:
|
||||
details: list[str] = []
|
||||
if status.players_online is not None:
|
||||
label = "player" if status.players_online == 1 else "players"
|
||||
details.append(f"{status.players_online} {label}")
|
||||
details.extend(
|
||||
(
|
||||
_server_component_text("login", status.login_online),
|
||||
_server_component_text("game", status.game_online),
|
||||
)
|
||||
)
|
||||
text = " · ".join((text, *details))
|
||||
return ServerStatusPresentation(text=text, style=style)
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Presentation state for managed-game installation operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameSyncPresentation:
|
||||
"""User-facing wording for one managed-game synchronization."""
|
||||
|
||||
operation_label: str
|
||||
file_progress_verb: str
|
||||
|
||||
|
||||
def game_sync_presentation(
|
||||
*,
|
||||
installed: bool,
|
||||
repair: bool = False,
|
||||
) -> GameSyncPresentation:
|
||||
"""Distinguish a fresh download from an update or repair."""
|
||||
|
||||
if not installed:
|
||||
return GameSyncPresentation(
|
||||
operation_label=(
|
||||
"Installing and verifying official game files…"
|
||||
if repair
|
||||
else "Installing official game files…"
|
||||
),
|
||||
file_progress_verb="Downloading",
|
||||
)
|
||||
if repair:
|
||||
return GameSyncPresentation(
|
||||
operation_label="Checking every game file against the official depot…",
|
||||
file_progress_verb="Repairing",
|
||||
)
|
||||
return GameSyncPresentation(
|
||||
operation_label="Synchronizing official game files…",
|
||||
file_progress_verb="Updating",
|
||||
)
|
||||
|
||||
|
||||
def game_file_progress_status(current_file: str, verb: str) -> str:
|
||||
"""Render the current depot asset with the operation's exact intent."""
|
||||
|
||||
return f"{verb} {current_file}"
|
||||
423
ptlinux/umu.py
423
ptlinux/umu.py
@@ -1,423 +0,0 @@
|
||||
"""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
|
||||
@@ -1,375 +0,0 @@
|
||||
"""Semantic release versions and channel-aware update selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import re
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
|
||||
STABLE_CHANNEL = "stable"
|
||||
PRERELEASE_CHANNEL = "prerelease"
|
||||
SUPPORTED_CHANNELS = frozenset({STABLE_CHANNEL, PRERELEASE_CHANNEL})
|
||||
ACTIVE_STATUS = "active"
|
||||
WITHDRAWN_STATUS = "withdrawn"
|
||||
BLOCKED_STATUS = "blocked"
|
||||
SUPPORTED_RELEASE_STATUSES = frozenset(
|
||||
{ACTIVE_STATUS, WITHDRAWN_STATUS, BLOCKED_STATUS}
|
||||
)
|
||||
|
||||
_VERSION_PATTERN = re.compile(
|
||||
r"(?P<major>0|[1-9][0-9]*)\."
|
||||
r"(?P<minor>0|[1-9][0-9]*)\."
|
||||
r"(?P<patch>0|[1-9][0-9]*)"
|
||||
r"(?:-(?P<label>beta|rc)\.(?P<number>0|[1-9][0-9]*))?\Z"
|
||||
)
|
||||
|
||||
|
||||
class ReleaseModelError(ValueError):
|
||||
"""Raised when release metadata violates the supported release model."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticVersion:
|
||||
"""The stable, beta, and release-candidate version subset used by releases."""
|
||||
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
prerelease_label: str | None = None
|
||||
prerelease_number: int | None = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, value: str) -> SemanticVersion:
|
||||
if not isinstance(value, str):
|
||||
raise ReleaseModelError("version must be a string")
|
||||
match = _VERSION_PATTERN.fullmatch(value)
|
||||
if match is None:
|
||||
raise ReleaseModelError(
|
||||
"version must use MAJOR.MINOR.PATCH, "
|
||||
"MAJOR.MINOR.PATCH-beta.N, or MAJOR.MINOR.PATCH-rc.N"
|
||||
)
|
||||
label = match.group("label")
|
||||
number_text = match.group("number")
|
||||
return cls(
|
||||
major=int(match.group("major")),
|
||||
minor=int(match.group("minor")),
|
||||
patch=int(match.group("patch")),
|
||||
prerelease_label=label,
|
||||
prerelease_number=int(number_text) if number_text is not None else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_tag(cls, tag: str) -> SemanticVersion:
|
||||
if not isinstance(tag, str) or not tag.startswith("v"):
|
||||
raise ReleaseModelError("release tag must start with 'v'")
|
||||
return cls.parse(tag[1:])
|
||||
|
||||
@property
|
||||
def is_prerelease(self) -> bool:
|
||||
return self.prerelease_label is not None
|
||||
|
||||
@property
|
||||
def channel(self) -> str:
|
||||
return PRERELEASE_CHANNEL if self.is_prerelease else STABLE_CHANNEL
|
||||
|
||||
@property
|
||||
def tag(self) -> str:
|
||||
return f"v{self}"
|
||||
|
||||
@property
|
||||
def target(self) -> tuple[int, int, int]:
|
||||
return self.major, self.minor, self.patch
|
||||
|
||||
def _ordering_key(self) -> tuple[int, int, int, int, int, int]:
|
||||
if self.prerelease_label is None:
|
||||
return self.major, self.minor, self.patch, 1, 0, 0
|
||||
label_rank = 0 if self.prerelease_label == "beta" else 1
|
||||
assert self.prerelease_number is not None
|
||||
return (
|
||||
self.major,
|
||||
self.minor,
|
||||
self.patch,
|
||||
0,
|
||||
label_rank,
|
||||
self.prerelease_number,
|
||||
)
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
if not isinstance(other, SemanticVersion):
|
||||
return NotImplemented
|
||||
return self._ordering_key() < other._ordering_key()
|
||||
|
||||
def __le__(self, other: object) -> bool:
|
||||
if not isinstance(other, SemanticVersion):
|
||||
return NotImplemented
|
||||
return self._ordering_key() <= other._ordering_key()
|
||||
|
||||
def __gt__(self, other: object) -> bool:
|
||||
if not isinstance(other, SemanticVersion):
|
||||
return NotImplemented
|
||||
return self._ordering_key() > other._ordering_key()
|
||||
|
||||
def __ge__(self, other: object) -> bool:
|
||||
if not isinstance(other, SemanticVersion):
|
||||
return NotImplemented
|
||||
return self._ordering_key() >= other._ordering_key()
|
||||
|
||||
def __str__(self) -> str:
|
||||
value = f"{self.major}.{self.minor}.{self.patch}"
|
||||
if self.prerelease_label is not None:
|
||||
value += f"-{self.prerelease_label}.{self.prerelease_number}"
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseRecord:
|
||||
"""The resolver-facing fields shared by channel indexes and release history."""
|
||||
|
||||
version: str
|
||||
channel: str
|
||||
status: str = ACTIVE_STATUS
|
||||
tag: str | None = None
|
||||
manifest_url: str | None = None
|
||||
manifest_signature_url: str | None = None
|
||||
release_date: str | None = None
|
||||
release_notes_url: str | None = None
|
||||
compatibility: object | None = None
|
||||
|
||||
@property
|
||||
def semantic_version(self) -> SemanticVersion:
|
||||
return SemanticVersion.parse(self.version)
|
||||
|
||||
def validate(self) -> ReleaseRecord:
|
||||
parsed = self.semantic_version
|
||||
validate_channel(self.channel)
|
||||
if parsed.channel != self.channel:
|
||||
raise ReleaseModelError(
|
||||
f"version {self.version} belongs to {parsed.channel}, not {self.channel}"
|
||||
)
|
||||
if self.status not in SUPPORTED_RELEASE_STATUSES:
|
||||
raise ReleaseModelError(f"unsupported release status: {self.status}")
|
||||
if self.tag is not None and self.tag != parsed.tag:
|
||||
raise ReleaseModelError(
|
||||
f"release tag {self.tag!r} does not match version {self.version}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class UpdateResolver:
|
||||
"""Centralize release eligibility for updates, switching, and rollback."""
|
||||
|
||||
def __init__(self, releases: Iterable[ReleaseRecord]) -> None:
|
||||
validated: dict[SemanticVersion, ReleaseRecord] = {}
|
||||
for release in releases:
|
||||
release.validate()
|
||||
parsed = release.semantic_version
|
||||
if parsed in validated:
|
||||
raise ReleaseModelError(f"duplicate release version: {release.version}")
|
||||
validated[parsed] = release
|
||||
self._releases = validated
|
||||
|
||||
def eligible_releases(
|
||||
self,
|
||||
selected_channel: str,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> tuple[ReleaseRecord, ...]:
|
||||
validate_channel(selected_channel)
|
||||
eligible = []
|
||||
for parsed, release in self._releases.items():
|
||||
if not include_inactive and release.status != ACTIVE_STATUS:
|
||||
continue
|
||||
if selected_channel == STABLE_CHANNEL and parsed.is_prerelease:
|
||||
continue
|
||||
eligible.append(release)
|
||||
eligible.sort(key=lambda item: item.semantic_version, reverse=True)
|
||||
return tuple(eligible)
|
||||
|
||||
def latest_eligible(self, selected_channel: str) -> ReleaseRecord | None:
|
||||
eligible = self.eligible_releases(selected_channel)
|
||||
return eligible[0] if eligible else None
|
||||
|
||||
def update_candidate(
|
||||
self,
|
||||
installed_version: str,
|
||||
selected_channel: str,
|
||||
) -> ReleaseRecord | None:
|
||||
installed = SemanticVersion.parse(installed_version)
|
||||
latest = self.latest_eligible(selected_channel)
|
||||
if latest is None or latest.semantic_version <= installed:
|
||||
return None
|
||||
return latest
|
||||
|
||||
def rollback_candidates(
|
||||
self,
|
||||
installed_version: str,
|
||||
selected_channel: str,
|
||||
*,
|
||||
show_prereleases: bool | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> tuple[ReleaseRecord, ...]:
|
||||
installed = SemanticVersion.parse(installed_version)
|
||||
validate_channel(selected_channel)
|
||||
include_prereleases = (
|
||||
selected_channel == PRERELEASE_CHANNEL
|
||||
if show_prereleases is None
|
||||
else show_prereleases
|
||||
)
|
||||
candidates = []
|
||||
for release in self._releases.values():
|
||||
parsed = release.semantic_version
|
||||
if not include_inactive and release.status != ACTIVE_STATUS:
|
||||
continue
|
||||
if parsed >= installed:
|
||||
continue
|
||||
if parsed.is_prerelease and not include_prereleases:
|
||||
continue
|
||||
candidates.append(release)
|
||||
candidates.sort(key=lambda item: item.semantic_version, reverse=True)
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def validate_channel(channel: str) -> str:
|
||||
if channel not in SUPPORTED_CHANNELS:
|
||||
raise ReleaseModelError(
|
||||
f"channel must be one of: {', '.join(sorted(SUPPORTED_CHANNELS))}"
|
||||
)
|
||||
return channel
|
||||
|
||||
|
||||
def validate_channel_version(channel: str, version: str) -> SemanticVersion:
|
||||
validate_channel(channel)
|
||||
parsed = SemanticVersion.parse(version)
|
||||
if parsed.channel != channel:
|
||||
raise ReleaseModelError(
|
||||
f"{channel} channel cannot publish version {version}; "
|
||||
f"the version belongs to {parsed.channel}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def validate_release_progression(
|
||||
channel: str,
|
||||
version: str,
|
||||
tags: Iterable[str],
|
||||
) -> SemanticVersion:
|
||||
"""Reject a release that would move its channel index backwards."""
|
||||
|
||||
target = validate_channel_version(channel, version)
|
||||
relevant: list[SemanticVersion] = []
|
||||
for tag in tags:
|
||||
try:
|
||||
candidate = SemanticVersion.from_tag(tag)
|
||||
except ReleaseModelError:
|
||||
continue
|
||||
if channel == STABLE_CHANNEL and candidate.is_prerelease:
|
||||
continue
|
||||
relevant.append(candidate)
|
||||
if relevant:
|
||||
latest = max(relevant)
|
||||
if target <= latest:
|
||||
raise ReleaseModelError(
|
||||
f"{channel} release {version} must be newer than existing "
|
||||
f"release {latest}"
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def previous_release_tag(
|
||||
channel: str,
|
||||
version: str,
|
||||
tags: Iterable[str],
|
||||
) -> str | None:
|
||||
"""Select the changelog base required by the two release channels."""
|
||||
|
||||
target = validate_channel_version(channel, version)
|
||||
parsed_tags: list[SemanticVersion] = []
|
||||
for tag in tags:
|
||||
try:
|
||||
candidate = SemanticVersion.from_tag(tag)
|
||||
except ReleaseModelError:
|
||||
continue
|
||||
if candidate < target:
|
||||
parsed_tags.append(candidate)
|
||||
if channel == STABLE_CHANNEL:
|
||||
stable = [candidate for candidate in parsed_tags if not candidate.is_prerelease]
|
||||
return max(stable).tag if stable else None
|
||||
matching_prereleases = [
|
||||
candidate
|
||||
for candidate in parsed_tags
|
||||
if candidate.is_prerelease and candidate.target == target.target
|
||||
]
|
||||
if matching_prereleases:
|
||||
return max(matching_prereleases).tag
|
||||
stable = [candidate for candidate in parsed_tags if not candidate.is_prerelease]
|
||||
return max(stable).tag if stable else None
|
||||
|
||||
|
||||
def release_metadata_from_tag(tag: str) -> dict[str, object]:
|
||||
version = SemanticVersion.from_tag(tag)
|
||||
return {
|
||||
"tag": tag,
|
||||
"version": str(version),
|
||||
"channel": version.channel,
|
||||
"prerelease": version.is_prerelease,
|
||||
}
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
validate = subparsers.add_parser("validate", help="validate a channel/version pair")
|
||||
validate.add_argument("channel", choices=sorted(SUPPORTED_CHANNELS))
|
||||
validate.add_argument("version")
|
||||
inspect_tag = subparsers.add_parser("inspect-tag", help="derive release metadata")
|
||||
inspect_tag.add_argument("tag")
|
||||
previous = subparsers.add_parser("previous-tag", help="select a changelog base")
|
||||
previous.add_argument("channel", choices=sorted(SUPPORTED_CHANNELS))
|
||||
previous.add_argument("version")
|
||||
previous.add_argument("tags", nargs="*")
|
||||
progression = subparsers.add_parser(
|
||||
"validate-next",
|
||||
help="validate that a release advances its channel",
|
||||
)
|
||||
progression.add_argument("channel", choices=sorted(SUPPORTED_CHANNELS))
|
||||
progression.add_argument("version")
|
||||
progression.add_argument("tags", nargs="*")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
arguments = _build_parser().parse_args(argv)
|
||||
try:
|
||||
if arguments.command == "validate":
|
||||
validate_channel_version(arguments.channel, arguments.version)
|
||||
return 0
|
||||
if arguments.command == "inspect-tag":
|
||||
print(json.dumps(release_metadata_from_tag(arguments.tag), sort_keys=True))
|
||||
return 0
|
||||
if arguments.command == "previous-tag":
|
||||
selected = previous_release_tag(
|
||||
arguments.channel,
|
||||
arguments.version,
|
||||
arguments.tags,
|
||||
)
|
||||
if selected is not None:
|
||||
print(selected)
|
||||
return 0
|
||||
if arguments.command == "validate-next":
|
||||
validate_release_progression(
|
||||
arguments.channel,
|
||||
arguments.version,
|
||||
arguments.tags,
|
||||
)
|
||||
return 0
|
||||
except ReleaseModelError as exc:
|
||||
parser_error = _build_parser()
|
||||
parser_error.error(str(exc))
|
||||
raise AssertionError(f"unhandled command: {arguments.command}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Bounded structured diagnostics for the two game-window supervisors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
WINDOW_DIAGNOSTICS_ENV = "PTLINUX_WINDOW_DIAGNOSTICS"
|
||||
WINDOW_DIAGNOSTICS_SESSION_ENV = "PTLINUX_WINDOW_DIAGNOSTICS_SESSION"
|
||||
WINDOW_DIAGNOSTIC_PREFIX = "PTLINUX_WINDOW_DIAGNOSTIC "
|
||||
WINDOW_DIAGNOSTIC_EVENT_LIMIT = 12
|
||||
WINDOW_DIAGNOSTIC_LINE_LIMIT = 4096
|
||||
WINDOW_DIAGNOSTIC_COMPONENTS = frozenset({"game_window", "gamescope_host"})
|
||||
_DIAGNOSTIC_NAME_PATTERN = re.compile(r"[a-z][a-z0-9_]{0,63}\Z")
|
||||
_SESSION_ID_PATTERN = re.compile(r"[0-9a-f]{32}\Z")
|
||||
_TRUE_VALUES = frozenset({"1", "true", "yes"})
|
||||
_MAX_DETAILS_DEPTH = 4
|
||||
_MAX_DETAILS_ITEMS = 48
|
||||
_MAX_STRING_CHARACTERS = 256
|
||||
|
||||
|
||||
def window_diagnostics_enabled(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> bool:
|
||||
env = os.environ if environment is None else environment
|
||||
return env.get(WINDOW_DIAGNOSTICS_ENV, "").strip().casefold() in _TRUE_VALUES
|
||||
|
||||
|
||||
class WindowDiagnosticRecorder:
|
||||
"""Emit a few samples while retaining aggregate counts for session shutdown."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
component: str,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
monotonic: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
if component not in WINDOW_DIAGNOSTIC_COMPONENTS:
|
||||
raise ValueError(f"unsupported window diagnostic component: {component}")
|
||||
env = os.environ if environment is None else environment
|
||||
session_id = env.get(WINDOW_DIAGNOSTICS_SESSION_ENV, "").strip().casefold()
|
||||
self.component = component
|
||||
self.enabled = window_diagnostics_enabled(env)
|
||||
self.session_id = (
|
||||
session_id if _SESSION_ID_PATTERN.fullmatch(session_id) is not None else None
|
||||
)
|
||||
self._monotonic = monotonic or time.monotonic
|
||||
self._started_at = self._monotonic()
|
||||
self._counts: Counter[str] = Counter()
|
||||
self._transitions: dict[str, object] = {}
|
||||
self._sequence = 0
|
||||
self._emitted = 0
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def accepts_samples(self) -> bool:
|
||||
return (
|
||||
self.enabled
|
||||
and not self._closed
|
||||
and self._emitted < WINDOW_DIAGNOSTIC_EVENT_LIMIT
|
||||
)
|
||||
|
||||
def record(self, diagnostic: str, **details: object) -> None:
|
||||
if self._closed or _DIAGNOSTIC_NAME_PATTERN.fullmatch(diagnostic) is None:
|
||||
return
|
||||
self._sequence += 1
|
||||
self._counts[diagnostic] += 1
|
||||
if not self.enabled or self._emitted >= WINDOW_DIAGNOSTIC_EVENT_LIMIT:
|
||||
return
|
||||
self._emitted += 1
|
||||
self._emit(diagnostic, self._sequence, details)
|
||||
|
||||
def transition(
|
||||
self,
|
||||
key: str,
|
||||
value: object,
|
||||
diagnostic: str,
|
||||
**details: object,
|
||||
) -> None:
|
||||
"""Record one diagnostic only when the observed state changes."""
|
||||
|
||||
if self._closed or self._transitions.get(key, object()) == value:
|
||||
return
|
||||
self._transitions[key] = value
|
||||
self.record(diagnostic, **details)
|
||||
|
||||
def close(self, **details: object) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if not self.enabled:
|
||||
return
|
||||
summary = {
|
||||
**details,
|
||||
"counts": dict(sorted(self._counts.items())),
|
||||
"sampled_events": self._emitted,
|
||||
"suppressed_events": max(0, self._sequence - self._emitted),
|
||||
}
|
||||
self._emit("session_summary", self._sequence + 1, summary)
|
||||
|
||||
def _emit(
|
||||
self,
|
||||
diagnostic: str,
|
||||
sequence: int,
|
||||
details: Mapping[str, object],
|
||||
) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"schema": 1,
|
||||
"component": self.component,
|
||||
"diagnostic": diagnostic,
|
||||
"sequence": sequence,
|
||||
"elapsed_ms": max(0, round((self._monotonic() - self._started_at) * 1000)),
|
||||
"details": _bounded_value(details, depth=0),
|
||||
}
|
||||
if self.session_id is not None:
|
||||
payload["session_id"] = self.session_id
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
)
|
||||
if len(encoded.encode("utf-8")) > WINDOW_DIAGNOSTIC_LINE_LIMIT:
|
||||
payload["details"] = {"payload_truncated": True}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
)
|
||||
print(
|
||||
f"{WINDOW_DIAGNOSTIC_PREFIX}{encoded}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return
|
||||
|
||||
|
||||
def parse_window_diagnostic(line: str) -> dict[str, object] | None:
|
||||
"""Parse only launcher-generated, bounded window diagnostic records."""
|
||||
|
||||
marker_index = line.find(WINDOW_DIAGNOSTIC_PREFIX)
|
||||
if marker_index < 0:
|
||||
return None
|
||||
encoded = line[marker_index + len(WINDOW_DIAGNOSTIC_PREFIX):].strip()
|
||||
if not encoded or len(encoded.encode("utf-8")) > WINDOW_DIAGNOSTIC_LINE_LIMIT:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(encoded)
|
||||
except (json.JSONDecodeError, UnicodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or payload.get("schema") != 1:
|
||||
return None
|
||||
component = payload.get("component")
|
||||
diagnostic = payload.get("diagnostic")
|
||||
sequence = payload.get("sequence")
|
||||
elapsed_ms = payload.get("elapsed_ms")
|
||||
details = payload.get("details")
|
||||
session_id = payload.get("session_id")
|
||||
if (
|
||||
not isinstance(component, str)
|
||||
or component not in WINDOW_DIAGNOSTIC_COMPONENTS
|
||||
or not isinstance(diagnostic, str)
|
||||
or _DIAGNOSTIC_NAME_PATTERN.fullmatch(diagnostic) is None
|
||||
or not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool)
|
||||
or sequence < 1
|
||||
or not isinstance(elapsed_ms, int)
|
||||
or isinstance(elapsed_ms, bool)
|
||||
or elapsed_ms < 0
|
||||
or not isinstance(details, dict)
|
||||
or (
|
||||
session_id is not None
|
||||
and (
|
||||
not isinstance(session_id, str)
|
||||
or _SESSION_ID_PATTERN.fullmatch(session_id) is None
|
||||
)
|
||||
)
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"schema": 1,
|
||||
"component": component,
|
||||
"diagnostic": diagnostic,
|
||||
"sequence": sequence,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"details": _bounded_value(details, depth=0),
|
||||
**({"session_id": session_id} if isinstance(session_id, str) else {}),
|
||||
}
|
||||
|
||||
|
||||
def _bounded_value(value: object, *, depth: int) -> object:
|
||||
if depth >= _MAX_DETAILS_DEPTH:
|
||||
return "<max-depth>"
|
||||
if value is None or isinstance(value, (bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return round(value, 3) if math.isfinite(value) else None
|
||||
if isinstance(value, str):
|
||||
return value[:_MAX_STRING_CHARACTERS]
|
||||
if isinstance(value, Mapping):
|
||||
result: dict[str, object] = {}
|
||||
for index, (raw_key, item) in enumerate(value.items()):
|
||||
if index >= _MAX_DETAILS_ITEMS:
|
||||
result["_truncated"] = True
|
||||
break
|
||||
key = str(raw_key)[:64]
|
||||
result[key] = _bounded_value(item, depth=depth + 1)
|
||||
return result
|
||||
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
|
||||
items = list(value[:_MAX_DETAILS_ITEMS])
|
||||
return [_bounded_value(item, depth=depth + 1) for item in items]
|
||||
return f"<{type(value).__name__}>"
|
||||
Reference in New Issue
Block a user