Added more folders and restructured classes inside them.

This commit is contained in:
2026-09-12 11:10:47 +02:00
parent be7a4782e8
commit 2cbb9315bf
152 changed files with 21734 additions and 914 deletions

View File

@@ -1,7 +0,0 @@
"""Portable PristonTale EU installer and Linux launcher."""
from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
__version__ = "0.9.11"

View File

@@ -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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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())

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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)},
)

View File

@@ -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

View File

@@ -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-----

View File

@@ -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)

View File

@@ -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())

View File

@@ -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()

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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))

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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",
]

View File

@@ -1 +0,0 @@
"""Reusable Tk dashboard components."""

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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),
)

View File

@@ -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",
)

View File

@@ -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)

View File

@@ -1 +0,0 @@
"""Modal and auxiliary windows owned by the native dashboard."""

View File

@@ -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)

View File

@@ -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

View File

@@ -1 +0,0 @@
"""Pure state and presentation models used by the native dashboard."""

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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}"

View File

@@ -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

View File

@@ -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())

View File

@@ -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__}>"