From be7a4782e855b64e3d635c67f39fc4a356128eda Mon Sep 17 00:00:00 2001 From: m4ko Date: Sat, 12 Sep 2026 11:10:17 +0200 Subject: [PATCH] Reworked the project structure. --- .gitignore | 44 +- install.sh | 117 +- ptdiag/experiment.py | 1822 ----------------- ptlinux/cli.py | 2388 ----------------------- ptlinux/depot.py | 2791 -------------------------- ptlinux/self_update.py | 3066 ----------------------------- ptlinux/telemetry_environment.py | 1395 ------------- ptlinux/ui/dashboard.py | 3115 ------------------------------ 8 files changed, 81 insertions(+), 14657 deletions(-) mode change 100644 => 100755 install.sh delete mode 100644 ptdiag/experiment.py delete mode 100644 ptlinux/cli.py delete mode 100644 ptlinux/depot.py delete mode 100644 ptlinux/self_update.py delete mode 100644 ptlinux/telemetry_environment.py delete mode 100644 ptlinux/ui/dashboard.py diff --git a/.gitignore b/.gitignore index b9080c9..1f9588d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,25 +3,35 @@ __pycache__/ *.py[cod] .pytest_cache/ .idea/ -.run/ + +# Legacy safety net only. Release tooling requires private keys outside the +# checkout and generated artifact tree. release-keys/ config/local.toml +diagnostics/config/local.toml -# Local development, investigation, and release tooling. The production -# repository intentionally contains only the installer/runtime sources. -AGENTS.md -AIO_INSTALLER.md -BOOTSTRAP_WORKPLAN.md -DEBUGGING.md -GITEA_UPDATES.md -README.md -config/ -configs/ -findings/ +# Generated builds, candidates, runtime evidence, and machine-local state. +.artifacts/ + +# Legacy output locations remain ignored while existing local data is migrated +# manually. New commands write only below .artifacts/. +candidates/ +dist/ replay_pid*.log results/ -testcases/ -tests/ -tools/ -dist/ -/native/ +runner-data/ +runner-token +infra/gitea-runner/.env +infra/gitea-runner/runner-data/ +infra/gitea-runner/runner-token +server/telemetry/.env +/diagnostics/ +/devtools/ +/docs/ +/infra/ +/artifacts/ +/release_tests/ +/tests/ +/tools/ +/.gitea/ +/.run/ diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 9fcc008..abbf619 --- a/install.sh +++ b/install.sh @@ -3,8 +3,23 @@ set -euo pipefail SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if [[ -d "${SOURCE_ROOT}/src/ptlinux" ]] +then + PACKAGE_SOURCE_ROOT="${SOURCE_ROOT}/src" +else + # Bootstrap archives retain the historical top-level package layout. + PACKAGE_SOURCE_ROOT="$SOURCE_ROOT" +fi +if [[ -d "${SOURCE_ROOT}/native/gamescope" ]] +then + GAMESCOPE_PROVENANCE_ROOT="${SOURCE_ROOT}/native/gamescope" + GAMESCOPE_PATCH_ROOT="${GAMESCOPE_PROVENANCE_ROOT}/patches" +else + GAMESCOPE_PROVENANCE_ROOT="${PACKAGE_SOURCE_ROOT}/ptlinux/native" + GAMESCOPE_PATCH_ROOT="$GAMESCOPE_PROVENANCE_ROOT" +fi SEMVER_PATTERN='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(beta|rc)\.(0|[1-9][0-9]*))?' -SOURCE_VERSION="$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "${SOURCE_ROOT}/ptlinux/__init__.py")" +SOURCE_VERSION="$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "${PACKAGE_SOURCE_ROOT}/ptlinux/__init__.py")" VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}" if [[ ! "$VERSION" =~ ^${SEMVER_PATTERN}$ ]] then @@ -153,25 +168,30 @@ APP_ROOT="${INSTALL_DIRECTORY}/app" RELEASES_ROOT="${APP_ROOT}/releases" RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}" -if [[ ! -d "${SOURCE_ROOT}/ptlinux" \ - || ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \ - || ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \ - || ! -f "${SOURCE_ROOT}/ptdiag/logs.py" \ - || ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ - || ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ - || ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \ - || ! -f "${SOURCE_ROOT}/ptlinux/ui/__init__.py" \ - || ! -f "${SOURCE_ROOT}/ptlinux/ui/dashboard.py" \ - || ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.README" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.copyright" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-lazy-output-images.patch" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \ - || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-keep-nested-windows-visible.patch" \ +if [[ ! -d "${PACKAGE_SOURCE_ROOT}/ptlinux" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/assbin.py" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/__init__.py" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/logs.py" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/desktop_entry.py" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/self_update.py" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/ui/__init__.py" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/ui/dashboard.py" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/ptr-bcrypt-proxy.dll" \ + || ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \ + || ! -f "${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.README" \ + || ! -f "${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.copyright" \ + || ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-lazy-output-images.patch" \ + || ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-live-resize.patch" \ + || ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-letterbox-backing.patch" \ + || ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-libdecor-input-scope.patch" \ + || ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-keep-nested-windows-visible.patch" \ + || ! -f "${SOURCE_ROOT}/scripts/__init__.py" \ + || ! -f "${SOURCE_ROOT}/scripts/repository_paths.py" \ + || ! -f "${SOURCE_ROOT}/scripts/release_lib.py" \ + || ! -f "${SOURCE_ROOT}/release/config.json" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ || ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \ @@ -223,7 +243,7 @@ then fi if ! "$PYTHON_BIN" -I -B -c \ 'import sys; sys.path.insert(0, sys.argv[1]); from ptlinux.update_resolver import validate_channel_version; validate_channel_version(sys.argv[2], sys.argv[3])' \ - "$SOURCE_ROOT" "$RELEASE_CHANNEL" "$VERSION" + "$PACKAGE_SOURCE_ROOT" "$RELEASE_CHANNEL" "$VERSION" then printf '%s\n' "Launcher version and release channel validation failed." >&2 exit 1 @@ -236,46 +256,17 @@ then printf 'Launcher staging path already exists: %s\n' "$STAGING_RELEASE" >&2 exit 1 fi -install -d -m 0755 "$STAGING_RELEASE" -install -d -m 0755 \ - "${STAGING_RELEASE}/ptlinux" \ - "${STAGING_RELEASE}/ptlinux/native" \ - "${STAGING_RELEASE}/ptdiag" -shopt -s globstar nullglob -for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"**/*.py -do - RELATIVE_MODULE="${SOURCE_MODULE#"${SOURCE_ROOT}/ptlinux/"}" - case "$RELATIVE_MODULE" in - desktop_entry.py|launcher_wrapper.py) - continue - ;; - esac - install -D -m 0644 \ - "$SOURCE_MODULE" \ - "${STAGING_RELEASE}/ptlinux/${RELATIVE_MODULE}" -done -shopt -u globstar nullglob -install -m 0644 \ - "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ - "${STAGING_RELEASE}/ptlinux/launcher-update-public.pem" -install -m 0644 \ - "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ - "${STAGING_RELEASE}/ptlinux/native/pt-identity-probe.exe" -install -m 0755 \ - "${SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \ - "${STAGING_RELEASE}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" -install -m 0644 \ - "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.README" \ - "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.copyright" \ - "${SOURCE_ROOT}/ptlinux/native/gamescope-lazy-output-images.patch" \ - "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \ - "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \ - "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \ - "${SOURCE_ROOT}/ptlinux/native/gamescope-keep-nested-windows-visible.patch" \ - "${STAGING_RELEASE}/ptlinux/native/" -install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py" -install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py" -install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py" +if ! "$PYTHON_BIN" -I -B -c \ + 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from scripts.release_lib import load_config, stage_runtime_payload; stage_runtime_payload(Path(sys.argv[2]), load_config(Path(sys.argv[3])), source_root=Path(sys.argv[4]))' \ + "$SOURCE_ROOT" "$STAGING_RELEASE" "${SOURCE_ROOT}/release/config.json" "$SOURCE_ROOT" +then + printf '%s\n' "The launcher runtime payload could not be staged." >&2 + if [[ -d "$STAGING_RELEASE" && ! -L "$STAGING_RELEASE" ]] + then + rm -r -- "$STAGING_RELEASE" + fi + exit 1 +fi printf '{\n "channel": "%s",\n "config_schema": 1,\n "minimum_config_schema": 1,\n "schema": 1,\n "tag": "v%s",\n "version": "%s"\n}\n' \ "$RELEASE_CHANNEL" "$VERSION" "$VERSION" > "${STAGING_RELEASE}/release.json" chmod 0644 "${STAGING_RELEASE}/release.json" @@ -357,7 +348,7 @@ CURRENT_LINK="${APP_ROOT}/.current.$$.tmp" ln -s -- "releases/${VERSION}" "$CURRENT_LINK" mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current" -"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ +"$PYTHON_BIN" "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ "${SOURCE_ROOT}/packaging/pristontale-eu" \ "$APP_ROOT" \ "$INSTALL_DIRECTORY" \ @@ -365,7 +356,7 @@ mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current" install -m 0644 \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ "$ICON_PATH" -"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ +"$PYTHON_BIN" "${PACKAGE_SOURCE_ROOT}/ptlinux/desktop_entry.py" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ "${BIN_HOME}/pristontale-eu" \ "$ICON_PATH" \ diff --git a/ptdiag/experiment.py b/ptdiag/experiment.py deleted file mode 100644 index b37f371..0000000 --- a/ptdiag/experiment.py +++ /dev/null @@ -1,1822 +0,0 @@ -"""Controlled Proton 7 runtime experiments and result generation.""" - -from __future__ import annotations - -from dataclasses import dataclass, replace -from datetime import datetime -import json -import os -from pathlib import Path, PurePosixPath -import shutil -import signal -import subprocess -import sys -import time -from typing import Any, Mapping, Sequence - -from .config import AppConfig, project_root -from .filesystem import sha256_file, verify_known_game_hashes -from .logs import ( - LogFileState, - discover_current_native_logs, - parse_current_run_log, - read_current_run_log_text, - snapshot_native_logs, -) -from .ini import IniError, inspect_ini_value -from .performance import PerformanceSampler -from .prefix import ( - PrefixSafetyError, - cleanup_dedicated_wineserver, - validate_dedicated_prefix, -) -from .process import collect_environment -from .registry import ( - DesktopRegistrySnapshot, - desktop_registry_restored, - read_registry_string_values, - snapshot_desktop_registry, -) -from .result import ResultDirectory, create_result_directory, write_json -from .runner import CommandLogger, CommandResult -from .testcase import TestIniOverride, TestModification, Testcase -from .trace import process_trace_shards - - -_EXPLORER_KEY = r"HKCU\Software\Wine\Explorer" -_DESKTOPS_KEY = r"HKCU\Software\Wine\Explorer\Desktops" -_WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU" -_DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable" -_PERSISTENT_STATE_CACHE_SENTINEL = "@ptdiag-persistent" -_PERSISTENT_STATE_CACHE_DIRECTORY = "ptdiag-cache/dxvk-state" - - -@dataclass(frozen=True) -class ExperimentResult: - result_directory: Path - summary: dict[str, Any] - exit_code: int - - -class ExperimentError(RuntimeError): - """Raised for a controlled experiment setup or lifecycle failure.""" - - -def run_experiment( - config: AppConfig, - testcase: Testcase, - *, - dry_run: bool, - results_root: Path | None = None, -) -> ExperimentResult: - if testcase.modification is not None: - return _run_modified_experiment( - config, - testcase, - dry_run=dry_run, - results_root=results_root, - ) - return _run_standard_experiment( - config, - testcase, - dry_run=dry_run, - results_root=results_root, - ) - - -def _run_standard_experiment( - config: AppConfig, - testcase: Testcase, - *, - dry_run: bool, - results_root: Path | None = None, - prepared_result: ResultDirectory | None = None, - started_at: datetime | None = None, -) -> ExperimentResult: - started_at = started_at or datetime.now().astimezone() - result = prepared_result or create_result_directory( - results_root or project_root() / "results", testcase.name, started_at - ) - logger = CommandLogger(result.commands_path) - (result.path / "runner.log").touch(exist_ok=True) - (result.path / "native.log").touch(exist_ok=True) - environment = collect_environment(config) - hashes = _collect_hashes(config, testcase) - write_json(result.path / "hashes.json", {"files": hashes}) - - desktop_name = _desktop_name(result.path) - metadata: dict[str, Any] = { - "test": testcase.name, - "description": testcase.description, - "started_at": started_at.isoformat(), - "result_directory": str(result.path), - "dry_run": dry_run, - "testcase": testcase.to_dict(), - "config_source": str(config.source), - "desktop_name": desktop_name, - "native_log_directory": str(config.paths.native_logs), - } - write_json(result.metadata_path, metadata) - - preflight_errors = _preflight(config, testcase, environment, hashes) - baseline = snapshot_native_logs(config.paths.native_logs) - metadata["old_log_baseline"] = _serialize_log_states(baseline) - write_json(result.metadata_path, metadata) - registry_before: DesktopRegistrySnapshot | None = None - try: - registry_before = snapshot_desktop_registry(config.proton7.prefix, desktop_name) - except OSError as exc: - preflight_errors.append(f"Could not snapshot dedicated-prefix registry: {exc}") - - launch_argv = _launch_argv(config, testcase, result.path) - env_overrides = _runtime_environment(config, testcase, result.path) - state_cache_summary = _state_cache_plan(config, env_overrides) - dll_overrides = ( - _collect_dll_overrides(config.proton7.prefix) - if testcase.capture.dll_overrides - else {} - ) - _write_text( - result.path / "environment.txt", - json.dumps( - { - "preflight": environment, - "runtime_overrides": dict(sorted(env_overrides.items())), - "dll_overrides": dll_overrides, - }, - indent=2, - sort_keys=True, - ), - ) - metadata["dll_overrides"] = dll_overrides - write_json(result.metadata_path, metadata) - summary: dict[str, Any] = { - "ok": False, - "status": "preflight-failed" if preflight_errors else "prepared", - "dry_run": dry_run, - "test": testcase.name, - "result_directory": str(result.path), - "preflight": { - "ok": not preflight_errors, - "errors": preflight_errors, - }, - "launch": { - "argv": launch_argv, - "cwd": str(config.paths.live_game), - "env_overrides": dict(sorted(env_overrides.items())), - "timeout_seconds": testcase.timeout_seconds, - }, - "native_log": { - "directory": str(config.paths.native_logs), - "baseline": _serialize_log_states(baseline), - "source": None, - "current_run_bytes": 0, - }, - "registry": { - "temporary_change": testcase.display.virtual_desktop, - "before": registry_before.to_dict() if registry_before else None, - "after": None, - "restored": None, - }, - "dxvk_state_cache": state_cache_summary, - } - - if preflight_errors: - _finalize(result, summary) - return ExperimentResult(result.path, summary, 2) - assert registry_before is not None - - if dry_run: - _dry_run_commands( - config, - testcase, - logger, - registry_before, - launch_argv, - env_overrides, - ) - registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name) - summary["registry"]["after"] = registry_after.to_dict() - summary["registry"]["restored"] = desktop_registry_restored( - registry_before, registry_after - ) - summary["status"] = "dry-run" - summary["ok"] = True - summary["review_gate"] = ( - "Phase C dry-run complete; review artifacts before a live Game.exe launch." - ) - _finalize(result, summary) - return ExperimentResult(result.path, summary, 0) - - runtime_exit = 1 - setup_complete = False - try: - if state_cache_summary is not None: - state_cache_summary = _prepare_state_cache( - config, - env_overrides, - logger, - ) - summary["dxvk_state_cache"] = state_cache_summary - _cleanup_checked(config, logger) - if testcase.display.virtual_desktop: - _run_checked_commands( - logger, - _registry_setup_commands(config, desktop_name, testcase.display.size), - cwd=config.paths.live_game, - env_overrides=_proton_environment(config), - ) - setup_complete = True - baseline = snapshot_native_logs(config.paths.native_logs) - metadata["old_log_baseline"] = _serialize_log_states(baseline) - write_json(result.metadata_path, metadata) - runtime = _run_and_watch( - config, - testcase, - result, - logger, - launch_argv, - env_overrides, - baseline, - ) - summary["runtime"] = runtime - summary["native_log"].update(runtime.pop("native_log")) - runtime_exit = ( - 0 - if runtime.get("failure_detected") or runtime.get("scene_import_survived") - else 1 - ) - trigger_epoch = runtime.get("trigger_epoch") - if testcase.capture.strace_files and isinstance(trigger_epoch, (int, float)): - trace = process_trace_shards( - result.path / "raw-strace", - result.path, - trigger_epoch=float(trigger_epoch), - focus_before_seconds=testcase.capture.focus_before_seconds, - focus_after_seconds=testcase.capture.focus_after_seconds, - live_game=config.paths.live_game, - ) - summary["trace"] = trace.to_dict() - else: - summary["trace"] = None - if runtime.get("failure_detected"): - summary["status"] = "captured-scene-failure" - elif runtime.get("scene_import_survived"): - summary["status"] = "scene-import-survived" - else: - summary["status"] = "no-trigger" - summary["ok"] = bool( - runtime.get("failure_detected") or runtime.get("scene_import_survived") - ) - except ( - ExperimentError, - OSError, - ValueError, - PrefixSafetyError, - subprocess.SubprocessError, - ) as exc: - summary["status"] = "runner-error" - summary["error"] = str(exc) - summary["ok"] = False - runtime_exit = 2 - finally: - cleanup_error: str | None = None - try: - _cleanup_checked(config, logger) - except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc: - cleanup_error = str(exc) - if testcase.display.virtual_desktop and setup_complete: - try: - _run_checked_commands( - logger, - _registry_restore_commands(config, registry_before), - cwd=config.paths.live_game, - env_overrides=_proton_environment(config), - ) - except ExperimentError as exc: - cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc) - try: - registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name) - summary["registry"]["after"] = registry_after.to_dict() - summary["registry"]["restored"] = desktop_registry_restored( - registry_before, registry_after - ) - except OSError as exc: - cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc) - if cleanup_error: - summary["cleanup_error"] = cleanup_error - summary["ok"] = False - runtime_exit = 2 - if state_cache_summary is not None: - state_cache_summary["after"] = _state_cache_file_snapshot( - Path(str(state_cache_summary["cache_file"])) - ) - summary["dxvk_state_cache"] = state_cache_summary - - _finalize(result, summary) - return ExperimentResult(result.path, summary, runtime_exit) - - -def _run_modified_experiment( - config: AppConfig, - testcase: Testcase, - *, - dry_run: bool, - results_root: Path | None, -) -> ExperimentResult: - modification = testcase.modification - assert modification is not None - started_at = datetime.now().astimezone() - result = create_result_directory( - results_root or project_root() / "results", testcase.name, started_at - ) - (result.path / "runner.log").touch(exist_ok=True) - (result.path / "native.log").touch(exist_ok=True) - logger = CommandLogger(result.commands_path) - source_environment = collect_environment(config) - source_hashes = _collect_hashes(config, testcase) - source_errors = _preflight(config, testcase, source_environment, source_hashes) - write_json( - result.path / "source-preflight.json", - { - "environment": source_environment, - "hashes": source_hashes, - "ok": not source_errors, - "errors": source_errors, - }, - ) - disposable_game = ( - config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME / result.path.name - ).absolute() - modification_summary: dict[str, Any] = { - "kind": modification.kind, - "source": modification.source, - "destination": modification.destination, - "texture_indices": list(modification.texture_indices), - "ini_overrides": [ - { - "path": item.path, - "section": item.section, - "key": item.key, - "expected": item.expected, - "value": item.value, - } - for item in modification.ini_overrides - ], - "disposable_game": str(disposable_game), - "source_preflight_ok": not source_errors, - "prepared": False, - "prefix_link_restored": None, - "disposable_removed": None, - } - write_json(result.path / "modification.json", modification_summary) - if source_errors: - summary = _setup_failure_summary( - testcase, result, dry_run, source_errors, modification_summary - ) - _finalize(result, summary) - return ExperimentResult(result.path, summary, 2) - - try: - source, live_destination = _resource_copy_paths( - config.paths.live_game, modification - ) - ini_preflight = _inspect_ini_overrides( - config.paths.live_game, - modification.ini_overrides, - ) - for item in ini_preflight: - if not item["matches_expected"]: - raise ExperimentError( - f"INI preflight mismatch for [{item['section']}] {item['key']}: " - f"expected {item['expected']!r}, found {item['current']!r}" - ) - except (OSError, ValueError, ExperimentError) as exc: - summary = _setup_failure_summary( - testcase, result, dry_run, [str(exc)], modification_summary - ) - _finalize(result, summary) - return ExperimentResult(result.path, summary, 2) - - modification_summary.update( - { - "source_path": str(source), - "live_destination_path": str(live_destination), - "live_destination_existed_before": live_destination.exists(), - "source_sha256": sha256_file(source), - "source_size": source.stat().st_size, - "ini_preflight": ini_preflight, - } - ) - if dry_run: - _log_modification_plan( - logger, - config, - disposable_game, - source, - modification, - ) - outcome = _run_standard_experiment( - config, - testcase, - dry_run=True, - prepared_result=result, - started_at=started_at, - ) - modification_summary["status"] = "planned-only" - outcome.summary["modification"] = modification_summary - write_json(result.path / "modification.json", modification_summary) - _finalize(result, outcome.summary) - return outcome - - prefix_link = ( - config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU" - ) - original_link_target: str | None = None - link_changed = False - disposable_created = False - runtime_config = config - outcome: ExperimentResult | None = None - setup_error: str | None = None - cleanup_errors: list[str] = [] - try: - _cleanup_checked(config, logger) - preparation = _prepare_disposable_game( - config, - result, - logger, - modification, - disposable_game, - source, - ) - disposable_created = True - modification_summary.update(preparation) - original_link_target = _replace_game_link( - prefix_link, - disposable_game, - config.paths.live_game, - logger, - ) - link_changed = True - modification_summary.update( - { - "prefix_link": str(prefix_link), - "prefix_link_original_target": original_link_target, - "prefix_link_runtime_target": str(disposable_game), - } - ) - runtime_config = replace( - config, - paths=replace( - config.paths, - live_game=disposable_game, - native_logs=disposable_game / "save/logs", - ), - ) - modification_summary["prepared"] = True - write_json(result.path / "modification.json", modification_summary) - outcome = _run_standard_experiment( - runtime_config, - testcase, - dry_run=False, - prepared_result=result, - started_at=started_at, - ) - except ( - ExperimentError, - OSError, - ValueError, - PrefixSafetyError, - subprocess.SubprocessError, - ) as exc: - setup_error = str(exc) - finally: - try: - _cleanup_checked(runtime_config, logger) - except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc: - cleanup_errors.append(f"wineserver cleanup: {exc}") - if disposable_created and disposable_game.exists() and modification.ini_overrides: - try: - modification_summary["ini_runtime_final"] = _inspect_ini_overrides( - disposable_game, - modification.ini_overrides, - ) - except (ExperimentError, IniError, OSError) as exc: - cleanup_errors.append(f"final INI capture: {exc}") - if link_changed and original_link_target is not None: - try: - _restore_game_link( - prefix_link, - original_link_target, - config.paths.live_game, - logger, - ) - modification_summary["prefix_link_restored"] = True - except (ExperimentError, OSError) as exc: - modification_summary["prefix_link_restored"] = False - cleanup_errors.append(f"prefix link restore: {exc}") - if disposable_created or disposable_game.exists(): - try: - _remove_disposable_game(config, result, disposable_game, logger) - modification_summary["disposable_removed"] = True - except (ExperimentError, OSError) as exc: - modification_summary["disposable_removed"] = False - cleanup_errors.append(f"disposable cleanup: {exc}") - - if outcome is None: - errors = [setup_error or "Modified experiment did not start", *cleanup_errors] - summary = _setup_failure_summary( - testcase, result, False, errors, modification_summary - ) - exit_code = 2 - else: - summary = outcome.summary - exit_code = outcome.exit_code - if setup_error: - summary["status"] = "setup-error" - summary["error"] = setup_error - summary["ok"] = False - exit_code = 2 - if cleanup_errors: - summary["cleanup_error"] = "; ".join(cleanup_errors) - summary["ok"] = False - exit_code = 2 - summary["modification"] = modification_summary - write_json(result.path / "modification.json", modification_summary) - _finalize(result, summary) - return ExperimentResult(result.path, summary, exit_code) - - -def _resource_copy_paths( - live_game: Path, - modification: TestModification, -) -> tuple[Path, Path]: - if modification.kind not in {"copy-file", "normalize-assbin-textures"}: - raise ExperimentError(f"Unsupported modification kind: {modification.kind}") - root = live_game.resolve(strict=True) - source_relative = _safe_resource_relative_path(modification.source) - destination_relative = _safe_resource_relative_path(modification.destination) - source = root.joinpath(*source_relative.parts).resolve(strict=True) - try: - source.relative_to(root) - except ValueError as exc: - raise ExperimentError("Modification source escapes the live game tree") from exc - if not source.is_file(): - raise ExperimentError(f"Modification source is not a file: {source}") - destination = root.joinpath(*destination_relative.parts) - destination_parent = destination.parent.resolve(strict=True) - try: - destination_parent.relative_to(root) - except ValueError as exc: - raise ExperimentError("Modification destination escapes the live game tree") from exc - if modification.kind == "copy-file" and (destination.exists() or destination.is_symlink()): - raise ExperimentError( - f"Live modification destination already exists; refusing experiment: {destination}" - ) - if modification.kind == "normalize-assbin-textures": - if source_relative != destination_relative: - raise ExperimentError( - "Assbin normalization source and destination must be the same disposable file" - ) - if not modification.texture_indices: - raise ExperimentError("Assbin normalization requires texture_indices") - if not destination.is_file(): - raise ExperimentError(f"Assbin normalization target is not a file: {destination}") - return source, destination - - -def _safe_resource_relative_path(value: str) -> PurePosixPath: - if "\\" in value: - raise ExperimentError("Modification paths must use forward slashes") - path = PurePosixPath(value) - if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts): - raise ExperimentError(f"Unsafe relative resource path: {value!r}") - return path - - -def _prepare_disposable_game( - config: AppConfig, - result: ResultDirectory, - logger: CommandLogger, - modification: TestModification, - disposable_game: Path, - source: Path, -) -> dict[str, Any]: - staging_root = (config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME).absolute() - _validate_disposable_path(staging_root, disposable_game, result.path.name) - if disposable_game.exists() or disposable_game.is_symlink(): - raise ExperimentError(f"Disposable game path already exists: {disposable_game}") - usage = shutil.disk_usage(staging_root.parent) - copy_bytes = _allocated_tree_bytes(config.paths.live_game) - if usage.free < copy_bytes + 2 * 1024**3: - raise ExperimentError( - f"Insufficient free space for disposable game copy: " - f"need at least {copy_bytes + 2 * 1024**3}, have {usage.free}" - ) - _require_command( - logger.run( - ["mkdir", "-p", "--", str(staging_root)], - timeout_seconds=15.0, - ), - "create disposable staging directory", - ) - _require_command( - logger.run( - [ - "cp", - "-a", - "--reflink=auto", - "--sparse=always", - f"{config.paths.live_game}/.", - str(disposable_game), - ], - timeout_seconds=900.0, - ), - "copy disposable game tree", - ) - destination_relative = _safe_resource_relative_path(modification.destination) - destination = disposable_game.joinpath(*destination_relative.parts) - if modification.kind == "copy-file": - if destination.exists() or destination.is_symlink(): - raise ExperimentError( - f"Disposable destination unexpectedly existed before copy: {destination}" - ) - _require_command( - logger.run( - ["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)], - timeout_seconds=30.0, - ), - "copy selected resource", - ) - normalization: dict[str, Any] | None = None - destination_existed_before = False - elif modification.kind == "normalize-assbin-textures": - if not destination.is_file() or destination.is_symlink(): - raise ExperimentError( - f"Disposable Assbin target is missing or unsafe: {destination}" - ) - if sha256_file(destination) != sha256_file(source): - raise ExperimentError("Disposable Assbin differs from live source before transform") - temporary_output = destination.with_name( - f".{destination.name}.{result.path.name}.normalized" - ) - if temporary_output.exists() or temporary_output.is_symlink(): - raise ExperimentError(f"Normalization temporary output exists: {temporary_output}") - normalize_result = logger.run( - [ - sys.executable, - "-m", - "ptdiag", - "scene", - "normalize-textures", - str(destination), - str(temporary_output), - *(str(index) for index in modification.texture_indices), - ], - cwd=project_root(), - timeout_seconds=240.0, - ) - _require_command(normalize_result, "normalize embedded Assbin textures") - try: - normalization = json.loads(normalize_result.stdout) - except json.JSONDecodeError as exc: - raise ExperimentError("Normalizer returned invalid JSON") from exc - if not isinstance(normalization, dict) or not normalization.get("ok"): - raise ExperimentError("Normalizer did not report a verified result") - _require_command( - logger.run( - ["mv", "--", str(temporary_output), str(destination)], - timeout_seconds=30.0, - ), - "install normalized Assbin in disposable tree", - ) - destination_existed_before = True - else: - raise ExperimentError(f"Unsupported modification kind: {modification.kind}") - destination_hash = sha256_file(destination) - source_hash = sha256_file(source) - if modification.kind == "copy-file" and destination_hash != source_hash: - raise ExperimentError("Copied resource hash differs from its source") - if modification.kind == "normalize-assbin-textures" and destination_hash == source_hash: - raise ExperimentError("Normalized Assbin hash did not change") - ini_changes = _apply_disposable_ini_overrides( - disposable_game, - modification.ini_overrides, - logger, - ) - return { - "staging_root": str(staging_root), - "free_bytes_before": usage.free, - "source_allocated_bytes": copy_bytes, - "destination_path": str(destination), - "destination_existed_before": destination_existed_before, - "destination_sha256_after": destination_hash, - "destination_size_after": destination.stat().st_size, - "normalization": normalization, - "ini_changes": ini_changes, - } - - -def _inspect_ini_overrides( - game_root: Path, - overrides: Sequence[TestIniOverride], -) -> list[dict[str, Any]]: - root = game_root.resolve(strict=True) - result: list[dict[str, Any]] = [] - for override in overrides: - path = _safe_game_file(root, override.path) - state = inspect_ini_value(path, override.section, override.key) - result.append( - { - "path": str(path), - "section": override.section, - "key": override.key, - "expected": override.expected, - "value": override.value, - "current": state["value"], - "matches_expected": state["value"] == override.expected, - "matches_value": state["value"] == override.value, - "sha256": state["sha256"], - "size": state["size"], - "mtime_ns": state["mtime_ns"], - } - ) - return result - - -def _apply_disposable_ini_overrides( - disposable_game: Path, - overrides: Sequence[TestIniOverride], - logger: CommandLogger, -) -> list[dict[str, Any]]: - root = disposable_game.resolve(strict=True) - result: list[dict[str, Any]] = [] - for override in overrides: - path = _safe_game_file(root, override.path) - outcome = logger.run( - _ini_override_command(path, override), - cwd=project_root(), - timeout_seconds=30.0, - ) - _require_command(outcome, f"override [{override.section}] {override.key}") - try: - payload = json.loads(outcome.stdout) - except json.JSONDecodeError as exc: - raise ExperimentError("INI override returned invalid JSON") from exc - if not isinstance(payload, dict) or not payload.get("ok"): - raise ExperimentError("INI override did not report a verified result") - result.append(payload) - return result - - -def _safe_game_file(root: Path, relative: str) -> Path: - parts = _safe_resource_relative_path(relative).parts - path = root.joinpath(*parts) - resolved = path.resolve(strict=True) - try: - resolved.relative_to(root) - except ValueError as exc: - raise ExperimentError(f"INI path escapes game tree: {relative}") from exc - if path.is_symlink() or not resolved.is_file(): - raise ExperimentError(f"INI path is missing or unsafe: {path}") - return resolved - - -def _ini_override_command(path: Path, override: TestIniOverride) -> list[str]: - return [ - sys.executable, - "-m", - "ptdiag", - "ini", - "override", - str(path), - override.section, - override.key, - override.expected, - override.value, - ] - - -def _replace_game_link( - prefix_link: Path, - disposable_game: Path, - expected_live_game: Path, - logger: CommandLogger, -) -> str: - if not prefix_link.is_symlink(): - raise ExperimentError(f"Dedicated-prefix game path is not a symlink: {prefix_link}") - if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True): - raise ExperimentError("Dedicated-prefix game link changed after source preflight") - original_target = os.readlink(prefix_link) - _require_command( - logger.run( - ["ln", "-sfn", "--", str(disposable_game), str(prefix_link)], - cwd=prefix_link.parent, - timeout_seconds=15.0, - ), - "point dedicated prefix at disposable game", - ) - if not prefix_link.is_symlink() or prefix_link.resolve(strict=True) != disposable_game.resolve(strict=True): - raise ExperimentError("Dedicated-prefix game link did not resolve to disposable copy") - return original_target - - -def _restore_game_link( - prefix_link: Path, - original_target: str, - expected_live_game: Path, - logger: CommandLogger, -) -> None: - _require_command( - logger.run( - ["ln", "-sfn", "--", original_target, str(prefix_link)], - cwd=prefix_link.parent, - timeout_seconds=15.0, - ), - "restore dedicated-prefix game link", - ) - if not prefix_link.is_symlink(): - raise ExperimentError("Restored dedicated-prefix game path is not a symlink") - if os.readlink(prefix_link) != original_target: - raise ExperimentError("Dedicated-prefix game link text was not restored exactly") - if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True): - raise ExperimentError("Dedicated-prefix game link target was not restored") - - -def _remove_disposable_game( - config: AppConfig, - result: ResultDirectory, - disposable_game: Path, - logger: CommandLogger, -) -> None: - staging_root = (config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME).absolute() - _validate_disposable_path(staging_root, disposable_game, result.path.name) - _require_command( - logger.run( - ["rm", "-rf", "--one-file-system", "--", str(disposable_game)], - timeout_seconds=900.0, - ), - "remove disposable game tree", - ) - if disposable_game.exists() or disposable_game.is_symlink(): - raise ExperimentError(f"Disposable game cleanup left its target: {disposable_game}") - try: - staging_root.rmdir() - except OSError: - pass - - -def _validate_disposable_path(staging_root: Path, path: Path, expected_name: str) -> None: - if path.parent != staging_root or path.name != expected_name: - raise ExperimentError(f"Unsafe disposable game path: {path}") - if staging_root.name != _DISPOSABLE_DIRECTORY_NAME: - raise ExperimentError(f"Unsafe disposable staging root: {staging_root}") - - -def _allocated_tree_bytes(root: Path) -> int: - total = 0 - for directory, _, filenames in os.walk(root, followlinks=False): - for filename in filenames: - path = Path(directory) / filename - try: - total += path.lstat().st_blocks * 512 - except FileNotFoundError: - continue - return total - - -def _require_command(outcome: CommandResult, operation: str) -> None: - if not _command_ok(outcome): - raise ExperimentError( - f"Could not {operation} ({outcome.returncode}): " - f"{outcome.error or outcome.stderr.strip() or 'command failed'}" - ) - - -def _log_modification_plan( - logger: CommandLogger, - config: AppConfig, - disposable_game: Path, - source: Path, - modification: TestModification, -) -> None: - staging_root = disposable_game.parent - destination = disposable_game.joinpath( - *_safe_resource_relative_path(modification.destination).parts - ) - prefix_link = config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU" - commands: list[tuple[list[str], float]] = [ - (["mkdir", "-p", "--", str(staging_root)], 15.0), - ( - [ - "cp", - "-a", - "--reflink=auto", - "--sparse=always", - f"{config.paths.live_game}/.", - str(disposable_game), - ], - 900.0, - ), - ] - if modification.kind == "copy-file": - commands.append( - (["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)], 30.0) - ) - elif modification.kind == "normalize-assbin-textures": - temporary_output = destination.with_name(f".{destination.name}.planned.normalized") - commands.extend( - ( - ( - [ - sys.executable, - "-m", - "ptdiag", - "scene", - "normalize-textures", - str(destination), - str(temporary_output), - *(str(index) for index in modification.texture_indices), - ], - 240.0, - ), - (["mv", "--", str(temporary_output), str(destination)], 30.0), - ) - ) - else: - raise ExperimentError(f"Unsupported modification kind: {modification.kind}") - for override in modification.ini_overrides: - ini_path = disposable_game.joinpath( - *_safe_resource_relative_path(override.path).parts - ) - commands.append((_ini_override_command(ini_path, override), 30.0)) - commands.extend( - ( - (["ln", "-sfn", "--", str(disposable_game), str(prefix_link)], 15.0), - (["ln", "-sfn", "--", os.readlink(prefix_link), str(prefix_link)], 15.0), - (["rm", "-rf", "--one-file-system", "--", str(disposable_game)], 900.0), - ) - ) - for command, timeout in commands: - logger.run(command, timeout_seconds=timeout, dry_run=True) - - -def _setup_failure_summary( - testcase: Testcase, - result: ResultDirectory, - dry_run: bool, - errors: Sequence[str], - modification: Mapping[str, Any], -) -> dict[str, Any]: - return { - "ok": False, - "status": "preflight-failed" if errors else "setup-error", - "dry_run": dry_run, - "test": testcase.name, - "result_directory": str(result.path), - "preflight": {"ok": False, "errors": list(errors)}, - "modification": dict(modification), - "native_log": {"source": None, "current_run_bytes": 0}, - "registry": {"restored": None}, - } - - -def _preflight( - config: AppConfig, - testcase: Testcase, - environment: Mapping[str, Any], - hashes: list[dict[str, Any]], -) -> list[str]: - errors: list[str] = [] - if not environment.get("ok"): - errors.append("Required environment checks failed") - if any(not item.get("ok", False) for item in hashes if item.get("required", False)): - errors.append("A required baseline hash is missing or mismatched") - if testcase.runner != "proton7": - errors.append(f"Unsupported runner: {testcase.runner}") - if testcase.game.args != config.game.args: - errors.append("Testcase game arguments differ from the known PTEU_REBORN baseline") - if testcase.game.cwd != "live_game": - errors.append("Only the verified live_game working directory is supported") - if testcase.display.size != config.game.virtual_desktop: - errors.append("Testcase desktop size differs from the 1600x900 baseline") - if testcase.capture.strace_files and shutil.which("strace") is None: - errors.append("scene-strace requires strace") - if not testcase.watch.require_new_native_log: - errors.append("Runtime tests must require a current-run native log") - try: - prefix = validate_dedicated_prefix(config) - except (OSError, ValueError, PrefixSafetyError) as exc: - errors.append(str(exc)) - else: - linked_game = prefix / "drive_c/Program Files (x86)/Pristontale EU" - try: - if linked_game.resolve(strict=True) != config.paths.live_game.resolve(strict=True): - errors.append("Dedicated prefix game link does not resolve to live_game") - except OSError as exc: - errors.append(f"Dedicated prefix game link is invalid: {exc}") - active = _game_processes() - if active: - errors.append(f"Game.exe is already running: {active}") - return errors - - -def _dry_run_commands( - config: AppConfig, - testcase: Testcase, - logger: CommandLogger, - registry_before: DesktopRegistrySnapshot, - launch_argv: list[str], - env_overrides: Mapping[str, str], -) -> None: - cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True) - if testcase.display.virtual_desktop: - for command in _registry_setup_commands( - config, registry_before.desktop_name, testcase.display.size - ): - logger.run( - command, - cwd=config.paths.live_game, - env_overrides=_proton_environment(config), - timeout_seconds=15.0, - dry_run=True, - ) - logger.run( - launch_argv, - cwd=config.paths.live_game, - env_overrides=env_overrides, - timeout_seconds=testcase.timeout_seconds, - dry_run=True, - ) - cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True) - if testcase.display.virtual_desktop: - for command in _registry_restore_commands(config, registry_before): - logger.run( - command, - cwd=config.paths.live_game, - env_overrides=_proton_environment(config), - timeout_seconds=15.0, - dry_run=True, - ) - - -def _run_and_watch( - config: AppConfig, - testcase: Testcase, - result: ResultDirectory, - logger: CommandLogger, - argv: Sequence[str], - env_overrides: Mapping[str, str], - baseline: Mapping[str, LogFileState], -) -> dict[str, Any]: - runner_log_path = result.path / "runner.log" - environment = os.environ.copy() - environment.update(env_overrides) - test_start_ns = time.time_ns() - started_at = datetime.now().astimezone() - write_json( - result.path / "test-start.json", - { - "test_start_ns": test_start_ns, - "test_start_epoch": test_start_ns / 1_000_000_000, - "recorded_at": started_at.isoformat(), - }, - ) - process: subprocess.Popen[str] | None = None - process_identity: dict[str, Any] | None = None - failure_state: LogFileState | None = None - failure_trigger_mtime_ns: int | None = None - current_state: LogFileState | None = None - current_analysis: dict[str, Any] | None = None - current_text = "" - current_log_captures: list[dict[str, Any]] = [] - performance_sampler: PerformanceSampler | None = None - performance_summary: dict[str, Any] | None = None - detected_at_ns: int | None = None - timed_out = False - stop_reason = "process-exited" - failure_reason: str | None = None - with runner_log_path.open("a", encoding="utf-8") as runner_log: - try: - process = subprocess.Popen( - list(argv), - cwd=config.paths.live_game, - env=environment, - stdout=runner_log, - stderr=subprocess.STDOUT, - text=True, - start_new_session=True, - ) - except OSError as exc: - logger.record_lifecycle( - argv, - state="launch-error", - cwd=config.paths.live_game, - env_overrides=env_overrides, - timeout_seconds=testcase.timeout_seconds, - started_at=started_at, - error=str(exc), - ) - raise ExperimentError(f"Could not launch experiment: {exc}") from exc - assert process is not None - logger.record_lifecycle( - argv, - state="started", - cwd=config.paths.live_game, - env_overrides=env_overrides, - timeout_seconds=testcase.timeout_seconds, - started_at=started_at, - pid=process.pid, - ) - deadline_ns = test_start_ns + int(testcase.timeout_seconds * 1_000_000_000) - post_failure_deadline_ns: int | None = None - while process.poll() is None: - now_ns = time.time_ns() - if process_identity is None: - process_identity = _capture_game_process(testcase, result.path) - if process_identity is not None and testcase.capture.performance_stats: - try: - performance_sampler = PerformanceSampler( - int(process_identity["pid"]), - result.path / "performance.csv", - ) - except (OSError, ValueError, KeyError) as exc: - performance_summary = {"error": str(exc)} - if performance_sampler is not None: - performance_sampler.maybe_sample(now_ns) - if failure_state is None: - for state in discover_current_native_logs( - config.paths.native_logs, baseline, test_start_ns - ): - analysis = parse_current_run_log(state, baseline) - current_state = state - current_analysis = analysis.to_summary() - current_text = read_current_run_log_text(state, baseline) - if ( - analysis.load_texture_to_material_failed - or analysis.asset_pack_health_failed - ): - failure_state = state - failure_trigger_mtime_ns = state.mtime_ns - detected_at_ns = now_ns - post_failure_deadline_ns = now_ns + int( - testcase.stop_after_failure_seconds * 1_000_000_000 - ) - failure_reason = ( - "asset-pack-health" - if analysis.asset_pack_health_failed - else "load-texture-to-material" - ) - break - if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns: - stop_reason = failure_reason or "load-texture-to-material" - _stop_process_group(process) - break - if now_ns >= deadline_ns: - timed_out = True - stop_reason = "timeout" - _stop_process_group(process) - break - time.sleep(0.1) - if process.poll() is None: - _stop_process_group(process) - returncode = process.wait(timeout=5.0) - if performance_sampler is not None: - performance_summary = performance_sampler.close() - logger.record_lifecycle( - argv, - state="finished", - cwd=config.paths.live_game, - env_overrides=env_overrides, - timeout_seconds=testcase.timeout_seconds, - started_at=started_at, - pid=process.pid, - returncode=returncode, - timed_out=timed_out, - ) - - final_current = discover_current_native_logs( - config.paths.native_logs, baseline, test_start_ns - ) - if final_current: - candidates: list[ - tuple[tuple[int, int, int, int, str], LogFileState, str, dict[str, Any]] - ] = [] - for index, state in enumerate(final_current): - text = read_current_run_log_text(state, baseline) - analysis = parse_current_run_log(state, baseline).to_summary() - scenes = analysis.get("scenes") - scene_names = ( - sorted(str(name) for name in scenes) - if isinstance(scenes, Mapping) - else [] - ) - captured_path = ( - result.path / "native-logs" / f"{index:02d}-{state.path.name}.current.log" - ) - _write_text(captured_path, text) - current_log_captures.append( - { - "source": str(state.path), - "state": _serialize_log_state(state), - "captured_path": str(captured_path), - "current_run_bytes": len(text.encode("utf-8")), - "scene_names": scene_names, - "load_texture_to_material_failed": analysis.get( - "load_texture_to_material_failed", False - ), - "asset_pack_health_failed": analysis.get( - "asset_pack_health_failed", False - ), - "import_scene_threw": analysis.get("import_scene_threw", False), - } - ) - candidates.append( - (_native_log_priority(state, analysis), state, text, analysis) - ) - _, current_state, current_text, current_analysis = max( - candidates, - key=lambda item: item[0], - ) - if failure_state is not None: - final_states = snapshot_native_logs(config.paths.native_logs) - final_state = final_states.get(str(failure_state.path.absolute())) - if final_state is not None: - current_state = final_state - current_text = read_current_run_log_text(final_state, baseline) - current_analysis = parse_current_run_log(final_state, baseline).to_summary() - if current_state is not None: - _write_text(result.path / "native.log", current_text) - whispering_vale = ( - current_analysis.get("whispering_vale") - if isinstance(current_analysis, Mapping) - else None - ) - scene_import_survived = bool( - whispering_vale - and not timed_out - and not current_analysis.get("load_texture_to_material_failed", False) - and not current_analysis.get("asset_pack_health_failed", False) - and not current_analysis.get("import_scene_threw", False) - and not current_analysis.get("failed_import_scenes", []) - ) if isinstance(current_analysis, Mapping) else False - trigger_ns = ( - max(test_start_ns, failure_trigger_mtime_ns) - if failure_trigger_mtime_ns is not None - else None - ) - return { - "test_start_ns": test_start_ns, - "test_start_epoch": test_start_ns / 1_000_000_000, - "finished_at": datetime.now().astimezone().isoformat(), - "launcher_pid": process.pid if process is not None else None, - "game_process": process_identity, - "returncode": process.returncode if process is not None else None, - "timed_out": timed_out, - "stop_reason": stop_reason, - "failure_detected": failure_state is not None, - "scene_import_survived": scene_import_survived, - "detected_at_ns": detected_at_ns, - "trigger_epoch": trigger_ns / 1_000_000_000 if trigger_ns is not None else None, - "analysis": current_analysis, - "native_logs": current_log_captures, - "performance": performance_summary, - "native_log": { - "source": str(current_state.path) if current_state else None, - "state": _serialize_log_state(current_state) if current_state else None, - "current_run_bytes": len(current_text.encode("utf-8")), - }, - } - - -def _native_log_priority( - state: LogFileState, - analysis: Mapping[str, Any], -) -> tuple[int, int, int, int, str]: - scenes = analysis.get("scenes") - scene_count = 0 - if isinstance(scenes, Mapping): - for imports in scenes.values(): - if isinstance(imports, list): - scene_count += len(imports) - return ( - int(scene_count > 0), - scene_count, - int(state.path.name.casefold().startswith("log_")), - state.mtime_ns, - str(state.path), - ) - - -def _stop_process_group(process: subprocess.Popen[str]) -> None: - if process.poll() is not None: - return - for signum, timeout in ( - (signal.SIGINT, 5.0), - (signal.SIGTERM, 5.0), - (signal.SIGKILL, 3.0), - ): - try: - os.killpg(process.pid, signum) - except ProcessLookupError: - return - try: - process.wait(timeout=timeout) - return - except subprocess.TimeoutExpired: - continue - - -def _capture_game_process(testcase: Testcase, result_directory: Path) -> dict[str, Any] | None: - processes = _game_processes() - if not processes: - return None - pid = processes[0] - root = Path("/proc") / str(pid) - identity: dict[str, Any] = {"pid": pid} - try: - identity["comm"] = (root / "comm").read_text(encoding="utf-8").strip() - identity["cmdline"] = ( - (root / "cmdline").read_bytes().decode("utf-8", errors="replace").split("\0") - ) - identity["cwd"] = str((root / "cwd").readlink()) - identity["exe"] = str((root / "exe").readlink()) - if testcase.capture.process_maps: - shutil.copyfile(root / "maps", result_directory / "process-maps.txt") - except OSError as exc: - identity["capture_error"] = str(exc) - return identity - - -def _game_processes() -> list[int]: - result: list[int] = [] - for entry in Path("/proc").iterdir(): - if not entry.name.isdigit(): - continue - try: - comm = (entry / "comm").read_text(encoding="utf-8").strip().casefold() - except OSError: - continue - if comm in {"game.exe", "gamese.exe"}: - result.append(int(entry.name)) - return sorted(result) - - -def _launch_argv(config: AppConfig, testcase: Testcase, result_directory: Path) -> list[str]: - game = f"{_WINDOWS_GAME_DIRECTORY}\\{config.game.exe}" - proton = _proton_command(config, "runinprefix", game, *testcase.game.args) - if not testcase.capture.strace_files: - return proton - raw_directory = result_directory / "raw-strace" - raw_directory.mkdir(exist_ok=False) - strace = shutil.which("strace") or "/usr/bin/strace" - return [ - strace, - "-ff", - "-qq", - "-ttt", - "-s", - "1024", - "-e", - "trace=%file", - "-o", - str(raw_directory / "trace"), - *proton, - ] - - -def _proton_command(config: AppConfig, *arguments: str) -> list[str]: - shim = project_root() / "ptlinux/proton_compat.py" - return [ - sys.executable, - str(shim), - str(config.proton7.proton), - *arguments, - ] - - -def _runtime_environment( - config: AppConfig, - testcase: Testcase, - result_directory: Path, -) -> dict[str, str]: - environment = _proton_environment(config) - environment.update(testcase.env) - if environment.get("DXVK_STATE_CACHE_PATH") == _PERSISTENT_STATE_CACHE_SENTINEL: - environment["DXVK_STATE_CACHE_PATH"] = str( - (config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute() - ) - environment.update( - { - "DXVK_LOG_PATH": str(result_directory), - "PROTON_LOG": "0", - "STEAM_COMPAT_INSTALL_PATH": str(config.paths.live_game), - "SteamAppId": "0", - "SteamGameId": "0", - } - ) - return environment - - -def _state_cache_plan( - config: AppConfig, - environment: Mapping[str, str], -) -> dict[str, Any] | None: - configured = environment.get("DXVK_STATE_CACHE_PATH") - if configured is None: - return None - cache_directory = Path(configured).expanduser().absolute() - cache_file = cache_directory / f"{Path(config.game.exe).stem}.dxvk-cache" - return { - "directory": str(cache_directory), - "cache_file": str(cache_file), - "persistent": True, - "seed_source": str(config.paths.live_game / cache_file.name), - "seeded": False, - "before": _state_cache_file_snapshot(cache_file), - "after": None, - } - - -def _prepare_state_cache( - config: AppConfig, - environment: Mapping[str, str], - logger: CommandLogger, -) -> dict[str, Any]: - plan = _state_cache_plan(config, environment) - if plan is None: - raise ExperimentError("Persistent DXVK state-cache plan is missing") - cache_directory = Path(str(plan["directory"])) - expected = (config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute() - if cache_directory != expected: - raise ExperimentError( - f"Refusing unmanaged DXVK state-cache directory: {cache_directory}; " - f"expected {expected}" - ) - _require_command( - logger.run( - ["mkdir", "-p", "--", str(cache_directory)], - timeout_seconds=15.0, - ), - "create managed DXVK state-cache directory", - ) - cache_file = Path(str(plan["cache_file"])) - seed_source = Path(str(plan["seed_source"])) - if not cache_file.exists() and seed_source.is_file(): - _require_command( - logger.run( - [ - "cp", - "--preserve=mode,timestamps", - "--", - str(seed_source), - str(cache_file), - ], - timeout_seconds=30.0, - ), - "seed managed DXVK state cache", - ) - plan["seeded"] = True - plan["before"] = _state_cache_file_snapshot(cache_file) - return plan - - -def _state_cache_file_snapshot(path: Path) -> dict[str, Any]: - result: dict[str, Any] = { - "path": str(path), - "exists": path.is_file(), - "size": None, - "mtime_ns": None, - "sha256": None, - } - if not result["exists"]: - return result - try: - state = path.stat() - result.update( - { - "size": state.st_size, - "mtime_ns": state.st_mtime_ns, - "sha256": sha256_file(path), - } - ) - except OSError as exc: - result["error"] = str(exc) - return result - - -def _proton_environment(config: AppConfig) -> dict[str, str]: - return { - "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(config.proton7.steam_root), - "STEAM_COMPAT_DATA_PATH": str(config.proton7.compat), - "WINEPREFIX": str(config.proton7.prefix), - "WINEDEBUG": "-all", - } - - -def _registry_setup_commands( - config: AppConfig, - desktop_name: str, - desktop_size: str, -) -> list[list[str]]: - return [ - _proton_command( - config, - "runinprefix", - "reg.exe", - "add", - _EXPLORER_KEY, - "/v", - "Desktop", - "/t", - "REG_SZ", - "/d", - desktop_name, - "/f", - ), - _proton_command( - config, - "runinprefix", - "reg.exe", - "add", - _DESKTOPS_KEY, - "/v", - desktop_name, - "/t", - "REG_SZ", - "/d", - desktop_size, - "/f", - ), - ] - - -def _registry_restore_commands( - config: AppConfig, - snapshot: DesktopRegistrySnapshot, -) -> list[list[str]]: - return [ - _registry_restore_command( - config, - _EXPLORER_KEY, - "Desktop", - snapshot.selected_desktop.exists, - snapshot.selected_desktop.value, - ), - _registry_restore_command( - config, - _DESKTOPS_KEY, - snapshot.desktop_name, - snapshot.desktop_size.exists, - snapshot.desktop_size.value, - ), - ] - - -def _registry_restore_command( - config: AppConfig, - key: str, - name: str, - existed: bool, - value: str | None, -) -> list[str]: - if existed: - assert value is not None - return _proton_command( - config, - "runinprefix", - "reg.exe", - "add", - key, - "/v", - name, - "/t", - "REG_SZ", - "/d", - value, - "/f", - ) - return _proton_command( - config, - "runinprefix", - "reg.exe", - "delete", - key, - "/v", - name, - "/f", - ) - - -def _run_checked_commands( - logger: CommandLogger, - commands: Sequence[Sequence[str]], - *, - cwd: Path, - env_overrides: Mapping[str, str], -) -> None: - for command in commands: - outcome = logger.run( - command, - cwd=cwd, - env_overrides=env_overrides, - timeout_seconds=15.0, - ) - if not _command_ok(outcome): - raise ExperimentError( - f"Command failed ({outcome.returncode}): {list(command)!r}: " - f"{outcome.error or outcome.stderr.strip()}" - ) - - -def _cleanup_checked(config: AppConfig, logger: CommandLogger) -> None: - outcome = cleanup_dedicated_wineserver( - config, - logger, - timeout_seconds=15.0, - ) - if ( - outcome.returncode == 1 - and not outcome.timed_out - and outcome.error is None - and not outcome.stdout - and not outcome.stderr - ): - # Proton 7 wineserver -k returns 1 when this dedicated prefix has no - # server to stop. That is the desired idempotent cleanup state. - return - if not _command_ok(outcome): - raise ExperimentError( - "Dedicated-prefix wineserver cleanup failed: " - f"{outcome.error or outcome.stderr.strip() or outcome.returncode}" - ) - - -def _command_ok(result: CommandResult) -> bool: - return not result.timed_out and result.error is None and result.returncode == 0 - - -def _collect_hashes(config: AppConfig, testcase: Testcase) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - for verification in verify_known_game_hashes(config.paths.live_game): - item = verification.to_dict() - item["required"] = True - item["role"] = "known-game-baseline" - items.append(item) - extra = { - config.proton7.proton: "proton-launcher", - config.proton7.prefix / "drive_c/windows/syswow64/d3dx9_43.dll": "native-d3dx9-43", - config.proton7.prefix - / "drive_c/windows/syswow64/d3dcompiler_43.dll": "native-d3dcompiler-43", - config.proton7.prefix / "drive_c/windows/syswow64/d3d9.dll": "dxvk-d3d9", - project_root() / "ptlinux/proton_compat.py": "proton-python314-shim", - config.source: "local-config", - testcase.source: "testcase", - } - dxvk_config = testcase.env.get("DXVK_CONFIG_FILE") - if dxvk_config: - dxvk_config_path = Path(dxvk_config).expanduser() - if not dxvk_config_path.is_absolute(): - dxvk_config_path = (project_root() / dxvk_config_path).absolute() - extra[dxvk_config_path] = "dxvk-config" - for path, role in extra.items(): - item: dict[str, Any] = { - "path": str(path), - "role": role, - "required": role not in {"local-config", "testcase"}, - "expected_sha256": None, - "actual_sha256": None, - "ok": False, - "error": None, - } - try: - item["actual_sha256"] = sha256_file(path) - item["ok"] = True - except OSError as exc: - item["error"] = str(exc) - items.append(item) - return items - - -def _collect_dll_overrides(prefix: Path) -> dict[str, str]: - sections = read_registry_string_values(prefix / "user.reg") - return dict(sorted(sections.get(r"Software\Wine\DllOverrides", {}).items())) - - -def _serialize_log_states(states: Mapping[str, LogFileState]) -> list[dict[str, Any]]: - return [_serialize_log_state(state) for state in sorted(states.values(), key=lambda x: str(x.path))] - - -def _serialize_log_state(state: LogFileState) -> dict[str, Any]: - return { - "path": str(state.path), - "mtime_ns": state.mtime_ns, - "size": state.size, - "inode": state.inode, - } - - -def _desktop_name(result_directory: Path) -> str: - digits = "".join(character for character in result_directory.name if character.isdigit()) - return f"PTDiag{digits[-12:]}" - - -def _finalize(result: ResultDirectory, summary: dict[str, Any]) -> None: - write_json(result.path / "summary.json", summary) - _write_text(result.path / "report.md", _report(summary)) - - -def _report(summary: Mapping[str, Any]) -> str: - preflight = summary.get("preflight", {}) - launch = summary.get("launch", {}) - registry = summary.get("registry", {}) - native_log = summary.get("native_log", {}) - modification = summary.get("modification") - state_cache = summary.get("dxvk_state_cache") - runtime = summary.get("runtime") - lines = [ - f"# {summary.get('test', 'experiment')}", - "", - f"Status: `{summary.get('status')}`", - f"Result directory: `{summary.get('result_directory')}`", - f"Dry run: `{summary.get('dry_run')}`", - "", - "## Preflight", - "", - f"Passed: `{preflight.get('ok')}`" if isinstance(preflight, Mapping) else "Passed: `False`", - ] - if isinstance(preflight, Mapping): - for error in preflight.get("errors", []): - lines.append(f"- {error}") - lines.extend( - [ - "", - "## Launch", - "", - f"CWD: `{launch.get('cwd')}`" if isinstance(launch, Mapping) else "", - f"Timeout: `{launch.get('timeout_seconds')}` seconds" - if isinstance(launch, Mapping) - else "", - "", - "## Current-run native log", - "", - f"Source: `{native_log.get('source')}`" if isinstance(native_log, Mapping) else "", - f"Captured bytes: `{native_log.get('current_run_bytes')}`" - if isinstance(native_log, Mapping) - else "", - "", - "## Registry rollback", - "", - f"Restored: `{registry.get('restored')}`" if isinstance(registry, Mapping) else "", - ] - ) - if summary.get("review_gate"): - lines.extend(["", "## Review gate", "", str(summary["review_gate"])]) - if isinstance(runtime, Mapping) and isinstance(runtime.get("performance"), Mapping): - performance = runtime["performance"] - lines.extend( - [ - "", - "## Performance capture", - "", - f"Samples: `{performance.get('sample_count')}`", - f"Interval: `{performance.get('interval_seconds')}` seconds", - f"CSV: `{performance.get('output')}`", - ] - ) - if isinstance(state_cache, Mapping): - lines.extend( - [ - "", - "## DXVK state cache", - "", - f"Directory: `{state_cache.get('directory')}`", - f"Seeded: `{state_cache.get('seeded')}`", - f"Before: `{state_cache.get('before')}`", - f"After: `{state_cache.get('after')}`", - ] - ) - if isinstance(modification, Mapping): - lines.extend( - [ - "", - "## Disposable modification", - "", - f"Kind: `{modification.get('kind')}`", - f"Source: `{modification.get('source')}`", - f"Destination: `{modification.get('destination')}`", - f"Prepared: `{modification.get('prepared')}`", - f"Prefix link restored: `{modification.get('prefix_link_restored')}`", - f"Disposable copy removed: `{modification.get('disposable_removed')}`", - ] - ) - if summary.get("error"): - lines.extend(["", "## Error", "", str(summary["error"])]) - return "\n".join(line for line in lines if line is not None).rstrip() + "\n" - - -def _write_text(path: Path, value: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") - temporary.write_text(value.rstrip("\n") + "\n", encoding="utf-8") - temporary.replace(path) diff --git a/ptlinux/cli.py b/ptlinux/cli.py deleted file mode 100644 index 1aba390..0000000 --- a/ptlinux/cli.py +++ /dev/null @@ -1,2388 +0,0 @@ -"""Command-line interface for installation, updates, repair, and play.""" - -from __future__ import annotations - -import argparse -from contextlib import AbstractContextManager, nullcontext -from dataclasses import asdict, replace -from datetime import datetime -import json -from pathlib import Path -import re -import signal -import sys -import threading -import time -from typing import Callable, Sequence -from uuid import uuid4 - -from ptdiag.logs import ( - LogFileState, - classify_asset_references, - newest_current_native_log, - parse_current_run_log, - snapshot_native_logs, -) - -from . import __version__ -from .command import CommandError, CommandOutcome, CommandRunner -from .container_host import ( - ContainerHostReport, - container_host_error, - inspect_container_host, - repair_container_host, - repair_container_host_after_update, -) -from .depot import ( - DownloadOptions, - SyncProgress, - read_cached_depot_launch_catalog, - read_current_depot_file_catalog, - preflight_managed_game, - select_depot_start_option, - sync_managed_game, -) -from .doctor import inspect_host -from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher -from .importer import GameImportResult, discover_existing_game, import_existing_game -from .import_state import game_import_requires_verification, mark_game_import_verified -from .launcher import ( - LauncherError, - WineTools, - configure_launcher_prefix, - find_wine_tools, - launch_official_updater, - prepare_launcher_binary, -) -from .locking import ( - LauncherPlayLease, - launcher_operation_lock, - launcher_play_lock, - managed_game_process_is_active, -) -from .login_diagnostics import inspect_login_environment -from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON -from .overlay import cache_compatibility_binaries, prepare_runtime_view -from .paths import AppPaths -from .runtime import ( - RuntimeErrorPT, - game_runtime_ready, - prepare_game_runtime, - run_game, - select_runtime, -) -from .settings import Settings, ensure_settings, load_settings, reset_settings -from .self_update import ( - check_for_launcher_update, - install_launcher_update, - rollback_launcher_update, - update_check_to_dict, - verify_launcher_update_channel, -) -from .update_resolver import STABLE_CHANNEL -from .telemetry import ( - TelemetryService, - classify_network_failure, - current_telemetry, - initialize_shared_telemetry, - network_http_status, - shutdown_shared_telemetry, -) -from .system_identity import is_running_inside_steam_runtime -from .umu import ( - UmuBootstrapResult, - find_umu_launcher, - provision_umu, - provision_umu_for_startup, -) -from .window_diagnostics import ( - WINDOW_DIAGNOSTICS_ENV, - WINDOW_DIAGNOSTICS_SESSION_ENV, - parse_window_diagnostic, -) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="pristontale-eu", - description="PristonTale EU all-in-one Linux installer and clean launcher", - ) - parser.add_argument( - "--version", - action="version", - version=f"%(prog)s {__version__}", - ) - parser.add_argument("--json", action="store_true", help="emit machine-readable output") - subparsers = parser.add_subparsers(dest="command") - - subparsers.add_parser("gui", help="open the native Linux dashboard") - subparsers.add_parser("doctor", help="check cross-distribution dependencies") - container_host = subparsers.add_parser( - "container-host", - help="check Bubblewrap and Ubuntu AppArmor for the Steam Runtime", - ) - container_host.add_argument( - "--repair", - action="store_true", - help="repair only recognized Ubuntu AppArmor profile states", - ) - subparsers.add_parser( - "runtime-bootstrap", - help="download and verify the Steam Runtime launcher helper", - ) - login_diagnostics = subparsers.add_parser( - "login-diagnostics", - help="check the client, runtime, and publisher login route without credentials", - ) - login_diagnostics.add_argument( - "--timeout", - type=float, - default=5.0, - help="TCP connection timeout in seconds (default: 5)", - ) - login_diagnostics.add_argument( - "--runtime", - choices=("configured", "auto", "proton7", "proton7-container", "umu"), - default="configured", - help=( - "probe one runtime without changing settings or starting the game " - "(default: configured)" - ), - ) - - fetch = subparsers.add_parser("fetch", help="fetch and safely prepare the latest official launcher") - fetch.add_argument("--dry-run", action="store_true") - - setup = subparsers.add_parser("setup", help="configure Wine and .NET Desktop for the updater") - setup.add_argument("--dry-run", action="store_true") - setup.add_argument("--force-runtime", action="store_true") - - update = subparsers.add_parser( - "update", - help="safely synchronize the official game depot without Wine", - ) - update.add_argument("--dry-run", action="store_true") - update.add_argument("--repair", action="store_true", help="hash-check every game file") - import_options = update.add_mutually_exclusive_group() - import_options.add_argument( - "--seed-from", - type=Path, - help="import an existing game before updating", - ) - import_options.add_argument( - "--no-import", - action="store_true", - help="disable automatic existing-game discovery", - ) - - sync = subparsers.add_parser( - "sync", - help="native official depot synchronization without bootstrap discovery", - ) - sync.add_argument("--dry-run", action="store_true") - sync.add_argument("--repair", action="store_true", help="hash-check every game file") - - legacy_update = subparsers.add_parser( - "wine-update", - help="legacy official WPF updater (normally unnecessary)", - ) - legacy_update.add_argument("--dry-run", action="store_true") - legacy_update.add_argument("--force-runtime", action="store_true") - legacy_import_options = legacy_update.add_mutually_exclusive_group() - legacy_import_options.add_argument("--seed-from", type=Path) - legacy_import_options.add_argument("--no-import", action="store_true") - - import_game = subparsers.add_parser( - "import-game", - help="safely seed the managed installation from an existing game", - ) - import_game.add_argument("source", type=Path) - import_game.add_argument("--dry-run", action="store_true") - - compatibility = subparsers.add_parser( - "cache-compatibility", - help="cache one exact verified Game.exe/game.dll compatibility pair", - ) - compatibility.add_argument("source", type=Path) - prepare = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix") - prepare.add_argument("--game-dir", type=Path) - - play = subparsers.add_parser("play", help="launch the game in the managed Proton window") - play.add_argument("--game-dir", type=Path) - play.add_argument( - "--realm", - help="launch an available realm from the last official depot manifest", - ) - play.add_argument("--no-box", action="store_true", help="launch without X11 window styling") - play.add_argument( - "--publisher-dll", - "--no-world-fix", - action="store_true", - dest="publisher_dll", - help=( - "compatibility option retained for older scripts; publisher code is " - "always used without the retired world fix" - ), - ) - play.add_argument("--dry-run", action="store_true") - play.add_argument("--skip-runtime-setup", action="store_true") - login_trace = subparsers.add_parser( - "login-trace", - help="retired unsafe authentication trace (always refuses to launch)", - ) - login_trace.add_argument("--game-dir", type=Path) - login_trace.add_argument("--no-box", action="store_true", help="launch without X11 window styling") - login_trace.add_argument("--skip-runtime-setup", action="store_true") - login_trace.add_argument( - "--timeout", - type=float, - default=DEFAULT_TRACE_TIMEOUT_SECONDS, - help="maximum game runtime in seconds (default: 600)", - ) - - settings = subparsers.add_parser("settings", help="show, initialize, or reset settings") - settings_action = settings.add_mutually_exclusive_group() - settings_action.add_argument( - "--init", - action="store_true", - help="create missing settings and migrate legacy files without resetting choices", - ) - settings_action.add_argument( - "--reset", - action="store_true", - help="explicitly replace all launcher settings with defaults", - ) - - launcher_update = subparsers.add_parser( - "launcher-update", - help="check, verify, install, or roll back the native Linux launcher", - ) - launcher_action = launcher_update.add_mutually_exclusive_group() - launcher_action.add_argument( - "--check", - action="store_true", - help="authenticate the update manifest without installing it", - ) - launcher_action.add_argument( - "--verify", - action="store_true", - help="download and fully validate the published release without installing it", - ) - launcher_action.add_argument( - "--rollback", - nargs="?", - const="", - metavar="VERSION", - help=( - "switch to VERSION from signed release history; without VERSION, " - "switch to the locally retained previous release" - ), - ) - launcher_update.add_argument( - "--manifest-url", - help="override the signed stable-manifest URL", - ) - launcher_update.add_argument( - "--public-key", - type=Path, - help="override the pinned launcher-update public key", - ) - launcher_update.add_argument( - "--history-url", - help="override the signed launcher release-history URL", - ) - return parser - - -def main( - argv: Sequence[str] | None = None, - *, - _manage_telemetry: bool | None = None, -) -> int: - parser = build_parser() - arguments = parser.parse_args(argv) - command = arguments.command or "gui" - paths = AppPaths.from_environment() - manage_telemetry = argv is None if _manage_telemetry is None else _manage_telemetry - telemetry: TelemetryService | None = None - telemetry_owner = False - if manage_telemetry: - interface = "gui" if command == "gui" else "cli" - initialized_telemetry, telemetry_owner = initialize_shared_telemetry( - paths, - session_metadata={"interface": interface, "command": command}, - report_launcher_lifecycle=command == "gui", - ) - telemetry = initialized_telemetry - telemetry_events = _command_telemetry_events(command, arguments, paths) - operation_started = time.monotonic() - if telemetry is not None and telemetry_events is not None: - operation, started_event, _success_event, _failed_event = telemetry_events - if operation == "repair": - telemetry.send_event( - "repair_requested", - metadata={"command": command, "reason": "user_requested"}, - context={"operation": operation, "stage": "requested"}, - ) - telemetry.send_event( - started_event, - metadata={"command": command}, - context={"operation": operation, "stage": "started"}, - ) - try: - returncode = _dispatch_command(parser, arguments, command, paths) - except KeyboardInterrupt: - _report_command_failure( - telemetry, - telemetry_events, - command, - operation_started, - "Operation interrupted", - stage="interrupted", - ) - message = "Interrupted; PristonTale and its compatibility runtime were stopped." - if arguments.json: - _print_json({"ok": False, "error": message, "interrupted": True}) - else: - print(f"\n{message}", file=sys.stderr) - return 130 - except (ValueError, OSError, RuntimeError) as exc: - _report_command_failure( - telemetry, - telemetry_events, - command, - operation_started, - str(exc), - stage=_failure_stage(command, exc), - ) - if telemetry is not None and isinstance(exc, ValueError): - telemetry.send_event( - "configuration_error", - severity="error", - message=str(exc), - metadata={"setting": "unknown", "reason": "invalid_value"}, - context={"operation": command, "stage": "configuration"}, - ) - if arguments.json: - _print_json({"ok": False, "error": str(exc)}) - else: - print(f"Error: {exc}", file=sys.stderr) - if isinstance(exc, CommandError): - if exc.outcome.stdout: - print(exc.outcome.stdout.rstrip(), file=sys.stderr) - if exc.outcome.stderr: - print(exc.outcome.stderr.rstrip(), file=sys.stderr) - return 1 - except BaseException as exc: - if telemetry is not None: - telemetry.report_exception( - "launcher_exception", - exc, - metadata={"command": command}, - context={"operation": command, "stage": "dispatch"}, - severity="critical", - ) - _report_command_failure( - telemetry, - telemetry_events, - command, - operation_started, - str(exc), - stage="dispatch", - ) - raise - else: - _report_command_result( - telemetry, - telemetry_events, - command, - operation_started, - returncode, - ) - return returncode - finally: - if telemetry is not None and telemetry_owner: - shutdown_shared_telemetry(telemetry) - - -def _dispatch_command( - parser: argparse.ArgumentParser, - arguments: argparse.Namespace, - command: str, - paths: AppPaths, -) -> int: - if command == "gui": - provision_umu_for_startup(paths) - _container_host_startup_migration(paths) - from .ui import run_dashboard - - run_dashboard(paths) - return 0 - if command == "doctor": - return _doctor(arguments.json) - if command == "container-host": - return _container_host(arguments.repair, arguments.json) - if command == "runtime-bootstrap": - return _runtime_bootstrap(paths, arguments.json) - if command == "login-diagnostics": - return _login_diagnostics( - paths, - arguments.timeout, - arguments.runtime, - arguments.json, - ) - if command == "fetch": - return _fetch(paths, arguments.dry_run, arguments.json) - if command == "setup": - return _setup( - paths, - arguments.dry_run, - arguments.force_runtime, - arguments.json, - ) - if command == "update": - return _native_update( - paths, - arguments.dry_run, - arguments.repair, - arguments.seed_from, - arguments.no_import, - arguments.json, - ) - if command == "sync": - return _native_update( - paths, - arguments.dry_run, - arguments.repair, - None, - True, - arguments.json, - ) - if command == "wine-update": - return _wine_update( - paths, - arguments.dry_run, - arguments.force_runtime, - arguments.seed_from, - arguments.no_import, - arguments.json, - ) - if command == "import-game": - return _import_game( - paths, - arguments.source, - arguments.dry_run, - arguments.json, - ) - if command == "cache-compatibility": - result = cache_compatibility_binaries(paths, arguments.source) - _emit({"ok": True, "compatibility": asdict(result)}, arguments.json) - return 0 - if command == "prepare": - return _prepare(paths, arguments.game_dir, arguments.json) - if command == "play": - return _play( - paths, - arguments.game_dir, - arguments.no_box, - arguments.dry_run, - arguments.skip_runtime_setup, - arguments.json, - realm=arguments.realm, - ) - if command == "login-trace": - return _login_trace( - paths, - arguments.game_dir, - arguments.no_box, - arguments.skip_runtime_setup, - arguments.timeout, - arguments.json, - ) - if command == "settings": - return _settings(paths, arguments.init, arguments.reset, arguments.json) - if command == "launcher-update": - return _launcher_update( - paths, - arguments.check, - arguments.verify, - arguments.rollback, - arguments.manifest_url, - arguments.public_key, - arguments.json, - history_url=arguments.history_url, - ) - parser.error(f"unknown command: {command}") - - -def _command_telemetry_events( - command: str, - arguments: argparse.Namespace, - paths: AppPaths, -) -> tuple[str, str, str, str] | None: - if bool(getattr(arguments, "dry_run", False)): - return None - if command in {"update", "sync", "wine-update"}: - if bool(getattr(arguments, "repair", False)): - return ("repair", "repair_started", "repair_success", "repair_failed") - if not paths.source_game.exists(): - return ("install", "install_started", "install_success", "install_failed") - return ("update", "update_started", "update_success", "update_failed") - if command == "launcher-update" and not ( - bool(getattr(arguments, "check", False)) - or bool(getattr(arguments, "verify", False)) - or getattr(arguments, "rollback", None) is not None - ): - return ( - "launcher_update", - "update_started", - "update_success", - "update_failed", - ) - if command in {"setup", "runtime-bootstrap"}: - return ( - "runtime_install", - "runtime_install_started", - "runtime_install_success", - "runtime_install_failed", - ) - if command == "doctor": - return ( - "compatibility_check", - "compatibility_check_started", - "compatibility_check_completed", - "compatibility_check_failed", - ) - return None - - -def _report_command_result( - telemetry: TelemetryService | None, - events: tuple[str, str, str, str] | None, - command: str, - started_at: float, - returncode: int, -) -> None: - if telemetry is None or events is None: - return - operation, _started_event, success_event, failed_event = events - duration_ms = round((time.monotonic() - started_at) * 1000) - telemetry.send_event( - success_event if returncode == 0 else failed_event, - severity="info" if returncode == 0 else "error", - message=None if returncode == 0 else f"Command exited with status {returncode}", - metadata={ - "command": command, - "duration_ms": duration_ms, - "exit_code": returncode, - }, - context={ - "operation": operation, - "stage": "completed" if returncode == 0 else "command_exit", - }, - ) - - -def _report_command_failure( - telemetry: TelemetryService | None, - events: tuple[str, str, str, str] | None, - command: str, - started_at: float, - message: str, - *, - stage: str = "failed", -) -> None: - if telemetry is None: - return - if command == "play": - telemetry.send_event( - "game_launch_failed", - severity="error", - message=message, - metadata={ - "duration_ms": round((time.monotonic() - started_at) * 1000), - }, - context={"operation": "launch_game", "stage": "preflight"}, - ) - return - if events is None: - return - operation, _started_event, _success_event, failed_event = events - telemetry.send_event( - failed_event, - severity="error", - message=message, - metadata={ - "command": command, - "duration_ms": round((time.monotonic() - started_at) * 1000), - }, - context={"operation": operation, "stage": stage}, - ) - - -def _failure_stage(command: str, exception: BaseException) -> str: - if isinstance(exception, ValueError): - return "configuration" - if isinstance(exception, CommandError): - return "subprocess" - message = str(exception).casefold() - if any(value in message for value in ("manifest", "cdn", "request failed")): - return "manifest" - if any(value in message for value in ("download", "incomplete response")): - return "download" - if any(value in message for value in ("sha-1", "checksum", "hash mismatch")): - return "validation" - if any(value in message for value in ("permission", "read-only", "cannot write")): - return "filesystem" - if any(value in message for value in ("prefix", "wine", "proton", "umu")): - return "runtime_check" - return f"{command}_operation" - - -def _doctor(as_json: bool) -> int: - report = inspect_host() - if as_json: - _print_json(report.to_dict()) - else: - print(f"Distribution: {report.distribution} {report.version} ({report.architecture})") - print(f"Display: {report.display_backend}") - for tool in report.tools: - status = tool.path or "MISSING" - required = "required" if tool.required else "optional" - print(f"{tool.name:12} {status} [{required}] — {tool.purpose}") - optional_missing = [ - tool.name for tool in report.tools if not tool.required and tool.path is None - ] - if optional_missing: - print( - "Optional tools missing (these do not block launch): " - + ", ".join(optional_missing) - ) - print(f"Host readiness: {'READY' if report.ok else 'BLOCKED'}") - if report.blockers: - print("Blocking diagnostics:") - for blocker in report.blockers: - print(f" - {blocker}") - for warning in report.warnings: - print(f"Warning: {warning}") - if report.package_hint and any( - tool.required and tool.path is None for tool in report.tools - ): - print(report.package_hint) - return 0 if report.ok else 1 - - -def _container_host(repair: bool, as_json: bool) -> int: - report = repair_container_host() if repair else inspect_container_host() - ok = _container_host_ok(report) - payload = {"ok": ok, "container_host": report.to_dict()} - if as_json: - _print_json(payload) - else: - status = "ready" if report.ready else "blocked" - print(f"Bubblewrap: {status} ({report.bwrap_path or 'not found'})") - if report.active_profile_source: - print(f"AppArmor: {report.active_profile_source}") - elif report.apparmor_restrict_unprivileged_userns == "1": - print( - "AppArmor: unprivileged user namespaces are restricted; " - "no bwrap profile found" - ) - if report.repair_actions: - for action in report.repair_actions: - print(f"Repair: {action}") - if report.repair_error: - print(f"Repair failed: {report.repair_error}", file=sys.stderr) - elif report.recommendation: - print(f"Action: {report.recommendation}", file=sys.stderr) - return 0 if ok else 1 - - -def _container_host_ok(report: ContainerHostReport) -> bool: - return ( - report.ready - and not report.broken_umu_profile_link - and report.repair_error is None - ) - - -def _container_host_startup_migration(paths: AppPaths) -> None: - report = repair_container_host_after_update(paths) - if report.repair_attempted and not _container_host_ok(report): - print(f"Warning: {container_host_error(report)}", file=sys.stderr) - - -def _runtime_bootstrap(paths: AppPaths, as_json: bool) -> int: - _print_operation_status(as_json, 1, 1, "Preparing the Steam Runtime helper…") - result = provision_umu(paths) - executable = find_umu_launcher(paths) - if executable is None: - raise LauncherError("the Steam Runtime helper is still unavailable") - _emit( - { - "ok": True, - "runtime_bootstrap": { - "executable": executable, - "managed_download": asdict(result) if result else None, - }, - }, - as_json, - ) - return 0 - - -def _login_diagnostics( - paths: AppPaths, - timeout_seconds: float, - runtime_choice: str, - as_json: bool, -) -> int: - settings = _load_or_default_settings(paths, persist=False) - runner = _runner(paths, "login-diagnostics", False) - with _operation_guard(paths, "login-diagnostics", False): - report = inspect_login_environment( - paths, - settings, - timeout_seconds=timeout_seconds, - runner=runner, - runtime_override=( - None if runtime_choice == "configured" else runtime_choice - ), - ) - if as_json: - _print_json(report.to_dict()) - else: - probe = report.login_server - print(f"Launcher: {report.launcher_version} ({report.launcher_module})") - print(f"Python: {report.python_executable}") - print(f"Host: {report.distribution_id} ({report.kernel_release})") - print( - "Runtime test: " - f"{report.runtime_requested} " - f"(configured: {report.runtime_configured}; settings unchanged)" - ) - status = "REACHABLE" if probe.reachable else "UNREACHABLE" - latency = f", {probe.latency_ms:.1f} ms" if probe.latency_ms is not None else "" - print(f"Login server: {status} ({probe.host}:{probe.port}{latency})") - if probe.error: - print(f"Network error: {probe.error}") - print("Authentication: not tested; no credentials or game-protocol data were sent") - print(f"Login responses: {report.login_response_ownership}") - identity_status = ( - "ready" - if report.client_identity_ready is True - else "NOT READY" - if report.client_identity_ready is False - else "not probed" - ) - print(f"Client identity: {identity_status}; raw identity values were not collected") - print(f"Privacy: {report.client_identity_privacy}") - if not report.login_trace_enabled: - print("Login trace: retired (will not launch or instrument the game)") - print(f"Publisher test: {report.publisher_comparison_command}") - print(f"Game profile: {report.game_profile}") - print(f"Game.exe: {report.game_exe}") - if report.game_exe_sha256: - print(f"Game SHA-256: {report.game_exe_sha256}") - print(f"game.dll: {report.game_dll}") - if report.game_dll_sha256: - print(f"DLL SHA-256: {report.game_dll_sha256}") - print( - "Managed source: " - + ("matches runtime" if report.runtime_matches_managed_source else "DIFFERS") - ) - if report.depot_build is not None: - print( - f"Depot: build {report.depot_build} " - f"({report.depot_version or 'unknown version'})" - ) - if report.runtime_kind: - print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})") - else: - print(f"Runtime: unavailable ({report.runtime_error or 'selection failed'})") - if report.proton_sync_effective is not None: - print( - "Proton sync: " - f"{report.proton_sync_configured} -> {report.proton_sync_effective}" - ) - prefix_status = "ready" if report.prefix_ready else "not ready" - directx_status = "ready" if report.directx_ready else "not ready" - print(f"Prefix: {prefix_status}; DirectX helpers {directx_status}") - print(f"Prefix path: {report.prefix or 'unavailable'}") - print(f"Latest log: {report.latest_game_log or 'not found'}") - if report.latest_game_log_modified_at: - print(f"Log modified: {report.latest_game_log_modified_at}") - for warning in report.warnings: - print(f"Warning: {warning}") - return 0 if report.preflight_ok else 1 - - -def _fetch(paths: AppPaths, dry_run: bool, as_json: bool) -> int: - if not dry_run: - paths.create_base_directories() - if dry_run: - value = { - "ok": True, - "dry_run": True, - "url": OFFICIAL_LAUNCHER_URL, - "download": str(paths.launcher_pristine), - "prepared": str(paths.launcher_patched), - } - else: - telemetry = current_telemetry() - download_started_at = time.monotonic() - if telemetry is not None: - telemetry.send_event( - "download_started", - metadata={ - "resource_type": "launcher", - "mirror_id": "pristontale.eu", - }, - context={"operation": "download", "stage": "launcher_download"}, - ) - try: - download = fetch_official_launcher( - paths.launcher_pristine, - paths.launcher_metadata, - ) - except BaseException as exc: - if telemetry is not None: - telemetry.send_event( - "download_failed", - severity="error", - message=str(exc), - metadata={ - "resource_type": "launcher", - "duration_ms": round( - (time.monotonic() - download_started_at) * 1000 - ), - "failure_type": classify_network_failure(exc), - "http_status": network_http_status(exc), - "mirror_id": "pristontale.eu", - }, - context={"operation": "download", "stage": "launcher_download"}, - ) - raise - download_duration = max(0.0, time.monotonic() - download_started_at) - download_bytes = download.size if download.changed else 0 - if telemetry is not None: - telemetry.send_event( - "download_completed", - metadata={ - "resource_type": "launcher", - "download_bytes": download_bytes, - "content_bytes": download.size, - "duration_ms": round(download_duration * 1000), - "average_mbps": ( - round((download_bytes * 8) / download_duration / 1_000_000, 3) - if download_bytes and download_duration > 0 - else None - ), - "retry_count": 0, - "resume_used": False, - "http_status": 200 if download.changed else 304, - "changed": download.changed, - "mirror_id": "pristontale.eu", - }, - context={"operation": "download", "stage": "launcher_download"}, - ) - validation_started_at = time.monotonic() - try: - patch = prepare_launcher_binary( - paths.launcher_pristine, - paths.launcher_patched, - ) - except BaseException as exc: - if telemetry is not None: - telemetry.send_event( - "file_validation_failed", - severity="error", - message=str(exc), - metadata={ - "file_category": "launcher", - "duration_ms": round( - (time.monotonic() - validation_started_at) * 1000 - ), - }, - context={"operation": "download", "stage": "validation"}, - ) - raise - value = { - "ok": True, - "download": asdict(download), - "patch": asdict(patch), - "warning": None if patch.supported_profile else ( - "The launcher is newer than the verified font-patch profile; " - "it was retained byte-for-byte." - ), - } - _emit(value, as_json) - return 0 - - -def _setup( - paths: AppPaths, - dry_run: bool, - force_runtime: bool, - as_json: bool, -) -> int: - tools = find_wine_tools() - if tools is None: - raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor") - runner = _runner(paths, "setup", dry_run) - with _operation_guard(paths, "setup", dry_run): - results = _configure_launcher_runtime( - paths, - tools, - runner, - force_runtime=force_runtime, - ) - _emit( - { - "ok": True, - "dry_run": dry_run, - "prefix": str(paths.launcher_prefix), - "commands": [asdict(result) for result in results], - }, - as_json, - ) - return 0 - - -def _native_update( - paths: AppPaths, - dry_run: bool, - repair: bool, - seed_from: Path | None, - no_import: bool, - as_json: bool, -) -> int: - """Use the native verified depot client for normal installs and updates.""" - - settings = _load_or_default_settings(paths, persist=not dry_run) - download_options = _configured_download_options(settings) - runner = _runner(paths, "native-update", dry_run) - with _operation_guard(paths, "native-update", dry_run): - _print_operation_status(as_json, 1, 3, "Checking local game files…") - game_import = None - if not no_import: - game_import = _maybe_import_game( - paths, - settings, - seed_from, - runner, - announce=not as_json, - ) - verify_import = game_import is not None or game_import_requires_verification(paths) - _print_operation_status(as_json, 2, 3, "Synchronizing official game files…") - result = sync_managed_game( - paths, - repair=repair or verify_import, - quarantine_unmanaged=verify_import, - dry_run=dry_run, - progress=None if as_json else _print_sync_progress, - **( - {"download_options": download_options} - if download_options is not None - else {} - ), - ) - if verify_import and not dry_run: - mark_game_import_verified( - paths, - build=result.build, - files_quarantined=result.files_quarantined, - quarantine_directory=result.quarantine_directory, - ) - overlay = None - if not dry_run and paths.source_game.is_dir(): - _print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…") - overlay = asdict(prepare_runtime_view(paths, paths.source_game)) - _emit( - { - "ok": True, - "dry_run": dry_run, - "game_import": asdict(game_import) if game_import else None, - "native_update": asdict(result), - "runtime_view": overlay, - }, - as_json, - ) - return 0 - - -def _wine_update( - paths: AppPaths, - dry_run: bool, - force_runtime: bool, - seed_from: Path | None, - no_import: bool, - as_json: bool, -) -> int: - _fetch(paths, dry_run, True) - tools = find_wine_tools() - if tools is None: - raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor") - runner = _runner(paths, "update", dry_run) - settings = _load_or_default_settings(paths, persist=not dry_run) - download_options = _configured_download_options(settings) - with _operation_guard(paths, "update", dry_run): - _configure_launcher_runtime( - paths, - tools, - runner, - force_runtime=force_runtime, - ) - game_import = None - if not no_import: - game_import = _maybe_import_game( - paths, - settings, - seed_from, - runner, - announce=not as_json, - ) - updater = launch_official_updater(paths, tools, runner) - native_verification = None - verify_import = not dry_run and ( - game_import is not None or game_import_requires_verification(paths) - ) - if verify_import: - native_verification = sync_managed_game( - paths, - repair=True, - quarantine_unmanaged=True, - progress=None if as_json else _print_sync_progress, - **( - {"download_options": download_options} - if download_options is not None - else {} - ), - ) - mark_game_import_verified( - paths, - build=native_verification.build, - files_quarantined=native_verification.files_quarantined, - quarantine_directory=native_verification.quarantine_directory, - ) - overlay = None - if not dry_run and paths.source_game.is_dir(): - overlay = asdict(prepare_runtime_view(paths, paths.source_game)) - value = { - "ok": True, - "dry_run": dry_run, - "game_import": asdict(game_import) if game_import else None, - "updater": asdict(updater), - "native_verification": ( - asdict(native_verification) if native_verification is not None else None - ), - "runtime_view": overlay, - } - _emit(value, as_json) - return 0 - - -def _configure_launcher_runtime( - paths: AppPaths, - tools: WineTools, - runner: CommandRunner, - *, - force_runtime: bool, -) -> list[CommandOutcome]: - if not force_runtime or runner.dry_run: - return configure_launcher_prefix( - paths, - tools, - runner, - force_runtime=force_runtime, - ) - telemetry = current_telemetry() - started_at = time.monotonic() - metadata = { - "runtime_type": "dotnet_desktop", - "runtime_version": "6", - } - if telemetry is not None: - telemetry.send_event( - "runtime_reinstall_requested", - metadata={**metadata, "reason": "force_runtime"}, - context={"operation": "runtime_repair", "stage": "requested"}, - ) - telemetry.send_event( - "runtime_repair_started", - metadata=metadata, - context={"operation": "runtime_repair", "stage": "started"}, - ) - try: - results = configure_launcher_prefix( - paths, - tools, - runner, - force_runtime=True, - ) - except BaseException as exc: - if telemetry is not None: - telemetry.send_event( - "runtime_repair_failed", - severity="error", - message=str(exc), - metadata={ - **metadata, - "duration_ms": round((time.monotonic() - started_at) * 1000), - }, - context={"operation": "runtime_repair", "stage": "runtime_install"}, - ) - raise - if telemetry is not None: - telemetry.send_event( - "runtime_repair_success", - metadata={ - **metadata, - "duration_ms": round((time.monotonic() - started_at) * 1000), - }, - context={"operation": "runtime_repair", "stage": "completed"}, - ) - return results - - -def _print_sync_progress(progress: SyncProgress) -> None: - """Emit line-delimited progress that the native Tk dashboard can consume.""" - - print(f"PROGRESS {json.dumps(asdict(progress), sort_keys=True)}", flush=True) - - -def _configured_download_options(settings: Settings) -> DownloadOptions | None: - if ( - settings.download_speed_limit_bytes_per_second is None - and not settings.low_bandwidth_mode - ): - return None - return DownloadOptions( - bandwidth_limit_bytes_per_second=( - settings.download_speed_limit_bytes_per_second - ), - low_bandwidth=settings.low_bandwidth_mode, - ) - - -def _print_operation_status( - as_json: bool, - step: int, - total: int, - message: str, -) -> None: - """Emit a structured operation stage for the native launcher dashboard.""" - - if not as_json: - print( - f"STATUS {json.dumps({'step': step, 'total': total, 'message': message})}", - flush=True, - ) - - -def _import_game( - paths: AppPaths, - source: Path, - dry_run: bool, - as_json: bool, -) -> int: - runner = _runner(paths, "import-game", dry_run) - if not as_json: - print(f"Importing existing game from {source.expanduser()}…", flush=True) - with _operation_guard(paths, "import-game", dry_run): - result = _import_with_telemetry(paths, source, runner) - _emit({"ok": True, "dry_run": dry_run, "game_import": asdict(result)}, as_json) - return 0 - - -def _maybe_import_game( - paths: AppPaths, - settings: Settings, - seed_from: Path | None, - runner: CommandRunner, - *, - announce: bool, -) -> GameImportResult | None: - if paths.source_game.exists() or paths.source_game.is_symlink(): - if seed_from is not None: - raise RuntimeError( - f"cannot seed an existing managed game directory: {paths.source_game}" - ) - return None - candidate = seed_from - if candidate is None: - candidate = discover_existing_game(paths, settings) - if candidate is None: - return None - if announce: - print(f"Bootstrapping from existing game: {candidate}", flush=True) - return _import_with_telemetry(paths, candidate, runner) - - -def _import_with_telemetry( - paths: AppPaths, - source: Path, - runner: CommandRunner, -) -> GameImportResult: - telemetry = current_telemetry() - started_at = time.monotonic() - if telemetry is not None and not runner.dry_run: - telemetry.send_event( - "import_started", - context={"operation": "import", "stage": "copy"}, - ) - try: - result = import_existing_game(paths, source, runner) - except BaseException as exc: - if telemetry is not None and not runner.dry_run: - telemetry.send_event( - "import_failed", - severity="error", - message=str(exc), - metadata={ - "duration_ms": round((time.monotonic() - started_at) * 1000), - }, - context={"operation": "import", "stage": "copy"}, - ) - raise - if telemetry is not None and not runner.dry_run: - telemetry.send_event( - "import_success", - metadata={ - "files_imported": result.files, - "bytes_imported": result.bytes, - "duration_ms": round((time.monotonic() - started_at) * 1000), - }, - context={"operation": "import", "stage": "completed"}, - ) - return result - - -def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: - with _operation_guard(paths, "prepare", False): - if game_dir is None and game_import_requires_verification(paths): - raise LauncherError( - "the imported game has not completed its full publisher verification; " - "run pristontale-eu update" - ) - settings = ensure_settings(paths) - source = _source_game(paths, settings, game_dir) - result = prepare_runtime_view(paths, source) - _emit({"ok": True, "runtime_view": asdict(result)}, as_json) - return 0 - - -def _play( - paths: AppPaths, - game_dir: Path | None, - no_box: bool, - dry_run: bool, - skip_runtime_setup: bool, - as_json: bool, - *, - realm: str | None = None, -) -> int: - with _play_guard(paths, dry_run) as play_lease: - return _play_locked( - paths, - game_dir, - no_box, - dry_run, - skip_runtime_setup, - as_json, - realm=realm, - reuse_prepared=not play_lease.preparation_allowed, - before_game=play_lease.allow_shared_clients, - game_lease_fds=play_lease.inherited_fds, - ) - - -def _play_locked( - paths: AppPaths, - game_dir: Path | None, - no_box: bool, - dry_run: bool, - skip_runtime_setup: bool, - as_json: bool, - *, - realm: str | None = None, - reuse_prepared: bool = False, - before_game: Callable[[], None] | None = None, - game_lease_fds: Sequence[int] = (), -) -> int: - telemetry = current_telemetry() - launch_requested_at = time.monotonic() - window_diagnostics_active = ( - telemetry is not None - and not dry_run - and bool(getattr(telemetry, "enabled", True)) - ) - launch_session_id = uuid4().hex if window_diagnostics_active else None - launch_session_metadata = ( - {"launch_session_id": launch_session_id} - if launch_session_id is not None - else {} - ) - if telemetry is not None and not dry_run: - telemetry.send_event( - "game_launch_requested", - metadata=launch_session_metadata, - context={"operation": "launch_game", "stage": "preflight"}, - ) - if game_dir is None and game_import_requires_verification(paths): - raise LauncherError( - "the imported game has not completed its full publisher verification; " - "run pristontale-eu update before playing" - ) - settings = _load_or_default_settings(paths, persist=not dry_run) - launcher_nested_in_steam_runtime = is_running_inside_steam_runtime() - if launcher_nested_in_steam_runtime and not dry_run: - print( - "Warning: the launcher itself is running inside Steam Runtime. Start " - "it from the desktop menu or a host terminal; nesting its UMU runtime " - "can cause black game output.", - file=sys.stderr, - flush=True, - ) - window_launch_metadata: dict[str, object] = { - "gamebox_enabled": not no_box, - "resizable_window_requested": settings.allow_window_resizing, - "launcher_inside_steam_runtime": launcher_nested_in_steam_runtime, - } - if ( - launcher_nested_in_steam_runtime - and settings.allow_window_resizing - and not no_box - ): - window_launch_metadata["gamescope_disabled_reason"] = ( - "launcher_inside_steam_runtime" - ) - start_option = select_depot_start_option( - read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache), - realm or settings.realm, - ) - source = _source_game(paths, settings, game_dir) - overlay = None - depot_file_catalog = None - depot_preflight = None - if game_dir is None and not dry_run: - depot_file_catalog = read_current_depot_file_catalog(paths) - _print_operation_status( - as_json, - 1, - 4, - ( - "Reusing the active managed game view…" - if reuse_prepared - else "Preparing the managed game view…" - ), - ) - if reuse_prepared: - if game_dir is not None: - raise LauncherError( - "an additional client cannot replace the active managed game view; " - "close every client before using --game-dir" - ) - if not paths.runtime_view.is_dir(): - raise LauncherError( - "another game client is active, but the prepared runtime view is missing" - ) - elif not dry_run: - if game_dir is None: - if depot_file_catalog is not None: - depot_preflight = preflight_managed_game( - paths, - catalog=depot_file_catalog, - ) - if depot_preflight is not None and not depot_preflight.ready: - details = "; ".join( - f"{issue.path}: {issue.reason}" - for issue in depot_preflight.issues[:3] - ) - raise LauncherError( - "the managed game integrity check found " - f"{depot_preflight.issue_count} missing or changed file(s)" - + (f" ({details})" if details else "") - + "; run `pristontale-eu update --repair` before playing" - ) - overlay = prepare_runtime_view(paths, source) - _print_operation_status(as_json, 2, 4, "Ensuring the compatibility runtime is available…") - umu_bootstrap = ( - None - if reuse_prepared - else _ensure_portable_umu_for_play(paths, settings, dry_run=dry_run) - ) - selection = select_runtime(settings, paths=paths) - runner = _runner(paths, "play", dry_run) - setup_results = [] - if reuse_prepared: - _print_operation_status( - as_json, - 3, - 4, - "Reusing the active Proton and DirectX runtime…", - ) - if not game_runtime_ready(paths, selection): - raise LauncherError( - "another game client is active, but its prepared runtime no longer " - "matches the current settings; close every client and launch again" - ) - elif not skip_runtime_setup: - _print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…") - setup_results = prepare_game_runtime(paths, selection, runner) - else: - _print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…") - _print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…") - native_log_directory = paths.game_save / "logs" - native_log_baseline = snapshot_native_logs( - native_log_directory, - patterns=("log_*.txt",), - ) - crash_directory = paths.game_save / "crash" - crash_baseline = snapshot_native_logs( - crash_directory, - patterns=("*.dat", "*.dmp"), - ) - game_started_ns = time.time_ns() - game_started_at = time.monotonic() - launch_success_lock = threading.Lock() - launch_success_reported = False - reported_runtime_warnings: set[str] = set() - if telemetry is not None and not dry_run: - telemetry.send_event( - "game_launch_started", - metadata={ - **launch_session_metadata, - **window_launch_metadata, - "launch_duration_ms": round( - (game_started_at - launch_requested_at) * 1000 - ), - "launch_stage": "game_start", - "runtime": selection.kind, - "realm": start_option.name, - }, - context={"operation": "launch_game", "stage": "game_start"}, - ) - if before_game is not None: - before_game() - managed_asset_paths = ( - [entry.path for entry in depot_file_catalog.files] - if depot_file_catalog is not None - else None - ) - - def observe_game_output(line: str) -> str | None: - """Timestamp launch success when the supervisor first confirms readiness.""" - - nonlocal launch_success_reported - presentation_failure = _gamescope_presentation_failure(line) - if telemetry is not None and not dry_run and presentation_failure is not None: - with launch_success_lock: - if presentation_failure not in reported_runtime_warnings: - reported_runtime_warnings.add(presentation_failure) - telemetry.send_event( - "game_window_diagnostic", - severity="warning", - metadata={ - **launch_session_metadata, - **window_launch_metadata, - "component": "gamescope_host", - "diagnostic": presentation_failure, - "runtime": selection.kind, - "realm": start_option.name, - }, - context={ - "operation": "launch_game", - "stage": "presentation", - }, - ) - window_diagnostic = parse_window_diagnostic(line) - if window_diagnostic is not None: - diagnostic_session_id = window_diagnostic.get("session_id") - if launch_session_id is None or diagnostic_session_id != launch_session_id: - return line - if telemetry is not None and window_diagnostics_active: - with launch_success_lock: - telemetry.send_event( - "game_window_diagnostic", - severity=_window_diagnostic_severity(window_diagnostic), - metadata={ - "runtime": selection.kind, - "realm": start_option.name, - **window_diagnostic, - }, - context={ - "operation": "launch_game", - "stage": "window_diagnostics", - }, - ) - return None - live_readiness_signal = _game_readiness_signal(line) - if telemetry is None or dry_run or live_readiness_signal is None: - return line - with launch_success_lock: - if launch_success_reported: - return line - ready_at = time.monotonic() - launch_success_reported = telemetry.send_event( - "game_launch_success", - metadata={ - **launch_session_metadata, - **window_launch_metadata, - "launch_duration_ms": round( - (ready_at - launch_requested_at) * 1000 - ), - "game_start_duration_ms": round( - (ready_at - game_started_at) * 1000 - ), - "game_window_ready": True, - "readiness_signal": live_readiness_signal, - "runtime": selection.kind, - "realm": start_option.name, - }, - context={"operation": "launch_game", "stage": "ready"}, - ) - return line - - try: - outcome = run_game( - paths, - settings, - selection, - runner, - start_option=start_option, - use_gamebox=not no_box, - concurrent_prefix_launch=reuse_prepared, - check=False, - output_line_filter=observe_game_output, - extra_environment=( - { - WINDOW_DIAGNOSTICS_ENV: "1", - WINDOW_DIAGNOSTICS_SESSION_ENV: launch_session_id, - } - if launch_session_id is not None - else None - ), - pass_fds=game_lease_fds, - ) - except KeyboardInterrupt: - if telemetry is not None and not dry_run: - telemetry.send_event( - "game_exited", - severity="warning", - message="Game launch was interrupted", - metadata={ - **launch_session_metadata, - **window_launch_metadata, - "runtime_seconds": round(time.monotonic() - game_started_at), - "clean_exit": False, - "session_duration_class": _session_duration_class( - time.monotonic() - game_started_at - ), - }, - context={"operation": "launch_game", "stage": "running"}, - ) - current_game_log = _current_game_log( - native_log_directory, - native_log_baseline, - game_started_ns, - managed_asset_paths=managed_asset_paths, - source_game=source if managed_asset_paths is not None else None, - ) - if current_game_log is not None and not as_json: - _print_interrupted_game_log(current_game_log) - raise - current_game_log = _current_game_log( - native_log_directory, - native_log_baseline, - game_started_ns, - managed_asset_paths=managed_asset_paths, - source_game=source if managed_asset_paths is not None else None, - ) - current_game_crash = _current_game_crash( - crash_directory, - crash_baseline, - game_started_ns, - ) - runtime_issues = _runtime_issue_summary(outcome.stderr) - game_window_ready = runtime_issues["game_window_ready"] is True - if ( - selection.kind == "proton7-container" - and runtime_issues["host_futex_fatal_count"] - ): - runtime_issues["recommended_one_variable_comparison"] = "proton7" - elif selection.kind == "proton7" and runtime_issues["gstreamer_missing_libraries"]: - runtime_issues["recommended_one_variable_comparison"] = "proton7-container" - elif runtime_issues["dbghelp_symdeferred_assertion_count"]: - runtime_issues["recommended_one_variable_comparison"] = "umu" - clean_exit = outcome.returncode == 0 and current_game_crash is None - launch_succeeded = ( - clean_exit or game_window_ready or current_game_log is not None - ) - report = { - "ok": clean_exit, - "launch_succeeded": launch_succeeded, - "dry_run": dry_run, - "runtime": asdict(selection), - "umu_bootstrap": asdict(umu_bootstrap) if umu_bootstrap else None, - "realm": asdict(start_option), - "runtime_view": asdict(overlay) if overlay else None, - "depot_preflight": ( - { - **asdict(depot_preflight), - "ready": depot_preflight.ready, - "fully_verified": depot_preflight.fully_verified, - } - if depot_preflight - else None - ), - "setup_commands": [asdict(result) for result in setup_results], - "game": asdict(outcome), - "current_game_log": current_game_log, - "current_game_crash": current_game_crash, - "runtime_issues": runtime_issues, - } - game_runtime_seconds = max(0.0, time.monotonic() - game_started_at) - if telemetry is not None and not dry_run: - common_game_metadata: dict[str, object] = { - **launch_session_metadata, - **window_launch_metadata, - "runtime_seconds": round(game_runtime_seconds), - "clean_exit": clean_exit, - "timed_out": outcome.timed_out, - "launch_succeeded": launch_succeeded, - "game_window_ready": game_window_ready, - "crash_dump": current_game_crash is not None, - "session_duration_class": _session_duration_class(game_runtime_seconds), - "runtime": selection.kind, - "realm": start_option.name, - **_game_exit_metadata(outcome.returncode, selection.kind), - } - if launch_succeeded: - if not launch_success_reported: - readiness_signal = _game_readiness_signal(outcome.stderr) - if readiness_signal is None: - readiness_signal = ( - "game_log" - if current_game_log is not None - else "clean_process_exit" - ) - telemetry.send_event( - "game_launch_success", - metadata={ - **launch_session_metadata, - **window_launch_metadata, - "detection_duration_ms": round(game_runtime_seconds * 1000), - "detected_after_exit": True, - "game_window_ready": game_window_ready, - "readiness_signal": readiness_signal, - "runtime": selection.kind, - "realm": start_option.name, - }, - context={"operation": "launch_game", "stage": "post_exit"}, - ) - else: - telemetry.send_event( - "game_launch_failed", - severity="error", - message=( - "Game client wrote a crash dump" - if current_game_crash is not None - else "Game session timed out" - if outcome.timed_out - else f"Game client exited with status {outcome.returncode}" - ), - metadata={ - **common_game_metadata, - "runtime_issues": runtime_issues, - "stdout": outcome.stdout, - "stderr": outcome.stderr, - }, - context={"operation": "launch_game", "stage": "game_start"}, - ) - if launch_succeeded: - exit_message = None - if current_game_crash is not None: - exit_message = "Game client wrote a crash dump" - elif outcome.timed_out: - exit_message = "Game session timed out" - elif common_game_metadata.get("signal_inferred") is True: - exit_message = ( - "Game session ended with status " - f"{common_game_metadata['raw_exit_code']}, consistent with " - f"{common_game_metadata['signal_name']} wrapped by UMU" - ) - elif "signal" in common_game_metadata: - exit_message = ( - "Game session was terminated by " - f"{common_game_metadata['signal_name']}" - ) - elif outcome.returncode not in {0, None}: - exit_message = f"Game session ended with status {outcome.returncode}" - telemetry.send_event( - "game_exited", - severity=( - "error" - if current_game_crash is not None - else "info" if clean_exit else "warning" - ), - message=exit_message, - metadata=( - common_game_metadata - if clean_exit - else { - **common_game_metadata, - "runtime_issues": runtime_issues, - "stdout": outcome.stdout, - "stderr": outcome.stderr, - } - ), - context={"operation": "launch_game", "stage": "exited"}, - ) - if as_json or dry_run: - _emit(report, as_json) - else: - _print_game_outcome( - outcome, - current_game_log, - runtime_issues, - current_game_crash, - ) - return 0 if outcome.returncode == 0 and current_game_crash is None else 1 - - -def _session_duration_class(duration_seconds: float) -> str: - if duration_seconds < 30: - return "under_30_seconds" - if duration_seconds < 5 * 60: - return "under_5_minutes" - return "normal" - - -def _game_exit_metadata( - returncode: int | None, - runtime_kind: str, -) -> dict[str, object]: - metadata: dict[str, object] = {"exit_code": returncode} - if returncode is None: - return metadata - if returncode < 0: - signal_number = -returncode - metadata.update( - { - "exit_code": 128 + min(signal_number, 127), - "raw_exit_code": returncode, - "signal": signal_number, - "signal_name": _signal_name(signal_number), - } - ) - return metadata - - # UMU returns Popen's negative child status from a Python main function. - # SystemExit encodes -SIGKILL as 247 before our Python gamebox sees it. - wrapped_sigkill = 256 - int(signal.SIGKILL) - if runtime_kind in {"umu", "proton7-container"} and returncode == wrapped_sigkill: - metadata.update( - { - "exit_code": 128 + int(signal.SIGKILL), - "raw_exit_code": returncode, - "signal": int(signal.SIGKILL), - "signal_name": signal.SIGKILL.name, - "signal_inferred": True, - "signal_inference": "umu_python_system_exit", - } - ) - return metadata - - -def _signal_name(signal_number: int) -> str: - try: - return signal.Signals(signal_number).name - except ValueError: - return f"signal {signal_number}" - - -def _ensure_portable_umu_for_play( - paths: AppPaths, - settings: Settings, - *, - dry_run: bool, -) -> UmuBootstrapResult | None: - """Provide a pinned UMU when the selected policy may use a container.""" - - if settings.runtime == "proton7": - return None - if dry_run: - return None - try: - return provision_umu(paths) - except (OSError, RuntimeError) as exc: - if settings.runtime != "auto": - raise LauncherError( - f"the verified portable UMU runtime helper could not be installed: {exc}" - ) from exc - try: - select_runtime(replace(settings, runtime="proton7"), paths=paths) - except RuntimeErrorPT: - raise LauncherError( - "no usable game runtime is available and the verified portable UMU " - f"runtime helper could not be installed: {exc}" - ) from exc - print( - "Warning: the Steam Runtime helper could not be installed; falling back " - f"to direct Proton 7 for this launch ({exc}).", - file=sys.stderr, - ) - return None - - -def _current_game_log( - directory: Path, - baseline: dict[str, LogFileState], - started_ns: int, - *, - managed_asset_paths: Sequence[str] | None = None, - source_game: Path | None = None, -) -> dict[str, object] | None: - state = newest_current_native_log( - directory, - baseline, - started_ns, - patterns=("log_*.txt",), - ) - if state is None: - return None - analysis = parse_current_run_log(state, baseline) - asset_references = classify_asset_references( - analysis, - managed_asset_paths, - source_game=source_game, - ) - return { - "path": str(state.path), - "modified_at": datetime.fromtimestamp( - state.mtime_ns / 1_000_000_000 - ).astimezone().isoformat(), - "size": state.size, - "load_texture_to_material_failed": analysis.load_texture_to_material_failed, - "import_scene_threw": analysis.import_scene_threw, - "asset_pack_health_failed": analysis.asset_pack_health_failed, - "managed_asset_integrity_failed": asset_references.managed_integrity_failed, - "asset_references": asset_references.to_dict(), - "runtime_out_of_memory": analysis.runtime_out_of_memory, - "out_of_memory_count": len(analysis.out_of_memory_events), - "out_of_memory_effect_paths": analysis.out_of_memory_effect_paths, - "effect_creation_failures": [ - event.to_dict() for event in analysis.effect_creation_failures - ], - "failed_import_scenes": analysis.failed_import_scenes, - "stage_without_new_stage": analysis.stage_without_new_stage, - "module_code_sync_error": analysis.module_code_sync_error, - "module_code_sync_left": analysis.module_code_sync_left, - "module_code_sync_right": analysis.module_code_sync_right, - "module_code_sync_delta": analysis.module_code_sync_delta, - "banned_code_1": analysis.banned_code_1, - "banned_code_1_in_native_log": analysis.banned_code_1, - "banned_code_1_observation_scope": ( - "native-game-log-only; server-provided login-screen text is not " - "written to this log" - ), - "failed_file_find_count": analysis.failed_file_find_count, - "failed_file_load_count": analysis.failed_file_load_count, - "failed_file_read_count": analysis.failed_file_read_count, - "failed_asset_file_count": analysis.failed_asset_file_count, - "failed_texture_file_count": analysis.failed_texture_file_count, - "failed_effect_file_count": analysis.failed_effect_file_count, - "missing_texture_reference_count": analysis.missing_texture_reference_count, - "missing_effect_reference_count": analysis.missing_effect_reference_count, - "missing_image_reference_count": analysis.missing_image_reference_count, - "missing_pack_image_reference_count": analysis.missing_pack_image_reference_count, - "stage_without_new_stage_events": [ - event.to_dict() - for event in analysis.events - if event.kind == "stage_without_new_stage" - ], - "scene_imports": { - name: [scene.to_dict() for scene in imports] - for name, imports in sorted(analysis.scenes.items()) - }, - "failed_file_count": len(analysis.failed_files), - "failed_files": [event.to_dict() for event in analysis.failed_files[:64]], - } - - -def _current_game_crash( - directory: Path, - baseline: dict[str, LogFileState], - started_ns: int, -) -> dict[str, object] | None: - state = newest_current_native_log( - directory, - baseline, - started_ns, - patterns=("*.dat", "*.dmp"), - ) - if state is None: - return None - return { - "path": str(state.path), - "modified_at": datetime.fromtimestamp( - state.mtime_ns / 1_000_000_000 - ).astimezone().isoformat(), - "size": state.size, - } - - -def _runtime_issue_summary(stderr: str) -> dict[str, object]: - folded = stderr.casefold() - futex_message = "the futex facility returned an unexpected error code" - wrong_elf_message = "wrong elf class: elfclass64" - dbghelp_assertion = "assertion failed: module->module.symtype != symdeferred" - bwrap_uid_map_denied = "bwrap: setting up uid map: permission denied" - missing_libraries = sorted( - { - library - for line in folded.splitlines() - if "gstreamer-warning" in line and "failed to load plugin" in line - for library in re.findall( - r"([a-z0-9_.+-]+\.so(?:\.\d+)*): cannot open shared object file", - line, - ) - } - ) - return { - "game_window_ready": _game_window_was_ready(folded), - "host_futex_fatal_count": folded.count(futex_message), - "gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message), - "gstreamer_missing_library_count": len(missing_libraries), - "gstreamer_missing_libraries": missing_libraries, - "gstreamer_stream_error": "winegstreamer error" in folded, - "mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded, - "gamescope_present_queue_unsupported": ( - "physical device queue doesn't support presenting on our surface" in folded - ), - "gamescope_vulkan_output_failed_count": folded.count( - "vulkan_make_output failed" - ), - "steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded, - "dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion), - "bubblewrap_uid_map_permission_denied": bwrap_uid_map_denied in folded, - } - - -def _gamescope_presentation_failure(output: str) -> str | None: - folded = output.casefold() - for diagnostic, marker in ( - ( - "gamescope_present_queue_unsupported", - "physical device queue doesn't support presenting on our surface", - ), - ("gamescope_vulkan_output_failed", "vulkan_make_output failed"), - ): - if marker in folded: - return diagnostic - return None - - -def _game_window_was_ready(stderr: str) -> bool: - return _game_readiness_signal(stderr) is not None - - -def _game_readiness_signal(output: str) -> str | None: - folded = output.casefold() - for signal_name, marker in ( - ("window_ready", "game window ready:"), - ("rendered_frame", "rendered game frame:"), - ( - "window_supervision", - "game window resizing is disabled to prevent repeated direct3d device resets", - ), - ): - if marker in folded: - return signal_name - return None - - -def _window_diagnostic_severity(diagnostic: dict[str, object]) -> str: - name = diagnostic.get("diagnostic") - details = diagnostic.get("details") - if isinstance(name, str) and ( - name.endswith(("_failed", "_unavailable")) - or name in { - "host_hidden_state_cleared", - "host_inactive_remap", - "host_minimize_latched", - } - ): - return "warning" - if isinstance(details, dict): - if name == "host_scaling_configured" and not all( - details.get(key) is True - for key in ( - "resize_increments_applied", - "scaling_enabled", - "compositing_preferred", - ) - ): - return "warning" - if name == "scaling_configured" and details.get("enabled") is not True: - return "warning" - return "debug" - - -def _print_game_outcome( - outcome: CommandOutcome, - current_game_log: dict[str, object] | None, - runtime_issues: dict[str, object], - current_game_crash: dict[str, object] | None = None, -) -> None: - # Keep the human-facing stream concise. CommandRunner already relayed every - # process line live; replaying its capture here made each Proton failure appear - # twice in the dashboard. - if current_game_crash is not None: - crash_path = current_game_crash.get("path") - crash_path_text = crash_path if isinstance(crash_path, str) else "unknown" - print( - f"Game client crashed and wrote a dump: {crash_path_text}", - file=sys.stderr, - ) - elif outcome.timed_out: - print("Game client timed out and was stopped.", file=sys.stderr) - elif outcome.returncode == 0: - print("Game client exited normally.") - elif outcome.returncode is None: - print("Game client exited without a status.", file=sys.stderr) - else: - print(f"Game client exited with status {outcome.returncode}.", file=sys.stderr) - if current_game_log is not None: - _print_interrupted_game_log(current_game_log) - wrong_elf_count = runtime_issues["gstreamer_wrong_elf_class_count"] - if isinstance(wrong_elf_count, int) and wrong_elf_count: - print( - "The repeated GStreamer wrong-ELF-class messages are plugin-scan noise; " - "they do not mean that Proton itself failed to start.", - file=sys.stderr, - ) - missing_libraries = runtime_issues["gstreamer_missing_libraries"] - if isinstance(missing_libraries, list) and missing_libraries: - print( - "Media plugins could not load these shared libraries: " - + ", ".join(str(value) for value in missing_libraries) - + ".", - file=sys.stderr, - ) - assertion_count = runtime_issues["dbghelp_symdeferred_assertion_count"] - if isinstance(assertion_count, int) and assertion_count: - print( - "Proton 7's older Wine build hit its dbghelp SymDeferred assertion; this " - "can mask the client failure that occurred immediately before it.", - file=sys.stderr, - ) - if runtime_issues["bubblewrap_uid_map_permission_denied"]: - print( - "Ubuntu denied Bubblewrap's UID map before Proton started. Run " - "`pristontale-eu container-host --repair` and retry.", - file=sys.stderr, - ) - recommendation = runtime_issues.get("recommended_one_variable_comparison") - if isinstance(recommendation, str): - print( - f"Recommended single-variable runtime comparison: {recommendation}.", - file=sys.stderr, - ) - - -def _print_interrupted_game_log(report: dict[str, object]) -> None: - log_path = report.get("path") - log_path_text = log_path if isinstance(log_path, str) else "unknown" - print(f"\nCurrent game log: {log_path_text}", file=sys.stderr) - asset_references = report.get("asset_references") - catalog_available = ( - asset_references.get("catalog_available") - if isinstance(asset_references, dict) - else False - ) - if report["module_code_sync_error"]: - print( - "Detected a publisher module-integrity mismatch; the game was launched " - "with publisher-pristine code in this launcher version.", - file=sys.stderr, - ) - elif report["runtime_out_of_memory"]: - effects = report.get("out_of_memory_effect_paths") - effect_text = "" - if isinstance(effects, list) and effects: - effect_text = " while creating " + ", ".join(str(path) for path in effects[:3]) - print( - "Detected Direct3D resource exhaustion in the 32-bit game process" - + effect_text - + ". Restarting the client can recover temporarily; depot repair will not " - "fix this failure.", - file=sys.stderr, - ) - elif report["managed_asset_integrity_failed"]: - print( - "The game reported an asset that belongs to the publisher manifest but is " - "missing from the managed installation.", - file=sys.stderr, - ) - print( - "Run `pristontale-eu update --repair` and restart.", - file=sys.stderr, - ) - elif report["asset_pack_health_failed"] and not catalog_available: - print( - "The game reported many unresolved asset paths, but no matching cached " - "publisher manifest was available to classify them. Run " - "`pristontale-eu update` to refresh the manifest cache.", - file=sys.stderr, - ) - elif report["load_texture_to_material_failed"]: - print( - "Detected a texture/material failure while loading the game world.", - file=sys.stderr, - ) - elif report["import_scene_threw"] or report["failed_import_scenes"]: - print("Detected a game-world scene import failure.", file=sys.stderr) - - -def _login_trace( - paths: AppPaths, - game_dir: Path | None, - no_box: bool, - skip_runtime_setup: bool, - timeout_seconds: float, - as_json: bool, -) -> int: - # Preserve the command name so old instructions fail safely before touching - # the prefix, preparing the runtime view, or starting Game.exe. - _ = paths, game_dir, no_box, skip_runtime_setup, timeout_seconds, as_json - raise RuntimeError(LOGIN_TRACE_DISABLED_REASON) - - -def _settings( - paths: AppPaths, - initialize: bool, - reset: bool, - as_json: bool, -) -> int: - if reset: - settings = reset_settings(paths) - elif initialize: - settings = ensure_settings(paths) - else: - settings = load_settings(paths) - _emit( - { - "ok": True, - "path": str(paths.settings_file), - "settings": asdict(settings), - }, - as_json, - ) - return 0 - - -def _launcher_update( - paths: AppPaths, - check_only: bool, - verify: bool, - rollback: str | bool | None, - manifest_url: str | None, - public_key: Path | None, - as_json: bool, - *, - history_url: str | None = None, -) -> int: - try: - selected_channel = load_settings(paths).update_channel - except (OSError, TypeError, ValueError): - # Keep update recovery usable when settings are absent or damaged. - selected_channel = STABLE_CHANNEL - rollback_requested = rollback is not None and rollback is not False - if rollback_requested: - target_version = rollback if isinstance(rollback, str) and rollback else None - result = rollback_launcher_update( - paths, - target_version=target_version, - selected_channel=selected_channel, - history_url=history_url, - public_key_path=public_key, - ) - _emit( - { - "ok": True, - "launcher_rollback": asdict(result), - }, - as_json, - ) - return 0 - if check_only: - check = check_for_launcher_update( - manifest_url=manifest_url, - public_key_path=public_key, - channel=selected_channel, - ) - _emit( - { - "ok": True, - "launcher_update": update_check_to_dict(check), - }, - as_json, - ) - return 0 - if verify: - _print_operation_status(as_json, 1, 2, "Authenticating the launcher release…") - result = verify_launcher_update_channel( - manifest_url=manifest_url, - public_key_path=public_key, - channel=selected_channel, - ) - _print_operation_status(as_json, 2, 2, "Published launcher release verified.") - _emit( - { - "ok": True, - "launcher_update_verification": asdict(result), - }, - as_json, - ) - return 0 - _print_operation_status(as_json, 1, 4, "Verifying the signed launcher release…") - result = install_launcher_update( - paths, - manifest_url=manifest_url, - public_key_path=public_key, - channel=selected_channel, - ) - _print_operation_status( - as_json, - 2, - 4, - "Launcher update installed; preparing the Steam Runtime helper…", - ) - umu_result: UmuBootstrapResult | None = None - umu_executable: str | None = None - umu_warning: str | None = None - try: - umu_result = provision_umu(paths) - umu_executable = find_umu_launcher(paths) - except (OSError, RuntimeError) as exc: - # Activation is already durable. Keep the update successful so the GUI can - # restart into the new release, whose startup hook and Play path both retry. - warning_message = ( - "the launcher update was installed, but the Steam Runtime helper could " - f"not be prepared yet: {exc}" - ) - umu_warning = warning_message - if not as_json: - print(f"Warning: {warning_message}", file=sys.stderr) - _print_operation_status( - as_json, - 3, - 4, - "Checking Steam Runtime sandbox permissions (authentication may be requested)…", - ) - container_report = repair_container_host() - container_warning = container_report.repair_error - if not container_report.ready and container_warning is None: - container_warning = container_report.recommendation - if container_warning and not as_json: - print(f"Warning: {container_warning}", file=sys.stderr) - _print_operation_status(as_json, 4, 4, "Launcher update and runtime check complete.") - _emit( - { - "ok": True, - "launcher_update": asdict(result), - "runtime_bootstrap": { - "executable": umu_executable, - "managed_download": asdict(umu_result) if umu_result else None, - "warning": umu_warning, - }, - "container_host": { - **container_report.to_dict(), - "warning": container_warning, - }, - }, - as_json, - ) - return 0 - - -def _source_game( - paths: AppPaths, - _settings: Settings, - argument: Path | None, -) -> Path: - """Select the managed game unless an explicit diagnostic path was supplied. - - The persisted ``game_source`` setting is an import seed only. It must not - redirect normal Play, Prepare, or Login Trace operations away from the - launcher-managed tree. - """ - - if argument is not None: - return argument.expanduser().resolve() - return paths.source_game - - -def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings: - if paths.settings_file.is_file(): - return load_settings(paths) - if persist: - return ensure_settings(paths) - return Settings() - - -def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner: - timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f") - return CommandRunner( - paths.logs / f"{timestamp}-{operation}.jsonl", - dry_run=dry_run, - operation=operation, - ) - - -def _play_guard( - paths: AppPaths, - dry_run: bool, -) -> AbstractContextManager[LauncherPlayLease]: - if dry_run: - return nullcontext(LauncherPlayLease.dry_run()) - return launcher_play_lock( - paths.launcher_operation_lock, - active_game_probe=lambda: _managed_game_process_is_active(paths), - ) - - -def _operation_guard( - paths: AppPaths, - operation: str, - dry_run: bool, -) -> AbstractContextManager[None]: - if dry_run: - return nullcontext() - return launcher_operation_lock( - paths.launcher_operation_lock, - operation, - active_game_probe=lambda: _managed_game_process_is_active(paths), - ) - - -def _managed_game_process_is_active(paths: AppPaths) -> bool: - return managed_game_process_is_active( - paths.runtime_view, - (paths.proton_prefix, paths.umu_prefix), - ) - - -def _emit(value: object, as_json: bool) -> None: - if as_json: - _print_json(value) - else: - if isinstance(value, dict) and value.get("ok") is True: - print("Done.") - print(json.dumps(value, indent=2, sort_keys=True)) - - -def _print_json(value: object) -> None: - print(json.dumps(value, indent=2, sort_keys=True)) diff --git a/ptlinux/depot.py b/ptlinux/depot.py deleted file mode 100644 index d60cebe..0000000 --- a/ptlinux/depot.py +++ /dev/null @@ -1,2791 +0,0 @@ -"""Native, verified updater for the public PristonTale EU depot.""" - -from __future__ import annotations - -from base64 import b64decode -from binascii import Error as BinAsciiError -from collections import deque -from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait -from dataclasses import asdict, dataclass -from datetime import datetime -import hashlib -from http.client import HTTPException -import json -import os -from pathlib import Path, PurePosixPath -import re -import stat -import threading -import time -from typing import Callable, Iterable, Mapping, Protocol -from urllib.error import HTTPError -from urllib.parse import quote, urlparse -from urllib.request import Request, urlopen -from uuid import uuid4 - -from . import __version__ -from .paths import AppPaths -from .telemetry import classify_network_failure, current_telemetry - - -CDN_CONFIGURATION_URL = "https://cdn-main.chatplex.io/pristontale-eu/cdn_configuration.json" -DEPOT_ID = 1 -FALLBACK_SERVERS = ( - "cdn-fr-sbg1.chatplex.io/pristontale-eu", - "cdn-us-ore1.chatplex.io/pristontale-eu", -) -READ_SIZE = 128 * 1024 -HASH_READ_SIZE = 1024 * 1024 -DOWNLOAD_WORKERS = 8 -DOWNLOAD_INITIAL_WORKERS = DOWNLOAD_WORKERS -LOW_BANDWIDTH_WORKERS = 2 -LOW_BANDWIDTH_BYTES_PER_SECOND = 5_000_000 -DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0) -PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075 -ADAPTIVE_EVALUATION_INTERVAL_SECONDS = 1.0 -ADAPTIVE_RETRY_WINDOW_SECONDS = 15.0 -ADAPTIVE_RETRY_RECOVERY_SECONDS = 5.0 -TRANSFER_RATE_WINDOW_SECONDS = 5.0 -MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 -MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024 -UNMANAGED_EXECUTABLE_SUFFIXES = frozenset( - { - ".asi", - ".bat", - ".cmd", - ".com", - ".cpl", - ".desktop", - ".dll", - ".drv", - ".exe", - ".jar", - ".js", - ".lnk", - ".lua", - ".msi", - ".ocx", - ".ps1", - ".py", - ".reg", - ".scr", - ".sh", - ".so", - ".url", - ".vbe", - ".vbs", - ".wsf", - } -) -ALWAYS_VERIFY_PATHS = frozenset( - {"game.exe", "game.dll", "gamese.exe", "gamese.dll"} -) -SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") -CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE) -DEPOT_LAUNCH_CATALOG_SCHEMA = 1 -DEPOT_FILE_CATALOG_SCHEMA = 1 -MAX_PREFLIGHT_ISSUES = 32 - - -class DepotError(RuntimeError): - """Raised when a depot response or update operation cannot be trusted.""" - - -class _RetryableDownloadError(DepotError): - """One transient network or content-integrity failure.""" - - def __init__( - self, - message: str, - *, - discard_partial: bool = False, - http_status: int | None = None, - ) -> None: - super().__init__(message) - self.discard_partial = discard_partial - self.http_status = http_status - - -@dataclass(frozen=True) -class DownloadOptions: - """User-selected limits around the adaptive depot scheduler.""" - - bandwidth_limit_bytes_per_second: int | None = None - low_bandwidth: bool = False - minimum_workers: int = 1 - maximum_workers: int | None = None - - def __post_init__(self) -> None: - if not isinstance(self.low_bandwidth, bool): - raise ValueError("low_bandwidth must be a boolean") - if ( - self.bandwidth_limit_bytes_per_second is not None - and ( - isinstance(self.bandwidth_limit_bytes_per_second, bool) - or self.bandwidth_limit_bytes_per_second <= 0 - ) - ): - raise ValueError("bandwidth limit must be positive or None") - if isinstance(self.minimum_workers, bool) or self.minimum_workers <= 0: - raise ValueError("minimum download workers must be positive") - if self.maximum_workers is not None and ( - isinstance(self.maximum_workers, bool) or self.maximum_workers <= 0 - ): - raise ValueError("maximum download workers must be positive or None") - if ( - self.maximum_workers is not None - and self.minimum_workers > self.maximum_workers - ): - raise ValueError("minimum download workers exceed the maximum") - if self.low_bandwidth and self.minimum_workers > LOW_BANDWIDTH_WORKERS: - raise ValueError("low-bandwidth minimum workers exceed its worker cap") - - @property - def effective_bandwidth_limit(self) -> int | None: - configured = self.bandwidth_limit_bytes_per_second - if not self.low_bandwidth: - return configured - return min( - configured if configured is not None else LOW_BANDWIDTH_BYTES_PER_SECOND, - LOW_BANDWIDTH_BYTES_PER_SECOND, - ) - - @property - def effective_maximum_workers(self) -> int: - configured = self.maximum_workers or DOWNLOAD_WORKERS - if self.low_bandwidth: - configured = min(configured, LOW_BANDWIDTH_WORKERS) - return max(self.minimum_workers, configured) - - -@dataclass(frozen=True) -class DownloadTelemetry: - """One internal measurement produced while retrieving a depot file.""" - - kind: str - file: str - server: str - attempt: int - latency_seconds: float | None = None - bytes_written: int = 0 - write_seconds: float = 0.0 - reason: str | None = None - http_status: int | None = None - resumed_bytes: int = 0 - - -DownloadObserver = Callable[[DownloadTelemetry], None] - - -class BandwidthLimiter: - """Thread-safe aggregate byte-rate limiter with a one-chunk burst allowance.""" - - def __init__( - self, - bytes_per_second: int, - *, - clock: Callable[[], float] = time.monotonic, - sleeper: Callable[[float], None] = time.sleep, - ) -> None: - if isinstance(bytes_per_second, bool) or bytes_per_second <= 0: - raise ValueError("bandwidth limit must be positive") - self.bytes_per_second = bytes_per_second - self._clock = clock - self._sleeper = sleeper - self._lock = threading.Lock() - self._next_available_at = clock() - - def consume(self, byte_count: int) -> None: - """Wait until this newly received chunk's aggregate reservation begins. - - The network read happens before this call. Reserving the chunk's *start* - keeps all workers under one aggregate rate without charging again for time - already spent receiving it. The first chunk is the bucket's burst allowance. - """ - - if byte_count < 0: - raise ValueError("byte count must not be negative") - if byte_count == 0: - return - with self._lock: - now = self._clock() - start = max(now, self._next_available_at) - self._next_available_at = start + (byte_count / self.bytes_per_second) - delay = start - now - if delay > 0: - self._sleeper(delay) - - -class AdaptiveConcurrencyController: - """Select a bounded worker target from live network, host, and plan data.""" - - def __init__( - self, - options: DownloadOptions, - *, - clock: Callable[[], float] = time.monotonic, - cpu_load: Callable[[], float | None] | None = None, - ) -> None: - self.options = options - self._clock = clock - self._cpu_load = cpu_load or _normalized_cpu_load - self._lock = threading.Lock() - self._target = max( - options.minimum_workers, - min(DOWNLOAD_INITIAL_WORKERS, options.effective_maximum_workers), - ) - self._last_evaluation = clock() - self._transferred_bytes = 0 - self._transfer_samples: deque[tuple[float, int]] = deque( - [(self._last_evaluation, 0)] - ) - self._response_samples: deque[tuple[float, float]] = deque() - self._retry_samples: deque[float] = deque() - self._retry_count = 0 - self._handled_retry_count = 0 - self._last_retry_at: float | None = None - self._latency_seconds: float | None = None - self._disk_samples: deque[tuple[float, int, float]] = deque() - - def record_response(self, latency_seconds: float) -> None: - if latency_seconds < 0: - return - now = self._clock() - with self._lock: - self._response_samples.append((now, latency_seconds)) - self._prune_health_samples(now) - if self._latency_seconds is None: - self._latency_seconds = latency_seconds - else: - self._latency_seconds = ( - (self._latency_seconds * 0.75) + (latency_seconds * 0.25) - ) - - def record_retry(self) -> None: - now = self._clock() - with self._lock: - self._retry_count += 1 - self._retry_samples.append(now) - self._last_retry_at = now - self._prune_health_samples(now) - - def record_chunk(self, byte_count: int, write_seconds: float) -> None: - if byte_count <= 0: - return - now = self._clock() - with self._lock: - self._transferred_bytes += byte_count - self._transfer_samples.append((now, self._transferred_bytes)) - _prune_transfer_samples(self._transfer_samples, now) - self._disk_samples.append((now, byte_count, max(0.0, write_seconds))) - self._prune_disk_samples(now) - - def _prune_health_samples(self, now: float) -> None: - cutoff = now - ADAPTIVE_RETRY_WINDOW_SECONDS - while self._response_samples and self._response_samples[0][0] < cutoff: - self._response_samples.popleft() - while self._retry_samples and self._retry_samples[0] < cutoff: - self._retry_samples.popleft() - - def _prune_disk_samples(self, now: float) -> None: - cutoff = now - TRANSFER_RATE_WINDOW_SECONDS - while self._disk_samples and self._disk_samples[0][0] < cutoff: - self._disk_samples.popleft() - - def target_workers(self, remaining_files: int, remaining_bytes: int) -> int: - """Return the next target; active workers are allowed to drain naturally.""" - - if remaining_files <= 0: - return 0 - maximum = min(self.options.effective_maximum_workers, remaining_files) - minimum = min(self.options.minimum_workers, maximum) - with self._lock: - self._target = min(maximum, max(minimum, self._target)) - now = self._clock() - average_file_size = remaining_bytes / max(1, remaining_files) - if now - self._last_evaluation < ADAPTIVE_EVALUATION_INTERVAL_SECONDS: - return self._target - self._last_evaluation = now - _prune_transfer_samples(self._transfer_samples, now) - self._prune_health_samples(now) - self._prune_disk_samples(now) - rate = _sample_rate(self._transfer_samples) - attempts = len(self._response_samples) + len(self._retry_samples) - error_rate = len(self._retry_samples) / attempts if attempts else 0.0 - latency = self._latency_seconds - disk_bytes = sum(sample[1] for sample in self._disk_samples) - disk_seconds = sum(sample[2] for sample in self._disk_samples) - disk_rate = ( - disk_bytes / disk_seconds - if disk_seconds > 0 - else None - ) - try: - cpu_load = self._cpu_load() - except (OSError, ValueError): - cpu_load = None - - new_retries = self._retry_count - self._handled_retry_count - if new_retries: - self._handled_retry_count = self._retry_count - if error_rate >= 0.12 and self._target > minimum: - backoff = min(2, new_retries) - self._target = max(minimum, self._target - backoff) - return self._target - - resource_constrained = ( - (cpu_load is not None and cpu_load >= 0.90) - or ( - rate is not None - and disk_rate is not None - and disk_rate < rate * 0.75 - ) - ) - if resource_constrained and self._target > minimum: - self._target -= 1 - return self._target - - limit = self.options.effective_bandwidth_limit - limit_saturated = ( - limit is not None and rate is not None and rate >= limit * 0.90 - ) - healthy = ( - (cpu_load is None or cpu_load < 0.80) - and ( - rate is None - or disk_rate is None - or disk_rate >= rate - ) - ) - retry_recovery_pending = ( - self._last_retry_at is not None - and now - self._last_retry_at < ADAPTIVE_RETRY_RECOVERY_SECONDS - ) - if ( - healthy - and not retry_recovery_pending - and not limit_saturated - and self._target < maximum - ): - # Parallel requests hide CDN round-trip time and are especially useful - # for this depot's large population of small files. - step = 2 if ( - (latency is not None and latency >= 0.75) - or average_file_size <= 512 * 1024 - ) else 1 - self._target = min(maximum, self._target + step) - return min(maximum, max(minimum, self._target)) - - -@dataclass(frozen=True) -class DepotFile: - """One exact file in the published depot build.""" - - path: str - build: int - size: int - sha1: str - - -@dataclass(frozen=True) -class CreateIfMissing: - """A small publisher-provided file created only when absent.""" - - path: str - content_base64: str - - -@dataclass(frozen=True) -class DepotStartOption: - """One publisher-advertised realm launch target.""" - - name: str - path: str - arguments: str - - -DEFAULT_DEPOT_START_OPTION = DepotStartOption( - name="Babel", - path="Game.exe", - arguments="PTEU_REBORN", -) - - -@dataclass(frozen=True) -class DepotSnapshot: - """The current, verified shape of the public game depot.""" - - region: str - server: str - name: str - build: int - version: str - files: tuple[DepotFile, ...] - deleted_files: tuple[str, ...] - create_if_missing: tuple[CreateIfMissing, ...] - start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) - default_start_option: str = DEFAULT_DEPOT_START_OPTION.name - - -@dataclass(frozen=True) -class DepotLaunchCatalog: - """Small cached subset of a depot manifest needed for offline Play.""" - - build: int - start_options: tuple[DepotStartOption, ...] - default_start_option: str - - -@dataclass(frozen=True) -class DepotFileCatalogEntry: - """One manifest file plus the identity recorded after verified installation.""" - - path: str - size: int - sha1: str - device: int - inode: int - mtime_ns: int - ctime_ns: int - verified: bool - - -@dataclass(frozen=True) -class DepotFileCatalog: - """Cached manifest used for fast, network-free launch preflight checks.""" - - build: int - files: tuple[DepotFileCatalogEntry, ...] - created_at: str - - -@dataclass(frozen=True) -class DepotIntegrityIssue: - path: str - reason: str - - -@dataclass(frozen=True) -class DepotPreflightResult: - """Outcome of validating cached file identities before starting the game.""" - - build: int - files_total: int - verified_files: int - unverified_files: int - changed_files_hashed: int - issue_count: int - issues: tuple[DepotIntegrityIssue, ...] - duration_ms: int - - @property - def ready(self) -> bool: - return self.issue_count == 0 - - @property - def fully_verified(self) -> bool: - return self.ready and self.unverified_files == 0 - - -@dataclass(frozen=True) -class _ScannedGameEntry: - path: Path - state: os.stat_result - kind: str - - -def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog: - return DepotLaunchCatalog( - build=snapshot.build, - start_options=snapshot.start_options, - default_start_option=snapshot.default_start_option, - ) - - -def select_depot_start_option( - catalog: DepotLaunchCatalog | None, - preferred_realm: str | None = None, -) -> DepotStartOption: - """Choose a published realm, falling back to Babel when possible.""" - - current = catalog or DepotLaunchCatalog( - build=0, - start_options=(DEFAULT_DEPOT_START_OPTION,), - default_start_option=DEFAULT_DEPOT_START_OPTION.name, - ) - if preferred_realm: - preferred = preferred_realm.casefold() - for option in current.start_options: - if option.name.casefold() == preferred: - return option - for option in current.start_options: - if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold(): - return option - for option in current.start_options: - if option.name.casefold() == current.default_start_option.casefold(): - return option - return current.start_options[0] - - -def cache_depot_launch_catalog(path: Path, snapshot: DepotSnapshot) -> None: - """Cache only publisher-advertised launch metadata for network-free Play.""" - - catalog = launch_catalog_from_snapshot(snapshot) - _write_json( - path, - { - "schema": DEPOT_LAUNCH_CATALOG_SCHEMA, - "build": catalog.build, - "default_start_option": catalog.default_start_option, - "start_options": [asdict(option) for option in catalog.start_options], - }, - ) - - -def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None: - """Read the last valid launch catalog without performing network I/O.""" - - try: - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - return None - if value.get("schema") != DEPOT_LAUNCH_CATALOG_SCHEMA: - return None - build = _positive_int(value.get("build"), "cached depot build") - raw_options = value.get("start_options") - if not isinstance(raw_options, list): - return None - manifest_options = [ - { - "Name": item.get("name"), - "Path": item.get("path"), - "Arguments": item.get("arguments"), - } - if isinstance(item, dict) - else item - for item in raw_options - ] - options, default_name = _parse_start_options( - manifest_options, - value.get("default_start_option"), - ) - except (OSError, UnicodeError, json.JSONDecodeError, DepotError): - return None - return DepotLaunchCatalog( - build=build, - start_options=options, - default_start_option=default_name, - ) - - -def cache_depot_file_catalog( - path: Path, - game_directory: Path, - snapshot: DepotSnapshot, - *, - verified_paths: Iterable[str], - previous: DepotFileCatalog | None = None, -) -> DepotFileCatalog: - """Record identities without rehashing files already verified by the updater.""" - - root = _prepare_game_root(game_directory, create=False) - scanned_files = _scan_game_tree(root) - explicitly_verified = {verified_path.casefold() for verified_path in verified_paths} - previous_files = ( - {entry.path.casefold(): entry for entry in previous.files} - if previous is not None - else {} - ) - entries: list[DepotFileCatalogEntry] = [] - for file in snapshot.files: - scanned = scanned_files.get(file.path) - if scanned is None: - raise DepotError(f"cannot cache missing depot file identity: {file.path}") - state = scanned.state - if scanned.kind != "file" or state.st_size != file.size: - raise DepotError(f"cannot cache invalid depot file identity: {file.path}") - prior = previous_files.get(file.path.casefold()) - verified = file.path.casefold() in explicitly_verified - if ( - not verified - and prior is not None - and prior.verified - and prior.size == file.size - and prior.sha1 == file.sha1 - and _catalog_identity_matches(prior, state) - ): - verified = True - entries.append(_catalog_entry(file, state, verified=verified)) - - catalog = DepotFileCatalog( - build=snapshot.build, - files=tuple(entries), - created_at=datetime.now().astimezone().isoformat(), - ) - _write_json( - path, - _depot_file_catalog_payload(catalog), - compact=True, - ) - return catalog - - -def read_cached_depot_file_catalog(path: Path) -> DepotFileCatalog | None: - """Read and strictly validate the local manifest/identity cache.""" - - try: - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - return None - if value.get("schema") != DEPOT_FILE_CATALOG_SCHEMA: - return None - build = _positive_int(value.get("build"), "cached depot file build") - created_at = _string(value.get("created_at"), "cached depot file timestamp") - raw_files = value.get("files") - if not isinstance(raw_files, list): - return None - entries: list[DepotFileCatalogEntry] = [] - seen: set[str] = set() - for raw in raw_files: - entry = _parse_depot_file_catalog_entry(raw) - if entry.path.casefold() in seen: - return None - seen.add(entry.path.casefold()) - entries.append(entry) - except (OSError, UnicodeError, json.JSONDecodeError, DepotError): - return None - return DepotFileCatalog(build=build, files=tuple(entries), created_at=created_at) - - -def read_current_depot_file_catalog(paths: AppPaths) -> DepotFileCatalog | None: - """Return the file catalog only when it matches the last completed sync.""" - - catalog = read_cached_depot_file_catalog(paths.depot_file_catalog) - if catalog is None: - return None - if _completed_depot_build(paths.native_depot_state) != catalog.build: - return None - return catalog - - -def preflight_managed_game( - paths: AppPaths, - *, - catalog: DepotFileCatalog | None = None, -) -> DepotPreflightResult | None: - """Check all managed paths cheaply and hash only files whose identity changed.""" - - catalog = catalog or read_current_depot_file_catalog(paths) - if catalog is None: - return None - if _completed_depot_build(paths.native_depot_state) != catalog.build: - return None - started = time.perf_counter() - root = _prepare_game_root(paths.source_game, create=False) - if not root.is_dir(): - return DepotPreflightResult( - build=catalog.build, - files_total=len(catalog.files), - verified_files=0, - unverified_files=sum(not entry.verified for entry in catalog.files), - changed_files_hashed=0, - issue_count=1, - issues=(DepotIntegrityIssue(".", "managed game directory is missing"),), - duration_ms=_elapsed_milliseconds(started), - ) - try: - scanned_files = _scan_game_tree(root) - except DepotError as exc: - return DepotPreflightResult( - build=catalog.build, - files_total=len(catalog.files), - verified_files=0, - unverified_files=sum(not entry.verified for entry in catalog.files), - changed_files_hashed=0, - issue_count=1, - issues=(DepotIntegrityIssue(".", str(exc)),), - duration_ms=_elapsed_milliseconds(started), - ) - - verified_files = 0 - unverified_files = 0 - changed_files_hashed = 0 - issue_count = 0 - issues: list[DepotIntegrityIssue] = [] - updated_entries: list[DepotFileCatalogEntry] = [] - cache_changed = False - for entry in catalog.files: - reason = None - state: os.stat_result | None = None - scanned = scanned_files.get(entry.path) - if scanned is None: - reason = "missing" - elif scanned.kind != "file": - reason = scanned.kind - elif scanned.state.st_size != entry.size: - reason = "size mismatch" - else: - state = scanned.state - - updated_entry = entry - if reason is None and state is not None: - if not entry.verified: - unverified_files += 1 - elif _catalog_identity_matches(entry, state): - verified_files += 1 - else: - changed_files_hashed += 1 - try: - assert scanned is not None - matches = _sha1(scanned.path).casefold() == entry.sha1 - except DepotError as exc: - reason = f"cannot verify changed file: {exc}" - else: - if matches: - verified_files += 1 - updated_entry = _catalog_entry_from_cached(entry, state) - cache_changed = True - else: - reason = "hash mismatch" - - if reason is not None: - issue_count += 1 - if len(issues) < MAX_PREFLIGHT_ISSUES: - issues.append(DepotIntegrityIssue(entry.path, reason)) - updated_entries.append(updated_entry) - - if cache_changed: - updated_catalog = DepotFileCatalog( - build=catalog.build, - files=tuple(updated_entries), - created_at=datetime.now().astimezone().isoformat(), - ) - _write_json( - paths.depot_file_catalog, - _depot_file_catalog_payload(updated_catalog), - compact=True, - ) - - return DepotPreflightResult( - build=catalog.build, - files_total=len(catalog.files), - verified_files=verified_files, - unverified_files=unverified_files, - changed_files_hashed=changed_files_hashed, - issue_count=issue_count, - issues=tuple(issues), - duration_ms=_elapsed_milliseconds(started), - ) - - -@dataclass(frozen=True) -class PlannedDownload: - file: DepotFile - reason: str - - -@dataclass(frozen=True) -class UpdatePlan: - """The safe changes needed to make a local tree match one depot snapshot.""" - - downloads: tuple[PlannedDownload, ...] - deletes: tuple[str, ...] - files_total: int - bytes_total: int - hash_verified_paths: tuple[str, ...] = () - - -@dataclass(frozen=True) -class SyncProgress: - """A thread-safe progress snapshot emitted by ``sync_managed_game``.""" - - phase: str - files_completed: int - files_total: int - bytes_completed: int - bytes_total: int - current_file: str | None - bytes_transferred: int = 0 - bytes_remaining: int = 0 - transfer_rate_bytes_per_second: float | None = None - eta_seconds: float | None = None - current_server: str | None = None - retry_count: int = 0 - retry_reason: str | None = None - active_workers: int = 0 - target_workers: int = 0 - - -@dataclass(frozen=True) -class DepotSyncResult: - """Persistent result of one native depot synchronization.""" - - region: str - server: str - name: str - build: int - version: str - files_total: int - files_downloaded: int - bytes_downloaded: int - files_deleted: int - files_created: int - files_quarantined: int - quarantine_directory: str | None - repair: bool - dry_run: bool - completed_at: str - files_planned: int = 0 - bytes_planned: int = 0 - manifest_duration_ms: int = 0 - scan_duration_ms: int = 0 - download_duration_ms: int = 0 - apply_duration_ms: int = 0 - update_duration_ms: int = 0 - network_bytes_downloaded: int = 0 - download_retry_count: int = 0 - download_resume_used: bool = False - download_average_mbps: float | None = None - mirror_id: str | None = None - - -@dataclass(frozen=True) -class DepotDownloadMetrics: - """Aggregate network measurements for one depot download phase.""" - - duration_ms: int = 0 - transferred_bytes: int = 0 - retry_count: int = 0 - resume_used: bool = False - resumed_bytes: int = 0 - average_mbps: float | None = None - mirror_id: str | None = None - http_status: int | None = None - - -@dataclass(frozen=True) -class QuarantineResult: - """Recoverable unmanaged entries removed before authenticating an import.""" - - files: int - directory: str | None - - -ProgressCallback = Callable[[SyncProgress], None] - - -class HttpResponse(Protocol): - """The small response surface used by the native depot client.""" - - headers: Mapping[str, str] - - def read(self, size: int = -1) -> bytes: ... - - def close(self) -> None: ... - - -HttpOpener = Callable[..., HttpResponse] - - -class DepotClient: - """Small HTTP client for the publisher's documented public depot endpoints.""" - - def __init__( - self, - *, - timeout_seconds: float = 60.0, - opener: HttpOpener = urlopen, - retry_delays: tuple[float, ...] = DOWNLOAD_RETRY_DELAYS_SECONDS, - sleeper: Callable[[float], None] = time.sleep, - download_observer: DownloadObserver | None = None, - ) -> None: - if timeout_seconds <= 0: - raise ValueError("timeout_seconds must be positive") - if any(delay < 0 for delay in retry_delays): - raise ValueError("retry delays must not be negative") - self.timeout_seconds = timeout_seconds - self._opener = opener - self._retry_delays = retry_delays - self._sleeper = sleeper - self._candidate_servers: tuple[str, ...] = () - self._download_observer = download_observer - self._bandwidth_limiter: BandwidthLimiter | None = None - - @property - def download_observer(self) -> DownloadObserver | None: - return self._download_observer - - @download_observer.setter - def download_observer(self, observer: DownloadObserver | None) -> None: - self._download_observer = observer - - @property - def bandwidth_limiter(self) -> BandwidthLimiter | None: - return self._bandwidth_limiter - - @bandwidth_limiter.setter - def bandwidth_limiter(self, limiter: BandwidthLimiter | None) -> None: - self._bandwidth_limiter = limiter - - def fetch_snapshot(self, *, preferred_region: str = "Europe") -> DepotSnapshot: - """Fetch the live depot and build manifests, trying trusted regional fallbacks.""" - - configuration: Mapping[str, object] | None = None - configuration_error: DepotError | None = None - try: - configuration = self._get_json(CDN_CONFIGURATION_URL) - except DepotError as exc: - configuration_error = exc - - candidates = _server_candidates(configuration, preferred_region) - self._candidate_servers = tuple(server for _region, server in candidates) - last_error = configuration_error - for region, server in candidates: - try: - depot_manifest = self._get_json(_depot_manifest_url(server)) - build = _positive_int(depot_manifest.get("Build"), "depot Build") - build_manifest = self._get_json(_build_manifest_url(server, build)) - return _parse_snapshot(region, server, depot_manifest, build_manifest) - except DepotError as exc: - last_error = exc - - if last_error is not None: - raise DepotError(f"could not load the PristonTale EU depot: {last_error}") - raise DepotError("could not load the PristonTale EU depot") - - def download_file( - self, - server: str, - file: DepotFile, - destination: Path, - *, - progress: Callable[[int], None] | None = None, - ) -> None: - """Download, resume, and verify one file before replacing its destination. - - The current publisher CDN normalizes a small number of legacy text files - from CRLF to LF while its manifest still hashes the CRLF bytes. A - candidate is accepted only when restoring bare LF line endings produces - the manifest's exact size and SHA-1. - """ - - servers = _ordered_download_servers(server, self._candidate_servers) - temporary = _download_partial_path(destination) - last_error: _RetryableDownloadError | None = None - attempts = len(self._retry_delays) + 1 - for attempt_index in range(attempts): - attempt_server = servers[attempt_index % len(servers)] - try: - self._download_file_once( - attempt_server, - file, - destination, - attempt=attempt_index + 1, - progress=progress, - ) - return - except _RetryableDownloadError as exc: - last_error = exc - self._notify_download( - DownloadTelemetry( - kind="retry", - file=file.path, - server=attempt_server, - attempt=attempt_index + 1, - reason=str(exc), - http_status=exc.http_status, - ) - ) - if exc.discard_partial: - temporary.unlink(missing_ok=True) - if attempt_index >= len(self._retry_delays): - break - self._sleeper(self._retry_delays[attempt_index]) - - assert last_error is not None - resume_hint = ( - "; partial data was retained for the next update run" - if temporary.is_file() and not temporary.is_symlink() - else "" - ) - raise DepotError( - f"download failed for {file.path} after {attempts} attempts: " - f"{last_error}{resume_hint}" - ) from last_error - - def _download_file_once( - self, - server: str, - file: DepotFile, - destination: Path, - *, - attempt: int, - progress: Callable[[int], None] | None, - ) -> None: - temporary = _download_partial_path(destination) - written = 0 - digest = hashlib.sha1(usedforsecurity=False) - if temporary.is_symlink(): - raise DepotError(f"refusing to use symbolic-link partial file for {file.path}") - if temporary.exists(): - if not temporary.is_file(): - raise DepotError(f"partial download is not a file for {file.path}") - try: - with temporary.open("rb") as partial_handle: - while True: - chunk = partial_handle.read(HASH_READ_SIZE) - if not chunk: - break - written += len(chunk) - digest.update(chunk) - except OSError as exc: - raise DepotError(f"cannot inspect partial download for {file.path}: {exc}") from exc - - if written > file.size: - temporary.unlink() - written = 0 - digest = hashlib.sha1(usedforsecurity=False) - elif written == file.size: - if digest.hexdigest().casefold() == file.sha1.casefold(): - self._notify_download( - DownloadTelemetry( - kind="resume", - file=file.path, - server=server, - attempt=attempt, - resumed_bytes=written, - ) - ) - temporary.replace(destination) - if progress is not None: - progress(written) - return - temporary.unlink() - written = 0 - digest = hashlib.sha1(usedforsecurity=False) - elif written and _recover_crlf_manifest_file(temporary, destination, file): - self._notify_download( - DownloadTelemetry( - kind="resume", - file=file.path, - server=server, - attempt=attempt, - resumed_bytes=written, - ) - ) - temporary.unlink(missing_ok=True) - if progress is not None: - progress(file.size) - return - if progress is not None: - progress(written) - - requested_offset = written - if requested_offset: - self._notify_download( - DownloadTelemetry( - kind="resume", - file=file.path, - server=server, - attempt=attempt, - resumed_bytes=requested_offset, - ) - ) - headers = _headers() - if requested_offset: - headers["Range"] = f"bytes={requested_offset}-" - url = _file_url(server, file) - request = Request(url, headers=headers, method="GET") - self._notify_download( - DownloadTelemetry( - kind="request", - file=file.path, - server=server, - attempt=attempt, - ) - ) - request_started_at = time.monotonic() - try: - response = self._opener(request, timeout=self.timeout_seconds) - except HTTPError as exc: - status = exc.code - exc.close() - if status == 416 and requested_offset: - raise _RetryableDownloadError( - f"server rejected the saved offset for {file.path}", - discard_partial=True, - http_status=status, - ) from exc - raise _RetryableDownloadError( - f"HTTP {status} from {server} for {file.path}", - http_status=status, - ) from exc - except (OSError, HTTPException) as exc: - raise _RetryableDownloadError( - f"could not connect to {server} for {file.path}: {exc}" - ) from exc - response_status = getattr(response, "status", None) - self._notify_download( - DownloadTelemetry( - kind="response", - file=file.path, - server=server, - attempt=attempt, - latency_seconds=max(0.0, time.monotonic() - request_started_at), - http_status=(response_status if isinstance(response_status, int) else None), - ) - ) - - try: - append = False - if requested_offset: - content_range = response.headers.get("Content-Range") - status = getattr(response, "status", None) - if status == 206 or content_range is not None: - try: - range_start, _range_end, range_total = _content_range(content_range) - except DepotError as exc: - raise _RetryableDownloadError( - f"invalid resume response from {server} for {file.path}: {exc}" - ) from exc - if range_start != requested_offset or range_total != file.size: - raise _RetryableDownloadError( - f"resume response did not match {file.path}: " - f"offset {range_start}/{requested_offset}, size {range_total}/{file.size}" - ) - append = True - else: - # A server may ignore Range and send a normal 200 response. In - # that case safely restart this file using the full response body. - written = 0 - digest = hashlib.sha1(usedforsecurity=False) - if progress is not None: - progress(written) - - try: - content_length = _content_length(response.headers.get("Content-Length")) - except DepotError as exc: - raise _RetryableDownloadError( - f"invalid response length from {server} for {file.path}: {exc}" - ) from exc - remaining = file.size - written - if content_length is not None and content_length > remaining: - raise _RetryableDownloadError( - f"download exceeds expected size for {file.path}: " - f"server declared {content_length}, expected at most {remaining}" - ) - - mode = "ab" if append else "wb" - try: - with temporary.open(mode) as handle: - while True: - try: - chunk = response.read(READ_SIZE) - except (OSError, HTTPException) as exc: - raise _RetryableDownloadError( - f"could not read {file.path} from {server}: {exc}" - ) from exc - if not chunk: - break - written += len(chunk) - if written > file.size: - raise _RetryableDownloadError( - f"download exceeds expected size for {file.path}", - discard_partial=True, - ) - if self._bandwidth_limiter is not None: - self._bandwidth_limiter.consume(len(chunk)) - digest.update(chunk) - write_started_at = time.monotonic() - handle.write(chunk) - self._notify_download( - DownloadTelemetry( - kind="chunk", - file=file.path, - server=server, - attempt=attempt, - bytes_written=len(chunk), - write_seconds=max( - 0.0, - time.monotonic() - write_started_at, - ), - ) - ) - if progress is not None: - progress(written) - except _RetryableDownloadError: - raise - except OSError as exc: - raise DepotError(f"cannot write partial download for {file.path}: {exc}") from exc - - if written != file.size or digest.hexdigest().casefold() != file.sha1.casefold(): - if _recover_crlf_manifest_file(temporary, destination, file): - temporary.unlink(missing_ok=True) - if progress is not None: - progress(file.size) - return - if written != file.size: - raise _RetryableDownloadError( - f"incomplete response from {server} for {file.path}: " - f"{written}/{file.size} bytes" - ) - raise _RetryableDownloadError( - f"SHA-1 mismatch for {file.path} from {server}", - discard_partial=True, - ) - temporary.replace(destination) - finally: - try: - response.close() - except (OSError, HTTPException): - pass - - def _notify_download(self, event: DownloadTelemetry) -> None: - observer = self._download_observer - if observer is not None: - # noinspection PyBroadException - try: - observer(event) - except Exception: - # Telemetry must never weaken verified download behavior. - pass - - def _get_json(self, url: str) -> Mapping[str, object]: - request = Request(url, headers=_headers(), method="GET") - try: - response = self._opener(request, timeout=self.timeout_seconds) - except (OSError, HTTPException) as exc: - raise DepotError(f"request failed for {url}: {exc}") from exc - payload = b"" - try: - payload = response.read() - except (OSError, HTTPException) as exc: - raise DepotError(f"could not read {url}: {exc}") from exc - finally: - try: - response.close() - except (OSError, HTTPException): - pass - try: - value = json.loads(payload.decode("utf-8-sig")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise DepotError(f"invalid JSON from {url}") from exc - if not isinstance(value, dict): - raise DepotError(f"unexpected JSON root from {url}") - return value - - -def plan_update( - game_directory: Path, - snapshot: DepotSnapshot, - *, - repair: bool = False, -) -> UpdatePlan: - """Inspect a local game tree without writing to it.""" - - root = _prepare_game_root(game_directory, create=False) - downloads: list[PlannedDownload] = [] - hash_verified_paths: list[str] = [] - for file in snapshot.files: - target = _safe_target(root, file.path) - if target.is_symlink(): - downloads.append(PlannedDownload(file, "symbolic link")) - continue - if not target.is_file(): - downloads.append(PlannedDownload(file, "missing")) - continue - try: - if target.stat().st_size != file.size: - downloads.append(PlannedDownload(file, "size mismatch")) - continue - except OSError as exc: - raise DepotError(f"cannot inspect {file.path}: {exc}") from exc - if ( - repair - or file.build == snapshot.build - or file.path.casefold() in ALWAYS_VERIFY_PATHS - ): - if _sha1(target).casefold() != file.sha1.casefold(): - downloads.append(PlannedDownload(file, "hash mismatch")) - else: - hash_verified_paths.append(file.path) - - deletes: list[str] = [] - for relative_path in snapshot.deleted_files: - target = _safe_target(root, relative_path) - if target.is_symlink() or target.is_file(): - deletes.append(relative_path) - elif target.exists(): - raise DepotError( - f"refusing to delete non-file depot target: {relative_path}" - ) - - return UpdatePlan( - downloads=tuple(downloads), - deletes=tuple(deletes), - files_total=len(snapshot.files), - bytes_total=sum(item.file.size for item in downloads), - hash_verified_paths=tuple(hash_verified_paths), - ) - - -def sync_managed_game( - paths: AppPaths, - *, - repair: bool = False, - quarantine_unmanaged: bool = False, - dry_run: bool = False, - progress: ProgressCallback | None = None, - client: DepotClient | None = None, - download_options: DownloadOptions | None = None, -) -> DepotSyncResult: - """Synchronize the managed game directory using only official public data.""" - - operation_started = time.perf_counter() - operation = ( - "repair" - if repair - else "update" - if paths.source_game.is_dir() - else "install" - ) - telemetry_enabled = not dry_run - paths.create_base_directories() - depot_client = client or DepotClient() - previous_file_catalog = read_current_depot_file_catalog(paths) - current_version = _completed_depot_version(paths.native_depot_state) - manifest_started = time.perf_counter() - if operation == "update": - _send_depot_telemetry( - "update_check_started", - metadata={"current_version": current_version}, - context={"operation": operation, "stage": "manifest"}, - enabled=telemetry_enabled, - ) - _emit_progress(progress, "manifest", 0, 0, 0, 0, None) - try: - snapshot = depot_client.fetch_snapshot() - except BaseException as exc: - _send_depot_telemetry( - "download_failed", - severity="error", - message=str(exc), - metadata={ - "resource_type": "manifest", - "duration_ms": _elapsed_milliseconds(manifest_started), - "failure_type": classify_network_failure(exc), - }, - context={"operation": operation, "stage": "manifest"}, - enabled=telemetry_enabled, - ) - raise - manifest_duration_ms = _elapsed_milliseconds(manifest_started) - scan_started = time.perf_counter() - quarantine = QuarantineResult(0, None) - try: - if quarantine_unmanaged and not dry_run: - quarantine = _quarantine_unmanaged_files(paths, snapshot) - plan = plan_update(paths.source_game, snapshot, repair=repair) - except BaseException as exc: - _send_depot_telemetry( - "file_validation_failed", - severity="error", - message=str(exc), - metadata={ - "duration_ms": _elapsed_milliseconds(scan_started), - "repair": repair, - }, - context={"operation": operation, "stage": "scan"}, - enabled=telemetry_enabled, - ) - raise - scan_duration_ms = _elapsed_milliseconds(scan_started) - _emit_progress( - progress, - "planned", - 0, - len(plan.downloads), - 0, - plan.bytes_total, - None, - ) - plan_metadata = { - "current_version": current_version, - "target_version": snapshot.version, - "changed_file_count": len(plan.downloads) + len(plan.deletes), - "files_checked": plan.files_total, - "files_missing": sum(item.reason == "missing" for item in plan.downloads), - "files_invalid": sum(item.reason != "missing" for item in plan.downloads), - "files_replaced": len(plan.downloads), - "download_bytes": plan.bytes_total, - "manifest_duration_ms": manifest_duration_ms, - "scan_duration_ms": scan_duration_ms, - } - if operation == "update": - _send_depot_telemetry( - "update_available" if plan.downloads or plan.deletes else "update_not_required", - metadata=plan_metadata, - context={"operation": operation, "stage": "scan"}, - enabled=telemetry_enabled, - ) - elif operation == "repair": - _send_depot_telemetry( - "repair_scan_completed", - metadata=plan_metadata, - context={"operation": operation, "stage": "scan"}, - enabled=telemetry_enabled, - ) - - if dry_run: - return _sync_result( - snapshot, - plan, - 0, - 0, - 0, - repair, - True, - quarantine=quarantine, - manifest_duration_ms=manifest_duration_ms, - scan_duration_ms=scan_duration_ms, - update_duration_ms=_elapsed_milliseconds(operation_started), - ) - - root = _prepare_game_root(paths.source_game, create=True) - download_metrics = _download_plan( - depot_client, - snapshot, - root, - plan, - progress, - download_options or DownloadOptions(), - operation=operation, - telemetry_enabled=telemetry_enabled, - ) - apply_started = time.perf_counter() - if operation == "update": - _send_depot_telemetry( - "update_apply_started", - metadata={ - "target_version": snapshot.version, - "changed_file_count": len(plan.downloads) + len(plan.deletes), - }, - context={"operation": operation, "stage": "apply"}, - enabled=telemetry_enabled, - ) - try: - deleted = _apply_deletions(root, plan.deletes) - created = _apply_create_if_missing(root, snapshot.create_if_missing) - verified_paths = { - *plan.hash_verified_paths, - *(download.file.path for download in plan.downloads), - } - cache_depot_file_catalog( - paths.depot_file_catalog, - root, - snapshot, - verified_paths=verified_paths, - previous=previous_file_catalog, - ) - cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot) - apply_duration_ms = _elapsed_milliseconds(apply_started) - update_duration_ms = _elapsed_milliseconds(operation_started) - result = _sync_result( - snapshot, - plan, - len(plan.downloads), - plan.bytes_total, - deleted, - repair, - False, - created=created, - quarantine=quarantine, - manifest_duration_ms=manifest_duration_ms, - scan_duration_ms=scan_duration_ms, - apply_duration_ms=apply_duration_ms, - update_duration_ms=update_duration_ms, - download_metrics=download_metrics, - ) - _write_json(paths.native_depot_state, asdict(result)) - except BaseException as exc: - _send_depot_telemetry( - "filesystem_error", - severity="error", - message=str(exc), - metadata={ - "duration_ms": _elapsed_milliseconds(apply_started), - "target_version": snapshot.version, - }, - context={"operation": operation, "stage": "apply"}, - enabled=telemetry_enabled, - ) - raise - if operation == "update": - _send_depot_telemetry( - "update_apply_completed", - metadata={ - **plan_metadata, - "apply_duration_ms": result.apply_duration_ms, - "download_duration_ms": result.download_duration_ms, - "update_duration_ms": result.update_duration_ms, - "network_bytes_downloaded": result.network_bytes_downloaded, - "average_download_mbps": result.download_average_mbps, - "retry_count": result.download_retry_count, - "resume_used": result.download_resume_used, - }, - context={"operation": operation, "stage": "apply"}, - enabled=telemetry_enabled, - ) - _emit_progress( - progress, - "complete", - len(plan.downloads), - len(plan.downloads), - plan.bytes_total, - plan.bytes_total, - None, - ) - return result - - -def _quarantine_unmanaged_files( - paths: AppPaths, - snapshot: DepotSnapshot, -) -> QuarantineResult: - """Move non-depot import contents aside without following links.""" - - root = _prepare_game_root(paths.source_game, create=False) - if not root.exists(): - return QuarantineResult(0, None) - allowed = { - file.path for file in snapshot.files - } | { - entry.path for entry in snapshot.create_if_missing - } - candidates: list[tuple[Path, str]] = [] - - def walk_error(error: OSError) -> None: - raise DepotError(f"cannot inspect imported game tree: {error}") from error - - for directory, directory_names, file_names in os.walk( - root, - topdown=True, - followlinks=False, - onerror=walk_error, - ): - parent = Path(directory) - for name in tuple(directory_names): - path = parent / name - try: - status = path.lstat() - except OSError as exc: - raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc - if stat.S_ISDIR(status.st_mode): - continue - directory_names.remove(name) - candidates.append((path, path.relative_to(root).as_posix())) - for name in file_names: - path = parent / name - relative = path.relative_to(root).as_posix() - try: - status = path.lstat() - except OSError as exc: - raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc - if not stat.S_ISREG(status.st_mode): - candidates.append((path, relative)) - continue - if relative in allowed: - continue - if _safe_imported_user_file(path, relative): - continue - candidates.append((path, relative)) - - if not candidates: - return QuarantineResult(0, None) - base = paths.game_import_quarantine.resolve(strict=False) - data_root = paths.data.resolve(strict=False) - try: - base.relative_to(data_root) - except ValueError as exc: - raise DepotError(f"import quarantine is outside the data root: {base}") from exc - stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") - quarantine = base / f"{stamp}-{uuid4().hex}" - files_root = quarantine / "files" - files_root.mkdir(parents=True, exist_ok=False) - manifest = { - "schema": 1, - "complete": False, - "created_at": datetime.now().astimezone().isoformat(), - "source": str(root), - "files": [relative for _path, relative in candidates], - } - _write_json(quarantine / "quarantine.json", manifest) - for source, relative in candidates: - destination = files_root.joinpath(*PurePosixPath(relative).parts) - destination.parent.mkdir(parents=True, exist_ok=True) - try: - source.replace(destination) - except OSError as exc: - raise DepotError( - f"could not quarantine imported entry {relative}: {exc}" - ) from exc - manifest["complete"] = True - _write_json(quarantine / "quarantine.json", manifest) - return QuarantineResult(len(candidates), str(quarantine)) - - -def _safe_imported_user_file(path: Path, relative: str) -> bool: - parts = PurePosixPath(relative).parts - if not parts or parts[0].casefold() != "save": - return False - if path.suffix.casefold() in UNMANAGED_EXECUTABLE_SUFFIXES: - return False - try: - with path.open("rb") as handle: - prefix = handle.read(4) - except OSError as exc: - raise DepotError(f"cannot inspect imported user file {path}: {exc}") from exc - return not ( - prefix.startswith(b"MZ") - or prefix.startswith(b"\x7fELF") - or prefix.startswith(b"#!") - ) - - -def _download_plan( - client: DepotClient, - depot_snapshot: DepotSnapshot, - root: Path, - plan: UpdatePlan, - progress: ProgressCallback | None, - options: DownloadOptions, - *, - operation: str = "update", - telemetry_enabled: bool = True, -) -> DepotDownloadMetrics: - if not plan.downloads: - return DepotDownloadMetrics(mirror_id=_server_display_name(depot_snapshot.server)) - - lock = threading.Lock() - emit_lock = threading.Lock() - current_bytes: dict[str, int] = {} - current_servers: dict[str, str] = {} - completed_files = 0 - completed_bytes = 0 - transferred_bytes = 0 - retry_count = 0 - retry_reason: str | None = None - resume_offsets: dict[str, int] = {} - mirrors: set[str] = set() - last_http_status: int | None = None - retry_events_sent = 0 - resume_events_sent = 0 - active_workers = 0 - target_workers = 0 - last_progress_emitted_at = 0.0 - started_at = time.monotonic() - transfer_samples: deque[tuple[float, int]] = deque([(started_at, 0)]) - controller = AdaptiveConcurrencyController(options) - bandwidth_limit = options.effective_bandwidth_limit - limiter = BandwidthLimiter(bandwidth_limit) if bandwidth_limit is not None else None - previous_observer = client.download_observer - previous_limiter = client.bandwidth_limiter - started_event, completed_event = _download_event_names(operation) - _send_depot_telemetry( - started_event, - metadata={ - "resource_type": "repair_file" if operation == "repair" else "game", - "download_bytes": plan.bytes_total, - "file_count": len(plan.downloads), - "mirror_id": _server_display_name(depot_snapshot.server), - "cdn_region": depot_snapshot.region, - }, - context={"operation": operation, "stage": "download"}, - enabled=telemetry_enabled, - ) - - def emit_download_progress( - current_file: str, - *, - force: bool = False, - ) -> None: - """Throttle progress I/O without holding the download-state lock. - - The workers can produce many chunk and file-completion updates per second. - The callback writes to a subprocess pipe for the native dashboard, so - retaining every event would make that pipe and its UI consumer the bottleneck. - """ - - nonlocal last_progress_emitted_at - if progress is None: - return - with emit_lock: - now = time.monotonic() - if not force and now - last_progress_emitted_at < PROGRESS_UPDATE_INTERVAL_SECONDS: - return - last_progress_emitted_at = now - with lock: - bytes_completed = min( - plan.bytes_total, - completed_bytes + sum(current_bytes.values()), - ) - _prune_transfer_samples(transfer_samples, now) - transfer_rate = _sample_rate(transfer_samples) - bytes_remaining = max(0, plan.bytes_total - bytes_completed) - eta_seconds = ( - bytes_remaining / transfer_rate - if transfer_rate is not None and transfer_rate > 0 - else None - ) - event = SyncProgress( - "downloading", - completed_files, - len(plan.downloads), - bytes_completed, - plan.bytes_total, - current_file, - transferred_bytes, - bytes_remaining, - transfer_rate, - eta_seconds, - current_servers.get( - current_file, - _server_display_name(depot_snapshot.server), - ), - retry_count, - retry_reason, - active_workers, - target_workers, - ) - progress(event) - - def observe_download(event: DownloadTelemetry) -> None: - nonlocal last_http_status, resume_events_sent, retry_count - nonlocal retry_events_sent, retry_reason, transferred_bytes - if event.kind == "response" and event.latency_seconds is not None: - controller.record_response(event.latency_seconds) - elif event.kind == "retry": - controller.record_retry() - elif event.kind == "chunk": - controller.record_chunk(event.bytes_written, event.write_seconds) - - force = event.kind == "retry" - emit_retry = False - emit_resume = False - if event.kind in {"request", "response", "retry", "resume", "chunk"}: - with lock: - current_servers[event.file] = _server_display_name(event.server) - mirrors.add(_server_display_name(event.server)) - if event.kind == "chunk": - transferred_bytes += event.bytes_written - now = time.monotonic() - transfer_samples.append((now, transferred_bytes)) - _prune_transfer_samples(transfer_samples, now) - if event.kind == "retry": - retry_count += 1 - retry_reason = event.reason - if retry_events_sent < 5: - retry_events_sent += 1 - emit_retry = True - if event.kind == "resume" and event.resumed_bytes > 0: - prior_offset = resume_offsets.get(event.file, 0) - resume_offsets[event.file] = max(prior_offset, event.resumed_bytes) - if prior_offset == 0 and resume_events_sent < 5: - resume_events_sent += 1 - emit_resume = True - if event.http_status is not None: - last_http_status = event.http_status - if previous_observer is not None: - previous_observer(event) - if emit_retry: - _send_depot_telemetry( - "download_retry", - severity="warning", - message=event.reason, - metadata={ - "resource_type": ( - "repair_file" if operation == "repair" else "game" - ), - "file": event.file, - "attempt": event.attempt, - "failure_type": classify_network_failure(event.reason), - "http_status": event.http_status, - "mirror_id": _server_display_name(event.server), - }, - context={"operation": operation, "stage": "download_retry"}, - enabled=telemetry_enabled, - ) - if emit_resume: - _send_depot_telemetry( - "download_resumed", - metadata={ - "resource_type": ( - "repair_file" if operation == "repair" else "game" - ), - "file": event.file, - "resumed_bytes": event.resumed_bytes, - "mirror_id": _server_display_name(event.server), - }, - context={"operation": operation, "stage": "download_resume"}, - enabled=telemetry_enabled, - ) - if event.kind != "chunk": - emit_download_progress(event.file, force=force) - - def download(planned_download: PlannedDownload) -> None: - target = _safe_target(root, planned_download.file.path) - _ensure_target_parent(root, target) - - def report_file_bytes(written: int) -> None: - with lock: - current_bytes[planned_download.file.path] = written - emit_download_progress(planned_download.file.path) - - client.download_file( - depot_snapshot.server, - planned_download.file, - target, - progress=report_file_bytes if progress is not None else None, - ) - - def metrics_snapshot() -> DepotDownloadMetrics: - with lock: - duration_seconds = max(0.0, time.monotonic() - started_at) - primary_mirror = _server_display_name(depot_snapshot.server) - return DepotDownloadMetrics( - duration_ms=round(duration_seconds * 1000), - transferred_bytes=transferred_bytes, - retry_count=retry_count, - resume_used=bool(resume_offsets), - resumed_bytes=sum(resume_offsets.values()), - average_mbps=_average_megabits_per_second( - transferred_bytes, - duration_seconds, - ), - mirror_id=primary_mirror, - http_status=last_http_status, - ) - - maximum_workers = min(options.effective_maximum_workers, len(plan.downloads)) - # Start large transfers first so they overlap the small-file request workload - # instead of becoming a single-connection tail at the end of an install. - remaining_items: deque[PlannedDownload] = deque( - sorted(plan.downloads, key=lambda item: item.file.size, reverse=True) - ) - client.download_observer = observe_download - client.bandwidth_limiter = limiter - try: - with ThreadPoolExecutor(max_workers=maximum_workers) as executor: - future_items: dict[Future[None], PlannedDownload] = {} - - def submit_until(worker_target: int) -> None: - nonlocal active_workers, target_workers - with lock: - target_workers = worker_target - while remaining_items and len(future_items) < worker_target: - next_item = remaining_items.popleft() - with lock: - active_workers += 1 - try: - submitted_future = executor.submit(download, next_item) - except BaseException: - with lock: - active_workers -= 1 - remaining_items.appendleft(next_item) - raise - future_items[submitted_future] = next_item - - try: - initial_target = controller.target_workers( - len(remaining_items), - plan.bytes_total, - ) - submit_until(initial_target) - while future_items: - done, _pending = wait( - tuple(future_items), - timeout=ADAPTIVE_EVALUATION_INTERVAL_SECONDS, - return_when=FIRST_COMPLETED, - ) - completed_items: list[PlannedDownload] = [] - for future in done: - completed_item = future_items.pop(future) - future.result() - completed_items.append(completed_item) - for completed_item in completed_items: - with lock: - active_workers -= 1 - completed_files += 1 - completed_bytes += completed_item.file.size - current_bytes.pop(completed_item.file.path, None) - is_final_download = completed_files == len(plan.downloads) - emit_download_progress( - completed_item.file.path, - force=is_final_download, - ) - with lock: - current_servers.pop(completed_item.file.path, None) - with lock: - in_progress_bytes = sum(current_bytes.values()) - remaining_file_count = len(remaining_items) + len(future_items) - remaining_byte_count = max( - 0, - plan.bytes_total - completed_bytes - in_progress_bytes, - ) - next_target = controller.target_workers( - remaining_file_count, - remaining_byte_count, - ) - submit_until(next_target) - except BaseException: - for future in future_items: - future.cancel() - raise - except BaseException as exc: - metrics = metrics_snapshot() - _send_depot_telemetry( - "download_failed", - severity="error", - message=str(exc), - metadata={ - "resource_type": "repair_file" if operation == "repair" else "game", - "download_bytes": metrics.transferred_bytes, - "content_bytes": plan.bytes_total, - "duration_ms": metrics.duration_ms, - "average_mbps": metrics.average_mbps, - "retry_count": metrics.retry_count, - "resume_used": metrics.resume_used, - "resumed_bytes": metrics.resumed_bytes, - "http_status": metrics.http_status, - "failure_type": classify_network_failure(exc), - "mirror_id": metrics.mirror_id, - "mirrors_tried": sorted(mirrors), - }, - context={"operation": operation, "stage": "download"}, - enabled=telemetry_enabled, - ) - raise - finally: - client.download_observer = previous_observer - client.bandwidth_limiter = previous_limiter - - metrics = metrics_snapshot() - _send_depot_telemetry( - completed_event, - metadata={ - "resource_type": "repair_file" if operation == "repair" else "game", - "download_bytes": metrics.transferred_bytes, - "content_bytes": plan.bytes_total, - "duration_ms": metrics.duration_ms, - "average_mbps": metrics.average_mbps, - "retry_count": metrics.retry_count, - "resume_used": metrics.resume_used, - "resumed_bytes": metrics.resumed_bytes, - "http_status": metrics.http_status, - "mirror_id": metrics.mirror_id, - "mirrors_used": sorted(mirrors), - "file_count": len(plan.downloads), - }, - context={"operation": operation, "stage": "download"}, - enabled=telemetry_enabled, - ) - return metrics - - -def _apply_deletions(root: Path, relative_paths: Iterable[str]) -> int: - deleted = 0 - for relative_path in relative_paths: - target = _safe_target(root, relative_path) - if target.is_symlink() or target.is_file(): - target.unlink() - deleted += 1 - return deleted - - -def _apply_create_if_missing( - root: Path, - entries: Iterable[CreateIfMissing], -) -> int: - created = 0 - for entry in entries: - target = _safe_target(root, entry.path) - if target.is_symlink(): - raise DepotError(f"refusing to follow symbolic link for {entry.path}") - if target.exists(): - continue - content = _decode_create_if_missing(entry) - _ensure_target_parent(root, target) - temporary = target.with_name(f".{target.name}.{uuid4().hex}.partial") - try: - with temporary.open("xb") as handle: - handle.write(content) - handle.flush() - os.fsync(handle.fileno()) - temporary.replace(target) - except BaseException: - temporary.unlink(missing_ok=True) - raise - created += 1 - return created - - -def _catalog_entry( - file: DepotFile, - state: os.stat_result, - *, - verified: bool, -) -> DepotFileCatalogEntry: - return DepotFileCatalogEntry( - path=file.path, - size=file.size, - sha1=file.sha1.casefold(), - device=state.st_dev, - inode=state.st_ino, - mtime_ns=state.st_mtime_ns, - ctime_ns=state.st_ctime_ns, - verified=verified, - ) - - -def _scan_game_tree(root: Path) -> dict[str, _ScannedGameEntry]: - """Inventory files without following symlinks or resolving every manifest path.""" - - entries: dict[str, _ScannedGameEntry] = {} - - def scan(directory: Path, relative_parent: PurePosixPath) -> None: - children = _scan_directory(directory) - for child in children: - relative = relative_parent / child.name - key = relative.as_posix() - try: - state = child.stat(follow_symlinks=False) - except OSError as exc: - raise DepotError(f"cannot inspect managed game path {key}: {exc}") from exc - path = Path(child.path) - if stat.S_ISLNK(state.st_mode): - entries[key] = _ScannedGameEntry(path, state, "symbolic link") - elif stat.S_ISDIR(state.st_mode): - scan(path, relative) - elif stat.S_ISREG(state.st_mode): - entries[key] = _ScannedGameEntry(path, state, "file") - else: - entries[key] = _ScannedGameEntry(path, state, "not a regular file") - - scan(root, PurePosixPath()) - return entries - - -def _scan_directory(directory: Path) -> list[os.DirEntry[str]]: - try: - with os.scandir(directory) as iterator: - return list(iterator) - except OSError as exc: - raise DepotError(f"cannot scan managed game directory {directory}: {exc}") from exc - - -def _catalog_entry_from_cached( - entry: DepotFileCatalogEntry, - state: os.stat_result, -) -> DepotFileCatalogEntry: - return DepotFileCatalogEntry( - path=entry.path, - size=entry.size, - sha1=entry.sha1, - device=state.st_dev, - inode=state.st_ino, - mtime_ns=state.st_mtime_ns, - ctime_ns=state.st_ctime_ns, - verified=True, - ) - - -def _catalog_identity_matches( - entry: DepotFileCatalogEntry, - state: os.stat_result, -) -> bool: - return ( - entry.device == state.st_dev - and entry.inode == state.st_ino - and entry.size == state.st_size - and entry.mtime_ns == state.st_mtime_ns - and entry.ctime_ns == state.st_ctime_ns - ) - - -def _depot_file_catalog_payload(catalog: DepotFileCatalog) -> dict[str, object]: - """Use compact rows because this cache contains tens of thousands of files.""" - - return { - "schema": DEPOT_FILE_CATALOG_SCHEMA, - "build": catalog.build, - "created_at": catalog.created_at, - "files": [ - [ - entry.path, - entry.size, - entry.sha1, - entry.device, - entry.inode, - entry.mtime_ns, - entry.ctime_ns, - entry.verified, - ] - for entry in catalog.files - ], - } - - -def _parse_depot_file_catalog_entry(value: object) -> DepotFileCatalogEntry: - if not isinstance(value, list) or len(value) != 8: - raise DepotError("cached depot file entry must be an eight-value row") - path = _normalise_relative_path(value[0], "cached depot file path") - size = _non_negative_int(value[1], f"cached depot file size for {path}") - sha1 = _string(value[2], f"cached depot file hash for {path}").casefold() - if not SHA1_PATTERN.fullmatch(sha1): - raise DepotError(f"invalid cached SHA-1 hash for {path}") - identity: dict[str, int] = {} - for field_name, field_value in zip( - ("device", "inode", "mtime_ns", "ctime_ns"), - value[3:7], - strict=True, - ): - identity[field_name] = _non_negative_int( - field_value, - f"cached depot file {field_name} for {path}", - ) - verified = value[7] - if not isinstance(verified, bool): - raise DepotError(f"cached depot verification flag is invalid for {path}") - return DepotFileCatalogEntry( - path=path, - size=size, - sha1=sha1, - device=identity["device"], - inode=identity["inode"], - mtime_ns=identity["mtime_ns"], - ctime_ns=identity["ctime_ns"], - verified=verified, - ) - - -def _completed_depot_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 - - -def _completed_depot_version(path: Path) -> str | 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 - version = value.get("version") - return version[:32] if isinstance(version, str) and version else None - - -def _elapsed_milliseconds(started: float) -> int: - return max(0, round((time.perf_counter() - started) * 1000)) - - -def _average_megabits_per_second(byte_count: int, duration_seconds: float) -> float | None: - if byte_count <= 0 or duration_seconds <= 0: - return None - return round((byte_count * 8) / duration_seconds / 1_000_000, 3) - - -def _download_event_names(operation: str) -> tuple[str, str]: - if operation == "install": - return "install_download_started", "install_download_completed" - if operation == "update": - return "update_download_started", "update_download_completed" - return "download_started", "download_completed" - - -def _send_depot_telemetry( - event: str, - *, - severity: str = "info", - message: str | None = None, - metadata: Mapping[str, object] | None = None, - context: Mapping[str, object] | None = None, - enabled: bool = True, -) -> None: - if not enabled: - return - telemetry = current_telemetry() - if telemetry is None: - return - try: - telemetry.send_event( - event, - severity=severity, - message=message, - metadata=metadata, - context=context, - ) - except Exception: - # Telemetry must never alter verified depot behavior. - pass - - -def _sync_result( - snapshot: DepotSnapshot, - plan: UpdatePlan, - files_downloaded: int, - bytes_downloaded: int, - files_deleted: int, - repair: bool, - dry_run: bool, - *, - created: int = 0, - quarantine: QuarantineResult = QuarantineResult(0, None), - manifest_duration_ms: int = 0, - scan_duration_ms: int = 0, - apply_duration_ms: int = 0, - update_duration_ms: int = 0, - download_metrics: DepotDownloadMetrics | None = None, -) -> DepotSyncResult: - metrics = download_metrics or DepotDownloadMetrics() - return DepotSyncResult( - region=snapshot.region, - server=snapshot.server, - name=snapshot.name, - build=snapshot.build, - version=snapshot.version, - files_total=plan.files_total, - files_planned=len(plan.downloads), - bytes_planned=plan.bytes_total, - files_downloaded=files_downloaded, - bytes_downloaded=bytes_downloaded, - files_deleted=files_deleted, - files_created=created, - files_quarantined=quarantine.files, - quarantine_directory=quarantine.directory, - repair=repair, - dry_run=dry_run, - completed_at=datetime.now().astimezone().isoformat(), - manifest_duration_ms=manifest_duration_ms, - scan_duration_ms=scan_duration_ms, - download_duration_ms=metrics.duration_ms, - apply_duration_ms=apply_duration_ms, - update_duration_ms=update_duration_ms, - network_bytes_downloaded=metrics.transferred_bytes, - download_retry_count=metrics.retry_count, - download_resume_used=metrics.resume_used, - download_average_mbps=metrics.average_mbps, - mirror_id=metrics.mirror_id, - ) - - -def _parse_snapshot( - region: str, - server: str, - depot_manifest: Mapping[str, object], - build_manifest: Mapping[str, object], -) -> DepotSnapshot: - build = _positive_int(depot_manifest.get("Build"), "depot Build") - build_manifest_build = _positive_int(build_manifest.get("b"), "build manifest b") - if build_manifest_build != build: - raise DepotError( - f"depot build {build} does not match build manifest {build_manifest_build}" - ) - name = _string(depot_manifest.get("Name"), "depot Name") - version = _string(depot_manifest.get("BuildVersionText"), "depot BuildVersionText") - files_value = build_manifest.get("f") - if not isinstance(files_value, list): - raise DepotError("build manifest f must be an array") - files: list[DepotFile] = [] - seen_paths: set[str] = set() - for value in files_value: - if not isinstance(value, dict): - raise DepotError("build manifest contains a non-object file entry") - path = _normalise_relative_path(value.get("p"), "file path") - if path.casefold() in seen_paths: - raise DepotError(f"build manifest has duplicate file path: {path}") - seen_paths.add(path.casefold()) - file_build = _positive_int(value.get("b"), f"file build for {path}") - size = _non_negative_int(value.get("s"), f"file size for {path}") - sha1 = _string(value.get("h"), f"file hash for {path}") - if not SHA1_PATTERN.fullmatch(sha1): - raise DepotError(f"invalid SHA-1 hash for {path}") - files.append(DepotFile(path, file_build, size, sha1.casefold())) - - deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths) - create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing")) - start_options, default_start_option = _parse_start_options( - depot_manifest.get("StartOptions"), - depot_manifest.get("DefaultStartOption"), - ) - canonical_file_paths = {file.path.casefold(): file.path for file in files} - start_options = tuple( - DepotStartOption( - name=option.name, - path=canonical_file_paths.get(option.path.casefold(), option.path), - arguments=option.arguments, - ) - for option in start_options - ) - return DepotSnapshot( - region=region, - server=server, - name=name, - build=build, - version=version, - files=tuple(files), - deleted_files=deleted, - create_if_missing=create_if_missing, - start_options=start_options, - default_start_option=default_start_option, - ) - - -def _parse_deleted_paths(value: object, known_paths: set[str]) -> tuple[str, ...]: - if value is None: - return () - if not isinstance(value, list): - raise DepotError("build manifest df must be an array") - paths: list[str] = [] - seen: set[str] = set() - for item in value: - path = _normalise_relative_path(item, "deleted file path") - folded = path.casefold() - if folded in known_paths: - raise DepotError(f"manifest lists a file as both present and deleted: {path}") - if folded in seen: - continue - seen.add(folded) - paths.append(path) - return tuple(paths) - - -def _parse_create_if_missing(value: object) -> tuple[CreateIfMissing, ...]: - if value is None: - return () - if not isinstance(value, list): - raise DepotError("CreateIfMissing must be an array") - entries: list[CreateIfMissing] = [] - seen: set[str] = set() - for item in value: - if not isinstance(item, dict): - raise DepotError("CreateIfMissing contains a non-object entry") - path = _normalise_relative_path(item.get("Path"), "CreateIfMissing path") - folded = path.casefold() - if folded in seen: - raise DepotError(f"CreateIfMissing has duplicate path: {path}") - seen.add(folded) - content = _string(item.get("Content"), f"CreateIfMissing content for {path}") - entry = CreateIfMissing(path, content) - _decode_create_if_missing(entry) - entries.append(entry) - return tuple(entries) - - -def _parse_start_options( - value: object, - default_value: object, -) -> tuple[tuple[DepotStartOption, ...], str]: - """Validate the launch targets published by the official depot manifest.""" - - # Older test fixtures and historical manifests did not expose StartOptions. - # Babel remains the launcher-safe compatibility default for that case. - if value is None: - return (DEFAULT_DEPOT_START_OPTION,), DEFAULT_DEPOT_START_OPTION.name - if not isinstance(value, list) or not value: - raise DepotError("StartOptions must be a non-empty array") - options: list[DepotStartOption] = [] - seen_names: set[str] = set() - for item in value: - if not isinstance(item, dict): - raise DepotError("StartOptions contains a non-object entry") - name = _start_option_text(item.get("Name"), "StartOptions Name", 64) - folded_name = name.casefold() - if folded_name in seen_names: - raise DepotError(f"StartOptions has duplicate realm name: {name}") - seen_names.add(folded_name) - path = _normalise_relative_path(item.get("Path"), f"StartOptions path for {name}") - if PurePosixPath(path).suffix.casefold() != ".exe": - raise DepotError(f"StartOptions path is not an executable for {name}: {path}") - arguments = _start_option_text( - item.get("Arguments"), - f"StartOptions arguments for {name}", - 2048, - ) - options.append(DepotStartOption(name=name, path=path, arguments=arguments)) - - if default_value is None: - default_name = next( - ( - option.name - for option in options - if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold() - ), - options[0].name, - ) - else: - default_name = _start_option_text( - default_value, - "DefaultStartOption", - 64, - ) - matching_default = next( - (option.name for option in options if option.name.casefold() == default_name.casefold()), - None, - ) - if matching_default is None: - raise DepotError( - f"DefaultStartOption does not match an advertised realm: {default_name}" - ) - default_name = matching_default - return tuple(options), default_name - - -def _start_option_text(value: object, field: str, maximum_length: int) -> str: - if not isinstance(value, str): - raise DepotError(f"{field} must be a string") - text = value.strip() - if not text or len(text) > maximum_length or any(ord(character) < 32 for character in text): - raise DepotError(f"invalid {field}") - return text - - -def _decode_create_if_missing(entry: CreateIfMissing) -> bytes: - try: - content = b64decode(entry.content_base64, validate=True) - except (ValueError, BinAsciiError) as exc: - raise DepotError(f"invalid base64 create-if-missing entry: {entry.path}") from exc - if len(content) > MAX_CREATE_IF_MISSING_BYTES: - raise DepotError(f"create-if-missing entry is too large: {entry.path}") - return content - - -def _server_candidates( - configuration: Mapping[str, object] | None, - preferred_region: str, -) -> tuple[tuple[str, str], ...]: - entries: list[tuple[str, str]] = [] - if configuration is not None: - regions = configuration.get("Regions") - default_region = configuration.get("DefaultRegion") - if isinstance(regions, list): - ordered = [preferred_region] - if isinstance(default_region, str) and default_region not in ordered: - ordered.append(default_region) - for region_name in ordered: - for region in regions: - if not isinstance(region, dict) or region.get("Name") != region_name: - continue - servers = region.get("Servers") - if isinstance(servers, list): - for server in servers: - if not isinstance(server, str): - continue - try: - entries.append((region_name, _normalise_server(server))) - except DepotError: - continue - seen: set[str] = set() - unique: list[tuple[str, str]] = [] - for region, server in [*entries, *(("Fallback", value) for value in FALLBACK_SERVERS)]: - if server in seen: - continue - seen.add(server) - unique.append((region, server)) - return tuple(unique) - - -def _normalise_server(value: str) -> str: - server = value.strip().strip("/") - if not server or "://" in server or any(character.isspace() for character in server): - raise DepotError(f"invalid CDN server: {value!r}") - parsed = urlparse(f"https://{server}") - if not parsed.netloc or parsed.query or parsed.fragment or "@" in parsed.netloc: - raise DepotError(f"invalid CDN server: {value!r}") - parts = PurePosixPath(parsed.path).parts - if any(part in (".", "..") for part in parts): - raise DepotError(f"invalid CDN server path: {value!r}") - return f"{parsed.netloc}{parsed.path.rstrip('/')}" - - -def _depot_manifest_url(server: str) -> str: - return f"https://{server}/depots/{DEPOT_ID}/manifest.json" - - -def _build_manifest_url(server: str, build: int) -> str: - return f"https://{server}/depots/{DEPOT_ID}/builds/{build}.json" - - -def _file_url(server: str, file: DepotFile) -> str: - return ( - f"https://{server}/depots/{DEPOT_ID}/builds/{file.build}/" - f"{quote(file.path, safe='/')}" - ) - - -def _ordered_download_servers( - primary: str, - candidates: Iterable[str], -) -> tuple[str, ...]: - ordered: list[str] = [] - seen: set[str] = set() - for value in (primary, *candidates): - server = _normalise_server(value) - if server in seen: - continue - seen.add(server) - ordered.append(server) - return tuple(ordered) - - -def _download_partial_path(destination: Path) -> Path: - return destination.with_name(f".{destination.name}.ptlinux.partial") - - -def _recover_crlf_manifest_file( - temporary: Path, - destination: Path, - file: DepotFile, -) -> bool: - """Restore publisher-normalized legacy text only on an exact hash match.""" - - try: - if temporary.stat().st_size > MAX_LINE_ENDING_RECOVERY_BYTES: - return False - data = temporary.read_bytes() - except OSError as exc: - raise DepotError(f"could not inspect downloaded {file.path}: {exc}") from exc - if b"\r" in data: - return False - recovered = data.replace(b"\n", b"\r\n") - if len(recovered) != file.size: - return False - if ( - hashlib.sha1(recovered, usedforsecurity=False).hexdigest().casefold() - != file.sha1.casefold() - ): - return False - recovered_temporary = destination.with_name( - f".{destination.name}.{os.getpid()}.{uuid4().hex}.recovered" - ) - try: - with recovered_temporary.open("xb") as handle: - handle.write(recovered) - handle.flush() - os.fsync(handle.fileno()) - recovered_temporary.replace(destination) - except BaseException: - recovered_temporary.unlink(missing_ok=True) - raise - return True - - -def _prepare_game_root(game_directory: Path, *, create: bool) -> Path: - root = game_directory.expanduser() - if root.is_symlink(): - raise DepotError(f"managed game directory must not be a symbolic link: {root}") - if root.exists() and not root.is_dir(): - raise DepotError(f"managed game path is not a directory: {root}") - if create: - root.mkdir(parents=True, exist_ok=True) - return root.resolve(strict=False) - - -def _safe_target(root: Path, relative_path: str) -> Path: - parts = _relative_parts(relative_path, "depot path") - target = root.joinpath(*parts) - try: - target.resolve(strict=False).relative_to(root) - except ValueError as exc: - raise DepotError(f"depot path escapes the managed game directory: {relative_path}") from exc - return target - - -def _ensure_target_parent(root: Path, target: Path) -> None: - try: - relative_parent = target.parent.relative_to(root) - except ValueError as exc: - raise DepotError(f"target escapes managed game directory: {target}") from exc - cursor = root - for part in relative_parent.parts: - cursor = cursor / part - if cursor.is_symlink(): - raise DepotError(f"refusing to traverse symbolic link in game tree: {cursor}") - if cursor.exists(): - if not cursor.is_dir(): - raise DepotError(f"cannot create game directory over file: {cursor}") - else: - try: - cursor.mkdir() - except FileExistsError: - # Another download worker can create a shared parent after - # ``exists()`` and before ``mkdir()``. Accept only the directory - # that the losing worker expected; never accept a file or symlink. - if cursor.is_symlink(): - raise DepotError( - f"refusing to traverse symbolic link in game tree: {cursor}" - ) - if not cursor.is_dir(): - raise DepotError(f"cannot create game directory over file: {cursor}") - except OSError as exc: - raise DepotError(f"cannot create game directory {cursor}: {exc}") from exc - - -def _normalise_relative_path(value: object, field: str) -> str: - if not isinstance(value, str): - raise DepotError(f"{field} must be a string") - parts = _relative_parts(value, field) - return "/".join(parts) - - -def _relative_parts(value: str, field: str) -> tuple[str, ...]: - if not value or "\x00" in value: - raise DepotError(f"invalid {field}") - normalised = value.replace("\\", "/") - if normalised.startswith("/") or normalised.startswith("//"): - raise DepotError(f"{field} must be relative: {value!r}") - parts = PurePosixPath(normalised).parts - if not parts or any(part in ("", ".", "..") or ":" in part for part in parts): - raise DepotError(f"invalid {field}: {value!r}") - return tuple(parts) - - -def _positive_int(value: object, field: str) -> int: - if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise DepotError(f"{field} must be a positive integer") - return value - - -def _non_negative_int(value: object, field: str) -> int: - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise DepotError(f"{field} must be a non-negative integer") - return value - - -def _string(value: object, field: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise DepotError(f"{field} must be a non-empty string") - return value - - -def _sha1(path: Path) -> str: - digest = hashlib.sha1(usedforsecurity=False) - try: - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(HASH_READ_SIZE), b""): - digest.update(chunk) - except OSError as exc: - raise DepotError(f"cannot hash {path}: {exc}") from exc - 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 DepotError(f"invalid Content-Length: {value!r}") from exc - if length < 0: - raise DepotError(f"invalid Content-Length: {value!r}") - return length - - -def _content_range(value: str | None) -> tuple[int, int, int]: - if value is None: - raise DepotError("206 response omitted Content-Range") - match = CONTENT_RANGE_PATTERN.fullmatch(value.strip()) - if match is None: - raise DepotError(f"invalid Content-Range: {value!r}") - start, end, total = (int(part) for part in match.groups()) - if start > end or end >= total: - raise DepotError(f"invalid Content-Range: {value!r}") - return start, end, total - - -def _normalized_cpu_load() -> float | None: - """Return one-minute runnable load normalized by the available CPU count.""" - - try: - load = os.getloadavg()[0] - except (AttributeError, OSError): - return None - processors = os.cpu_count() - if processors is None or processors <= 0: - return None - return max(0.0, load / processors) - - -def _prune_transfer_samples( - samples: deque[tuple[float, int]], - now: float, -) -> None: - cutoff = now - TRANSFER_RATE_WINDOW_SECONDS - while len(samples) > 2 and samples[1][0] <= cutoff: - samples.popleft() - - -def _sample_rate(samples: deque[tuple[float, int]]) -> float | None: - if len(samples) < 2: - return None - first_time, first_bytes = samples[0] - last_time, last_bytes = samples[-1] - elapsed = last_time - first_time - if elapsed <= 0 or last_bytes < first_bytes: - return None - return (last_bytes - first_bytes) / elapsed - - -def _server_display_name(server: str) -> str: - parsed = urlparse(server if "://" in server else f"//{server}") - return parsed.hostname or server.split("/", 1)[0] - - -def _headers() -> dict[str, str]: - return {"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"} - - -def _write_json(path: Path, value: object, *, compact: bool = False) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") - try: - payload = ( - json.dumps(value, separators=(",", ":"), sort_keys=True) - if compact - else json.dumps(value, indent=2, sort_keys=True) - ) - temporary.write_text(payload + "\n", encoding="utf-8") - temporary.replace(path) - except BaseException: - temporary.unlink(missing_ok=True) - raise - - -def _emit_progress( - callback: ProgressCallback | None, - phase: str, - files_completed: int, - files_total: int, - bytes_completed: int, - bytes_total: int, - current_file: str | None, -) -> None: - if callback is not None: - callback( - SyncProgress( - phase, - files_completed, - files_total, - bytes_completed, - bytes_total, - current_file, - bytes_remaining=max(0, bytes_total - bytes_completed), - ) - ) diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py deleted file mode 100644 index 075ed45..0000000 --- a/ptlinux/self_update.py +++ /dev/null @@ -1,3066 +0,0 @@ -"""Signed, atomic self-updates for the native Linux launcher.""" - -from __future__ import annotations - -from contextlib import contextmanager -from dataclasses import asdict, dataclass, replace -from datetime import datetime -import hashlib -import json -import os -from pathlib import Path, PurePosixPath -import re -import shutil -import stat -import subprocess -import sys -import tarfile -import tempfile -import time -from typing import Iterator, Mapping -from urllib.error import HTTPError, URLError -from urllib.parse import urlsplit, urlunsplit -from urllib.request import Request, urlopen - -from . import __version__ -from .locking import launcher_operation_lock, managed_game_process_is_active -from .paths import AppPaths -from .telemetry import ( - classify_network_failure, - current_telemetry, - network_http_status, -) -from .update_resolver import ( - ACTIVE_STATUS, - PRERELEASE_CHANNEL, - STABLE_CHANNEL, - SUPPORTED_RELEASE_STATUSES, - ReleaseModelError, - ReleaseRecord, - SemanticVersion, - UpdateResolver, - validate_channel, - validate_channel_version, -) - - -DEFAULT_UPDATE_REPOSITORY_URL = ( - "https://git.m4ko.net/m4ko/pristontale-eu-linux" -) -DEFAULT_STABLE_UPDATE_MANIFEST_URL = ( - f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/stable.json" -) -DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL = ( - f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/prerelease.json" -) -DEFAULT_UPDATE_HISTORY_URL = ( - f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/history.json" -) -# Retain the original name for callers importing the stable default. -DEFAULT_UPDATE_MANIFEST_URL = DEFAULT_STABLE_UPDATE_MANIFEST_URL -UPDATE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_MANIFEST_URL" -UPDATE_STABLE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_STABLE_MANIFEST_URL" -UPDATE_PRERELEASE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_PRERELEASE_MANIFEST_URL" -UPDATE_HISTORY_URL_VARIABLE = "PTLINUX_UPDATE_HISTORY_URL" -UPDATE_PUBLIC_KEY_VARIABLE = "PTLINUX_UPDATE_PUBLIC_KEY" -PUBLIC_KEY_FILENAME = "launcher-update-public.pem" -UPDATE_APPLICATION = "PristonTale-EU" -USER_AGENT = f"PT-Linux/{__version__} launcher-updater" -READ_SIZE = 256 * 1024 -MAX_MANIFEST_BYTES = 128 * 1024 -MAX_HISTORY_BYTES = 512 * 1024 -MAX_SIGNATURE_BYTES = 16 * 1024 -MAX_EMBEDDED_RELEASE_NOTES_BYTES = 64 * 1024 -MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 -MAX_EXTRACTED_BYTES = 64 * 1024 * 1024 -MAX_ARCHIVE_MEMBERS = 512 -UPDATE_ARCHIVE_PREFIX = "pristontale-eu-linux-update-v" -_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z") -_ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"} -UPDATE_CHECK_CACHE_SCHEMA = 2 -RELEASE_HISTORY_SCHEMA = 1 -MAX_RELEASE_HISTORY = 200 -LOCAL_OPERATION_HISTORY_SCHEMA = 1 -MAX_LOCAL_OPERATION_HISTORY = 30 - - -class LauncherUpdateError(RuntimeError): - """Raised when a launcher update cannot be trusted or installed safely.""" - - -@dataclass(frozen=True) -class ManifestFile: - path: str - size: int - sha256: str - - -@dataclass(frozen=True) -class UpdateManifest: - schema: int - channel: str - version: str - minimum_updater_version: str - archive_url: str - archive_sha256: str - archive_size: int - published_at: str - notes_url: str | None - tag: str | None = None - commit: str | None = None - status: str = ACTIVE_STATUS - title: str | None = None - summary: str | None = None - notes_markdown: str | None = None - archive_name: str | None = None - files: tuple[ManifestFile, ...] = () - manifest_sha256: str | None = None - config_schema: int = 1 - minimum_config_schema: int = 1 - - -@dataclass(frozen=True) -class LauncherUpdateCheck: - current_version: str - latest_version: str - available: bool - supported: bool - reason: str - manifest_url: str - manifest: UpdateManifest - checked_at: str | None = None - selected_channel: str = STABLE_CHANNEL - - -@dataclass(frozen=True) -class LauncherUpdateResult: - changed: bool - current_version: str - installed_version: str - release_directory: str | None - previous_release: str | None - archive: str | None - archive_sha256: str | None - restart_required: bool - state_recorded: bool - warning: str | None - selected_channel: str = STABLE_CHANNEL - installed_channel: str = STABLE_CHANNEL - tag: str | None = None - manifest_schema: int | None = None - manifest_sha256: str | None = None - - -@dataclass(frozen=True) -class LauncherUpdateVerification: - current_version: str - release_version: str - manifest_url: str - archive_url: str - archive_size: int - archive_sha256: str - payload_files: int - channel: str = STABLE_CHANNEL - tag: str | None = None - manifest_sha256: str | None = None - - -@dataclass(frozen=True) -class LauncherRollbackResult: - from_version: str - to_version: str - current_release: str - previous_release: str - restart_required: bool - from_channel: str = STABLE_CHANNEL - to_channel: str = STABLE_CHANNEL - state_recorded: bool = True - warning: str | None = None - - -@dataclass(frozen=True) -class LauncherInstalledRelease: - """One validated release retained in the launcher's version store.""" - - version: str - installed_at: str - directory: str - active: bool - previous: bool - channel: str = STABLE_CHANNEL - - -@dataclass(frozen=True) -class LauncherUpdateHistory: - """Validated launcher releases available for inspection or rollback.""" - - current_version: str | None - previous_version: str | None - releases: tuple[LauncherInstalledRelease, ...] - - -@dataclass(frozen=True) -class LauncherRemoteRelease: - """One independently installable release from signed remote history.""" - - version: str - channel: str - tag: str - manifest_url: str - manifest_signature_url: str - release_date: str - release_notes_url: str - status: str - title: str - release_id: int | None = None - config_schema: int | None = None - minimum_config_schema: int | None = None - - def resolver_record(self) -> ReleaseRecord: - return ReleaseRecord( - version=self.version, - channel=self.channel, - status=self.status, - tag=self.tag, - manifest_url=self.manifest_url, - manifest_signature_url=self.manifest_signature_url, - release_date=self.release_date, - release_notes_url=self.release_notes_url, - compatibility={ - "config_schema": self.config_schema, - "minimum_config_schema": self.minimum_config_schema, - }, - ) - - -@dataclass(frozen=True) -class LauncherRemoteHistory: - """Authenticated release catalog used for history and remote rollback.""" - - generated_at: str - history_url: str - releases: tuple[LauncherRemoteRelease, ...] - history_sha256: str - - -def default_public_key_path() -> Path: - return Path(__file__).with_name(PUBLIC_KEY_FILENAME) - - -def configured_manifest_url( - environment: Mapping[str, str] | None = None, - *, - channel: str = STABLE_CHANNEL, -) -> str: - """Return the direct channel URL retained for legacy callers.""" - - urls = configured_manifest_urls(channel=channel, environment=environment) - return urls[-1][1] - - -def configured_manifest_urls( - *, - channel: str = STABLE_CHANNEL, - environment: Mapping[str, str] | None = None, -) -> tuple[tuple[str, str, bool], ...]: - """Return channel, URL, and optionality for authenticated discovery.""" - - try: - validate_channel(channel) - except ReleaseModelError as exc: - raise LauncherUpdateError(str(exc)) from exc - env = os.environ if environment is None else environment - generic_override = env.get(UPDATE_MANIFEST_URL_VARIABLE) - if generic_override: - return ((channel, generic_override, False),) - stable_url = env.get( - UPDATE_STABLE_MANIFEST_URL_VARIABLE, - DEFAULT_STABLE_UPDATE_MANIFEST_URL, - ) - if channel == STABLE_CHANNEL: - return ((STABLE_CHANNEL, stable_url, False),) - prerelease_url = env.get( - UPDATE_PRERELEASE_MANIFEST_URL_VARIABLE, - DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL, - ) - return ( - (STABLE_CHANNEL, stable_url, False), - (PRERELEASE_CHANNEL, prerelease_url, True), - ) - - -def configured_public_key_path( - environment: Mapping[str, str] | None = None, -) -> Path: - env = os.environ if environment is None else environment - configured = env.get(UPDATE_PUBLIC_KEY_VARIABLE) - return Path(configured).expanduser() if configured else default_public_key_path() - - -def configured_history_url( - environment: Mapping[str, str] | None = None, -) -> str: - env = os.environ if environment is None else environment - return env.get(UPDATE_HISTORY_URL_VARIABLE, DEFAULT_UPDATE_HISTORY_URL) - - -def fetch_launcher_release_history( - *, - history_url: str | None = None, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, - timeout_seconds: float = 20.0, -) -> LauncherRemoteHistory: - """Fetch and authenticate the immutable-release catalog used for rollback.""" - - url = history_url or configured_history_url(environment) - _require_https_url(url, "release history URL") - key = public_key_path or configured_public_key_path(environment) - history_bytes = _fetch_small_file( - url, - maximum_bytes=MAX_HISTORY_BYTES, - timeout_seconds=timeout_seconds, - label="release history", - ) - signature_bytes = _fetch_small_file( - _signature_url(url), - maximum_bytes=MAX_SIGNATURE_BYTES, - timeout_seconds=timeout_seconds, - label="release history signature", - ) - _verify_manifest_signature(history_bytes, signature_bytes, key) - return parse_launcher_release_history(history_bytes, history_url=url) - - -def cache_launcher_release_history( - cache_path: Path, - history: LauncherRemoteHistory, - *, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, -) -> bool: - """Cache authenticated history for display; installation always re-fetches it.""" - - key = public_key_path or configured_public_key_path(environment) - try: - _write_json( - cache_path, - { - "schema": RELEASE_HISTORY_SCHEMA, - "history_url": history.history_url, - "history_sha256": history.history_sha256, - "public_key_sha256": _sha256_file(key.expanduser().resolve()), - "generated_at": history.generated_at, - "releases": [ - _remote_release_to_history_dict(release) - for release in history.releases - ], - }, - ) - except (OSError, RuntimeError): - return False - return True - - -def read_cached_launcher_release_history( - cache_path: Path, - *, - history_url: str | None = None, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, -) -> LauncherRemoteHistory | None: - """Read display-only history previously authenticated with the current key.""" - - url = history_url or configured_history_url(environment) - key = public_key_path or configured_public_key_path(environment) - try: - _require_https_url(url, "release history URL") - value = json.loads(cache_path.read_text(encoding="utf-8")) - key_sha256 = _sha256_file(key.expanduser().resolve()) - except ( - OSError, - UnicodeError, - json.JSONDecodeError, - LauncherUpdateError, - RuntimeError, - ): - return None - if not isinstance(value, dict): - return None - if value.get("schema") != RELEASE_HISTORY_SCHEMA: - return None - if value.get("history_url") != url or value.get("public_key_sha256") != key_sha256: - return None - stored_digest = value.get("history_sha256") - if not isinstance(stored_digest, str) or _SHA256_PATTERN.fullmatch(stored_digest) is None: - return None - try: - serialized = { - "application": UPDATE_APPLICATION, - "generated_at": value.get("generated_at"), - "releases": value.get("releases"), - "schema": RELEASE_HISTORY_SCHEMA, - } - history = parse_launcher_release_history( - (json.dumps(serialized, sort_keys=True) + "\n").encode("utf-8"), - history_url=url, - ) - except (LauncherUpdateError, TypeError, ValueError): - return None - return replace(history, history_sha256=stored_digest) - - -def _remote_release_to_history_dict( - release: LauncherRemoteRelease, -) -> dict[str, object]: - return { - "channel": release.channel, - "compatibility": { - "config_schema": release.config_schema, - "minimum_config_schema": release.minimum_config_schema, - }, - "manifest_signature_url": release.manifest_signature_url, - "manifest_url": release.manifest_url, - "release_date": release.release_date, - "release_id": release.release_id, - "release_notes_url": release.release_notes_url, - "status": release.status, - "tag": release.tag, - "title": release.title, - "version": release.version, - } - - -def check_for_launcher_update( - *, - current_version: str = __version__, - manifest_url: str | None = None, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, - timeout_seconds: float = 20.0, - channel: str = STABLE_CHANNEL, -) -> LauncherUpdateCheck: - """Fetch authenticated channel metadata and resolve the greatest candidate.""" - - _parse_version(current_version, "current launcher version") - try: - validate_channel(channel) - except ReleaseModelError as exc: - raise LauncherUpdateError(str(exc)) from exc - configured = ( - ((channel, manifest_url, False),) - if manifest_url is not None - else configured_manifest_urls(channel=channel, environment=environment) - ) - key = public_key_path or configured_public_key_path(environment) - candidates: list[tuple[UpdateManifest, str]] = [] - for expected_channel, url, optional in configured: - _require_https_url(url, "update manifest URL") - try: - manifest = _fetch_authenticated_manifest( - url, - key, - timeout_seconds=timeout_seconds, - ) - except LauncherUpdateError as exc: - if optional and "HTTP 404" in str(exc): - continue - raise - if manifest.channel != expected_channel: - raise LauncherUpdateError( - f"{expected_channel} channel URL returned a {manifest.channel} manifest" - ) - candidates.append((manifest, url)) - if not candidates: - raise LauncherUpdateError(f"no authenticated {channel} release metadata is available") - try: - resolver = UpdateResolver( - ReleaseRecord( - version=manifest.version, - channel=manifest.channel, - status=manifest.status, - tag=manifest.tag, - manifest_url=url, - ) - for manifest, url in candidates - ) - selected = resolver.latest_eligible(channel) - except ReleaseModelError as exc: - raise LauncherUpdateError(str(exc)) from exc - if selected is None: - # Preserve authenticated metadata for a clear withdrawn/blocked status. - manifest, url = max( - candidates, - key=lambda item: _parse_version(item[0].version, "manifest version"), - ) - else: - manifest, url = next( - item for item in candidates if item[0].version == selected.version - ) - check = _evaluate_launcher_update( - manifest, - current_version, - url, - checked_at=datetime.now().astimezone().isoformat(timespec="seconds"), - selected_channel=channel, - ) - _send_launcher_release_event( - "update_release_discovered", - metadata=_launcher_release_metadata( - current_version=current_version, - target_version=manifest.version, - current_channel=SemanticVersion.parse(current_version).channel, - target_channel=manifest.channel, - selected_channel=channel, - tag=manifest.tag, - manifest_schema=manifest.schema, - manifest_sha256=manifest.manifest_sha256, - rollback=False, - ), - operation="launcher_update", - stage="discovery", - ) - return check - - -def _fetch_authenticated_manifest( - url: str, - public_key_path: Path, - *, - timeout_seconds: float, -) -> UpdateManifest: - manifest_bytes = _fetch_small_file( - url, - maximum_bytes=MAX_MANIFEST_BYTES, - timeout_seconds=timeout_seconds, - label="update manifest", - ) - signature_bytes = _fetch_small_file( - _signature_url(url), - maximum_bytes=MAX_SIGNATURE_BYTES, - timeout_seconds=timeout_seconds, - label="update manifest signature", - ) - _verify_manifest_signature(manifest_bytes, signature_bytes, public_key_path) - return parse_update_manifest(manifest_bytes) - - -def _fetch_historical_manifest( - release: LauncherRemoteRelease, - public_key_path: Path, - *, - timeout_seconds: float, -) -> UpdateManifest: - manifest_bytes = _fetch_small_file( - release.manifest_url, - maximum_bytes=MAX_MANIFEST_BYTES, - timeout_seconds=timeout_seconds, - label=f"launcher {release.version} manifest", - ) - signature_bytes = _fetch_small_file( - release.manifest_signature_url, - maximum_bytes=MAX_SIGNATURE_BYTES, - timeout_seconds=timeout_seconds, - label=f"launcher {release.version} manifest signature", - ) - _verify_manifest_signature(manifest_bytes, signature_bytes, public_key_path) - manifest = parse_update_manifest(manifest_bytes) - for field, actual, expected in ( - ("version", manifest.version, release.version), - ("channel", manifest.channel, release.channel), - ("tag", manifest.tag, release.tag), - ): - if actual != expected: - raise LauncherUpdateError( - f"historical manifest {field} {actual!r} does not match " - f"signed history {expected!r}" - ) - if ( - release.config_schema is not None - or release.minimum_config_schema is not None - ): - if release.config_schema is None or release.minimum_config_schema is None: - raise LauncherUpdateError( - "signed history has incomplete config compatibility metadata" - ) - manifest = replace( - manifest, - config_schema=release.config_schema, - minimum_config_schema=release.minimum_config_schema, - ) - return manifest - - -def read_cached_launcher_update_check( - cache_path: Path, - *, - current_version: str = __version__, - manifest_url: str | None = None, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, - channel: str = STABLE_CHANNEL, -) -> LauncherUpdateCheck | None: - """Read a display-only result from the last authenticated live check.""" - - try: - validate_channel(channel) - except ReleaseModelError: - return None - allowed_urls = ( - {manifest_url} - if manifest_url is not None - else {url for _source_channel, url, _optional in configured_manifest_urls( - channel=channel, - environment=environment, - )} - ) - key = public_key_path or configured_public_key_path(environment) - try: - _parse_version(current_version, "current launcher version") - for allowed_url in allowed_urls: - _require_https_url(allowed_url, "update manifest URL") - key_sha256 = _sha256_file(key.expanduser().resolve()) - value = json.loads(cache_path.read_text(encoding="utf-8")) - except ( - OSError, - UnicodeError, - json.JSONDecodeError, - LauncherUpdateError, - RuntimeError, - ): - return None - if not isinstance(value, dict): - return None - if value.get("schema") != UPDATE_CHECK_CACHE_SCHEMA: - return None - if value.get("current_version") != current_version: - return None - url = value.get("manifest_url") - if not isinstance(url, str) or url not in allowed_urls: - return None - if value.get("selected_channel") != channel: - return None - if value.get("public_key_sha256") != key_sha256: - return None - manifest_value = value.get("manifest") - if not isinstance(manifest_value, dict): - return None - checked_at = _cached_timestamp(value.get("checked_at")) - if checked_at is None: - return None - try: - manifest = parse_update_manifest( - (json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8") - ) - cached_manifest_sha256 = manifest_value.get("manifest_sha256") - if ( - isinstance(cached_manifest_sha256, str) - and _SHA256_PATTERN.fullmatch(cached_manifest_sha256) is not None - ): - manifest = replace( - manifest, - manifest_sha256=cached_manifest_sha256, - ) - except (LauncherUpdateError, TypeError, ValueError): - return None - return _evaluate_launcher_update( - manifest, - current_version, - url, - checked_at=checked_at, - selected_channel=channel, - ) - - -def cache_launcher_update_check( - cache_path: Path, - check: LauncherUpdateCheck, - *, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, -) -> bool: - """Cache an authenticated result for startup display, never for installation.""" - - key = public_key_path or configured_public_key_path(environment) - try: - key_sha256 = _sha256_file(key.expanduser().resolve()) - _write_json( - cache_path, - { - "schema": UPDATE_CHECK_CACHE_SCHEMA, - "checked_at": check.checked_at - or datetime.now().astimezone().isoformat(timespec="seconds"), - "current_version": check.current_version, - "manifest_url": check.manifest_url, - "selected_channel": check.selected_channel, - "public_key_sha256": key_sha256, - "manifest": asdict(check.manifest), - }, - ) - except (OSError, RuntimeError): - return False - return True - - -def _evaluate_launcher_update( - manifest: UpdateManifest, - current_version: str, - manifest_url: str, - *, - checked_at: str | None = None, - selected_channel: str = STABLE_CHANNEL, -) -> LauncherUpdateCheck: - try: - validate_channel(selected_channel) - except ReleaseModelError as exc: - raise LauncherUpdateError(str(exc)) from exc - current = _parse_version(current_version, "current launcher version") - latest = _parse_version(manifest.version, "manifest version") - minimum = _parse_version( - manifest.minimum_updater_version, - "minimum updater version", - ) - available = latest > current and manifest.status == ACTIVE_STATUS - supported = current >= minimum - if manifest.status != ACTIVE_STATUS: - reason = f"Launcher {manifest.version} is {manifest.status} and will not be offered." - elif not available: - reason = ( - "The selected channel will not downgrade this launcher." - if latest < current - else "The launcher is up to date." - ) - elif not supported: - reason = ( - f"Version {manifest.version} requires updater " - f"{manifest.minimum_updater_version} or newer; install it manually." - ) - else: - reason = f"Launcher {manifest.version} is available." - return LauncherUpdateCheck( - current_version=current_version, - latest_version=manifest.version, - available=available, - supported=supported, - reason=reason, - manifest_url=manifest_url, - manifest=manifest, - checked_at=checked_at, - selected_channel=selected_channel, - ) - - -def install_launcher_update( - paths: AppPaths, - *, - current_version: str = __version__, - manifest_url: str | None = None, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, - timeout_seconds: float = 60.0, - channel: str = STABLE_CHANNEL, -) -> LauncherUpdateResult: - """Authenticate, stage, validate, and atomically activate one update.""" - - check = check_for_launcher_update( - current_version=current_version, - manifest_url=manifest_url, - public_key_path=public_key_path, - environment=environment, - timeout_seconds=min(timeout_seconds, 30.0), - channel=channel, - ) - if not check.available: - return LauncherUpdateResult( - changed=False, - current_version=check.current_version, - installed_version=check.current_version, - release_directory=None, - previous_release=None, - archive=None, - archive_sha256=None, - restart_required=False, - state_recorded=False, - warning=None, - selected_channel=check.selected_channel, - installed_channel=SemanticVersion.parse(check.current_version).channel, - ) - if not check.supported: - raise LauncherUpdateError(check.reason) - - from_channel = SemanticVersion.parse(check.current_version).channel - event_metadata = _launcher_release_metadata( - current_version=check.current_version, - target_version=check.manifest.version, - current_channel=from_channel, - target_channel=check.manifest.channel, - selected_channel=check.selected_channel, - tag=check.manifest.tag, - manifest_schema=check.manifest.schema, - manifest_sha256=check.manifest.manifest_sha256, - rollback=False, - ) - paths.create_base_directories() - with _launcher_mutation_lock(paths, "launcher-update"): - _recover_interrupted_activation(paths) - _precheck_release_install(paths, check.manifest) - archive = paths.launcher_update_archive(check.manifest.version) - _download_archive( - check.manifest, - archive, - timeout_seconds=timeout_seconds, - event_family="update", - event_metadata=event_metadata, - ) - _send_launcher_release_event( - "update_apply_started", - metadata=event_metadata, - operation="launcher_update", - stage="apply", - ) - try: - release_directory = _stage_and_install_release(paths, archive, check.manifest) - previous = _activate_release( - paths, - release_directory, - telemetry_metadata=event_metadata, - restore_reason="failed_update", - ) - except BaseException as exc: - _send_launcher_release_event( - "update_apply_failed", - metadata=event_metadata, - operation="launcher_update", - stage="apply", - severity="error", - message=str(exc), - ) - _record_launcher_operation( - paths, - from_version=check.current_version, - to_version=check.manifest.version, - from_channel=from_channel, - to_channel=check.manifest.channel, - selected_channel=check.selected_channel, - result="failed", - rollback=False, - reason="automatic_update", - tag=check.manifest.tag, - manifest_schema=check.manifest.schema, - manifest_sha256=check.manifest.manifest_sha256, - ) - raise - _send_launcher_release_event( - "update_apply_success", - metadata=event_metadata, - operation="launcher_update", - stage="apply", - ) - state_warning = None - try: - _write_json( - paths.launcher_update_state, - { - "schema": 1, - "updated_at": datetime.now().astimezone().isoformat(), - "from_version": check.current_version, - "to_version": check.manifest.version, - "selected_channel": check.selected_channel, - "from_channel": from_channel, - "to_channel": check.manifest.channel, - "tag": check.manifest.tag, - "manifest_schema": check.manifest.schema, - "manifest_sha256": check.manifest.manifest_sha256, - "manifest_url": check.manifest_url, - "archive_url": check.manifest.archive_url, - "archive_sha256": check.manifest.archive_sha256, - "release_directory": str(release_directory), - "previous_release": str(previous) if previous else None, - }, - ) - except OSError as exc: - # Activation is the durable user-facing operation. A diagnostics-only - # state record must not strand the dashboard on the old release. - state_warning = f"launcher updated, but update history could not be saved: {exc}" - history_recorded = _record_launcher_operation( - paths, - from_version=check.current_version, - to_version=check.manifest.version, - from_channel=from_channel, - to_channel=check.manifest.channel, - selected_channel=check.selected_channel, - result="success", - rollback=False, - reason="automatic_update", - tag=check.manifest.tag, - manifest_schema=check.manifest.schema, - manifest_sha256=check.manifest.manifest_sha256, - ) - if not history_recorded and state_warning is None: - state_warning = "launcher updated, but the local operation history could not be saved" - return LauncherUpdateResult( - changed=True, - current_version=check.current_version, - installed_version=check.manifest.version, - release_directory=str(release_directory), - previous_release=str(previous) if previous else None, - archive=str(archive), - archive_sha256=check.manifest.archive_sha256, - restart_required=True, - state_recorded=state_warning is None, - warning=state_warning, - selected_channel=check.selected_channel, - installed_channel=check.manifest.channel, - tag=check.manifest.tag, - manifest_schema=check.manifest.schema, - manifest_sha256=check.manifest.manifest_sha256, - ) - - -def verify_launcher_update_channel( - *, - current_version: str = __version__, - manifest_url: str | None = None, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, - timeout_seconds: float = 60.0, - channel: str = STABLE_CHANNEL, -) -> LauncherUpdateVerification: - """Authenticate, download, extract, and import a release without activating it.""" - - check = check_for_launcher_update( - current_version=current_version, - manifest_url=manifest_url, - public_key_path=public_key_path, - environment=environment, - timeout_seconds=min(timeout_seconds, 30.0), - channel=channel, - ) - with tempfile.TemporaryDirectory(prefix="ptlinux-update-verification-") as temporary: - root = Path(temporary) - archive = root / f"{UPDATE_ARCHIVE_PREFIX}{check.manifest.version}.tar.gz" - _download_archive( - check.manifest, - archive, - timeout_seconds=timeout_seconds, - event_family="update", - event_metadata=_launcher_release_metadata( - current_version=current_version, - target_version=check.manifest.version, - current_channel=SemanticVersion.parse(current_version).channel, - target_channel=check.manifest.channel, - selected_channel=check.selected_channel, - tag=check.manifest.tag, - manifest_schema=check.manifest.schema, - manifest_sha256=check.manifest.manifest_sha256, - rollback=False, - ), - ) - staging = root / "release" - staging.mkdir(mode=0o700) - _extract_release_archive(archive, staging, check.manifest.version) - _verify_manifest_files(staging, check.manifest) - staged_version = _validate_release_directory(staging) - if staged_version != check.manifest.version: - raise LauncherUpdateError( - f"verified launcher version is {staged_version}, " - f"expected {check.manifest.version}" - ) - _self_check_release(staging, check.manifest.version) - _validate_release_directory(staging) - payload_files = sum(1 for item in staging.rglob("*") if item.is_file()) - return LauncherUpdateVerification( - current_version=current_version, - release_version=check.manifest.version, - manifest_url=check.manifest_url, - archive_url=check.manifest.archive_url, - archive_size=check.manifest.archive_size, - archive_sha256=check.manifest.archive_sha256, - payload_files=payload_files, - channel=check.manifest.channel, - tag=check.manifest.tag, - manifest_sha256=check.manifest.manifest_sha256, - ) - - -def launcher_rollback_candidates( - history: LauncherRemoteHistory, - *, - installed_version: str, - selected_channel: str, - show_prereleases: bool | None = None, -) -> tuple[LauncherRemoteRelease, ...]: - """Resolve valid rollback choices from authenticated release history.""" - - try: - resolver = UpdateResolver(release.resolver_record() for release in history.releases) - resolved = resolver.rollback_candidates( - installed_version, - selected_channel, - show_prereleases=show_prereleases, - ) - except ReleaseModelError as exc: - raise LauncherUpdateError(str(exc)) from exc - by_version = {release.version: release for release in history.releases} - return tuple(by_version[release.version] for release in resolved) - - -def rollback_launcher_update( - paths: AppPaths, - *, - target_version: str | None = None, - selected_channel: str = STABLE_CHANNEL, - history_url: str | None = None, - public_key_path: Path | None = None, - environment: Mapping[str, str] | None = None, - timeout_seconds: float = 60.0, - reason: str = "manual", -) -> LauncherRollbackResult: - """Activate a retained or authenticated historical launcher release.""" - - try: - validate_channel(selected_channel) - except ReleaseModelError as exc: - raise LauncherUpdateError(str(exc)) from exc - if reason not in { - "manual", - "failed_update", - "failed_launch", - "support_recommendation", - "hotfix_recovery", - }: - raise LauncherUpdateError(f"unsupported rollback reason: {reason!r}") - paths.create_base_directories() - with _launcher_mutation_lock(paths, "launcher-rollback"): - _recover_interrupted_activation(paths) - current_release = _release_from_link( - paths.launcher_current, - paths.launcher_releases, - ) - if current_release is None: - raise LauncherUpdateError("the current launcher release link is missing") - current_version = _validate_release_directory(current_release) - current_semantic = _parse_version(current_version, "current launcher version") - manifest: UpdateManifest | None = None - archive: Path | None = None - release_id: int | None = None - remote_target = False - retained_compatibility: tuple[int, int] | None = None - if target_version is None: - target_release = _release_from_link( - paths.launcher_previous, - paths.launcher_releases, - ) - if target_release is None: - raise LauncherUpdateError("no previous launcher release is available") - target_version = _validate_release_directory(target_release) - retained_compatibility = _verify_retained_release_integrity( - paths, - target_release, - target_version, - ) - else: - try: - target_semantic = SemanticVersion.parse(target_version) - except ReleaseModelError as exc: - raise LauncherUpdateError(f"invalid rollback version: {target_version!r}") from exc - if target_semantic >= current_semantic: - raise LauncherUpdateError( - f"rollback target {target_version} must be older than {current_version}" - ) - target_release = paths.launcher_releases / target_version - if target_release.is_dir() and not target_release.is_symlink(): - if _validate_release_directory(target_release) != target_version: - raise LauncherUpdateError("retained rollback release version does not match") - retained_compatibility = _verify_retained_release_integrity( - paths, - target_release, - target_version, - ) - else: - remote_target = True - target_semantic = _parse_version(target_version, "rollback target version") - if target_semantic >= current_semantic: - raise LauncherUpdateError( - f"rollback target {target_version} must be older than {current_version}" - ) - from_channel = current_semantic.channel - to_channel = target_semantic.channel - event_metadata = _launcher_release_metadata( - current_version=current_version, - target_version=target_version, - current_channel=from_channel, - target_channel=to_channel, - selected_channel=selected_channel, - tag=manifest.tag if manifest is not None else target_semantic.tag, - manifest_schema=manifest.schema if manifest is not None else None, - manifest_sha256=( - manifest.manifest_sha256 if manifest is not None else None - ), - rollback=True, - reason=reason, - release_id=release_id, - ) - _send_launcher_release_event( - "rollback_requested", - metadata=event_metadata, - operation="launcher_rollback", - stage="requested", - ) - if remote_target: - history = fetch_launcher_release_history( - history_url=history_url, - public_key_path=public_key_path, - environment=environment, - timeout_seconds=min(timeout_seconds, 30.0), - ) - candidates = launcher_rollback_candidates( - history, - installed_version=current_version, - selected_channel=selected_channel, - show_prereleases=target_semantic.is_prerelease, - ) - record = next( - (release for release in candidates if release.version == target_version), - None, - ) - if record is None: - raise LauncherUpdateError( - f"release {target_version} is not an active rollback candidate" - ) - release_id = record.release_id - fetched_manifest = _fetch_historical_manifest( - record, - public_key_path or configured_public_key_path(environment), - timeout_seconds=min(timeout_seconds, 30.0), - ) - _validate_rollback_config_compatibility(paths, fetched_manifest) - _precheck_release_install(paths, fetched_manifest) - manifest = fetched_manifest - archive = paths.launcher_update_archive(fetched_manifest.version) - event_metadata = _launcher_release_metadata( - current_version=current_version, - target_version=target_version, - current_channel=from_channel, - target_channel=to_channel, - selected_channel=selected_channel, - tag=fetched_manifest.tag, - manifest_schema=fetched_manifest.schema, - manifest_sha256=fetched_manifest.manifest_sha256, - rollback=True, - reason=reason, - release_id=release_id, - ) - if manifest is not None and archive is not None: - _download_archive( - manifest, - archive, - timeout_seconds=timeout_seconds, - event_family="rollback", - event_metadata=event_metadata, - ) - _send_launcher_release_event( - "rollback_apply_started", - metadata=event_metadata, - operation="launcher_rollback", - stage="apply", - ) - try: - if manifest is not None and archive is not None: - target_release = _stage_and_install_release(paths, archive, manifest) - if current_release == target_release.resolve(): - raise LauncherUpdateError("current and rollback releases are identical") - if manifest is not None: - _validate_rollback_config_compatibility(paths, manifest) - elif retained_compatibility is not None: - config_schema, minimum_config_schema = retained_compatibility - _validate_config_schema_range( - _current_config_schema(paths), - minimum_config_schema, - config_schema, - f"launcher {target_version}", - ) - else: - _validate_rollback_release_compatibility(paths, target_release) - previous = _activate_release( - paths, - target_release, - telemetry_metadata=event_metadata, - restore_reason=reason, - ) - if previous is None: - raise LauncherUpdateError("the current launcher release disappeared") - except BaseException as exc: - failed_tag = event_metadata.get("release_tag") - if not isinstance(failed_tag, str): - failed_tag = None - _send_launcher_release_event( - "rollback_apply_failed", - metadata=event_metadata, - operation="launcher_rollback", - stage="apply", - severity="error", - message=str(exc), - ) - _record_launcher_operation( - paths, - from_version=current_version, - to_version=target_version, - from_channel=from_channel, - to_channel=to_channel, - selected_channel=selected_channel, - result="failed", - rollback=True, - reason=reason, - tag=failed_tag, - manifest_schema=manifest.schema if manifest is not None else None, - manifest_sha256=( - manifest.manifest_sha256 if manifest is not None else None - ), - ) - raise - _send_launcher_release_event( - "rollback_apply_success", - metadata=event_metadata, - operation="launcher_rollback", - stage="apply", - ) - state_warning = None - try: - _write_json( - paths.launcher_update_state, - { - "schema": 1, - "rolled_back_at": datetime.now().astimezone().isoformat(), - "from_version": current_version, - "to_version": target_version, - "selected_channel": selected_channel, - "from_channel": from_channel, - "to_channel": to_channel, - "reason": reason, - "rollback": True, - "release_directory": str(target_release), - "previous_release": str(previous), - }, - ) - except OSError as exc: - # The symlink transaction is already durable. Diagnostics must not make - # a completed rollback appear to have failed or encourage a retry. - state_warning = f"launcher rolled back, but update history could not be saved: {exc}" - history_recorded = _record_launcher_operation( - paths, - from_version=current_version, - to_version=target_version, - from_channel=from_channel, - to_channel=to_channel, - selected_channel=selected_channel, - result="success", - rollback=True, - reason=reason, - tag=manifest.tag if manifest is not None else target_semantic.tag, - manifest_schema=manifest.schema if manifest is not None else None, - manifest_sha256=( - manifest.manifest_sha256 if manifest is not None else None - ), - ) - if not history_recorded and state_warning is None: - state_warning = "launcher rolled back, but the local operation history could not be saved" - return LauncherRollbackResult( - from_version=current_version, - to_version=target_version, - current_release=str(target_release), - previous_release=str(previous), - restart_required=True, - from_channel=from_channel, - to_channel=to_channel, - state_recorded=state_warning is None, - warning=state_warning, - ) - - -def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory: - """Return validated retained releases without trusting directory names alone.""" - - current = _release_from_link(paths.launcher_current, paths.launcher_releases) - previous = _release_from_link(paths.launcher_previous, paths.launcher_releases) - try: - candidates = tuple(paths.launcher_releases.iterdir()) - except FileNotFoundError: - candidates = () - except OSError as exc: - raise LauncherUpdateError(f"cannot inspect installed launcher releases: {exc}") from exc - releases: list[LauncherInstalledRelease] = [] - for candidate in candidates: - try: - SemanticVersion.parse(candidate.name) - except ReleaseModelError: - continue - try: - version = _validate_release_directory(candidate) - installed_at = datetime.fromtimestamp( - candidate.stat().st_mtime - ).astimezone().isoformat(timespec="seconds") - except (LauncherUpdateError, OSError, ValueError, OverflowError): - continue - if version != candidate.name: - continue - resolved = candidate.resolve() - releases.append( - LauncherInstalledRelease( - version=version, - installed_at=installed_at, - directory=str(resolved), - active=resolved == current, - previous=resolved == previous, - channel=SemanticVersion.parse(version).channel, - ) - ) - releases.sort( - key=lambda release: _parse_version(release.version, "launcher release version"), - reverse=True, - ) - return LauncherUpdateHistory( - current_version=_release_version(releases, active=True), - previous_version=_release_version(releases, previous=True), - releases=tuple(releases), - ) - - -def parse_launcher_release_history( - data: bytes, - *, - history_url: str, -) -> LauncherRemoteHistory: - """Strictly parse an already authenticated remote release catalog.""" - - if not data or len(data) > MAX_HISTORY_BYTES: - raise LauncherUpdateError("release history has an invalid size") - _require_https_url(history_url, "release history URL") - try: - value = json.loads( - data.decode("utf-8"), - object_pairs_hook=_unique_json_object, - ) - except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: - raise LauncherUpdateError(f"release history is invalid JSON: {exc}") from exc - if not isinstance(value, dict): - raise LauncherUpdateError("release history must contain a JSON object") - if value.get("application") != UPDATE_APPLICATION: - raise LauncherUpdateError( - f"release history application must be {UPDATE_APPLICATION!r}" - ) - if _required_integer(value, "schema") != RELEASE_HISTORY_SCHEMA: - raise LauncherUpdateError("unsupported release history schema") - generated_at = _required_string(value, "generated_at") - _parse_published_at(generated_at) - releases_value = value.get("releases") - if not isinstance(releases_value, list) or len(releases_value) > MAX_RELEASE_HISTORY: - raise LauncherUpdateError("release history releases must be a bounded array") - releases: list[LauncherRemoteRelease] = [] - seen: set[SemanticVersion] = set() - last_version: SemanticVersion | None = None - for item in releases_value: - if not isinstance(item, dict): - raise LauncherUpdateError("release history entry must be an object") - version = _required_string(item, "version") - channel = _required_string(item, "channel") - tag = _required_string(item, "tag") - try: - parsed = validate_channel_version(channel, version) - except ReleaseModelError as exc: - raise LauncherUpdateError(f"invalid release history entry: {exc}") from exc - if tag != parsed.tag: - raise LauncherUpdateError( - f"release history tag {tag!r} does not match version {version}" - ) - if parsed in seen: - raise LauncherUpdateError(f"duplicate release history version: {version}") - if last_version is not None and parsed >= last_version: - raise LauncherUpdateError( - "release history entries must be sorted by descending semantic version" - ) - seen.add(parsed) - last_version = parsed - status = _required_string(item, "status") - if status not in SUPPORTED_RELEASE_STATUSES: - raise LauncherUpdateError(f"unsupported release status: {status!r}") - manifest_url = _required_string(item, "manifest_url") - manifest_signature_url = _required_string(item, "manifest_signature_url") - release_date = _required_string(item, "release_date") - release_notes_url = _required_string(item, "release_notes_url") - for url, label in ( - (manifest_url, "historical manifest URL"), - (manifest_signature_url, "historical manifest signature URL"), - (release_notes_url, "historical release notes URL"), - ): - _require_https_url(url, label) - if _resource_url(manifest_signature_url) != _resource_url( - _signature_url(manifest_url) - ): - raise LauncherUpdateError( - f"release history signature URL does not match manifest {version}" - ) - _parse_published_at(release_date) - title = _required_string(item, "title") - release_id_value = item.get("release_id") - if release_id_value is not None and ( - isinstance(release_id_value, bool) - or not isinstance(release_id_value, int) - or release_id_value <= 0 - ): - raise LauncherUpdateError("release history release_id must be positive or null") - config_schema, minimum_config_schema = _parse_history_compatibility( - item.get("compatibility") - ) - releases.append( - LauncherRemoteRelease( - version=version, - channel=channel, - tag=tag, - manifest_url=manifest_url, - manifest_signature_url=manifest_signature_url, - release_date=release_date, - release_notes_url=release_notes_url, - status=status, - title=title, - release_id=release_id_value, - config_schema=config_schema, - minimum_config_schema=minimum_config_schema, - ) - ) - return LauncherRemoteHistory( - generated_at=generated_at, - history_url=history_url, - releases=tuple(releases), - history_sha256=hashlib.sha256(data).hexdigest(), - ) - - -def _parse_history_compatibility(value: object) -> tuple[int | None, int | None]: - if value is None: - return None, None - if not isinstance(value, dict): - raise LauncherUpdateError("release history compatibility must be an object") - config_schema = _optional_positive_integer(value, "config_schema") - minimum_config_schema = _optional_positive_integer( - value, - "minimum_config_schema", - ) - if (config_schema is None) != (minimum_config_schema is None): - raise LauncherUpdateError( - "release history compatibility must include both config schema bounds" - ) - if ( - config_schema is not None - and minimum_config_schema is not None - and minimum_config_schema > config_schema - ): - raise LauncherUpdateError( - "release history minimum_config_schema exceeds config_schema" - ) - return config_schema, minimum_config_schema - - -def parse_update_manifest(data: bytes) -> UpdateManifest: - """Strictly parse one already-authenticated manifest.""" - - if not data or len(data) > MAX_MANIFEST_BYTES: - raise LauncherUpdateError("update manifest has an invalid size") - try: - value = json.loads( - data.decode("utf-8"), - object_pairs_hook=_unique_json_object, - ) - except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: - raise LauncherUpdateError(f"update manifest is invalid JSON: {exc}") from exc - if not isinstance(value, dict): - raise LauncherUpdateError("update manifest must contain a JSON object") - application = value.get("application") - if application is not None and application != UPDATE_APPLICATION: - raise LauncherUpdateError( - f"update manifest application must be {UPDATE_APPLICATION!r}" - ) - schema = _required_integer(value, "schema") - if schema != 1: - raise LauncherUpdateError(f"unsupported update manifest schema: {schema}") - channel = _required_string(value, "channel") - version = _required_string(value, "version") - minimum = _required_string(value, "minimum_updater_version") - try: - parsed_version = validate_channel_version(channel, version) - except ReleaseModelError as exc: - raise LauncherUpdateError(f"invalid update manifest release: {exc}") from exc - _parse_version(minimum, "minimum updater version") - archive_url = _required_string(value, "archive_url") - _require_https_url(archive_url, "update archive URL") - archive_sha256 = _required_string(value, "archive_sha256") - if _SHA256_PATTERN.fullmatch(archive_sha256) is None: - raise LauncherUpdateError("archive_sha256 must be a lowercase SHA-256 digest") - archive_size = _required_integer(value, "archive_size") - if archive_size <= 0 or archive_size > MAX_ARCHIVE_BYTES: - raise LauncherUpdateError( - f"update archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes" - ) - published_at = _required_string(value, "published_at") - _parse_published_at(published_at) - notes_value = value.get("notes_url") - if notes_value is not None and not isinstance(notes_value, str): - raise LauncherUpdateError("notes_url must be a string or null") - if notes_value: - _require_https_url(notes_value, "release notes URL") - tag_value = value.get("tag", parsed_version.tag) - if not isinstance(tag_value, str) or tag_value != parsed_version.tag: - raise LauncherUpdateError("manifest tag does not match its version") - commit_value = value.get("commit") - if commit_value is not None and ( - not isinstance(commit_value, str) - or re.fullmatch(r"[0-9a-f]{7,64}", commit_value) is None - ): - raise LauncherUpdateError("manifest commit must be a lowercase Git object ID") - status_value = value.get("status", ACTIVE_STATUS) - if not isinstance(status_value, str) or status_value not in SUPPORTED_RELEASE_STATUSES: - raise LauncherUpdateError(f"unsupported release status: {status_value!r}") - title_value: str | None = None - summary_value: str | None = None - notes_markdown_value: str | None = None - release_value = value.get("release") - if release_value is not None: - if not isinstance(release_value, dict): - raise LauncherUpdateError("manifest release field must be an object") - title_value = _required_string(release_value, "title") - summary_value = _required_string(release_value, "summary") - notes_markdown_candidate = release_value.get("notes_markdown") - if notes_markdown_candidate is not None: - if ( - not isinstance(notes_markdown_candidate, str) - or not notes_markdown_candidate - or "\x00" in notes_markdown_candidate - or len(notes_markdown_candidate.encode("utf-8")) - > MAX_EMBEDDED_RELEASE_NOTES_BYTES - ): - raise LauncherUpdateError( - "manifest release.notes_markdown is invalid or too large" - ) - notes_markdown_value = notes_markdown_candidate - release_notes_value = _required_string(release_value, "release_notes_url") - _require_https_url(release_notes_value, "release notes URL") - if notes_value and notes_value != release_notes_value: - raise LauncherUpdateError("manifest release notes URLs do not match") - notes_value = release_notes_value - archive_name = PurePosixPath(urlsplit(archive_url).path).name - archive_value = value.get("archive") - if archive_value is not None: - if not isinstance(archive_value, dict): - raise LauncherUpdateError("manifest archive field must be an object") - archive_name = _required_string(archive_value, "name") - if PurePosixPath(archive_name).name != archive_name: - raise LauncherUpdateError("manifest archive name must be a filename") - if PurePosixPath(urlsplit(archive_url).path).name != archive_name: - raise LauncherUpdateError("manifest archive name does not match its URL") - if ( - _required_string(archive_value, "url") != archive_url - or _required_string(archive_value, "sha256") != archive_sha256 - or _required_integer(archive_value, "size") != archive_size - ): - raise LauncherUpdateError("manifest archive fields do not match legacy metadata") - config_schema = 1 - minimum_config_schema = 1 - compatibility_value = value.get("compatibility") - if compatibility_value is not None: - if not isinstance(compatibility_value, dict): - raise LauncherUpdateError("manifest compatibility field must be an object") - config_schema = _required_positive_integer( - compatibility_value, - "config_schema", - ) - minimum_config_schema = _required_positive_integer( - compatibility_value, - "minimum_config_schema", - ) - if minimum_config_schema > config_schema: - raise LauncherUpdateError( - "manifest minimum_config_schema exceeds config_schema" - ) - compatibility_manifest_schema = compatibility_value.get("manifest_schema") - if compatibility_manifest_schema is not None and compatibility_manifest_schema != schema: - raise LauncherUpdateError( - "manifest compatibility schema does not match its schema" - ) - compatibility_minimum = compatibility_value.get("minimum_updater_version") - if compatibility_minimum is not None and compatibility_minimum != minimum: - raise LauncherUpdateError( - "manifest compatibility minimum updater version does not match" - ) - files = _parse_manifest_files(value.get("files")) - return UpdateManifest( - schema=schema, - channel=channel, - version=version, - minimum_updater_version=minimum, - archive_url=archive_url, - archive_sha256=archive_sha256, - archive_size=archive_size, - published_at=published_at, - notes_url=notes_value or None, - tag=tag_value, - commit=commit_value, - status=status_value, - title=title_value, - summary=summary_value, - notes_markdown=notes_markdown_value, - archive_name=archive_name, - files=files, - manifest_sha256=hashlib.sha256(data).hexdigest(), - config_schema=config_schema, - minimum_config_schema=minimum_config_schema, - ) - - -def _parse_manifest_files(value: object) -> tuple[ManifestFile, ...]: - if value is None: - return () - if not isinstance(value, list) or len(value) > MAX_ARCHIVE_MEMBERS: - raise LauncherUpdateError("manifest files must be a bounded array") - files: list[ManifestFile] = [] - last_path = "" - for item in value: - if not isinstance(item, dict): - raise LauncherUpdateError("manifest file entry must be an object") - path_value = _required_string(item, "path") - raw_parts = path_value.split("/") - path = PurePosixPath(path_value) - if ( - "\\" in path_value - or "\x00" in path_value - or any(part in {"", ".", ".."} for part in raw_parts) - or path.is_absolute() - or not path.parts - or path.parts[0] not in _ALLOWED_RELEASE_ROOTS - ): - raise LauncherUpdateError(f"unsafe manifest file path: {path_value!r}") - if path_value <= last_path: - raise LauncherUpdateError("manifest files must be uniquely sorted by path") - size = _required_integer(item, "size") - if size < 0 or size > MAX_EXTRACTED_BYTES: - raise LauncherUpdateError(f"invalid manifest file size: {path_value}") - digest = _required_string(item, "sha256") - if _SHA256_PATTERN.fullmatch(digest) is None: - raise LauncherUpdateError(f"invalid manifest file SHA-256: {path_value}") - files.append(ManifestFile(path_value, size, digest)) - last_path = path_value - return tuple(files) - - -def _fetch_small_file( - url: str, - *, - maximum_bytes: int, - timeout_seconds: float, - label: str, -) -> bytes: - request = Request( - url, - headers={ - "User-Agent": USER_AGENT, - "Accept": "application/octet-stream, application/json", - "Accept-Encoding": "identity", - "Cache-Control": "no-cache", - "Pragma": "no-cache", - }, - method="GET", - ) - try: - response = urlopen(request, timeout=timeout_seconds) - except HTTPError as exc: - status = exc.code - exc.close() - raise LauncherUpdateError(f"{label} request failed: HTTP {status}") from exc - except (URLError, OSError) as exc: - raise LauncherUpdateError(f"{label} request failed: {exc}") from exc - with response: - final_url = response.geturl() - _require_https_url(final_url, f"final {label} URL") - if _resource_url(final_url) != _resource_url(url): - raise LauncherUpdateError( - f"{label} redirected unexpectedly to {final_url}; " - "the launcher update channel must be publicly readable" - ) - length = _content_length(response.headers.get("Content-Length"), label) - if length is not None and length > maximum_bytes: - raise LauncherUpdateError(f"{label} exceeds its size limit") - data = response.read(maximum_bytes + 1) - if len(data) > maximum_bytes: - raise LauncherUpdateError(f"{label} exceeds its size limit") - if length is not None and len(data) != length: - raise LauncherUpdateError(f"{label} response was incomplete") - if not data: - raise LauncherUpdateError(f"{label} response was empty") - return data - - -def _verify_manifest_signature( - manifest: bytes, - signature: bytes, - public_key_path: Path, -) -> None: - if not signature or len(signature) > MAX_SIGNATURE_BYTES: - raise LauncherUpdateError("update manifest signature has an invalid size") - key = public_key_path.expanduser().resolve() - if not key.is_file(): - raise LauncherUpdateError(f"launcher update public key is missing: {key}") - openssl = shutil.which("openssl") - if not openssl: - raise LauncherUpdateError("OpenSSL is required to verify launcher updates") - with tempfile.TemporaryDirectory(prefix="ptlinux-update-signature-") as temporary: - root = Path(temporary) - manifest_path = root / "manifest.json" - signature_path = root / "manifest.json.sig" - manifest_path.write_bytes(manifest) - signature_path.write_bytes(signature) - commands = ( - [ - openssl, - "dgst", - "-sha256", - "-verify", - str(key), - "-signature", - str(signature_path), - str(manifest_path), - ], - [ - openssl, - "pkeyutl", - "-verify", - "-rawin", - "-pubin", - "-inkey", - str(key), - "-sigfile", - str(signature_path), - "-in", - str(manifest_path), - ], - ) - for command in commands: - try: - result = subprocess.run( - command, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=10, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - continue - if result.returncode == 0: - return - raise LauncherUpdateError("launcher update manifest signature is invalid") - - -def _launcher_release_metadata( - *, - current_version: str, - target_version: str, - current_channel: str, - target_channel: str, - selected_channel: str, - tag: str | None, - manifest_schema: int | None, - manifest_sha256: str | None, - rollback: bool, - reason: str | None = None, - release_id: int | None = None, -) -> dict[str, object]: - metadata: dict[str, object] = { - "current_version": current_version, - "target_version": target_version, - "current_channel": current_channel, - "target_channel": target_channel, - "selected_update_channel": selected_channel, - "release_tag": tag, - "manifest_version": manifest_schema, - "manifest_hash": manifest_sha256, - "rollback": rollback, - } - if reason is not None: - metadata["reason"] = reason - if release_id is not None: - metadata["release_id"] = release_id - if rollback: - metadata.update( - { - "from_version": current_version, - "to_version": target_version, - "from_channel": current_channel, - "to_channel": target_channel, - } - ) - return metadata - - -def _send_launcher_release_event( - event: str, - *, - metadata: Mapping[str, object], - operation: str, - stage: str, - severity: str = "info", - message: str | None = None, -) -> None: - telemetry = current_telemetry() - if telemetry is not None: - telemetry.send_event( - event, - severity=severity, - message=message, - metadata=metadata, - context={"operation": operation, "stage": stage}, - ) - - -def _download_archive( - manifest: UpdateManifest, - destination: Path, - *, - timeout_seconds: float, - event_family: str | None = None, - event_metadata: Mapping[str, object] | None = None, -) -> None: - if destination.is_file() and _archive_matches(destination, manifest): - return - telemetry = current_telemetry() - download_started_at = time.monotonic() - operation = "launcher_rollback" if event_family == "rollback" else "launcher_update" - started_event = ( - f"{event_family}_download_started" - if event_family is not None - else "download_started" - ) - success_event = ( - f"{event_family}_download_success" - if event_family is not None - else "download_completed" - ) - failed_event = ( - f"{event_family}_download_failed" - if event_family is not None - else "download_failed" - ) - base_metadata = { - "resource_type": "launcher_update", - "content_bytes": manifest.archive_size, - "mirror_id": "launcher_update_channel", - **dict(event_metadata or {}), - } - if telemetry is not None: - telemetry.send_event( - started_event, - metadata=base_metadata, - context={"operation": operation, "stage": "download"}, - ) - destination.parent.mkdir(parents=True, exist_ok=True) - temporary = destination.with_name(f".{destination.name}.{os.getpid()}.part") - temporary.unlink(missing_ok=True) - request = Request( - manifest.archive_url, - headers={ - "User-Agent": USER_AGENT, - "Accept": "application/gzip, application/octet-stream", - "Accept-Encoding": "identity", - }, - method="GET", - ) - written = 0 - try: - try: - response = urlopen(request, timeout=timeout_seconds) - except HTTPError as exc: - status = exc.code - exc.close() - raise LauncherUpdateError( - f"launcher archive request failed: HTTP {status}" - ) from exc - except (URLError, OSError) as exc: - raise LauncherUpdateError(f"launcher archive request failed: {exc}") from exc - with response: - _require_https_url(response.geturl(), "final update archive URL") - length = _content_length( - response.headers.get("Content-Length"), - "launcher archive", - ) - if length is not None and length != manifest.archive_size: - raise LauncherUpdateError( - "launcher archive Content-Length does not match the signed manifest" - ) - digest = hashlib.sha256() - with temporary.open("xb") as handle: - while True: - chunk = response.read(READ_SIZE) - if not chunk: - break - written += len(chunk) - if written > manifest.archive_size or written > MAX_ARCHIVE_BYTES: - raise LauncherUpdateError("launcher archive exceeded its signed size") - digest.update(chunk) - handle.write(chunk) - handle.flush() - os.fsync(handle.fileno()) - if written != manifest.archive_size: - raise LauncherUpdateError( - f"launcher archive was incomplete: {written}/{manifest.archive_size} bytes" - ) - if digest.hexdigest() != manifest.archive_sha256: - raise LauncherUpdateError("launcher archive SHA-256 does not match the manifest") - temporary.replace(destination) - _fsync_directory(destination.parent) - except BaseException as exc: - temporary.unlink(missing_ok=True) - if telemetry is not None: - duration = max(0.0, time.monotonic() - download_started_at) - telemetry.send_event( - failed_event, - severity="error", - message=str(exc), - metadata={ - **base_metadata, - "download_bytes": written, - "duration_ms": round(duration * 1000), - "average_mbps": _average_mbps(written, duration), - "retry_count": 0, - "resume_used": False, - "http_status": network_http_status(exc), - "failure_type": classify_network_failure(exc), - }, - context={"operation": operation, "stage": "download"}, - ) - raise - duration = max(0.0, time.monotonic() - download_started_at) - if telemetry is not None: - telemetry.send_event( - success_event, - metadata={ - **base_metadata, - "download_bytes": written, - "duration_ms": round(duration * 1000), - "average_mbps": _average_mbps(written, duration), - "retry_count": 0, - "resume_used": False, - "http_status": 200, - }, - context={"operation": operation, "stage": "download"}, - ) - - -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 _stage_and_install_release( - paths: AppPaths, - archive: Path, - manifest: UpdateManifest, -) -> Path: - if paths.app_home.is_symlink(): - raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}") - paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755) - paths.app_home.chmod(0o755) - if paths.launcher_releases.is_symlink(): - raise LauncherUpdateError( - f"launcher release directory cannot be a link: {paths.launcher_releases}" - ) - paths.launcher_releases.mkdir(parents=True, exist_ok=True, mode=0o755) - paths.launcher_releases.chmod(0o755) - staging = paths.launcher_releases / f".{manifest.version}.{os.getpid()}.staging" - if staging.exists() or staging.is_symlink(): - raise LauncherUpdateError(f"launcher staging path already exists: {staging}") - staging.mkdir(mode=0o700) - try: - _extract_release_archive(archive, staging, manifest.version) - _verify_manifest_files(staging, manifest) - staged_version = _validate_release_directory(staging) - if staged_version != manifest.version: - raise LauncherUpdateError( - f"staged launcher version is {staged_version}, expected {manifest.version}" - ) - _self_check_release(staging, manifest.version) - _validate_release_directory(staging) - staging.chmod(0o755) - destination = paths.launcher_releases / manifest.version - if destination.exists() or destination.is_symlink(): - if not destination.is_dir() or destination.is_symlink(): - raise LauncherUpdateError( - f"launcher release destination is not a directory: {destination}" - ) - _validate_release_directory(destination) - if _tree_digest(destination) != _tree_digest(staging): - raise LauncherUpdateError( - f"launcher release {manifest.version} already exists with different contents" - ) - shutil.rmtree(staging) - else: - staging.replace(destination) - _fsync_directory(paths.launcher_releases) - _write_release_integrity_record(paths, destination, manifest) - return destination - except BaseException: - if staging.exists() and staging.is_dir() and not staging.is_symlink(): - shutil.rmtree(staging) - raise - - -def _verify_manifest_files(release: Path, manifest: UpdateManifest) -> None: - _verify_file_inventory(release, manifest.files) - - -def _verify_file_inventory( - release: Path, - files: tuple[ManifestFile, ...], -) -> None: - if not files: - return - expected = {entry.path: entry for entry in files} - actual: set[str] = set() - for path in release.rglob("*"): - if not path.is_file(): - continue - relative = path.relative_to(release).as_posix() - actual.add(relative) - entry = expected.get(relative) - if entry is None: - raise LauncherUpdateError( - f"launcher archive file is absent from its signed manifest: {relative}" - ) - if path.stat().st_size != entry.size or _sha256_file(path) != entry.sha256: - raise LauncherUpdateError( - f"launcher archive file does not match its signed manifest: {relative}" - ) - missing = sorted(set(expected).difference(actual)) - if missing: - raise LauncherUpdateError( - "launcher archive is missing signed manifest files: " + ", ".join(missing) - ) - - -def _write_release_integrity_record( - paths: AppPaths, - release: Path, - manifest: UpdateManifest, -) -> None: - if not manifest.files: - return - directory = paths.launcher_release_integrity - if directory.is_symlink(): - raise LauncherUpdateError( - f"launcher release-integrity directory cannot be a link: {directory}" - ) - directory.mkdir(parents=True, exist_ok=True, mode=0o700) - directory.chmod(0o700) - if release.resolve() != (paths.launcher_releases / manifest.version).resolve(): - raise LauncherUpdateError("launcher integrity record release path is inconsistent") - _write_json( - directory / f"{manifest.version}.json", - { - "schema": 1, - "version": manifest.version, - "channel": manifest.channel, - "tag": manifest.tag or SemanticVersion.parse(manifest.version).tag, - "manifest_schema": manifest.schema, - "manifest_sha256": manifest.manifest_sha256, - "config_schema": manifest.config_schema, - "minimum_config_schema": manifest.minimum_config_schema, - "files": [asdict(entry) for entry in manifest.files], - }, - ) - - -def _verify_retained_release_integrity( - paths: AppPaths, - release: Path, - version: str, -) -> tuple[int, int] | None: - """Verify a retained release against its install-time signed file inventory.""" - - record = paths.launcher_release_integrity / f"{version}.json" - try: - if record.is_symlink(): - raise LauncherUpdateError(f"launcher integrity record cannot be a link: {record}") - data = record.read_bytes() - except FileNotFoundError: - # Releases installed by an older launcher predate integrity persistence. - return None - except OSError as exc: - raise LauncherUpdateError(f"cannot read launcher integrity record: {exc}") from exc - if not data or len(data) > MAX_MANIFEST_BYTES: - raise LauncherUpdateError("launcher integrity record has an invalid size") - try: - value = json.loads(data.decode("utf-8"), object_pairs_hook=_unique_json_object) - except (UnicodeError, json.JSONDecodeError, ValueError) as exc: - raise LauncherUpdateError(f"launcher integrity record is invalid: {exc}") from exc - if not isinstance(value, dict) or value.get("schema") != 1: - raise LauncherUpdateError("launcher integrity record has an unsupported schema") - semantic = _parse_version(version, "retained launcher version") - if ( - value.get("version") != version - or value.get("channel") != semantic.channel - or value.get("tag") != semantic.tag - or value.get("manifest_schema") != 1 - ): - raise LauncherUpdateError("launcher integrity record identity does not match release") - manifest_sha256 = value.get("manifest_sha256") - if manifest_sha256 is not None and ( - not isinstance(manifest_sha256, str) - or _SHA256_PATTERN.fullmatch(manifest_sha256) is None - ): - raise LauncherUpdateError("launcher integrity record manifest hash is invalid") - config_schema = _required_positive_integer(value, "config_schema") - minimum_config_schema = _required_positive_integer( - value, - "minimum_config_schema", - ) - if minimum_config_schema > config_schema: - raise LauncherUpdateError( - "launcher integrity record has invalid config compatibility" - ) - files = _parse_manifest_files(value.get("files")) - if not files: - raise LauncherUpdateError("launcher integrity record has no managed files") - _verify_file_inventory(release, files) - return config_schema, minimum_config_schema - - -def _extract_release_archive(archive: Path, destination: Path, version: str) -> None: - expected_root = f"{UPDATE_ARCHIVE_PREFIX}{version}" - archive_layout: str | None = None - seen: set[PurePosixPath] = set() - total_bytes = 0 - try: - bundle = tarfile.open(archive, "r:gz") - except (OSError, tarfile.TarError) as exc: - raise LauncherUpdateError(f"launcher archive cannot be opened: {exc}") from exc - with bundle: - members = bundle.getmembers() - if not members or len(members) > MAX_ARCHIVE_MEMBERS: - raise LauncherUpdateError("launcher archive has an invalid member count") - for member in members: - name = member.name[:-1] if member.isdir() and member.name.endswith("/") else member.name - if not name or "\\" in name or "\x00" in name: - raise LauncherUpdateError(f"unsafe launcher archive path: {member.name!r}") - raw_parts = name.split("/") - if any(part in {"", ".", ".."} for part in raw_parts): - raise LauncherUpdateError(f"unsafe launcher archive path: {member.name!r}") - path = PurePosixPath(name) - if path.is_absolute() or not path.parts: - raise LauncherUpdateError( - f"unsafe launcher archive path: {member.name!r}" - ) - if path.parts[0] == expected_root: - member_layout = "wrapped" - elif path.parts[0] in _ALLOWED_RELEASE_ROOTS: - member_layout = "flat" - else: - raise LauncherUpdateError( - f"unexpected launcher archive path: {member.name!r}" - ) - if archive_layout is None: - archive_layout = member_layout - elif archive_layout != member_layout: - raise LauncherUpdateError( - "launcher archive mixes flat and legacy wrapped paths" - ) - relative = ( - PurePosixPath(*path.parts[1:]) - if archive_layout == "wrapped" - else path - ) - if not relative.parts: - if not member.isdir(): - raise LauncherUpdateError("launcher archive root must be a directory") - continue - if relative.parts[0] not in _ALLOWED_RELEASE_ROOTS: - raise LauncherUpdateError( - f"unexpected launcher payload path: {relative.as_posix()}" - ) - if relative in seen: - raise LauncherUpdateError( - f"duplicate launcher archive path: {relative.as_posix()}" - ) - seen.add(relative) - if not member.isdir() and not member.isfile(): - raise LauncherUpdateError( - f"unsupported launcher archive member: {member.name!r}" - ) - if ( - relative.parts[0] == "release.json" - and (len(relative.parts) != 1 or not member.isfile()) - ): - raise LauncherUpdateError( - f"unexpected launcher metadata path: {member.name!r}" - ) - if ( - len(relative.parts) == 1 - and relative.parts[0] in {"ptlinux", "ptdiag"} - and not member.isdir() - ): - raise LauncherUpdateError( - f"launcher package root must be a directory: {member.name!r}" - ) - if member.mode & 0o022: - raise LauncherUpdateError( - f"launcher archive member is group/world writable: {member.name!r}" - ) - permissions = member.mode & 0o777 - if member.isfile() and permissions not in {0o644, 0o755}: - raise LauncherUpdateError( - f"launcher archive member has unsupported permissions: {member.name!r}" - ) - target = destination.joinpath(*relative.parts) - if member.isdir(): - target.mkdir(parents=True, exist_ok=True, mode=0o755) - continue - if member.size < 0: - raise LauncherUpdateError("launcher archive contains a negative file size") - total_bytes += member.size - if total_bytes > MAX_EXTRACTED_BYTES: - raise LauncherUpdateError("launcher archive exceeds its extraction limit") - target.parent.mkdir(parents=True, exist_ok=True, mode=0o755) - source = bundle.extractfile(member) - if source is None: - raise LauncherUpdateError(f"cannot read launcher payload: {member.name!r}") - written = 0 - with source, target.open("xb") as handle: - while True: - chunk = source.read(READ_SIZE) - if not chunk: - break - written += len(chunk) - if written > member.size: - raise LauncherUpdateError( - f"launcher payload exceeded its declared size: {member.name!r}" - ) - handle.write(chunk) - if written != member.size: - raise LauncherUpdateError( - f"launcher payload was incomplete: {member.name!r}" - ) - target.chmod(permissions) - - -def _validate_release_directory(release: Path) -> str: - if not release.is_dir() or release.is_symlink(): - raise LauncherUpdateError(f"launcher release directory is invalid: {release}") - _remove_generated_bytecode_caches(release) - for item in (release, *release.rglob("*")): - if item.is_symlink(): - raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}") - try: - mode = item.stat().st_mode - except OSError as exc: - raise LauncherUpdateError(f"cannot inspect launcher release path {item}: {exc}") from exc - if mode & 0o022: - raise LauncherUpdateError( - f"launcher release path is group/world writable: {item}" - ) - if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode): - raise LauncherUpdateError(f"launcher release contains a special file: {item}") - metadata = _read_release_metadata(release) - version = metadata.get("version") - if not isinstance(version, str): - raise LauncherUpdateError("launcher release metadata has no version") - parsed_version = _parse_version(version, "launcher release version") - channel = metadata.get("channel") - if channel is not None and channel != parsed_version.channel: - raise LauncherUpdateError("launcher release channel does not match its version") - tag = metadata.get("tag") - if tag is not None and tag != parsed_version.tag: - raise LauncherUpdateError("launcher release tag does not match its version") - for required in ( - Path("ptlinux/__init__.py"), - Path("ptlinux/__main__.py"), - Path("ptlinux/cli.py"), - Path("ptlinux/self_update.py"), - Path("ptlinux") / PUBLIC_KEY_FILENAME, - Path("ptdiag/__init__.py"), - Path("ptdiag/assbin.py"), - Path("ptdiag/logs.py"), - ): - if not (release / required).is_file(): - raise LauncherUpdateError(f"launcher release is missing {required}") - bundled_gamescope = ( - release - / "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" - ) - if bundled_gamescope.exists(): - mode = bundled_gamescope.stat().st_mode - if not stat.S_ISREG(mode) or not mode & 0o111: - raise LauncherUpdateError( - "launcher release contains a non-executable Gamescope compatibility binary" - ) - return version - - -def _remove_generated_bytecode_caches(release: Path) -> None: - """Remove interpreter caches that are never part of a signed release.""" - - try: - cache_directories = sorted( - release.rglob("__pycache__"), - key=lambda path: len(path.parts), - reverse=True, - ) - except OSError as exc: - raise LauncherUpdateError( - f"cannot inspect launcher bytecode caches in {release}: {exc}" - ) from exc - for cache in cache_directories: - if cache.is_symlink() or not cache.is_dir(): - raise LauncherUpdateError(f"launcher bytecode cache is unsafe: {cache}") - try: - entries = tuple(cache.iterdir()) - except OSError as exc: - raise LauncherUpdateError( - f"cannot inspect launcher bytecode cache {cache}: {exc}" - ) from exc - for entry in entries: - if entry.is_symlink() or not entry.is_file() or entry.suffix not in {".pyc", ".pyo"}: - raise LauncherUpdateError( - f"launcher bytecode cache contains an unexpected path: {entry}" - ) - try: - for entry in entries: - entry.unlink() - cache.rmdir() - except OSError as exc: - raise LauncherUpdateError( - f"cannot remove launcher bytecode cache {cache}: {exc}" - ) from exc - - -def _self_check_release(release: Path, expected_version: str) -> None: - check = ( - "import sys; " - "sys.path.insert(0, sys.argv[1]); " - "import ptlinux, ptlinux.cli, ptlinux.self_update, ptlinux.ui; " - "raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)" - ) - try: - result = subprocess.run( - [sys.executable, "-I", "-B", "-c", check, str(release), expected_version], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=15, - check=False, - ) - except (OSError, subprocess.TimeoutExpired) as exc: - raise LauncherUpdateError(f"could not test the staged launcher: {exc}") from exc - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise LauncherUpdateError( - "staged launcher self-check failed" + (f": {detail}" if detail else "") - ) - - -@contextmanager -def _launcher_mutation_lock(paths: AppPaths, operation: str) -> Iterator[None]: - """Exclude game launches and concurrent release-link mutations.""" - - with launcher_operation_lock( - paths.launcher_operation_lock, - operation, - active_game_probe=lambda: managed_game_process_is_active( - paths.runtime_view, - (paths.proton_prefix, paths.umu_prefix), - ), - ): - with launcher_operation_lock(paths.launcher_update_lock, operation): - yield - - -def _precheck_release_install(paths: AppPaths, manifest: UpdateManifest) -> None: - """Reject a release before download when local transaction prerequisites fail.""" - - _validate_rollback_config_compatibility(paths, manifest) - for directory, label in ( - (paths.downloads, "launcher download cache"), - (paths.app_home, "launcher application directory"), - ): - directory.mkdir(parents=True, exist_ok=True) - if directory.is_symlink() or not directory.is_dir() or not os.access(directory, os.W_OK): - raise LauncherUpdateError(f"{label} is not a writable directory: {directory}") - archive = paths.launcher_update_archive(manifest.version) - cache_bytes = 0 if _archive_matches(archive, manifest) else manifest.archive_size - extracted_bytes = ( - sum(file.size for file in manifest.files) - if manifest.files - else min(MAX_EXTRACTED_BYTES, manifest.archive_size * 4) - ) - _require_free_space(paths.downloads, cache_bytes + 1024 * 1024, "download") - _require_free_space(paths.app_home, extracted_bytes + 1024 * 1024, "staging") - - -def _require_free_space(path: Path, required_bytes: int, label: str) -> None: - try: - free_bytes = shutil.disk_usage(path).free - except OSError as exc: - raise LauncherUpdateError(f"cannot inspect free space for {path}: {exc}") from exc - if free_bytes < required_bytes: - raise LauncherUpdateError( - f"insufficient free disk space for launcher {label}: " - f"need {required_bytes} bytes, have {free_bytes}" - ) - - -def _current_config_schema(paths: AppPaths) -> int: - from .settings import SETTINGS_SCHEMA - - try: - data = paths.settings_file.read_bytes() - except FileNotFoundError: - return SETTINGS_SCHEMA - except OSError as exc: - raise LauncherUpdateError(f"cannot inspect launcher settings schema: {exc}") from exc - if len(data) > 1024 * 1024: - raise LauncherUpdateError("launcher settings file is too large to validate") - try: - value = json.loads(data.decode("utf-8-sig"), object_pairs_hook=_unique_json_object) - except (UnicodeError, json.JSONDecodeError, ValueError) as exc: - raise LauncherUpdateError(f"launcher settings are invalid: {exc}") from exc - if not isinstance(value, dict): - raise LauncherUpdateError("launcher settings must contain an object") - schema = value.get("schema", SETTINGS_SCHEMA) - if isinstance(schema, bool) or not isinstance(schema, int) or schema <= 0: - raise LauncherUpdateError("launcher settings schema is invalid") - return schema - - -def _validate_rollback_config_compatibility( - paths: AppPaths, - manifest: UpdateManifest, -) -> None: - _validate_config_schema_range( - _current_config_schema(paths), - manifest.minimum_config_schema, - manifest.config_schema, - f"launcher {manifest.version}", - ) - - -def _validate_rollback_release_compatibility(paths: AppPaths, release: Path) -> None: - metadata = _read_release_metadata(release) - config_schema = metadata.get("config_schema", 1) - minimum_config_schema = metadata.get("minimum_config_schema", 1) - if ( - isinstance(config_schema, bool) - or not isinstance(config_schema, int) - or isinstance(minimum_config_schema, bool) - or not isinstance(minimum_config_schema, int) - ): - raise LauncherUpdateError("launcher release has invalid config compatibility") - _validate_config_schema_range( - _current_config_schema(paths), - minimum_config_schema, - config_schema, - f"launcher {metadata.get('version', release.name)}", - ) - - -def _validate_config_schema_range( - current: int, - minimum: int, - maximum: int, - label: str, -) -> None: - if minimum <= 0 or maximum <= 0 or minimum > maximum: - raise LauncherUpdateError(f"{label} has invalid config schema compatibility") - if current < minimum or current > maximum: - raise LauncherUpdateError( - f"{label} supports config schemas {minimum} through {maximum}, " - f"but the installed configuration uses schema {current}" - ) - - -def _read_release_metadata(release: Path) -> dict[str, object]: - metadata_path = release / "release.json" - try: - metadata = json.loads( - metadata_path.read_text(encoding="utf-8"), - object_pairs_hook=_unique_json_object, - ) - except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: - raise LauncherUpdateError(f"launcher release metadata is invalid: {exc}") from exc - if not isinstance(metadata, dict) or metadata.get("schema") != 1: - raise LauncherUpdateError("launcher release metadata has an unsupported schema") - return metadata - - -def _activate_release( - paths: AppPaths, - release: Path, - *, - telemetry_metadata: Mapping[str, object] | None = None, - restore_reason: str = "failed_update", -) -> Path | None: - if paths.app_home.is_symlink(): - raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}") - paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755) - paths.app_home.chmod(0o755) - _recover_interrupted_activation(paths) - current = _release_from_link(paths.launcher_current, paths.launcher_releases) - previous = _release_from_link(paths.launcher_previous, paths.launcher_releases) - if paths.launcher_current.exists() and not paths.launcher_current.is_symlink(): - raise LauncherUpdateError( - f"launcher current path is not a symbolic link: {paths.launcher_current}" - ) - if paths.launcher_previous.exists() and not paths.launcher_previous.is_symlink(): - raise LauncherUpdateError( - f"launcher previous path is not a symbolic link: {paths.launcher_previous}" - ) - resolved_release = release.resolve() - _validate_release_directory(resolved_release) - if current == resolved_release: - return current - _write_activation_journal( - paths, - state="applying", - current=current, - previous=previous, - target=resolved_release, - ) - try: - if current is not None: - _validate_release_directory(current) - _replace_release_link( - paths.launcher_previous, - current, - paths.launcher_releases, - ) - _replace_release_link( - paths.launcher_current, - resolved_release, - paths.launcher_releases, - ) - _fsync_directory(paths.app_home) - activated = _release_from_link(paths.launcher_current, paths.launcher_releases) - retained_previous = _release_from_link( - paths.launcher_previous, - paths.launcher_releases, - ) - expected_previous = current if current is not None else previous - if activated is None or activated != resolved_release or retained_previous != expected_previous: - raise LauncherUpdateError( - "launcher release links failed their post-activation health check" - ) - assert activated is not None - _validate_release_directory(activated) - _write_activation_journal( - paths, - state="activated", - current=current, - previous=previous, - target=resolved_release, - ) - except BaseException as activation_error: - restore_metadata = { - **dict(telemetry_metadata or {}), - "reason": restore_reason, - } - _send_launcher_release_event( - "rollback_restore_started", - metadata=restore_metadata, - operation="launcher_rollback", - stage="restore", - ) - try: - _restore_release_links(paths, current=current, previous=previous) - except BaseException as restore_error: - _send_launcher_release_event( - "rollback_restore_failed", - metadata=restore_metadata, - operation="launcher_rollback", - stage="restore", - severity="critical", - message=str(restore_error), - ) - raise LauncherUpdateError( - f"launcher activation failed ({activation_error}); " - f"snapshot restoration also failed ({restore_error})" - ) from activation_error - paths.launcher_update_transaction.unlink(missing_ok=True) - _fsync_directory(paths.launcher_update_transaction.parent) - _send_launcher_release_event( - "rollback_restore_success", - metadata=restore_metadata, - operation="launcher_rollback", - stage="restore", - severity="warning", - ) - raise - paths.launcher_update_transaction.unlink(missing_ok=True) - _fsync_directory(paths.launcher_update_transaction.parent) - return current - - -def _recover_interrupted_activation(paths: AppPaths) -> None: - journal = paths.launcher_update_transaction - try: - if journal.is_symlink(): - raise LauncherUpdateError(f"launcher transaction journal cannot be a link: {journal}") - value = json.loads( - journal.read_text(encoding="utf-8"), - object_pairs_hook=_unique_json_object, - ) - except FileNotFoundError: - return - except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: - raise LauncherUpdateError(f"launcher transaction journal is invalid: {exc}") from exc - if not isinstance(value, dict) or value.get("schema") != 1: - raise LauncherUpdateError("launcher transaction journal has an unsupported schema") - state = value.get("state") - if state not in {"applying", "activated"}: - raise LauncherUpdateError("launcher transaction journal has an invalid state") - current = _journal_release(paths, value.get("current"), "current") - previous = _journal_release(paths, value.get("previous"), "previous") - target_version = _journal_version(value.get("target"), "target") - target_path = paths.launcher_releases / target_version - try: - _validate_release_directory(target_path) - except LauncherUpdateError: - target = None - else: - target = target_path.resolve() - if state == "activated": - active = _release_from_link(paths.launcher_current, paths.launcher_releases) - retained_previous = _release_from_link( - paths.launcher_previous, - paths.launcher_releases, - ) - expected_previous = current if current is not None else previous - if target is not None and active == target and retained_previous == expected_previous: - journal.unlink() - _fsync_directory(journal.parent) - return - metadata = _recovery_telemetry_metadata(current, target_version) - _send_launcher_release_event( - "rollback_restore_started", - metadata=metadata, - operation="launcher_rollback", - stage="restore", - severity="warning", - ) - try: - _restore_release_links(paths, current=current, previous=previous) - except BaseException as exc: - _send_launcher_release_event( - "rollback_restore_failed", - metadata=metadata, - operation="launcher_rollback", - stage="restore", - severity="critical", - message=str(exc), - ) - raise - journal.unlink() - _fsync_directory(journal.parent) - _send_launcher_release_event( - "rollback_restore_success", - metadata=metadata, - operation="launcher_rollback", - stage="restore", - severity="warning", - ) - - -def _recovery_telemetry_metadata( - current: Path | None, - target_version: str, -) -> dict[str, object]: - current_version = current.name if current is not None else __version__ - try: - current_channel = SemanticVersion.parse(current_version).channel - except ReleaseModelError: - current_channel = STABLE_CHANNEL - target_channel = SemanticVersion.parse(target_version).channel - return _launcher_release_metadata( - current_version=current_version, - target_version=target_version, - current_channel=current_channel, - target_channel=target_channel, - selected_channel=target_channel, - tag=SemanticVersion.parse(target_version).tag, - manifest_schema=None, - manifest_sha256=None, - rollback=True, - reason="failed_update", - ) - - -def _journal_version(value: object, label: str) -> str: - if not isinstance(value, str): - raise LauncherUpdateError(f"launcher transaction {label} release is invalid") - try: - SemanticVersion.parse(value) - except ReleaseModelError as exc: - raise LauncherUpdateError( - f"launcher transaction {label} version is invalid" - ) from exc - return value - - -def _journal_release(paths: AppPaths, value: object, label: str) -> Path | None: - if value is None: - return None - version = _journal_version(value, label) - release = paths.launcher_releases / version - _validate_release_directory(release) - return release.resolve() - - -def _restore_release_links( - paths: AppPaths, - *, - current: Path | None, - previous: Path | None, -) -> None: - _set_release_link(paths.launcher_current, current, paths.launcher_releases) - _set_release_link(paths.launcher_previous, previous, paths.launcher_releases) - _fsync_directory(paths.app_home) - - -def _set_release_link(link: Path, release: Path | None, releases: Path) -> None: - if release is not None: - _replace_release_link(link, release, releases) - return - if link.is_symlink(): - link.unlink() - elif link.exists(): - raise LauncherUpdateError(f"launcher release link is not symbolic: {link}") - - -def _write_activation_journal( - paths: AppPaths, - *, - state: str, - current: Path | None, - previous: Path | None, - target: Path, -) -> None: - journal = paths.launcher_update_transaction - payload = { - "schema": 1, - "state": state, - "current": current.name if current is not None else None, - "previous": previous.name if previous is not None else None, - "target": target.name, - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - } - journal.parent.mkdir(parents=True, exist_ok=True) - temporary = journal.with_name(f".{journal.name}.{os.getpid()}.tmp") - try: - with temporary.open("w", 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, journal) - _fsync_directory(journal.parent) - except BaseException: - temporary.unlink(missing_ok=True) - raise - - -def _replace_release_link(link: Path, release: Path, releases: Path) -> None: - resolved_release = release.resolve() - resolved_releases = releases.resolve() - if resolved_release.parent != resolved_releases: - raise LauncherUpdateError(f"release is outside the launcher release directory: {release}") - _validate_release_directory(resolved_release) - temporary = link.with_name(f".{link.name}.{os.getpid()}.tmp") - temporary.unlink(missing_ok=True) - try: - temporary.symlink_to(Path("releases") / resolved_release.name) - os.replace(temporary, link) - except BaseException: - temporary.unlink(missing_ok=True) - raise - - -def _release_from_link(link: Path, releases: Path) -> Path | None: - if not link.is_symlink(): - if link.exists(): - raise LauncherUpdateError(f"launcher release link is not symbolic: {link}") - return None - try: - target_text = os.readlink(link) - except OSError as exc: - raise LauncherUpdateError(f"cannot read launcher release link {link}: {exc}") from exc - target = (link.parent / target_text).resolve() - root = releases.resolve() - if target.parent != root: - raise LauncherUpdateError(f"launcher release link escapes its release directory: {link}") - if not target.is_dir() or target.is_symlink(): - raise LauncherUpdateError(f"launcher release link is broken: {link}") - return target - - -def _archive_matches(path: Path, manifest: UpdateManifest) -> bool: - try: - return ( - path.stat().st_size == manifest.archive_size - and _sha256_file(path) == manifest.archive_sha256 - ) - except OSError: - return False - - -def _tree_digest(root: Path) -> str: - digest = hashlib.sha256() - root_mode = root.stat().st_mode - digest.update(b"R\x00" + str(root_mode & 0o777).encode("ascii") + b"\x00") - for item in sorted(root.rglob("*"), key=lambda value: value.relative_to(root).as_posix()): - relative = item.relative_to(root).as_posix().encode("utf-8") - if item.is_symlink(): - raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}") - state = item.stat() - mode = str(state.st_mode & 0o777).encode("ascii") - if stat.S_ISDIR(state.st_mode): - digest.update(b"D\x00" + relative + b"\x00" + mode + b"\x00") - elif stat.S_ISREG(state.st_mode): - digest.update(b"F\x00" + relative + b"\x00") - digest.update(mode + b"\x00") - digest.update(str(state.st_size).encode("ascii") + b"\x00") - with item.open("rb") as handle: - for chunk in iter(lambda: handle.read(READ_SIZE), b""): - digest.update(chunk) - digest.update(b"\x00") - else: - raise LauncherUpdateError(f"launcher release contains a special file: {item}") - return digest.hexdigest() - - -def _sha256_file(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 _signature_url(manifest_url: str) -> str: - parts = urlsplit(manifest_url) - return urlunsplit((parts.scheme, parts.netloc, parts.path + ".sig", parts.query, "")) - - -def _resource_url(value: str) -> str: - parts = urlsplit(value) - return urlunsplit( - (parts.scheme.casefold(), parts.netloc.casefold(), parts.path, parts.query, "") - ) - - -def _require_https_url(value: str, label: str) -> None: - parts = urlsplit(value) - if ( - parts.scheme.casefold() != "https" - or not parts.netloc - or parts.username is not None - or parts.password is not None - or parts.fragment - ): - raise LauncherUpdateError(f"{label} must be an HTTPS URL without credentials") - - -def _parse_version(value: str, label: str) -> SemanticVersion: - try: - return SemanticVersion.parse(value) - except ReleaseModelError as exc: - raise LauncherUpdateError(f"invalid {label}: {value!r}: {exc}") from exc - - -def _parse_published_at(value: str) -> datetime: - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError as exc: - raise LauncherUpdateError( - f"manifest published_at must be an ISO-8601 timestamp: {value!r}" - ) from exc - if parsed.tzinfo is None or parsed.utcoffset() is None: - raise LauncherUpdateError("manifest published_at must include a timezone") - return parsed - - -def _cached_timestamp(value: object) -> str | None: - if not isinstance(value, str) or not value: - return None - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None or parsed.utcoffset() is None: - return None - return value - - -def _release_version( - releases: list[LauncherInstalledRelease], - *, - active: bool = False, - previous: bool = False, -) -> str | None: - for release in releases: - if (active and release.active) or (previous and release.previous): - return release.version - return None - - -def _required_string(value: dict[str, object], key: str) -> str: - item = value.get(key) - if not isinstance(item, str) or not item: - raise LauncherUpdateError(f"update manifest field {key!r} must be a string") - return item - - -def _required_integer(value: dict[str, object], key: str) -> int: - item = value.get(key) - if isinstance(item, bool) or not isinstance(item, int): - raise LauncherUpdateError(f"update manifest field {key!r} must be an integer") - return item - - -def _required_positive_integer(value: dict[str, object], key: str) -> int: - item = _required_integer(value, key) - if item <= 0: - raise LauncherUpdateError( - f"update manifest field {key!r} must be a positive integer" - ) - return item - - -def _optional_positive_integer(value: dict[str, object], key: str) -> int | None: - item = value.get(key) - if item is None: - return None - if isinstance(item, bool) or not isinstance(item, int) or item <= 0: - raise LauncherUpdateError( - f"release history field {key!r} must be a positive integer or null" - ) - return item - - -def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: - result: dict[str, object] = {} - for key, value in pairs: - if key in result: - raise ValueError(f"duplicate JSON key: {key}") - result[key] = value - return result - - -def _content_length(value: str | None, label: str) -> int | None: - if value is None: - return None - try: - length = int(value) - except ValueError as exc: - raise LauncherUpdateError(f"{label} returned an invalid Content-Length") from exc - if length < 0: - raise LauncherUpdateError(f"{label} returned an invalid Content-Length") - return length - - -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") - try: - with temporary.open("w", 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) - _fsync_directory(path.parent) - except BaseException: - temporary.unlink(missing_ok=True) - raise - - -def _record_launcher_operation( - paths: AppPaths, - *, - from_version: str, - to_version: str, - from_channel: str, - to_channel: str, - selected_channel: str, - result: str, - rollback: bool, - reason: str, - tag: str | None = None, - manifest_schema: int | None = None, - manifest_sha256: str | None = None, -) -> bool: - """Append one bounded diagnostic record without affecting an installation.""" - - path = paths.launcher_update_operations - try: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - value = {"schema": LOCAL_OPERATION_HISTORY_SCHEMA, "operations": []} - if not isinstance(value, dict) or value.get("schema") != LOCAL_OPERATION_HISTORY_SCHEMA: - return False - operations = value.get("operations") - if not isinstance(operations, list): - return False - operations = [item for item in operations if isinstance(item, dict)] - operations.append( - { - "at": datetime.now().astimezone().isoformat(timespec="seconds"), - "from": from_version, - "from_channel": from_channel, - "manifest_hash": manifest_sha256, - "manifest_version": manifest_schema, - "reason": reason, - "result": result, - "rollback": rollback, - "selected_channel": selected_channel, - "tag": tag, - "to": to_version, - "to_channel": to_channel, - } - ) - _write_json( - path, - { - "schema": LOCAL_OPERATION_HISTORY_SCHEMA, - "operations": operations[-MAX_LOCAL_OPERATION_HISTORY:], - }, - ) - except (OSError, UnicodeError, json.JSONDecodeError, ValueError, RuntimeError): - return False - return True - - -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 update_check_to_dict(check: LauncherUpdateCheck) -> dict[str, object]: - """Return an explicitly public JSON representation for CLI/UI callers.""" - - return asdict(check) diff --git a/ptlinux/telemetry_environment.py b/ptlinux/telemetry_environment.py deleted file mode 100644 index 3874c49..0000000 --- a/ptlinux/telemetry_environment.py +++ /dev/null @@ -1,1395 +0,0 @@ -"""Bounded, privacy-safe compatibility details for launcher telemetry.""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from datetime import datetime, timezone -import json -import os -from pathlib import Path -import platform -import re -import shutil -import struct -import subprocess - -from .paths import AppPaths - - -PROBE_TIMEOUT_SECONDS = 3.0 -MAX_MARKER_BYTES = 256 * 1024 -MAX_DLL_SCAN_BYTES = 16 * 1024 * 1024 -MAX_PROBE_OUTPUT_CHARACTERS = 64 * 1024 - -_VULKAN_MANIFEST_DIRECTORIES = ( - Path("/etc/vulkan/icd.d"), - Path("/usr/local/share/vulkan/icd.d"), - Path("/usr/share/vulkan/icd.d"), -) -_LIBRARY_ROOTS = ( - Path("/lib"), - Path("/lib32"), - Path("/usr/lib"), - Path("/usr/lib32"), - Path("/usr/local/lib"), - Path("/usr/local/lib32"), -) -_PCI_DATABASES = ( - Path("/usr/share/hwdata/pci.ids"), - Path("/usr/share/misc/pci.ids"), - Path("/usr/share/pci.ids"), -) -_GPU_VENDORS = { - 0x1002: "AMD", - 0x10DE: "NVIDIA", - 0x8086: "Intel", -} -_DXVK_VERSION_PATTERN = re.compile( - r"\bdxvk(?:\s+version|[- /:])?\s*v?(\d+\.\d+(?:\.\d+){0,2})\b", - re.IGNORECASE, -) -_VERSION_PATTERN = re.compile(r"\d+(?:\.\d+){1,3}") - - -@dataclass(frozen=True) -class ProbeResult: - """Small result surface for bounded local compatibility probes.""" - - returncode: int - stdout: str - stderr: str - - -ProbeRunner = Callable[[Sequence[str], Mapping[str, str], float], ProbeResult | None] - - -def collect_extended_environment( - paths: AppPaths, - environment: Mapping[str, str], - *, - probe_runner: ProbeRunner | None = None, - sysfs_drm: Path = Path("/sys/class/drm"), - vulkan_manifest_directories: Sequence[Path] = _VULKAN_MANIFEST_DIRECTORIES, - library_roots: Sequence[Path] = _LIBRARY_ROOTS, - pci_databases: Sequence[Path] = _PCI_DATABASES, - now: datetime | None = None, -) -> dict[str, object]: - """Collect compatibility data once without returning paths or device IDs.""" - - run_probe = probe_runner or _run_probe - marker = _read_json_object(paths.game_runtime_marker) - prefix = _selected_prefix(paths, marker) - runtime = _runtime_info( - paths, - marker, - prefix, - environment, - run_probe, - vulkan_manifest_directories=vulkan_manifest_directories, - library_roots=library_roots, - now=now or datetime.now(timezone.utc), - ) - gpu = _gpu_info( - runtime.pop("_vulkan_summary", None), - environment=environment, - sysfs_drm=sysfs_drm, - pci_databases=pci_databases, - ) - dependencies = _dependency_health( - environment, - prefix, - runtime, - ) - result: dict[str, object] = { - "runtime": runtime, - "dependencies": dependencies, - } - display = _display_info(sysfs_drm) - display.update(_kde_display_scaling_info(environment, run_probe)) - if display: - result["display"] = display - if gpu: - result["gpu"] = gpu - return result - - -def _runtime_info( - paths: AppPaths, - marker: Mapping[str, object] | None, - prefix: Path | None, - environment: Mapping[str, str], - probe_runner: ProbeRunner, - *, - vulkan_manifest_directories: Sequence[Path], - library_roots: Sequence[Path], - now: datetime, -) -> dict[str, object]: - runtime_marker = _object_field(marker, "runtime") - runtime_kind = _short_string(runtime_marker.get("kind")) if runtime_marker else None - result: dict[str, object] = { - "kind": runtime_kind or "unknown", - "wine_vendor": _wine_vendor(runtime_kind, runtime_marker, paths), - } - wine_command = _wine_command(runtime_marker, environment) - if wine_command is not None: - wine_probe = probe_runner( - (wine_command, "--version"), - environment, - PROBE_TIMEOUT_SECONDS, - ) - wine_build = _first_probe_line(wine_probe) - if wine_build: - result["wine_build"] = wine_build - wine_version = _wine_version(wine_build) - if wine_version: - result["wine"] = wine_version - - if runtime_marker: - proton_name = _proton_name(runtime_marker.get("proton_path")) - if proton_name: - result["proton_version"] = proton_name - - if prefix is not None: - prefix_architecture = _wine_prefix_architecture(prefix) - if prefix_architecture: - result["prefix_arch"] = prefix_architecture - configured_at = _marker_timestamp(marker) - if configured_at is not None: - result["prefix_created_at"] = configured_at.isoformat() - result["prefix_age_days"] = max(0, (now - configured_at).days) - result.update( - _dxvk_info( - prefix, - packaged_version=_proton_dxvk_version(runtime_marker), - ) - ) - else: - result.update( - { - "dxvk_enabled": False, - "dxvk_install_status": "missing", - } - ) - result.update(_runtime_component_info(paths.launcher_prefix, prefix)) - - vulkan = _vulkan_info( - environment, - probe_runner, - manifest_directories=vulkan_manifest_directories, - library_roots=library_roots, - ) - result.update(vulkan) - return result - - -def _vulkan_info( - environment: Mapping[str, str], - probe_runner: ProbeRunner, - *, - manifest_directories: Sequence[Path], - library_roots: Sequence[Path], -) -> dict[str, object]: - loader_architectures = _vulkan_loader_architectures(library_roots) - manifest_architectures: set[int] = set() - api_versions: list[str] = [] - for manifest in _vulkan_manifests(environment, manifest_directories): - parsed = _read_json_object(manifest) - icd = _object_field(parsed, "ICD") - if icd is None: - continue - api_version = _short_string(icd.get("api_version")) - if api_version and _VERSION_PATTERN.fullmatch(api_version): - api_versions.append(api_version) - library = _short_string(icd.get("library_path")) - architectures = _manifest_library_architectures( - manifest, - library, - library_roots, - ) - manifest_architectures.update(architectures) - - vulkaninfo = shutil.which("vulkaninfo", path=environment.get("PATH", "")) - probe = ( - probe_runner( - (vulkaninfo, "--summary"), - environment, - PROBE_TIMEOUT_SECONDS, - ) - if vulkaninfo - else None - ) - functional_64bit = probe is not None and probe.returncode == 0 - summary = _probe_text(probe) if functional_64bit else "" - if summary: - api_version = _summary_value(summary, "apiVersion") - if api_version: - api_versions.append(api_version) - instance_version = re.search( - r"Vulkan\s+Instance\s+Version\s*:\s*(\S+)", - summary, - re.IGNORECASE, - ) - if instance_version: - api_versions.append(instance_version.group(1)) - - available_64bit = functional_64bit or ( - 64 in loader_architectures and 64 in manifest_architectures - ) - available_32bit = ( - 32 in loader_architectures and 32 in manifest_architectures - ) - result: dict[str, object] = { - "vulkan_available": available_64bit, - "vulkan_64bit": available_64bit, - "vulkan_32bit": available_32bit, - "vulkan_64bit_loader_available": 64 in loader_architectures, - "vulkan_64bit_icd_available": 64 in manifest_architectures, - "vulkan_64bit_probe_succeeded": functional_64bit, - "vulkan_32bit_loader_available": 32 in loader_architectures, - "vulkan_32bit_icd_available": 32 in manifest_architectures, - "vulkan_64bit_detection": ( - "vulkaninfo" if functional_64bit else "loader_and_icd" - ), - "vulkan_32bit_detection": "loader_and_icd", - } - best_api_version = _maximum_version(api_versions) - if best_api_version: - result["vulkan_api_version"] = best_api_version - driver_info = _summary_value(summary, "driverInfo") - if driver_info: - result["vulkan_driver_version"] = _driver_version(driver_info) or driver_info - if summary: - result["_vulkan_summary"] = summary - return result - - -def inspect_vulkan_support( - environment: Mapping[str, str], - *, - probe_runner: ProbeRunner | None = None, - manifest_directories: Sequence[Path] = _VULKAN_MANIFEST_DIRECTORIES, - library_roots: Sequence[Path] = _LIBRARY_ROOTS, -) -> dict[str, object]: - """Return host Vulkan loader and ICD readiness for dependency checks.""" - - return _vulkan_info( - environment, - probe_runner or _run_probe, - manifest_directories=manifest_directories, - library_roots=library_roots, - ) - - -def _gpu_info( - vulkan_summary: object, - *, - environment: Mapping[str, str], - sysfs_drm: Path, - pci_databases: Sequence[Path], -) -> dict[str, object]: - summary = vulkan_summary if isinstance(vulkan_summary, str) else "" - vulkan_gpus = _vulkan_gpu_summaries(summary) - has_gpu_blocks = re.search(r"(?mi)^\s*GPU\d+\s*:\s*$", summary) is not None - if vulkan_gpus: - primary_summary = vulkan_gpus[0] - elif has_gpu_blocks: - primary_summary = "" - else: - primary_summary = summary - vendor_id = _hex_summary_value(primary_summary, "vendorID") - model = _summary_value(primary_summary, "deviceName") - driver_name = _summary_value(primary_summary, "driverName") - driver_info = _summary_value(primary_summary, "driverInfo") - sysfs_gpus = _sysfs_gpus(sysfs_drm) - sysfs = next( - (gpu for gpu in sysfs_gpus if gpu.get("boot_vga") is True), - sysfs_gpus[0] if sysfs_gpus else None, - ) - if vendor_id is None and sysfs is not None: - vendor_id = sysfs.get("vendor_id") - if model is None and sysfs is not None: - model = _short_string(sysfs.get("model")) - if model is None and sysfs is not None: - device_id = sysfs.get("device_id") - if isinstance(vendor_id, int) and isinstance(device_id, int): - model = _pci_model(vendor_id, device_id, pci_databases) - if driver_name is None and sysfs is not None: - driver_name = _short_string(sysfs.get("driver")) - if driver_info is None and sysfs is not None: - driver_info = _short_string(sysfs.get("driver_version")) - - vendor = _GPU_VENDORS.get(vendor_id) if isinstance(vendor_id, int) else None - if vendor is None: - vendor = _vendor_from_model(model) - driver = _friendly_driver_name(driver_name, driver_info) - driver_version = _driver_version(driver_info) - result: dict[str, object] = {} - if vendor: - result["vendor"] = vendor - if model: - result["model"] = model[:255] - if driver: - result["driver"] = driver[:128] - if driver_version: - result["driver_version"] = driver_version[:128] - if sysfs is not None and isinstance(sysfs.get("vram_mb"), int): - result["vram_mb"] = sysfs["vram_mb"] - summary_gpu_count = len(vulkan_gpus) - if summary_gpu_count == 0 and model: - summary_gpu_count = 1 - gpu_count = max(len(sysfs_gpus), summary_gpu_count) - if gpu_count: - result["count"] = gpu_count - if vendor: - result["primary_vendor"] = vendor - if model: - result["primary_model"] = model[:255] - vendor_ids = { - vendor - for gpu in sysfs_gpus - if isinstance((vendor := gpu.get("vendor_id")), int) - } - for gpu_summary in vulkan_gpus: - vendor_ids.update(_hex_summary_values(gpu_summary, "vendorID")) - device_types: set[str] = set() - for gpu_summary in vulkan_gpus: - device_type = _summary_value(gpu_summary, "deviceType") - if isinstance(device_type, str): - device_types.add(device_type.casefold()) - integrated_and_discrete = ( - any("integrated" in value for value in device_types) - and any("discrete" in value for value in device_types) - ) - result["hybrid_graphics"] = gpu_count > 1 and ( - len(vendor_ids) > 1 or integrated_and_discrete - ) - result["offload_detected"] = _gpu_offload_detected(environment) - return result - - -def _dependency_health( - environment: Mapping[str, str], - prefix: Path | None, - runtime: Mapping[str, object], -) -> dict[str, object]: - tools: dict[str, bool] = {} - required: list[str] = [] - missing: list[str] = [] - container_ready: bool | None = None - try: - from .doctor import inspect_host - - report = inspect_host(environment, vulkan_support=runtime) - for item in report.tools: - available = item.path is not None - tools[item.name] = available - if item.required: - required.append(item.name) - if not available: - missing.append(item.name) - container_ready = report.container_host.ready - report_ok: bool | None = report.ok - except Exception: - report_ok = None - - wine_available = tools.get("wine") - if wine_available is None: - wine_available = _wine_command(None, environment) is not None - wine_32bit = _wine_32bit_available(prefix) - vulkan_64bit = _optional_bool(runtime.get("vulkan_64bit")) - vulkan_32bit = _optional_bool(runtime.get("vulkan_32bit")) - font_dependencies = _font_dependencies_present(prefix) - archive_tools = tools.get("cabextract") - if archive_tools is None: - archive_tools = shutil.which( - "cabextract", - path=environment.get("PATH", ""), - ) is not None - shared_libraries = ( - vulkan_64bit and vulkan_32bit - if platform.machine().casefold() in {"x86_64", "amd64"} - else vulkan_64bit - ) - healthy = report_ok - if healthy is not None: - healthy = bool(healthy and shared_libraries) - result: dict[str, object] = { - "healthy": healthy, - "required_count": len(required), - "available_required_count": len(required) - len(missing), - "missing_required": missing, - "tools": tools, - "wine_available": wine_available, - "wine_32bit_available": wine_32bit, - "vulkan_available": bool(runtime.get("vulkan_available")), - "vulkan_64bit_available": vulkan_64bit, - "vulkan_32bit_available": vulkan_32bit, - "font_dependencies_present": font_dependencies, - "dotnet_present": bool(runtime.get("dotnet_present")), - "archive_tools_present": archive_tools, - "required_shared_libraries_present": shared_libraries, - "directx_runtime_present": bool(runtime.get("directx_runtime_present")), - } - if container_ready is not None: - result["container_ready"] = container_ready - return result - - -def _selected_prefix( - paths: AppPaths, - marker: Mapping[str, object] | None, -) -> Path | None: - runtime = _object_field(marker, "runtime") - kind = _short_string(runtime.get("kind")) if runtime else None - preferred = paths.umu_prefix if kind == "umu" else paths.proton_prefix - for candidate in (preferred, paths.proton_prefix, paths.umu_prefix): - if (candidate / "system.reg").is_file() or ( - candidate / "drive_c/windows" - ).is_dir(): - return candidate - return None - - -def _wine_command( - runtime: Mapping[str, object] | None, - environment: Mapping[str, str], -) -> str | None: - if runtime: - proton_path = _short_string(runtime.get("proton_path")) - if proton_path: - root = Path(proton_path).expanduser().parent - for relative in ( - "files/bin/wine64", - "files/bin/wine", - "dist/bin/wine64", - "dist/bin/wine", - ): - candidate = root / relative - if candidate.is_file() and os.access(candidate, os.X_OK): - return str(candidate) - executable = _short_string(runtime.get("executable")) - if executable and Path(executable).name.casefold() in {"wine", "wine64"}: - return executable - return shutil.which("wine", path=environment.get("PATH", "")) - - -def _wine_vendor( - runtime_kind: str | None, - runtime: Mapping[str, object] | None, - paths: AppPaths, -) -> str: - if runtime_kind in {"proton7", "proton7-container", "umu"}: - return "proton" if runtime_kind != "umu" else "umu" - if runtime: - executable = _short_string(runtime.get("executable")) - if executable: - lowered = executable.casefold() - if "lutris" in lowered: - return "lutris" - try: - if Path(executable).resolve().is_relative_to(paths.data.resolve()): - return "pristontale-runtime" - except (OSError, RuntimeError): - pass - return "system" - - -def _wine_version(build: str) -> str | None: - match = re.search(r"\bwine(?:64)?[- ]v?(\d+(?:\.\d+){1,3})", build, re.IGNORECASE) - if match: - return match.group(1) - match = _VERSION_PATTERN.search(build) - return match.group(0) if match else None - - -def _proton_name(value: object) -> str | None: - path = _short_string(value) - if not path: - return None - name = Path(path).parent.name.strip() - if "proton" not in name.casefold(): - return None - version = re.search(r"\d+(?:[._-]\d+)+(?:[A-Za-z0-9._-]*)?", name) - return version.group(0)[:64] if version else None - - -def _wine_prefix_architecture(prefix: Path) -> str | None: - try: - with (prefix / "system.reg").open("r", encoding="utf-8", errors="replace") as handle: - text = handle.read(4096) - except OSError: - return None - match = re.search(r"(?m)^#arch=(win32|win64)\s*$", text, re.IGNORECASE) - return match.group(1).casefold() if match else None - - -def _marker_timestamp(marker: Mapping[str, object] | None) -> datetime | None: - if marker is None: - return None - value = _short_string(marker.get("configured_at")) - if not value: - return None - try: - parsed = datetime.fromisoformat(value) - except ValueError: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - - -def _dxvk_info( - prefix: Path, - *, - packaged_version: str | None = None, -) -> dict[str, object]: - components: dict[str, str | None] = {} - identified_components: set[str] = set() - files_present = 0 - dxvk_components = 0 - invalid = 0 - for name in ("d3d9", "d3d11", "dxgi"): - versions: list[str] = [] - component_present = False - component_is_dxvk = False - for system_directory in ("syswow64", "system32"): - dll = prefix / "drive_c/windows" / system_directory / f"{name}.dll" - if dll.is_symlink() and not dll.exists(): - invalid += 1 - continue - if not dll.is_file(): - continue - component_present = True - is_dxvk, version = _dxvk_dll_details(dll) - component_is_dxvk = component_is_dxvk or is_dxvk - if version: - versions.append(version) - if component_present: - files_present += 1 - if component_is_dxvk: - dxvk_components += 1 - identified_components.add(name) - components[name] = _maximum_version(versions) - - if invalid: - status = "invalid" - elif files_present == 0: - status = "missing" - elif dxvk_components == 0: - status = "unknown" - elif dxvk_components < len(components): - status = "partial" - else: - status = "installed" - result: dict[str, object] = { - "dxvk_enabled": dxvk_components > 0 and status != "invalid", - "dxvk_install_status": status, - } - all_versions = [ - version for version in components.values() if isinstance(version, str) - ] - dxvk_version = _maximum_version(all_versions) or packaged_version - if dxvk_version: - result["dxvk_version"] = dxvk_version - for name, version in components.items(): - if version is None and name in identified_components: - version = packaged_version - if version: - result[f"dxvk_{name}_version"] = version - return result - - -def _proton_dxvk_version(runtime: Mapping[str, object] | None) -> str | None: - if runtime is None: - return None - proton_path = _short_string(runtime.get("proton_path")) - if not proton_path: - return None - root = Path(proton_path).expanduser().parent - for relative in ( - "dist/lib/wine/dxvk/version", - "dist/lib64/wine/dxvk/version", - "files/lib/wine/dxvk/version", - "files/lib64/wine/dxvk/version", - ): - value = _read_short_text(root / relative) - if not value: - continue - match = re.search( - r"\bdxvk\s+\(v([0-9][0-9A-Za-z.+-]{1,63})\)", - value, - re.IGNORECASE, - ) - if match: - return match.group(1) - return None - - -def _dxvk_dll_details(path: Path) -> tuple[bool, str | None]: - try: - with path.open("rb") as handle: - content = handle.read(MAX_DLL_SCAN_BYTES + 1) - except OSError: - return False, None - if len(content) > MAX_DLL_SCAN_BYTES: - content = content[:MAX_DLL_SCAN_BYTES] - texts = ( - content.decode("latin-1", errors="ignore"), - content.decode("utf-16-le", errors="ignore"), - ) - versions: list[str] = [] - is_dxvk = False - for text in texts: - is_dxvk = is_dxvk or "dxvk" in text.casefold() - versions.extend(match.group(1) for match in _DXVK_VERSION_PATTERN.finditer(text)) - version = _maximum_version(versions) - if version is None and is_dxvk: - version = _pe_fixed_file_version(content) - return is_dxvk, version - - -def _pe_fixed_file_version( - content: bytes, - *, - maximum_part: int = 10_000, -) -> str | None: - """Read the numeric VS_FIXEDFILEINFO version without a PE dependency.""" - - signature = b"\xbd\x04\xef\xfe" - offset = content.find(signature) - while offset >= 0 and offset + 52 <= len(content): - try: - values = struct.unpack_from("<13I", content, offset) - except struct.error: - return None - structure_version = values[1] - major = values[2] >> 16 - minor = values[2] & 0xFFFF - build = values[3] >> 16 - revision = values[3] & 0xFFFF - parts = [major, minor, build, revision] - if structure_version >> 16 == 1 and 0 < major < 100 and all( - part < maximum_part for part in parts - ): - while len(parts) > 2 and parts[-1] == 0: - parts.pop() - return ".".join(str(part) for part in parts) - offset = content.find(signature, offset + len(signature)) - return None - - -def _directx_runtime_present(prefix: Path) -> bool: - directory = prefix / "drive_c/windows/syswow64" - return all((directory / f"{name}.dll").is_file() for name in ("d3dx9_43", "d3dcompiler_43")) - - -def _runtime_component_info( - launcher_prefix: Path, - game_prefix: Path | None, -) -> dict[str, object]: - prefixes = tuple( - dict.fromkeys( - prefix - for prefix in (game_prefix, launcher_prefix) - if prefix is not None - ) - ) - dotnet_runtime_versions: list[str] = [] - dotnet_desktop_versions: list[str] = [] - dotnet_framework_versions: list[str] = [] - for prefix in prefixes: - dotnet_root = prefix / "drive_c/Program Files/dotnet/shared" - dotnet_runtime_versions.extend( - _directory_versions(dotnet_root / "Microsoft.NETCore.App") - ) - dotnet_desktop_versions.extend( - _directory_versions(dotnet_root / "Microsoft.WindowsDesktop.App") - ) - for framework_name in ("Framework", "Framework64"): - dotnet_framework_versions.extend( - _directory_versions( - prefix / "drive_c/windows/Microsoft.NET" / framework_name - ) - ) - - dotnet_runtime = _maximum_version(dotnet_runtime_versions) - dotnet_desktop = _maximum_version(dotnet_desktop_versions) - dotnet_framework = _maximum_version(dotnet_framework_versions) - vc_present, vc_version = _vc_runtime_info(prefixes) - directx_present = ( - _directx_runtime_present(game_prefix) - if game_prefix is not None - else False - ) - winetricks_components: list[str] = [] - if game_prefix is not None: - system = game_prefix / "drive_c/windows/syswow64" - for name in ("d3dcompiler_43", "d3dx9_43"): - if (system / f"{name}.dll").is_file(): - winetricks_components.append(name) - if dotnet_desktop: - winetricks_components.append( - "dotnetdesktop6" - if dotnet_desktop.startswith("6.") - else "dotnetdesktop" - ) - if vc_present: - winetricks_components.append("vcrun2015-2022") - - runtime_components: dict[str, object] = { - "directx": directx_present, - "vc_runtime": vc_present, - } - if dotnet_runtime or dotnet_framework: - runtime_components["dotnet_runtime"] = dotnet_runtime or dotnet_framework - if dotnet_desktop: - runtime_components["dotnet_desktop"] = dotnet_desktop - if vc_version: - runtime_components["vc_runtime_version"] = vc_version - - result: dict[str, object] = { - "dotnet_present": bool( - dotnet_runtime - or dotnet_desktop - or dotnet_framework - or (game_prefix is not None and _dotnet_present(game_prefix)) - ), - "vc_runtime_present": vc_present, - "directx_runtime_present": directx_present, - "winetricks_components": winetricks_components, - "runtime_components": runtime_components, - } - if dotnet_runtime or dotnet_framework: - result["dotnet_runtime_version"] = dotnet_runtime or dotnet_framework - if dotnet_desktop: - result["dotnet_desktop_runtime_version"] = dotnet_desktop - if vc_version: - result["vc_runtime_version"] = vc_version - return result - - -def _directory_versions(root: Path) -> tuple[str, ...]: - try: - iterator = root.iterdir() - except OSError: - return () - versions: list[str] = [] - try: - for index, entry in enumerate(iterator): - if index >= 64: - break - try: - is_directory = entry.is_dir() - except OSError: - continue - if not is_directory: - continue - version = entry.name.removeprefix("v") - if re.fullmatch(r"\d+(?:\.\d+){0,3}", version): - versions.append(version) - except OSError: - return tuple(versions) - return tuple(versions) - - -def _vc_runtime_info(prefixes: Sequence[Path]) -> tuple[bool, str | None]: - versions: list[str] = [] - present = False - for prefix in prefixes: - for system_directory in ("syswow64", "system32"): - system = prefix / "drive_c/windows" / system_directory - for name in ("vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll"): - dll = system / name - if not dll.is_file(): - continue - present = True - version = _dll_file_version(dll) - if version: - versions.append(version) - return present, _maximum_version(versions) - - -def _dll_file_version(path: Path) -> str | None: - try: - with path.open("rb") as handle: - content = handle.read(MAX_DLL_SCAN_BYTES) - except OSError: - return None - return _pe_fixed_file_version(content, maximum_part=1_000_000) - - -def _dotnet_present(prefix: Path) -> bool: - framework = prefix / "drive_c/windows/Microsoft.NET" - try: - return any(path.is_file() for path in framework.glob("Framework*/v*/mscorlib.dll")) - except OSError: - return False - - -def _wine_32bit_available(prefix: Path | None) -> bool | None: - if prefix is None: - return None - architecture = _wine_prefix_architecture(prefix) - if architecture == "win32": - return True - return (prefix / "drive_c/windows/syswow64").is_dir() - - -def _font_dependencies_present(prefix: Path | None) -> bool: - candidates = [ - Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), - Path("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf"), - ] - if prefix is not None: - fonts = prefix / "drive_c/windows/Fonts" - candidates.extend((fonts / "arial.ttf", fonts / "tahoma.ttf")) - return any(candidate.is_file() for candidate in candidates) - - -def _vulkan_manifests( - environment: Mapping[str, str], - directories: Sequence[Path], -) -> tuple[Path, ...]: - candidates: list[Path] = [] - override = environment.get("VK_DRIVER_FILES", "").strip() - if not override: - override = environment.get("VK_ICD_FILENAMES", "").strip() - if override: - candidates.extend(_manifest_entries(override)) - else: - candidates.extend(_manifest_entries(environment.get("VK_ADD_DRIVER_FILES", ""))) - for directory in _vulkan_manifest_search_directories(environment, directories): - try: - candidates.extend(sorted(directory.glob("*.json"))) - except OSError: - continue - seen: set[Path] = set() - result: list[Path] = [] - for candidate in candidates: - try: - resolved = candidate.resolve() - except (OSError, RuntimeError): - continue - if resolved in seen or not resolved.is_file(): - continue - seen.add(resolved) - result.append(resolved) - return tuple(result) - - -def _manifest_entries(value: str) -> tuple[Path, ...]: - result: list[Path] = [] - for raw_entry in value.split(os.pathsep): - if not raw_entry: - continue - entry = Path(raw_entry).expanduser() - try: - if entry.is_dir(): - result.extend(sorted(entry.glob("*.json"))) - else: - result.append(entry) - except OSError: - continue - return tuple(result) - - -def _vulkan_manifest_search_directories( - environment: Mapping[str, str], - directories: Sequence[Path], -) -> tuple[Path, ...]: - if directories is not _VULKAN_MANIFEST_DIRECTORIES: - return tuple(dict.fromkeys(directories)) - home = environment.get("HOME", "").strip() - config_home = environment.get("XDG_CONFIG_HOME", "").strip() - data_home = environment.get("XDG_DATA_HOME", "").strip() - config_directories = environment.get("XDG_CONFIG_DIRS", "/etc/xdg") - data_directories = environment.get( - "XDG_DATA_DIRS", - "/usr/local/share:/usr/share", - ) - result = list(directories) - if config_home: - result.append(Path(config_home).expanduser() / "vulkan/icd.d") - elif home: - result.append(Path(home).expanduser() / ".config/vulkan/icd.d") - for value in config_directories.split(os.pathsep): - if value: - result.append(Path(value).expanduser() / "vulkan/icd.d") - if data_home: - result.append(Path(data_home).expanduser() / "vulkan/icd.d") - elif home: - result.append(Path(home).expanduser() / ".local/share/vulkan/icd.d") - for value in data_directories.split(os.pathsep): - if value: - result.append(Path(value).expanduser() / "vulkan/icd.d") - return tuple(dict.fromkeys(result)) - - -def _vulkan_loader_architectures(library_roots: Sequence[Path]) -> set[int]: - architectures: set[int] = set() - for root in library_roots: - for candidate in _library_candidates(root, "libvulkan.so"): - architecture = _elf_architecture(candidate) - if architecture is not None: - architectures.add(architecture) - return architectures - - -def _manifest_library_architectures( - manifest: Path, - library: str | None, - library_roots: Sequence[Path], -) -> set[int]: - architectures: set[int] = set() - if library: - path = Path(library) - if path.is_absolute(): - architecture = _elf_architecture(path) - if architecture is not None: - architectures.add(architecture) - else: - candidates = [manifest.parent / path] - for root in library_roots: - candidates.extend(_library_candidates(root, path.name)) - for candidate in candidates: - architecture = _elf_architecture(candidate) - if architecture is not None: - architectures.add(architecture) - if architectures: - return architectures - lowered = manifest.name.casefold() - if any(token in lowered for token in ("i386", "i486", "i586", "i686", "x86_32")): - return {32} - if any(token in lowered for token in ("x86_64", "amd64")): - return {64} - return set() - - -def _library_candidates(root: Path, prefix: str) -> tuple[Path, ...]: - try: - direct = tuple(root.glob(f"{prefix}*")) - children = tuple(root.glob(f"*/{prefix}*")) - except OSError: - return () - return direct + children - - -def _elf_architecture(path: Path) -> int | None: - try: - with path.open("rb") as handle: - header = handle.read(5) - except OSError: - return None - if len(header) < 5 or header[:4] != b"\x7fELF": - return None - if header[4] == 1: - return 32 - if header[4] == 2: - return 64 - return None - - -def _sysfs_gpus(sysfs_drm: Path) -> tuple[dict[str, object], ...]: - try: - cards = sorted( - path for path in sysfs_drm.glob("card[0-9]*") if path.name[4:].isdigit() - ) - except OSError: - return () - result_gpus: list[dict[str, object]] = [] - for card in cards[:16]: - device = card / "device" - vendor_id = _read_hex(device / "vendor") - device_id = _read_hex(device / "device") - if vendor_id is None or device_id is None: - continue - result: dict[str, object] = { - "vendor_id": vendor_id, - "device_id": device_id, - } - if _read_decimal(device / "boot_vga") == 1: - result["boot_vga"] = True - for name in ("product_name", "label"): - value = _read_short_text(device / name) - if value: - result["model"] = value - break - try: - driver = (device / "driver").resolve(strict=True).name - except (OSError, RuntimeError): - driver = None - if driver: - result["driver"] = driver - version = _read_short_text(Path("/sys/module") / driver / "version") - if version: - result["driver_version"] = version - vram_bytes = _read_decimal(device / "mem_info_vram_total") - if vram_bytes is not None and vram_bytes > 0: - result["vram_mb"] = round(vram_bytes / (1024 * 1024)) - result_gpus.append(result) - return tuple(result_gpus) - - -def _display_info(sysfs_drm: Path) -> dict[str, object]: - monitor_count = _connected_monitor_count(sysfs_drm) - return {"monitor_count": monitor_count} if monitor_count is not None else {} - - -def _kde_display_scaling_info( - environment: Mapping[str, str], - probe_runner: ProbeRunner, -) -> dict[str, object]: - """Collect bounded Plasma scale facts without monitor names or resolutions.""" - - if not environment.get("WAYLAND_DISPLAY", "").strip() or not _is_kde_desktop( - environment - ): - return {} - result: dict[str, object] = {"kde_wayland": True} - executable = shutil.which( - "kscreen-doctor", - path=environment.get("PATH", ""), - ) - result["kscreen_doctor_available"] = executable is not None - if executable is None: - return result - probe = probe_runner( - (executable, "--json"), - environment, - PROBE_TIMEOUT_SECONDS, - ) - if probe is None or probe.returncode != 0: - result["scale_probe_succeeded"] = False - return result - try: - payload = json.loads(probe.stdout) - except (json.JSONDecodeError, TypeError, UnicodeError): - result["scale_probe_succeeded"] = False - return result - if not isinstance(payload, dict): - result["scale_probe_succeeded"] = False - return result - raw_outputs = payload.get("outputs") - if isinstance(raw_outputs, list): - outputs = raw_outputs - elif isinstance(raw_outputs, dict): - outputs = list(raw_outputs.values()) - else: - outputs = [] - scales: list[float] = [] - for output in outputs[:32]: - if not isinstance(output, dict): - continue - if output.get("connected") is False or output.get("enabled") is False: - continue - value = output.get("scale") - if isinstance(value, bool): - continue - try: - scale = float(value) - except (TypeError, ValueError): - continue - if not 0.5 <= scale <= 8.0: - continue - scales.append(round(scale, 3)) - result["scale_probe_succeeded"] = bool(scales) - if not scales: - return result - unique_scales = sorted(set(scales)) - result.update( - { - "scale_output_count": len(scales), - "scale_min": unique_scales[0], - "scale_max": unique_scales[-1], - "fractional_scaling": any( - abs(scale - round(scale)) > 0.001 for scale in unique_scales - ), - "mixed_scaling": len(unique_scales) > 1, - "scale_detection": "kscreen_doctor", - } - ) - return result - - -def _is_kde_desktop(environment: Mapping[str, str]) -> bool: - 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 _connected_monitor_count(sysfs_drm: Path) -> int | None: - try: - connectors = sorted(sysfs_drm.glob("card[0-9]*-*/status"))[:32] - except OSError: - return None - if not connectors: - return None - connected = 0 - for status in connectors: - value = _read_short_text(status) - if value and value.casefold() == "connected": - connected += 1 - return connected - - -def _pci_model( - vendor_id: int, - device_id: int, - databases: Sequence[Path], -) -> str | None: - vendor_key = f"{vendor_id:04x}" - device_key = f"{device_id:04x}" - for database in databases: - try: - lines = database.read_text(encoding="utf-8", errors="replace").splitlines() - except OSError: - continue - inside_vendor = False - for line in lines: - if not line or line.startswith("#"): - continue - if not line.startswith("\t"): - parts = line.split(None, 1) - inside_vendor = bool(parts and parts[0].casefold() == vendor_key) - continue - if not inside_vendor or line.startswith("\t\t"): - continue - parts = line.strip().split(None, 1) - if len(parts) == 2 and parts[0].casefold() == device_key: - return parts[1].strip()[:255] - return None - - -def _friendly_driver_name(name: str | None, info: str | None) -> str | None: - lowered = (name or "").casefold() - combined = f"{name or ''} {info or ''}".casefold() - if "radv" in combined: - return "Mesa RADV" - if "anv" in combined or "intel open-source mesa" in combined: - return "Mesa ANV" - if "nvidia" in combined: - return "NVIDIA" - if "nouveau" in combined: - return "Mesa NVK" if "nvk" in combined else "Nouveau" - if lowered in {"amdgpu", "i915", "xe"}: - return lowered - return name.strip() if name and name.strip() else None - - -def _driver_version(info: str | None) -> str | None: - if not info: - return None - mesa = re.search(r"\bMesa\s+(\d+(?:\.\d+){1,3})", info, re.IGNORECASE) - if mesa: - return mesa.group(1) - match = _VERSION_PATTERN.search(info) - return match.group(0) if match else None - - -def _vendor_from_model(model: str | None) -> str | None: - lowered = (model or "").casefold() - if "amd" in lowered or "radeon" in lowered: - return "AMD" - if "nvidia" in lowered or "geforce" in lowered: - return "NVIDIA" - if "intel" in lowered: - return "Intel" - return None - - -def _summary_value(summary: str, key: str) -> str | None: - match = re.search( - rf"(?mi)^\s*{re.escape(key)}\s*=\s*([^\r\n]+?)\s*$", - summary, - ) - return match.group(1).strip()[:255] if match else None - - -def _vulkan_gpu_summaries(summary: str) -> tuple[str, ...]: - headers = tuple(re.finditer(r"(?mi)^\s*GPU\d+\s*:\s*$", summary)) - result: list[str] = [] - for index, header in enumerate(headers): - end = headers[index + 1].start() if index + 1 < len(headers) else len(summary) - block = summary[header.start():end] - device_type = _summary_value(block, "deviceType") - if device_type and "cpu" in device_type.casefold(): - continue - if _summary_value(block, "deviceName"): - result.append(block) - return tuple(result) - - -def _hex_summary_value(summary: str, key: str) -> int | None: - value = _summary_value(summary, key) - if not value: - return None - match = re.search(r"0x([0-9a-f]+)", value, re.IGNORECASE) - if not match: - return None - try: - return int(match.group(1), 16) - except ValueError: - return None - - -def _hex_summary_values(summary: str, key: str) -> set[int]: - values: set[int] = set() - for match in re.finditer( - rf"(?mi)^\s*{re.escape(key)}\s*=\s*0x([0-9a-f]+)\b", - summary, - ): - try: - values.add(int(match.group(1), 16)) - except ValueError: - continue - return values - - -def _gpu_offload_detected(environment: Mapping[str, str]) -> bool: - dri_prime = environment.get("DRI_PRIME", "").casefold().strip() - nvidia_offload = ( - environment.get("__NV_PRIME_RENDER_OFFLOAD", "").casefold().strip() - ) - glx_vendor = environment.get("__GLX_VENDOR_LIBRARY_NAME", "").casefold().strip() - nvidia_optimus = environment.get("__VK_LAYER_NV_optimus", "").strip() - return ( - dri_prime not in {"", "0", "false"} - or nvidia_offload not in {"", "0", "false"} - or glx_vendor == "nvidia" - or bool(nvidia_optimus) - ) - - -def _maximum_version(values: Sequence[str]) -> str | None: - parsed: list[tuple[tuple[int, ...], str]] = [] - for value in values: - match = _VERSION_PATTERN.search(value) - if not match: - continue - version = match.group(0) - try: - parts = tuple(int(part) for part in version.split(".")) - except ValueError: - continue - parsed.append((parts, version)) - if not parsed: - return None - return max(parsed, key=lambda item: item[0])[1] - - -def _run_probe( - argv: Sequence[str], - environment: Mapping[str, str], - timeout_seconds: float, -) -> ProbeResult | None: - if not argv or timeout_seconds <= 0: - return None - probe_environment = os.environ.copy() - probe_environment.update(environment) - probe_environment["LC_ALL"] = "C" - probe_environment.pop("VK_LOADER_DEBUG", None) - try: - completed = subprocess.run( - [str(value) for value in argv], - env=probe_environment, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout_seconds, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - return None - return ProbeResult( - returncode=completed.returncode, - stdout=completed.stdout[-MAX_PROBE_OUTPUT_CHARACTERS:], - stderr=completed.stderr[-MAX_PROBE_OUTPUT_CHARACTERS:], - ) - - -def _probe_text(result: ProbeResult | None) -> str: - if result is None: - return "" - return (result.stdout or result.stderr).strip() - - -def _first_probe_line(result: ProbeResult | None) -> str | None: - text = _probe_text(result) - if not text: - return None - return text.splitlines()[0].strip()[:128] or None - - -def _read_json_object(path: Path) -> dict[str, object] | None: - try: - with path.open("rb") as handle: - content = handle.read(MAX_MARKER_BYTES + 1) - if len(content) > MAX_MARKER_BYTES: - return None - value = json.loads(content.decode("utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError): - return None - return value if isinstance(value, dict) else None - - -def _object_field( - value: Mapping[str, object] | None, - key: str, -) -> dict[str, object] | None: - if value is None: - return None - field = value.get(key) - return field if isinstance(field, dict) else None - - -def _short_string(value: object) -> str | None: - return value[:255] if isinstance(value, str) and value else None - - -def _optional_bool(value: object) -> bool | None: - return value if isinstance(value, bool) else None - - -def _read_short_text(path: Path) -> str | None: - try: - value = path.read_text(encoding="utf-8", errors="replace").strip() - except OSError: - return None - return value[:255] or None - - -def _read_hex(path: Path) -> int | None: - value = _read_short_text(path) - if value is None: - return None - try: - return int(value, 16) - except ValueError: - return None - - -def _read_decimal(path: Path) -> int | None: - value = _read_short_text(path) - if value is None: - return None - try: - return int(value, 10) - except ValueError: - return None diff --git a/ptlinux/ui/dashboard.py b/ptlinux/ui/dashboard.py deleted file mode 100644 index bf931af..0000000 --- a/ptlinux/ui/dashboard.py +++ /dev/null @@ -1,3115 +0,0 @@ -"""Native PristonTale EU dashboard; Wine never hosts the updater UI.""" - -from __future__ import annotations - -import base64 -import json -import math -import queue -import subprocess -import sys -import threading -import time -from typing import TYPE_CHECKING, Callable, Iterator, Mapping - -if TYPE_CHECKING: - from tkinter import PhotoImage, Toplevel - -from .. import __version__ -from ..changelog import ( - MAX_PATCHES as MAX_PATCH_NOTES, - PatchNote, - fetch_patch_notes, - patch_change_tag, - read_cached_patch_notes, -) -from ..depot import ( - DEFAULT_DEPOT_START_OPTION, - DepotClient, - DepotError, - DepotSnapshot, - DepotStartOption, - cache_depot_launch_catalog, - launch_catalog_from_snapshot, - select_depot_start_option, -) -from ..desktop_identity import ( - APPLICATION_NAME, - APPLICATION_WM_CLASS, - find_application_icon, -) -from ..dashboard_debug import ( - RuntimeDebugInfo, - VerificationDebugInfo, - inspect_runtime_debug, - read_verification_debug, -) -from ..install_status import ( - GameInstallationStatus, - PrimaryAction, - inspect_game_installation, -) -from ..import_state import game_import_requires_verification -from ..news import ( - Broadcast, - NewsArticle, - fetch_latest_broadcast, - fetch_news, - fetch_news_image, - fetch_official_logo, - read_cached_broadcast, - read_cached_news, - read_cached_news_image, - read_cached_official_logo, -) -from ..overlay import RuntimeCompatibility, inspect_runtime_compatibility -from ..paths import AppPaths -from ..settings import Settings, ensure_settings, load_settings, update_settings -from ..self_update import ( - LauncherRemoteHistory, - LauncherUpdateCheck, - cache_launcher_release_history, - cache_launcher_update_check, - check_for_launcher_update, - fetch_launcher_release_history, - read_cached_launcher_release_history, - read_cached_launcher_update_check, -) -from ..server_status import RealmServerStatus -from ..tk_chrome import ( - centered_window_geometry, - create_dashboard_chrome, - create_window_chrome, - load_square_window_icon, - show_themed_dialog, -) -from ..telemetry import current_telemetry, set_shared_telemetry_enabled -from .components.action_panel import build_action_panel -from .components.broadcasts_panel import build_broadcasts_panel -from .components.header import build_dashboard_header -from .components.news_panel import ( - MAX_NEWS_CARDS, - NEWS_CARD_HEIGHT, - NEWS_IMAGE_HEIGHT, - NEWS_IMAGE_WIDTH, - PATCH_CARD_WRAP_LENGTH, - PATCH_CHANGE_WRAP_LENGTH, - build_news_panel, -) -from .components.progress_panel import build_progress_panel -from .components.server_status import render_server_status as render_server_status_component -from .components.theme import configure_dashboard_style -from .components.update_dialog import LauncherUpdateDialog -from .dialogs.activity import build_activity_window -from .viewmodels.dashboard import ( - ACTIVE_POLL_INTERVAL_MILLISECONDS, - BASE_DISPLAY_DPI, - BASE_TK_SCALING, - DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, - DASHBOARD_STARTUP_COMPONENTS, - DASHBOARD_TRANSITION_STEP_MILLISECONDS, - DASHBOARD_TRANSITION_STEPS, - PRELOADER_STATUS_CHARACTERS, - STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS, - WINDOW_HEIGHT, - WINDOW_WIDTH, - DashboardError, - GameClientTracker, - GameLaunchSupervisorRegistry, - 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, - startup_progress_animation_value, -) -from .viewmodels.downloader import ( - ProgressCoalescer, - TransferRateEstimator, - as_non_negative_float as _as_float, - as_non_negative_int as _as_int, - download_activity_metadata, - format_bytes as _format_bytes, - format_eta, - format_transfer_rate, - operation_activity_text, - should_refresh_operation_elapsed, -) -from .viewmodels.health import ( - ServerStatusCheck, - perform_server_status_check, - server_status_check_text, - server_status_presentation, -) -from .viewmodels.installation import ( - game_file_progress_status, - game_sync_presentation, -) -from .dialogs.settings_dialog import open_settings_dialog - - -LAUNCH_STAGE_TOTAL = 4 -EVENT_BATCH_SIZE = 48 -MAX_ACTIVITY_LOG_LINES = 5000 -ACTIVITY_LOG_TRIM_LINES = 500 -PATCH_PRELOAD_INTERVAL_MILLISECONDS = 1 -DASHBOARD_DEBUG_KEY = "" - - -def launch_external_url(url: str) -> None: - """Open one web URL without leaking desktop-toolkit diagnostics to stderr.""" - - if not url.casefold().startswith("https://"): - raise DashboardError("only HTTPS links can be opened") - try: - process = subprocess.Popen( - ["xdg-open", url], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - except OSError as error: - raise DashboardError(f"could not start the desktop web browser: {error}") from error - threading.Thread( - target=process.wait, - name="ptlinux-desktop-url-opener", - daemon=True, - ).start() - - -def patch_change_colors(type_key: str) -> tuple[str, str]: - """Return a restrained card-background palette for one patch change tag.""" - - return { - "fixed": ("#321c1a", "#e88879"), - "hotfix": ("#352018", "#e6a06a"), - "new-feature": ("#192a20", "#77c795"), - "improvement": ("#19252c", "#82b8d2"), - "security": ("#302719", "#ddbb69"), - "server": ("#282035", "#b8a0d8"), - "game": ("#242b1b", "#a8ca75"), - }.get(type_key.casefold(), ("#2a201c", "#d8aa61")) - - -def run_dashboard(paths: AppPaths) -> None: - try: - import tkinter as tk - from tkinter import filedialog, ttk - except ImportError as exc: - raise DashboardError( - "Python Tk is missing. Install your distribution's python3-tk package " - "or use the pristontale-eu command-line interface." - ) from exc - - paths.create_base_directories() - root = tk.Tk(className=APPLICATION_WM_CLASS) - root.title(f"{APPLICATION_NAME} — Linux Launcher") - root.iconname(APPLICATION_NAME) - window_icon: PhotoImage | None = None - window_icon_path = find_application_icon() - if window_icon_path is not None: - try: - loaded_window_icon = load_square_window_icon(root, window_icon_path) - root.iconphoto(True, loaded_window_icon) - window_icon = loaded_window_icon - except (OSError, tk.TclError) as icon_error: - print( - f"Warning: could not load the launcher icon: {icon_error}", - file=sys.stderr, - ) - - def apply_window_icon(window: Toplevel) -> None: - icon = window_icon - if icon is not None: - window.iconphoto(False, icon) - - root.configure(background="#0c0908") - root.withdraw() - root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") - try: - startup_settings = ensure_settings(paths) - except (OSError, ValueError) as startup_error: - root.deiconify() - show_themed_dialog( - root, - title="Launcher settings", - message=f"The launcher could not load its settings:\n\n{startup_error}", - kind="error", - window_icon=window_icon, - ) - root.destroy() - return - try: - detected_dpi = float(root.winfo_fpixels("1i")) - except (tk.TclError, TypeError, ValueError): - detected_dpi = BASE_DISPLAY_DPI - launcher_ui_scale = resolve_ui_scale(startup_settings.ui_scale, detected_dpi) - news_card_height = round(NEWS_CARD_HEIGHT * launcher_ui_scale) - news_image_width = round(NEWS_IMAGE_WIDTH * launcher_ui_scale) - news_image_height = round(NEWS_IMAGE_HEIGHT * launcher_ui_scale) - patch_card_wrap_length = round(PATCH_CARD_WRAP_LENGTH * launcher_ui_scale) - patch_change_wrap_length = round(PATCH_CHANGE_WRAP_LENGTH * launcher_ui_scale) - try: - root.tk.call("tk", "scaling", BASE_TK_SCALING * launcher_ui_scale) - except tk.TclError: - pass - initial_width, initial_height = fitted_window_size( - WINDOW_WIDTH, - WINDOW_HEIGHT, - scale=launcher_ui_scale, - screen_width=root.winfo_screenwidth(), - screen_height=root.winfo_screenheight(), - ) - root.geometry(f"{initial_width}x{initial_height}") - root.resizable(False, False) - dashboard_chrome = create_dashboard_chrome( - root, - title="PRISTONTALE EU", - subtitle="NATIVE LINUX LAUNCHER", - window_icon=window_icon, - ) - dashboard_content = dashboard_chrome.content - - configure_dashboard_style(root, ttk, launcher_ui_scale) - - startup_started_at = time.monotonic() - startup_status = tk.StringVar(value="Loading dashboard contents…") - startup_image_references: list[tk.PhotoImage] = [] - startup_overlay = tk.Frame( - dashboard_content, - background="#0c0908", - borderwidth=0, - highlightthickness=0, - ) - startup_overlay.place(x=0, y=0, relwidth=1, relheight=1) - startup_border = tk.Frame( - startup_overlay, - background="#6a482d", - borderwidth=0, - highlightthickness=0, - ) - startup_border.place(relx=0.5, rely=0.5, anchor="center") - startup_shell = ttk.Frame( - startup_border, - style="Root.TFrame", - padding=(34, 26), - ) - startup_shell.pack(fill="both", expand=True, padx=1, pady=1) - startup_logo_source = window_icon - if startup_logo_source is not None: - startup_logo_factor = preloader_logo_subsample_factor( - startup_logo_source.width(), - startup_logo_source.height(), - ) - loaded_startup_logo = startup_logo_source.subsample( - startup_logo_factor, - startup_logo_factor, - ) - startup_image_references.append(loaded_startup_logo) - tk.Label( - startup_shell, - image=loaded_startup_logo, - background="#0c0908", - borderwidth=0, - highlightthickness=0, - ).pack(anchor="center", pady=(0, 16)) - ttk.Label( - startup_shell, - text="Preparing the launcher…", - style="PreloaderStatus.TLabel", - anchor="center", - ).pack(fill="x") - startup_status_label = ttk.Label( - startup_shell, - textvariable=startup_status, - style="PreloaderMuted.TLabel", - width=PRELOADER_STATUS_CHARACTERS, - anchor="center", - ) - startup_status_label.pack(fill="x", pady=(5, 11)) - startup_progress_value = tk.DoubleVar(value=0.0) - startup_progress = ttk.Progressbar( - startup_shell, - style="Depot.Horizontal.TProgressbar", - orient="horizontal", - mode="determinate", - maximum=len(DASHBOARD_STARTUP_COMPONENTS), - variable=startup_progress_value, - length=420, - takefocus=False, - ) - startup_progress.pack(fill="x") - freeze_preloader_geometry(startup_border) - startup_ready_components: set[str] = set() - startup_transition_started = False - startup_ready_after: str | None = None - startup_timeout_after: str | None = None - startup_progress_after: str | None = None - startup_progress_target = 0.0 - remote_refresh_started = False - root.deiconify() - root.lift() - startup_overlay.lift() - root.update() - - def cancel_startup_progress_animation() -> None: - nonlocal startup_progress_after - if startup_progress_after is None: - return - try: - root.after_cancel(startup_progress_after) - except tk.TclError: - pass - startup_progress_after = None - - def animate_startup_progress() -> None: - nonlocal startup_progress_after - startup_progress_after = None - if startup_transition_started or not startup_overlay.winfo_exists(): - return - current = float(startup_progress_value.get()) - value = startup_progress_animation_value(current, startup_progress_target) - startup_progress_value.set(value) - if value != startup_progress_target: - startup_progress_after = root.after( - STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS, - animate_startup_progress, - ) - - def set_startup_progress_target(value: float, *, immediate: bool = False) -> None: - nonlocal startup_progress_after, startup_progress_target - startup_progress_target = min( - max(0.0, value), - float(len(DASHBOARD_STARTUP_COMPONENTS)), - ) - if immediate: - cancel_startup_progress_animation() - startup_progress_value.set(startup_progress_target) - return - if startup_progress_after is None: - startup_progress_after = root.after( - STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS, - animate_startup_progress, - ) - - def update_startup_progress(message: str) -> None: - if startup_transition_started: - return - set_startup_progress_target(float(len(startup_ready_components))) - startup_status.set( - f"{message} · {len(startup_ready_components)}/" - f"{len(DASHBOARD_STARTUP_COMPONENTS)}" - ) - - def cancel_startup_after(identifier: str | None) -> None: - if identifier is None: - return - try: - root.after_cancel(identifier) - except tk.TclError: - pass - - def set_startup_component_pending(component: str) -> None: - nonlocal startup_ready_after - if startup_transition_started or component not in DASHBOARD_STARTUP_COMPONENTS: - return - if component not in startup_ready_components: - return - startup_ready_components.remove(component) - cancel_startup_after(startup_ready_after) - startup_ready_after = None - update_startup_progress("Loading dashboard content…") - - def mark_startup_component_ready(component: str, message: str) -> None: - if startup_transition_started or component not in DASHBOARD_STARTUP_COMPONENTS: - return - startup_ready_components.add(component) - update_startup_progress(message) - maybe_start_dashboard_transition() - - def set_root_opacity(value: float) -> bool: - try: - root.attributes("-alpha", value) - except tk.TclError: - return False - return True - - def finish_dashboard_transition() -> None: - set_root_opacity(1.0) - root.lift() - play_button.focus_set() - telemetry = current_telemetry() - if telemetry is not None: - telemetry.mark_ready({"interface": "gui"}) - root.after_idle(start_remote_content_refresh) - - def fade_dashboard_in(step: int = 0) -> None: - if not set_root_opacity( - dashboard_transition_opacity(step, fading_in=True) - ): - finish_dashboard_transition() - return - if step >= DASHBOARD_TRANSITION_STEPS: - finish_dashboard_transition() - return - root.after( - DASHBOARD_TRANSITION_STEP_MILLISECONDS, - lambda: fade_dashboard_in(step + 1), - ) - - def swap_loader_for_dashboard(*, fade_supported: bool) -> None: - if startup_overlay.winfo_exists(): - startup_overlay.destroy() - startup_image_references.clear() - root.update_idletasks() - if fade_supported: - fade_dashboard_in() - else: - finish_dashboard_transition() - - def fade_loader_out(step: int = 0) -> None: - fade_supported = set_root_opacity( - dashboard_transition_opacity(step, fading_in=False) - ) - if not fade_supported or step >= DASHBOARD_TRANSITION_STEPS: - swap_loader_for_dashboard(fade_supported=fade_supported) - return - root.after( - DASHBOARD_TRANSITION_STEP_MILLISECONDS, - lambda: fade_loader_out(step + 1), - ) - - def begin_dashboard_transition(*, timed_out: bool = False) -> None: - nonlocal startup_transition_started - nonlocal startup_ready_after, startup_timeout_after - if startup_transition_started: - return - startup_transition_started = True - cancel_startup_after(startup_ready_after) - cancel_startup_after(startup_timeout_after) - startup_ready_after = None - startup_timeout_after = None - if timed_out: - settle_pending_news_artwork() - if "version" not in startup_ready_components: - version.set( - f"Launcher {__version__} · game build check continuing in background" - ) - set_startup_progress_target( - float(len(DASHBOARD_STARTUP_COMPONENTS)), - immediate=True, - ) - startup_status.set( - "Opening dashboard with available content…" - if timed_out - else "Opening dashboard…" - ) - startup_overlay.update_idletasks() - root.after(80, fade_loader_out) - - def maybe_start_dashboard_transition() -> None: - nonlocal startup_ready_after - if startup_transition_started or not dashboard_content_ready( - startup_ready_components - ): - return - remaining = dashboard_preloader_delay(startup_started_at) - if remaining <= 0: - begin_dashboard_transition() - return - if startup_ready_after is None: - startup_ready_after = root.after( - remaining, - start_dashboard_after_minimum, - ) - - def start_dashboard_after_minimum() -> None: - nonlocal startup_ready_after - startup_ready_after = None - maybe_start_dashboard_transition() - - def timeout_startup_preloader() -> None: - nonlocal startup_timeout_after - startup_timeout_after = None - begin_dashboard_transition(timed_out=True) - - header_widgets = build_dashboard_header( - dashboard_content, - widgets=ttk, - tk_module=tk, - launcher_version=__version__, - window_icon=window_icon, - ) - frame = header_widgets.frame - version = header_widgets.version - logo = header_widgets.logo - - action_widgets = build_action_panel( - frame, - ttk=ttk, - launcher_version=__version__, - ) - controls = action_widgets.container - game_actions = action_widgets.game_actions - system_actions = action_widgets.system_actions - update_button = action_widgets.update_button - repair_button = action_widgets.repair_button - import_button = action_widgets.import_button - doctor_button = action_widgets.doctor_button - settings_button = action_widgets.settings_button - launcher_update_button = action_widgets.launcher_update_button - launcher_update_badge = action_widgets.launcher_update_badge - - progress_widgets = build_progress_panel( - frame, - widgets=ttk, - tk_module=tk, - default_realm=DEFAULT_DEPOT_START_OPTION.name, - ) - realm_value = progress_widgets.realm_value - realm_selector = progress_widgets.realm_selector - play_button = progress_widgets.play_button - status = progress_widgets.status - progress_text = progress_widgets.progress_text - speed_text = progress_widgets.speed_text - client_activity_text = progress_widgets.client_activity_text - progress_bar = progress_widgets.progress_bar - server_status_content = progress_widgets.server_status_content - server_status_debug_text = progress_widgets.server_status_debug_text - log_toggle = progress_widgets.log_toggle - refresh_activity_meta_text = progress_widgets.refresh_activity_metadata - set_download_context_text = progress_widgets.set_download_context - - news_widgets = build_news_panel( - frame, - ttk_module=ttk, - tk_module=tk, - controls=controls, - ui_scale=launcher_ui_scale, - ) - news_panel = news_widgets.container - news_tab_button = news_widgets.news_tab_button - patches_tab_button = news_widgets.patches_tab_button - news_canvas = news_widgets.canvas - news_scrollbar = news_widgets.scrollbar - news_content_host = news_widgets.content_host - news_content = news_widgets.news_content - patches_content = news_widgets.patches_content - news_window = news_widgets.canvas_window - - broadcast_widgets = build_broadcasts_panel( - frame, - ttk=ttk, - before=news_panel, - ) - broadcast_content = broadcast_widgets.content - - activity_widgets = build_activity_window( - root, - tk_module=tk, - ui_scale=launcher_ui_scale, - apply_window_icon=apply_window_icon, - ) - activity_window = activity_widgets.window - log = activity_widgets.log - events: queue.Queue[tuple[str, object]] = queue.Queue() - progress_updates = ProgressCoalescer() - transfer_rate = TransferRateEstimator() - news_image_targets: dict[int, tk.Canvas] = {} - news_image_references: dict[int, tk.PhotoImage] = {} - news_card_widgets: list[tk.Frame] = [] - news_layout_columns = MAX_NEWS_CARDS - startup_news_images_pending: set[int] = set() - active_news_tab = "news" - rendered_news_articles: tuple[NewsArticle, ...] | None = None - rendered_patch_notes: tuple[PatchNote, ...] | None = None - patch_render_generation = 0 - patch_render_target: tuple[PatchNote, ...] | None = None - patch_render_callbacks: list[Callable[[], None]] = [] - logo_image_references: list[tk.PhotoImage] = [] - preloader: tk.Toplevel | None = None - preloader_status: tk.StringVar | None = None - preloader_progress: ttk.Progressbar | None = None - preloader_image_references: list[tk.PhotoImage] = [] - settings_window: tk.Toplevel | None = None - launcher_update_dialog: LauncherUpdateDialog | None = None - dashboard_debug_window: tk.Toplevel | None = None - dashboard_debug_output: tk.Text | None = None - game_clients = GameClientTracker() - game_launch_supervisors = GameLaunchSupervisorRegistry() - client_runtime_debug: dict[int, RuntimeDebugInfo] = {} - client_launch_settings: dict[int, Settings] = {} - active_game_launch_id: int | None = None - operation_active = False - game_window_active = False - activity_log_lines = 0 - operation_started_at: float | None = None - operation_stage = 0 - operation_stage_total = 0 - operation_message = "" - operation_progress_detail: str | None = None - operation_refreshes_compatibility = False - operation_file_progress_verb = "Updating" - last_elapsed_second = -1 - official_snapshot: DepotSnapshot | None = None - latest_installation_status: GameInstallationStatus | None = None - available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) - preferred_realm = startup_settings.realm - selected_update_channel = startup_settings.update_channel - latest_server_statuses: tuple[RealmServerStatus, ...] | None = None - latest_server_status_check: ServerStatusCheck | None = None - server_status_placeholder = "Checking official server status…" - show_dashboard_debug = False - dashboard_runtime_debug: RuntimeDebugInfo | None = None - dashboard_verification_debug: VerificationDebugInfo | None = None - dashboard_debug_state_error: str | None = None - last_dashboard_debug_render = "" - launcher_update_check: LauncherUpdateCheck | None = None - launcher_release_history: LauncherRemoteHistory | None = None - runtime_compatibility: RuntimeCompatibility | None = None - compatibility_check_generation = 0 - controls_are_stacked: bool | None = None - responsive_layout_after: str | None = None - buttons = ( - play_button, - *action_widgets.operation_buttons, - ) - game_mutation_buttons = action_widgets.mutation_buttons - - def append_log(value: str) -> None: - nonlocal activity_log_lines - rendered = value.rstrip() + "\n" - log.configure(state="normal") - log.insert("end", rendered) - activity_log_lines += rendered.count("\n") - if activity_log_lines > MAX_ACTIVITY_LOG_LINES: - retained_lines = MAX_ACTIVITY_LOG_LINES - ACTIVITY_LOG_TRIM_LINES - excess = activity_log_lines - retained_lines - log.delete("1.0", f"{excess + 1}.0") - activity_log_lines -= excess - log.see("end") - log.configure(state="disabled") - - def toggle_log() -> None: - if activity_window.state() == "withdrawn": - activity_window.deiconify() - activity_window.lift() - activity_window.focus_set() - else: - activity_window.withdraw() - - def hide_activity_window() -> None: - activity_window.withdraw() - - activity_window.protocol("WM_DELETE_WINDOW", hide_activity_window) - - def set_busy(value: bool) -> None: - new_state = "disabled" if value else "normal" - for button in buttons: - button.configure(state=new_state) - realm_selector.configure(state="disabled" if value else "readonly") - if not value and game_clients.running_count: - for button in game_mutation_buttons: - button.configure(state="disabled") - - def update_progress(event: Mapping[str, object]) -> None: - nonlocal operation_progress_detail - phase = event.get("phase") - files_completed = _as_int(event.get("files_completed")) - files_total = _as_int(event.get("files_total")) - bytes_completed = _as_int(event.get("bytes_completed")) - bytes_total = _as_int(event.get("bytes_total")) - current_file = event.get("current_file") - if phase == "manifest": - transfer_rate.reset() - speed_text.set("") - set_download_context_text("") - set_operation_stage(1, 3, "Checking official CDN manifests…") - return - progress_bar.stop() - progress_bar.configure(mode="determinate") - if bytes_total: - progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total)) - bytes_remaining = _as_int(event.get("bytes_remaining")) - if "bytes_remaining" not in event: - bytes_remaining = max(0, bytes_total - bytes_completed) - percent = min(100, round((bytes_completed / bytes_total) * 100)) - operation_progress_detail = ( - f"{percent}% · {files_completed:,}/{files_total:,} files · " - f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)} · " - f"{_format_bytes(bytes_remaining)} remaining" - ) - if phase == "downloading": - bytes_transferred = _as_int(event.get("bytes_transferred")) - estimated_rate = transfer_rate.update(bytes_transferred) - reported_rate = _as_float( - event.get("transfer_rate_bytes_per_second") - ) - rate = reported_rate if reported_rate is not None else estimated_rate - eta_seconds = _as_float(event.get("eta_seconds")) - if eta_seconds is None and rate is not None and rate > 0: - eta_seconds = bytes_remaining / rate - speed_text.set( - f"{format_transfer_rate(rate) if rate is not None else '0 B/s'} · " - f"ETA {format_eta(eta_seconds)}" - ) - current_server = event.get("current_server") - active_workers = _as_int(event.get("active_workers")) - target_workers = _as_int(event.get("target_workers")) - retry_reason = event.get("retry_reason") - set_download_context_text( - download_activity_metadata( - current_server=( - current_server - if isinstance(current_server, str) and current_server - else None - ), - retry_count=_as_int(event.get("retry_count")), - retry_reason=( - retry_reason - if isinstance(retry_reason, str) and retry_reason - else None - ), - active_workers=active_workers, - target_workers=target_workers, - ) - ) - else: - transfer_rate.reset() - speed_text.set("") - set_download_context_text("") - else: - progress_bar.configure(maximum=3, value=3) - operation_progress_detail = "Game files are already up to date." - transfer_rate.reset() - speed_text.set("") - set_download_context_text("") - progress_text.set(activity_text()) - if isinstance(current_file, str) and current_file: - status.set( - game_file_progress_status(current_file, operation_file_progress_verb) - ) - elif phase == "complete": - status.set("Game files are up to date and verified.") - - def activity_text() -> str: - return operation_activity_text( - operation_message, - started_at=operation_started_at, - step=operation_stage, - total=operation_stage_total, - detail=operation_progress_detail, - ) - - def set_operation_stage(step: int, total: int, message: str) -> None: - nonlocal operation_stage, operation_stage_total, operation_message - nonlocal operation_progress_detail - operation_stage = min(max(step, 0), total) - operation_stage_total = max(total, 1) - operation_message = message - operation_progress_detail = None - transfer_rate.reset() - speed_text.set("") - set_download_context_text("") - progress_bar.stop() - progress_bar.configure( - mode="determinate", - maximum=operation_stage_total, - value=operation_stage, - ) - status.set(message) - progress_text.set(activity_text()) - set_preloader_status(message, operation_stage, operation_stage_total) - - def update_elapsed_indicator() -> None: - nonlocal last_elapsed_second - if not should_refresh_operation_elapsed( - active=operation_active, - started_at=operation_started_at, - stage_total=operation_stage_total, - detail=operation_progress_detail, - ): - return - assert operation_started_at is not None - elapsed = int(time.monotonic() - operation_started_at) - if elapsed == last_elapsed_second: - return - last_elapsed_second = elapsed - progress_text.set(activity_text()) - set_preloader_status(operation_message, operation_stage, operation_stage_total) - - def cancel_active_game_launch() -> None: - client_id = active_game_launch_id - if client_id is None: - close_game_preloader() - return - process = game_launch_supervisors.request_cancel(client_id) - interrupted = process is not None and interrupt_game_supervisor(process) - if interrupted: - append_log(f"Cancelling game client {client_id}; stopping its process group.") - elif process is None: - append_log(f"Cancelling game client {client_id} before its supervisor starts.") - else: - append_log(f"Game client {client_id} was already stopping.") - close_game_preloader() - root.deiconify() - root.lift() - status.set(f"Cancelling game client {client_id}…") - progress_text.set("Stopping the initiated game and compatibility processes…") - - def show_game_preloader() -> None: - nonlocal preloader, preloader_status, preloader_progress - if preloader is not None and preloader.winfo_exists(): - return - window = tk.Toplevel(root) - window.withdraw() - preloader = window - window.title("Launching PristonTale EU") - window.configure(background="#0c0908") - window.resizable(False, False) - window.protocol("WM_DELETE_WINDOW", cancel_active_game_launch) - window.transient(root) - apply_window_icon(window) - loader_chrome = create_window_chrome( - window, - title="", - subtitle="", - show_title_bar=False, - show_close=False, - show_minimize=False, - allow_close=True, - ) - loader_chrome.set_close_command(cancel_active_game_launch) - cancel_button = tk.Button( - loader_chrome.content, - text="×", - command=cancel_active_game_launch, - background="#0c0908", - foreground="#bda998", - activebackground="#a63f2f", - activeforeground="#ffffff", - font=("Sans", 12), - width=3, - borderwidth=0, - highlightthickness=0, - relief="flat", - cursor="hand2", - takefocus=True, - ) - cancel_button.place(relx=1.0, x=-9, y=9, anchor="ne") - status_variable = tk.StringVar(value="Starting launcher process…") - preloader_status = status_variable - content = ttk.Frame( - loader_chrome.content, - style="Root.TFrame", - padding=(34, 26), - ) - content.pack(fill="both", expand=True) - cancel_button.lift() - logo_source = window_icon - if logo_source is not None: - modal_logo_factor = preloader_logo_subsample_factor( - logo_source.width(), - logo_source.height(), - ) - modal_logo = logo_source.subsample( - modal_logo_factor, - modal_logo_factor, - ) - preloader_image_references.clear() - preloader_image_references.append(modal_logo) - tk.Label( - content, - image=modal_logo, - background="#0c0908", - borderwidth=0, - highlightthickness=0, - ).pack(anchor="center", pady=(0, 16)) - ttk.Label( - content, - text="Preparing the game window…", - style="PreloaderStatus.TLabel", - anchor="center", - ).pack(fill="x") - status_label = ttk.Label( - content, - textvariable=status_variable, - style="PreloaderMuted.TLabel", - width=PRELOADER_STATUS_CHARACTERS, - anchor="center", - ) - status_label.pack( - fill="x", - pady=(5, 11), - ) - progress_widget = ttk.Progressbar( - content, - style="Depot.Horizontal.TProgressbar", - mode="determinate", - maximum=LAUNCH_STAGE_TOTAL, - value=1, - ) - preloader_progress = progress_widget - progress_widget.pack(fill="x") - freeze_preloader_geometry(content) - root.update_idletasks() - window.update_idletasks() - width = window.winfo_reqwidth() - height = window.winfo_reqheight() - window.geometry( - centered_window_geometry( - width, - height, - parent_x=root.winfo_rootx(), - parent_y=root.winfo_rooty(), - parent_width=root.winfo_width(), - parent_height=root.winfo_height(), - ) - ) - window.deiconify() - window.lift() - window.grab_set() - window.focus_force() - - def set_preloader_status(value: str, step: int | None = None, total: int | None = None) -> None: - if preloader_status is not None: - preloader_status.set(preloader_status_text(value, step, total)) - if preloader_progress is not None and step is not None and total is not None: - preloader_progress.configure(maximum=total, value=step) - - def close_game_preloader() -> None: - nonlocal preloader, preloader_status, preloader_progress - if preloader is not None and preloader.winfo_exists(): - try: - preloader.grab_release() - except tk.TclError: - pass - preloader.destroy() - preloader = None - preloader_status = None - preloader_progress = None - preloader_image_references.clear() - - def run_operation( - label: str, - command: list[str], - *, - show_preloader: bool = False, - restart_on_success: bool = False, - game_files_may_change: bool = False, - file_progress_verb: str = "Updating", - game_client_id: int | None = None, - ) -> None: - nonlocal operation_active, game_window_active - nonlocal operation_started_at - nonlocal operation_stage - nonlocal operation_stage_total - nonlocal operation_message - nonlocal operation_progress_detail - nonlocal operation_refreshes_compatibility - nonlocal operation_file_progress_verb - nonlocal last_elapsed_second - operation_active = True - game_window_active = game_clients.running_count > 0 - operation_started_at = time.monotonic() - operation_stage = 0 - operation_stage_total = 0 - operation_message = "" - operation_progress_detail = None - operation_refreshes_compatibility = game_files_may_change - operation_file_progress_verb = file_progress_verb - last_elapsed_second = -1 - progress_updates.clear() - transfer_rate.reset() - speed_text.set("") - set_download_context_text("") - set_busy(True) - status.set(label) - progress_text.set("Waiting for the operation to report progress…") - if show_preloader: - show_game_preloader() - set_operation_stage(1, LAUNCH_STAGE_TOTAL, "Starting launcher process…") - else: - progress_bar.stop() - progress_bar.configure(mode="determinate", maximum=3, value=0) - append_log(f"$ {' '.join(command)}") - - def worker() -> None: - def publish_output(output_line: str) -> None: - if game_client_id is None: - events.put(("output", output_line)) - else: - events.put(("output", f"[Client {game_client_id}] {output_line}")) - - if ( - game_client_id is not None - and game_launch_supervisors.is_cancel_requested(game_client_id) - ): - publish_output("Launch cancelled before the game supervisor started.\n") - events.put(("game_done", (game_client_id, 130))) - return - try: - process = subprocess.Popen( - command, - text=True, - encoding="utf-8", - errors="replace", - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - env=launcher_child_environment(), - ) - except OSError as process_error: - publish_output(f"Could not start operation: {process_error}\n") - if game_client_id is None: - events.put(("done", 1)) - else: - events.put(("game_done", (game_client_id, 1))) - return - if game_client_id is not None: - cancel_on_start = game_launch_supervisors.attach( - game_client_id, - process, - ) - events.put( - ("game_supervisor_started", (game_client_id, process.pid)) - ) - if cancel_on_start: - publish_output("Stopping the game supervisor after launch cancellation.\n") - interrupt_game_supervisor(process) - if process.stdout is not None: - for line in process.stdout: - if line.startswith("STATUS "): - try: - parsed_status = json.loads(line.removeprefix("STATUS ")) - except json.JSONDecodeError: - publish_output(line) - else: - if isinstance(parsed_status, dict): - if game_client_id is None: - events.put(("operation_status", parsed_status)) - else: - events.put( - ( - "game_operation_status", - (game_client_id, parsed_status), - ) - ) - else: - publish_output(line) - elif line.startswith("PROGRESS "): - try: - parsed = json.loads(line.removeprefix("PROGRESS ")) - except json.JSONDecodeError: - publish_output(line) - else: - if isinstance(parsed, dict): - progress_updates.publish(parsed) - else: - publish_output(line) - else: - publish_output(line) - if game_client_id is not None and line.startswith("Game window created;"): - events.put( - ( - "game_operation_status", - ( - game_client_id, - { - "step": 4, - "total": LAUNCH_STAGE_TOTAL, - "message": "Game client started.", - }, - ), - ) - ) - events.put(("game_started", game_client_id)) - if game_client_id is not None and line.startswith( - ("Game window ready:", "Rendered game frame:") - ): - events.put(("game_window_ready", game_client_id)) - returncode = process.wait() - if game_client_id is not None: - game_launch_supervisors.detach(game_client_id, process) - if game_client_id is None: - events.put( - ("launcher_update_done" if restart_on_success else "done", returncode) - ) - else: - events.put(("game_done", (game_client_id, returncode))) - - threading.Thread( - target=worker, - name=( - f"ptlinux-game-client-{game_client_id}" - if game_client_id is not None - else "ptlinux-operation" - ), - daemon=True, - ).start() - - def open_article(url: str) -> None: - try: - launch_external_url(url) - except DashboardError as error: - append_log(f"Could not open external link: {error}") - show_themed_dialog( - root, - title="Open web link", - message=str(error), - kind="error", - window_icon=window_icon, - ) - - def scroll_news_view(event: object) -> str | None: - if active_news_tab != "patches": - return None - delta = getattr(event, "delta", 0) - button = getattr(event, "num", None) - if isinstance(delta, int) and delta: - direction = -1 if delta > 0 else 1 - elif button == 4: - direction = -1 - elif button == 5: - direction = 1 - else: - return None - news_canvas.yview_scroll(direction * 3, "units") - return "break" - - def make_news_card_clickable( - card: tk.Frame, - url: str, - *, - border_only: bool = False, - ) -> None: - def enter(_event: object) -> None: - card.configure(highlightbackground="#e4a34c", highlightthickness=1) - if not border_only: - card.configure(background="#2a1e17") - - def leave(_event: object) -> None: - card.configure(highlightbackground="#4a3528", highlightthickness=1) - if not border_only: - card.configure(background="#211916") - - def open_card(_event: object) -> None: - open_article(url) - - def activate_card(_event: object) -> str: - open_article(url) - return "break" - - def bind_widget(widget: object) -> None: - bind = getattr(widget, "bind") - configure = getattr(widget, "configure") - bind("", enter) - bind("", leave) - bind("", open_card) - bind("", scroll_news_view, "+") - bind("", scroll_news_view, "+") - bind("", scroll_news_view, "+") - configure(cursor="hand2") - for child in getattr(widget, "winfo_children")(): - bind_widget(child) - - card.configure(takefocus=True) - card.bind("", enter) - card.bind("", leave) - card.bind("", activate_card) - card.bind("", activate_card) - bind_widget(card) - - def show_broadcast(broadcast: Broadcast | None) -> None: - for child in broadcast_content.winfo_children(): - child.destroy() - if broadcast is None: - ttk.Label( - broadcast_content, - text="No current broadcast is available.", - style="Muted.TLabel", - ).pack(anchor="w", padx=8, pady=6) - return - card = tk.Frame( - broadcast_content, - background="#211916", - highlightbackground="#4a3528", - highlightthickness=1, - padx=10, - pady=8, - ) - card.pack(fill="x") - meta = " · ".join( - part for part in (f"BROADCAST · {broadcast.author}", broadcast.published_at) if part - ) - tk.Label( - card, - text=meta, - background="#211916", - foreground="#e5b66c", - font=("Sans", 9, "bold"), - ).pack(anchor="w") - tk.Label( - card, - text=broadcast.content, - background="#211916", - foreground="#e8ded2", - font=("Sans", 10), - wraplength=930, - justify="left", - anchor="w", - ).pack(anchor="w", fill="x", pady=(3, 0)) - make_news_card_clickable(card, broadcast.url) - - def layout_news_cards() -> None: - nonlocal news_layout_columns - columns = news_card_columns(max(1, root.winfo_width()), launcher_ui_scale) - news_layout_columns = columns - for column in range(MAX_NEWS_CARDS): - news_content.grid_columnconfigure( - column, - weight=1 if column < columns else 0, - uniform="news-card" if column < columns else "", - ) - for row in range(MAX_NEWS_CARDS): - news_content.grid_rowconfigure(row, weight=0) - for index, card in enumerate(news_card_widgets): - row, column = divmod(index, columns) - news_content.grid_rowconfigure(row, weight=1) - card.grid( - row=row, - column=column, - sticky="nsew", - padx=3, - pady=3, - ) - root.after_idle(update_news_viewport) - - def render_news(articles: tuple[NewsArticle, ...]) -> None: - nonlocal rendered_news_articles - visible_articles = articles[:MAX_NEWS_CARDS] - if rendered_news_articles == visible_articles: - return - news_image_targets.clear() - news_image_references.clear() - news_card_widgets.clear() - for child in news_content.winfo_children(): - child.destroy() - for column in range(MAX_NEWS_CARDS): - news_content.grid_columnconfigure(column, weight=0, uniform="") - for row in range(MAX_NEWS_CARDS): - news_content.grid_rowconfigure(row, weight=0) - if not visible_articles: - ttk.Label( - news_content, - text="Official news is temporarily unavailable. Your cached game can still play.", - style="Muted.TLabel", - ).pack(anchor="w", padx=8, pady=8) - startup_news_images_pending.clear() - mark_startup_component_ready( - "news_artwork", - "News layout ready", - ) - rendered_news_articles = visible_articles - return - for index, article in enumerate(visible_articles): - card = tk.Frame( - news_content, - background="#211916", - highlightbackground="#4a3528", - highlightthickness=1, - height=news_card_height, - padx=8, - pady=8, - ) - card.grid_propagate(False) - news_card_widgets.append(card) - image_canvas = tk.Canvas( - card, - background="#211916", - width=news_image_width, - height=news_image_height, - highlightthickness=0, - borderwidth=0, - ) - image_canvas.pack(fill="x") - image_canvas.create_text( - news_image_width // 2, - news_image_height // 2, - text="Loading official artwork…" if article.image_url else "Official news", - fill="#a8998a", - font=("Sans", 9), - tags=("centered-content",), - ) - image_canvas.bind( - "", - lambda event: event.widget.coords( - "centered-content", - max(1, int(event.width)) // 2, - max(1, int(event.height)) // 2, - ), - ) - if article.image_url: - news_image_targets[article.id] = image_canvas - ttk.Label( - card, - text=article.title, - style="NewsTitle.TLabel", - wraplength=news_image_width, - justify="left", - ).pack(anchor="w", fill="x", pady=(6, 0)) - meta = article.published_at - if meta: - ttk.Label(card, text=meta, style="NewsMeta.TLabel").pack(anchor="w", pady=(2, 4)) - tk.Label( - card, - text=article.summary, - background="#211916", - foreground="#c9baaa", - font=("Sans", 9), - wraplength=news_image_width, - justify="left", - height=3, - anchor="nw", - ).pack(anchor="w", fill="x") - make_news_card_clickable(card, article.url) - layout_news_cards() - load_news_images(visible_articles) - rendered_news_articles = visible_articles - - def show_news(articles: tuple[NewsArticle, ...]) -> None: - render_news(articles) - - def append_patch_note(patch: PatchNote) -> Iterator[None]: - card = tk.Frame( - patches_content, - background="#211916", - highlightbackground="#4a3528", - highlightthickness=1, - padx=16, - pady=13, - ) - card.pack(fill="x", padx=4, pady=(4, 9)) - tk.Label( - card, - text=patch.title, - background="#211916", - foreground="#fff0d2", - font=("Sans", 12, "bold"), - wraplength=patch_card_wrap_length, - justify="left", - anchor="w", - ).pack(anchor="w", fill="x") - metadata = tk.Frame(card, background="#211916") - metadata.pack(anchor="w", fill="x", pady=(7, 0)) - tk.Label( - metadata, - text=f"VERSION {patch.version or '—'}", - background="#30231d", - foreground="#d8aa61", - font=("Sans", 8, "bold"), - padx=6, - pady=2, - ).pack(side="left", padx=(0, 7)) - for tag in patch.tags or ("general",): - tk.Label( - metadata, - text=tag.upper(), - background="#2a201c", - foreground="#d8aa61", - font=("Sans", 7, "bold"), - padx=6, - pady=2, - ).pack(side="left", padx=(0, 7)) - if patch.published_at: - tk.Label( - metadata, - text=patch.published_at, - background="#211916", - foreground="#c9baaa", - font=("Sans", 8), - ).pack(side="right") - tk.Label( - card, - text=patch.summary or "No summary provided.", - background="#211916", - foreground="#ded1c4", - font=("Sans", 10), - wraplength=patch_card_wrap_length, - justify="left", - anchor="w", - ).pack(anchor="w", fill="x", pady=(9, 3)) - if not patch.sections: - tk.Label( - card, - text="No detailed changes were published for this patch.", - background="#211916", - foreground="#ded1c4", - font=("Sans", 9), - ).pack(anchor="w", pady=(8, 0)) - yield - for section in patch.sections: - tk.Label( - card, - text=section.title.upper(), - background="#211916", - foreground="#d8aa61", - font=("Sans", 9, "bold"), - ).pack(anchor="w", pady=(12, 5)) - yield - for change in section.items: - change_row = tk.Frame(card, background="#211916") - change_row.pack(anchor="w", fill="x", pady=3) - badge_background, badge_foreground = patch_change_colors( - change.type_key - ) - tk.Label( - change_row, - text=patch_change_tag(change.type_key), - background=badge_background, - foreground=badge_foreground, - font=("Sans", 8, "bold"), - width=14, - anchor="center", - padx=5, - pady=2, - ).pack(side="left", anchor="n", padx=(0, 8)) - tk.Label( - change_row, - text=change.text, - background="#211916", - foreground="#ded1c4", - font=("Sans", 10), - wraplength=patch_change_wrap_length, - justify="left", - anchor="w", - ).pack(side="left", anchor="w", fill="x", expand=True) - yield - make_news_card_clickable(card, patch.url, border_only=True) - - def render_patch_notes( - patches: tuple[PatchNote, ...], - *, - on_complete: Callable[[], None] | None = None, - ) -> None: - """Preload patch cards incrementally without blocking Tk's event loop.""" - - nonlocal patch_render_generation, patch_render_target, rendered_patch_notes - visible_patches = patches[:MAX_PATCH_NOTES] - if rendered_patch_notes == visible_patches: - if on_complete is not None: - on_complete() - return - if patch_render_target == visible_patches: - if on_complete is not None: - patch_render_callbacks.append(on_complete) - return - - patch_render_generation += 1 - generation = patch_render_generation - patch_render_target = visible_patches - if on_complete is not None: - patch_render_callbacks.append(on_complete) - rendered_patch_notes = None - for child in patches_content.winfo_children(): - child.destroy() - patches_content.grid() - if active_news_tab == "patches": - patches_content.tkraise() - else: - news_content.tkraise() - - def finish_render() -> None: - nonlocal patch_render_target, rendered_patch_notes - if generation != patch_render_generation: - return - rendered_patch_notes = visible_patches - patch_render_target = None - if active_news_tab == "patches": - patches_content.grid() - patches_content.tkraise() - else: - patches_content.grid_remove() - news_content.tkraise() - news_canvas.configure(scrollregion=news_canvas.bbox("all")) - callbacks = tuple(patch_render_callbacks) - patch_render_callbacks.clear() - for callback in callbacks: - callback() - - if not visible_patches: - ttk.Label( - patches_content, - text=( - "Official patch notes are temporarily unavailable. " - "Your cached game can still play." - ), - style="Muted.TLabel", - ).pack(anchor="w", padx=8, pady=8) - root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, finish_render) - return - - def patch_render_steps() -> Iterator[None]: - for patch in visible_patches: - yield from append_patch_note(patch) - - render_steps = patch_render_steps() - - def append_next() -> None: - if generation != patch_render_generation: - return - try: - next(render_steps) - except StopIteration: - finish_render() - return - patches_content.update_idletasks() - root.after( - PATCH_PRELOAD_INTERVAL_MILLISECONDS, - append_next, - ) - - root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, append_next) - - def show_patch_notes( - patches: tuple[PatchNote, ...], - *, - on_complete: Callable[[], None] | None = None, - ) -> None: - render_patch_notes(patches, on_complete=on_complete) - - def switch_news_tab(tab: str) -> None: - nonlocal active_news_tab - if tab not in {"news", "patches"} or tab == active_news_tab: - return - active_news_tab = tab - news_tab_button.configure( - style="NewsTabActive.TButton" if tab == "news" else "NewsTab.TButton" - ) - patches_tab_button.configure( - style=( - "NewsTabActive.TButton" if tab == "patches" else "NewsTab.TButton" - ) - ) - news_canvas.yview_moveto(0.0) - if tab == "patches": - news_content.grid_remove() - patches_content.grid() - news_scrollbar.grid() - else: - patches_content.grid_remove() - news_content.grid() - news_scrollbar.grid_remove() - root.after_idle(update_news_viewport) - - def switch_news_tab_from_keyboard(tab: str, button: ttk.Button) -> str: - switch_news_tab(tab) - button.focus_set() - return "break" - - news_tab_button.configure(command=lambda: switch_news_tab("news")) - patches_tab_button.configure(command=lambda: switch_news_tab("patches")) - - def load_news_images(articles: tuple[NewsArticle, ...]) -> None: - image_articles = tuple( - article for article in articles if article.image_url is not None - ) - startup_news_images_pending.clear() - startup_news_images_pending.update(article.id for article in image_articles) - if startup_news_images_pending: - set_startup_component_pending("news_artwork") - else: - mark_startup_component_ready( - "news_artwork", - "News artwork ready", - ) - - def worker(news_article: NewsArticle) -> None: - image = ( - read_cached_news_image(news_article, paths.news_image_cache) - if startup_settings.low_bandwidth_mode - else fetch_news_image(news_article, paths.news_image_cache) - ) - events.put(("news_image", (news_article.id, image))) - - for article in image_articles: - threading.Thread( - target=worker, - args=(article,), - name="ptlinux-news-image", - daemon=True, - ).start() - - def install_news_image(value: object) -> None: - if ( - not isinstance(value, tuple) - or len(value) != 2 - or not isinstance(value[0], int) - or (value[1] is not None and not isinstance(value[1], bytes)) - ): - return - article_id, image_data = value - target = news_image_targets.get(article_id) - if target is None or not target.winfo_exists(): - return - if image_data is None: - target.delete("all") - target.create_text( - news_image_width // 2, - news_image_height // 2, - text="Artwork unavailable", - fill="#a8998a", - font=("Sans", 8), - tags=("centered-content",), - ) - return - try: - image = tk.PhotoImage(data=base64.b64encode(image_data), format="png") - except tk.TclError: - target.delete("all") - target.create_text( - news_image_width // 2, - news_image_height // 2, - text="Artwork unavailable", - fill="#a8998a", - font=("Sans", 8), - tags=("centered-content",), - ) - return - target_width = target.winfo_width() - target_height = target.winfo_height() - if target_width <= 1: - target_width = news_image_width - if target_height <= 1: - target_height = news_image_height - if image.width() < target_width or image.height() < target_height: - factor = max( - math.ceil(target_width / image.width()), - math.ceil(target_height / image.height()), - ) - image = image.zoom(factor, factor) - else: - factor = min(image.width() // target_width, image.height() // target_height) - if factor > 1: - image = image.subsample(factor, factor) - news_image_references[article_id] = image - target.delete("all") - target.create_image( - target_width // 2, - target_height // 2, - image=image, - tags=("centered-content",), - ) - - def complete_news_image(value: object) -> None: - install_news_image(value) - if not isinstance(value, tuple) or not value or not isinstance(value[0], int): - return - startup_news_images_pending.discard(value[0]) - if not startup_news_images_pending: - mark_startup_component_ready( - "news_artwork", - "News artwork ready", - ) - - def settle_pending_news_artwork() -> None: - for article_id in tuple(startup_news_images_pending): - target = news_image_targets.get(article_id) - if target is None or not target.winfo_exists(): - continue - target.delete("all") - target.create_text( - news_image_width // 2, - news_image_height // 2, - text="Artwork unavailable", - fill="#a8998a", - font=("Sans", 8), - tags=("centered-content",), - ) - startup_news_images_pending.clear() - - def install_official_logo(value: object) -> None: - if not isinstance(value, bytes): - return - try: - image = tk.PhotoImage(data=base64.b64encode(value), format="png") - except tk.TclError: - return - factor = max(1, math.ceil(image.width() / 42), math.ceil(image.height() / 42)) - if factor > 1: - image = image.subsample(factor, factor) - logo_image_references.clear() - logo_image_references.append(image) - logo.delete("all") - logo.create_image(21, 21, image=image) - - def load_official_logo() -> None: - def worker() -> None: - image = ( - read_cached_official_logo(paths.logo_cache) - if startup_settings.low_bandwidth_mode - else fetch_official_logo(paths.logo_cache) - ) - events.put(("logo_image", image)) - - threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start() - - def update_server_status_debug_text() -> None: - value = "" - if show_dashboard_debug: - value = ( - server_status_check_text(latest_server_status_check) - if latest_server_status_check is not None - else "status check pending" - ) - if server_status_debug_text.get() != value: - server_status_debug_text.set(value) - - def render_server_status() -> None: - update_server_status_debug_text() - render_server_status_component( - server_status_content, - ttk=ttk, - statuses=latest_server_statuses, - start_options=available_start_options, - placeholder=server_status_placeholder, - show_debug_details=show_dashboard_debug, - ) - - def show_server_status( - check: ServerStatusCheck, - ) -> None: - nonlocal latest_server_statuses, latest_server_status_check - nonlocal server_status_placeholder - latest_server_status_check = check - latest_server_statuses = check.statuses - server_status_placeholder = ( - "Server status unavailable." - if check.error is not None - else "Official status is unavailable for this realm." - ) - render_server_status() - - def refresh_dashboard_debug_disk_state() -> None: - nonlocal dashboard_runtime_debug, dashboard_verification_debug - nonlocal dashboard_debug_state_error - dashboard_debug_state_error = None - try: - settings = load_settings(paths) - except (OSError, ValueError) as error: - dashboard_runtime_debug = None - dashboard_debug_state_error = f"settings unavailable: {error}" - else: - dashboard_runtime_debug = inspect_runtime_debug(paths, settings) - try: - dashboard_verification_debug = read_verification_debug(paths) - except OSError as error: - dashboard_verification_debug = None - detail = f"verification state unavailable: {error}" - dashboard_debug_state_error = ( - f"{dashboard_debug_state_error}; {detail}" - if dashboard_debug_state_error - else detail - ) - - def current_debug_start_option() -> DepotStartOption | None: - selected_name = realm_value.get().casefold() - return next( - ( - option - for option in available_start_options - if option.name.casefold() == selected_name - ), - None, - ) - - def dashboard_debug_report() -> str: - lines = ["PT-LINUX DASHBOARD DEBUG", ""] - lines.extend(("SERVERS", "-------")) - lines.append( - server_status_check_text(latest_server_status_check) - if latest_server_status_check is not None - else "status check pending" - ) - if latest_server_statuses: - lines.extend( - server_status_presentation( - server, - show_debug_details=True, - ).text - for server in latest_server_statuses - ) - else: - lines.append(server_status_placeholder) - - lines.extend(("", "LAUNCH", "------")) - start_option = current_debug_start_option() - lines.append(f"Realm: {realm_value.get() or 'unknown'}") - lines.append( - f"Executable: {start_option.path if start_option is not None else 'unknown'}" - ) - lines.append( - f"Arguments: {start_option.arguments if start_option is not None else 'unknown'}" - ) - if official_snapshot is None: - lines.append("Official depot: unavailable") - else: - lines.append( - f"Official depot: build {official_snapshot.build} · " - f"version {official_snapshot.version}" - ) - if latest_installation_status is None: - lines.append("Installed depot: unchecked") - else: - local_build = latest_installation_status.local_build - lines.append( - f"Installed depot: {local_build if local_build is not None else 'unknown'}" - ) - - client_states = game_clients.debug_states() - active_runtime = next( - ( - client_runtime_debug[state.client_id] - for state in client_states - if state.client_id in client_runtime_debug - ), - dashboard_runtime_debug, - ) - lines.extend(("", "RUNTIME", "-------")) - lines.append( - "Source: active client launch snapshot" - if client_states and active_runtime is not dashboard_runtime_debug - else "Source: next-launch selection" - ) - if active_runtime is None: - lines.append(dashboard_debug_state_error or "Runtime state unavailable") - else: - lines.append( - f"Requested / selected: {active_runtime.requested_kind} / " - f"{active_runtime.selected_kind or 'unavailable'}" - ) - lines.append(f"Runtime executable: {active_runtime.executable or 'unavailable'}") - lines.append(f"Proton: {active_runtime.proton_path or 'not applicable'}") - lines.append(f"Prefix: {active_runtime.prefix or 'unavailable'}") - lines.append( - f"Synchronization: {active_runtime.sync_configured} / " - f"{active_runtime.sync_effective or 'runtime managed'}" - ) - lines.append( - f"Prepared runtime: {active_runtime.prepared_kind or 'not recorded'} · " - f"{active_runtime.prepared_at or 'time unavailable'}" - ) - if active_runtime.prepared_proton_path is not None: - lines.append(f"Prepared Proton: {active_runtime.prepared_proton_path}") - marker_match = active_runtime.marker_matches_selection - lines.append( - "Preparation marker matches selection: " - + ( - "yes" - if marker_match is True - else "no" - if marker_match is False - else "unknown" - ) - ) - lines.append( - f"DXVK D3D9 override: " - f"{active_runtime.dxvk_d3d9_override or 'not recorded'}" - ) - lines.append(f"DXVK state cache: {paths.dxvk_cache}") - if active_runtime.error: - lines.append(f"Runtime selection error: {active_runtime.error}") - - lines.extend(("", "CLIENTS", "-------")) - lines.append( - f"Starting: {game_clients.starting_count} · " - f"running: {game_clients.running_count}" - ) - if not client_states: - lines.append("No active game clients") - for state in client_states: - pid = str(state.supervisor_pid) if state.supervisor_pid is not None else "pending" - lines.append( - f"Client {state.client_id}: {state.phase} · " - f"{debug_elapsed_text(state.elapsed_seconds)} · supervisor PID {pid}" - ) - client_runtime = client_runtime_debug.get(state.client_id) - if client_runtime is not None: - lines.append( - f" runtime {client_runtime.selected_kind or 'unavailable'} · " - f"prefix {client_runtime.prefix or 'unavailable'}" - ) - if state.client_id == active_game_launch_id and operation_stage_total > 0: - lines.append( - f" launch stage {operation_stage}/{operation_stage_total} · " - f"{operation_message or 'waiting'}" - ) - if operation_active and active_game_launch_id is None: - lines.append( - f"Launcher operation: {operation_stage}/{operation_stage_total} · " - f"{operation_message or status.get()}" - ) - - lines.extend(("", "VERIFICATION", "------------")) - verification = dashboard_verification_debug - if verification is None: - lines.append(dashboard_debug_state_error or "Verification state unavailable") - else: - lines.append(f"Imported-tree trust: {verification.import_state}") - if verification.import_verified_at is not None: - lines.append( - f"Import verified: build " - f"{verification.import_build if verification.import_build is not None else 'unknown'} · " - f"{verification.import_verified_at}" - ) - lines.append( - f"Last full verification: " - f"{verification.last_full_verification_at or 'not recorded'}" - ) - lines.append( - f"Last depot sync: {verification.last_sync_at or 'not recorded'} · " - f"{verification.mode or 'mode unknown'}" - ) - lines.append( - f"Cached integrity: build " - f"{verification.build if verification.build is not None else 'unknown'} · " - f"version {verification.version or 'unknown'} · " - f"{verification.files_total if verification.files_total is not None else '?'} files" - ) - lines.append( - "Last sync changes: " - f"downloaded {verification.files_downloaded if verification.files_downloaded is not None else '?'} · " - f"deleted {verification.files_deleted if verification.files_deleted is not None else '?'} · " - f"created {verification.files_created if verification.files_created is not None else '?'} · " - f"quarantined {verification.files_quarantined if verification.files_quarantined is not None else '?'}" - ) - if latest_installation_status is not None: - missing = latest_installation_status.missing_files - lines.append( - "Required files: complete" - if not missing - else "Required files missing: " + ", ".join(str(path) for path in missing) - ) - return "\n".join(lines) + "\n" - - def update_dashboard_debug_view(*, force: bool = False) -> None: - nonlocal last_dashboard_debug_render - if ( - not show_dashboard_debug - or dashboard_debug_output is None - or not dashboard_debug_output.winfo_exists() - ): - return - rendered = dashboard_debug_report() - if not force and rendered == last_dashboard_debug_render: - return - position = dashboard_debug_output.yview()[0] - dashboard_debug_output.configure(state="normal") - dashboard_debug_output.delete("1.0", "end") - dashboard_debug_output.insert("1.0", rendered) - dashboard_debug_output.configure(state="disabled") - dashboard_debug_output.yview_moveto(position) - last_dashboard_debug_render = rendered - - def ensure_dashboard_debug_window() -> None: - nonlocal dashboard_debug_window, dashboard_debug_output - if dashboard_debug_window is not None and dashboard_debug_window.winfo_exists(): - return - window = tk.Toplevel(root) - dashboard_debug_window = window - window.title("PristonTale EU — Debug View") - window.geometry("900x640") - window.minsize(700, 440) - window.configure(background="#0c0908") - apply_window_icon(window) - debug_header = tk.Frame(window, background="#24170f", padx=16, pady=12) - debug_header.pack(fill="x") - tk.Label( - debug_header, - text="DASHBOARD DEBUG VIEW", - background="#24170f", - foreground="#f5d18b", - font=("Sans", 10, "bold"), - ).pack(anchor="w") - tk.Label( - debug_header, - text="Read-only live launcher, runtime, client, and trust state", - background="#24170f", - foreground="#c9baaa", - font=("Sans", 9), - ).pack(anchor="w", pady=(2, 0)) - body = tk.Frame(window, background="#0c0908") - body.pack(fill="both", expand=True, padx=12, pady=12) - body.grid_rowconfigure(0, weight=1) - body.grid_columnconfigure(0, weight=1) - output = tk.Text( - body, - background="#100d0c", - foreground="#ded1c4", - insertbackground="#f8fafc", - font=("Monospace", 10), - wrap="none", - state="disabled", - padx=12, - pady=10, - relief="flat", - ) - dashboard_debug_output = output - vertical = ttk.Scrollbar(body, orient="vertical", command=output.yview) - horizontal = ttk.Scrollbar(body, orient="horizontal", command=output.xview) - output.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set) - output.grid(row=0, column=0, sticky="nsew") - vertical.grid(row=0, column=1, sticky="ns") - horizontal.grid(row=1, column=0, sticky="ew") - window.protocol("WM_DELETE_WINDOW", lambda: set_dashboard_debug(False)) - - def set_dashboard_debug(enabled: bool) -> None: - nonlocal show_dashboard_debug, last_dashboard_debug_render - show_dashboard_debug = enabled - last_dashboard_debug_render = "" - if enabled: - refresh_dashboard_debug_disk_state() - ensure_dashboard_debug_window() - assert dashboard_debug_window is not None - dashboard_debug_window.deiconify() - dashboard_debug_window.lift() - dashboard_debug_window.focus_set() - else: - if dashboard_debug_window is not None and dashboard_debug_window.winfo_exists(): - dashboard_debug_window.withdraw() - render_server_status() - update_dashboard_debug_view(force=True) - - def toggle_dashboard_debug(_event: object | None = None) -> str: - set_dashboard_debug(not show_dashboard_debug) - return "break" - - root.bind_all( - DASHBOARD_DEBUG_KEY, - toggle_dashboard_debug, - add="+", - ) - - def install_realm_options(snapshot: DepotSnapshot) -> None: - nonlocal available_start_options, preferred_realm - catalog = launch_catalog_from_snapshot(snapshot) - available_start_options = catalog.start_options - selected = select_depot_start_option(catalog, preferred_realm) - realm_selector.configure( - values=tuple(option.name for option in available_start_options), - state="disabled" if operation_active else "readonly", - ) - realm_value.set(selected.name) - if selected.name != preferred_realm: - preferred_realm = selected.name - try: - update_settings(paths, realm=selected.name) - except (OSError, ValueError) as save_error: - append_log(f"Could not save the selected realm: {save_error}") - render_server_status() - - def on_realm_selected(_event: object | None = None) -> None: - nonlocal preferred_realm - selected_name = realm_value.get() - selected = next( - ( - option - for option in available_start_options - if option.name.casefold() == selected_name.casefold() - ), - None, - ) - if selected is None: - fallback = select_depot_start_option( - launch_catalog_from_snapshot(official_snapshot) - if official_snapshot is not None - else None, - preferred_realm, - ) - realm_value.set(fallback.name) - return - realm_value.set(selected.name) - if preferred_realm == selected.name: - return - preferred_realm = selected.name - try: - update_settings(paths, realm=selected.name) - except (OSError, ValueError) as save_error: - append_log(f"Could not save the selected realm: {save_error}") - - realm_selector.bind("<>", on_realm_selected) - - def refresh_primary_action( - snapshot: DepotSnapshot | None = None, - *, - installation: GameInstallationStatus | None = None, - update_activity: bool = True, - ) -> GameInstallationStatus: - nonlocal official_snapshot, latest_installation_status - if snapshot is not None: - official_snapshot = snapshot - if installation is None: - installation = inspect_game_installation( - paths, - official_snapshot.build if official_snapshot is not None else None, - compatibility_ready=( - runtime_compatibility.compatible - if runtime_compatibility is not None - else None - ), - ) - latest_installation_status = installation - additional_client_ready = game_clients.running_count > 0 and installation.installed - client_activity_text.set(game_client_activity_text(game_clients.running_count)) - refresh_activity_meta_text() - play_button.configure( - text="PLAY" if additional_client_ready else installation.action.value - ) - if not operation_active: - play_button.configure( - state=( - "disabled" - if ( - installation.action is PrimaryAction.CHECKING - and not additional_client_ready - ) - else "normal" - ), - ) - if not update_activity or operation_active: - return installation - if additional_client_ready: - status.set("Ready for another account.") - progress_text.set("Click Play to open another game window.") - elif installation.action is PrimaryAction.INSTALL: - status.set("Game is not installed.") - progress_text.set("Click Install to download and verify the official game files.") - elif installation.action is PrimaryAction.UPDATE: - status.set("A game update is available.") - official_build = installation.official_build - if official_build is None: - progress_text.set("Local game files need an official build check.") - elif installation.local_build is None: - progress_text.set( - f"Local files need verification · official depot {official_build}" - ) - else: - progress_text.set( - f"Installed depot {installation.local_build} · " - f"official depot {official_build}" - ) - elif installation.action is PrimaryAction.CHECKING: - status.set("Checking game compatibility…") - progress_text.set("The installed release is being inspected without modifying it.") - elif installation.action is PrimaryAction.LAUNCHER_UPDATE: - status.set("Launcher compatibility update required.") - progress_text.set("This installed game release is not recognized by the launcher.") - else: - status.set("Ready") - progress_text.set("Click Play to start your journey") - return installation - - def refresh_runtime_compatibility(*, update_activity: bool = True) -> None: - nonlocal compatibility_check_generation, runtime_compatibility - compatibility_check_generation += 1 - check_generation = compatibility_check_generation - runtime_compatibility = None - local_status = inspect_game_installation( - paths, - official_snapshot.build if official_snapshot is not None else None, - compatibility_ready=None, - ) - refresh_primary_action( - installation=local_status, - update_activity=update_activity, - ) - if not local_status.installed or game_import_requires_verification(paths): - mark_startup_component_ready( - "compatibility", - "Game state ready", - ) - return - - def worker() -> None: - result = inspect_runtime_compatibility(paths, paths.source_game) - events.put( - ( - "compatibility", - (check_generation, result, update_activity), - ) - ) - - threading.Thread( - target=worker, - name="ptlinux-compatibility", - daemon=True, - ).start() - - def show_launcher_update_check( - check: LauncherUpdateCheck, - *, - log_reason: bool = True, - ) -> None: - nonlocal launcher_update_check - launcher_update_check = check - presentation = launcher_update_presentation(check) - launcher_update_button.configure( - text=presentation.button_text, - style=presentation.button_style, - state=( - "disabled" - if operation_active or game_clients.running_count - else "normal" - ), - ) - if presentation.badge_text is not None: - launcher_update_badge.configure(text=presentation.badge_text) - if not launcher_update_badge.winfo_manager(): - launcher_update_badge.pack(side="right") - else: - launcher_update_badge.pack_forget() - if log_reason: - append_log(check.reason) - if launcher_update_dialog is not None: - launcher_update_dialog.refresh() - - def refresh_launcher_update_check() -> None: - requested_channel = selected_update_channel - - def worker() -> None: - try: - check = check_for_launcher_update(channel=requested_channel) - except RuntimeError as update_error: - events.put( - ("launcher_update_error", (requested_channel, str(update_error))) - ) - else: - cache_launcher_update_check(paths.launcher_update_check_cache, check) - events.put(("launcher_update_check", (requested_channel, check))) - - threading.Thread( - target=worker, - name="ptlinux-self-update", - daemon=True, - ).start() - - def history_worker() -> None: - try: - history = fetch_launcher_release_history() - except RuntimeError as history_error: - events.put(("launcher_history_error", str(history_error))) - else: - cache_launcher_release_history( - paths.launcher_release_history_cache, - history, - ) - events.put(("launcher_release_history", history)) - - threading.Thread( - target=history_worker, - name="ptlinux-release-history", - daemon=True, - ).start() - - def refresh_remote_content() -> None: - def broadcast_worker() -> None: - events.put(("broadcast", fetch_latest_broadcast(paths.broadcast_cache))) - - def news_worker() -> None: - events.put(("news", fetch_news(paths.news_cache))) - - def patches_worker() -> None: - events.put(("patches", fetch_patch_notes(paths.patches_cache))) - - def server_status_worker() -> None: - events.put(("server_status_check", perform_server_status_check())) - - def version_worker() -> None: - try: - snapshot = DepotClient().fetch_snapshot() - except DepotError as remote_error: - events.put(("version_error", str(remote_error))) - else: - try: - cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot) - except OSError as cache_error: - events.put( - ( - "output", - f"Could not cache official realm choices: {cache_error}\n", - ) - ) - events.put(("version", snapshot)) - - if startup_settings.low_bandwidth_mode: - append_log( - "Low-bandwidth mode: using cached news, broadcasts, patch notes, " - "and artwork." - ) - for component, message in ( - ("broadcast", "Cached broadcast ready"), - ("news", "Cached news ready"), - ("patches", "Cached patch notes ready"), - ("launcher_update", "Cached launcher update status ready"), - ): - mark_startup_component_ready(component, message) - else: - threading.Thread( - target=broadcast_worker, - name="ptlinux-broadcast", - daemon=True, - ).start() - threading.Thread( - target=news_worker, - name="ptlinux-news", - daemon=True, - ).start() - threading.Thread( - target=patches_worker, - name="ptlinux-patches", - daemon=True, - ).start() - threading.Thread( - target=server_status_worker, - name="ptlinux-server-status", - daemon=True, - ).start() - threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start() - if not startup_settings.low_bandwidth_mode: - refresh_launcher_update_check() - - def start_remote_content_refresh() -> None: - nonlocal remote_refresh_started - if remote_refresh_started: - return - remote_refresh_started = True - refresh_remote_content() - - def release_game_launch_controls( - client_id: int, - *, - ready: bool, - returncode: int | None = None, - cancelled: bool = False, - ) -> None: - nonlocal active_game_launch_id - nonlocal operation_active, game_window_active - nonlocal operation_refreshes_compatibility, operation_started_at - if active_game_launch_id != client_id: - return - active_game_launch_id = None - operation_active = False - game_window_active = game_clients.running_count > 0 - operation_refreshes_compatibility = False - operation_started_at = None - progress_updates.clear() - transfer_rate.reset() - speed_text.set("") - set_download_context_text("") - progress_bar.stop() - close_game_preloader() - if not ready: - root.deiconify() - set_busy(False) - refresh_primary_action(update_activity=False) - if ready: - status.set("Ready for another account.") - progress_text.set("Click Play to open another game window.") - dashboard_chrome.minimize() - elif cancelled: - root.lift() - status.set("Game launch cancelled.") - progress_text.set("The initiated game and compatibility processes were stopped.") - else: - root.lift() - status.set( - f"Client {client_id} failed to start" - + (f" (exit {returncode})" if returncode is not None else "") - ) - progress_text.set("See the activity log for details.") - - def finish_operation(returncode: int) -> None: - nonlocal operation_active, game_window_active - nonlocal operation_refreshes_compatibility, operation_started_at - refresh_compatibility = operation_refreshes_compatibility - operation_active = False - game_window_active = game_clients.running_count > 0 - operation_refreshes_compatibility = False - operation_started_at = None - progress_updates.clear() - transfer_rate.reset() - speed_text.set("") - set_download_context_text("") - progress_bar.stop() - close_game_preloader() - root.deiconify() - if not game_window_active: - root.lift() - set_busy(False) - if refresh_compatibility: - refresh_runtime_compatibility(update_activity=returncode == 0) - else: - refresh_primary_action(update_activity=returncode == 0) - if show_dashboard_debug: - refresh_dashboard_debug_disk_state() - update_dashboard_debug_view(force=True) - if returncode != 0: - status.set(f"Operation failed (exit {returncode})") - progress_text.set("See the activity log for details.") - - def poll_events() -> None: - nonlocal game_window_active, launcher_update_check, launcher_release_history - nonlocal runtime_compatibility - processed = 0 - try: - while processed < EVENT_BATCH_SIZE: - kind, value = events.get_nowait() - processed += 1 - if kind == "output" and isinstance(value, str): - append_log(value) - elif kind == "done" and isinstance(value, int): - finish_operation(value) - elif kind == "launcher_update_done" and isinstance(value, int): - finish_operation(value) - if value == 0: - append_log("Launcher update activated; restarting the dashboard.") - try: - restart_command, restart_environment = launcher_restart_spec(paths) - subprocess.Popen( - restart_command, - env=restart_environment, - close_fds=True, - start_new_session=True, - ) - except OSError as restart_error: - show_themed_dialog( - root, - title="Launcher update", - message=( - "The update was installed, but the launcher could not " - f"restart: {restart_error}" - ), - kind="error", - window_icon=window_icon, - ) - else: - root.destroy() - return - elif kind == "operation_status" and isinstance(value, Mapping): - step = _as_int(value.get("step")) - total = _as_int(value.get("total")) - message = value.get("message") - if step > 0 and total > 0 and isinstance(message, str) and message: - set_operation_stage(step, total, message) - elif ( - kind == "game_supervisor_started" - and isinstance(value, tuple) - and len(value) == 2 - and isinstance(value[0], int) - and isinstance(value[1], int) - ): - game_clients.attach_supervisor(value[0], value[1]) - elif ( - kind == "game_operation_status" - and isinstance(value, tuple) - and len(value) == 2 - and isinstance(value[0], int) - and isinstance(value[1], Mapping) - and value[0] == active_game_launch_id - and not game_launch_supervisors.is_cancel_requested(value[0]) - ): - step = _as_int(value[1].get("step")) - total = _as_int(value[1].get("total")) - message = value[1].get("message") - if step > 0 and total > 0 and isinstance(message, str) and message: - set_operation_stage(step, total, message) - elif ( - kind == "game_started" - and isinstance(value, int) - and not game_launch_supervisors.is_cancel_requested(value) - ): - launch_settings = client_launch_settings.get(value) - if launch_settings is not None: - client_runtime_debug[value] = inspect_runtime_debug( - paths, - launch_settings, - ) - progress_bar.stop() - status.set(f"Game client {value} started; preparing its window…") - elif ( - kind == "game_window_ready" - and isinstance(value, int) - and not game_launch_supervisors.is_cancel_requested(value) - ): - if game_clients.mark_running(value): - game_window_active = True - release_game_launch_controls(value, ready=True) - elif ( - kind == "game_done" - and isinstance(value, tuple) - and len(value) == 2 - and isinstance(value[0], int) - and isinstance(value[1], int) - ): - client_id, returncode = value - cancelled = game_launch_supervisors.finish(client_id) - phase = game_clients.finish(client_id) - client_runtime_debug.pop(client_id, None) - client_launch_settings.pop(client_id, None) - game_window_active = game_clients.running_count > 0 - append_log(f"Client {client_id} exited with status {returncode}.") - if phase == "starting": - release_game_launch_controls( - client_id, - ready=False, - returncode=returncode, - cancelled=cancelled, - ) - elif phase == "running" and not operation_active: - set_busy(False) - refresh_primary_action(update_activity=False) - count = game_clients.running_count - if count: - status.set("Ready for another account.") - progress_text.set("Click Play to open another game window.") - else: - status.set("All game clients have closed.") - progress_text.set("Click Play to start a new client.") - root.deiconify() - root.lift() - if show_dashboard_debug: - refresh_dashboard_debug_disk_state() - elif kind == "broadcast": - show_broadcast(value if isinstance(value, Broadcast) else None) - mark_startup_component_ready( - "broadcast", - "Broadcast ready", - ) - elif kind == "server_status_check" and isinstance( - value, - ServerStatusCheck, - ): - show_server_status(value) - if value.error is not None: - append_log(f"Server status check unavailable: {value.error}") - mark_startup_component_ready( - "server_status", - ( - "Server status ready" - if value.error is None - else "Server status check complete" - ), - ) - elif kind == "news" and isinstance(value, tuple): - show_news(value) - mark_startup_component_ready( - "news", - "News cards ready", - ) - elif ( - kind == "patches" - and isinstance(value, tuple) - and all(isinstance(item, PatchNote) for item in value) - ): - show_patch_notes( - value, - on_complete=lambda: mark_startup_component_ready( - "patches", - "Patch notes ready", - ), - ) - elif kind == "news_image": - complete_news_image(value) - elif kind == "logo_image": - install_official_logo(value) - mark_startup_component_ready( - "logo", - "Launcher artwork ready", - ) - elif ( - kind == "compatibility" - and isinstance(value, tuple) - and len(value) == 3 - and isinstance(value[0], int) - and isinstance(value[1], RuntimeCompatibility) - and isinstance(value[2], bool) - ): - check_generation, checked, update_activity = value - if check_generation == compatibility_check_generation: - runtime_compatibility = checked - if checked.compatible: - append_log( - "Compatibility check passed: " - f"scene={checked.scene_action}, " - f"binaries={checked.binary_action}" - ) - else: - append_log( - "Launcher compatibility update required: " - f"{checked.reason or 'unknown release profile'}" - ) - refresh_primary_action(update_activity=update_activity) - mark_startup_component_ready( - "compatibility", - "Game compatibility ready", - ) - elif kind == "version" and isinstance(value, DepotSnapshot): - version.set( - f"Launcher {__version__} · {value.version} · depot {value.build}" - ) - install_realm_options(value) - refresh_primary_action(value) - mark_startup_component_ready( - "version", - "Game version ready", - ) - elif kind == "version_error": - version.set( - f"Launcher {__version__} · game build check unavailable" - ) - refresh_primary_action() - mark_startup_component_ready( - "version", - "Game version check complete", - ) - elif ( - kind == "launcher_update_check" - and isinstance(value, tuple) - and len(value) == 2 - and isinstance(value[0], str) - and isinstance(value[1], LauncherUpdateCheck) - ): - requested_channel, update_check = value - if requested_channel != selected_update_channel: - continue - show_launcher_update_check(update_check) - mark_startup_component_ready( - "launcher_update", - "Launcher update status ready", - ) - elif ( - kind == "launcher_update_error" - and isinstance(value, tuple) - and len(value) == 2 - and isinstance(value[0], str) - and isinstance(value[1], str) - ): - requested_channel, update_error = value - if requested_channel != selected_update_channel: - continue - if launcher_update_check is None: - launcher_update_button.configure( - text="CHECK LAUNCHER", - style="Secondary.TButton", - state=( - "disabled" - if operation_active or game_clients.running_count - else "normal" - ), - ) - launcher_update_badge.pack_forget() - append_log(f"Launcher update check unavailable: {update_error}") - if launcher_update_dialog is not None: - launcher_update_dialog.show_error(update_error) - mark_startup_component_ready( - "launcher_update", - "Launcher update check complete", - ) - elif kind == "launcher_release_history" and isinstance( - value, - LauncherRemoteHistory, - ): - launcher_release_history = value - if launcher_update_dialog is not None: - launcher_update_dialog.refresh() - elif kind == "launcher_history_error" and isinstance(value, str): - append_log(f"Launcher release history unavailable: {value}") - except queue.Empty: - pass - except Exception as event_error: - append_log( - "Dashboard event processing recovered from an error: " - f"{event_error}" - ) - if operation_active: - latest_progress = progress_updates.take() - if latest_progress is not None: - update_progress(latest_progress) - if speed_text.get() != "0 B/s" and transfer_rate.is_idle(): - speed_text.set("0 B/s") - update_elapsed_indicator() - update_server_status_debug_text() - update_dashboard_debug_view() - poll_delay = ( - ACTIVE_POLL_INTERVAL_MILLISECONDS - if not startup_transition_started - else dashboard_poll_interval( - operation_active=operation_active, - game_window_active=game_window_active, - pending_events=not events.empty(), - ) - ) - root.after(poll_delay, poll_events) - - def command_for(*arguments: str) -> list[str]: - return launcher_child_command(*arguments) - - def install_launcher_and_restart(version_to_install: str) -> None: - run_operation( - f"Installing launcher {version_to_install}…", - command_for("launcher-update"), - restart_on_success=True, - ) - - def rollback_launcher_and_restart(target_version: str) -> None: - run_operation( - f"Rolling back launcher to {target_version}…", - command_for("launcher-update", "--rollback", target_version), - restart_on_success=True, - ) - - launcher_update_dialog = LauncherUpdateDialog( - root, - paths=paths, - launcher_version=__version__, - ui_scale=launcher_ui_scale, - window_icon=window_icon, - apply_window_icon=apply_window_icon, - get_check=lambda: launcher_update_check, - get_remote_history=lambda: launcher_release_history, - get_selected_channel=lambda: selected_update_channel, - is_busy=lambda: operation_active or bool(game_clients.running_count), - request_check=refresh_launcher_update_check, - install_and_restart=install_launcher_and_restart, - rollback_and_restart=rollback_launcher_and_restart, - open_article=open_article, - append_log=append_log, - ) - - def run_launcher_update(required_reason: str | None = None) -> None: - assert launcher_update_dialog is not None - launcher_update_dialog.open(required_reason) - - launcher_update_button.configure(command=run_launcher_update) - - def start_game_client() -> None: - nonlocal active_game_launch_id - if official_snapshot is not None: - try: - cache_depot_launch_catalog( - paths.depot_launch_catalog_cache, - official_snapshot, - ) - except OSError as cache_error: - show_themed_dialog( - root, - title="Realm selection", - message=( - "The launcher could not save the official realm selection:\n\n" - f"{cache_error}" - ), - kind="error", - window_icon=window_icon, - ) - return - launch_settings: Settings | None = None - runtime_debug: RuntimeDebugInfo | None = None - try: - loaded_launch_settings = load_settings(paths) - except (OSError, ValueError) as settings_error: - append_log(f"Could not inspect launch runtime settings: {settings_error}") - else: - launch_settings = loaded_launch_settings - runtime_debug = inspect_runtime_debug(paths, loaded_launch_settings) - client_id = game_clients.begin() - if runtime_debug is not None and launch_settings is not None: - client_runtime_debug[client_id] = runtime_debug - client_launch_settings[client_id] = launch_settings - active_game_launch_id = client_id - run_operation( - f"Starting PristonTale EU {realm_value.get()} client {client_id}…", - command_for("play", "--realm", realm_value.get()), - show_preloader=True, - game_client_id=client_id, - ) - - def synchronize_game_files( - installation: GameInstallationStatus | None = None, - *, - repair: bool = False, - ) -> None: - current = installation or refresh_primary_action(update_activity=False) - presentation = game_sync_presentation( - installed=current.installed, - repair=repair, - ) - command = ( - command_for("update", "--no-import", "--repair") - if repair - else command_for("update") - ) - run_operation( - presentation.operation_label, - command, - game_files_may_change=True, - file_progress_verb=presentation.file_progress_verb, - ) - - def run_primary_action() -> None: - installation = refresh_primary_action(update_activity=False) - if ( - installation.action is PrimaryAction.PLAY - or (game_clients.running_count > 0 and installation.installed) - ): - start_game_client() - elif installation.action in {PrimaryAction.INSTALL, PrimaryAction.UPDATE}: - synchronize_game_files(installation) - elif installation.action is PrimaryAction.LAUNCHER_UPDATE: - run_launcher_update( - runtime_compatibility.reason - if runtime_compatibility is not None - else "The installed release profile is not recognized.", - ) - - play_button.configure(command=run_primary_action) - update_button.configure(command=synchronize_game_files) - repair_button.configure(command=lambda: synchronize_game_files(repair=True)) - doctor_button.configure( - command=lambda: run_operation("Checking the system…", command_for("doctor")) - ) - - def apply_saved_settings(updated_settings: Settings) -> None: - nonlocal selected_update_channel, launcher_update_check - previous_update_channel = selected_update_channel - selected_update_channel = updated_settings.update_channel - set_shared_telemetry_enabled(updated_settings.telemetry_enabled) - saved_limit = ( - f"{updated_settings.download_speed_limit_mbps:g} MB/s" - if updated_settings.download_speed_limit_mbps is not None - else "unlimited" - ) - append_log( - f"Saved launcher settings to {paths.settings_file}: " - f"runtime={updated_settings.runtime}, " - f"proton_sync={updated_settings.proton_sync}, " - f"download_limit={saved_limit}, " - f"low_bandwidth={updated_settings.low_bandwidth_mode}, " - f"update_channel={updated_settings.update_channel}, " - f"telemetry={updated_settings.telemetry_enabled}" - ) - status.set("Settings saved.") - progress_text.set( - "Game and download settings apply to the next operation; launcher scale " - "and cached-content mode apply after restart." - ) - if show_dashboard_debug: - refresh_dashboard_debug_disk_state() - update_dashboard_debug_view(force=True) - if previous_update_channel != selected_update_channel: - launcher_update_check = None - append_log( - f"Launcher update channel changed from {previous_update_channel} " - f"to {selected_update_channel}; checking eligible releases." - ) - refresh_launcher_update_check() - - def open_settings() -> None: - nonlocal settings_window - settings_window = open_settings_dialog( - root, - paths=paths, - launcher_ui_scale=launcher_ui_scale, - window_icon=window_icon, - apply_window_icon=apply_window_icon, - on_saved=apply_saved_settings, - existing=settings_window, - ) - - settings_button.configure(command=open_settings) - - def choose_import_source() -> None: - source = filedialog.askdirectory( - title="Choose an existing PristonTale EU game directory", - mustexist=True, - ) - if not source: - return - run_operation( - "Importing the existing game into the managed installation…", - command_for("import-game", source), - game_files_may_change=True, - ) - - import_button.configure(command=choose_import_source) - log_toggle.configure(command=toggle_log) - - def close_dashboard() -> None: - # Keep draining each client process while it owns a shared runtime lease. - # Closing the window therefore minimizes the dashboard until all clients - # and any active launcher operation have finished. - if operation_active or game_clients.active_count: - set_dashboard_debug(False) - dashboard_chrome.minimize() - return - root.destroy() - - dashboard_chrome.set_close_command(close_dashboard) - root.protocol("WM_DELETE_WINDOW", close_dashboard) - - def update_news_viewport(_event: object | None = None) -> None: - news_canvas.configure(scrollregion=news_canvas.bbox("all")) - - def resize_news_content(event: object) -> None: - width = getattr(event, "width", 0) - if isinstance(width, int) and width > 0: - news_canvas.itemconfigure(news_window, width=width) - - def apply_responsive_layout() -> None: - nonlocal controls_are_stacked, responsive_layout_after - responsive_layout_after = None - width = max(1, root.winfo_width()) - stacked = dashboard_controls_stacked(width, launcher_ui_scale) - if stacked != controls_are_stacked: - controls_are_stacked = stacked - if stacked: - game_actions.grid_configure( - row=0, - column=0, - columnspan=2, - padx=0, - pady=(0, 4), - ) - system_actions.grid_configure( - row=1, - column=0, - columnspan=2, - padx=0, - pady=(4, 0), - ) - else: - game_actions.grid_configure( - row=0, - column=0, - columnspan=1, - padx=(0, 4), - pady=0, - ) - system_actions.grid_configure( - row=0, - column=1, - columnspan=1, - padx=(4, 0), - pady=0, - ) - if news_card_columns(width, launcher_ui_scale) != news_layout_columns: - layout_news_cards() - - def schedule_responsive_layout(event: object | None = None) -> None: - nonlocal responsive_layout_after - if event is not None and getattr(event, "widget", root) is not root: - return - if responsive_layout_after is not None: - try: - root.after_cancel(responsive_layout_after) - except tk.TclError: - pass - responsive_layout_after = root.after_idle(apply_responsive_layout) - - def invoke_shortcut(button: ttk.Button) -> str: - button.invoke() - return "break" - - def scroll_news_keyboard(units: int) -> str: - news_canvas.yview_scroll(units, "units") - return "break" - - news_content_host.bind("", update_news_viewport) - news_content.bind("", update_news_viewport) - patches_content.bind("", update_news_viewport) - news_canvas.bind("", resize_news_content) - news_canvas.bind("", lambda _event: news_canvas.focus_set(), add="+") - news_canvas.bind("", lambda _event: scroll_news_keyboard(-1)) - news_canvas.bind("", lambda _event: scroll_news_keyboard(1)) - news_canvas.bind("", lambda _event: scroll_news_keyboard(-8)) - news_canvas.bind("", lambda _event: scroll_news_keyboard(8)) - news_canvas.bind("", lambda _event: news_canvas.yview_moveto(0.0)) - news_canvas.bind("", lambda _event: news_canvas.yview_moveto(1.0)) - news_tab_button.bind( - "", - lambda _event: switch_news_tab_from_keyboard("patches", patches_tab_button), - ) - patches_tab_button.bind( - "", - lambda _event: switch_news_tab_from_keyboard("news", news_tab_button), - ) - root.bind("", lambda _event: invoke_shortcut(play_button)) - root.bind("", lambda _event: invoke_shortcut(update_button)) - root.bind("", lambda _event: invoke_shortcut(repair_button)) - root.bind("", lambda _event: invoke_shortcut(import_button)) - root.bind("", lambda _event: invoke_shortcut(doctor_button)) - root.bind("", lambda _event: invoke_shortcut(settings_button)) - root.bind("", lambda _event: invoke_shortcut(launcher_update_button)) - root.bind("", lambda _event: invoke_shortcut(log_toggle)) - root.bind("", schedule_responsive_layout, add="+") - activity_window.bind("", lambda _event: hide_activity_window()) - for scroll_widget in ( - news_canvas, - news_content_host, - news_content, - patches_content, - ): - scroll_widget.bind("", scroll_news_view, add="+") - scroll_widget.bind("", scroll_news_view, add="+") - scroll_widget.bind("", scroll_news_view, add="+") - apply_responsive_layout() - startup_overlay.lift() - startup_status.set("Restoring dashboard content…") - startup_overlay.update_idletasks() - show_broadcast(read_cached_broadcast(paths.broadcast_cache)) - show_news(read_cached_news(paths.news_cache)) - show_patch_notes(read_cached_patch_notes(paths.patches_cache)) - cached_launcher_update = read_cached_launcher_update_check( - paths.launcher_update_check_cache, - channel=selected_update_channel, - ) - if cached_launcher_update is not None: - show_launcher_update_check(cached_launcher_update, log_reason=False) - launcher_release_history = read_cached_launcher_release_history( - paths.launcher_release_history_cache, - ) - refresh_runtime_compatibility() - mark_startup_component_ready( - "cached_content", - "Cached dashboard content ready", - ) - poll_events() - load_official_logo() - update_startup_progress("Loading online dashboard content…") - startup_overlay.update_idletasks() - if not startup_transition_started: - startup_timeout_after = root.after( - DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, - timeout_startup_preloader, - ) - maybe_start_dashboard_transition() - try: - root.mainloop() - except KeyboardInterrupt: - try: - root.destroy() - except tk.TclError: - pass