diff --git a/pyproject.toml b/pyproject.toml index 35984d9..8533ded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + [project] name = "PTEU-Linux-Launcher" version = "0.9.11" @@ -8,3 +12,27 @@ dependencies = [] [project.scripts] ptdiag = "ptdiag.cli:main" pristontale-eu = "ptlinux.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +ptlinux = ["launcher-update-public.pem", "native/*"] + +[tool.ruff] +target-version = "py311" +line-length = 100 +extend-exclude = [ + ".artifacts", + ".venv", + "candidates", + "dist", + "results", +] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] diff --git a/ptdiag/__init__.py b/src/ptdiag/__init__.py similarity index 75% rename from ptdiag/__init__.py rename to src/ptdiag/__init__.py index b919f76..d1f7533 100644 --- a/ptdiag/__init__.py +++ b/src/ptdiag/__init__.py @@ -13,9 +13,9 @@ __version__ = "0.1.0" def __getattr__(name: str) -> Any: """Load diagnostic configuration helpers only when the full harness needs them. - The production launcher ships only :mod:`ptdiag.assbin`, which it uses for - verified scene handling. Deferring the larger diagnostic-only imports keeps - the compact package importable without shipping the investigation harness. + The launcher release retains :mod:`ptdiag.assbin` and :mod:`ptdiag.logs` as + compatibility façades. Deferring the larger diagnostic-only imports keeps the + compact package importable without shipping the investigation harness. """ if name in {"AppConfig", "ConfigError", "load_config"}: from .config import AppConfig, ConfigError, load_config diff --git a/ptdiag/__main__.py b/src/ptdiag/__main__.py similarity index 100% rename from ptdiag/__main__.py rename to src/ptdiag/__main__.py diff --git a/src/ptdiag/artifacts.py b/src/ptdiag/artifacts.py new file mode 100644 index 0000000..c29ffd6 --- /dev/null +++ b/src/ptdiag/artifacts.py @@ -0,0 +1,22 @@ +"""Generated diagnostic output paths for checkout-only experiments.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Mapping + +from .config import repository_root + + +RESULTS_ROOT_ENVIRONMENT_VARIABLE = "PTDIAG_RESULTS_ROOT" + + +def default_results_root(environment: Mapping[str, str] | None = None) -> Path: + """Return the configurable root for generated diagnostic evidence.""" + + values = os.environ if environment is None else environment + explicit = values.get(RESULTS_ROOT_ENVIRONMENT_VARIABLE, "").strip() + if explicit: + return Path(explicit).expanduser().absolute() + return repository_root() / ".artifacts/diagnostics/results" diff --git a/src/ptdiag/assbin.py b/src/ptdiag/assbin.py new file mode 100644 index 0000000..96f8e25 --- /dev/null +++ b/src/ptdiag/assbin.py @@ -0,0 +1,72 @@ +"""Compatibility façade for launcher-owned wrapped Assbin handling.""" + +from ptlinux.game.compatibility.assbin import ( + ASSBIN_CHUNK_AIANIMATION, + ASSBIN_CHUNK_AIMATERIAL, + ASSBIN_CHUNK_AIMATERIALPROPERTY, + ASSBIN_CHUNK_AIMESH, + ASSBIN_CHUNK_AINODE, + ASSBIN_CHUNK_AISCENE, + ASSBIN_CHUNK_AITEXTURE, + ASSBIN_HEADER_LENGTH, + MAX_CAPTURE_LENGTH, + MAX_STRING_LENGTH, + PROPERTY_TYPE_NAMES, + READ_CHUNK_SIZE, + AssbinError, + AssbinHeader, + BlobSummary, + BytesReader, + Chunk, + EmbeddedTexture, + Material, + MaterialProperty, + PngMetadata, + Reader, + SceneCounts, + SceneInspection, + XorFileReader, + _inspect_png, + _png_scanlines_sha256, + extract_embedded_texture, + inspect_wrapped_assbin, + normalize_png_16_rgb_to_8, + normalize_wrapped_assbin_textures, + xor_bytes, +) + + +__all__ = [ + "ASSBIN_CHUNK_AIANIMATION", + "ASSBIN_CHUNK_AIMATERIAL", + "ASSBIN_CHUNK_AIMATERIALPROPERTY", + "ASSBIN_CHUNK_AIMESH", + "ASSBIN_CHUNK_AINODE", + "ASSBIN_CHUNK_AISCENE", + "ASSBIN_CHUNK_AITEXTURE", + "ASSBIN_HEADER_LENGTH", + "MAX_CAPTURE_LENGTH", + "MAX_STRING_LENGTH", + "PROPERTY_TYPE_NAMES", + "READ_CHUNK_SIZE", + "AssbinError", + "AssbinHeader", + "BlobSummary", + "BytesReader", + "Chunk", + "EmbeddedTexture", + "Material", + "MaterialProperty", + "PngMetadata", + "Reader", + "SceneCounts", + "SceneInspection", + "XorFileReader", + "_inspect_png", + "_png_scanlines_sha256", + "extract_embedded_texture", + "inspect_wrapped_assbin", + "normalize_png_16_rgb_to_8", + "normalize_wrapped_assbin_textures", + "xor_bytes", +] diff --git a/ptdiag/cli.py b/src/ptdiag/cli.py similarity index 100% rename from ptdiag/cli.py rename to src/ptdiag/cli.py diff --git a/ptdiag/config.py b/src/ptdiag/config.py similarity index 75% rename from ptdiag/config.py rename to src/ptdiag/config.py index 1613161..de63539 100644 --- a/ptdiag/config.py +++ b/src/ptdiag/config.py @@ -9,7 +9,8 @@ import re import tomllib -DEFAULT_CONFIG_PATH = Path("config/local.toml") +DEFAULT_CONFIG_PATH = Path("diagnostics/config/local.toml") +LEGACY_CONFIG_PATH = Path("config/local.toml") class ConfigError(ValueError): @@ -53,19 +54,48 @@ class AppConfig: def project_root() -> Path: + """Return the Python source root used by checkout-only helper commands.""" + return Path(__file__).resolve().parent.parent +def repository_root() -> Path: + """Return the repository root while retaining flat-install compatibility.""" + + source_root = project_root() + checkout_root = source_root.parent + if source_root.name == "src" and (checkout_root / "pyproject.toml").is_file(): + return checkout_root + return source_root + + +def diagnostics_root() -> Path: + return repository_root() / "diagnostics" + + +def resolve_config_path(path: str | Path | None = None) -> Path: + if path is not None: + return Path(path).expanduser().absolute() + root = repository_root() + preferred = root / DEFAULT_CONFIG_PATH + legacy = root / LEGACY_CONFIG_PATH + if preferred.is_file(): + return preferred.absolute() + if legacy.is_file(): + return legacy.absolute() + return preferred.absolute() + + def load_config(path: str | Path | None = None) -> AppConfig: - config_path = Path(path) if path is not None else project_root() / DEFAULT_CONFIG_PATH - config_path = config_path.expanduser().absolute() + config_path = resolve_config_path(path) try: with config_path.open("rb") as handle: data = tomllib.load(handle) except FileNotFoundError as exc: raise ConfigError( f"Configuration not found: {config_path}. " - "Copy config/local.example.toml to config/local.toml." + "Copy diagnostics/config/local.example.toml to " + "diagnostics/config/local.toml. Legacy config/local.toml is also recognized." ) from exc except tomllib.TOMLDecodeError as exc: raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc diff --git a/src/ptdiag/experiment.py b/src/ptdiag/experiment.py new file mode 100644 index 0000000..c1a71cd --- /dev/null +++ b/src/ptdiag/experiment.py @@ -0,0 +1,775 @@ +"""Controlled Proton 7 runtime experiments and result generation.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import time +from typing import Any, Mapping, Sequence + +from .artifacts import default_results_root +from .config import AppConfig, project_root +from .experiments.cleanup import ( + DISPOSABLE_DIRECTORY_NAME as _DISPOSABLE_DIRECTORY_NAME, + cleanup_checked as _cleanup_checked_impl, + command_ok as _command_ok, + registry_restore_command as _registry_restore_command_impl, + registry_restore_commands as _registry_restore_commands_impl, + registry_setup_commands as _registry_setup_commands_impl, + remove_disposable_game as _remove_disposable_game_impl, + replace_game_link as _replace_game_link, + require_command as _require_command, + restore_game_link as _restore_game_link, + run_checked_commands as _run_checked_commands, + validate_disposable_path as _validate_disposable_path_impl, +) +from .experiments.evidence import ( + collect_dll_overrides as _collect_dll_overrides_impl, + collect_hashes as _collect_hashes_impl, + desktop_name as _desktop_name, + finalize_result as _finalize_impl, + render_report as _report, + serialize_log_state as _serialize_log_state, + serialize_log_states as _serialize_log_states, + setup_failure_summary as _setup_failure_summary, + write_text as _write_text, +) +from .experiments.execution import ( + capture_game_process as _capture_game_process_impl, + game_processes as _game_processes_impl, + run_and_watch as _run_and_watch_impl, + stop_process_group as _stop_process_group_impl, +) +from .experiments.log_selection import ( + capture_current_native_logs, + native_log_priority as _native_log_priority, + observe_current_native_logs, + scene_import_survived, +) +from .experiments.models import ExperimentError, ExperimentResult +from .experiments.preparation import ( + _allocated_tree_bytes, + _apply_disposable_ini_overrides, + _dry_run_commands, + _ini_override_command, + _inspect_ini_overrides, + _launch_argv, + _log_modification_plan, + _preflight as _preflight_impl, + _prepare_disposable_game, + _prepare_state_cache, + _proton_command, + _proton_environment, + _resource_copy_paths, + _runtime_environment, + _safe_game_file, + _safe_resource_relative_path, + _state_cache_file_snapshot, + _state_cache_plan, +) +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 +from .performance import PerformanceSampler +from .prefix import ( + PrefixSafetyError, + cleanup_dedicated_wineserver, +) +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 +from .testcase import Testcase +from .trace import process_trace_shards + + +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 default_results_root(), 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 default_results_root(), 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 _remove_disposable_game( + config: AppConfig, + result: ResultDirectory, + disposable_game: Path, + logger: CommandLogger, +) -> None: + _remove_disposable_game_impl( + config, + result, + disposable_game, + logger, + directory_name=_DISPOSABLE_DIRECTORY_NAME, + ) + + +def _validate_disposable_path( + staging_root: Path, + path: Path, + expected_name: str, +) -> None: + _validate_disposable_path_impl( + staging_root, + path, + expected_name, + directory_name=_DISPOSABLE_DIRECTORY_NAME, + ) + + +def _preflight( + config: AppConfig, + testcase: Testcase, + environment: Mapping[str, Any], + hashes: list[dict[str, Any]], +) -> list[str]: + return _preflight_impl( + config, + testcase, + environment, + hashes, + game_processes=_game_processes, + ) + + +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]: + return _run_and_watch_impl( + config, + testcase, + result, + logger, + argv, + env_overrides, + baseline, + popen=subprocess.Popen, + clock_ns=time.time_ns, + sleep=time.sleep, + now=lambda: datetime.now().astimezone(), + environment_factory=lambda: os.environ.copy(), + json_writer=write_json, + process_capture=_capture_game_process, + process_stopper=_stop_process_group, + performance_sampler_factory=PerformanceSampler, + log_observer=observe_current_native_logs, + log_capture=capture_current_native_logs, + scene_survived=scene_import_survived, + serialize_state=_serialize_log_state, + text_writer=_write_text, + prioritize_log=_native_log_priority, + discover_logs=discover_current_native_logs, + parse_log=parse_current_run_log, + read_log=read_current_run_log_text, + snapshot_logs=snapshot_native_logs, + ) + + +def _stop_process_group(process: subprocess.Popen[str]) -> None: + _stop_process_group_impl( + process, + kill_group=os.killpg, + signals=( + (signal.SIGINT, 5.0), + (signal.SIGTERM, 5.0), + (signal.SIGKILL, 3.0), + ), + ) + + +def _capture_game_process( + testcase: Testcase, + result_directory: Path, +) -> dict[str, Any] | None: + return _capture_game_process_impl( + testcase, + result_directory, + game_process_scan=_game_processes, + proc_root=Path("/proc"), + copy_file=shutil.copyfile, + ) + + +def _game_processes() -> list[int]: + return _game_processes_impl(proc_root=Path("/proc")) + + +def _registry_setup_commands( + config: AppConfig, + desktop_name: str, + desktop_size: str, +) -> list[list[str]]: + return _registry_setup_commands_impl( + config, + desktop_name, + desktop_size, + proton_command=_proton_command, + ) + + +def _registry_restore_commands( + config: AppConfig, + snapshot: DesktopRegistrySnapshot, +) -> list[list[str]]: + return _registry_restore_commands_impl( + config, + snapshot, + proton_command=_proton_command, + ) + + +def _registry_restore_command( + config: AppConfig, + key: str, + name: str, + existed: bool, + value: str | None, +) -> list[str]: + return _registry_restore_command_impl( + config, + key, + name, + existed, + value, + proton_command=_proton_command, + ) + + +def _cleanup_checked(config: AppConfig, logger: CommandLogger) -> None: + _cleanup_checked_impl( + config, + logger, + cleaner=cleanup_dedicated_wineserver, + ) + + +def _collect_hashes( + config: AppConfig, + testcase: Testcase, +) -> list[dict[str, Any]]: + return _collect_hashes_impl( + config, + testcase, + repository_root=project_root(), + verifier=verify_known_game_hashes, + hash_file=sha256_file, + ) + + +def _collect_dll_overrides(prefix: Path) -> dict[str, str]: + return _collect_dll_overrides_impl( + prefix, + reader=read_registry_string_values, + ) + + +def _finalize(result: ResultDirectory, summary: dict[str, Any]) -> None: + _finalize_impl( + result, + summary, + json_writer=write_json, + text_writer=_write_text, + report_renderer=_report, + ) diff --git a/src/ptdiag/experiments/__init__.py b/src/ptdiag/experiments/__init__.py new file mode 100644 index 0000000..a29d94c --- /dev/null +++ b/src/ptdiag/experiments/__init__.py @@ -0,0 +1 @@ +"""Controlled diagnostic experiment implementation modules.""" diff --git a/src/ptdiag/experiments/cleanup.py b/src/ptdiag/experiments/cleanup.py new file mode 100644 index 0000000..c92c662 --- /dev/null +++ b/src/ptdiag/experiments/cleanup.py @@ -0,0 +1,287 @@ +"""Experiment cleanup, prefix-link rollback, and registry restoration.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +import os +from pathlib import Path + +from ..config import AppConfig +from ..prefix import cleanup_dedicated_wineserver +from ..registry import DesktopRegistrySnapshot +from ..result import ResultDirectory +from ..runner import CommandLogger, CommandResult +from .models import ExperimentError + + +EXPLORER_KEY = r"HKCU\Software\Wine\Explorer" +DESKTOPS_KEY = r"HKCU\Software\Wine\Explorer\Desktops" +DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable" + +ProtonCommand = Callable[..., list[str]] +WineserverCleaner = Callable[..., CommandResult] + + +def replace_game_link( + prefix_link: Path, + disposable_game: Path, + expected_live_game: Path, + logger: CommandLogger, +) -> str: + """Point the dedicated prefix at a verified disposable game tree.""" + + 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: + """Restore the dedicated-prefix game link and verify its exact target.""" + + 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, + *, + directory_name: str = DISPOSABLE_DIRECTORY_NAME, +) -> None: + """Remove only the result-scoped disposable tree beneath its managed root.""" + + staging_root = (config.paths.app_root / directory_name).absolute() + validate_disposable_path( + staging_root, + disposable_game, + result.path.name, + directory_name=directory_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, + *, + directory_name: str = DISPOSABLE_DIRECTORY_NAME, +) -> None: + """Reject cleanup targets outside the managed, result-scoped staging root.""" + + if path.parent != staging_root or path.name != expected_name: + raise ExperimentError(f"Unsafe disposable game path: {path}") + if staging_root.name != directory_name: + raise ExperimentError(f"Unsafe disposable staging root: {staging_root}") + + +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 registry_setup_commands( + config: AppConfig, + desktop_name: str, + desktop_size: str, + *, + proton_command: ProtonCommand, +) -> 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, + *, + proton_command: ProtonCommand, +) -> list[list[str]]: + return [ + registry_restore_command( + config, + EXPLORER_KEY, + "Desktop", + snapshot.selected_desktop.exists, + snapshot.selected_desktop.value, + proton_command=proton_command, + ), + registry_restore_command( + config, + DESKTOPS_KEY, + snapshot.desktop_name, + snapshot.desktop_size.exists, + snapshot.desktop_size.value, + proton_command=proton_command, + ), + ] + + +def registry_restore_command( + config: AppConfig, + key: str, + name: str, + existed: bool, + value: str | None, + *, + proton_command: ProtonCommand, +) -> 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, + *, + cleaner: WineserverCleaner = cleanup_dedicated_wineserver, +) -> None: + """Stop the dedicated wineserver while accepting its idle return contract.""" + + outcome = cleaner(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 + ): + 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 diff --git a/src/ptdiag/experiments/evidence.py b/src/ptdiag/experiments/evidence.py new file mode 100644 index 0000000..2da0e1c --- /dev/null +++ b/src/ptdiag/experiments/evidence.py @@ -0,0 +1,257 @@ +"""Evidence collection, serialization, and experiment report generation.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +import os +from pathlib import Path +from typing import Any, Protocol + +from ..config import AppConfig, project_root +from ..filesystem import sha256_file, verify_known_game_hashes +from ..logs import LogFileState +from ..registry import read_registry_string_values +from ..result import ResultDirectory, write_json +from ..testcase import Testcase + + +class HashVerification(Protocol): + def to_dict(self) -> dict[str, Any]: ... + + +HashVerifier = Callable[[Path], Sequence[HashVerification]] +HashFile = Callable[[Path], str] +RegistryReader = Callable[[Path], Mapping[str, Mapping[str, str]]] +JsonWriter = Callable[[Path, Any], None] +TextWriter = Callable[[Path, str], None] +ReportRenderer = Callable[[Mapping[str, Any]], str] + + +def collect_hashes( + config: AppConfig, + testcase: Testcase, + *, + repository_root: Path | None = None, + verifier: HashVerifier = verify_known_game_hashes, + hash_file: HashFile = sha256_file, +) -> list[dict[str, Any]]: + """Collect the immutable inputs needed to interpret one experiment.""" + + root = project_root() if repository_root is None else repository_root + items: list[dict[str, Any]] = [] + for verification in verifier(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", + 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 = (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"] = hash_file(path) + item["ok"] = True + except OSError as error: + item["error"] = str(error) + items.append(item) + return items + + +def collect_dll_overrides( + prefix: Path, + *, + reader: RegistryReader = read_registry_string_values, +) -> dict[str, str]: + sections = reader(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 item: str(item.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 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 finalize_result( + result: ResultDirectory, + summary: dict[str, Any], + *, + json_writer: JsonWriter = write_json, + text_writer: TextWriter, + report_renderer: ReportRenderer, +) -> None: + json_writer(result.path / "summary.json", summary) + text_writer(result.path / "report.md", report_renderer(summary)) + + +def render_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, *, pid: int | None = None) -> None: + """Atomically write normalized UTF-8 text evidence.""" + + path.parent.mkdir(parents=True, exist_ok=True) + process_id = os.getpid() if pid is None else pid + temporary = path.with_name(f".{path.name}.{process_id}.tmp") + temporary.write_text(value.rstrip("\n") + "\n", encoding="utf-8") + temporary.replace(path) diff --git a/src/ptdiag/experiments/execution.py b/src/ptdiag/experiments/execution.py new file mode 100644 index 0000000..7589c58 --- /dev/null +++ b/src/ptdiag/experiments/execution.py @@ -0,0 +1,332 @@ +"""Process execution and runtime observation for diagnostic experiments.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime +import os +from pathlib import Path +import shutil +import signal +import subprocess +import time +from typing import Any + +from ..config import AppConfig +from ..logs import ( + LogFileState, + discover_current_native_logs, + parse_current_run_log, + read_current_run_log_text, + snapshot_native_logs, +) +from ..performance import PerformanceSampler +from ..result import ResultDirectory, write_json +from ..runner import CommandLogger +from ..testcase import Testcase +from .evidence import serialize_log_state, write_text +from .log_selection import ( + NativeLogObservation, + NativeLogSelection, + capture_current_native_logs, + native_log_priority, + observe_current_native_logs, + scene_import_survived, +) +from .models import ExperimentError + + +ProcessFactory = Callable[..., subprocess.Popen[str]] +ProcessCapture = Callable[[Testcase, Path], dict[str, Any] | None] +ProcessStopper = Callable[[subprocess.Popen[str]], None] +PerformanceSamplerFactory = Callable[[int, Path], PerformanceSampler] +JsonWriter = Callable[[Path, Any], None] +TextWriter = Callable[[Path, str], None] +EnvironmentFactory = Callable[[], dict[str, str]] +LogObserver = Callable[..., Sequence[NativeLogObservation]] +LogCapture = Callable[..., NativeLogSelection] +LogStateSerializer = Callable[[LogFileState], dict[str, Any]] +LogPrioritizer = Callable[ + [LogFileState, Mapping[str, Any]], + tuple[int, int, int, int, str], +] + + +def game_processes(*, proc_root: Path = Path("/proc")) -> list[int]: + """Return the matching game process IDs visible under one proc tree.""" + + result: list[int] = [] + for entry in proc_root.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 capture_game_process( + testcase: Testcase, + result_directory: Path, + *, + game_process_scan: Callable[[], list[int]] = game_processes, + proc_root: Path = Path("/proc"), + copy_file: Callable[[Path, Path], str] = shutil.copyfile, +) -> dict[str, Any] | None: + """Capture the first matching process identity and optional memory map.""" + + processes = game_process_scan() + if not processes: + return None + pid = processes[0] + root = proc_root / 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: + copy_file(root / "maps", result_directory / "process-maps.txt") + except OSError as exc: + identity["capture_error"] = str(exc) + return identity + + +def stop_process_group( + process: subprocess.Popen[str], + *, + kill_group: Callable[[int, int], None] = os.killpg, + signals: Sequence[tuple[int, float]] = ( + (signal.SIGINT, 5.0), + (signal.SIGTERM, 5.0), + (signal.SIGKILL, 3.0), + ), +) -> None: + """Stop a launcher process group with bounded escalation.""" + + if process.poll() is not None: + return + for signum, timeout in signals: + try: + kill_group(process.pid, signum) + except ProcessLookupError: + return + try: + process.wait(timeout=timeout) + return + except subprocess.TimeoutExpired: + continue + + +def _current_time() -> datetime: + return datetime.now().astimezone() + + +def _copy_environment() -> dict[str, str]: + return os.environ.copy() + + +def run_and_watch( + config: AppConfig, + testcase: Testcase, + result: ResultDirectory, + logger: CommandLogger, + argv: Sequence[str], + env_overrides: Mapping[str, str], + baseline: Mapping[str, LogFileState], + *, + popen: ProcessFactory = subprocess.Popen, + clock_ns: Callable[[], int] = time.time_ns, + sleep: Callable[[float], None] = time.sleep, + now: Callable[[], datetime] = _current_time, + environment_factory: EnvironmentFactory = _copy_environment, + json_writer: JsonWriter = write_json, + process_capture: ProcessCapture = capture_game_process, + process_stopper: ProcessStopper = stop_process_group, + performance_sampler_factory: PerformanceSamplerFactory = PerformanceSampler, + log_observer: LogObserver = observe_current_native_logs, + log_capture: LogCapture = capture_current_native_logs, + scene_survived: Callable[..., bool] = scene_import_survived, + serialize_state: LogStateSerializer = serialize_log_state, + text_writer: TextWriter = write_text, + prioritize_log: LogPrioritizer = native_log_priority, + discover_logs: Callable[..., Sequence[LogFileState]] = discover_current_native_logs, + parse_log: Callable[..., Any] = parse_current_run_log, + read_log: Callable[..., str] = read_current_run_log_text, + snapshot_logs: Callable[[Path], Mapping[str, LogFileState]] = snapshot_native_logs, +) -> dict[str, Any]: + """Run one diagnostic process and collect its current-run evidence.""" + + runner_log_path = result.path / "runner.log" + environment = environment_factory() + environment.update(env_overrides) + test_start_ns = clock_ns() + started_at = now() + json_writer( + 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_observation: NativeLogObservation | None = None + 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 = 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 = clock_ns() + if process_identity is None: + process_identity = process_capture(testcase, result.path) + if process_identity is not None and testcase.capture.performance_stats: + try: + performance_sampler = performance_sampler_factory( + 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 observation in log_observer( + config.paths.native_logs, + baseline, + test_start_ns, + discoverer=discover_logs, + parser=parse_log, + reader=read_log, + ): + current_observation = observation + if observation.failure_reason is not None: + failure_state = observation.state + failure_trigger_mtime_ns = observation.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 = observation.failure_reason + 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" + process_stopper(process) + break + if now_ns >= deadline_ns: + timed_out = True + stop_reason = "timeout" + process_stopper(process) + break + sleep(0.1) + if process.poll() is None: + process_stopper(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, + ) + + selection = log_capture( + config.paths.native_logs, + baseline, + test_start_ns, + result.path, + fallback=current_observation, + failure_state=failure_state, + serialize_state=serialize_state, + write_text=text_writer, + prioritize=prioritize_log, + discoverer=discover_logs, + parser=parse_log, + reader=read_log, + snapshotter=snapshot_logs, + ) + current_observation = selection.observation + current_state = current_observation.state if current_observation is not None else None + current_text = current_observation.text if current_observation is not None else "" + current_analysis = ( + current_observation.analysis if current_observation is not None else None + ) + survived = scene_survived(current_analysis, timed_out=timed_out) + 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": now().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": 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": list(selection.captures), + "performance": performance_summary, + "native_log": { + "source": str(current_state.path) if current_state else None, + "state": serialize_state(current_state) if current_state else None, + "current_run_bytes": len(current_text.encode("utf-8")), + }, + } diff --git a/src/ptdiag/experiments/log_selection.py b/src/ptdiag/experiments/log_selection.py new file mode 100644 index 0000000..e1750bb --- /dev/null +++ b/src/ptdiag/experiments/log_selection.py @@ -0,0 +1,212 @@ +"""Current-run native-log observation, capture, and selection.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from ..logs import ( + LogFileState, + discover_current_native_logs, + parse_current_run_log, + read_current_run_log_text, + snapshot_native_logs, +) + + +class NativeLogAnalysis(Protocol): + load_texture_to_material_failed: bool + asset_pack_health_failed: bool + + def to_summary(self) -> dict[str, Any]: ... + + +NativeLogDiscoverer = Callable[ + [Path, Mapping[str, LogFileState], int], + Sequence[LogFileState], +] +NativeLogParser = Callable[ + [LogFileState, Mapping[str, LogFileState]], + NativeLogAnalysis, +] +NativeLogReader = Callable[ + [LogFileState, Mapping[str, LogFileState]], + str, +] +NativeLogSnapshotter = Callable[[Path], Mapping[str, LogFileState]] +LogStateSerializer = Callable[[LogFileState], dict[str, Any]] +NativeLogPrioritizer = Callable[ + [LogFileState, Mapping[str, Any]], + tuple[int, int, int, int, str], +] +TextWriter = Callable[[Path, str], None] + + +@dataclass(frozen=True) +class NativeLogObservation: + state: LogFileState + text: str + analysis: dict[str, Any] + failure_reason: str | None + + +@dataclass(frozen=True) +class NativeLogSelection: + observation: NativeLogObservation | None + captures: tuple[dict[str, Any], ...] + + +def observe_current_native_logs( + log_directory: Path, + baseline: Mapping[str, LogFileState], + test_start_ns: int, + *, + discoverer: NativeLogDiscoverer = discover_current_native_logs, + parser: NativeLogParser = parse_current_run_log, + reader: NativeLogReader = read_current_run_log_text, +) -> tuple[NativeLogObservation, ...]: + """Parse each current-run candidate and identify an actionable failure.""" + + observations: list[NativeLogObservation] = [] + for state in discoverer(log_directory, baseline, test_start_ns): + parsed = parser(state, baseline) + failure_reason = ( + "asset-pack-health" + if parsed.asset_pack_health_failed + else "load-texture-to-material" + if parsed.load_texture_to_material_failed + else None + ) + observations.append( + NativeLogObservation( + state=state, + text=reader(state, baseline), + analysis=parsed.to_summary(), + failure_reason=failure_reason, + ) + ) + return tuple(observations) + + +def capture_current_native_logs( + log_directory: Path, + baseline: Mapping[str, LogFileState], + test_start_ns: int, + result_directory: Path, + *, + fallback: NativeLogObservation | None, + failure_state: LogFileState | None, + serialize_state: LogStateSerializer, + write_text: TextWriter, + prioritize: NativeLogPrioritizer | None = None, + discoverer: NativeLogDiscoverer = discover_current_native_logs, + parser: NativeLogParser = parse_current_run_log, + reader: NativeLogReader = read_current_run_log_text, + snapshotter: NativeLogSnapshotter = snapshot_native_logs, +) -> NativeLogSelection: + """Capture all current-run logs and choose the strongest primary evidence.""" + + captures: list[dict[str, Any]] = [] + candidates: list[NativeLogObservation] = [] + for index, state in enumerate(discoverer(log_directory, baseline, test_start_ns)): + text = reader(state, baseline) + analysis = parser(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_directory + / "native-logs" + / f"{index:02d}-{state.path.name}.current.log" + ) + write_text(captured_path, text) + captures.append( + { + "source": str(state.path), + "state": serialize_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( + NativeLogObservation( + state=state, + text=text, + analysis=analysis, + failure_reason=None, + ) + ) + prioritizer = native_log_priority if prioritize is None else prioritize + selected = ( + max(candidates, key=lambda item: prioritizer(item.state, item.analysis)) + if candidates + else fallback + ) + if failure_state is not None: + final_states = snapshotter(log_directory) + final_state = final_states.get(str(failure_state.path.absolute())) + if final_state is not None: + selected = NativeLogObservation( + state=final_state, + text=reader(final_state, baseline), + analysis=parser(final_state, baseline).to_summary(), + failure_reason=None, + ) + if selected is not None: + write_text(result_directory / "native.log", selected.text) + return NativeLogSelection(observation=selected, captures=tuple(captures)) + + +def native_log_priority( + state: LogFileState, + analysis: Mapping[str, Any], +) -> tuple[int, int, int, int, str]: + """Rank scene-rich engine logs ahead of newer low-signal logs.""" + + 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 scene_import_survived( + analysis: Mapping[str, Any] | None, + *, + timed_out: bool, +) -> bool: + """Return whether the target scene completed without a captured failure.""" + + if analysis is None: + return False + return bool( + analysis.get("whispering_vale") + and not timed_out + and not analysis.get("load_texture_to_material_failed", False) + and not analysis.get("asset_pack_health_failed", False) + and not analysis.get("import_scene_threw", False) + and not analysis.get("failed_import_scenes", []) + ) diff --git a/src/ptdiag/experiments/models.py b/src/ptdiag/experiments/models.py new file mode 100644 index 0000000..15f61b8 --- /dev/null +++ b/src/ptdiag/experiments/models.py @@ -0,0 +1,18 @@ +"""Stable result and error models for controlled diagnostic experiments.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@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.""" diff --git a/src/ptdiag/experiments/preparation.py b/src/ptdiag/experiments/preparation.py new file mode 100644 index 0000000..4df6c49 --- /dev/null +++ b/src/ptdiag/experiments/preparation.py @@ -0,0 +1,624 @@ +"""Safe testcase preparation for controlled diagnostic experiments.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import sys +from typing import Any + +from ..config import AppConfig, project_root +from ..filesystem import sha256_file +from ..ini import IniError, inspect_ini_value +from ..prefix import ( + PrefixSafetyError, + cleanup_dedicated_wineserver, + validate_dedicated_prefix, +) +from ..registry import DesktopRegistrySnapshot +from ..result import ResultDirectory +from ..runner import CommandLogger +from ..testcase import TestIniOverride, TestModification, Testcase +from .cleanup import ( + registry_restore_commands as _registry_restore_commands_impl, + registry_setup_commands as _registry_setup_commands_impl, + require_command as _require_command, + validate_disposable_path as _validate_disposable_path, +) +from .models import ExperimentError + + +_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" + + +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 _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 _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 _preflight( + config: AppConfig, + testcase: Testcase, + environment: Mapping[str, Any], + hashes: list[dict[str, Any]], + *, + game_processes: Callable[[], list[int]], +) -> 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 _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 _registry_setup_commands_impl( + config, + desktop_name, + desktop_size, + proton_command=_proton_command, + ) + + +def _registry_restore_commands( + config: AppConfig, + snapshot: DesktopRegistrySnapshot, +) -> list[list[str]]: + return _registry_restore_commands_impl( + config, + snapshot, + proton_command=_proton_command, + ) diff --git a/ptdiag/filesystem.py b/src/ptdiag/filesystem.py similarity index 100% rename from ptdiag/filesystem.py rename to src/ptdiag/filesystem.py diff --git a/ptdiag/ini.py b/src/ptdiag/ini.py similarity index 100% rename from ptdiag/ini.py rename to src/ptdiag/ini.py diff --git a/src/ptdiag/logs.py b/src/ptdiag/logs.py new file mode 100644 index 0000000..86d3abf --- /dev/null +++ b/src/ptdiag/logs.py @@ -0,0 +1,40 @@ +"""Compatibility façade for launcher-owned native game-log parsing.""" + +from ptlinux.diagnostics.native_logs import ( + DEFAULT_NATIVE_LOG_PATTERNS, + AssetReferenceReport, + LogEvent, + LogFileState, + NativeLogAnalysis, + SceneImport, + classify_asset_references, + discover_current_native_logs, + is_current_run_log, + newest_current_native_log, + normalize_asset_reference, + parse_current_run_log, + parse_native_log, + parse_native_log_lines, + read_current_run_log_text, + snapshot_native_logs, +) + + +__all__ = [ + "DEFAULT_NATIVE_LOG_PATTERNS", + "AssetReferenceReport", + "LogEvent", + "LogFileState", + "NativeLogAnalysis", + "SceneImport", + "classify_asset_references", + "discover_current_native_logs", + "is_current_run_log", + "newest_current_native_log", + "normalize_asset_reference", + "parse_current_run_log", + "parse_native_log", + "parse_native_log_lines", + "read_current_run_log_text", + "snapshot_native_logs", +] diff --git a/ptdiag/performance.py b/src/ptdiag/performance.py similarity index 100% rename from ptdiag/performance.py rename to src/ptdiag/performance.py diff --git a/ptdiag/prefix.py b/src/ptdiag/prefix.py similarity index 100% rename from ptdiag/prefix.py rename to src/ptdiag/prefix.py diff --git a/ptdiag/process.py b/src/ptdiag/process.py similarity index 100% rename from ptdiag/process.py rename to src/ptdiag/process.py diff --git a/ptdiag/registry.py b/src/ptdiag/registry.py similarity index 100% rename from ptdiag/registry.py rename to src/ptdiag/registry.py diff --git a/ptdiag/result.py b/src/ptdiag/result.py similarity index 100% rename from ptdiag/result.py rename to src/ptdiag/result.py diff --git a/ptdiag/runner.py b/src/ptdiag/runner.py similarity index 100% rename from ptdiag/runner.py rename to src/ptdiag/runner.py diff --git a/ptdiag/testcase.py b/src/ptdiag/testcase.py similarity index 88% rename from ptdiag/testcase.py rename to src/ptdiag/testcase.py index 642366b..f6e5058 100644 --- a/ptdiag/testcase.py +++ b/src/ptdiag/testcase.py @@ -7,7 +7,12 @@ from pathlib import Path from typing import Any, Mapping import tomllib -from .config import project_root +from .config import repository_root + + +DEFAULT_TESTCASE_DIRECTORY = Path("diagnostics/testcases") +LEGACY_TESTCASE_DIRECTORY = Path("testcases") +_PATH_ENVIRONMENT_KEYS = frozenset({"DXVK_CONFIG_FILE"}) class TestcaseError(ValueError): @@ -86,7 +91,15 @@ def resolve_testcase_path(value: str | Path) -> Path: path = Path(value) if path.suffix or path.parent != Path("."): return path.expanduser().absolute() - return (project_root() / "testcases" / f"{path.name}.toml").absolute() + root = repository_root() + filename = f"{path.name}.toml" + preferred = root / DEFAULT_TESTCASE_DIRECTORY / filename + legacy = root / LEGACY_TESTCASE_DIRECTORY / filename + if preferred.is_file(): + return preferred.absolute() + if legacy.is_file(): + return legacy.absolute() + return preferred.absolute() def load_testcase(value: str | Path) -> Testcase: @@ -94,7 +107,10 @@ def load_testcase(value: str | Path) -> Testcase: data, sources = _load_inherited(path, ()) game = _section(data, "game") display = _section(data, "display") - environment = _section(data, "env") + environment = _resolve_environment_paths( + _string_mapping(_section(data, "env"), "env"), + path.parent, + ) watch = _section(data, "watch") capture = _section(data, "capture") modification = _optional_section(data, "modification") @@ -114,7 +130,7 @@ def load_testcase(value: str | Path) -> Testcase: virtual_desktop=_boolean(display, "virtual_desktop"), size=_string(display, "size"), ), - env=_string_mapping(environment, "env"), + env=environment, watch=TestWatch( require_new_native_log=_boolean(watch, "require_new_native_log"), ), @@ -149,6 +165,25 @@ def load_testcase(value: str | Path) -> Testcase: ) +def _resolve_environment_paths( + environment: Mapping[str, str], + testcase_directory: Path, +) -> dict[str, str]: + result = dict(environment) + for key in _PATH_ENVIRONMENT_KEYS: + value = result.get(key) + if value is None: + continue + path = Path(value).expanduser() + if path.is_absolute(): + result[key] = str(path) + continue + candidate = (testcase_directory / path).resolve(strict=False) + if candidate.is_file(): + result[key] = str(candidate) + return result + + def _load_inherited( path: Path, stack: tuple[Path, ...], diff --git a/ptdiag/trace.py b/src/ptdiag/trace.py similarity index 100% rename from ptdiag/trace.py rename to src/ptdiag/trace.py diff --git a/ptlinux/__init__.py b/src/ptlinux/__init__.py similarity index 100% rename from ptlinux/__init__.py rename to src/ptlinux/__init__.py diff --git a/ptlinux/__main__.py b/src/ptlinux/__main__.py similarity index 100% rename from ptlinux/__main__.py rename to src/ptlinux/__main__.py diff --git a/src/ptlinux/application/__init__.py b/src/ptlinux/application/__init__.py new file mode 100644 index 0000000..0bcaba6 --- /dev/null +++ b/src/ptlinux/application/__init__.py @@ -0,0 +1,2 @@ +"""Application workflows shared by the CLI and dashboard entry points.""" + diff --git a/src/ptlinux/application/commands/__init__.py b/src/ptlinux/application/commands/__init__.py new file mode 100644 index 0000000..ba44c1b --- /dev/null +++ b/src/ptlinux/application/commands/__init__.py @@ -0,0 +1,2 @@ +"""Feature-oriented command workflows.""" + diff --git a/src/ptlinux/application/commands/health.py b/src/ptlinux/application/commands/health.py new file mode 100644 index 0000000..125c4ff --- /dev/null +++ b/src/ptlinux/application/commands/health.py @@ -0,0 +1,190 @@ +"""Host health, runtime bootstrap, and login diagnostic commands.""" + +from __future__ import annotations + +from dataclasses import asdict +import sys +from typing import Any + +from ...container_host import ContainerHostReport +from ...launcher import LauncherError +from ...paths import AppPaths + + +def doctor(api: Any, as_json: bool) -> int: + report = api.inspect_host() + if as_json: + api._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(api: Any, repair: bool, as_json: bool) -> int: + report = api.repair_container_host() if repair else api.inspect_container_host() + ok = container_host_ok(report) + payload = {"ok": ok, "container_host": report.to_dict()} + if as_json: + api._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(api: Any, paths: AppPaths) -> None: + report = api.repair_container_host_after_update(paths) + if report.repair_attempted and not container_host_ok(report): + print(f"Warning: {api.container_host_error(report)}", file=sys.stderr) + + +def runtime_bootstrap(api: Any, paths: AppPaths, as_json: bool) -> int: + api._print_operation_status(as_json, 1, 1, "Preparing the Steam Runtime helper…") + result = api.provision_umu(paths) + executable = api.find_umu_launcher(paths) + if executable is None: + raise LauncherError("the Steam Runtime helper is still unavailable") + api._emit( + { + "ok": True, + "runtime_bootstrap": { + "executable": executable, + "managed_download": asdict(result) if result else None, + }, + }, + as_json, + ) + return 0 + + +def login_diagnostics( + api: Any, + paths: AppPaths, + timeout_seconds: float, + runtime_choice: str, + as_json: bool, +) -> int: + settings = api._load_or_default_settings(paths, persist=False) + runner = api._runner(paths, "login-diagnostics", False) + with api._operation_guard(paths, "login-diagnostics", False): + report = api.inspect_login_environment( + paths, + settings, + timeout_seconds=timeout_seconds, + runner=runner, + runtime_override=( + None if runtime_choice == "configured" else runtime_choice + ), + ) + if as_json: + api._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 diff --git a/src/ptlinux/application/commands/installation.py b/src/ptlinux/application/commands/installation.py new file mode 100644 index 0000000..1f81e73 --- /dev/null +++ b/src/ptlinux/application/commands/installation.py @@ -0,0 +1,479 @@ +"""Download, setup, depot synchronization, import, and prepare commands.""" + +from __future__ import annotations + +from dataclasses import asdict +import json +from pathlib import Path +import time +from typing import Any + +from ...command import CommandOutcome, CommandRunner +from ...depot import DownloadOptions, SyncProgress +from ...importer import GameImportResult +from ...launcher import LauncherError, WineTools +from ...paths import AppPaths +from ...settings import Settings + + +def fetch(api: Any, 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": api.OFFICIAL_LAUNCHER_URL, + "download": str(paths.launcher_pristine), + "prepared": str(paths.launcher_patched), + } + else: + telemetry = api.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 = api.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": api.classify_network_failure(exc), + "http_status": api.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 = api.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." + ), + } + api._emit(value, as_json) + return 0 + + +def setup( + api: Any, + paths: AppPaths, + dry_run: bool, + force_runtime: bool, + as_json: bool, +) -> int: + tools = api.find_wine_tools() + if tools is None: + raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor") + runner = api._runner(paths, "setup", dry_run) + with api._operation_guard(paths, "setup", dry_run): + results = api._configure_launcher_runtime( + paths, + tools, + runner, + force_runtime=force_runtime, + ) + api._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( + api: Any, + 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 = api._load_or_default_settings(paths, persist=not dry_run) + download_options = api._configured_download_options(settings) + runner = api._runner(paths, "native-update", dry_run) + with api._operation_guard(paths, "native-update", dry_run): + api._print_operation_status(as_json, 1, 3, "Checking local game files…") + game_import = None + if not no_import: + game_import = api._maybe_import_game( + paths, + settings, + seed_from, + runner, + announce=not as_json, + ) + verify_import = game_import is not None or api.game_import_requires_verification(paths) + api._print_operation_status(as_json, 2, 3, "Synchronizing official game files…") + result = api.sync_managed_game( + paths, + repair=repair or verify_import, + quarantine_unmanaged=verify_import, + dry_run=dry_run, + progress=None if as_json else api._print_sync_progress, + **( + {"download_options": download_options} + if download_options is not None + else {} + ), + ) + if verify_import and not dry_run: + api.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(): + api._print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…") + overlay = asdict(api.prepare_runtime_view(paths, paths.source_game)) + api._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( + api: Any, + paths: AppPaths, + dry_run: bool, + force_runtime: bool, + seed_from: Path | None, + no_import: bool, + as_json: bool, +) -> int: + api._fetch(paths, dry_run, True) + tools = api.find_wine_tools() + if tools is None: + raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor") + runner = api._runner(paths, "update", dry_run) + settings = api._load_or_default_settings(paths, persist=not dry_run) + download_options = api._configured_download_options(settings) + with api._operation_guard(paths, "update", dry_run): + api._configure_launcher_runtime( + paths, + tools, + runner, + force_runtime=force_runtime, + ) + game_import = None + if not no_import: + game_import = api._maybe_import_game( + paths, + settings, + seed_from, + runner, + announce=not as_json, + ) + updater = api.launch_official_updater(paths, tools, runner) + native_verification = None + verify_import = not dry_run and ( + game_import is not None or api.game_import_requires_verification(paths) + ) + if verify_import: + native_verification = api.sync_managed_game( + paths, + repair=True, + quarantine_unmanaged=True, + progress=None if as_json else api._print_sync_progress, + **( + {"download_options": download_options} + if download_options is not None + else {} + ), + ) + api.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(api.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, + } + api._emit(value, as_json) + return 0 + + +def configure_launcher_runtime( + api: Any, + paths: AppPaths, + tools: WineTools, + runner: CommandRunner, + *, + force_runtime: bool, +) -> list[CommandOutcome]: + if not force_runtime or runner.dry_run: + return api.configure_launcher_prefix( + paths, + tools, + runner, + force_runtime=force_runtime, + ) + telemetry = api.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 = api.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( + api: Any, + paths: AppPaths, + source: Path, + dry_run: bool, + as_json: bool, +) -> int: + runner = api._runner(paths, "import-game", dry_run) + if not as_json: + print(f"Importing existing game from {source.expanduser()}…", flush=True) + with api._operation_guard(paths, "import-game", dry_run): + result = api._import_with_telemetry(paths, source, runner) + api._emit({"ok": True, "dry_run": dry_run, "game_import": asdict(result)}, as_json) + return 0 + + +def maybe_import_game( + api: Any, + 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 = api.discover_existing_game(paths, settings) + if candidate is None: + return None + if announce: + print(f"Bootstrapping from existing game: {candidate}", flush=True) + return api._import_with_telemetry(paths, candidate, runner) + + +def import_with_telemetry( + api: Any, + paths: AppPaths, + source: Path, + runner: CommandRunner, +) -> GameImportResult: + telemetry = api.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 = api.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(api: Any, paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: + with api._operation_guard(paths, "prepare", False): + if game_dir is None and api.game_import_requires_verification(paths): + raise LauncherError( + "the imported game has not completed its full publisher verification; " + "run pristontale-eu update" + ) + settings = api.ensure_settings(paths) + source = api._source_game(paths, settings, game_dir) + result = api.prepare_runtime_view(paths, source) + api._emit({"ok": True, "runtime_view": asdict(result)}, as_json) + return 0 + diff --git a/src/ptlinux/application/commands/launcher_update.py b/src/ptlinux/application/commands/launcher_update.py new file mode 100644 index 0000000..27334ec --- /dev/null +++ b/src/ptlinux/application/commands/launcher_update.py @@ -0,0 +1,138 @@ +"""Launcher self-update command workflow.""" + +from __future__ import annotations + +from dataclasses import asdict +from pathlib import Path +import sys +from typing import Any + +from ...paths import AppPaths +from ...umu import UmuBootstrapResult +from ...update_resolver import STABLE_CHANNEL + + +def launcher_update( + api: Any, + 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 = api.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 = api.rollback_launcher_update( + paths, + target_version=target_version, + selected_channel=selected_channel, + history_url=history_url, + public_key_path=public_key, + ) + api._emit( + { + "ok": True, + "launcher_rollback": asdict(result), + }, + as_json, + ) + return 0 + if check_only: + check = api.check_for_launcher_update( + manifest_url=manifest_url, + public_key_path=public_key, + channel=selected_channel, + ) + api._emit( + { + "ok": True, + "launcher_update": api.update_check_to_dict(check), + }, + as_json, + ) + return 0 + if verify: + api._print_operation_status(as_json, 1, 2, "Authenticating the launcher release…") + result = api.verify_launcher_update_channel( + manifest_url=manifest_url, + public_key_path=public_key, + channel=selected_channel, + ) + api._print_operation_status(as_json, 2, 2, "Published launcher release verified.") + api._emit( + { + "ok": True, + "launcher_update_verification": asdict(result), + }, + as_json, + ) + return 0 + api._print_operation_status(as_json, 1, 4, "Verifying the signed launcher release…") + result = api.install_launcher_update( + paths, + manifest_url=manifest_url, + public_key_path=public_key, + channel=selected_channel, + ) + api._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 = api.provision_umu(paths) + umu_executable = api.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) + api._print_operation_status( + as_json, + 3, + 4, + "Checking Steam Runtime sandbox permissions (authentication may be requested)…", + ) + container_report = api.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) + api._print_operation_status(as_json, 4, 4, "Launcher update and runtime check complete.") + api._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 diff --git a/src/ptlinux/application/commands/play.py b/src/ptlinux/application/commands/play.py new file mode 100644 index 0000000..2683593 --- /dev/null +++ b/src/ptlinux/application/commands/play.py @@ -0,0 +1,938 @@ +"""Game launch, runtime outcome, log, and crash reporting commands.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import asdict, replace +from datetime import datetime +from pathlib import Path +import re +import signal +import sys +import threading +import time +from typing import Any +from uuid import uuid4 + +from ...command import CommandOutcome +from ...diagnostics.native_logs import LogFileState +from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm +from ...launcher import LauncherError +from ...login_trace import LOGIN_TRACE_DISABLED_REASON +from ...paths import AppPaths +from ...settings import Settings +from ...umu import UmuBootstrapResult +from ...window_diagnostics import ( + WINDOW_DIAGNOSTICS_ENV, + WINDOW_DIAGNOSTICS_SESSION_ENV, +) + + +def play( + api: Any, + 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 api._play_guard(paths, dry_run) as play_lease: + return api._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( + api: Any, + 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 = api.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 api.game_import_requires_verification(paths): + raise LauncherError( + "the imported game has not completed its full publisher verification; " + "run pristontale-eu update before playing" + ) + settings = api._load_or_default_settings(paths, persist=not dry_run) + launcher_nested_in_steam_runtime = api.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 = api.select_depot_start_option( + api.read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache), + realm or settings.realm, + ) + settings = runtime_settings_for_realm(settings, start_option.name) + source = api._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 = api.read_current_depot_file_catalog(paths) + api._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 = api.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 = api.prepare_runtime_view(paths, source) + api._print_operation_status(as_json, 2, 4, "Ensuring the compatibility runtime is available…") + umu_bootstrap = ( + None + if reuse_prepared + else api._ensure_portable_umu_for_play(paths, settings, dry_run=dry_run) + ) + selection = api.select_runtime(settings, paths=paths) + runner = api._runner(paths, "play", dry_run) + setup_results = [] + if reuse_prepared: + api._print_operation_status( + as_json, + 3, + 4, + "Reusing the active Proton and DirectX runtime…", + ) + if not api.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: + api._print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…") + setup_results = api.prepare_game_runtime(paths, selection, runner) + else: + api._print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…") + api._print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…") + native_log_directory = paths.game_save / "logs" + native_log_baseline = api.snapshot_native_logs( + native_log_directory, + patterns=("log_*.txt",), + ) + crash_directory = paths.game_save / "crash" + crash_baseline = api.snapshot_native_logs( + crash_directory, + patterns=("*.dat", "*.dmp"), + ) + game_started_ns = time.time_ns() + game_started_at = time.monotonic() + launch_success_lock = threading.Lock() + # CommandRunner keeps a bounded output tail. Preserve readiness here so a + # long, noisy session cannot evict the evidence and become a false failure. + observed_readiness_signal: str | None = None + 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 observed_readiness_signal, launch_success_reported + presentation_failure = api._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 = api.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=api._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 = api._game_readiness_signal(line) + if live_readiness_signal is None: + return line + with launch_success_lock: + if observed_readiness_signal is None: + observed_readiness_signal = live_readiness_signal + if telemetry is None or dry_run or 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": observed_readiness_signal, + "runtime": selection.kind, + "realm": start_option.name, + }, + context={"operation": "launch_game", "stage": "ready"}, + ) + return line + + try: + outcome = api.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: + game_runtime_seconds = max(0.0, time.monotonic() - game_started_at) + current_game_log = api._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, + ) + launch_evidence = observed_readiness_signal + if launch_evidence is None and current_game_log is not None: + launch_evidence = "game_log" + if telemetry is not None and not dry_run: + api._report_game_launch_cancelled( + telemetry, + launch_requested_at, + stage="running" if launch_evidence is not None else "game_start", + metadata={ + **launch_session_metadata, + **window_launch_metadata, + "runtime_seconds": round(game_runtime_seconds), + "clean_exit": False, + "launch_succeeded": launch_evidence is not None, + "game_window_ready": observed_readiness_signal is not None, + "readiness_signal": launch_evidence, + "session_duration_class": api._session_duration_class( + game_runtime_seconds + ), + "runtime": selection.kind, + "realm": start_option.name, + }, + ) + if current_game_log is not None and not as_json: + api._print_interrupted_game_log(current_game_log) + raise api._GameLaunchInterruptionReported from None + current_game_log = api._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 = api._current_game_crash( + crash_directory, + crash_baseline, + game_started_ns, + ) + runtime_issues = api._runtime_issue_summary(outcome.stderr) + retained_readiness_signal = api._game_readiness_signal(outcome.stderr) + game_readiness_signal = observed_readiness_signal or retained_readiness_signal + game_window_ready = game_readiness_signal is not None + runtime_issues["game_window_ready"] = game_window_ready + if game_readiness_signal is not None: + runtime_issues["game_readiness_signal"] = game_readiness_signal + 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_evidence = game_readiness_signal + if launch_evidence is None and current_game_log is not None: + launch_evidence = "game_log" + elif launch_evidence is None and clean_exit: + launch_evidence = "clean_process_exit" + launch_succeeded = launch_evidence 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, + "readiness_signal": launch_evidence, + "crash_dump": current_game_crash is not None, + "session_duration_class": api._session_duration_class(game_runtime_seconds), + "runtime": selection.kind, + "realm": start_option.name, + **api._game_exit_metadata(outcome.returncode, selection.kind), + } + if launch_succeeded: + if not launch_success_reported: + assert launch_evidence is not None + 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": launch_evidence, + "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: + api._emit(report, as_json) + else: + api._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( + api: Any, + 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 api.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: + api.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( + api: Any, + 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 = api.newest_current_native_log( + directory, + baseline, + started_ns, + patterns=("log_*.txt",), + ) + if state is None: + return None + analysis = api.parse_current_run_log(state, baseline) + asset_references = api.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( + api: Any, + directory: Path, + baseline: dict[str, LogFileState], + started_ns: int, +) -> dict[str, object] | None: + state = api.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) diff --git a/src/ptlinux/application/commands/settings.py b/src/ptlinux/application/commands/settings.py new file mode 100644 index 0000000..66e8640 --- /dev/null +++ b/src/ptlinux/application/commands/settings.py @@ -0,0 +1,32 @@ +"""Launcher settings command workflow.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from ...paths import AppPaths + + +def settings( + api: Any, + paths: AppPaths, + initialize: bool, + reset: bool, + as_json: bool, +) -> int: + if reset: + settings = api.reset_settings(paths) + elif initialize: + settings = api.ensure_settings(paths) + else: + settings = api.load_settings(paths) + api._emit( + { + "ok": True, + "path": str(paths.settings_file), + "settings": asdict(settings), + }, + as_json, + ) + return 0 diff --git a/src/ptlinux/application/common.py b/src/ptlinux/application/common.py new file mode 100644 index 0000000..222a8d7 --- /dev/null +++ b/src/ptlinux/application/common.py @@ -0,0 +1,245 @@ +"""Shared CLI telemetry, result reporting, and output helpers.""" + +from __future__ import annotations + +import argparse +import json +import signal +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from ..command import CommandError +from ..paths import AppPaths +from ..telemetry import TelemetryService + + +CommandTelemetryEvents = tuple[str, str, str, str] + + +@dataclass(frozen=True, slots=True) +class CommandTelemetryLifecycle: + telemetry: TelemetryService | None + events: CommandTelemetryEvents | None + started_at: float + owner: bool + + +def start_command_telemetry( + api: Any, + paths: AppPaths, + command: str, + arguments: argparse.Namespace, + manage_telemetry: bool, +) -> CommandTelemetryLifecycle: + telemetry: TelemetryService | None = None + telemetry_owner = False + if manage_telemetry: + interface = "gui" if command == "gui" else "cli" + telemetry, telemetry_owner = api.initialize_shared_telemetry( + paths, + session_metadata={"interface": interface, "command": command}, + report_launcher_lifecycle=command == "gui", + ) + events = api._command_telemetry_events(command, arguments, paths) + started_at = time.monotonic() + if telemetry is not None and events is not None: + operation, started_event, _success_event, _failed_event = 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"}, + ) + return CommandTelemetryLifecycle( + telemetry=telemetry, + events=events, + started_at=started_at, + owner=telemetry_owner, + ) + + +def shutdown_command_telemetry( + api: Any, + lifecycle: CommandTelemetryLifecycle, +) -> None: + if lifecycle.telemetry is not None and lifecycle.owner: + api.shutdown_shared_telemetry(lifecycle.telemetry) + + +def command_telemetry_events( + command: str, + arguments: argparse.Namespace, + paths: AppPaths, +) -> CommandTelemetryEvents | 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: CommandTelemetryEvents | 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: CommandTelemetryEvents | 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": stage}, + ) + 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 report_game_launch_cancelled( + telemetry: TelemetryService | None, + started_at: float, + *, + stage: str, + metadata: dict[str, object] | None = None, +) -> None: + if telemetry is None: + return + event_metadata: dict[str, object] = { + "duration_ms": round((time.monotonic() - started_at) * 1000), + "interrupted": True, + "launcher_exit_code": 130, + "signal": int(signal.SIGINT), + "signal_name": signal.SIGINT.name, + } + if metadata is not None: + event_metadata.update(metadata) + telemetry.send_event( + "game_launch_cancelled", + severity="info", + message="Game launch was cancelled", + metadata=event_metadata, + context={"operation": "launch_game", "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", "writable") + ): + return "filesystem" + if any(value in message for value in ("prefix", "wine", "proton", "umu")): + return "runtime_check" + return f"{command}_operation" + + +def emit( + value: object, + as_json: bool, + *, + json_printer: Callable[[object], None], +) -> None: + if as_json: + json_printer(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/changelog.py b/src/ptlinux/changelog.py similarity index 100% rename from ptlinux/changelog.py rename to src/ptlinux/changelog.py diff --git a/src/ptlinux/cli.py b/src/ptlinux/cli.py new file mode 100644 index 0000000..a6e0299 --- /dev/null +++ b/src/ptlinux/cli.py @@ -0,0 +1,918 @@ +"""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 +from typing import Callable, Sequence +from uuid import uuid4 + +from .diagnostics.native_logs import ( + LogFileState, + classify_asset_references, + newest_current_native_log, + parse_current_run_log, + snapshot_native_logs, +) + +from . import __version__ +from .application import common as _application_helpers +from .application.commands import health as _health_commands +from .application.commands import installation as _installation_commands +from .application.commands import launcher_update as _launcher_update_commands +from .application.commands import play as _play_commands +from .application.commands import settings as _settings_commands +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 ( + 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, +) + + +class _GameLaunchInterruptionReported(KeyboardInterrupt): + """Mark an in-game interrupt whose cancellation telemetry was already sent.""" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="pristontale-eu", + description="PristonTale EU all-in-one Linux installer and clean launcher", + ) + # Python 3.14 enables ANSI-colored argparse output by default. Keep the CLI's + # help and error text stable for redirected output and downstream tooling. + if hasattr(parser, "color"): + parser.color = False + 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_lifecycle = _start_command_telemetry( + sys.modules[__name__], + paths, + command, + arguments, + manage_telemetry, + ) + telemetry = telemetry_lifecycle.telemetry + telemetry_events = telemetry_lifecycle.events + operation_started = telemetry_lifecycle.started_at + try: + returncode = _dispatch_command(parser, arguments, command, paths) + except KeyboardInterrupt as exc: + if command == "play": + if ( + not isinstance(exc, _GameLaunchInterruptionReported) + and not bool(getattr(arguments, "dry_run", False)) + ): + _report_game_launch_cancelled( + telemetry, + operation_started, + stage="preflight", + ) + else: + _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: + _shutdown_command_telemetry(sys.modules[__name__], telemetry_lifecycle) + + +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}") + + +_command_telemetry_events = _application_helpers.command_telemetry_events +_start_command_telemetry = _application_helpers.start_command_telemetry +_shutdown_command_telemetry = _application_helpers.shutdown_command_telemetry +_report_command_result = _application_helpers.report_command_result +_report_command_failure = _application_helpers.report_command_failure +_report_game_launch_cancelled = _application_helpers.report_game_launch_cancelled +_failure_stage = _application_helpers.failure_stage +def _doctor(as_json: bool) -> int: + return _health_commands.doctor(sys.modules[__name__], as_json) + + +def _container_host(repair: bool, as_json: bool) -> int: + return _health_commands.container_host(sys.modules[__name__], repair, as_json) + + +_container_host_ok = _health_commands.container_host_ok + + +def _container_host_startup_migration(paths: AppPaths) -> None: + _health_commands.container_host_startup_migration(sys.modules[__name__], paths) + + +def _runtime_bootstrap(paths: AppPaths, as_json: bool) -> int: + return _health_commands.runtime_bootstrap(sys.modules[__name__], paths, as_json) + + +def _login_diagnostics( + paths: AppPaths, + timeout_seconds: float, + runtime_choice: str, + as_json: bool, +) -> int: + return _health_commands.login_diagnostics( + sys.modules[__name__], + paths, + timeout_seconds, + runtime_choice, + as_json, + ) + +def _fetch(paths: AppPaths, dry_run: bool, as_json: bool) -> int: + return _installation_commands.fetch( + sys.modules[__name__], + paths, + dry_run, + as_json, + ) + + +def _setup( + paths: AppPaths, + dry_run: bool, + force_runtime: bool, + as_json: bool, +) -> int: + return _installation_commands.setup( + sys.modules[__name__], + paths, + dry_run, + force_runtime, + as_json, + ) + + +def _native_update( + paths: AppPaths, + dry_run: bool, + repair: bool, + seed_from: Path | None, + no_import: bool, + as_json: bool, +) -> int: + return _installation_commands.native_update( + sys.modules[__name__], + paths, + dry_run, + repair, + seed_from, + no_import, + as_json, + ) + + +def _wine_update( + paths: AppPaths, + dry_run: bool, + force_runtime: bool, + seed_from: Path | None, + no_import: bool, + as_json: bool, +) -> int: + return _installation_commands.wine_update( + sys.modules[__name__], + paths, + dry_run, + force_runtime, + seed_from, + no_import, + as_json, + ) + + +def _configure_launcher_runtime( + paths: AppPaths, + tools: WineTools, + runner: CommandRunner, + *, + force_runtime: bool, +) -> list[CommandOutcome]: + return _installation_commands.configure_launcher_runtime( + sys.modules[__name__], + paths, + tools, + runner, + force_runtime=force_runtime, + ) + + +_print_sync_progress = _installation_commands.print_sync_progress +_configured_download_options = _installation_commands.configured_download_options +_print_operation_status = _installation_commands.print_operation_status + + +def _import_game( + paths: AppPaths, + source: Path, + dry_run: bool, + as_json: bool, +) -> int: + return _installation_commands.import_game( + sys.modules[__name__], + paths, + source, + dry_run, + as_json, + ) + + +def _maybe_import_game( + paths: AppPaths, + settings: Settings, + seed_from: Path | None, + runner: CommandRunner, + *, + announce: bool, +) -> GameImportResult | None: + return _installation_commands.maybe_import_game( + sys.modules[__name__], + paths, + settings, + seed_from, + runner, + announce=announce, + ) + + +def _import_with_telemetry( + paths: AppPaths, + source: Path, + runner: CommandRunner, +) -> GameImportResult: + return _installation_commands.import_with_telemetry( + sys.modules[__name__], + paths, + source, + runner, + ) + + +def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: + return _installation_commands.prepare( + sys.modules[__name__], + paths, + game_dir, + as_json, + ) +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: + return _play_commands.play( + sys.modules[__name__], + paths, + game_dir, + no_box, + dry_run, + skip_runtime_setup, + as_json, + realm=realm, + ) + + +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: + return _play_commands.play_locked( + sys.modules[__name__], + paths, + game_dir, + no_box, + dry_run, + skip_runtime_setup, + as_json, + realm=realm, + reuse_prepared=reuse_prepared, + before_game=before_game, + game_lease_fds=game_lease_fds, + ) + + +_session_duration_class = _play_commands.session_duration_class +_game_exit_metadata = _play_commands.game_exit_metadata +_signal_name = _play_commands.signal_name + + +def _ensure_portable_umu_for_play( + paths: AppPaths, + settings: Settings, + *, + dry_run: bool, +) -> UmuBootstrapResult | None: + return _play_commands.ensure_portable_umu_for_play( + sys.modules[__name__], + paths, + settings, + dry_run=dry_run, + ) + + +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: + return _play_commands.current_game_log( + sys.modules[__name__], + directory, + baseline, + started_ns, + managed_asset_paths=managed_asset_paths, + source_game=source_game, + ) + + +def _current_game_crash( + directory: Path, + baseline: dict[str, LogFileState], + started_ns: int, +) -> dict[str, object] | None: + return _play_commands.current_game_crash( + sys.modules[__name__], + directory, + baseline, + started_ns, + ) + + +_runtime_issue_summary = _play_commands.runtime_issue_summary +_gamescope_presentation_failure = _play_commands.gamescope_presentation_failure +_game_window_was_ready = _play_commands.game_window_was_ready +_game_readiness_signal = _play_commands.game_readiness_signal +_window_diagnostic_severity = _play_commands.window_diagnostic_severity +_print_game_outcome = _play_commands.print_game_outcome +_print_interrupted_game_log = _play_commands.print_interrupted_game_log +_login_trace = _play_commands.login_trace +def _settings( + paths: AppPaths, + initialize: bool, + reset: bool, + as_json: bool, +) -> int: + return _settings_commands.settings( + sys.modules[__name__], + paths, + initialize, + reset, + as_json, + ) + + +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: + return _launcher_update_commands.launcher_update( + sys.modules[__name__], + paths, + check_only, + verify, + rollback, + manifest_url, + public_key, + as_json, + history_url=history_url, + ) +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: + _application_helpers.emit(value, as_json, json_printer=_print_json) + + +def _print_json(value: object) -> None: + _application_helpers.print_json(value) diff --git a/ptlinux/command.py b/src/ptlinux/command.py similarity index 93% rename from ptlinux/command.py rename to src/ptlinux/command.py index acf5874..1495b8b 100644 --- a/ptlinux/command.py +++ b/src/ptlinux/command.py @@ -342,15 +342,22 @@ def _finish_stream_relays( stdout_thread: threading.Thread, stderr_thread: threading.Thread, ) -> None: - """Bound relay cleanup so a second interrupt can never be required.""" + """Bound relay cleanup even when an orphan inherits the output pipes.""" stdout_thread.join(timeout=2.0) stderr_thread.join(timeout=2.0) - for stream in (process.stdout, process.stderr): - if stream is not None: + for stream, relay_thread in ( + (process.stdout, stdout_thread), + (process.stderr, stderr_thread), + ): + # TextIOWrapper.close() waits for an active reader's internal lock. Wine + # and Gamescope helpers can outlive their direct parent while retaining a + # standard-output descriptor, leaving readline() blocked even though the + # supervised process has exited. The play command itself is short-lived, + # so let its daemon relay leave with the process instead of blocking the + # game-completion event indefinitely. + if stream is not None and not relay_thread.is_alive(): try: stream.close() except OSError: pass - stdout_thread.join(timeout=0.25) - stderr_thread.join(timeout=0.25) diff --git a/ptlinux/container_host.py b/src/ptlinux/container_host.py similarity index 100% rename from ptlinux/container_host.py rename to src/ptlinux/container_host.py diff --git a/ptlinux/dashboard_debug.py b/src/ptlinux/dashboard_debug.py similarity index 100% rename from ptlinux/dashboard_debug.py rename to src/ptlinux/dashboard_debug.py diff --git a/src/ptlinux/depot.py b/src/ptlinux/depot.py new file mode 100644 index 0000000..39fce99 --- /dev/null +++ b/src/ptlinux/depot.py @@ -0,0 +1,1270 @@ +"""Native, verified updater for the public PristonTale EU depot.""" + +from __future__ import annotations + +from collections import deque +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from dataclasses import asdict, dataclass +from datetime import datetime +import json +import os +from pathlib import Path +import re +import threading +import time +from typing import Callable, Iterable, Mapping +from urllib.parse import urlparse +from urllib.request import urlopen + +from . import __version__ +from .game.depot.catalogs import ( + DEPOT_FILE_CATALOG_SCHEMA, + DEPOT_LAUNCH_CATALOG_SCHEMA, + depot_file_catalog_payload as _depot_file_catalog_payload, + launch_catalog_from_snapshot, + launch_catalog_payload as _launch_catalog_payload, + parse_depot_file_catalog as _parse_depot_file_catalog, + parse_depot_file_catalog_entry as _parse_depot_file_catalog_entry, + parse_launch_catalog as _parse_launch_catalog, + select_depot_start_option, +) +from .game.depot.manifest import ( + MAX_CREATE_IF_MISSING_BYTES, + SHA1_PATTERN, + decode_create_if_missing as _decode_create_if_missing, + non_negative_int as _non_negative_int, + normalize_relative_path as _normalise_relative_path, + parse_create_if_missing as _parse_create_if_missing, + parse_deleted_paths as _parse_deleted_paths, + parse_snapshot as _parse_snapshot, + parse_start_options as _parse_start_options, + positive_int as _positive_int, + relative_parts as _relative_parts, + required_string as _string, + start_option_text as _start_option_text, +) +from .game.depot.integrity import ( + ScannedGameEntry as _ScannedGameEntry, + build_depot_file_catalog as _build_depot_file_catalog, + catalog_entry as _catalog_entry, + catalog_entry_from_cached as _catalog_entry_from_cached, + catalog_identity_matches as _catalog_identity_matches, + inspect_managed_game as _inspect_managed_game, + plan_update as _plan_update, + prepare_game_root as _prepare_game_root, + safe_target as _safe_target, + scan_directory as _scan_directory, + scan_game_tree as _scan_game_tree, + sha1_file as _sha1_file, +) +from .game.depot.filesystem import ( + DEFAULT_MAX_LINE_ENDING_RECOVERY_BYTES, + DEFAULT_UNMANAGED_EXECUTABLE_SUFFIXES, + apply_create_if_missing as _filesystem_apply_create_if_missing, + apply_deletions as _filesystem_apply_deletions, + ensure_target_parent as _filesystem_ensure_target_parent, + quarantine_unmanaged_files as _filesystem_quarantine_unmanaged_files, + recover_crlf_manifest_file as _filesystem_recover_crlf_manifest_file, + safe_imported_user_file as _filesystem_safe_imported_user_file, + write_json as _filesystem_write_json, +) +from .game.depot.transfer import ( + AdaptiveConcurrencyController as _AdaptiveConcurrencyController, + BandwidthLimiter, + DepotHttpClient as _DepotHttpClient, + DepotHttpPolicy as _DepotHttpPolicy, + DownloadLimits as _DownloadLimits, + DownloadObserver, + DownloadTelemetry, + HttpOpener, + HttpResponse, + build_manifest_url as _transfer_build_manifest_url, + content_length as _transfer_content_length, + content_range as _transfer_content_range, + depot_manifest_url as _transfer_depot_manifest_url, + download_partial_path as _transfer_download_partial_path, + file_url as _transfer_file_url, + normalize_server as _transfer_normalise_server, + normalized_cpu_load as _transfer_normalized_cpu_load, + ordered_download_servers as _transfer_ordered_download_servers, + prune_transfer_samples as _transfer_prune_samples, + request_headers as _transfer_request_headers, + sample_rate as _transfer_sample_rate, + server_candidates as _transfer_server_candidates, +) +from .game.depot import ( + DEFAULT_DEPOT_START_OPTION, + CreateIfMissing, + DepotDownloadMetrics, + DepotError, + DepotFile, + DepotFileCatalog, + DepotFileCatalogEntry, + DepotIntegrityIssue, + DepotLaunchCatalog, + DepotPreflightResult, + DepotSnapshot, + DepotStartOption, + DepotSyncResult, + PlannedDownload, + ProgressCallback, + QuarantineResult, + SyncProgress, + UpdatePlan, +) +from .paths import AppPaths +from .telemetry import classify_network_failure, current_telemetry + + +# Keep historical imports available while implementation moves into the feature +# package. Referencing them here makes that compatibility surface explicit to +# static analysis without narrowing this module's existing wildcard behavior. +_COMPATIBILITY_EXPORTS = ( + DEFAULT_DEPOT_START_OPTION, + DEPOT_FILE_CATALOG_SCHEMA, + DEPOT_LAUNCH_CATALOG_SCHEMA, + MAX_CREATE_IF_MISSING_BYTES, + SHA1_PATTERN, + BandwidthLimiter, + DownloadTelemetry, + HttpOpener, + HttpResponse, + DepotStartOption, + DepotFileCatalogEntry, + DepotIntegrityIssue, + _depot_file_catalog_payload, + _catalog_entry, + _catalog_entry_from_cached, + _catalog_identity_matches, + _non_negative_int, + _normalise_relative_path, + _parse_create_if_missing, + _parse_deleted_paths, + _parse_depot_file_catalog_entry, + _parse_start_options, + _relative_parts, + _start_option_text, + _string, + _ScannedGameEntry, + _scan_directory, + _scan_game_tree, + launch_catalog_from_snapshot, + select_depot_start_option, +) + + +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_LINE_ENDING_RECOVERY_BYTES = DEFAULT_MAX_LINE_ENDING_RECOVERY_BYTES +UNMANAGED_EXECUTABLE_SUFFIXES = DEFAULT_UNMANAGED_EXECUTABLE_SUFFIXES +ALWAYS_VERIFY_PATHS = frozenset( + {"game.exe", "game.dll", "gamese.exe", "gamese.dll"} +) +CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE) +MAX_PREFLIGHT_ISSUES = 32 + + +@dataclass(frozen=True) +class DownloadOptions(_DownloadLimits): + """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) + + +class AdaptiveConcurrencyController(_AdaptiveConcurrencyController): + """Compatibility facade configured from the historical module constants.""" + + def __init__( + self, + options: DownloadOptions, + *, + clock: Callable[[], float] = time.monotonic, + cpu_load: Callable[[], float | None] | None = None, + ) -> None: + super().__init__( + options, + clock=clock, + cpu_load=cpu_load or _normalized_cpu_load, + initial_workers=DOWNLOAD_INITIAL_WORKERS, + evaluation_interval_seconds=ADAPTIVE_EVALUATION_INTERVAL_SECONDS, + retry_window_seconds=ADAPTIVE_RETRY_WINDOW_SECONDS, + retry_recovery_seconds=ADAPTIVE_RETRY_RECOVERY_SECONDS, + transfer_rate_window_seconds=TRANSFER_RATE_WINDOW_SECONDS, + ) + + +def cache_depot_launch_catalog(path: Path, snapshot: DepotSnapshot) -> None: + """Cache only publisher-advertised launch metadata for network-free Play.""" + + _write_json(path, _launch_catalog_payload(snapshot)) + + +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")) + return _parse_launch_catalog(value) + except (OSError, UnicodeError, json.JSONDecodeError, DepotError): + return None + + +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.""" + + catalog = _build_depot_file_catalog( + game_directory, + snapshot, + verified_paths=verified_paths, + previous=previous, + ) + _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")) + return _parse_depot_file_catalog(value) + except (OSError, UnicodeError, json.JSONDecodeError, DepotError): + return None + + +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 + result, updated_catalog = _inspect_managed_game( + paths.source_game, + catalog, + hash_file=_sha1, + max_issues=MAX_PREFLIGHT_ISSUES, + clock=time.perf_counter, + ) + if updated_catalog is not None: + _write_json( + paths.depot_file_catalog, + _depot_file_catalog_payload(updated_catalog), + compact=True, + ) + return result + + +def _depot_http_policy() -> _DepotHttpPolicy: + return _DepotHttpPolicy( + configuration_url=lambda: CDN_CONFIGURATION_URL, + server_candidates=lambda configuration, preferred_region: _server_candidates( + configuration, + preferred_region, + ), + depot_manifest_url=lambda server: _depot_manifest_url(server), + build_manifest_url=lambda server, build: _build_manifest_url(server, build), + file_url=lambda server, file: _file_url(server, file), + ordered_download_servers=lambda primary, candidates: _ordered_download_servers( + primary, + candidates, + ), + download_partial_path=lambda destination: _download_partial_path(destination), + recover_manifest_file=lambda temporary, destination, file: ( + _recover_crlf_manifest_file(temporary, destination, file) + ), + parse_snapshot=lambda region, server, depot_manifest, build_manifest: ( + _parse_snapshot(region, server, depot_manifest, build_manifest) + ), + positive_int=lambda value, field: _positive_int(value, field), + content_length=lambda value: _content_length(value), + content_range=lambda value: _content_range(value), + headers=lambda: _headers(), + read_size=lambda: READ_SIZE, + hash_read_size=lambda: HASH_READ_SIZE, + clock=lambda: time.monotonic(), + ) + + +class DepotClient(_DepotHttpClient): + """Stable facade for the publisher depot HTTP client.""" + + 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: + super().__init__( + policy=_depot_http_policy(), + timeout_seconds=timeout_seconds, + opener=opener, + retry_delays=retry_delays, + sleeper=sleeper, + download_observer=download_observer, + ) + + +def plan_update( + game_directory: Path, + snapshot: DepotSnapshot, + *, + repair: bool = False, +) -> UpdatePlan: + """Inspect a local game tree without writing to it.""" + + return _plan_update( + game_directory, + snapshot, + repair=repair, + hash_file=_sha1, + always_verify_paths=ALWAYS_VERIFY_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: + return _filesystem_quarantine_unmanaged_files( + paths.source_game, + paths.game_import_quarantine, + paths.data, + snapshot, + unmanaged_executable_suffixes=UNMANAGED_EXECUTABLE_SUFFIXES, + prepare_root=lambda game_directory, *, create: _prepare_game_root( + game_directory, + create=create, + ), + safe_user_file=lambda path, relative: _safe_imported_user_file( + path, + relative, + ), + write_manifest=lambda path, value: _write_json(path, value), + clock=lambda: datetime.now().astimezone(), + ) + + +def _safe_imported_user_file(path: Path, relative: str) -> bool: + return _filesystem_safe_imported_user_file( + path, + relative, + executable_suffixes=UNMANAGED_EXECUTABLE_SUFFIXES, + ) + + +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: Callable[[DownloadTelemetry], None] | None = ( + 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: + return _filesystem_apply_deletions( + root, + relative_paths, + target_for=lambda game_root, relative: _safe_target(game_root, relative), + ) + + +def _apply_create_if_missing( + root: Path, + entries: Iterable[CreateIfMissing], +) -> int: + return _filesystem_apply_create_if_missing( + root, + entries, + target_for=lambda game_root, relative: _safe_target(game_root, relative), + decode_entry=lambda entry: _decode_create_if_missing(entry), + ensure_parent=lambda game_root, target: _ensure_target_parent( + game_root, + target, + ), + ) + + +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 _server_candidates( + configuration: Mapping[str, object] | None, + preferred_region: str, +) -> tuple[tuple[str, str], ...]: + return _transfer_server_candidates( + configuration, + preferred_region, + fallback_servers=FALLBACK_SERVERS, + ) + + +def _normalise_server(value: str) -> str: + return _transfer_normalise_server(value) + + +def _depot_manifest_url(server: str) -> str: + return _transfer_depot_manifest_url(server, depot_id=DEPOT_ID) + + +def _build_manifest_url(server: str, build: int) -> str: + return _transfer_build_manifest_url(server, build, depot_id=DEPOT_ID) + + +def _file_url(server: str, file: DepotFile) -> str: + return _transfer_file_url(server, file, depot_id=DEPOT_ID) + + +def _ordered_download_servers( + primary: str, + candidates: Iterable[str], +) -> tuple[str, ...]: + return _transfer_ordered_download_servers(primary, candidates) + + +def _download_partial_path(destination: Path) -> Path: + return _transfer_download_partial_path(destination) + + +def _recover_crlf_manifest_file( + temporary: Path, + destination: Path, + file: DepotFile, +) -> bool: + return _filesystem_recover_crlf_manifest_file( + temporary, + destination, + file, + maximum_bytes=MAX_LINE_ENDING_RECOVERY_BYTES, + process_id=lambda: os.getpid(), + ) + + +def _ensure_target_parent(root: Path, target: Path) -> None: + _filesystem_ensure_target_parent(root, target) + + +def _sha1(path: Path) -> str: + return _sha1_file(path, read_size=HASH_READ_SIZE) + + +def _content_length(value: str | None) -> int | None: + return _transfer_content_length(value) + + +def _content_range(value: str | None) -> tuple[int, int, int]: + return _transfer_content_range(value, pattern=CONTENT_RANGE_PATTERN) + + +def _normalized_cpu_load() -> float | None: + return _transfer_normalized_cpu_load() + + +def _prune_transfer_samples( + samples: deque[tuple[float, int]], + now: float, +) -> None: + _transfer_prune_samples( + samples, + now, + window_seconds=TRANSFER_RATE_WINDOW_SECONDS, + ) + + +def _sample_rate(samples: deque[tuple[float, int]]) -> float | None: + return _transfer_sample_rate(samples) + + +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 _transfer_request_headers(__version__) + + +def _write_json(path: Path, value: object, *, compact: bool = False) -> None: + _filesystem_write_json(path, value, compact=compact) + + +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/desktop_entry.py b/src/ptlinux/desktop_entry.py similarity index 100% rename from ptlinux/desktop_entry.py rename to src/ptlinux/desktop_entry.py diff --git a/ptlinux/desktop_identity.py b/src/ptlinux/desktop_identity.py similarity index 100% rename from ptlinux/desktop_identity.py rename to src/ptlinux/desktop_identity.py diff --git a/src/ptlinux/diagnostics/__init__.py b/src/ptlinux/diagnostics/__init__.py new file mode 100644 index 0000000..63b37f4 --- /dev/null +++ b/src/ptlinux/diagnostics/__init__.py @@ -0,0 +1 @@ +"""Launcher-owned diagnostic parsing used by CLI and runtime workflows.""" diff --git a/ptdiag/logs.py b/src/ptlinux/diagnostics/native_logs.py similarity index 100% rename from ptdiag/logs.py rename to src/ptlinux/diagnostics/native_logs.py diff --git a/ptlinux/doctor.py b/src/ptlinux/doctor.py similarity index 100% rename from ptlinux/doctor.py rename to src/ptlinux/doctor.py diff --git a/ptlinux/download.py b/src/ptlinux/download.py similarity index 100% rename from ptlinux/download.py rename to src/ptlinux/download.py diff --git a/src/ptlinux/game/__init__.py b/src/ptlinux/game/__init__.py new file mode 100644 index 0000000..80234dd --- /dev/null +++ b/src/ptlinux/game/__init__.py @@ -0,0 +1 @@ +"""Launcher-owned game data, synchronization, and compatibility services.""" diff --git a/src/ptlinux/game/compatibility/__init__.py b/src/ptlinux/game/compatibility/__init__.py new file mode 100644 index 0000000..40cef72 --- /dev/null +++ b/src/ptlinux/game/compatibility/__init__.py @@ -0,0 +1 @@ +"""Verified compatibility transforms for publisher game data.""" diff --git a/ptdiag/assbin.py b/src/ptlinux/game/compatibility/assbin.py similarity index 100% rename from ptdiag/assbin.py rename to src/ptlinux/game/compatibility/assbin.py diff --git a/src/ptlinux/game/depot/__init__.py b/src/ptlinux/game/depot/__init__.py new file mode 100644 index 0000000..2c86c33 --- /dev/null +++ b/src/ptlinux/game/depot/__init__.py @@ -0,0 +1,87 @@ +"""Public models for the verified game-depot feature.""" + +from .catalogs import launch_catalog_from_snapshot, select_depot_start_option +from .filesystem import ( + apply_create_if_missing, + apply_deletions, + ensure_target_parent, + quarantine_unmanaged_files, + recover_crlf_manifest_file, + safe_imported_user_file, + write_json, +) +from .integrity import ( + build_depot_file_catalog, + inspect_managed_game, + plan_update, +) +from .manifest import parse_snapshot +from .models import ( + DEFAULT_DEPOT_START_OPTION, + CreateIfMissing, + DepotDownloadMetrics, + DepotError, + DepotFile, + DepotFileCatalog, + DepotFileCatalogEntry, + DepotIntegrityIssue, + DepotLaunchCatalog, + DepotPreflightResult, + DepotSnapshot, + DepotStartOption, + DepotSyncResult, + PlannedDownload, + ProgressCallback, + QuarantineResult, + SyncProgress, + UpdatePlan, +) +from .transfer import ( + AdaptiveConcurrencyController, + BandwidthLimiter, + DepotHttpClient, + DownloadTelemetry, + HttpOpener, + HttpResponse, +) + + +__all__ = [ + "DEFAULT_DEPOT_START_OPTION", + "AdaptiveConcurrencyController", + "BandwidthLimiter", + "CreateIfMissing", + "DepotDownloadMetrics", + "DepotError", + "DepotFile", + "DepotFileCatalog", + "DepotFileCatalogEntry", + "DepotHttpClient", + "DepotIntegrityIssue", + "DepotLaunchCatalog", + "DepotPreflightResult", + "DepotSnapshot", + "DepotStartOption", + "DepotSyncResult", + "DownloadTelemetry", + "HttpOpener", + "HttpResponse", + "PlannedDownload", + "ProgressCallback", + "QuarantineResult", + "SyncProgress", + "UpdatePlan", + "apply_create_if_missing", + "apply_deletions", + "build_depot_file_catalog", + "ensure_target_parent", + "inspect_managed_game", + "launch_catalog_from_snapshot", + "plan_update", + "parse_snapshot", + "quarantine_unmanaged_files", + "recover_crlf_manifest_file", + "safe_imported_user_file", + "select_depot_start_option", + "write_json", +] diff --git a/src/ptlinux/game/depot/catalogs.py b/src/ptlinux/game/depot/catalogs.py new file mode 100644 index 0000000..334fa65 --- /dev/null +++ b/src/ptlinux/game/depot/catalogs.py @@ -0,0 +1,208 @@ +"""Pure serialization and strict parsing for cached depot catalogs.""" + +from __future__ import annotations + +from dataclasses import asdict + +from .manifest import ( + SHA1_PATTERN, + non_negative_int, + normalize_relative_path, + parse_start_options, + positive_int, + required_string, +) +from .models import ( + DEFAULT_DEPOT_START_OPTION, + DepotError, + DepotFileCatalog, + DepotFileCatalogEntry, + DepotLaunchCatalog, + DepotSnapshot, + DepotStartOption, +) +from .realms import launcher_launch_catalog, launcher_realm_name + + +DEPOT_LAUNCH_CATALOG_SCHEMA = 1 +DEPOT_FILE_CATALOG_SCHEMA = 1 + + +def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog: + return launcher_launch_catalog( + 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 = launcher_launch_catalog( + catalog + or DepotLaunchCatalog( + build=0, + start_options=(DEFAULT_DEPOT_START_OPTION,), + default_start_option=DEFAULT_DEPOT_START_OPTION.name, + ) + ) + if preferred_realm: + preferred_names = { + preferred_realm.casefold(), + launcher_realm_name(preferred_realm).casefold(), + } + for option in current.start_options: + if option.name.casefold() in preferred_names: + 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 launch_catalog_payload(snapshot: DepotSnapshot) -> dict[str, object]: + catalog = launch_catalog_from_snapshot(snapshot) + return { + "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 parse_launch_catalog(value: object) -> DepotLaunchCatalog | None: + 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"), + ) + return launcher_launch_catalog( + DepotLaunchCatalog( + build=build, + start_options=options, + default_start_option=default_name, + ) + ) + + +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 = normalize_relative_path(value[0], "cached depot file path") + size = non_negative_int(value[1], f"cached depot file size for {path}") + sha1 = required_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 parse_depot_file_catalog(value: object) -> DepotFileCatalog | None: + 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 = required_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) + return DepotFileCatalog( + build=build, + files=tuple(entries), + created_at=created_at, + ) + + +__all__ = [ + "DEPOT_FILE_CATALOG_SCHEMA", + "DEPOT_LAUNCH_CATALOG_SCHEMA", + "depot_file_catalog_payload", + "launch_catalog_from_snapshot", + "launch_catalog_payload", + "parse_depot_file_catalog", + "parse_depot_file_catalog_entry", + "parse_launch_catalog", + "select_depot_start_option", +] diff --git a/src/ptlinux/game/depot/filesystem.py b/src/ptlinux/game/depot/filesystem.py new file mode 100644 index 0000000..92bc6c1 --- /dev/null +++ b/src/ptlinux/game/depot/filesystem.py @@ -0,0 +1,338 @@ +"""Filesystem mutation and quarantine operations for the game depot.""" + +from __future__ import annotations + +from datetime import datetime +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import stat +from typing import Callable, Iterable +from uuid import uuid4 + +from .integrity import prepare_game_root, safe_target +from .manifest import decode_create_if_missing +from .models import CreateIfMissing, DepotError, DepotFile, DepotSnapshot, QuarantineResult + + +DEFAULT_MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024 +DEFAULT_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", + } +) + + +def quarantine_unmanaged_files( + source_game: Path, + quarantine_base: Path, + data_root: Path, + snapshot: DepotSnapshot, + *, + unmanaged_executable_suffixes: frozenset[str] = ( + DEFAULT_UNMANAGED_EXECUTABLE_SUFFIXES + ), + prepare_root: Callable[..., Path] = prepare_game_root, + safe_user_file: Callable[[Path, str], bool] | None = None, + write_manifest: Callable[[Path, object], None] | None = None, + clock: Callable[[], datetime] | None = None, + identifier: Callable[[], str] | None = None, +) -> QuarantineResult: + """Move non-depot import contents aside without following links.""" + + root = prepare_root(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]] = [] + user_file_is_safe = safe_user_file or ( + lambda candidate_path, candidate_relative: safe_imported_user_file( + candidate_path, + candidate_relative, + executable_suffixes=unmanaged_executable_suffixes, + ) + ) + + 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 user_file_is_safe(path, relative): + continue + candidates.append((path, relative)) + + if not candidates: + return QuarantineResult(0, None) + base = quarantine_base.resolve(strict=False) + resolved_data_root = data_root.resolve(strict=False) + try: + base.relative_to(resolved_data_root) + except ValueError as exc: + raise DepotError(f"import quarantine is outside the data root: {base}") from exc + now = clock or (lambda: datetime.now().astimezone()) + new_identifier = identifier or (lambda: uuid4().hex) + stamp = now().strftime("%Y%m%d-%H%M%S") + quarantine = base / f"{stamp}-{new_identifier()}" + files_root = quarantine / "files" + files_root.mkdir(parents=True, exist_ok=False) + manifest = { + "schema": 1, + "complete": False, + "created_at": now().isoformat(), + "source": str(root), + "files": [relative for _path, relative in candidates], + } + write = write_manifest or write_json + write(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(quarantine / "quarantine.json", manifest) + return QuarantineResult(len(candidates), str(quarantine)) + + +def safe_imported_user_file( + path: Path, + relative: str, + *, + executable_suffixes: frozenset[str] = DEFAULT_UNMANAGED_EXECUTABLE_SUFFIXES, +) -> bool: + """Return whether an unmanaged save entry is safe to leave in place.""" + + parts = PurePosixPath(relative).parts + if not parts or parts[0].casefold() != "save": + return False + if path.suffix.casefold() in 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 apply_deletions( + root: Path, + relative_paths: Iterable[str], + *, + target_for: Callable[[Path, str], Path] = safe_target, +) -> int: + """Delete publisher-retired files and links within the managed root.""" + + deleted = 0 + for relative_path in relative_paths: + target = target_for(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], + *, + target_for: Callable[[Path, str], Path] = safe_target, + decode_entry: Callable[[CreateIfMissing], bytes] = decode_create_if_missing, + ensure_parent: Callable[[Path, Path], None] | None = None, + identifier: Callable[[], str] | None = None, +) -> int: + """Atomically create publisher defaults while preserving existing files.""" + + created = 0 + prepare_parent = ensure_parent or ensure_target_parent + new_identifier = identifier or (lambda: uuid4().hex) + for entry in entries: + target = target_for(root, entry.path) + if target.is_symlink(): + raise DepotError(f"refusing to follow symbolic link for {entry.path}") + if target.exists(): + continue + content = decode_entry(entry) + prepare_parent(root, target) + temporary = target.with_name(f".{target.name}.{new_identifier()}.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 recover_crlf_manifest_file( + temporary: Path, + destination: Path, + file: DepotFile, + *, + maximum_bytes: int = DEFAULT_MAX_LINE_ENDING_RECOVERY_BYTES, + process_id: Callable[[], int] = os.getpid, + identifier: Callable[[], str] | None = None, +) -> bool: + """Restore publisher-normalized legacy text only on an exact hash match.""" + + try: + if temporary.stat().st_size > maximum_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 + new_identifier = identifier or (lambda: uuid4().hex) + recovered_temporary = destination.with_name( + f".{destination.name}.{process_id()}.{new_identifier()}.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 ensure_target_parent(root: Path, target: Path) -> None: + """Create a target's parent directories without traversing symlinks.""" + + 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: + 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 write_json(path: Path, value: object, *, compact: bool = False) -> None: + """Atomically serialize one JSON document beside its destination.""" + + 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 + + +__all__ = [ + "DEFAULT_MAX_LINE_ENDING_RECOVERY_BYTES", + "DEFAULT_UNMANAGED_EXECUTABLE_SUFFIXES", + "apply_create_if_missing", + "apply_deletions", + "ensure_target_parent", + "quarantine_unmanaged_files", + "recover_crlf_manifest_file", + "safe_imported_user_file", + "write_json", +] diff --git a/src/ptlinux/game/depot/integrity.py b/src/ptlinux/game/depot/integrity.py new file mode 100644 index 0000000..2015b58 --- /dev/null +++ b/src/ptlinux/game/depot/integrity.py @@ -0,0 +1,413 @@ +"""Read-only update planning and managed-game integrity inspection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import hashlib +import os +from pathlib import Path, PurePosixPath +import stat +import time +from typing import Callable, Iterable + +from .manifest import relative_parts +from .models import ( + DepotError, + DepotFile, + DepotFileCatalog, + DepotFileCatalogEntry, + DepotIntegrityIssue, + DepotPreflightResult, + DepotSnapshot, + PlannedDownload, + UpdatePlan, +) + + +HashFile = Callable[[Path], str] +Clock = Callable[[], float] + + +@dataclass(frozen=True) +class ScannedGameEntry: + path: Path + state: os.stat_result + kind: str + + +def sha1_file(path: Path, *, read_size: int = 1024 * 1024) -> str: + digest = hashlib.sha1(usedforsecurity=False) + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(read_size), b""): + digest.update(chunk) + except OSError as exc: + raise DepotError(f"cannot hash {path}: {exc}") from exc + return digest.hexdigest() + + +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 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( + 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 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 build_depot_file_catalog( + game_directory: Path, + snapshot: DepotSnapshot, + *, + verified_paths: Iterable[str], + previous: DepotFileCatalog | None = None, + created_at: str | None = None, +) -> DepotFileCatalog: + """Build an identity catalog without rehashing updater-verified files.""" + + 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)) + + return DepotFileCatalog( + build=snapshot.build, + files=tuple(entries), + created_at=( + created_at + if created_at is not None + else datetime.now().astimezone().isoformat() + ), + ) + + +def plan_update( + game_directory: Path, + snapshot: DepotSnapshot, + *, + repair: bool = False, + hash_file: HashFile = sha1_file, + always_verify_paths: frozenset[str] = frozenset( + {"game.exe", "game.dll", "gamese.exe", "gamese.dll"} + ), +) -> 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 hash_file(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 inspect_managed_game( + game_directory: Path, + catalog: DepotFileCatalog, + *, + hash_file: HashFile = sha1_file, + max_issues: int = 32, + clock: Clock = time.perf_counter, +) -> tuple[DepotPreflightResult, DepotFileCatalog | None]: + """Inspect one catalog and return its report plus any refreshed identities.""" + + started = clock() + root = prepare_game_root(game_directory, 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, clock), + ), + None, + ) + 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, clock), + ), + None, + ) + + 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 = hash_file(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_issues: + issues.append(DepotIntegrityIssue(entry.path, reason)) + updated_entries.append(updated_entry) + + updated_catalog = ( + DepotFileCatalog( + build=catalog.build, + files=tuple(updated_entries), + created_at=datetime.now().astimezone().isoformat(), + ) + if cache_changed + else None + ) + 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, clock), + ), + updated_catalog, + ) + + +def elapsed_milliseconds(started: float, clock: Clock = time.perf_counter) -> int: + return max(0, round((clock() - started) * 1000)) + + +__all__ = [ + "HashFile", + "ScannedGameEntry", + "build_depot_file_catalog", + "catalog_entry", + "catalog_entry_from_cached", + "catalog_identity_matches", + "inspect_managed_game", + "plan_update", + "prepare_game_root", + "safe_target", + "scan_directory", + "scan_game_tree", + "sha1_file", +] diff --git a/src/ptlinux/game/depot/manifest.py b/src/ptlinux/game/depot/manifest.py new file mode 100644 index 0000000..7066c88 --- /dev/null +++ b/src/ptlinux/game/depot/manifest.py @@ -0,0 +1,279 @@ +"""Strict parsing for publisher depot manifests.""" + +from __future__ import annotations + +from base64 import b64decode +from binascii import Error as BinAsciiError +from pathlib import PurePosixPath +import re +from typing import Mapping + +from .models import ( + DEFAULT_DEPOT_START_OPTION, + CreateIfMissing, + DepotError, + DepotFile, + DepotSnapshot, + DepotStartOption, +) + + +MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 +SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") + + +def parse_snapshot( + region: str, + server: str, + depot_manifest: Mapping[str, object], + build_manifest: Mapping[str, object], +) -> DepotSnapshot: + """Validate publisher manifests and return their canonical snapshot.""" + + 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 = required_string(depot_manifest.get("Name"), "depot Name") + version = required_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 = normalize_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 = required_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 = normalize_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 = normalize_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 = required_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.""" + + 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 = normalize_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( + "DefaultStartOption does not match an advertised realm: " + f"{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 normalize_relative_path(value: object, field: str) -> str: + if not isinstance(value, str): + raise DepotError(f"{field} must be a string") + return "/".join(relative_parts(value, field)) + + +def relative_parts(value: str, field: str) -> tuple[str, ...]: + if not value or "\x00" in value: + raise DepotError(f"invalid {field}") + normalized = value.replace("\\", "/") + if normalized.startswith("/") or normalized.startswith("//"): + raise DepotError(f"{field} must be relative: {value!r}") + parts = PurePosixPath(normalized).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 required_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 + + +__all__ = [ + "MAX_CREATE_IF_MISSING_BYTES", + "SHA1_PATTERN", + "decode_create_if_missing", + "non_negative_int", + "normalize_relative_path", + "parse_create_if_missing", + "parse_deleted_paths", + "parse_snapshot", + "parse_start_options", + "positive_int", + "relative_parts", + "required_string", + "start_option_text", +] diff --git a/src/ptlinux/game/depot/models.py b/src/ptlinux/game/depot/models.py new file mode 100644 index 0000000..3d79638 --- /dev/null +++ b/src/ptlinux/game/depot/models.py @@ -0,0 +1,238 @@ +"""Immutable models and public validation errors for depot synchronization.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + + +class DepotError(RuntimeError): + """Raised when a depot response or update operation cannot be trusted.""" + + +@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 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 depot synchronization.""" + + 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] + + +__all__ = [ + "DEFAULT_DEPOT_START_OPTION", + "CreateIfMissing", + "DepotDownloadMetrics", + "DepotError", + "DepotFile", + "DepotFileCatalog", + "DepotFileCatalogEntry", + "DepotIntegrityIssue", + "DepotLaunchCatalog", + "DepotPreflightResult", + "DepotSnapshot", + "DepotStartOption", + "DepotSyncResult", + "PlannedDownload", + "ProgressCallback", + "QuarantineResult", + "SyncProgress", + "UpdatePlan", +] diff --git a/src/ptlinux/game/depot/realms.py b/src/ptlinux/game/depot/realms.py new file mode 100644 index 0000000..1125ace --- /dev/null +++ b/src/ptlinux/game/depot/realms.py @@ -0,0 +1,82 @@ +"""Launcher labels and runtime requirements for publisher realm options.""" + +from __future__ import annotations + +from pathlib import PurePosixPath + +from .models import DepotLaunchCatalog, DepotStartOption + + +PTR_BABEL_REALM_NAME = "PTR Babel" +PUBLISHER_TEST_BABEL_REALM_NAME = "Test Babel" +PTR_BABEL_EXECUTABLE_PATH = PurePosixPath("Gamese.exe") +PTR_BABEL_DLL_PATH = PurePosixPath("gamese.dll") + + +def launcher_realm_name(name: str) -> str: + """Return the launcher-facing name for one publisher realm name.""" + + if name.casefold() == PUBLISHER_TEST_BABEL_REALM_NAME.casefold(): + return PTR_BABEL_REALM_NAME + return name + + +def launcher_launch_catalog(catalog: DepotLaunchCatalog) -> DepotLaunchCatalog: + """Apply launcher presentation names without changing launch metadata.""" + + options = tuple(_launcher_start_option(option) for option in catalog.start_options) + default_name = catalog.default_start_option + for publisher_option, launcher_option in zip( + catalog.start_options, + options, + strict=True, + ): + if publisher_option.name.casefold() == default_name.casefold(): + default_name = launcher_option.name + break + return DepotLaunchCatalog( + build=catalog.build, + start_options=options, + default_start_option=default_name, + ) + + +def required_start_option_files( + option: DepotStartOption, +) -> tuple[PurePosixPath, ...]: + """Return files required by launcher-defined realm behavior.""" + + executable = PurePosixPath(option.path) + if ( + option.name.casefold() != PTR_BABEL_REALM_NAME.casefold() + or executable.as_posix().casefold() + != PTR_BABEL_EXECUTABLE_PATH.as_posix().casefold() + ): + return () + return executable, PTR_BABEL_DLL_PATH + + +def _launcher_start_option(option: DepotStartOption) -> DepotStartOption: + name = launcher_realm_name(option.name) + if ( + name == option.name + or PurePosixPath(option.path).as_posix().casefold() + != PTR_BABEL_EXECUTABLE_PATH.as_posix().casefold() + ): + return option + return DepotStartOption( + name=name, + path=option.path, + arguments=option.arguments, + ) + + +__all__ = [ + "PTR_BABEL_DLL_PATH", + "PTR_BABEL_EXECUTABLE_PATH", + "PTR_BABEL_REALM_NAME", + "PUBLISHER_TEST_BABEL_REALM_NAME", + "launcher_launch_catalog", + "launcher_realm_name", + "required_start_option_files", +] diff --git a/src/ptlinux/game/depot/transfer.py b/src/ptlinux/game/depot/transfer.py new file mode 100644 index 0000000..56c6fa8 --- /dev/null +++ b/src/ptlinux/game/depot/transfer.py @@ -0,0 +1,913 @@ +"""Verified HTTP transfer and adaptive scheduling for the game depot.""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +import hashlib +from http.client import HTTPException +import json +import os +from pathlib import Path, PurePosixPath +import re +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 + +from .models import DepotError, DepotFile, DepotSnapshot + + +class DownloadLimits: + """Scheduler settings supplied by the stable depot facade.""" + + minimum_workers: int + + @property + def effective_bandwidth_limit(self) -> int | None: + raise NotImplementedError + + @property + def effective_maximum_workers(self) -> int: + raise NotImplementedError + + +@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: + """Reserve aggregate transfer time for a newly received chunk.""" + + 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) + + +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, + *, + window_seconds: float, +) -> None: + """Discard transfer samples older than the configured rate window.""" + + cutoff = now - window_seconds + while len(samples) > 2 and samples[1][0] <= cutoff: + samples.popleft() + + +def sample_rate(samples: deque[tuple[float, int]]) -> float | None: + """Calculate bytes per second between the oldest and newest samples.""" + + 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 + + +class AdaptiveConcurrencyController: + """Select a bounded worker target from live network, host, and plan data.""" + + def __init__( + self, + options: DownloadLimits, + *, + clock: Callable[[], float] = time.monotonic, + cpu_load: Callable[[], float | None] | None = None, + initial_workers: int = 8, + evaluation_interval_seconds: float = 1.0, + retry_window_seconds: float = 15.0, + retry_recovery_seconds: float = 5.0, + transfer_rate_window_seconds: float = 5.0, + ) -> None: + self.options = options + self._clock = clock + self._cpu_load = cpu_load or normalized_cpu_load + self._initial_workers = initial_workers + self._evaluation_interval_seconds = evaluation_interval_seconds + self._retry_window_seconds = retry_window_seconds + self._retry_recovery_seconds = retry_recovery_seconds + self._transfer_rate_window_seconds = transfer_rate_window_seconds + self._lock = threading.Lock() + self._target = max( + options.minimum_workers, + min(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, + window_seconds=self._transfer_rate_window_seconds, + ) + 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 - self._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 - self._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 < self._evaluation_interval_seconds: + return self._target + self._last_evaluation = now + prune_transfer_samples( + self._transfer_samples, + now, + window_seconds=self._transfer_rate_window_seconds, + ) + 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 < self._retry_recovery_seconds + ) + if ( + healthy + and not retry_recovery_pending + and not limit_saturated + and self._target < maximum + ): + 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)) + + +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] + + +@dataclass(frozen=True) +class DepotHttpPolicy: + """Facade-owned policy and parsers consumed by the HTTP implementation.""" + + configuration_url: Callable[[], str] + server_candidates: Callable[ + [Mapping[str, object] | None, str], + tuple[tuple[str, str], ...], + ] + depot_manifest_url: Callable[[str], str] + build_manifest_url: Callable[[str, int], str] + file_url: Callable[[str, DepotFile], str] + ordered_download_servers: Callable[[str, Iterable[str]], tuple[str, ...]] + download_partial_path: Callable[[Path], Path] + recover_manifest_file: Callable[[Path, Path, DepotFile], bool] + parse_snapshot: Callable[ + [str, str, Mapping[str, object], Mapping[str, object]], + DepotSnapshot, + ] + positive_int: Callable[[object, str], int] + content_length: Callable[[str | None], int | None] + content_range: Callable[[str | None], tuple[int, int, int]] + headers: Callable[[], dict[str, str]] + read_size: Callable[[], int] + hash_read_size: Callable[[], int] + clock: Callable[[], float] + + +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 + + +class DepotHttpClient: + """HTTP implementation for the publisher's documented public depot.""" + + def __init__( + self, + *, + policy: DepotHttpPolicy, + timeout_seconds: float = 60.0, + opener: HttpOpener, + retry_delays: tuple[float, ...], + sleeper: Callable[[float], None], + 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._policy = policy + 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 from trusted regional servers.""" + + configuration: Mapping[str, object] | None = None + configuration_error: DepotError | None = None + try: + configuration = self._get_json(self._policy.configuration_url()) + except DepotError as exc: + configuration_error = exc + + candidates = self._policy.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(self._policy.depot_manifest_url(server)) + build = self._policy.positive_int( + depot_manifest.get("Build"), + "depot Build", + ) + build_manifest = self._get_json( + self._policy.build_manifest_url(server, build) + ) + return self._policy.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.""" + + servers = self._policy.ordered_download_servers( + server, + self._candidate_servers, + ) + temporary = self._policy.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 = self._policy.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(self._policy.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 self._policy.recover_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 = self._policy.headers() + if requested_offset: + headers["Range"] = f"bytes={requested_offset}-" + url = self._policy.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 = self._policy.clock() + 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, + self._policy.clock() - request_started_at, + ), + http_status=( + response_status if isinstance(response_status, int) else None + ), + ) + ) + + try: + append = False + if requested_offset: + range_header = response.headers.get("Content-Range") + status = getattr(response, "status", None) + if status == 206 or range_header is not None: + try: + range_start, _range_end, range_total = ( + self._policy.content_range(range_header) + ) + 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}, " + f"size {range_total}/{file.size}" + ) + append = True + else: + written = 0 + digest = hashlib.sha1(usedforsecurity=False) + if progress is not None: + progress(written) + + try: + declared_length = self._policy.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 declared_length is not None and declared_length > remaining: + raise _RetryableDownloadError( + f"download exceeds expected size for {file.path}: " + f"server declared {declared_length}, expected at most {remaining}" + ) + + mode = "ab" if append else "wb" + try: + with temporary.open(mode) as handle: + while True: + try: + chunk = response.read(self._policy.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 = self._policy.clock() + 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, + self._policy.clock() - 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 self._policy.recover_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=self._policy.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 server_candidates( + configuration: Mapping[str, object] | None, + preferred_region: str, + *, + fallback_servers: Iterable[str], +) -> tuple[tuple[str, str], ...]: + """Return normalized publisher servers in deterministic preference order.""" + + 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, normalize_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 normalize_server(value: str) -> str: + """Validate and normalize one trusted CDN host/path value.""" + + 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, *, depot_id: int) -> str: + return f"https://{server}/depots/{depot_id}/manifest.json" + + +def build_manifest_url(server: str, build: int, *, depot_id: int) -> str: + return f"https://{server}/depots/{depot_id}/builds/{build}.json" + + +def file_url(server: str, file: DepotFile, *, depot_id: int) -> 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 = normalize_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 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, + *, + pattern: re.Pattern[str], +) -> tuple[int, int, int]: + if value is None: + raise DepotError("206 response omitted Content-Range") + match = 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 request_headers(version: str) -> dict[str, str]: + return {"User-Agent": f"PT-Linux/{version} (+https://pristontale.eu/)"} + + +__all__ = [ + "AdaptiveConcurrencyController", + "BandwidthLimiter", + "DepotHttpClient", + "DepotHttpPolicy", + "DownloadLimits", + "DownloadObserver", + "DownloadTelemetry", + "HttpOpener", + "HttpResponse", + "build_manifest_url", + "content_length", + "content_range", + "depot_manifest_url", + "download_partial_path", + "file_url", + "normalize_server", + "normalized_cpu_load", + "ordered_download_servers", + "prune_transfer_samples", + "request_headers", + "sample_rate", + "server_candidates", +] diff --git a/src/ptlinux/game/ptr_compat.py b/src/ptlinux/game/ptr_compat.py new file mode 100644 index 0000000..f314b6d --- /dev/null +++ b/src/ptlinux/game/ptr_compat.py @@ -0,0 +1,151 @@ +"""Realm-specific compatibility files for the PTR game client.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +import uuid + +from ..resource_paths import native_resource_path + + +PTR_BCRYPT_PROXY_FILENAME = "ptr-bcrypt-proxy.dll" +PTR_BCRYPT_PROXY_SHA256 = ( + "cf543a0c8f29231e35123c9d229194ee424b57b073a96da4f4ae6b0ebb5ce16a" +) +PTR_BCRYPT_RUNTIME_FILENAME = "bcrypt.dll" +PTR_BCRYPT_ORIGINAL_RUNTIME_FILENAME = "bcrypt_orig.dll" +PTR_BCRYPT_RUNTIME_FILENAMES = frozenset( + {PTR_BCRYPT_RUNTIME_FILENAME, PTR_BCRYPT_ORIGINAL_RUNTIME_FILENAME} +) +_PROTON_BCRYPT_CANDIDATES = ( + Path("dist/lib/wine/i386-windows/bcrypt.dll"), + Path("files/lib/wine/i386-windows/bcrypt.dll"), +) + + +class PtrCompatibilityError(RuntimeError): + """Raised when the verified PTR compatibility overlay cannot be staged.""" + + +def stage_ptr_bcrypt_compatibility( + runtime_view: Path, + proton_script: str | Path | None, +) -> tuple[Path, Path]: + """Stage the verified proxy and Proton 7's original 32-bit BCrypt DLL.""" + + if proton_script is None: + raise PtrCompatibilityError( + "PTR Babel requires the Proton 7 compatibility runtime" + ) + try: + proton = Path(proton_script).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise PtrCompatibilityError( + f"could not resolve the Proton 7 script for PTR Babel: {exc}" + ) from exc + if not proton.is_file(): + raise PtrCompatibilityError( + f"the Proton 7 script for PTR Babel is not a file: {proton}" + ) + + proxy_source = native_resource_path(PTR_BCRYPT_PROXY_FILENAME) + proxy_bytes = _read_file(proxy_source, "packaged PTR BCrypt proxy") + proxy_hash = _sha256_bytes(proxy_bytes) + if proxy_hash != PTR_BCRYPT_PROXY_SHA256: + raise PtrCompatibilityError( + "the packaged PTR BCrypt proxy failed its integrity check" + ) + + original_source = _find_proton_bcrypt(proton) + original_bytes = _read_file(original_source, "Proton 7 32-bit BCrypt DLL") + if not _is_i386_pe(original_bytes): + raise PtrCompatibilityError( + f"the Proton 7 BCrypt module is not a 32-bit PE DLL: {original_source}" + ) + + original_target = runtime_view / PTR_BCRYPT_ORIGINAL_RUNTIME_FILENAME + proxy_target = runtime_view / PTR_BCRYPT_RUNTIME_FILENAME + # Install the forwarding target first, so a partial write can never expose a + # proxy whose forwarded exports have nowhere to resolve. + _write_runtime_file(original_target, original_bytes) + _write_runtime_file(proxy_target, proxy_bytes) + return proxy_target, original_target + + +def ptr_bcrypt_dll_overrides(current: str | None) -> str: + """Force native BCrypt while preserving unrelated Wine DLL overrides.""" + + overrides: list[str] = [] + for entry in (current or "").split(";"): + entry = entry.strip() + if not entry: + continue + name, separator, _value = entry.partition("=") + if separator and name.strip().lstrip("*").casefold() == "bcrypt": + continue + overrides.append(entry) + overrides.append("bcrypt=n") + return ";".join(overrides) + + +def _find_proton_bcrypt(proton_script: Path) -> Path: + for relative in _PROTON_BCRYPT_CANDIDATES: + candidate = proton_script.parent / relative + if candidate.is_file(): + return candidate + locations = ", ".join(str(path) for path in _PROTON_BCRYPT_CANDIDATES) + raise PtrCompatibilityError( + "the selected Proton 7 installation has no 32-bit BCrypt module " + f"({locations})" + ) + + +def _read_file(path: Path, description: str) -> bytes: + try: + return path.read_bytes() + except OSError as exc: + raise PtrCompatibilityError(f"could not read {description} {path}: {exc}") from exc + + +def _write_runtime_file(destination: Path, content: bytes) -> None: + expected_hash = _sha256_bytes(content) + if not destination.is_symlink() and destination.is_file(): + try: + if _sha256_bytes(destination.read_bytes()) == expected_hash: + return + except OSError: + pass + temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.tmp") + try: + temporary.write_bytes(content) + temporary.chmod(0o644) + temporary.replace(destination) + if _sha256_bytes(destination.read_bytes()) != expected_hash: + raise OSError("written file did not retain the expected digest") + except OSError as exc: + raise PtrCompatibilityError( + f"could not stage PTR compatibility file {destination}: {exc}" + ) from exc + finally: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + + +def _is_i386_pe(content: bytes) -> bool: + if len(content) < 0x40 or content[:2] != b"MZ": + return False + pe_offset = int.from_bytes(content[0x3C:0x40], "little") + if pe_offset < 0x40 or pe_offset + 26 > len(content): + return False + return ( + content[pe_offset : pe_offset + 4] == b"PE\0\0" + and int.from_bytes(content[pe_offset + 4 : pe_offset + 6], "little") == 0x14C + and int.from_bytes(content[pe_offset + 24 : pe_offset + 26], "little") == 0x10B + ) + + +def _sha256_bytes(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() diff --git a/ptlinux/runtime.py b/src/ptlinux/game/runtime.py similarity index 92% rename from ptlinux/runtime.py rename to src/ptlinux/game/runtime.py index 9ffa86c..68ecb64 100644 --- a/ptlinux/runtime.py +++ b/src/ptlinux/game/runtime.py @@ -1,8 +1,8 @@ -"""Portable game runtime selection and Proton/UMU preparation.""" +"""Portable game runtime selection, preparation, and launch.""" from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, replace from datetime import datetime import hashlib import json @@ -15,22 +15,32 @@ import shutil import sys from typing import Mapping, Sequence -from .command import CommandOutcome, CommandRunner, OutputLineFilter -from .container_host import container_host_error, inspect_container_host -from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption -from .gamebox import ( +from ..command import CommandOutcome, CommandRunner, OutputLineFilter +from ..container_host import container_host_error, inspect_container_host +from ..depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption +from ..gamebox import ( GameboxConfigurationError, build_gamebox_command, find_gamescope, read_game_canvas_size, ) -from .paths import AppPaths -from .settings import Settings -from .system_identity import ( +from ..paths import AppPaths +from ..settings import Settings +from ..system_identity import ( detect_distribution_id as _detect_distribution_id, is_running_inside_steam_runtime as _is_running_inside_steam_runtime, ) -from .umu import find_umu_launcher +from ..umu import find_umu_launcher +from .depot.realms import ( + PTR_BABEL_REALM_NAME, + launcher_realm_name, + required_start_option_files, +) +from .ptr_compat import ( + PtrCompatibilityError, + ptr_bcrypt_dll_overrides, + stage_ptr_bcrypt_compatibility, +) WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU" @@ -68,6 +78,26 @@ class RuntimeSelection: steam_root: str | None +def runtime_settings_for_realm( + settings: Settings, + realm: str | None = None, +) -> Settings: + """Apply compatibility requirements for the selected game realm.""" + + selected_realm = launcher_realm_name(realm or settings.realm) + if selected_realm.casefold() == PTR_BABEL_REALM_NAME.casefold(): + # PTR V21.78.89 is stable on the retained Proton 7 runtime once its CNG + # SHA-256 pseudo-handle call is supplied by the scoped BCrypt proxy. + return replace( + settings, + realm=PTR_BABEL_REALM_NAME, + runtime="proton7", + ) + if selected_realm != settings.realm: + return replace(settings, realm=selected_realm) + return settings + + def select_runtime( settings: Settings, environment: Mapping[str, str] | None = None, @@ -76,6 +106,7 @@ def select_runtime( distribution_id: str | None = None, kernel_release: str | None = None, ) -> RuntimeSelection: + settings = runtime_settings_for_realm(settings) env = os.environ if environment is None else environment umu = find_umu_launcher(paths, env) current_umu_host = prefers_current_umu_host( @@ -419,10 +450,21 @@ def run_game( _require_container_host(selection, runner) if not paths.runtime_view.is_dir() and not runner.dry_run: raise RuntimeErrorPT("runtime view is missing; prepare the game first") + ptr_babel = bool(required_start_option_files(start_option)) native_executable, windows_executable, game_arguments = _game_start_command( paths, start_option, ) + if not runner.dry_run: + _validate_start_option_runtime_files(paths.runtime_view, start_option) + if ptr_babel: + try: + stage_ptr_bcrypt_compatibility( + paths.runtime_view, + selection.proton_path, + ) + except PtrCompatibilityError as exc: + raise RuntimeErrorPT(str(exc)) from exc if selection.kind == "umu": game_command = [ selection.executable, @@ -469,6 +511,14 @@ def run_game( ) if extra_environment: environment.update(extra_environment) + if ptr_babel: + existing_overrides = environment.get( + "WINEDLLOVERRIDES", + os.environ.get("WINEDLLOVERRIDES"), + ) + environment["WINEDLLOVERRIDES"] = ptr_bcrypt_dll_overrides( + existing_overrides + ) if use_gamebox: gamescope_path = None game_size = None @@ -577,6 +627,19 @@ def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path: return cursor +def _validate_start_option_runtime_files( + runtime_view: Path, + option: DepotStartOption, +) -> None: + for relative in required_start_option_files(option): + path = _resolve_runtime_path_case(runtime_view, relative) + if not path.is_file(): + raise RuntimeErrorPT( + f"realm {option.name} is unavailable; runtime view is missing " + f"{relative.as_posix()}" + ) + + def run_runtime_prefix_command( paths: AppPaths, selection: RuntimeSelection, @@ -635,7 +698,7 @@ def _require_container_host( def _runtime_python_path(existing: str | None) -> str: """Make the installed package importable after changing into runtime_view.""" - package_root = str(Path(__file__).resolve().parent.parent) + package_root = str(Path(__file__).resolve().parents[2]) if not existing: return package_root return os.pathsep.join((package_root, existing)) diff --git a/ptlinux/overlay.py b/src/ptlinux/game/runtime_view.py similarity index 98% rename from ptlinux/overlay.py rename to src/ptlinux/game/runtime_view.py index 7e5bafb..6b05970 100644 --- a/ptlinux/overlay.py +++ b/src/ptlinux/game/runtime_view.py @@ -1,4 +1,4 @@ -"""Updater-safe runtime view with one verified normalized scene cache.""" +"""Updater-safe linked game runtime view and compatibility cache.""" from __future__ import annotations @@ -12,13 +12,14 @@ import shutil from typing import Mapping import uuid -from ptdiag.assbin import ( +from .compatibility.assbin import ( SceneInspection, inspect_wrapped_assbin, normalize_wrapped_assbin_textures, ) +from .ptr_compat import PTR_BCRYPT_RUNTIME_FILENAMES -from .paths import AppPaths +from ..paths import AppPaths WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug") @@ -869,8 +870,13 @@ def _runtime_layout_ready( runtime = paths.runtime_view if runtime.is_symlink() or not runtime.is_dir(): return False - top_level_exceptions = {"game", "game.ini", "save"} - top_level_special = {"game", "game.ini", "save"} + ptr_compatibility_files = { + name + for name in PTR_BCRYPT_RUNTIME_FILENAMES + if not (runtime / name).is_symlink() and (runtime / name).is_file() + } + top_level_exceptions = {"game", "game.ini", "save", *ptr_compatibility_files} + top_level_special = {"game", "game.ini", "save", *ptr_compatibility_files} for name, binary_source in ( ("Game.exe", game_exe_source), ("game.dll", game_dll_source), @@ -913,6 +919,9 @@ def _runtime_layout_ready( actual_links += 1 if game_dll_source != source / "game.dll": actual_links += 1 + actual_links += sum( + 1 for name in ptr_compatibility_files if (source / name).exists() + ) return actual_links == expected_links diff --git a/ptlinux/gamebox.py b/src/ptlinux/gamebox.py similarity index 100% rename from ptlinux/gamebox.py rename to src/ptlinux/gamebox.py diff --git a/src/ptlinux/gamebox_runtime.py b/src/ptlinux/gamebox_runtime.py new file mode 100644 index 0000000..0d31530 --- /dev/null +++ b/src/ptlinux/gamebox_runtime.py @@ -0,0 +1,13 @@ +"""Compatibility entry point for managed game-window supervision.""" + +from __future__ import annotations + +import sys + +from .presentation import game_window as _implementation + + +if __name__ == "__main__": + raise SystemExit(_implementation.main()) + +sys.modules[__name__] = _implementation diff --git a/src/ptlinux/gamescope_child_runtime.py b/src/ptlinux/gamescope_child_runtime.py new file mode 100644 index 0000000..cc79d22 --- /dev/null +++ b/src/ptlinux/gamescope_child_runtime.py @@ -0,0 +1,13 @@ +"""Compatibility entry point for nested Gamescope process status.""" + +from __future__ import annotations + +import sys + +from .presentation import gamescope_child as _implementation + + +if __name__ == "__main__": + raise SystemExit(_implementation.main()) + +sys.modules[__name__] = _implementation diff --git a/src/ptlinux/gamescope_runtime.py b/src/ptlinux/gamescope_runtime.py new file mode 100644 index 0000000..93ce036 --- /dev/null +++ b/src/ptlinux/gamescope_runtime.py @@ -0,0 +1,13 @@ +"""Compatibility entry point for Gamescope host supervision.""" + +from __future__ import annotations + +import sys + +from .presentation import gamescope as _implementation + + +if __name__ == "__main__": + raise SystemExit(_implementation.main()) + +sys.modules[__name__] = _implementation diff --git a/ptlinux/identity_probe.py b/src/ptlinux/identity_probe.py similarity index 99% rename from ptlinux/identity_probe.py rename to src/ptlinux/identity_probe.py index cdae753..519da92 100644 --- a/ptlinux/identity_probe.py +++ b/src/ptlinux/identity_probe.py @@ -10,6 +10,7 @@ import re from .command import CommandRunner from .paths import AppPaths +from .resource_paths import native_resource_path from .runtime import RuntimeSelection, run_runtime_prefix_command @@ -54,7 +55,7 @@ class IdentityProbeResult: def packaged_identity_probe() -> Path: - return Path(__file__).resolve().parent / "native" / "pt-identity-probe.exe" + return native_resource_path("pt-identity-probe.exe") def probe_client_identity( diff --git a/ptlinux/import_state.py b/src/ptlinux/import_state.py similarity index 100% rename from ptlinux/import_state.py rename to src/ptlinux/import_state.py diff --git a/ptlinux/importer.py b/src/ptlinux/importer.py similarity index 100% rename from ptlinux/importer.py rename to src/ptlinux/importer.py diff --git a/ptlinux/install_status.py b/src/ptlinux/install_status.py similarity index 100% rename from ptlinux/install_status.py rename to src/ptlinux/install_status.py diff --git a/ptlinux/launcher-update-public.pem b/src/ptlinux/launcher-update-public.pem similarity index 100% rename from ptlinux/launcher-update-public.pem rename to src/ptlinux/launcher-update-public.pem diff --git a/ptlinux/launcher.py b/src/ptlinux/launcher.py similarity index 100% rename from ptlinux/launcher.py rename to src/ptlinux/launcher.py diff --git a/src/ptlinux/launcher_updates/__init__.py b/src/ptlinux/launcher_updates/__init__.py new file mode 100644 index 0000000..dc96b12 --- /dev/null +++ b/src/ptlinux/launcher_updates/__init__.py @@ -0,0 +1,140 @@ +"""Launcher update models and feature services.""" + +from .activation import ( + ActivationJournal, + ActivationPaths, + activate_release, + journal_release, + journal_version, + read_activation_journal, + recover_interrupted_activation, + recovery_telemetry_metadata, + release_from_link, + replace_release_link, + restore_release_links, + set_release_link, + write_activation_journal, +) +from .archive import ( + archive_matches, + download_archive, + extract_release_archive, + sha256_file, +) +from .discovery import ( + ChannelDiscoveryPolicy, + configured_history_url, + configured_manifest_url, + configured_manifest_urls, + configured_public_key_path, + fetch_authenticated_manifest, + fetch_historical_manifest, + fetch_launcher_release_history, + fetch_small_file, + verify_manifest_signature, +) +from .models import ( + LauncherInstalledRelease, + LauncherRemoteHistory, + LauncherRemoteRelease, + LauncherRollbackResult, + LauncherUpdateCheck, + LauncherUpdateError, + LauncherUpdateHistory, + LauncherUpdateResult, + LauncherUpdateVerification, + ManifestFile, + UpdateManifest, +) +from .parsing import ( + UpdateParsingPolicy, + parse_launcher_release_history, + parse_update_manifest, +) +from .rollback import ( + RollbackOperations, + RollbackPaths, + SUPPORTED_ROLLBACK_REASONS, + inspect_launcher_update_history, + launcher_rollback_candidates, + rollback_launcher_update, +) +from .staging import ( + StagingPaths, + precheck_release_install, + read_release_metadata, + remove_generated_bytecode_caches, + require_free_space, + self_check_release, + stage_and_install_release, + tree_digest, + validate_release_directory, + verify_file_inventory, + verify_manifest_files, + verify_retained_release_integrity, + write_release_integrity_record, +) + + +__all__ = [ + "ActivationJournal", + "ActivationPaths", + "ChannelDiscoveryPolicy", + "LauncherInstalledRelease", + "LauncherRemoteHistory", + "LauncherRemoteRelease", + "LauncherRollbackResult", + "LauncherUpdateCheck", + "LauncherUpdateError", + "LauncherUpdateHistory", + "LauncherUpdateResult", + "LauncherUpdateVerification", + "ManifestFile", + "RollbackOperations", + "RollbackPaths", + "SUPPORTED_ROLLBACK_REASONS", + "StagingPaths", + "UpdateManifest", + "UpdateParsingPolicy", + "archive_matches", + "activate_release", + "configured_history_url", + "configured_manifest_url", + "configured_manifest_urls", + "configured_public_key_path", + "download_archive", + "fetch_authenticated_manifest", + "fetch_historical_manifest", + "fetch_launcher_release_history", + "fetch_small_file", + "extract_release_archive", + "inspect_launcher_update_history", + "journal_release", + "journal_version", + "launcher_rollback_candidates", + "parse_launcher_release_history", + "parse_update_manifest", + "precheck_release_install", + "read_activation_journal", + "read_release_metadata", + "recover_interrupted_activation", + "recovery_telemetry_metadata", + "release_from_link", + "remove_generated_bytecode_caches", + "replace_release_link", + "require_free_space", + "restore_release_links", + "rollback_launcher_update", + "self_check_release", + "set_release_link", + "sha256_file", + "stage_and_install_release", + "tree_digest", + "validate_release_directory", + "verify_file_inventory", + "verify_manifest_files", + "verify_manifest_signature", + "verify_retained_release_integrity", + "write_activation_journal", + "write_release_integrity_record", +] diff --git a/src/ptlinux/launcher_updates/activation.py b/src/ptlinux/launcher_updates/activation.py new file mode 100644 index 0000000..96c3ca6 --- /dev/null +++ b/src/ptlinux/launcher_updates/activation.py @@ -0,0 +1,461 @@ +"""Crash-safe journal and symlink operations for launcher activation.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +from typing import Callable, Mapping, Protocol + +from ..update_resolver import ReleaseModelError, STABLE_CHANNEL, SemanticVersion +from .models import LauncherUpdateError + + +class ActivationPaths(Protocol): + @property + def app_home(self) -> Path: ... + + @property + def launcher_current(self) -> Path: ... + + @property + def launcher_previous(self) -> Path: ... + + @property + def launcher_releases(self) -> Path: ... + + @property + def launcher_update_transaction(self) -> Path: ... + + +@dataclass(frozen=True) +class ActivationJournal: + state: str + current: object + previous: object + target: object + + +def read_activation_journal( + paths: ActivationPaths, + *, + unique_json_object: Callable[[list[tuple[str, object]]], dict[str, object]], +) -> ActivationJournal | None: + """Read and validate the fixed fields of an activation transaction.""" + + 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 None + 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") + assert isinstance(state, str) + return ActivationJournal( + state=state, + current=value.get("current"), + previous=value.get("previous"), + target=value.get("target"), + ) + + +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: ActivationPaths, + value: object, + label: str, + *, + validate_release: Callable[[Path], str], +) -> Path | None: + if value is None: + return None + version = journal_version(value, label) + release = paths.launcher_releases / version + validate_release(release) + return release.resolve() + + +def restore_release_links( + paths: ActivationPaths, + *, + current: Path | None, + previous: Path | None, + set_link: Callable[[Path, Path | None, Path], None], + fsync_directory: Callable[[Path], None], +) -> None: + set_link(paths.launcher_current, current, paths.launcher_releases) + set_link(paths.launcher_previous, previous, paths.launcher_releases) + fsync_directory(paths.app_home) + + +def set_release_link( + link: Path, + release: Path | None, + releases: Path, + *, + replace_link: Callable[[Path, Path, Path], None], +) -> None: + if release is not None: + replace_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: ActivationPaths, + *, + state: str, + current: Path | None, + previous: Path | None, + target: Path, + updated_at: str, + process_id: int, + fsync_directory: Callable[[Path], None], +) -> 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": updated_at, + } + journal.parent.mkdir(parents=True, exist_ok=True) + temporary = journal.with_name(f".{journal.name}.{process_id}.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, + *, + validate_release: Callable[[Path], str], + process_id: int, +) -> 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(resolved_release) + temporary = link.with_name(f".{link.name}.{process_id}.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 activate_release( + paths: ActivationPaths, + release: Path, + *, + telemetry_metadata: Mapping[str, object] | None, + restore_reason: str, + recover: Callable[..., None], + resolve_link: Callable[[Path, Path], Path | None], + validate: Callable[[Path], str], + write_journal: Callable[..., None], + replace_link: Callable[[Path, Path, Path], None], + sync_directory: Callable[[Path], None], + restore_snapshot: Callable[..., None], + emit_event: Callable[..., None], +) -> Path | None: + """Atomically activate a validated release and restore its prior snapshot.""" + + 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(paths) + current = resolve_link(paths.launcher_current, paths.launcher_releases) + previous = resolve_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(resolved_release) + if current == resolved_release: + return current + write_journal( + paths, + state="applying", + current=current, + previous=previous, + target=resolved_release, + ) + try: + if current is not None: + validate(current) + replace_link( + paths.launcher_previous, + current, + paths.launcher_releases, + ) + replace_link( + paths.launcher_current, + resolved_release, + paths.launcher_releases, + ) + sync_directory(paths.app_home) + activated = resolve_link(paths.launcher_current, paths.launcher_releases) + retained_previous = resolve_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(activated) + write_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, + } + emit_event( + "rollback_restore_started", + metadata=restore_metadata, + operation="launcher_rollback", + stage="restore", + ) + try: + restore_snapshot(paths, current=current, previous=previous) + except BaseException as restore_error: + emit_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) + sync_directory(paths.launcher_update_transaction.parent) + emit_event( + "rollback_restore_success", + metadata=restore_metadata, + operation="launcher_rollback", + stage="restore", + severity="warning", + ) + raise + paths.launcher_update_transaction.unlink(missing_ok=True) + sync_directory(paths.launcher_update_transaction.parent) + return current + + +def recover_interrupted_activation( + paths: ActivationPaths, + *, + read_journal: Callable[..., ActivationJournal | None], + resolve_journal_release: Callable[..., Path | None], + validate_version: Callable[[object, str], str], + validate: Callable[[Path], str], + resolve_link: Callable[[Path, Path], Path | None], + build_recovery_metadata: Callable[[Path | None, str], dict[str, object]], + emit_event: Callable[..., None], + restore_snapshot: Callable[..., None], + sync_directory: Callable[[Path], None], +) -> None: + """Complete or restore an interrupted activation transaction.""" + + journal = paths.launcher_update_transaction + transaction = read_journal(paths) + if transaction is None: + return + current = resolve_journal_release(paths, transaction.current, "current") + previous = resolve_journal_release(paths, transaction.previous, "previous") + target_version = validate_version(transaction.target, "target") + target_path = paths.launcher_releases / target_version + try: + validate(target_path) + except LauncherUpdateError: + target = None + else: + target = target_path.resolve() + if transaction.state == "activated": + active = resolve_link(paths.launcher_current, paths.launcher_releases) + retained_previous = resolve_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() + sync_directory(journal.parent) + return + metadata = build_recovery_metadata(current, target_version) + emit_event( + "rollback_restore_started", + metadata=metadata, + operation="launcher_rollback", + stage="restore", + severity="warning", + ) + try: + restore_snapshot(paths, current=current, previous=previous) + except BaseException as exc: + emit_event( + "rollback_restore_failed", + metadata=metadata, + operation="launcher_rollback", + stage="restore", + severity="critical", + message=str(exc), + ) + raise + journal.unlink() + sync_directory(journal.parent) + emit_event( + "rollback_restore_success", + metadata=metadata, + operation="launcher_rollback", + stage="restore", + severity="warning", + ) + + +def recovery_telemetry_metadata( + current: Path | None, + target_version: str, + *, + running_version: str, + release_metadata: Callable[..., dict[str, object]], +) -> dict[str, object]: + current_version = current.name if current is not None else running_version + try: + current_channel = SemanticVersion.parse(current_version).channel + except ReleaseModelError: + current_channel = STABLE_CHANNEL + target_semantic = SemanticVersion.parse(target_version) + return release_metadata( + current_version=current_version, + target_version=target_version, + current_channel=current_channel, + target_channel=target_semantic.channel, + selected_channel=target_semantic.channel, + tag=target_semantic.tag, + manifest_schema=None, + manifest_sha256=None, + rollback=True, + reason="failed_update", + ) + + +__all__ = [ + "ActivationJournal", + "ActivationPaths", + "activate_release", + "journal_release", + "journal_version", + "read_activation_journal", + "recover_interrupted_activation", + "recovery_telemetry_metadata", + "release_from_link", + "replace_release_link", + "restore_release_links", + "set_release_link", + "write_activation_journal", +] diff --git a/src/ptlinux/launcher_updates/archive.py b/src/ptlinux/launcher_updates/archive.py new file mode 100644 index 0000000..6a2c210 --- /dev/null +++ b/src/ptlinux/launcher_updates/archive.py @@ -0,0 +1,302 @@ +"""Bounded launcher archive download and safe extraction.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path, PurePosixPath +import tarfile +from typing import AbstractSet, Callable, Mapping, Protocol +from urllib.error import HTTPError, URLError +from urllib.request import Request + +from .models import LauncherUpdateError, UpdateManifest +from .parsing import require_https_url + + +class ArchiveResponse(Protocol): + headers: Mapping[str, str] + + def __enter__(self) -> ArchiveResponse: ... + + def __exit__(self, *args: object) -> object: ... + + def geturl(self) -> str: ... + + def read(self, size: int = -1) -> bytes: ... + + +ArchiveOpener = Callable[..., ArchiveResponse] + + +def download_archive( + manifest: UpdateManifest, + destination: Path, + *, + timeout_seconds: float, + user_agent: str, + read_size: int, + maximum_archive_bytes: int, + opener: ArchiveOpener, + content_length: Callable[[str | None, str], int | None], + fsync_directory: Callable[[Path], None], + progress: Callable[[int], None] | None = None, + process_id: Callable[[], int] = os.getpid, +) -> int: + """Download one signed archive atomically and return its received byte count.""" + + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f".{destination.name}.{process_id()}.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 = opener(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 progress is not None: + progress(written) + if ( + written > manifest.archive_size + or written > maximum_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: + temporary.unlink(missing_ok=True) + raise + return written + + +def extract_release_archive( + archive: Path, + destination: Path, + version: str, + *, + archive_prefix: str, + allowed_release_roots: AbstractSet[str], + maximum_archive_members: int, + maximum_extracted_bytes: int, + read_size: int, +) -> None: + """Extract a flat or bridge-wrapped archive after validating every member.""" + + expected_root = f"{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) > maximum_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 = ( + path.relative_to(expected_root) + 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( + "launcher archive member is group/world writable: " + f"{member.name!r}" + ) + permissions = member.mode & 0o777 + if member.isfile() and permissions not in {0o644, 0o755}: + raise LauncherUpdateError( + "launcher archive member has unsupported permissions: " + f"{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 > maximum_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( + "launcher payload exceeded its declared size: " + f"{member.name!r}" + ) + handle.write(chunk) + if written != member.size: + raise LauncherUpdateError( + f"launcher payload was incomplete: {member.name!r}" + ) + target.chmod(permissions) + + +def archive_matches( + path: Path, + manifest: UpdateManifest, + *, + read_size: int, + hash_file: Callable[[Path], str] | None = None, +) -> bool: + try: + if path.stat().st_size != manifest.archive_size: + return False + digest = ( + hash_file(path) + if hash_file is not None + else sha256_file(path, read_size=read_size) + ) + return digest == manifest.archive_sha256 + except OSError: + return False + + +def sha256_file(path: Path, *, read_size: int) -> 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() + + +__all__ = [ + "ArchiveOpener", + "archive_matches", + "download_archive", + "extract_release_archive", + "sha256_file", +] diff --git a/src/ptlinux/launcher_updates/discovery.py b/src/ptlinux/launcher_updates/discovery.py new file mode 100644 index 0000000..f609035 --- /dev/null +++ b/src/ptlinux/launcher_updates/discovery.py @@ -0,0 +1,377 @@ +"""Channel discovery and authenticated launcher metadata retrieval.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from typing import Callable, Mapping, Protocol +from urllib.error import HTTPError, URLError +from urllib.request import Request + +from ..update_resolver import ReleaseModelError, validate_channel +from .models import ( + LauncherRemoteHistory, + LauncherRemoteRelease, + LauncherUpdateError, + UpdateManifest, +) +from .parsing import require_https_url, resource_url, signature_url + + +@dataclass(frozen=True) +class ChannelDiscoveryPolicy: + """Channel names, defaults, and environment keys owned by the facade.""" + + stable_channel: str + prerelease_channel: str + default_stable_manifest_url: str + default_prerelease_manifest_url: str + default_history_url: str + manifest_url_variable: str + stable_manifest_url_variable: str + prerelease_manifest_url_variable: str + history_url_variable: str + public_key_variable: str + + +class SmallResponse(Protocol): + headers: Mapping[str, str] + + def __enter__(self) -> SmallResponse: ... + + def __exit__(self, *args: object) -> object: ... + + def geturl(self) -> str: ... + + def read(self, size: int = -1) -> bytes: ... + + +SmallFileOpener = Callable[..., SmallResponse] +SmallFileFetcher = Callable[..., bytes] +SignatureVerifier = Callable[[bytes, bytes, Path], None] +ManifestParser = Callable[[bytes], UpdateManifest] +HistoryParser = Callable[..., LauncherRemoteHistory] + + +def configured_manifest_url( + environment: Mapping[str, str] | None, + *, + channel: str, + policy: ChannelDiscoveryPolicy, +) -> str: + """Return the direct channel URL retained for legacy callers.""" + + urls = configured_manifest_urls( + channel=channel, + environment=environment, + policy=policy, + ) + return urls[-1][1] + + +def configured_manifest_urls( + *, + channel: str, + environment: Mapping[str, str] | None, + policy: ChannelDiscoveryPolicy, +) -> 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(policy.manifest_url_variable) + if generic_override: + return ((channel, generic_override, False),) + stable_url = env.get( + policy.stable_manifest_url_variable, + policy.default_stable_manifest_url, + ) + if channel == policy.stable_channel: + return ((policy.stable_channel, stable_url, False),) + prerelease_url = env.get( + policy.prerelease_manifest_url_variable, + policy.default_prerelease_manifest_url, + ) + return ( + (policy.stable_channel, stable_url, False), + (policy.prerelease_channel, prerelease_url, True), + ) + + +def configured_public_key_path( + environment: Mapping[str, str] | None, + *, + variable: str, + default_path: Path, +) -> Path: + env = os.environ if environment is None else environment + configured = env.get(variable) + return Path(configured).expanduser() if configured else default_path + + +def configured_history_url( + environment: Mapping[str, str] | None, + *, + policy: ChannelDiscoveryPolicy, +) -> str: + env = os.environ if environment is None else environment + return env.get(policy.history_url_variable, policy.default_history_url) + + +def fetch_launcher_release_history( + *, + history_url: str, + public_key_path: Path, + timeout_seconds: float, + history_bytes_limit: int, + signature_bytes_limit: int, + small_file_fetcher: SmallFileFetcher, + verify_signature: SignatureVerifier, + parse_history: HistoryParser, +) -> LauncherRemoteHistory: + """Fetch and authenticate the immutable-release catalog used for rollback.""" + + require_https_url(history_url, "release history URL") + history_bytes = small_file_fetcher( + history_url, + maximum_bytes=history_bytes_limit, + timeout_seconds=timeout_seconds, + label="release history", + ) + signature_bytes = small_file_fetcher( + signature_url(history_url), + maximum_bytes=signature_bytes_limit, + timeout_seconds=timeout_seconds, + label="release history signature", + ) + verify_signature(history_bytes, signature_bytes, public_key_path) + return parse_history(history_bytes, history_url=history_url) + + +def fetch_authenticated_manifest( + url: str, + public_key_path: Path, + *, + timeout_seconds: float, + manifest_bytes_limit: int, + signature_bytes_limit: int, + small_file_fetcher: SmallFileFetcher, + verify_signature: SignatureVerifier, + parse_manifest: ManifestParser, +) -> UpdateManifest: + manifest_bytes = small_file_fetcher( + url, + maximum_bytes=manifest_bytes_limit, + timeout_seconds=timeout_seconds, + label="update manifest", + ) + signature_bytes = small_file_fetcher( + signature_url(url), + maximum_bytes=signature_bytes_limit, + timeout_seconds=timeout_seconds, + label="update manifest signature", + ) + verify_signature(manifest_bytes, signature_bytes, public_key_path) + return parse_manifest(manifest_bytes) + + +def fetch_historical_manifest( + release: LauncherRemoteRelease, + public_key_path: Path, + *, + timeout_seconds: float, + manifest_bytes_limit: int, + signature_bytes_limit: int, + small_file_fetcher: SmallFileFetcher, + verify_signature: SignatureVerifier, + parse_manifest: ManifestParser, +) -> UpdateManifest: + manifest_bytes = small_file_fetcher( + release.manifest_url, + maximum_bytes=manifest_bytes_limit, + timeout_seconds=timeout_seconds, + label=f"launcher {release.version} manifest", + ) + signature_bytes = small_file_fetcher( + release.manifest_signature_url, + maximum_bytes=signature_bytes_limit, + timeout_seconds=timeout_seconds, + label=f"launcher {release.version} manifest signature", + ) + verify_signature(manifest_bytes, signature_bytes, public_key_path) + manifest = parse_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 fetch_small_file( + url: str, + *, + maximum_bytes: int, + timeout_seconds: float, + label: str, + user_agent: str, + opener: SmallFileOpener, +) -> bytes: + """Retrieve one bounded file without accepting cross-resource redirects.""" + + 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 = opener(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, + *, + maximum_signature_bytes: int, +) -> None: + """Verify an update document with the pinned launcher public key.""" + + if not signature or len(signature) > maximum_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 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 + + +__all__ = [ + "ChannelDiscoveryPolicy", + "SmallFileOpener", + "configured_history_url", + "configured_manifest_url", + "configured_manifest_urls", + "configured_public_key_path", + "content_length", + "fetch_authenticated_manifest", + "fetch_historical_manifest", + "fetch_launcher_release_history", + "fetch_small_file", + "verify_manifest_signature", +] diff --git a/src/ptlinux/launcher_updates/models.py b/src/ptlinux/launcher_updates/models.py new file mode 100644 index 0000000..9deb6cb --- /dev/null +++ b/src/ptlinux/launcher_updates/models.py @@ -0,0 +1,181 @@ +"""Immutable models shared by launcher update services.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..update_resolver import ACTIVE_STATUS, STABLE_CHANNEL, ReleaseRecord + + +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 + + +__all__ = [ + "LauncherInstalledRelease", + "LauncherRemoteHistory", + "LauncherRemoteRelease", + "LauncherRollbackResult", + "LauncherUpdateCheck", + "LauncherUpdateError", + "LauncherUpdateHistory", + "LauncherUpdateResult", + "LauncherUpdateVerification", + "ManifestFile", + "UpdateManifest", +] diff --git a/src/ptlinux/launcher_updates/parsing.py b/src/ptlinux/launcher_updates/parsing.py new file mode 100644 index 0000000..6243f66 --- /dev/null +++ b/src/ptlinux/launcher_updates/parsing.py @@ -0,0 +1,527 @@ +"""Strict parsers for authenticated launcher manifests and release history.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import hashlib +import json +from pathlib import PurePosixPath +import re +from typing import AbstractSet +from urllib.parse import urlsplit, urlunsplit + +from ..update_resolver import ( + ReleaseModelError, + SemanticVersion, + validate_channel_version, +) +from .models import ( + LauncherInstalledRelease, + LauncherRemoteHistory, + LauncherRemoteRelease, + LauncherUpdateError, + ManifestFile, + UpdateManifest, +) + + +@dataclass(frozen=True) +class UpdateParsingPolicy: + """Size, schema, and release-shape limits supplied by the public facade.""" + + application: str + manifest_bytes: int + history_bytes: int + embedded_release_notes_bytes: int + archive_bytes: int + extracted_bytes: int + archive_members: int + release_history_schema: int + release_history_entries: int + sha256_pattern: re.Pattern[str] + allowed_release_roots: AbstractSet[str] + active_status: str + supported_release_statuses: AbstractSet[str] + + +def parse_launcher_release_history( + data: bytes, + *, + history_url: str, + policy: UpdateParsingPolicy, +) -> LauncherRemoteHistory: + """Strictly parse an already authenticated remote release catalog.""" + + if not data or len(data) > policy.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") != policy.application: + raise LauncherUpdateError( + f"release history application must be {policy.application!r}" + ) + if required_integer(value, "schema") != policy.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) > policy.release_history_entries + ): + 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 policy.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, + *, + policy: UpdateParsingPolicy, +) -> UpdateManifest: + """Strictly parse one already-authenticated manifest.""" + + if not data or len(data) > policy.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 != policy.application: + raise LauncherUpdateError( + f"update manifest application must be {policy.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 policy.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 > policy.archive_bytes: + raise LauncherUpdateError( + f"update archive size must be between 1 and {policy.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", policy.active_status) + if ( + not isinstance(status_value, str) + or status_value not in policy.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")) + > policy.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"), policy=policy) + 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, + *, + policy: UpdateParsingPolicy, +) -> tuple[ManifestFile, ...]: + if value is None: + return () + if not isinstance(value, list) or len(value) > policy.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 policy.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 > policy.extracted_bytes: + raise LauncherUpdateError(f"invalid manifest file size: {path_value}") + digest = required_string(item, "sha256") + if policy.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 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 + + +__all__ = [ + "UpdateParsingPolicy", + "cached_timestamp", + "optional_positive_integer", + "parse_history_compatibility", + "parse_launcher_release_history", + "parse_manifest_files", + "parse_published_at", + "parse_update_manifest", + "parse_version", + "release_version", + "require_https_url", + "required_integer", + "required_positive_integer", + "required_string", + "resource_url", + "signature_url", + "unique_json_object", +] diff --git a/src/ptlinux/launcher_updates/rollback.py b/src/ptlinux/launcher_updates/rollback.py new file mode 100644 index 0000000..716234d --- /dev/null +++ b/src/ptlinux/launcher_updates/rollback.py @@ -0,0 +1,476 @@ +"""Launcher rollback selection, installation, and retained-history services.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Callable, Mapping, Protocol + +from ..update_resolver import ( + ReleaseModelError, + SemanticVersion, + UpdateResolver, + validate_channel, +) +from .models import ( + LauncherInstalledRelease, + LauncherRemoteHistory, + LauncherRemoteRelease, + LauncherRollbackResult, + LauncherUpdateError, + LauncherUpdateHistory, + UpdateManifest, +) + + +SUPPORTED_ROLLBACK_REASONS = frozenset( + { + "manual", + "failed_update", + "failed_launch", + "support_recommendation", + "hotfix_recovery", + } +) + + +class RollbackPaths(Protocol): + @property + def launcher_current(self) -> Path: ... + + @property + def launcher_previous(self) -> Path: ... + + @property + def launcher_releases(self) -> Path: ... + + @property + def launcher_update_state(self) -> Path: ... + + def create_base_directories(self) -> None: ... + + def launcher_update_archive(self, version: str) -> Path: ... + + +@dataclass(frozen=True) +class RollbackOperations: + mutation_lock: Callable[..., AbstractContextManager[None]] + recover_interrupted: Callable[..., None] + resolve_link: Callable[[Path, Path], Path | None] + validate_release: Callable[[Path], str] + parse_version: Callable[[str, str], SemanticVersion] + verify_retained_integrity: Callable[..., tuple[int, int] | None] + build_release_metadata: Callable[..., dict[str, object]] + emit_event: Callable[..., None] + fetch_history: Callable[..., LauncherRemoteHistory] + rollback_candidates: Callable[..., tuple[LauncherRemoteRelease, ...]] + configured_public_key: Callable[..., Path] + fetch_historical_manifest: Callable[..., UpdateManifest] + validate_manifest_compatibility: Callable[..., None] + precheck_install: Callable[..., None] + download_archive: Callable[..., None] + stage_release: Callable[..., Path] + current_config_schema: Callable[..., int] + validate_schema_range: Callable[..., None] + validate_release_compatibility: Callable[..., None] + activate_release: Callable[..., Path | None] + record_operation: Callable[..., bool] + write_json: Callable[[Path, object], None] + current_timestamp: Callable[[], str] + + +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: RollbackPaths, + *, + target_version: str | None, + selected_channel: str, + history_url: str | None, + public_key_path: Path | None, + environment: Mapping[str, str] | None, + timeout_seconds: float, + reason: str, + operations: RollbackOperations, +) -> 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 SUPPORTED_ROLLBACK_REASONS: + raise LauncherUpdateError(f"unsupported rollback reason: {reason!r}") + paths.create_base_directories() + with operations.mutation_lock(paths, "launcher-rollback"): + operations.recover_interrupted(paths) + current_release = operations.resolve_link( + paths.launcher_current, + paths.launcher_releases, + ) + if current_release is None: + raise LauncherUpdateError("the current launcher release link is missing") + current_version = operations.validate_release(current_release) + current_semantic = operations.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 = operations.resolve_link( + paths.launcher_previous, + paths.launcher_releases, + ) + if target_release is None: + raise LauncherUpdateError( + "no previous launcher release is available" + ) + target_version = operations.validate_release(target_release) + retained_compatibility = operations.verify_retained_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 " + f"{current_version}" + ) + target_release = paths.launcher_releases / target_version + if target_release.is_dir() and not target_release.is_symlink(): + if operations.validate_release(target_release) != target_version: + raise LauncherUpdateError( + "retained rollback release version does not match" + ) + retained_compatibility = operations.verify_retained_integrity( + paths, + target_release, + target_version, + ) + else: + remote_target = True + assert target_version is not None + target_semantic = operations.parse_version( + target_version, + "rollback target version", + ) + if target_semantic >= current_semantic: + raise LauncherUpdateError( + f"rollback target {target_version} must be older than " + f"{current_version}" + ) + from_channel = current_semantic.channel + to_channel = target_semantic.channel + event_metadata = operations.build_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, + ) + operations.emit_event( + "rollback_requested", + metadata=event_metadata, + operation="launcher_rollback", + stage="requested", + ) + if remote_target: + history = operations.fetch_history( + history_url=history_url, + public_key_path=public_key_path, + environment=environment, + timeout_seconds=min(timeout_seconds, 30.0), + ) + candidates = operations.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 = operations.fetch_historical_manifest( + record, + public_key_path or operations.configured_public_key(environment), + timeout_seconds=min(timeout_seconds, 30.0), + ) + operations.validate_manifest_compatibility(paths, fetched_manifest) + operations.precheck_install(paths, fetched_manifest) + manifest = fetched_manifest + archive = paths.launcher_update_archive(fetched_manifest.version) + event_metadata = operations.build_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: + operations.download_archive( + manifest, + archive, + timeout_seconds=timeout_seconds, + event_family="rollback", + event_metadata=event_metadata, + ) + operations.emit_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 = operations.stage_release( + paths, + archive, + manifest, + ) + if current_release == target_release.resolve(): + raise LauncherUpdateError( + "current and rollback releases are identical" + ) + if manifest is not None: + operations.validate_manifest_compatibility(paths, manifest) + elif retained_compatibility is not None: + config_schema, minimum_config_schema = retained_compatibility + operations.validate_schema_range( + operations.current_config_schema(paths), + minimum_config_schema, + config_schema, + f"launcher {target_version}", + ) + else: + operations.validate_release_compatibility(paths, target_release) + previous = operations.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 + operations.emit_event( + "rollback_apply_failed", + metadata=event_metadata, + operation="launcher_rollback", + stage="apply", + severity="error", + message=str(exc), + ) + operations.record_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 + operations.emit_event( + "rollback_apply_success", + metadata=event_metadata, + operation="launcher_rollback", + stage="apply", + ) + state_warning = None + try: + operations.write_json( + paths.launcher_update_state, + { + "schema": 1, + "rolled_back_at": operations.current_timestamp(), + "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: + state_warning = ( + "launcher rolled back, but update history could not be saved: " + f"{exc}" + ) + history_recorded = operations.record_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: RollbackPaths, + *, + resolve_link: Callable[[Path, Path], Path | None], + validate_release: Callable[[Path], str], + parse_version: Callable[[str, str], SemanticVersion], + release_version: Callable[..., str | None], +) -> LauncherUpdateHistory: + """Return validated retained releases without trusting directory names alone.""" + + current = resolve_link(paths.launcher_current, paths.launcher_releases) + previous = resolve_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(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), + ) + + +__all__ = [ + "RollbackOperations", + "RollbackPaths", + "SUPPORTED_ROLLBACK_REASONS", + "inspect_launcher_update_history", + "launcher_rollback_candidates", + "rollback_launcher_update", +] diff --git a/src/ptlinux/launcher_updates/service.py b/src/ptlinux/launcher_updates/service.py new file mode 100644 index 0000000..192915b --- /dev/null +++ b/src/ptlinux/launcher_updates/service.py @@ -0,0 +1,1651 @@ +"""Signed launcher-update orchestration and compatibility policy.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import asdict, replace +from datetime import datetime +from functools import partial +import json +import os +from pathlib import Path +import re +import shutil +import sys +import tempfile +import time +from typing import Iterator, Mapping +from urllib.request import urlopen + +from .. import __version__ +from ..locking import launcher_operation_lock, managed_game_process_is_active +from .activation import ( + ActivationJournal as _ActivationJournal, + activate_release as _activation_activate_release, + journal_release as _activation_journal_release, + journal_version as _activation_journal_version, + read_activation_journal as _activation_read_journal, + recover_interrupted_activation as _activation_recover_interrupted, + recovery_telemetry_metadata as _activation_recovery_metadata, + release_from_link as _activation_release_from_link, + replace_release_link as _activation_replace_release_link, + restore_release_links as _activation_restore_release_links, + set_release_link as _activation_set_release_link, + write_activation_journal as _activation_write_journal, +) +from .archive import ( + archive_matches as _archive_file_matches, + download_archive as _download_archive_file, + extract_release_archive as _extract_archive, + sha256_file as _archive_sha256_file, +) +from .discovery import ( + ChannelDiscoveryPolicy as _ChannelDiscoveryPolicy, + configured_history_url as _configured_history_url, + configured_manifest_url as _configured_manifest_url, + configured_manifest_urls as _configured_manifest_urls, + configured_public_key_path as _configured_public_key_path, + content_length as _discovery_content_length, + fetch_authenticated_manifest as _discovery_fetch_authenticated_manifest, + fetch_historical_manifest as _discovery_fetch_historical_manifest, + fetch_launcher_release_history as _discovery_fetch_release_history, + fetch_small_file as _discovery_fetch_small_file, + verify_manifest_signature as _discovery_verify_signature, +) +from .models import ( + LauncherInstalledRelease, + LauncherRemoteHistory, + LauncherRemoteRelease, + LauncherRollbackResult, + LauncherUpdateCheck, + LauncherUpdateError, + LauncherUpdateHistory, + LauncherUpdateResult, + LauncherUpdateVerification, + ManifestFile, + UpdateManifest, +) +from .parsing import ( + UpdateParsingPolicy as _UpdateParsingPolicy, + cached_timestamp as _cached_timestamp, + optional_positive_integer as _optional_positive_integer, + parse_history_compatibility as _parse_history_compatibility, + parse_launcher_release_history as _parse_launcher_release_history, + parse_manifest_files as _parse_update_manifest_files, + parse_published_at as _parse_published_at, + parse_update_manifest as _parse_update_manifest, + parse_version as _parse_version, + release_version as _release_version, + require_https_url as _require_https_url, + required_integer as _required_integer, + required_positive_integer as _required_positive_integer, + required_string as _required_string, + resource_url as _resource_url, + signature_url as _signature_url, + unique_json_object as _unique_json_object, +) +from .rollback import ( + RollbackOperations as _RollbackOperations, + inspect_launcher_update_history as _rollback_inspect_update_history, + launcher_rollback_candidates as _rollback_launcher_candidates, + rollback_launcher_update as _rollback_launcher_update, +) +from .staging import ( + precheck_release_install as _staging_precheck_release_install, + read_release_metadata as _staging_read_release_metadata, + remove_generated_bytecode_caches as _staging_remove_bytecode_caches, + require_free_space as _staging_require_free_space, + self_check_release as _staging_self_check_release, + stage_and_install_release as _staging_stage_and_install_release, + tree_digest as _staging_tree_digest, + validate_release_directory as _staging_validate_release_directory, + verify_file_inventory as _staging_verify_file_inventory, + verify_manifest_files as _staging_verify_manifest_files, + verify_retained_release_integrity as _staging_verify_retained_integrity, + write_release_integrity_record as _staging_write_integrity_record, +) +from ..paths import AppPaths +from ..resource_paths import launcher_public_key_path +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, +) + + +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 + + +# Retain private parsing helpers that historical tests and tooling may import +# while their implementation lives in the launcher-update feature package. +_PARSING_COMPATIBILITY_EXPORTS = ( + _parse_history_compatibility, + _parse_published_at, + _optional_positive_integer, + _resource_url, + _required_integer, + _required_string, + _signature_url, +) +_MODEL_COMPATIBILITY_EXPORTS = (LauncherInstalledRelease,) + + +def _channel_discovery_policy() -> _ChannelDiscoveryPolicy: + return _ChannelDiscoveryPolicy( + stable_channel=STABLE_CHANNEL, + prerelease_channel=PRERELEASE_CHANNEL, + default_stable_manifest_url=DEFAULT_STABLE_UPDATE_MANIFEST_URL, + default_prerelease_manifest_url=DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL, + default_history_url=DEFAULT_UPDATE_HISTORY_URL, + manifest_url_variable=UPDATE_MANIFEST_URL_VARIABLE, + stable_manifest_url_variable=UPDATE_STABLE_MANIFEST_URL_VARIABLE, + prerelease_manifest_url_variable=UPDATE_PRERELEASE_MANIFEST_URL_VARIABLE, + history_url_variable=UPDATE_HISTORY_URL_VARIABLE, + public_key_variable=UPDATE_PUBLIC_KEY_VARIABLE, + ) + + +def default_public_key_path() -> Path: + return launcher_public_key_path() + + +def configured_manifest_url( + environment: Mapping[str, str] | None = None, + *, + channel: str = STABLE_CHANNEL, +) -> str: + return _configured_manifest_url( + environment, + channel=channel, + policy=_channel_discovery_policy(), + ) + + +def configured_manifest_urls( + *, + channel: str = STABLE_CHANNEL, + environment: Mapping[str, str] | None = None, +) -> tuple[tuple[str, str, bool], ...]: + return _configured_manifest_urls( + channel=channel, + environment=environment, + policy=_channel_discovery_policy(), + ) + + +def configured_public_key_path( + environment: Mapping[str, str] | None = None, +) -> Path: + return _configured_public_key_path( + environment, + variable=UPDATE_PUBLIC_KEY_VARIABLE, + default_path=default_public_key_path(), + ) + + +def configured_history_url( + environment: Mapping[str, str] | None = None, +) -> str: + return _configured_history_url( + environment, + policy=_channel_discovery_policy(), + ) + + +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: + url = history_url or configured_history_url(environment) + key = public_key_path or configured_public_key_path(environment) + return _discovery_fetch_release_history( + history_url=url, + public_key_path=key, + timeout_seconds=timeout_seconds, + history_bytes_limit=MAX_HISTORY_BYTES, + signature_bytes_limit=MAX_SIGNATURE_BYTES, + small_file_fetcher=_fetch_small_file, + verify_signature=_verify_manifest_signature, + parse_history=parse_launcher_release_history, + ) + + +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: + return _discovery_fetch_authenticated_manifest( + url, + public_key_path, + timeout_seconds=timeout_seconds, + manifest_bytes_limit=MAX_MANIFEST_BYTES, + signature_bytes_limit=MAX_SIGNATURE_BYTES, + small_file_fetcher=_fetch_small_file, + verify_signature=_verify_manifest_signature, + parse_manifest=parse_update_manifest, + ) + + +def _fetch_historical_manifest( + release: LauncherRemoteRelease, + public_key_path: Path, + *, + timeout_seconds: float, +) -> UpdateManifest: + return _discovery_fetch_historical_manifest( + release, + public_key_path, + timeout_seconds=timeout_seconds, + manifest_bytes_limit=MAX_MANIFEST_BYTES, + signature_bytes_limit=MAX_SIGNATURE_BYTES, + small_file_fetcher=_fetch_small_file, + verify_signature=_verify_manifest_signature, + parse_manifest=parse_update_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, ...]: + return _rollback_launcher_candidates( + history, + installed_version=installed_version, + selected_channel=selected_channel, + show_prereleases=show_prereleases, + ) + + +def _rollback_timestamp() -> str: + return datetime.now().astimezone().isoformat() + + +def _rollback_operations() -> _RollbackOperations: + return _RollbackOperations( + mutation_lock=_launcher_mutation_lock, + recover_interrupted=_recover_interrupted_activation, + resolve_link=_release_from_link, + validate_release=_validate_release_directory, + parse_version=_parse_version, + verify_retained_integrity=_verify_retained_release_integrity, + build_release_metadata=_launcher_release_metadata, + emit_event=_send_launcher_release_event, + fetch_history=fetch_launcher_release_history, + rollback_candidates=launcher_rollback_candidates, + configured_public_key=configured_public_key_path, + fetch_historical_manifest=_fetch_historical_manifest, + validate_manifest_compatibility=_validate_rollback_config_compatibility, + precheck_install=_precheck_release_install, + download_archive=_download_archive, + stage_release=_stage_and_install_release, + current_config_schema=_current_config_schema, + validate_schema_range=_validate_config_schema_range, + validate_release_compatibility=_validate_rollback_release_compatibility, + activate_release=_activate_release, + record_operation=_record_launcher_operation, + write_json=_write_json, + current_timestamp=_rollback_timestamp, + ) + + +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: + return _rollback_launcher_update( + paths, + target_version=target_version, + selected_channel=selected_channel, + history_url=history_url, + public_key_path=public_key_path, + environment=environment, + timeout_seconds=timeout_seconds, + reason=reason, + operations=_rollback_operations(), + ) + + +def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory: + return _rollback_inspect_update_history( + paths, + resolve_link=_release_from_link, + validate_release=_validate_release_directory, + parse_version=_parse_version, + release_version=_release_version, + ) + + +def _update_parsing_policy() -> _UpdateParsingPolicy: + return _UpdateParsingPolicy( + application=UPDATE_APPLICATION, + manifest_bytes=MAX_MANIFEST_BYTES, + history_bytes=MAX_HISTORY_BYTES, + embedded_release_notes_bytes=MAX_EMBEDDED_RELEASE_NOTES_BYTES, + archive_bytes=MAX_ARCHIVE_BYTES, + extracted_bytes=MAX_EXTRACTED_BYTES, + archive_members=MAX_ARCHIVE_MEMBERS, + release_history_schema=RELEASE_HISTORY_SCHEMA, + release_history_entries=MAX_RELEASE_HISTORY, + sha256_pattern=_SHA256_PATTERN, + allowed_release_roots=_ALLOWED_RELEASE_ROOTS, + active_status=ACTIVE_STATUS, + supported_release_statuses=SUPPORTED_RELEASE_STATUSES, + ) + + +def parse_launcher_release_history( + data: bytes, + *, + history_url: str, +) -> LauncherRemoteHistory: + return _parse_launcher_release_history( + data, + history_url=history_url, + policy=_update_parsing_policy(), + ) + + +def parse_update_manifest(data: bytes) -> UpdateManifest: + return _parse_update_manifest(data, policy=_update_parsing_policy()) + + +def _parse_manifest_files(value: object) -> tuple[ManifestFile, ...]: + return _parse_update_manifest_files( + value, + policy=_update_parsing_policy(), + ) + + +def _fetch_small_file( + url: str, + *, + maximum_bytes: int, + timeout_seconds: float, + label: str, +) -> bytes: + return _discovery_fetch_small_file( + url, + maximum_bytes=maximum_bytes, + timeout_seconds=timeout_seconds, + label=label, + user_agent=USER_AGENT, + opener=lambda request, *, timeout: urlopen(request, timeout=timeout), + ) + + +def _verify_manifest_signature( + manifest: bytes, + signature: bytes, + public_key_path: Path, +) -> None: + _discovery_verify_signature( + manifest, + signature, + public_key_path, + maximum_signature_bytes=MAX_SIGNATURE_BYTES, + ) + + +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"}, + ) + written = 0 + + def report_download_bytes(byte_count: int) -> None: + nonlocal written + written = byte_count + + try: + written = _download_archive_file( + manifest, + destination, + timeout_seconds=timeout_seconds, + user_agent=USER_AGENT, + read_size=READ_SIZE, + maximum_archive_bytes=MAX_ARCHIVE_BYTES, + opener=lambda request, *, timeout: urlopen(request, timeout=timeout), + content_length=_content_length, + fsync_directory=_fsync_directory, + progress=report_download_bytes, + process_id=lambda: os.getpid(), + ) + except BaseException as exc: + 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: + return _staging_stage_and_install_release( + paths, + archive, + manifest, + extract_archive=_extract_release_archive, + verify_files=_verify_manifest_files, + validate_release=_validate_release_directory, + self_check=_self_check_release, + digest_tree=_tree_digest, + fsync_directory=_fsync_directory, + record_integrity=partial(_write_release_integrity_record, paths), + process_id=os.getpid, + ) + + +def _verify_manifest_files(release: Path, manifest: UpdateManifest) -> None: + _staging_verify_manifest_files(release, manifest, hash_file=_sha256_file) + + +def _verify_file_inventory( + release: Path, + files: tuple[ManifestFile, ...], +) -> None: + _staging_verify_file_inventory(release, files, hash_file=_sha256_file) + + +def _write_release_integrity_record( + paths: AppPaths, + release: Path, + manifest: UpdateManifest, +) -> None: + _staging_write_integrity_record( + paths.launcher_release_integrity, + paths.launcher_releases, + release, + manifest, + write_json=_write_json, + ) + + +def _verify_retained_release_integrity( + paths: AppPaths, + release: Path, + version: str, +) -> tuple[int, int] | None: + return _staging_verify_retained_integrity( + paths.launcher_release_integrity, + release, + version, + maximum_record_bytes=MAX_MANIFEST_BYTES, + sha256_pattern=_SHA256_PATTERN, + unique_json_object=_unique_json_object, + parse_version=_parse_version, + required_positive_integer=_required_positive_integer, + parse_manifest_files=_parse_manifest_files, + verify_inventory=_verify_file_inventory, + ) + + +def _extract_release_archive(archive: Path, destination: Path, version: str) -> None: + _extract_archive( + archive, + destination, + version, + archive_prefix=UPDATE_ARCHIVE_PREFIX, + allowed_release_roots=_ALLOWED_RELEASE_ROOTS, + maximum_archive_members=MAX_ARCHIVE_MEMBERS, + maximum_extracted_bytes=MAX_EXTRACTED_BYTES, + read_size=READ_SIZE, + ) + + +def _validate_release_directory(release: Path) -> str: + return _staging_validate_release_directory( + release, + public_key_filename=PUBLIC_KEY_FILENAME, + remove_bytecode_caches=_remove_generated_bytecode_caches, + read_metadata=_read_release_metadata, + parse_version=_parse_version, + ) + + +def _remove_generated_bytecode_caches(release: Path) -> None: + _staging_remove_bytecode_caches(release) + + +def _self_check_release(release: Path, expected_version: str) -> None: + _staging_self_check_release( + release, + expected_version, + python_executable=sys.executable, + ) + + +@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: + _staging_precheck_release_install( + paths, + manifest, + validate_config_compatibility=partial( + _validate_rollback_config_compatibility, + paths, + ), + archive_matches=_archive_matches, + check_free_space=_require_free_space, + maximum_extracted_bytes=MAX_EXTRACTED_BYTES, + ) + + +def _require_free_space(path: Path, required_bytes: int, label: str) -> None: + _staging_require_free_space( + path, + required_bytes, + label, + disk_usage=shutil.disk_usage, + ) + + +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]: + return _staging_read_release_metadata( + release, + unique_json_object=_unique_json_object, + ) + + +def _activate_release( + paths: AppPaths, + release: Path, + *, + telemetry_metadata: Mapping[str, object] | None = None, + restore_reason: str = "failed_update", +) -> Path | None: + return _activation_activate_release( + paths, + release, + telemetry_metadata=telemetry_metadata, + restore_reason=restore_reason, + recover=_recover_interrupted_activation, + resolve_link=_release_from_link, + validate=_validate_release_directory, + write_journal=_write_activation_journal, + replace_link=_replace_release_link, + sync_directory=_fsync_directory, + restore_snapshot=_restore_release_links, + emit_event=_send_launcher_release_event, + ) + + +def _recover_interrupted_activation(paths: AppPaths) -> None: + _activation_recover_interrupted( + paths, + read_journal=_read_activation_journal, + resolve_journal_release=_journal_release, + validate_version=_journal_version, + validate=_validate_release_directory, + resolve_link=_release_from_link, + build_recovery_metadata=_recovery_telemetry_metadata, + emit_event=_send_launcher_release_event, + restore_snapshot=_restore_release_links, + sync_directory=_fsync_directory, + ) + + +def _recovery_telemetry_metadata( + current: Path | None, + target_version: str, +) -> dict[str, object]: + return _activation_recovery_metadata( + current, + target_version, + running_version=__version__, + release_metadata=_launcher_release_metadata, + ) + + +def _read_activation_journal(paths: AppPaths) -> _ActivationJournal | None: + return _activation_read_journal( + paths, + unique_json_object=_unique_json_object, + ) + + +def _journal_version(value: object, label: str) -> str: + return _activation_journal_version(value, label) + + +def _journal_release(paths: AppPaths, value: object, label: str) -> Path | None: + return _activation_journal_release( + paths, + value, + label, + validate_release=_validate_release_directory, + ) + + +def _restore_release_links( + paths: AppPaths, + *, + current: Path | None, + previous: Path | None, +) -> None: + _activation_restore_release_links( + paths, + current=current, + previous=previous, + set_link=_set_release_link, + fsync_directory=_fsync_directory, + ) + + +def _set_release_link(link: Path, release: Path | None, releases: Path) -> None: + _activation_set_release_link( + link, + release, + releases, + replace_link=_replace_release_link, + ) + + +def _write_activation_journal( + paths: AppPaths, + *, + state: str, + current: Path | None, + previous: Path | None, + target: Path, +) -> None: + _activation_write_journal( + paths, + state=state, + current=current, + previous=previous, + target=target, + updated_at=datetime.now().astimezone().isoformat(timespec="seconds"), + process_id=os.getpid(), + fsync_directory=_fsync_directory, + ) + + +def _replace_release_link(link: Path, release: Path, releases: Path) -> None: + _activation_replace_release_link( + link, + release, + releases, + validate_release=_validate_release_directory, + process_id=os.getpid(), + ) + + +def _release_from_link(link: Path, releases: Path) -> Path | None: + return _activation_release_from_link(link, releases) + + +def _archive_matches(path: Path, manifest: UpdateManifest) -> bool: + return _archive_file_matches( + path, + manifest, + read_size=READ_SIZE, + hash_file=_sha256_file, + ) + + +def _tree_digest(root: Path) -> str: + return _staging_tree_digest(root, read_size=READ_SIZE) + + +def _sha256_file(path: Path) -> str: + return _archive_sha256_file(path, read_size=READ_SIZE) + + +def _content_length(value: str | None, label: str) -> int | None: + return _discovery_content_length(value, label) + + +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/src/ptlinux/launcher_updates/staging.py b/src/ptlinux/launcher_updates/staging.py new file mode 100644 index 0000000..f6d5e1e --- /dev/null +++ b/src/ptlinux/launcher_updates/staging.py @@ -0,0 +1,551 @@ +"""Launcher release staging and signed file-inventory verification.""" + +from __future__ import annotations + +from dataclasses import asdict +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +from typing import Callable, Protocol, cast + +from ..update_resolver import SemanticVersion +from .models import LauncherUpdateError, ManifestFile, UpdateManifest + + +class StagingPaths(Protocol): + @property + def app_home(self) -> Path: ... + + @property + def downloads(self) -> Path: ... + + @property + def launcher_release_integrity(self) -> Path: ... + + @property + def launcher_releases(self) -> Path: ... + + def launcher_update_archive(self, version: str) -> Path: ... + + +class DiskUsage(Protocol): + free: int + + +def stage_and_install_release( + paths: StagingPaths, + archive: Path, + manifest: UpdateManifest, + *, + extract_archive: Callable[[Path, Path, str], None], + verify_files: Callable[[Path, UpdateManifest], None], + validate_release: Callable[[Path], str], + self_check: Callable[[Path, str], None], + digest_tree: Callable[[Path], str], + fsync_directory: Callable[[Path], None], + record_integrity: Callable[[Path, UpdateManifest], None], + process_id: Callable[[], int] = os.getpid, +) -> Path: + """Stage, verify, and retain one immutable launcher release.""" + + 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}.{process_id()}.staging" + ) + if staging.exists() or staging.is_symlink(): + raise LauncherUpdateError(f"launcher staging path already exists: {staging}") + staging.mkdir(mode=0o700) + try: + extract_archive(archive, staging, manifest.version) + verify_files(staging, manifest) + staged_version = validate_release(staging) + if staged_version != manifest.version: + raise LauncherUpdateError( + f"staged launcher version is {staged_version}, " + f"expected {manifest.version}" + ) + self_check(staging, manifest.version) + validate_release(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( + "launcher release destination is not a directory: " + f"{destination}" + ) + validate_release(destination) + if digest_tree(destination) != digest_tree(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) + record_integrity(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, + *, + hash_file: Callable[[Path], str], +) -> None: + verify_file_inventory(release, manifest.files, hash_file=hash_file) + + +def verify_file_inventory( + release: Path, + files: tuple[ManifestFile, ...], + *, + hash_file: Callable[[Path], str], +) -> None: + """Verify that a release exactly matches its signed file inventory.""" + + 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( + "launcher archive file is absent from its signed manifest: " + f"{relative}" + ) + if path.stat().st_size != entry.size or hash_file(path) != entry.sha256: + raise LauncherUpdateError( + "launcher archive file does not match its signed manifest: " + f"{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( + integrity_directory: Path, + releases_directory: Path, + release: Path, + manifest: UpdateManifest, + *, + write_json: Callable[[Path, object], None], +) -> None: + if not manifest.files: + return + if integrity_directory.is_symlink(): + raise LauncherUpdateError( + "launcher release-integrity directory cannot be a link: " + f"{integrity_directory}" + ) + integrity_directory.mkdir(parents=True, exist_ok=True, mode=0o700) + integrity_directory.chmod(0o700) + if release.resolve() != (releases_directory / manifest.version).resolve(): + raise LauncherUpdateError( + "launcher integrity record release path is inconsistent" + ) + write_json( + integrity_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( + integrity_directory: Path, + release: Path, + version: str, + *, + maximum_record_bytes: int, + sha256_pattern: re.Pattern[str], + unique_json_object: Callable[[list[tuple[str, object]]], dict[str, object]], + parse_version: Callable[[str, str], SemanticVersion], + required_positive_integer: Callable[[dict[str, object], str], int], + parse_manifest_files: Callable[[object], tuple[ManifestFile, ...]], + verify_inventory: Callable[[Path, tuple[ManifestFile, ...]], None], +) -> tuple[int, int] | None: + """Verify a retained release against its install-time signed inventory.""" + + record = integrity_directory / 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: + return None + except OSError as exc: + raise LauncherUpdateError( + f"cannot read launcher integrity record: {exc}" + ) from exc + if not data or len(data) > maximum_record_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_inventory(release, files) + return config_schema, minimum_config_schema + + +def validate_release_directory( + release: Path, + *, + public_key_filename: str, + remove_bytecode_caches: Callable[[Path], None], + read_metadata: Callable[[Path], dict[str, object]], + parse_version: Callable[[str, str], SemanticVersion], +) -> str: + """Validate permissions, identity, and required compatibility files.""" + + if not release.is_dir() or release.is_symlink(): + raise LauncherUpdateError(f"launcher release directory is invalid: {release}") + remove_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_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( + "launcher bytecode cache contains an unexpected path: " + f"{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, + *, + python_executable: str = sys.executable, +) -> None: + """Import a staged launcher in an isolated interpreter process.""" + + 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( + [ + python_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 "") + ) + + +def precheck_release_install( + paths: StagingPaths, + manifest: UpdateManifest, + *, + validate_config_compatibility: Callable[[UpdateManifest], None], + archive_matches: Callable[[Path, UpdateManifest], bool], + check_free_space: Callable[[Path, int, str], None], + maximum_extracted_bytes: int, +) -> None: + """Reject an update before download when transaction prerequisites fail.""" + + validate_config_compatibility(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(maximum_extracted_bytes, manifest.archive_size * 4) + ) + check_free_space(paths.downloads, cache_bytes + 1024 * 1024, "download") + check_free_space(paths.app_home, extracted_bytes + 1024 * 1024, "staging") + + +def require_free_space( + path: Path, + required_bytes: int, + label: str, + *, + disk_usage: Callable[[Path], object] = shutil.disk_usage, +) -> None: + try: + free_bytes = cast(DiskUsage, 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 read_release_metadata( + release: Path, + *, + unique_json_object: Callable[[list[tuple[str, object]]], dict[str, object]], +) -> 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 tree_digest(root: Path, *, read_size: int) -> str: + """Hash release paths, modes, sizes, and contents deterministically.""" + + 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() + + +__all__ = [ + "StagingPaths", + "precheck_release_install", + "read_release_metadata", + "remove_generated_bytecode_caches", + "require_free_space", + "self_check_release", + "stage_and_install_release", + "tree_digest", + "validate_release_directory", + "verify_file_inventory", + "verify_manifest_files", + "verify_retained_release_integrity", + "write_release_integrity_record", +] diff --git a/ptlinux/launcher_wrapper.py b/src/ptlinux/launcher_wrapper.py similarity index 100% rename from ptlinux/launcher_wrapper.py rename to src/ptlinux/launcher_wrapper.py diff --git a/ptlinux/locking.py b/src/ptlinux/locking.py similarity index 100% rename from ptlinux/locking.py rename to src/ptlinux/locking.py diff --git a/ptlinux/login_diagnostics.py b/src/ptlinux/login_diagnostics.py similarity index 100% rename from ptlinux/login_diagnostics.py rename to src/ptlinux/login_diagnostics.py diff --git a/ptlinux/login_trace.py b/src/ptlinux/login_trace.py similarity index 100% rename from ptlinux/login_trace.py rename to src/ptlinux/login_trace.py diff --git a/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64 b/src/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64 similarity index 100% rename from ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64 rename to src/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64 diff --git a/ptlinux/native/pt-identity-probe.exe b/src/ptlinux/native/pt-identity-probe.exe similarity index 100% rename from ptlinux/native/pt-identity-probe.exe rename to src/ptlinux/native/pt-identity-probe.exe diff --git a/src/ptlinux/native/ptr-bcrypt-proxy.dll b/src/ptlinux/native/ptr-bcrypt-proxy.dll new file mode 100755 index 0000000..f9e734d Binary files /dev/null and b/src/ptlinux/native/ptr-bcrypt-proxy.dll differ diff --git a/ptlinux/news.py b/src/ptlinux/news.py similarity index 100% rename from ptlinux/news.py rename to src/ptlinux/news.py diff --git a/src/ptlinux/overlay.py b/src/ptlinux/overlay.py new file mode 100644 index 0000000..f3d556f --- /dev/null +++ b/src/ptlinux/overlay.py @@ -0,0 +1,10 @@ +"""Compatibility alias for the game runtime-view implementation.""" + +from __future__ import annotations + +import sys + +from .game import runtime_view as _implementation + + +sys.modules[__name__] = _implementation diff --git a/ptlinux/paths.py b/src/ptlinux/paths.py similarity index 100% rename from ptlinux/paths.py rename to src/ptlinux/paths.py diff --git a/src/ptlinux/presentation/__init__.py b/src/ptlinux/presentation/__init__.py new file mode 100644 index 0000000..adbc8c7 --- /dev/null +++ b/src/ptlinux/presentation/__init__.py @@ -0,0 +1 @@ +"""Game-window and Gamescope presentation services.""" diff --git a/ptlinux/gamebox_runtime.py b/src/ptlinux/presentation/game_window.py similarity index 99% rename from ptlinux/gamebox_runtime.py rename to src/ptlinux/presentation/game_window.py index de7e41b..b45189c 100644 --- a/ptlinux/gamebox_runtime.py +++ b/src/ptlinux/presentation/game_window.py @@ -1,4 +1,4 @@ -"""Map and style the real Wine game window on the host X11 display.""" +"""Map, style, and supervise the real Wine game window on X11.""" from __future__ import annotations @@ -13,12 +13,12 @@ import sys import time from collections.abc import Collection, Iterable, Mapping, Sequence -from .desktop_identity import ( +from ..desktop_identity import ( APPLICATION_NAME, APPLICATION_WM_CLASS, APPLICATION_WM_INSTANCE, ) -from .window_diagnostics import WindowDiagnosticRecorder +from ..window_diagnostics import WindowDiagnosticRecorder WINDOW_TITLE = APPLICATION_NAME WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0 @@ -89,6 +89,12 @@ def _matches_game_window_title(title: str, expected_title: str) -> bool: expected = expected_title.strip().casefold() if not candidate or not expected: return False + # The publisher prefixes PTR client titles with ``PTR -`` while retaining + # the regular game title and version suffix. Treat that publisher qualifier + # as part of the game title so launch readiness and later focus repair see + # the same client window. + if candidate.startswith("ptr - "): + candidate = candidate.removeprefix("ptr - ").lstrip() return candidate == expected or candidate.startswith(f"{expected} ") diff --git a/ptlinux/gamescope_runtime.py b/src/ptlinux/presentation/gamescope.py similarity index 99% rename from ptlinux/gamescope_runtime.py rename to src/ptlinux/presentation/gamescope.py index 0559bd5..4372201 100644 --- a/ptlinux/gamescope_runtime.py +++ b/src/ptlinux/presentation/gamescope.py @@ -1,4 +1,4 @@ -"""Launch Gamescope with low-overhead host-window resize behavior.""" +"""Launch and supervise Gamescope's resizable host presentation.""" from __future__ import annotations @@ -14,7 +14,7 @@ import tempfile import time from collections.abc import Mapping, Sequence -from .gamebox_runtime import ( +from .game_window import ( GAMESCOPE_HOST_FOCUS_STATE_ENV, GameboxRuntimeError, _X11, @@ -23,7 +23,8 @@ from .gamebox_runtime import ( _sample_window_diagnostic_details, _wm_state_name, ) -from .window_diagnostics import WindowDiagnosticRecorder +from ..resource_paths import native_resource_path +from ..window_diagnostics import WindowDiagnosticRecorder WINDOW_DISCOVERY_TIMEOUT_SECONDS = 10.0 WINDOW_DISCOVERY_POLL_SECONDS = 0.05 @@ -382,7 +383,7 @@ def _read_gamescope_version(executable: str) -> tuple[int, int, int] | None: def _bundled_gamescope_path() -> Path: - return Path(__file__).resolve().parent / "native" / BUNDLED_GAMESCOPE_FILENAME + return native_resource_path(BUNDLED_GAMESCOPE_FILENAME) def _bundled_gamescope_host_supported() -> bool: diff --git a/ptlinux/gamescope_child_runtime.py b/src/ptlinux/presentation/gamescope_child.py similarity index 95% rename from ptlinux/gamescope_child_runtime.py rename to src/ptlinux/presentation/gamescope_child.py index 3ec648f..0be2e4d 100644 --- a/ptlinux/gamescope_child_runtime.py +++ b/src/ptlinux/presentation/gamescope_child.py @@ -1,4 +1,4 @@ -"""Report the nested client status to the outer Gamescope supervisor.""" +"""Run a nested client and report its status to the Gamescope supervisor.""" from __future__ import annotations diff --git a/ptlinux/proton_compat.py b/src/ptlinux/proton_compat.py similarity index 100% rename from ptlinux/proton_compat.py rename to src/ptlinux/proton_compat.py diff --git a/src/ptlinux/resource_paths.py b/src/ptlinux/resource_paths.py new file mode 100644 index 0000000..a77110c --- /dev/null +++ b/src/ptlinux/resource_paths.py @@ -0,0 +1,41 @@ +"""Stable locations for files shipped beside the launcher package.""" + +from __future__ import annotations + +from pathlib import Path + + +PACKAGE_DIRECTORY = Path(__file__).resolve().parent +NATIVE_DIRECTORY = PACKAGE_DIRECTORY / "native" +LAUNCHER_PUBLIC_KEY_FILENAME = "launcher-update-public.pem" + + +def package_resource_path(filename: str) -> Path: + """Return one package-root file without accepting a nested or unsafe name.""" + + _validate_filename(filename) + return PACKAGE_DIRECTORY / filename + + +def native_resource_path(filename: str) -> Path: + """Return one native package resource at its compatibility location.""" + + _validate_filename(filename) + return NATIVE_DIRECTORY / filename + + +def launcher_public_key_path() -> Path: + """Return the deployed launcher-update verification key path.""" + + return package_resource_path(LAUNCHER_PUBLIC_KEY_FILENAME) + + +def _validate_filename(filename: str) -> None: + if ( + not filename + or filename in {".", ".."} + or "/" in filename + or "\\" in filename + or "\x00" in filename + ): + raise ValueError(f"resource filename must be one safe path component: {filename!r}") diff --git a/src/ptlinux/runtime.py b/src/ptlinux/runtime.py new file mode 100644 index 0000000..15dc7ea --- /dev/null +++ b/src/ptlinux/runtime.py @@ -0,0 +1,10 @@ +"""Compatibility alias for the game runtime implementation.""" + +from __future__ import annotations + +import sys + +from .game import runtime as _implementation + + +sys.modules[__name__] = _implementation diff --git a/src/ptlinux/self_update.py b/src/ptlinux/self_update.py new file mode 100644 index 0000000..d12e7d9 --- /dev/null +++ b/src/ptlinux/self_update.py @@ -0,0 +1,10 @@ +"""Compatibility alias for launcher-update orchestration.""" + +from __future__ import annotations + +import sys + +from .launcher_updates import service as _implementation + + +sys.modules[__name__] = _implementation diff --git a/ptlinux/server_status.py b/src/ptlinux/server_status.py similarity index 97% rename from ptlinux/server_status.py rename to src/ptlinux/server_status.py index 16a1dd5..f5933c4 100644 --- a/ptlinux/server_status.py +++ b/src/ptlinux/server_status.py @@ -8,6 +8,7 @@ from typing import Callable, Mapping, Protocol from urllib.request import Request, urlopen from . import __version__ +from .game.depot.realms import launcher_realm_name # This is the same publisher endpoint embedded in Windows launcher 1.0.0.64. @@ -123,7 +124,7 @@ def _parse_realm( if not service_states and players_online is None: return None online = all(service_states) if service_states else None - display_name = name[0].upper() + name[1:] + display_name = launcher_realm_name(name[0].upper() + name[1:]) return RealmServerStatus( name=display_name, online=online, diff --git a/ptlinux/settings.py b/src/ptlinux/settings.py similarity index 100% rename from ptlinux/settings.py rename to src/ptlinux/settings.py diff --git a/ptlinux/system_identity.py b/src/ptlinux/system_identity.py similarity index 100% rename from ptlinux/system_identity.py rename to src/ptlinux/system_identity.py diff --git a/src/ptlinux/telemetry/__init__.py b/src/ptlinux/telemetry/__init__.py new file mode 100644 index 0000000..2591dd6 --- /dev/null +++ b/src/ptlinux/telemetry/__init__.py @@ -0,0 +1,423 @@ +"""Anonymous, best-effort launcher telemetry with bounded asynchronous delivery.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +import json +import math +import os +from pathlib import Path +import platform +import re +import resource +import socket +import sys +import threading +import time +from urllib.parse import urlsplit +from urllib.request import Request, urlopen +import uuid + +from ..paths import AppPaths +from ..system_identity import SystemIdentity, detect_system_identity, read_os_release +from . import environment as _environment_helpers +from . import errors as _error_helpers +from .extended import collect_extended_environment +from . import identity as _identity_helpers +from . import payload as _payload_helpers +from . import privacy as _privacy_helpers +from . import transport as _transport_helpers +from .service import ( + DEFAULT_TELEMETRY_ENDPOINT, + DELIVERY_TIMEOUT_SECONDS, + ERROR_SUPPRESSION_WINDOW_SECONDS, + MAX_CONTAINER_ITEMS, + MAX_CONTEXT_BYTES, + MAX_ERROR_FINGERPRINTS, + MAX_MESSAGE_BYTES, + MAX_METADATA_BYTES, + MAX_REQUEST_BYTES, + MAX_STDERR_CHARACTERS, + MAX_STDOUT_CHARACTERS, + MAX_TRACEBACK_CHARACTERS, + MAX_VALUE_DEPTH, + QUEUE_CAPACITY, + SHUTDOWN_WAIT_SECONDS, + TELEMETRY_DEBUG_VARIABLE, + TELEMETRY_ENDPOINT_VARIABLE, + TELEMETRY_KEY_VARIABLE, + TELEMETRY_OPERATION_PROCESS_ROLE, + TELEMETRY_PROCESS_ROLE_VARIABLE, + TelemetryService as _TelemetryService, + TelemetryTransport, + VALID_EVENTS, + VALID_SEVERITIES, + _RepeatedErrorState, + _RESOURCE_USAGE_EVENTS, + _is_error_event, + _string_field, +) + + +_environment_current_game_version = _environment_helpers.current_game_version +_environment_display_info = _environment_helpers.display_info +_environment_execution_info = _environment_helpers.execution_environment_info +_environment_filesystem_info = _environment_helpers.filesystem_info +_environment_install_is_external = _environment_helpers.install_is_external +_environment_operating_system_info = _environment_helpers.operating_system_info +_environment_read_mountinfo = _environment_helpers.read_mountinfo +_environment_read_os_release = _environment_helpers.read_os_release +_environment_resource_usage = _environment_helpers.resource_usage +_environment_settings_schema_version = _environment_helpers.settings_schema_version +_environment_unescape_mount_field = _environment_helpers.unescape_mount_field +_error_classify_network_failure = _error_helpers.classify_network_failure +_error_fingerprint = _error_helpers.error_fingerprint +_error_network_http_status = _error_helpers.network_http_status +_error_normalized_message = _error_helpers.normalized_error_message +_identity_fsync_directory = _identity_helpers.fsync_directory +_identity_load_or_create = _identity_helpers.load_or_create_installation_id +_identity_read = _identity_helpers.read_installation_id +_payload_bound_mapping = _payload_helpers.bound_mapping +_payload_encode = _payload_helpers.encode_payload +_payload_json_bytes = _payload_helpers.json_bytes +_AUTHORIZATION_PATTERN = _privacy_helpers.AUTHORIZATION_PATTERN +_EMAIL_PATTERN = _privacy_helpers.EMAIL_PATTERN +_HOME_PATTERN = _privacy_helpers.HOME_PATTERN +_SECRET_ASSIGNMENT_PATTERN = _privacy_helpers.SECRET_ASSIGNMENT_PATTERN +_SECRET_KEYS = _privacy_helpers.SECRET_KEYS +_SECRET_QUERY_KEYS = _privacy_helpers.SECRET_QUERY_KEYS +_SSH_PATH_PATTERN = _privacy_helpers.SSH_PATH_PATTERN +_URL_PATTERN = _privacy_helpers.URL_PATTERN +_WINDOWS_USER_PATTERN = _privacy_helpers.WINDOWS_USER_PATTERN +_PrivacyPolicy = _privacy_helpers.PrivacyPolicy +_privacy_is_secret_key = _privacy_helpers.is_secret_key +_privacy_keep_string_tail = _privacy_helpers.keep_string_tail +_privacy_sanitize_text = _privacy_helpers.sanitize_text +_privacy_sanitize_url_match = _privacy_helpers.sanitize_url_match +_privacy_sanitize_value = _privacy_helpers.sanitize_value +_privacy_string_limit_for_key = _privacy_helpers.string_limit_for_key +_privacy_truncate_characters = _privacy_helpers.truncate_characters +_privacy_truncate_utf8 = _privacy_helpers.truncate_utf8 +_transport_post_json = _transport_helpers.post_json +_transport_validate_endpoint = _transport_helpers.validate_endpoint + + +_NETWORK_FILESYSTEMS = _environment_helpers.NETWORK_FILESYSTEMS +_UUID_PATTERN = _error_helpers.UUID_PATTERN +_MEMORY_ADDRESS_PATTERN = _error_helpers.MEMORY_ADDRESS_PATTERN +_HTTP_STATUS_PATTERN = _error_helpers.HTTP_STATUS_PATTERN +_INSTALL_ID_FILE_MODE = _identity_helpers.INSTALL_ID_FILE_MODE + + +class TelemetryService(_TelemetryService): + """Stable facade for the extracted telemetry service implementation.""" + + def __init__( + self, + paths: AppPaths, + *, + enabled: bool = True, + endpoint: str | None = None, + launcher_key: str | None = None, + environment: Mapping[str, str] | None = None, + transport: TelemetryTransport | None = None, + session_metadata: Mapping[str, object] | None = None, + report_launcher_lifecycle: bool | None = None, + monotonic: Callable[[], float] = time.monotonic, + ) -> None: + super().__init__( + paths, + _facade=sys.modules[__name__], + enabled=enabled, + endpoint=endpoint, + launcher_key=launcher_key, + environment=environment, + transport=transport, + session_metadata=session_metadata, + report_launcher_lifecycle=report_launcher_lifecycle, + monotonic=monotonic, + ) + + +_shared_service: TelemetryService | None = None +_shared_service_lock = threading.Lock() + + +def initialize_shared_telemetry( + paths: AppPaths, + *, + session_metadata: Mapping[str, object] | None = None, + report_launcher_lifecycle: bool | None = None, +) -> tuple[TelemetryService, bool]: + """Return the process-wide service and whether this caller owns its shutdown.""" + + global _shared_service + with _shared_service_lock: + if _shared_service is not None: + return _shared_service, False + service = TelemetryService( + paths, + enabled=_telemetry_enabled_from_settings(paths), + session_metadata=session_metadata, + report_launcher_lifecycle=report_launcher_lifecycle, + ) + _shared_service = service + service.initialize() + return service, True + + +def current_telemetry() -> TelemetryService | None: + return _shared_service + + +def set_shared_telemetry_enabled(enabled: bool) -> None: + service = _shared_service + if service is not None: + service.set_enabled(enabled) + + +def shutdown_shared_telemetry( + service: TelemetryService, + *, + timeout_seconds: float = SHUTDOWN_WAIT_SECONDS, +) -> None: + global _shared_service + service.shutdown(timeout_seconds=timeout_seconds) + with _shared_service_lock: + if _shared_service is service: + _shared_service = None + + +def _telemetry_enabled_from_settings(paths: AppPaths) -> bool: + try: + from ..settings import Settings, load_settings + + if not paths.settings_file.is_file(): + return Settings().telemetry_enabled + return load_settings(paths).telemetry_enabled + except (OSError, ValueError): + return False + + +def _post_json( + endpoint: str, + body: bytes, + headers: Mapping[str, str], + timeout_seconds: float, +) -> None: + _transport_post_json( + endpoint, + body, + headers, + timeout_seconds, + request_factory=Request, + opener=urlopen, + ) + + +def _validate_endpoint(endpoint: str) -> None: + _transport_validate_endpoint(endpoint, split_url=urlsplit) + + +def _load_or_create_installation_id(path: Path) -> tuple[str, bool, str]: + return _identity_load_or_create( + path, + read_identity=_read_installation_id, + make_uuid=uuid.uuid4, + process_id=os.getpid, + thread_identity=threading.get_ident, + sync_directory=_fsync_directory, + first_seen_from_timestamp=lambda timestamp: datetime.fromtimestamp( + timestamp, + timezone.utc, + ).isoformat(), + file_mode=_INSTALL_ID_FILE_MODE, + os_module=os, + ) + + +def _read_installation_id(path: Path) -> str: + return _identity_read(path, parse_uuid=uuid.UUID) + + +def _fsync_directory(path: Path) -> None: + _identity_fsync_directory(path, os_module=os) + + +def _operating_system_info( + identity: SystemIdentity | None = None, +) -> dict[str, object]: + return _environment_operating_system_info( + identity, + detect_identity=detect_system_identity, + system_name=platform.system, + kernel_release=platform.release, + machine_name=platform.machine, + libc_version=platform.libc_ver, + ) + + +def _read_os_release(path: Path = Path("/etc/os-release")) -> dict[str, str]: + return _environment_read_os_release(path, reader=read_os_release) + + +def _execution_environment_info( + identity: SystemIdentity, +) -> dict[str, object] | None: + return _environment_execution_info(identity, libc_version=platform.libc_ver) + + +def _display_info(environment: Mapping[str, str]) -> dict[str, object]: + return _environment_display_info(environment) + + +def _filesystem_info(target: Path) -> dict[str, object]: + return _environment_filesystem_info( + target, + read_mounts=_read_mountinfo, + access=os.access, + writable_mode=os.W_OK, + network_filesystems=_NETWORK_FILESYSTEMS, + ) + + +def _read_mountinfo(path: Path = Path("/proc/self/mountinfo")) -> list[tuple[Path, str, set[str]]]: + return _environment_read_mountinfo(path, unescape=_unescape_mount_field) + + +def _unescape_mount_field(value: str) -> str: + return _environment_unescape_mount_field(value) + + +def _install_is_external(paths: AppPaths, environment: Mapping[str, str]) -> bool: + return _environment_install_is_external(paths, environment) + + +def _current_game_version(path: Path) -> str | None: + return _environment_current_game_version(path, json_loader=json.loads) + + +def _settings_schema_version() -> int: + return _environment_settings_schema_version() + + +def _privacy_policy() -> _PrivacyPolicy: + return _PrivacyPolicy( + max_stderr_characters=MAX_STDERR_CHARACTERS, + max_stdout_characters=MAX_STDOUT_CHARACTERS, + max_traceback_characters=MAX_TRACEBACK_CHARACTERS, + max_container_items=MAX_CONTAINER_ITEMS, + max_value_depth=MAX_VALUE_DEPTH, + secret_keys=_SECRET_KEYS, + secret_query_keys=_SECRET_QUERY_KEYS, + url_pattern=_URL_PATTERN, + email_pattern=_EMAIL_PATTERN, + home_pattern=_HOME_PATTERN, + windows_user_pattern=_WINDOWS_USER_PATTERN, + ssh_path_pattern=_SSH_PATH_PATTERN, + authorization_pattern=_AUTHORIZATION_PATTERN, + secret_assignment_pattern=_SECRET_ASSIGNMENT_PATTERN, + ) + + +def _is_secret_key(key: str) -> bool: + return _privacy_is_secret_key(key, policy=_privacy_policy()) + + +def _string_limit_for_key(key: str | None) -> int: + return _privacy_string_limit_for_key(key, policy=_privacy_policy()) + + +def _keep_string_tail(key: str | None) -> bool: + return _privacy_keep_string_tail(key) + + +def _sanitize_url_match(match: re.Match[str]) -> str: + return _privacy_sanitize_url_match(match, policy=_privacy_policy()) + + +def _truncate_characters(value: str, limit: int, *, tail: bool = False) -> str: + return _privacy_truncate_characters(value, limit, tail=tail) + + +def _truncate_utf8(value: str, limit: int, *, tail: bool = False) -> str: + return _privacy_truncate_utf8(value, limit, tail=tail) + + +def _bound_mapping(value: dict[str, object], limit: int) -> dict[str, object]: + return _payload_bound_mapping( + value, + limit, + encode_json=_json_bytes, + truncate=_truncate_utf8, + keep_tail=_keep_string_tail, + ) + + +def _encode_payload(payload: dict[str, object]) -> bytes | None: + return _payload_encode( + payload, + maximum_request_bytes=MAX_REQUEST_BYTES, + encode_json=_json_bytes, + truncate=_truncate_utf8, + ) + + +def _json_bytes(value: object) -> bytes: + return _payload_json_bytes(value) + + +def normalized_error_message(message: str) -> str: + """Normalize dynamic values for stable server-compatible fingerprints.""" + + return _error_normalized_message( + message, + uuid_pattern=_UUID_PATTERN, + memory_address_pattern=_MEMORY_ADDRESS_PATTERN, + timestamp_pattern=_error_helpers.TIMESTAMP_PATTERN, + ) + + +def _resource_usage() -> dict[str, object]: + return _environment_resource_usage( + get_usage=resource.getrusage, + usage_target=resource.RUSAGE_SELF, + platform_name=sys.platform, + is_finite=math.isfinite, + ) + + +def error_fingerprint( + event: str, + message: str, + *, + exception_type: str = "", + source_module: str = "", + source_function: str = "", +) -> str: + """Create a privacy-safe deterministic fingerprint for tests and server parity.""" + + return _error_fingerprint( + event, + message, + exception_type=exception_type, + source_module=source_module, + source_function=source_function, + normalizer=normalized_error_message, + ) + + +def classify_network_failure(error: object) -> str: + """Return one stable low-cardinality class for download failures.""" + + return _error_classify_network_failure(error) + + +def network_http_status(error: object) -> int | None: + """Extract an HTTP status without exposing a failed request URL.""" + + return _error_network_http_status( + error, + http_status_pattern=_HTTP_STATUS_PATTERN, + ) diff --git a/src/ptlinux/telemetry/dependencies.py b/src/ptlinux/telemetry/dependencies.py new file mode 100644 index 0000000..8e1d2a8 --- /dev/null +++ b/src/ptlinux/telemetry/dependencies.py @@ -0,0 +1,86 @@ +"""Dependency-health aggregation for extended telemetry.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +import platform +import shutil + +from .probes import _optional_bool +from .runtime import ( + _font_dependencies_present, + _wine_32bit_available, + _wine_command, +) + + +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 + + +__all__: list[str] = [] diff --git a/src/ptlinux/telemetry/environment.py b/src/ptlinux/telemetry/environment.py new file mode 100644 index 0000000..7c797fc --- /dev/null +++ b/src/ptlinux/telemetry/environment.py @@ -0,0 +1,267 @@ +"""Static host, display, filesystem, and process probes for telemetry.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +import json +import math +import os +from pathlib import Path +import platform +import re +import resource +import sys + +from ..paths import AppPaths +from ..system_identity import ( + SystemIdentity, + detect_system_identity, + read_os_release as read_system_os_release, +) + + +NETWORK_FILESYSTEMS = frozenset( + {"9p", "afs", "ceph", "cifs", "nfs", "nfs4", "smb3", "sshfs"} +) +MOUNT_ESCAPE_PATTERN = re.compile(r"\\([0-7]{3})") + + +def operating_system_info( + identity: SystemIdentity | None = None, + *, + detect_identity: Callable[[], SystemIdentity] = detect_system_identity, + system_name: Callable[[], str] = platform.system, + kernel_release: Callable[[], str] = platform.release, + machine_name: Callable[[], str] = platform.machine, + libc_version: Callable[[], tuple[str, str]] = platform.libc_ver, +) -> dict[str, object]: + identity = detect_identity() if identity is None else identity + release = identity.operating_system_release + return { + "name": release.get("NAME", system_name() or "unknown"), + "id": release.get("ID", "unknown"), + "version": release.get("PRETTY_NAME", release.get("VERSION", "unknown")), + "version_id": release.get("VERSION_ID", "unknown"), + "kernel": kernel_release(), + "architecture": machine_name() or "unknown", + "machine_architecture": machine_name() or "unknown", + "glibc_version": libc_version()[1] or None, + "identity_source": ( + "container_host_os_release" + if identity.host_release is not None + else "execution_os_release" + ), + } + + +def read_os_release( + path: Path = Path("/etc/os-release"), + *, + reader: Callable[[Path], dict[str, str]] = read_system_os_release, +) -> dict[str, str]: + return reader(path) + + +def execution_environment_info( + identity: SystemIdentity, + *, + libc_version: Callable[[], tuple[str, str]] = platform.libc_ver, +) -> dict[str, object] | None: + if not identity.running_inside_steam_runtime: + return None + release = identity.execution_release + return { + "containerized": True, + "kind": "steam_runtime", + "id": release.get("ID", "steamrt"), + "name": release.get("NAME", "Steam Runtime"), + "version": release.get( + "PRETTY_NAME", + release.get("VERSION", "unknown"), + ), + "version_id": release.get("VERSION_ID", "unknown"), + "host_os_release_available": identity.host_release is not None, + "launcher_inside_game_runtime": True, + "glibc_version": libc_version()[1] or None, + } + + +def display_info(environment: Mapping[str, str]) -> dict[str, object]: + session_type = environment.get("XDG_SESSION_TYPE", "").casefold() + if session_type not in {"wayland", "x11"}: + if environment.get("WAYLAND_DISPLAY"): + session_type = "wayland" + elif environment.get("DISPLAY"): + session_type = "x11" + else: + session_type = "unknown" + desktop = environment.get("XDG_CURRENT_DESKTOP", "") + desktop = desktop.split(":", 1)[0].strip().casefold() or "unknown" + session = environment.get("DESKTOP_SESSION", "").strip().casefold() or "unknown" + return { + "server": session_type, + "desktop": desktop[:64], + "session": session[:64], + } + + +Mount = tuple[Path, str, set[str]] + + +def filesystem_info( + target: Path, + *, + read_mounts: Callable[[], list[Mount]] | None = None, + access: Callable[[Path, int], bool] = os.access, + writable_mode: int = os.W_OK, + network_filesystems: frozenset[str] = NETWORK_FILESYSTEMS, +) -> dict[str, object]: + existing = target + while not existing.exists() and existing != existing.parent: + existing = existing.parent + filesystem_type = "unknown" + readonly = False + noexec = False + try: + mounts = read_mountinfo() if read_mounts is None else read_mounts() + resolved = existing.resolve(strict=True) + matches = [ + mount + for mount in mounts + if resolved == mount[0] or resolved.is_relative_to(mount[0]) + ] + if matches: + _mountpoint, filesystem_type, options = max( + matches, + key=lambda item: len(item[0].parts), + ) + readonly = "ro" in options + noexec = "noexec" in options + except (OSError, RuntimeError, ValueError): + pass + try: + writable = access(existing, writable_mode) and not readonly + except OSError: + writable = False + return { + "type": filesystem_type, + "writable": writable, + "readonly": readonly, + "noexec": noexec, + "network": filesystem_type.casefold() in network_filesystems, + } + + +def read_mountinfo( + path: Path = Path("/proc/self/mountinfo"), + *, + unescape: Callable[[str], str] | None = None, +) -> list[Mount]: + decode = unescape_mount_field if unescape is None else unescape + mounts: list[Mount] = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + fields = line.split() + try: + separator = fields.index("-") + except ValueError: + continue + if separator < 6 or len(fields) <= separator + 3: + continue + mountpoint = Path(decode(fields[4])) + filesystem_type = fields[separator + 1] + options = set(fields[5].split(",")) | set(fields[separator + 3].split(",")) + mounts.append((mountpoint, filesystem_type, options)) + return mounts + + +def unescape_mount_field(value: str) -> str: + return MOUNT_ESCAPE_PATTERN.sub( + lambda match: chr(int(match.group(1), 8)), + value, + ) + + +def install_is_external(paths: AppPaths, environment: Mapping[str, str]) -> bool: + home_value = environment.get("HOME") + if not home_value: + return False + try: + home = Path(home_value).expanduser().resolve() + data = paths.data.resolve() + return data != home and not data.is_relative_to(home) + except (OSError, RuntimeError): + return False + + +def current_game_version( + path: Path, + *, + json_loader: Callable[[str], object] = json.loads, +) -> str | None: + try: + value = json_loader(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(value, dict): + return None + version = value.get("version") + return version[:32] if isinstance(version, str) and version else None + + +def settings_schema_version() -> int: + try: + from ..settings import SETTINGS_SCHEMA + + return SETTINGS_SCHEMA + except Exception: + return 1 + + +def resource_usage( + *, + get_usage: Callable[[int], object] = resource.getrusage, + usage_target: int = resource.RUSAGE_SELF, + platform_name: str = sys.platform, + is_finite: Callable[[float], bool] = math.isfinite, +) -> dict[str, object]: + """Read one bounded process-usage snapshot on the Linux launcher host.""" + + try: + usage = get_usage(usage_target) + peak_value = float(getattr(usage, "ru_maxrss")) + peak_divisor = 1024 * 1024 if platform_name == "darwin" else 1024 + peak_memory_mb = peak_value / peak_divisor + cpu_time_seconds = float(getattr(usage, "ru_utime")) + float( + getattr(usage, "ru_stime") + ) + except (OSError, TypeError, ValueError, OverflowError): + return {} + if ( + not is_finite(peak_memory_mb) + or not is_finite(cpu_time_seconds) + or peak_memory_mb < 0 + or cpu_time_seconds < 0 + ): + return {} + return { + "peak_memory_mb": round(peak_memory_mb, 2), + "cpu_time_seconds": round(cpu_time_seconds, 3), + } + + +__all__ = [ + "MOUNT_ESCAPE_PATTERN", + "Mount", + "NETWORK_FILESYSTEMS", + "current_game_version", + "display_info", + "execution_environment_info", + "filesystem_info", + "install_is_external", + "operating_system_info", + "read_mountinfo", + "read_os_release", + "resource_usage", + "settings_schema_version", + "unescape_mount_field", +] diff --git a/src/ptlinux/telemetry/errors.py b/src/ptlinux/telemetry/errors.py new file mode 100644 index 0000000..a662c09 --- /dev/null +++ b/src/ptlinux/telemetry/errors.py @@ -0,0 +1,122 @@ +"""Telemetry error normalization, fingerprinting, and network classification.""" + +from __future__ import annotations + +from collections.abc import Callable +import hashlib +import re + + +UUID_PATTERN = re.compile( + r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b", + re.IGNORECASE, +) +MEMORY_ADDRESS_PATTERN = re.compile(r"\b0x[0-9a-f]{6,16}\b", re.IGNORECASE) +TIMESTAMP_PATTERN = re.compile(r"\b\d{4}-\d{2}-\d{2}[T ][0-9:.+-Z]+\b") +HTTP_STATUS_PATTERN = re.compile(r"\bHTTP\s+([1-5][0-9]{2})\b", re.IGNORECASE) +HTTP_ERROR_PATTERN = re.compile(r"\bhttp\s+\d{3}\b") + + +def normalized_error_message( + message: str, + *, + uuid_pattern: re.Pattern[str] = UUID_PATTERN, + memory_address_pattern: re.Pattern[str] = MEMORY_ADDRESS_PATTERN, + timestamp_pattern: re.Pattern[str] = TIMESTAMP_PATTERN, +) -> str: + """Normalize dynamic values for stable server-compatible fingerprints.""" + + value = uuid_pattern.sub("", message) + value = memory_address_pattern.sub("
", value) + return timestamp_pattern.sub("", value) + + +def error_fingerprint( + event: str, + message: str, + *, + exception_type: str = "", + source_module: str = "", + source_function: str = "", + normalizer: Callable[[str], str] | None = None, +) -> str: + """Create a privacy-safe deterministic fingerprint for tests and server parity.""" + + normalize = normalized_error_message if normalizer is None else normalizer + material = "\n".join( + (event, exception_type, source_module, source_function, normalize(message)) + ) + return hashlib.sha256(material.encode("utf-8", errors="replace")).hexdigest() + + +def classify_network_failure(error: object) -> str: + """Return one stable low-cardinality class for download failures.""" + + message = str(error).casefold() + name = type(error).__name__.casefold() + if "timeout" in name or "timed out" in message or "timeout" in message: + return "connection_timeout" + if any( + value in message + for value in ( + "name or service not known", + "name resolution", + "nodename nor servname", + "temporary failure in name", + ) + ): + return "dns_failure" + if "connection refused" in message: + return "connection_refused" + if "connection reset" in message or "broken pipe" in message: + return "connection_reset" + if any(value in message for value in ("certificate", "ssl", "tls")): + return "tls_failure" + if HTTP_ERROR_PATTERN.search(message): + return "http_error" + if any( + value in message + for value in ("sha-1 mismatch", "sha-256", "checksum", "content-integrity") + ): + return "content_integrity" + if "could not connect" in message or "unreachable" in message: + return "cdn_unreachable" + return "unknown" + + +def network_http_status( + error: object, + *, + http_status_pattern: re.Pattern[str] = HTTP_STATUS_PATTERN, +) -> int | None: + """Extract an HTTP status without exposing a failed request URL.""" + + current: object | None = error + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + status = getattr(current, "code", None) + if isinstance(status, int) and 100 <= status <= 599: + return status + match = http_status_pattern.search(str(current)) + if match is not None: + return int(match.group(1)) + current = getattr(current, "__cause__", None) or getattr( + current, + "__context__", + None, + ) + return None + + +__all__ = [ + "HTTP_ERROR_PATTERN", + "HTTP_STATUS_PATTERN", + "MEMORY_ADDRESS_PATTERN", + "TIMESTAMP_PATTERN", + "UUID_PATTERN", + "classify_network_failure", + "error_fingerprint", + "network_http_status", + "normalized_error_message", +] diff --git a/src/ptlinux/telemetry/extended.py b/src/ptlinux/telemetry/extended.py new file mode 100644 index 0000000..db45ad8 --- /dev/null +++ b/src/ptlinux/telemetry/extended.py @@ -0,0 +1,160 @@ +"""Bounded, privacy-safe compatibility details for launcher telemetry.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from pathlib import Path + +from ..paths import AppPaths +from .probes import ( + MAX_MARKER_BYTES, + MAX_PROBE_OUTPUT_CHARACTERS, + PROBE_TIMEOUT_SECONDS, + ProbeResult, + ProbeRunner, + _VERSION_PATTERN, + _first_probe_line, + _maximum_version, + _object_field, + _optional_bool, + _probe_text, + _read_decimal, + _read_hex, + _read_json_object, + _read_short_text, + _run_probe, + _short_string, +) +from . import dependencies as _dependency_helpers +from . import graphics as _graphics_helpers +from . import runtime as _runtime_helpers + + +MAX_DLL_SCAN_BYTES = _runtime_helpers.MAX_DLL_SCAN_BYTES +_DXVK_VERSION_PATTERN = _runtime_helpers._DXVK_VERSION_PATTERN +_GPU_VENDORS = _graphics_helpers._GPU_VENDORS +_LIBRARY_ROOTS = _graphics_helpers._LIBRARY_ROOTS +_PCI_DATABASES = _graphics_helpers._PCI_DATABASES +_VULKAN_MANIFEST_DIRECTORIES = _graphics_helpers._VULKAN_MANIFEST_DIRECTORIES +_connected_monitor_count = _graphics_helpers._connected_monitor_count +_dependency_health = _dependency_helpers._dependency_health +_display_info = _graphics_helpers._display_info +_directx_runtime_present = _runtime_helpers._directx_runtime_present +_directory_versions = _runtime_helpers._directory_versions +_dll_file_version = _runtime_helpers._dll_file_version +_dotnet_present = _runtime_helpers._dotnet_present +_driver_version = _graphics_helpers._driver_version +_dxvk_dll_details = _runtime_helpers._dxvk_dll_details +_dxvk_info = _runtime_helpers._dxvk_info +_elf_architecture = _graphics_helpers._elf_architecture +_font_dependencies_present = _runtime_helpers._font_dependencies_present +_friendly_driver_name = _graphics_helpers._friendly_driver_name +_gpu_info = _graphics_helpers._gpu_info +_gpu_offload_detected = _graphics_helpers._gpu_offload_detected +_hex_summary_value = _graphics_helpers._hex_summary_value +_hex_summary_values = _graphics_helpers._hex_summary_values +_is_kde_desktop = _graphics_helpers._is_kde_desktop +_kde_display_scaling_info = _graphics_helpers._kde_display_scaling_info +_library_candidates = _graphics_helpers._library_candidates +_manifest_entries = _graphics_helpers._manifest_entries +_manifest_library_architectures = _graphics_helpers._manifest_library_architectures +_marker_timestamp = _runtime_helpers._marker_timestamp +_pe_fixed_file_version = _runtime_helpers._pe_fixed_file_version +_pci_model = _graphics_helpers._pci_model +_proton_dxvk_version = _runtime_helpers._proton_dxvk_version +_proton_name = _runtime_helpers._proton_name +_runtime_component_info = _runtime_helpers._runtime_component_info +_runtime_probe = _runtime_helpers._runtime_info +_selected_prefix = _runtime_helpers._selected_prefix +_summary_value = _graphics_helpers._summary_value +_sysfs_gpus = _graphics_helpers._sysfs_gpus +_vc_runtime_info = _runtime_helpers._vc_runtime_info +_vendor_from_model = _graphics_helpers._vendor_from_model +_vulkan_gpu_summaries = _graphics_helpers._vulkan_gpu_summaries +_vulkan_info = _graphics_helpers._vulkan_info +_vulkan_loader_architectures = _graphics_helpers._vulkan_loader_architectures +_vulkan_manifest_search_directories = ( + _graphics_helpers._vulkan_manifest_search_directories +) +_vulkan_manifests = _graphics_helpers._vulkan_manifests +_wine_32bit_available = _runtime_helpers._wine_32bit_available +_wine_command = _runtime_helpers._wine_command +_wine_prefix_architecture = _runtime_helpers._wine_prefix_architecture +_wine_vendor = _runtime_helpers._wine_vendor +_wine_version = _runtime_helpers._wine_version +inspect_vulkan_support = _graphics_helpers.inspect_vulkan_support + + +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]: + return _runtime_probe( + paths, + marker, + prefix, + environment, + probe_runner, + vulkan_manifest_directories=vulkan_manifest_directories, + library_roots=library_roots, + now=now, + vulkan_probe=_vulkan_info, + ) diff --git a/src/ptlinux/telemetry/graphics.py b/src/ptlinux/telemetry/graphics.py new file mode 100644 index 0000000..0646661 --- /dev/null +++ b/src/ptlinux/telemetry/graphics.py @@ -0,0 +1,665 @@ +"""Vulkan, GPU, and display telemetry probes.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +import json +import os +from pathlib import Path +import re +import shutil + +from .probes import ( + PROBE_TIMEOUT_SECONDS, + ProbeRunner, + _VERSION_PATTERN, + _maximum_version, + _object_field, + _probe_text, + _read_decimal, + _read_hex, + _read_json_object, + _read_short_text, + _run_probe, + _short_string, +) + + +_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", +} + + +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 _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) + ) + + +__all__ = ["inspect_vulkan_support"] diff --git a/src/ptlinux/telemetry/identity.py b/src/ptlinux/telemetry/identity.py new file mode 100644 index 0000000..f9c260e --- /dev/null +++ b/src/ptlinux/telemetry/identity.py @@ -0,0 +1,119 @@ +"""Private installation identity persistence for launcher telemetry.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timezone +import os +from pathlib import Path +import threading +from types import ModuleType +import uuid + + +INSTALL_ID_FILE_MODE = 0o600 + + +def load_or_create_installation_id( + path: Path, + *, + read_identity: Callable[[Path], str] | None = None, + make_uuid: Callable[[], uuid.UUID] | None = None, + process_id: Callable[[], int] | None = None, + thread_identity: Callable[[], int] | None = None, + sync_directory: Callable[[Path], None] | None = None, + first_seen_from_timestamp: Callable[[float], str] | None = None, + file_mode: int = INSTALL_ID_FILE_MODE, + os_module: ModuleType = os, +) -> tuple[str, bool, str]: + """Read or atomically create the anonymous installation UUID.""" + + read = read_installation_id if read_identity is None else read_identity + new_uuid = uuid.uuid4 if make_uuid is None else make_uuid + get_process_id = os_module.getpid if process_id is None else process_id + get_thread_identity = ( + threading.get_ident if thread_identity is None else thread_identity + ) + sync = fsync_directory if sync_directory is None else sync_directory + if first_seen_from_timestamp is None: + format_first_seen = lambda timestamp: datetime.fromtimestamp( + timestamp, + timezone.utc, + ).isoformat() + else: + format_first_seen = first_seen_from_timestamp + + path.parent.mkdir(parents=True, exist_ok=True) + try: + identity = read(path) + created = False + except FileNotFoundError: + candidate = str(new_uuid()) + temporary = path.with_name( + f".{path.name}.{get_process_id()}.{get_thread_identity()}." + f"{new_uuid().hex}.tmp" + ) + try: + with temporary.open("x", encoding="ascii", newline="\n") as handle: + os_module.fchmod(handle.fileno(), file_mode) + handle.write(candidate + "\n") + handle.flush() + os_module.fsync(handle.fileno()) + try: + os_module.link(temporary, path) + except FileExistsError: + identity = read(path) + created = False + else: + identity = candidate + created = True + sync(path.parent) + finally: + temporary.unlink(missing_ok=True) + os_module.chmod(path, file_mode) + first_seen = format_first_seen(path.stat().st_mtime) + return identity, created, first_seen + + +def read_installation_id( + path: Path, + *, + parse_uuid: Callable[[str], uuid.UUID] | None = None, +) -> str: + """Read and validate one canonical version-four installation UUID.""" + + parse = uuid.UUID if parse_uuid is None else parse_uuid + raw = path.read_text(encoding="ascii").strip() + try: + value = parse(raw) + except (ValueError, AttributeError) as exc: + raise ValueError("invalid telemetry installation ID") from exc + if value.version != 4 or str(value) != raw.casefold(): + raise ValueError("invalid telemetry installation ID") + return str(value) + + +def fsync_directory(path: Path, *, os_module: ModuleType = os) -> None: + """Best-effort sync a directory after publishing the identity file.""" + + try: + descriptor = os_module.open( + path, + os_module.O_RDONLY | getattr(os_module, "O_DIRECTORY", 0), + ) + except OSError: + return + try: + os_module.fsync(descriptor) + except OSError: + pass + finally: + os_module.close(descriptor) + + +__all__ = [ + "INSTALL_ID_FILE_MODE", + "fsync_directory", + "load_or_create_installation_id", + "read_installation_id", +] diff --git a/src/ptlinux/telemetry/payload.py b/src/ptlinux/telemetry/payload.py new file mode 100644 index 0000000..ed8e225 --- /dev/null +++ b/src/ptlinux/telemetry/payload.py @@ -0,0 +1,75 @@ +"""Deterministic telemetry JSON encoding and hard payload bounds.""" + +from __future__ import annotations + +from collections.abc import Callable +import json + + +def json_bytes(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def bound_mapping( + value: dict[str, object], + limit: int, + *, + encode_json: Callable[[object], bytes], + truncate: Callable[..., str], + keep_tail: Callable[[str | None], bool], +) -> dict[str, object]: + if len(encode_json(value)) <= limit: + return value + bounded: dict[str, object] = {} + for key, item in value.items(): + candidate = {**bounded, key: item, "_truncated": True} + if len(encode_json(candidate)) <= limit: + bounded[key] = item + continue + if isinstance(item, str): + for string_limit in (4096, 2048, 1024, 512, 256, 128): + shortened = truncate( + item, + string_limit, + tail=keep_tail(key), + ) + candidate = {**bounded, key: shortened, "_truncated": True} + if len(encode_json(candidate)) <= limit: + bounded[key] = shortened + break + bounded["_truncated"] = True + while len(encode_json(bounded)) > limit and len(bounded) > 1: + last = next(key for key in reversed(bounded) if key != "_truncated") + bounded.pop(last) + return bounded + + +def encode_payload( + payload: dict[str, object], + *, + maximum_request_bytes: int, + encode_json: Callable[[object], bytes], + truncate: Callable[..., str], +) -> bytes | None: + body = encode_json(payload) + if len(body) <= maximum_request_bytes: + return body + for key in ("metadata", "context"): + if key in payload: + payload[key] = {"_truncated": True} + body = encode_json(payload) + if len(body) <= maximum_request_bytes: + return body + message = payload.get("message") + if isinstance(message, str): + payload["message"] = truncate(message, 2048) + body = encode_json(payload) + return body if len(body) <= maximum_request_bytes else None + + +__all__ = ["bound_mapping", "encode_payload", "json_bytes"] diff --git a/src/ptlinux/telemetry/privacy.py b/src/ptlinux/telemetry/privacy.py new file mode 100644 index 0000000..327f205 --- /dev/null +++ b/src/ptlinux/telemetry/privacy.py @@ -0,0 +1,278 @@ +"""Pure telemetry redaction, sanitization, and value-bounding helpers.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +import math +from pathlib import Path +import re +from urllib.parse import parse_qsl, urlsplit, urlunsplit + + +SECRET_KEYS = frozenset( + { + "api_key", + "apikey", + "authorization", + "client_secret", + "cookie", + "launcher_key", + "password", + "passwd", + "proxy_authorization", + "refresh_token", + "secret", + "session_id", + "sessionid", + "set_cookie", + "token", + "access_token", + } +) +SECRET_QUERY_KEYS = SECRET_KEYS | frozenset( + { + "expires", + "key-pair-id", + "policy", + "signature", + "sig", + "x-amz-credential", + "x-amz-signature", + "x-amz-security-token", + } +) +URL_PATTERN = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE) +EMAIL_PATTERN = re.compile( + r"(?|/home/|/root)[\\/]\.ssh(?:[\\/]\S*)?" +) +AUTHORIZATION_PATTERN = re.compile( + r"(?i)(authorization\s*:\s*(?:bearer|basic)\s+)[^\s,;]+" +) +SECRET_ASSIGNMENT_PATTERN = re.compile( + r"(?i)\b(password|passwd|token|access_token|refresh_token|api[_-]?key|" + r"client_secret|session_id|cookie)\s*([=:]\s*)(?:\"[^\"]*\"|'[^']*'|[^&\s,;]+)" +) + + +@dataclass(frozen=True) +class PrivacyPolicy: + max_stderr_characters: int + max_stdout_characters: int + max_traceback_characters: int + max_container_items: int + max_value_depth: int + secret_keys: frozenset[str] = SECRET_KEYS + secret_query_keys: frozenset[str] = SECRET_QUERY_KEYS + url_pattern: re.Pattern[str] = URL_PATTERN + email_pattern: re.Pattern[str] = EMAIL_PATTERN + home_pattern: re.Pattern[str] = HOME_PATTERN + windows_user_pattern: re.Pattern[str] = WINDOWS_USER_PATTERN + ssh_path_pattern: re.Pattern[str] = SSH_PATH_PATTERN + authorization_pattern: re.Pattern[str] = AUTHORIZATION_PATTERN + secret_assignment_pattern: re.Pattern[str] = SECRET_ASSIGNMENT_PATTERN + + +def is_secret_key(key: str, *, policy: PrivacyPolicy) -> bool: + normalized = key.casefold().replace("-", "_").strip() + return normalized in policy.secret_keys + + +def string_limit_for_key(key: str | None, *, policy: PrivacyPolicy) -> int: + normalized = (key or "").casefold() + if normalized == "stderr": + return policy.max_stderr_characters + if normalized == "stdout": + return policy.max_stdout_characters + if normalized in {"sanitized_traceback", "traceback"}: + return policy.max_traceback_characters + return 8192 + + +def keep_string_tail(key: str | None) -> bool: + return (key or "").casefold() in { + "stderr", + "stdout", + "sanitized_traceback", + "traceback", + } + + +def sanitize_url_match( + match: re.Match[str], + *, + policy: PrivacyPolicy, +) -> str: + raw = match.group(0) + trailing = "" + while raw and raw[-1] in ".,;:)]": + trailing = raw[-1] + trailing + raw = raw[:-1] + try: + parsed = urlsplit(raw) + except ValueError: + return "" + trailing + host = parsed.hostname or "" + if not host: + return "" + trailing + netloc = host + if ":" in host and not host.startswith("["): + netloc = f"[{host}]" + try: + if parsed.port is not None: + netloc += f":{parsed.port}" + except ValueError: + return "" + trailing + query = parsed.query + if parsed.username is not None or any( + str(key).casefold() in policy.secret_query_keys + for key, _value in parse_qsl(query) + ): + query = "" if query else "" + return urlunsplit((parsed.scheme, netloc, parsed.path, query, "")) + trailing + + +def truncate_characters(value: str, limit: int, *, tail: bool = False) -> str: + if len(value) <= limit: + return value + marker = "" + remaining = max(0, limit - len(marker)) + return marker + value[-remaining:] if tail else value[:remaining] + marker + + +def truncate_utf8(value: str, limit: int, *, tail: bool = False) -> str: + encoded = value.encode("utf-8") + if len(encoded) <= limit: + return value + marker = b"" + remaining = max(0, limit - len(marker)) + chunk = encoded[-remaining:] if tail else encoded[:remaining] + decoded = chunk.decode("utf-8", errors="ignore") + return "" + decoded if tail else decoded + "" + + +def sanitize_text( + value: str, + *, + source_game: Path, + runtime_view: Path, + data_directory: Path, + environment: Mapping[str, str], + private_literals: Sequence[str], + policy: PrivacyPolicy, +) -> str: + text = value.replace("\x00", "") + for target, replacement in ( + (str(source_game), ""), + (str(runtime_view), ""), + (str(data_directory), ""), + ): + if target and target != ".": + text = text.replace(target.rstrip("/"), replacement) + try: + home = str(Path(environment.get("HOME", "")).expanduser()) + except (OSError, RuntimeError): + home = "" + if home and home not in {".", "/"}: + text = text.replace(home.rstrip("/"), "") + text = policy.home_pattern.sub("", text) + text = policy.windows_user_pattern.sub(r"\1", text) + text = policy.ssh_path_pattern.sub("", text) + text = policy.email_pattern.sub("", text) + text = policy.authorization_pattern.sub(r"\1", text) + text = policy.secret_assignment_pattern.sub(r"\1\2", text) + def sanitize_match(match: re.Match[str]) -> str: + return sanitize_url_match(match, policy=policy) + + text = policy.url_pattern.sub(sanitize_match, text) + for private in private_literals: + text = re.sub( + rf"(?", + text, + ) + return text + + +def sanitize_value( + value: object, + *, + key: str | None, + depth: int, + policy: PrivacyPolicy, + text_sanitizer: Callable[[str], str], + secret_key_checker: Callable[[str], bool], + string_limit: Callable[[str | None], int], + keep_tail: Callable[[str | None], bool], + truncate: Callable[..., str], +) -> object: + if key is not None and secret_key_checker(key): + return "" + if depth >= policy.max_value_depth: + return "" + if value is None or isinstance(value, (bool, int)): + return value + if isinstance(value, float): + return value if math.isfinite(value) else None + if isinstance(value, (str, Path)): + text = text_sanitizer(str(value)) + return truncate(text, string_limit(key), tail=keep_tail(key)) + if isinstance(value, Mapping): + result: dict[str, object] = {} + for index, (raw_key, item) in enumerate(value.items()): + if index >= policy.max_container_items: + result["_truncated"] = True + break + name = truncate(text_sanitizer(str(raw_key)), 128) + result[name] = sanitize_value( + item, + key=name, + depth=depth + 1, + policy=policy, + text_sanitizer=text_sanitizer, + secret_key_checker=secret_key_checker, + string_limit=string_limit, + keep_tail=keep_tail, + truncate=truncate, + ) + return result + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): + items = list(value[: policy.max_container_items]) + result = [ + sanitize_value( + item, + key=key, + depth=depth + 1, + policy=policy, + text_sanitizer=text_sanitizer, + secret_key_checker=secret_key_checker, + string_limit=string_limit, + keep_tail=keep_tail, + truncate=truncate, + ) + for item in items + ] + if len(value) > policy.max_container_items: + result.append("") + return result + return f"<{type(value).__name__}>" + + +__all__ = [ + "PrivacyPolicy", + "is_secret_key", + "keep_string_tail", + "sanitize_text", + "sanitize_url_match", + "sanitize_value", + "string_limit_for_key", + "truncate_characters", + "truncate_utf8", +] diff --git a/src/ptlinux/telemetry/probes.py b/src/ptlinux/telemetry/probes.py new file mode 100644 index 0000000..7475a85 --- /dev/null +++ b/src/ptlinux/telemetry/probes.py @@ -0,0 +1,159 @@ +"""Shared bounded I/O primitives for extended telemetry probes.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +import subprocess + + +PROBE_TIMEOUT_SECONDS = 3.0 +MAX_MARKER_BYTES = 256 * 1024 +MAX_PROBE_OUTPUT_CHARACTERS = 64 * 1024 +_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 _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 + + +__all__ = [ + "MAX_MARKER_BYTES", + "MAX_PROBE_OUTPUT_CHARACTERS", + "PROBE_TIMEOUT_SECONDS", + "ProbeResult", + "ProbeRunner", +] diff --git a/src/ptlinux/telemetry/runtime.py b/src/ptlinux/telemetry/runtime.py new file mode 100644 index 0000000..3614a0e --- /dev/null +++ b/src/ptlinux/telemetry/runtime.py @@ -0,0 +1,524 @@ +"""Wine, Proton, DXVK, and Windows runtime telemetry probes.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime, timezone +import os +from pathlib import Path +import re +import shutil +import struct + +from ..paths import AppPaths +from .probes import ( + PROBE_TIMEOUT_SECONDS, + ProbeRunner, + _VERSION_PATTERN, + _first_probe_line, + _maximum_version, + _object_field, + _read_short_text, + _short_string, +) + + +MAX_DLL_SCAN_BYTES = 16 * 1024 * 1024 +_DXVK_VERSION_PATTERN = re.compile( + r"\bdxvk(?:\s+version|[- /:])?\s*v?(\d+\.\d+(?:\.\d+){0,2})\b", + re.IGNORECASE, +) +VulkanProbe = Callable[..., dict[str, object]] + + +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, + vulkan_probe: VulkanProbe, +) -> 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_probe( + environment, + probe_runner, + manifest_directories=vulkan_manifest_directories, + library_roots=library_roots, + ) + result.update(vulkan) + 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) + + +__all__ = ["MAX_DLL_SCAN_BYTES", "VulkanProbe"] diff --git a/ptlinux/telemetry.py b/src/ptlinux/telemetry/service.py similarity index 57% rename from ptlinux/telemetry.py rename to src/ptlinux/telemetry/service.py index 1e2d078..c839627 100644 --- a/ptlinux/telemetry.py +++ b/src/ptlinux/telemetry/service.py @@ -1,19 +1,15 @@ -"""Anonymous, best-effort launcher telemetry with bounded asynchronous delivery.""" +"""Telemetry lifecycle, queueing, suppression, and exception handling.""" from __future__ import annotations from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone -import hashlib -import json -import math import os from pathlib import Path import platform import queue import re -import resource import socket import sys import threading @@ -21,14 +17,9 @@ import time import traceback from types import TracebackType from typing import Any -from urllib.parse import parse_qsl, urlsplit, urlunsplit -from urllib.request import Request, urlopen -import uuid -from . import __version__ -from .paths import AppPaths -from .system_identity import SystemIdentity, detect_system_identity, read_os_release -from .telemetry_environment import collect_extended_environment +from .. import __version__ +from ..paths import AppPaths DEFAULT_TELEMETRY_ENDPOINT = "https://pristontale.eu/api/launcher/telemetry" @@ -72,7 +63,6 @@ _RESOURCE_USAGE_EVENTS = frozenset( "update_success", } ) - VALID_SEVERITIES = frozenset({"debug", "info", "warning", "error", "critical"}) VALID_EVENTS = frozenset( { @@ -105,6 +95,7 @@ VALID_EVENTS = frozenset( "first_run_failed", "first_run_started", "game_exited", + "game_launch_cancelled", "game_launch_failed", "game_launch_requested", "game_launch_started", @@ -177,69 +168,23 @@ VALID_EVENTS = frozenset( "wine_32bit_check_failed", } ) - -_SECRET_KEYS = frozenset( - { - "api_key", - "apikey", - "authorization", - "client_secret", - "cookie", - "launcher_key", - "password", - "passwd", - "proxy_authorization", - "refresh_token", - "secret", - "session_id", - "sessionid", - "set_cookie", - "token", - "access_token", - } -) -_SECRET_QUERY_KEYS = _SECRET_KEYS | frozenset( - { - "expires", - "key-pair-id", - "policy", - "signature", - "sig", - "x-amz-credential", - "x-amz-signature", - "x-amz-security-token", - } -) -_NETWORK_FILESYSTEMS = frozenset( - {"9p", "afs", "ceph", "cifs", "nfs", "nfs4", "smb3", "sshfs"} -) -_URL_PATTERN = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE) -_EMAIL_PATTERN = re.compile( - r"(?|/home/|/root)[\\/]\.ssh(?:[\\/]\S*)?") -_AUTHORIZATION_PATTERN = re.compile( - r"(?i)(authorization\s*:\s*(?:bearer|basic)\s+)[^\s,;]+" -) -_SECRET_ASSIGNMENT_PATTERN = re.compile( - r"(?i)\b(password|passwd|token|access_token|refresh_token|api[_-]?key|" - r"client_secret|session_id|cookie)\s*([=:]\s*)(?:\"[^\"]*\"|'[^']*'|[^&\s,;]+)" -) -_UUID_PATTERN = re.compile( - r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b", - re.IGNORECASE, -) -_MEMORY_ADDRESS_PATTERN = re.compile(r"\b0x[0-9a-f]{6,16}\b", re.IGNORECASE) -_HTTP_STATUS_PATTERN = re.compile(r"\bHTTP\s+([1-5][0-9]{2})\b", re.IGNORECASE) -_INSTALL_ID_FILE_MODE = 0o600 - TelemetryTransport = Callable[[str, bytes, Mapping[str, str], float], None] +def _is_error_event(event: str, severity: str) -> bool: + return ( + severity in {"error", "critical"} + or "failed" in event + or "exception" in event + or "error" in event + ) + + +def _string_field(values: Mapping[str, object], key: str) -> str: + value = values.get(key) + return value if isinstance(value, str) else "" + + @dataclass class _RepeatedErrorState: fingerprint: str @@ -262,6 +207,7 @@ class TelemetryService: self, paths: AppPaths, *, + _facade: Any, enabled: bool = True, endpoint: str | None = None, launcher_key: str | None = None, @@ -272,6 +218,7 @@ class TelemetryService: monotonic: Callable[[], float] = time.monotonic, ) -> None: self.paths = paths + self._facade = _facade self._environment = os.environ if environment is None else environment configured_endpoint = self._environment.get(TELEMETRY_ENDPOINT_VARIABLE) self.endpoint = ( @@ -281,7 +228,7 @@ class TelemetryService: ) configured_key = self._environment.get(TELEMETRY_KEY_VARIABLE) self._launcher_key = launcher_key if launcher_key is not None else configured_key - self._transport = transport or _post_json + self._transport = transport or self._facade._post_json self._session_metadata = dict(session_metadata or {}) self._monotonic = monotonic self._started_at = monotonic() @@ -345,8 +292,8 @@ class TelemetryService: if self._active or not self._enabled: return try: - _validate_endpoint(self.endpoint) - identity, created, first_seen = _load_or_create_installation_id( + self._facade._validate_endpoint(self.endpoint) + identity, created, first_seen = self._facade._load_or_create_installation_id( self.paths.installation_id_file ) self._installation_id = identity @@ -458,20 +405,20 @@ class TelemetryService: **base_payload, } if message is not None: - payload["message"] = _truncate_utf8( + payload["message"] = self._facade._truncate_utf8( self._sanitize_text(str(message)), MAX_MESSAGE_BYTES, ) if context: sanitized_context = self._sanitize_value(dict(context), key=None, depth=0) if isinstance(sanitized_context, dict): - payload["context"] = _bound_mapping( + payload["context"] = self._facade._bound_mapping( sanitized_context, MAX_CONTEXT_BYTES, ) event_metadata = dict(metadata or {}) if event in _RESOURCE_USAGE_EVENTS: - for key, value in _resource_usage().items(): + for key, value in self._facade._resource_usage().items(): event_metadata.setdefault(key, value) if event_metadata: sanitized_metadata = self._sanitize_value( @@ -480,11 +427,11 @@ class TelemetryService: depth=0, ) if isinstance(sanitized_metadata, dict): - payload["metadata"] = _bound_mapping( + payload["metadata"] = self._facade._bound_mapping( sanitized_metadata, MAX_METADATA_BYTES, ) - body = _encode_payload(payload) + body = self._facade._encode_payload(payload) if body is None: return False self._queue.put_nowait(body) @@ -517,7 +464,7 @@ class TelemetryService: { "exception_type": type(exception).__name__, "exception_module": type(exception).__module__, - "sanitized_traceback": _truncate_characters( + "sanitized_traceback": self._facade._truncate_characters( formatted, MAX_TRACEBACK_CHARACTERS, tail=True, @@ -557,12 +504,12 @@ class TelemetryService: "duration_ms": round( float(getattr(result, "duration_seconds", 0.0)) * 1000 ), - "stdout": _truncate_characters( + "stdout": self._facade._truncate_characters( str(getattr(result, "stdout", "")), MAX_STDOUT_CHARACTERS, tail=True, ), - "stderr": _truncate_characters( + "stderr": self._facade._truncate_characters( str(getattr(result, "stderr", "")), MAX_STDERR_CHARACTERS, tail=True, @@ -614,11 +561,11 @@ class TelemetryService: if event == "repeated_error" or not _is_error_event(event, severity): return False, (), None details = metadata or {} - sanitized_message = _truncate_utf8( + sanitized_message = self._facade._truncate_utf8( self._sanitize_text(str(message or "")), MAX_MESSAGE_BYTES, ) - fingerprint = error_fingerprint( + fingerprint = self._facade.error_fingerprint( event, sanitized_message, exception_type=self._sanitize_text( @@ -815,7 +762,7 @@ class TelemetryService: def _collect_extended_environment(self) -> None: started_at = self._monotonic() try: - details = collect_extended_environment(self.paths, self._environment) + details = self._facade.collect_extended_environment(self.paths, self._environment) sanitized = self._sanitize_value(details, key=None, depth=0) if not isinstance(sanitized, dict): return @@ -930,7 +877,7 @@ class TelemetryService: def _collect_common_payload(self, first_seen: str) -> dict[str, object]: environment = self._environment - system_identity = detect_system_identity() + system_identity = self._facade.detect_system_identity() payload: dict[str, object] = { "launcher_version": __version__, "launcher_build": environment.get("PTLINUX_BUILD", __version__)[:32], @@ -939,20 +886,20 @@ class TelemetryService: "installation": { "first_seen": first_seen, "install_type": self._install_type(), - "install_is_external": _install_is_external(self.paths, environment), - "configuration_schema_version": _settings_schema_version(), + "install_is_external": self._facade._install_is_external(self.paths, environment), + "configuration_schema_version": self._facade._settings_schema_version(), }, - "os": _operating_system_info(system_identity), - "display": _display_info(environment), - "filesystem": _filesystem_info(self.paths.data), + "os": self._facade._operating_system_info(system_identity), + "display": self._facade._display_info(environment), + "filesystem": self._facade._filesystem_info(self.paths.data), } - execution_environment = _execution_environment_info(system_identity) + execution_environment = self._facade._execution_environment_info(system_identity) if execution_environment is not None: payload["execution_environment"] = execution_environment commit = environment.get("PTLINUX_COMMIT") if commit and re.fullmatch(r"[0-9a-fA-F]{7,40}", commit): payload["launcher_commit"] = commit[:12].lower() - game_version = _current_game_version(self.paths.native_depot_state) + game_version = self._facade._current_game_version(self.paths.native_depot_state) if game_version is not None: payload["game_version"] = game_version filesystem = payload["filesystem"] @@ -972,71 +919,29 @@ class TelemetryService: return "unknown" def _sanitize_value(self, value: object, *, key: str | None, depth: int) -> object: - if key is not None and _is_secret_key(key): - return "" - if depth >= MAX_VALUE_DEPTH: - return "" - if value is None or isinstance(value, (bool, int)): - return value - if isinstance(value, float): - return value if math.isfinite(value) else None - if isinstance(value, (str, Path)): - text = self._sanitize_text(str(value)) - limit = _string_limit_for_key(key) - return _truncate_characters(text, limit, tail=_keep_string_tail(key)) - if isinstance(value, Mapping): - result: dict[str, object] = {} - for index, (raw_key, item) in enumerate(value.items()): - if index >= MAX_CONTAINER_ITEMS: - result["_truncated"] = True - break - name = _truncate_characters(self._sanitize_text(str(raw_key)), 128) - result[name] = self._sanitize_value( - item, - key=name, - depth=depth + 1, - ) - return result - if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): - items = list(value[:MAX_CONTAINER_ITEMS]) - result = [ - self._sanitize_value(item, key=key, depth=depth + 1) - for item in items - ] - if len(value) > MAX_CONTAINER_ITEMS: - result.append("") - return result - return f"<{type(value).__name__}>" + return self._facade._privacy_sanitize_value( + value, + key=key, + depth=depth, + policy=self._facade._privacy_policy(), + text_sanitizer=self._sanitize_text, + secret_key_checker=self._facade._is_secret_key, + string_limit=self._facade._string_limit_for_key, + keep_tail=self._facade._keep_string_tail, + truncate=self._facade._truncate_characters, + ) def _sanitize_text(self, value: str) -> str: - text = value.replace("\x00", "") - for target, replacement in ( - (str(self.paths.source_game), ""), - (str(self.paths.runtime_view), ""), - (str(self.paths.data), ""), - ): - if target and target != ".": - text = text.replace(target.rstrip("/"), replacement) - try: - home = str(Path(self._environment.get("HOME", "")).expanduser()) - except (OSError, RuntimeError): - home = "" - if home and home not in {".", "/"}: - text = text.replace(home.rstrip("/"), "") - text = _HOME_PATTERN.sub("", text) - text = _WINDOWS_USER_PATTERN.sub(r"\1", text) - text = _SSH_PATH_PATTERN.sub("", text) - text = _EMAIL_PATTERN.sub("", text) - text = _AUTHORIZATION_PATTERN.sub(r"\1", text) - text = _SECRET_ASSIGNMENT_PATTERN.sub(r"\1\2", text) - text = _URL_PATTERN.sub(_sanitize_url_match, text) - for private in self._private_literals: - text = re.sub( - rf"(?", - text, - ) - return text + return self._facade._privacy_sanitize_text( + value, + source_game=self.paths.source_game, + runtime_view=self.paths.runtime_view, + data_directory=self.paths.data, + environment=self._environment, + private_literals=self._private_literals, + policy=self._facade._privacy_policy(), + ) + def _install_exception_hooks(self) -> None: previous_sys_excepthook = sys.excepthook @@ -1098,542 +1003,3 @@ class TelemetryService: print(f"Telemetry: {message}", file=sys.stderr) except Exception: pass - - -_shared_service: TelemetryService | None = None -_shared_service_lock = threading.Lock() - - -def initialize_shared_telemetry( - paths: AppPaths, - *, - session_metadata: Mapping[str, object] | None = None, - report_launcher_lifecycle: bool | None = None, -) -> tuple[TelemetryService, bool]: - """Return the process-wide service and whether this caller owns its shutdown.""" - - global _shared_service - with _shared_service_lock: - if _shared_service is not None: - return _shared_service, False - service = TelemetryService( - paths, - enabled=_telemetry_enabled_from_settings(paths), - session_metadata=session_metadata, - report_launcher_lifecycle=report_launcher_lifecycle, - ) - _shared_service = service - service.initialize() - return service, True - - -def current_telemetry() -> TelemetryService | None: - return _shared_service - - -def set_shared_telemetry_enabled(enabled: bool) -> None: - service = _shared_service - if service is not None: - service.set_enabled(enabled) - - -def shutdown_shared_telemetry( - service: TelemetryService, - *, - timeout_seconds: float = SHUTDOWN_WAIT_SECONDS, -) -> None: - global _shared_service - service.shutdown(timeout_seconds=timeout_seconds) - with _shared_service_lock: - if _shared_service is service: - _shared_service = None - - -def _telemetry_enabled_from_settings(paths: AppPaths) -> bool: - try: - from .settings import Settings, load_settings - - if not paths.settings_file.is_file(): - return Settings().telemetry_enabled - return load_settings(paths).telemetry_enabled - except (OSError, ValueError): - return False - - -def _post_json( - endpoint: str, - body: bytes, - headers: Mapping[str, str], - timeout_seconds: float, -) -> None: - request = Request(endpoint, data=body, headers=dict(headers), method="POST") - with urlopen(request, timeout=timeout_seconds) as response: - status = getattr(response, "status", response.getcode()) - response.read(1024) - if not isinstance(status, int) or status < 200 or status >= 300: - raise OSError(f"telemetry endpoint returned HTTP {status}") - - -def _validate_endpoint(endpoint: str) -> None: - parsed = urlsplit(endpoint) - if ( - parsed.scheme != "https" - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - ): - raise ValueError("telemetry endpoint must be a credential-free HTTPS URL") - - -def _load_or_create_installation_id(path: Path) -> tuple[str, bool, str]: - path.parent.mkdir(parents=True, exist_ok=True) - try: - identity = _read_installation_id(path) - created = False - except FileNotFoundError: - candidate = str(uuid.uuid4()) - temporary = path.with_name( - f".{path.name}.{os.getpid()}.{threading.get_ident()}.{uuid.uuid4().hex}.tmp" - ) - try: - with temporary.open("x", encoding="ascii", newline="\n") as handle: - os.fchmod(handle.fileno(), _INSTALL_ID_FILE_MODE) - handle.write(candidate + "\n") - handle.flush() - os.fsync(handle.fileno()) - try: - os.link(temporary, path) - except FileExistsError: - identity = _read_installation_id(path) - created = False - else: - identity = candidate - created = True - _fsync_directory(path.parent) - finally: - temporary.unlink(missing_ok=True) - os.chmod(path, _INSTALL_ID_FILE_MODE) - first_seen = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat() - return identity, created, first_seen - - -def _read_installation_id(path: Path) -> str: - raw = path.read_text(encoding="ascii").strip() - try: - value = uuid.UUID(raw) - except (ValueError, AttributeError) as exc: - raise ValueError("invalid telemetry installation ID") from exc - if value.version != 4 or str(value) != raw.casefold(): - raise ValueError("invalid telemetry installation ID") - return str(value) - - -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 _operating_system_info( - identity: SystemIdentity | None = None, -) -> dict[str, object]: - identity = detect_system_identity() if identity is None else identity - release = identity.operating_system_release - return { - "name": release.get("NAME", platform.system() or "unknown"), - "id": release.get("ID", "unknown"), - "version": release.get("PRETTY_NAME", release.get("VERSION", "unknown")), - "version_id": release.get("VERSION_ID", "unknown"), - "kernel": platform.release(), - "architecture": platform.machine() or "unknown", - "machine_architecture": platform.machine() or "unknown", - "glibc_version": platform.libc_ver()[1] or None, - "identity_source": ( - "container_host_os_release" - if identity.host_release is not None - else "execution_os_release" - ), - } - - -def _read_os_release(path: Path = Path("/etc/os-release")) -> dict[str, str]: - return read_os_release(path) - - -def _execution_environment_info( - identity: SystemIdentity, -) -> dict[str, object] | None: - if not identity.running_inside_steam_runtime: - return None - release = identity.execution_release - return { - "containerized": True, - "kind": "steam_runtime", - "id": release.get("ID", "steamrt"), - "name": release.get("NAME", "Steam Runtime"), - "version": release.get( - "PRETTY_NAME", - release.get("VERSION", "unknown"), - ), - "version_id": release.get("VERSION_ID", "unknown"), - "host_os_release_available": identity.host_release is not None, - "launcher_inside_game_runtime": True, - "glibc_version": platform.libc_ver()[1] or None, - } - - -def _display_info(environment: Mapping[str, str]) -> dict[str, object]: - session_type = environment.get("XDG_SESSION_TYPE", "").casefold() - if session_type not in {"wayland", "x11"}: - if environment.get("WAYLAND_DISPLAY"): - session_type = "wayland" - elif environment.get("DISPLAY"): - session_type = "x11" - else: - session_type = "unknown" - desktop = environment.get("XDG_CURRENT_DESKTOP", "") - desktop = desktop.split(":", 1)[0].strip().casefold() or "unknown" - session = environment.get("DESKTOP_SESSION", "").strip().casefold() or "unknown" - return { - "server": session_type, - "desktop": desktop[:64], - "session": session[:64], - } - - -def _filesystem_info(target: Path) -> dict[str, object]: - existing = target - while not existing.exists() and existing != existing.parent: - existing = existing.parent - filesystem_type = "unknown" - readonly = False - noexec = False - try: - mounts = _read_mountinfo() - resolved = existing.resolve(strict=True) - matches = [ - mount - for mount in mounts - if resolved == mount[0] or resolved.is_relative_to(mount[0]) - ] - if matches: - mountpoint, filesystem_type, options = max( - matches, - key=lambda item: len(item[0].parts), - ) - _ = mountpoint - readonly = "ro" in options - noexec = "noexec" in options - except (OSError, RuntimeError, ValueError): - pass - try: - writable = os.access(existing, os.W_OK) and not readonly - except OSError: - writable = False - return { - "type": filesystem_type, - "writable": writable, - "readonly": readonly, - "noexec": noexec, - "network": filesystem_type.casefold() in _NETWORK_FILESYSTEMS, - } - - -def _read_mountinfo(path: Path = Path("/proc/self/mountinfo")) -> list[tuple[Path, str, set[str]]]: - mounts: list[tuple[Path, str, set[str]]] = [] - for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): - fields = line.split() - try: - separator = fields.index("-") - except ValueError: - continue - if separator < 6 or len(fields) <= separator + 3: - continue - mountpoint = Path(_unescape_mount_field(fields[4])) - filesystem_type = fields[separator + 1] - options = set(fields[5].split(",")) | set(fields[separator + 3].split(",")) - mounts.append((mountpoint, filesystem_type, options)) - return mounts - - -def _unescape_mount_field(value: str) -> str: - return re.sub( - r"\\([0-7]{3})", - lambda match: chr(int(match.group(1), 8)), - value, - ) - - -def _install_is_external(paths: AppPaths, environment: Mapping[str, str]) -> bool: - home_value = environment.get("HOME") - if not home_value: - return False - try: - home = Path(home_value).expanduser().resolve() - data = paths.data.resolve() - return data != home and not data.is_relative_to(home) - except (OSError, RuntimeError): - return False - - -def _current_game_version(path: Path) -> str | None: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - if not isinstance(value, dict): - return None - version = value.get("version") - return version[:32] if isinstance(version, str) and version else None - - -def _settings_schema_version() -> int: - try: - from .settings import SETTINGS_SCHEMA - - return SETTINGS_SCHEMA - except Exception: - return 1 - - -def _is_secret_key(key: str) -> bool: - normalized = key.casefold().replace("-", "_").strip() - return normalized in _SECRET_KEYS - - -def _string_limit_for_key(key: str | None) -> int: - normalized = (key or "").casefold() - if normalized == "stderr": - return MAX_STDERR_CHARACTERS - if normalized == "stdout": - return MAX_STDOUT_CHARACTERS - if normalized in {"sanitized_traceback", "traceback"}: - return MAX_TRACEBACK_CHARACTERS - return 8192 - - -def _keep_string_tail(key: str | None) -> bool: - return (key or "").casefold() in {"stderr", "stdout", "sanitized_traceback", "traceback"} - - -def _sanitize_url_match(match: re.Match[str]) -> str: - raw = match.group(0) - trailing = "" - while raw and raw[-1] in ".,;:)]": - trailing = raw[-1] + trailing - raw = raw[:-1] - try: - parsed = urlsplit(raw) - except ValueError: - return "" + trailing - host = parsed.hostname or "" - if not host: - return "" + trailing - netloc = host - if ":" in host and not host.startswith("["): - netloc = f"[{host}]" - try: - if parsed.port is not None: - netloc += f":{parsed.port}" - except ValueError: - return "" + trailing - query = parsed.query - if parsed.username is not None or any( - str(key).casefold() in _SECRET_QUERY_KEYS for key, _value in parse_qsl(query) - ): - query = "" if query else "" - return urlunsplit((parsed.scheme, netloc, parsed.path, query, "")) + trailing - - -def _truncate_characters(value: str, limit: int, *, tail: bool = False) -> str: - if len(value) <= limit: - return value - marker = "" - remaining = max(0, limit - len(marker)) - return marker + value[-remaining:] if tail else value[:remaining] + marker - - -def _truncate_utf8(value: str, limit: int, *, tail: bool = False) -> str: - encoded = value.encode("utf-8") - if len(encoded) <= limit: - return value - marker = b"" - remaining = max(0, limit - len(marker)) - chunk = encoded[-remaining:] if tail else encoded[:remaining] - decoded = chunk.decode("utf-8", errors="ignore") - return "" + decoded if tail else decoded + "" - - -def _bound_mapping(value: dict[str, object], limit: int) -> dict[str, object]: - if len(_json_bytes(value)) <= limit: - return value - bounded: dict[str, object] = {} - for key, item in value.items(): - candidate = {**bounded, key: item, "_truncated": True} - if len(_json_bytes(candidate)) <= limit: - bounded[key] = item - continue - if isinstance(item, str): - for string_limit in (4096, 2048, 1024, 512, 256, 128): - shortened = _truncate_utf8(item, string_limit, tail=_keep_string_tail(key)) - candidate = {**bounded, key: shortened, "_truncated": True} - if len(_json_bytes(candidate)) <= limit: - bounded[key] = shortened - break - bounded["_truncated"] = True - while len(_json_bytes(bounded)) > limit and len(bounded) > 1: - last = next(key for key in reversed(bounded) if key != "_truncated") - bounded.pop(last) - return bounded - - -def _encode_payload(payload: dict[str, object]) -> bytes | None: - body = _json_bytes(payload) - if len(body) <= MAX_REQUEST_BYTES: - return body - for key in ("metadata", "context"): - if key in payload: - payload[key] = {"_truncated": True} - body = _json_bytes(payload) - if len(body) <= MAX_REQUEST_BYTES: - return body - if isinstance(payload.get("message"), str): - payload["message"] = _truncate_utf8(str(payload["message"]), 2048) - body = _json_bytes(payload) - return body if len(body) <= MAX_REQUEST_BYTES else None - - -def _json_bytes(value: object) -> bytes: - return json.dumps( - value, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - - -def normalized_error_message(message: str) -> str: - """Normalize dynamic values for stable server-compatible fingerprints.""" - - value = _UUID_PATTERN.sub("", message) - value = _MEMORY_ADDRESS_PATTERN.sub("
", value) - return re.sub(r"\b\d{4}-\d{2}-\d{2}[T ][0-9:.+-Z]+\b", "", value) - - -def _resource_usage() -> dict[str, object]: - """Read one bounded process-usage snapshot on the Linux launcher host.""" - - try: - usage = resource.getrusage(resource.RUSAGE_SELF) - peak_value = float(usage.ru_maxrss) - peak_divisor = 1024 * 1024 if sys.platform == "darwin" else 1024 - peak_memory_mb = peak_value / peak_divisor - cpu_time_seconds = float(usage.ru_utime) + float(usage.ru_stime) - except (OSError, TypeError, ValueError, OverflowError): - return {} - if ( - not math.isfinite(peak_memory_mb) - or not math.isfinite(cpu_time_seconds) - or peak_memory_mb < 0 - or cpu_time_seconds < 0 - ): - return {} - return { - "peak_memory_mb": round(peak_memory_mb, 2), - "cpu_time_seconds": round(cpu_time_seconds, 3), - } - - -def _is_error_event(event: str, severity: str) -> bool: - return ( - severity in {"error", "critical"} - or "failed" in event - or "exception" in event - or "error" in event - ) - - -def _string_field(values: Mapping[str, object], key: str) -> str: - value = values.get(key) - return value if isinstance(value, str) else "" - - -def error_fingerprint( - event: str, - message: str, - *, - exception_type: str = "", - source_module: str = "", - source_function: str = "", -) -> str: - """Create a privacy-safe deterministic fingerprint for tests and server parity.""" - - material = "\n".join( - (event, exception_type, source_module, source_function, normalized_error_message(message)) - ) - return hashlib.sha256(material.encode("utf-8", errors="replace")).hexdigest() - - -def classify_network_failure(error: object) -> str: - """Return one stable low-cardinality class for download failures.""" - - message = str(error).casefold() - name = type(error).__name__.casefold() - if "timeout" in name or "timed out" in message or "timeout" in message: - return "connection_timeout" - if any( - value in message - for value in ( - "name or service not known", - "name resolution", - "nodename nor servname", - "temporary failure in name", - ) - ): - return "dns_failure" - if "connection refused" in message: - return "connection_refused" - if "connection reset" in message or "broken pipe" in message: - return "connection_reset" - if any(value in message for value in ("certificate", "ssl", "tls")): - return "tls_failure" - if re.search(r"\bhttp\s+\d{3}\b", message): - return "http_error" - if any( - value in message - for value in ("sha-1 mismatch", "sha-256", "checksum", "content-integrity") - ): - return "content_integrity" - if "could not connect" in message or "unreachable" in message: - return "cdn_unreachable" - return "unknown" - - -def network_http_status(error: object) -> int | None: - """Extract an HTTP status without exposing a failed request URL.""" - - current: object | None = error - seen: set[int] = set() - while current is not None and id(current) not in seen: - seen.add(id(current)) - status = getattr(current, "code", None) - if isinstance(status, int) and 100 <= status <= 599: - return status - match = _HTTP_STATUS_PATTERN.search(str(current)) - if match is not None: - return int(match.group(1)) - current = getattr(current, "__cause__", None) or getattr( - current, - "__context__", - None, - ) - return None diff --git a/src/ptlinux/telemetry/transport.py b/src/ptlinux/telemetry/transport.py new file mode 100644 index 0000000..9d94955 --- /dev/null +++ b/src/ptlinux/telemetry/transport.py @@ -0,0 +1,57 @@ +"""HTTPS transport primitives for launcher telemetry.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any +from urllib.parse import SplitResult, urlsplit +from urllib.request import Request, urlopen + + +def post_json( + endpoint: str, + body: bytes, + headers: Mapping[str, str], + timeout_seconds: float, + *, + request_factory: Callable[..., Request] | None = None, + opener: Callable[..., Any] | None = None, +) -> None: + """POST one encoded payload and reject non-success responses.""" + + make_request = Request if request_factory is None else request_factory + open_request = urlopen if opener is None else opener + request = make_request( + endpoint, + data=body, + headers=dict(headers), + method="POST", + ) + with open_request(request, timeout=timeout_seconds) as response: + status = getattr(response, "status", response.getcode()) + response.read(1024) + if not isinstance(status, int) or status < 200 or status >= 300: + raise OSError(f"telemetry endpoint returned HTTP {status}") + + +def validate_endpoint( + endpoint: str, + *, + split_url: Callable[[str], SplitResult] | None = None, +) -> None: + """Require a credential-free HTTPS telemetry endpoint.""" + + parse = urlsplit if split_url is None else split_url + parsed = parse(endpoint) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError("telemetry endpoint must be a credential-free HTTPS URL") + + +__all__ = ["post_json", "validate_endpoint"] diff --git a/src/ptlinux/telemetry_environment.py b/src/ptlinux/telemetry_environment.py new file mode 100644 index 0000000..8e56866 --- /dev/null +++ b/src/ptlinux/telemetry_environment.py @@ -0,0 +1,88 @@ +"""Compatibility facade for extended launcher telemetry environment probes.""" + +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 +from .telemetry.extended import ( + MAX_DLL_SCAN_BYTES, + MAX_MARKER_BYTES, + MAX_PROBE_OUTPUT_CHARACTERS, + PROBE_TIMEOUT_SECONDS, + ProbeResult, + ProbeRunner, + _DXVK_VERSION_PATTERN, + _GPU_VENDORS, + _LIBRARY_ROOTS, + _PCI_DATABASES, + _VERSION_PATTERN, + _VULKAN_MANIFEST_DIRECTORIES, + _connected_monitor_count, + _dependency_health, + _directx_runtime_present, + _directory_versions, + _display_info, + _dll_file_version, + _dotnet_present, + _driver_version, + _dxvk_dll_details, + _dxvk_info, + _elf_architecture, + _first_probe_line, + _font_dependencies_present, + _friendly_driver_name, + _gpu_info, + _gpu_offload_detected, + _hex_summary_value, + _hex_summary_values, + _is_kde_desktop, + _kde_display_scaling_info, + _library_candidates, + _manifest_entries, + _manifest_library_architectures, + _marker_timestamp, + _maximum_version, + _object_field, + _optional_bool, + _pci_model, + _pe_fixed_file_version, + _probe_text, + _proton_dxvk_version, + _proton_name, + _read_decimal, + _read_hex, + _read_json_object, + _read_short_text, + _run_probe, + _runtime_component_info, + _runtime_info, + _selected_prefix, + _short_string, + _summary_value, + _sysfs_gpus, + _vc_runtime_info, + _vendor_from_model, + _vulkan_gpu_summaries, + _vulkan_info, + _vulkan_loader_architectures, + _vulkan_manifest_search_directories, + _vulkan_manifests, + _wine_32bit_available, + _wine_command, + _wine_prefix_architecture, + _wine_vendor, + _wine_version, + collect_extended_environment, + inspect_vulkan_support, +) diff --git a/ptlinux/tk_chrome.py b/src/ptlinux/tk_chrome.py similarity index 100% rename from ptlinux/tk_chrome.py rename to src/ptlinux/tk_chrome.py diff --git a/ptlinux/ui/__init__.py b/src/ptlinux/ui/__init__.py similarity index 92% rename from ptlinux/ui/__init__.py rename to src/ptlinux/ui/__init__.py index 007ec99..fdd2282 100644 --- a/ptlinux/ui/__init__.py +++ b/src/ptlinux/ui/__init__.py @@ -1,6 +1,16 @@ """Public interface for the native PristonTale EU dashboard.""" -from .dashboard import launch_external_url, patch_change_colors, run_dashboard +from .dashboard import launch_external_url, run_dashboard +from .components.content_view import patch_change_colors +from .components.preloader import freeze_preloader_geometry +from .controllers.content import perform_server_status_check +from .controllers.launcher_update import launcher_restart_spec +from .controllers.operation import ( + GameLaunchSupervisorRegistry, + interrupt_game_supervisor, + launcher_child_command, + launcher_child_environment, +) from .viewmodels.dashboard import ( ACTIVE_POLL_INTERVAL_MILLISECONDS, BASE_DISPLAY_DPI, @@ -23,7 +33,6 @@ from .viewmodels.dashboard import ( DashboardError, GameClientDebugState, GameClientTracker, - GameLaunchSupervisorRegistry, LauncherUpdatePresentation, dashboard_content_ready, dashboard_controls_stacked, @@ -32,12 +41,7 @@ from .viewmodels.dashboard import ( 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, @@ -67,7 +71,6 @@ from .viewmodels.health import ( SERVER_STATUS_STALE_SECONDS, ServerStatusCheck, ServerStatusPresentation, - perform_server_status_check, server_status_check_text, server_status_presentation, ) diff --git a/ptlinux/ui/components/__init__.py b/src/ptlinux/ui/components/__init__.py similarity index 100% rename from ptlinux/ui/components/__init__.py rename to src/ptlinux/ui/components/__init__.py diff --git a/ptlinux/ui/components/action_panel.py b/src/ptlinux/ui/components/action_panel.py similarity index 100% rename from ptlinux/ui/components/action_panel.py rename to src/ptlinux/ui/components/action_panel.py diff --git a/ptlinux/ui/components/broadcasts_panel.py b/src/ptlinux/ui/components/broadcasts_panel.py similarity index 100% rename from ptlinux/ui/components/broadcasts_panel.py rename to src/ptlinux/ui/components/broadcasts_panel.py diff --git a/src/ptlinux/ui/components/content_view.py b/src/ptlinux/ui/components/content_view.py new file mode 100644 index 0000000..9d18e89 --- /dev/null +++ b/src/ptlinux/ui/components/content_view.py @@ -0,0 +1,712 @@ +"""Tk rendering for dashboard broadcasts, news, patch notes, and artwork.""" + +from __future__ import annotations + +import base64 +from collections.abc import Callable, Iterator +import math +from typing import Any + +from ...changelog import MAX_PATCHES, PatchNote, patch_change_tag +from ...news import Broadcast, NewsArticle +from ..viewmodels.dashboard import news_card_columns +from .broadcasts_panel import BroadcastsPanel +from .news_panel import ( + MAX_NEWS_CARDS, + NEWS_CARD_HEIGHT, + NEWS_IMAGE_HEIGHT, + NEWS_IMAGE_WIDTH, + PATCH_CARD_WRAP_LENGTH, + PATCH_CHANGE_WRAP_LENGTH, + NewsPanel, +) + + +PATCH_PRELOAD_INTERVAL_MILLISECONDS = 1 + + +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")) + + +class DashboardContentView: + """Own content widgets and apply data delivered by dashboard controllers.""" + + def __init__( + self, + root: Any, + *, + tk: Any, + ttk: Any, + news_panel: NewsPanel, + broadcasts_panel: BroadcastsPanel, + logo: Any, + ui_scale: float, + open_article: Callable[[str], None], + request_news_images: Callable[[tuple[NewsArticle, ...]], None], + set_component_pending: Callable[[str], None], + mark_component_ready: Callable[[str, str], None], + ) -> None: + self._root = root + self._tk = tk + self._ttk = ttk + self._news_panel = news_panel + self._broadcasts_panel = broadcasts_panel + self._logo = logo + self._ui_scale = ui_scale + self._open_article = open_article + self._request_news_images = request_news_images + self._set_component_pending = set_component_pending + self._mark_component_ready = mark_component_ready + self._news_card_height = round(NEWS_CARD_HEIGHT * ui_scale) + self._news_image_width = round(NEWS_IMAGE_WIDTH * ui_scale) + self._news_image_height = round(NEWS_IMAGE_HEIGHT * ui_scale) + self._patch_card_wrap_length = round(PATCH_CARD_WRAP_LENGTH * ui_scale) + self._patch_change_wrap_length = round(PATCH_CHANGE_WRAP_LENGTH * ui_scale) + self._news_image_targets: dict[int, Any] = {} + self._news_image_references: dict[int, Any] = {} + self._news_card_widgets: list[Any] = [] + self._news_layout_columns = MAX_NEWS_CARDS + self._pending_news_images: set[int] = set() + self._active_tab = "news" + self._rendered_news: tuple[NewsArticle, ...] | None = None + self._rendered_patches: tuple[PatchNote, ...] | None = None + self._patch_render_generation = 0 + self._patch_render_target: tuple[PatchNote, ...] | None = None + self._patch_render_callbacks: list[Callable[[], None]] = [] + self._logo_image_references: list[Any] = [] + self._bind_navigation() + + @property + def layout_columns(self) -> int: + return self._news_layout_columns + + def show_broadcast(self, broadcast: Broadcast | None) -> None: + content = self._broadcasts_panel.content + for child in content.winfo_children(): + child.destroy() + if broadcast is None: + self._ttk.Label( + content, + text="No current broadcast is available.", + style="Muted.TLabel", + ).pack(anchor="w", padx=8, pady=6) + return + card = self._tk.Frame( + 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 + ) + self._tk.Label( + card, + text=meta, + background="#211916", + foreground="#e5b66c", + font=("Sans", 9, "bold"), + ).pack(anchor="w") + self._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)) + self._make_card_clickable(card, broadcast.url) + + def show_news(self, articles: tuple[NewsArticle, ...]) -> None: + visible_articles = articles[:MAX_NEWS_CARDS] + if self._rendered_news == visible_articles: + return + self._news_image_targets.clear() + self._news_image_references.clear() + self._news_card_widgets.clear() + content = self._news_panel.news_content + for child in content.winfo_children(): + child.destroy() + for column in range(MAX_NEWS_CARDS): + content.grid_columnconfigure(column, weight=0, uniform="") + for row in range(MAX_NEWS_CARDS): + content.grid_rowconfigure(row, weight=0) + if not visible_articles: + self._ttk.Label( + content, + text=( + "Official news is temporarily unavailable. " + "Your cached game can still play." + ), + style="Muted.TLabel", + ).pack(anchor="w", padx=8, pady=8) + self._pending_news_images.clear() + self._mark_component_ready("news_artwork", "News layout ready") + self._rendered_news = visible_articles + return + for article in visible_articles: + card = self._tk.Frame( + content, + background="#211916", + highlightbackground="#4a3528", + highlightthickness=1, + height=self._news_card_height, + padx=8, + pady=8, + ) + card.grid_propagate(False) + self._news_card_widgets.append(card) + image_canvas = self._tk.Canvas( + card, + background="#211916", + width=self._news_image_width, + height=self._news_image_height, + highlightthickness=0, + borderwidth=0, + ) + image_canvas.pack(fill="x") + image_canvas.create_text( + self._news_image_width // 2, + self._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: + self._news_image_targets[article.id] = image_canvas + self._ttk.Label( + card, + text=article.title, + style="NewsTitle.TLabel", + wraplength=self._news_image_width, + justify="left", + ).pack(anchor="w", fill="x", pady=(6, 0)) + if article.published_at: + self._ttk.Label( + card, + text=article.published_at, + style="NewsMeta.TLabel", + ).pack(anchor="w", pady=(2, 4)) + self._tk.Label( + card, + text=article.summary, + background="#211916", + foreground="#c9baaa", + font=("Sans", 9), + wraplength=self._news_image_width, + justify="left", + height=3, + anchor="nw", + ).pack(anchor="w", fill="x") + self._make_card_clickable(card, article.url) + self._layout_news_cards() + self._load_news_images(visible_articles) + self._rendered_news = visible_articles + + def show_patch_notes( + self, + patches: tuple[PatchNote, ...], + *, + on_complete: Callable[[], None] | None = None, + ) -> None: + """Preload patch cards incrementally without blocking Tk's event loop.""" + + visible_patches = patches[:MAX_PATCHES] + if self._rendered_patches == visible_patches: + if on_complete is not None: + on_complete() + return + if self._patch_render_target == visible_patches: + if on_complete is not None: + self._patch_render_callbacks.append(on_complete) + return + + self._patch_render_generation += 1 + generation = self._patch_render_generation + self._patch_render_target = visible_patches + if on_complete is not None: + self._patch_render_callbacks.append(on_complete) + self._rendered_patches = None + content = self._news_panel.patches_content + for child in content.winfo_children(): + child.destroy() + content.grid() + if self._active_tab == "patches": + content.tkraise() + else: + self._news_panel.news_content.tkraise() + + def finish_render() -> None: + if generation != self._patch_render_generation: + return + self._rendered_patches = visible_patches + self._patch_render_target = None + if self._active_tab == "patches": + content.grid() + content.tkraise() + else: + content.grid_remove() + self._news_panel.news_content.tkraise() + self.update_viewport() + callbacks = tuple(self._patch_render_callbacks) + self._patch_render_callbacks.clear() + for callback in callbacks: + callback() + + if not visible_patches: + self._ttk.Label( + content, + text=( + "Official patch notes are temporarily unavailable. " + "Your cached game can still play." + ), + style="Muted.TLabel", + ).pack(anchor="w", padx=8, pady=8) + self._root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, finish_render) + return + + def patch_render_steps() -> Iterator[None]: + for patch in visible_patches: + yield from self._append_patch_note(patch) + + render_steps = patch_render_steps() + + def append_next() -> None: + if generation != self._patch_render_generation: + return + try: + next(render_steps) + except StopIteration: + finish_render() + return + content.update_idletasks() + self._root.after( + PATCH_PRELOAD_INTERVAL_MILLISECONDS, + append_next, + ) + + self._root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, append_next) + + def complete_news_image(self, value: object) -> None: + self._install_news_image(value) + if not isinstance(value, tuple) or not value or not isinstance(value[0], int): + return + self._pending_news_images.discard(value[0]) + if not self._pending_news_images: + self._mark_component_ready("news_artwork", "News artwork ready") + + def settle_pending_news_artwork(self) -> None: + for article_id in tuple(self._pending_news_images): + target = self._news_image_targets.get(article_id) + if target is None or not target.winfo_exists(): + continue + self._show_unavailable_artwork(target) + self._pending_news_images.clear() + + def install_official_logo(self, value: object) -> None: + if not isinstance(value, bytes): + return + try: + image = self._tk.PhotoImage( + data=base64.b64encode(value), + format="png", + ) + except self._tk.TclError: + return + factor = max( + 1, + math.ceil(image.width() / 42), + math.ceil(image.height() / 42), + ) + if factor > 1: + image = image.subsample(factor, factor) + self._logo_image_references.clear() + self._logo_image_references.append(image) + self._logo.delete("all") + self._logo.create_image(21, 21, image=image) + + def apply_width(self, width: int) -> None: + if news_card_columns(width, self._ui_scale) != self._news_layout_columns: + self._layout_news_cards() + + def update_viewport(self, _event: object | None = None) -> None: + canvas = self._news_panel.canvas + canvas.configure(scrollregion=canvas.bbox("all")) + + def _resize_content(self, event: object) -> None: + width = getattr(event, "width", 0) + if isinstance(width, int) and width > 0: + self._news_panel.canvas.itemconfigure( + self._news_panel.canvas_window, + width=width, + ) + + def _layout_news_cards(self) -> None: + columns = news_card_columns(max(1, self._root.winfo_width()), self._ui_scale) + self._news_layout_columns = columns + content = self._news_panel.news_content + for column in range(MAX_NEWS_CARDS): + 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): + content.grid_rowconfigure(row, weight=0) + for index, card in enumerate(self._news_card_widgets): + row, column = divmod(index, columns) + content.grid_rowconfigure(row, weight=1) + card.grid( + row=row, + column=column, + sticky="nsew", + padx=3, + pady=3, + ) + self._root.after_idle(self.update_viewport) + + def _make_card_clickable( + self, + card: Any, + 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: + self._open_article(url) + + def activate_card(_event: object) -> str: + self._open_article(url) + return "break" + + def bind_widget(widget: Any) -> None: + widget.bind("", enter) + widget.bind("", leave) + widget.bind("", open_card) + widget.bind("", self._scroll_view, "+") + widget.bind("", self._scroll_view, "+") + widget.bind("", self._scroll_view, "+") + widget.configure(cursor="hand2") + for child in 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 _append_patch_note(self, patch: PatchNote) -> Iterator[None]: + content = self._news_panel.patches_content + card = self._tk.Frame( + content, + background="#211916", + highlightbackground="#4a3528", + highlightthickness=1, + padx=16, + pady=13, + ) + card.pack(fill="x", padx=4, pady=(4, 9)) + self._tk.Label( + card, + text=patch.title, + background="#211916", + foreground="#fff0d2", + font=("Sans", 12, "bold"), + wraplength=self._patch_card_wrap_length, + justify="left", + anchor="w", + ).pack(anchor="w", fill="x") + metadata = self._tk.Frame(card, background="#211916") + metadata.pack(anchor="w", fill="x", pady=(7, 0)) + self._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",): + self._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: + self._tk.Label( + metadata, + text=patch.published_at, + background="#211916", + foreground="#c9baaa", + font=("Sans", 8), + ).pack(side="right") + self._tk.Label( + card, + text=patch.summary or "No summary provided.", + background="#211916", + foreground="#ded1c4", + font=("Sans", 10), + wraplength=self._patch_card_wrap_length, + justify="left", + anchor="w", + ).pack(anchor="w", fill="x", pady=(9, 3)) + if not patch.sections: + self._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: + self._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 = self._tk.Frame(card, background="#211916") + change_row.pack(anchor="w", fill="x", pady=3) + badge_background, badge_foreground = patch_change_colors( + change.type_key + ) + self._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)) + self._tk.Label( + change_row, + text=change.text, + background="#211916", + foreground="#ded1c4", + font=("Sans", 10), + wraplength=self._patch_change_wrap_length, + justify="left", + anchor="w", + ).pack(side="left", anchor="w", fill="x", expand=True) + yield + self._make_card_clickable(card, patch.url, border_only=True) + + def _load_news_images(self, articles: tuple[NewsArticle, ...]) -> None: + image_articles = tuple( + article for article in articles if article.image_url is not None + ) + self._pending_news_images.clear() + self._pending_news_images.update(article.id for article in image_articles) + if self._pending_news_images: + self._set_component_pending("news_artwork") + self._request_news_images(image_articles) + else: + self._mark_component_ready("news_artwork", "News artwork ready") + + def _install_news_image(self, 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 = self._news_image_targets.get(article_id) + if target is None or not target.winfo_exists(): + return + if image_data is None: + self._show_unavailable_artwork(target) + return + try: + image = self._tk.PhotoImage( + data=base64.b64encode(image_data), + format="png", + ) + except self._tk.TclError: + self._show_unavailable_artwork(target) + return + target_width = target.winfo_width() + target_height = target.winfo_height() + if target_width <= 1: + target_width = self._news_image_width + if target_height <= 1: + target_height = self._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) + self._news_image_references[article_id] = image + target.delete("all") + target.create_image( + target_width // 2, + target_height // 2, + image=image, + tags=("centered-content",), + ) + + def _show_unavailable_artwork(self, target: Any) -> None: + target.delete("all") + target.create_text( + self._news_image_width // 2, + self._news_image_height // 2, + text="Artwork unavailable", + fill="#a8998a", + font=("Sans", 8), + tags=("centered-content",), + ) + + def _switch_tab(self, tab: str) -> None: + if tab not in {"news", "patches"} or tab == self._active_tab: + return + self._active_tab = tab + self._news_panel.news_tab_button.configure( + style="NewsTabActive.TButton" if tab == "news" else "NewsTab.TButton" + ) + self._news_panel.patches_tab_button.configure( + style=( + "NewsTabActive.TButton" if tab == "patches" else "NewsTab.TButton" + ) + ) + self._news_panel.canvas.yview_moveto(0.0) + if tab == "patches": + self._news_panel.news_content.grid_remove() + self._news_panel.patches_content.grid() + self._news_panel.scrollbar.grid() + else: + self._news_panel.patches_content.grid_remove() + self._news_panel.news_content.grid() + self._news_panel.scrollbar.grid_remove() + self._root.after_idle(self.update_viewport) + + def _switch_tab_from_keyboard(self, tab: str, button: Any) -> str: + self._switch_tab(tab) + button.focus_set() + return "break" + + def _scroll_view(self, event: object) -> str | None: + if self._active_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 + self._news_panel.canvas.yview_scroll(direction * 3, "units") + return "break" + + def _scroll_keyboard(self, units: int) -> str: + self._news_panel.canvas.yview_scroll(units, "units") + return "break" + + def _bind_navigation(self) -> None: + panel = self._news_panel + panel.news_tab_button.configure(command=lambda: self._switch_tab("news")) + panel.patches_tab_button.configure(command=lambda: self._switch_tab("patches")) + panel.content_host.bind("", self.update_viewport) + panel.news_content.bind("", self.update_viewport) + panel.patches_content.bind("", self.update_viewport) + panel.canvas.bind("", self._resize_content) + panel.canvas.bind( + "", + lambda _event: panel.canvas.focus_set(), + add="+", + ) + panel.canvas.bind("", lambda _event: self._scroll_keyboard(-1)) + panel.canvas.bind("", lambda _event: self._scroll_keyboard(1)) + panel.canvas.bind("", lambda _event: self._scroll_keyboard(-8)) + panel.canvas.bind("", lambda _event: self._scroll_keyboard(8)) + panel.canvas.bind("", lambda _event: panel.canvas.yview_moveto(0.0)) + panel.canvas.bind("", lambda _event: panel.canvas.yview_moveto(1.0)) + panel.news_tab_button.bind( + "", + lambda _event: self._switch_tab_from_keyboard( + "patches", + panel.patches_tab_button, + ), + ) + panel.patches_tab_button.bind( + "", + lambda _event: self._switch_tab_from_keyboard( + "news", + panel.news_tab_button, + ), + ) + for widget in ( + panel.canvas, + panel.content_host, + panel.news_content, + panel.patches_content, + ): + widget.bind("", self._scroll_view, add="+") + widget.bind("", self._scroll_view, add="+") + widget.bind("", self._scroll_view, add="+") diff --git a/ptlinux/ui/components/header.py b/src/ptlinux/ui/components/header.py similarity index 100% rename from ptlinux/ui/components/header.py rename to src/ptlinux/ui/components/header.py diff --git a/ptlinux/ui/components/news_panel.py b/src/ptlinux/ui/components/news_panel.py similarity index 100% rename from ptlinux/ui/components/news_panel.py rename to src/ptlinux/ui/components/news_panel.py diff --git a/src/ptlinux/ui/components/preloader.py b/src/ptlinux/ui/components/preloader.py new file mode 100644 index 0000000..735a224 --- /dev/null +++ b/src/ptlinux/ui/components/preloader.py @@ -0,0 +1,20 @@ +"""Shared Tk preloader widget behavior.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from tkinter import Frame + + +def freeze_preloader_geometry(frame: Frame) -> tuple[int, int]: + """Lock a measured preloader frame so later status text cannot reflow it.""" + + frame.update_idletasks() + width = max(1, frame.winfo_reqwidth()) + height = max(1, frame.winfo_reqheight()) + frame.configure(width=width, height=height) + frame.pack_propagate(False) + return width, height diff --git a/ptlinux/ui/components/progress_panel.py b/src/ptlinux/ui/components/progress_panel.py similarity index 100% rename from ptlinux/ui/components/progress_panel.py rename to src/ptlinux/ui/components/progress_panel.py diff --git a/ptlinux/ui/components/server_status.py b/src/ptlinux/ui/components/server_status.py similarity index 100% rename from ptlinux/ui/components/server_status.py rename to src/ptlinux/ui/components/server_status.py diff --git a/src/ptlinux/ui/components/startup_preloader.py b/src/ptlinux/ui/components/startup_preloader.py new file mode 100644 index 0000000..23f4227 --- /dev/null +++ b/src/ptlinux/ui/components/startup_preloader.py @@ -0,0 +1,312 @@ +"""Dashboard startup overlay and its timed transition lifecycle.""" + +from __future__ import annotations + +import time +from typing import Any, Callable + +from .preloader import freeze_preloader_geometry +from ..viewmodels.dashboard import ( + DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, + DASHBOARD_STARTUP_COMPONENTS, + DASHBOARD_TRANSITION_STEP_MILLISECONDS, + DASHBOARD_TRANSITION_STEPS, + PRELOADER_STATUS_CHARACTERS, + STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS, + dashboard_content_ready, + dashboard_preloader_delay, + dashboard_transition_opacity, + preloader_logo_subsample_factor, + startup_progress_animation_value, +) + + +class DashboardStartupPreloader: + """Own startup widgets, readiness state, timers, and cross-fade callbacks.""" + + def __init__( + self, + root: Any, + parent: Any, + *, + tk: Any, + ttk: Any, + window_icon: Any | None, + on_timeout: Callable[[frozenset[str]], None], + on_complete: Callable[[], None], + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._root = root + self._tk = tk + self._on_timeout = on_timeout + self._on_complete = on_complete + self._clock = clock + self._started_at = clock() + self._image_references: list[Any] = [] + self._ready_components: set[str] = set() + self._transition_started = False + self._ready_after: str | None = None + self._timeout_after: str | None = None + self._progress_after: str | None = None + self._progress_target = 0.0 + + self._status = tk.StringVar(value="Loading dashboard contents…") + self._overlay = tk.Frame( + parent, + background="#0c0908", + borderwidth=0, + highlightthickness=0, + ) + self._overlay.place(x=0, y=0, relwidth=1, relheight=1) + border = tk.Frame( + self._overlay, + background="#6a482d", + borderwidth=0, + highlightthickness=0, + ) + border.place(relx=0.5, rely=0.5, anchor="center") + shell = ttk.Frame( + border, + style="Root.TFrame", + padding=(34, 26), + ) + shell.pack(fill="both", expand=True, padx=1, pady=1) + if window_icon is not None: + factor = preloader_logo_subsample_factor( + window_icon.width(), + window_icon.height(), + ) + logo = window_icon.subsample(factor, factor) + self._image_references.append(logo) + tk.Label( + shell, + image=logo, + background="#0c0908", + borderwidth=0, + highlightthickness=0, + ).pack(anchor="center", pady=(0, 16)) + ttk.Label( + shell, + text="Preparing the launcher…", + style="PreloaderStatus.TLabel", + anchor="center", + ).pack(fill="x") + ttk.Label( + shell, + textvariable=self._status, + style="PreloaderMuted.TLabel", + width=PRELOADER_STATUS_CHARACTERS, + anchor="center", + ).pack(fill="x", pady=(5, 11)) + self._progress_value = tk.DoubleVar(value=0.0) + ttk.Progressbar( + shell, + style="Depot.Horizontal.TProgressbar", + orient="horizontal", + mode="determinate", + maximum=len(DASHBOARD_STARTUP_COMPONENTS), + variable=self._progress_value, + length=420, + takefocus=False, + ).pack(fill="x") + freeze_preloader_geometry(border) + root.deiconify() + root.lift() + self._overlay.lift() + root.update() + + @property + def transition_started(self) -> bool: + return self._transition_started + + @property + def ready_components(self) -> frozenset[str]: + return frozenset(self._ready_components) + + def raise_overlay(self) -> None: + self._overlay.lift() + + def update_idletasks(self) -> None: + self._overlay.update_idletasks() + + def set_status(self, message: str) -> None: + if not self._transition_started: + self._status.set(message) + + def set_message(self, message: str) -> None: + if self._transition_started: + return + self._set_progress_target(float(len(self._ready_components))) + self._status.set( + f"{message} · {len(self._ready_components)}/" + f"{len(DASHBOARD_STARTUP_COMPONENTS)}" + ) + + def set_pending(self, component: str) -> None: + if self._transition_started or component not in DASHBOARD_STARTUP_COMPONENTS: + return + if component not in self._ready_components: + return + self._ready_components.remove(component) + self._cancel_after(self._ready_after) + self._ready_after = None + self.set_message("Loading dashboard content…") + + def mark_ready(self, component: str, message: str) -> None: + if self._transition_started or component not in DASHBOARD_STARTUP_COMPONENTS: + return + self._ready_components.add(component) + self.set_message(message) + self.maybe_start_transition() + + def schedule_timeout( + self, + delay_milliseconds: int = DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, + ) -> None: + if self._transition_started or self._timeout_after is not None: + return + self._timeout_after = self._root.after( + delay_milliseconds, + self._timeout, + ) + + def maybe_start_transition(self) -> None: + if self._transition_started or not dashboard_content_ready( + self._ready_components + ): + return + remaining = dashboard_preloader_delay( + self._started_at, + timestamp=self._clock(), + ) + if remaining <= 0: + self._begin_transition() + return + if self._ready_after is None: + self._ready_after = self._root.after( + remaining, + self._start_after_minimum, + ) + + def _cancel_progress_animation(self) -> None: + if self._progress_after is None: + return + try: + self._root.after_cancel(self._progress_after) + except self._tk.TclError: + pass + self._progress_after = None + + def _animate_progress(self) -> None: + self._progress_after = None + if self._transition_started or not self._overlay.winfo_exists(): + return + current = float(self._progress_value.get()) + value = startup_progress_animation_value(current, self._progress_target) + self._progress_value.set(value) + if value != self._progress_target: + self._progress_after = self._root.after( + STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS, + self._animate_progress, + ) + + def _set_progress_target(self, value: float, *, immediate: bool = False) -> None: + self._progress_target = min( + max(0.0, value), + float(len(DASHBOARD_STARTUP_COMPONENTS)), + ) + if immediate: + self._cancel_progress_animation() + self._progress_value.set(self._progress_target) + return + if self._progress_after is None: + self._progress_after = self._root.after( + STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS, + self._animate_progress, + ) + + def _cancel_after(self, identifier: str | None) -> None: + if identifier is None: + return + try: + self._root.after_cancel(identifier) + except self._tk.TclError: + pass + + def _set_root_opacity(self, value: float) -> bool: + try: + self._root.attributes("-alpha", value) + except self._tk.TclError: + return False + return True + + def _finish_transition(self) -> None: + self._set_root_opacity(1.0) + self._root.lift() + self._on_complete() + + def _fade_dashboard_in(self, step: int = 0) -> None: + if not self._set_root_opacity( + dashboard_transition_opacity(step, fading_in=True) + ): + self._finish_transition() + return + if step >= DASHBOARD_TRANSITION_STEPS: + self._finish_transition() + return + self._root.after( + DASHBOARD_TRANSITION_STEP_MILLISECONDS, + lambda: self._fade_dashboard_in(step + 1), + ) + + def _swap_for_dashboard(self, *, fade_supported: bool) -> None: + if self._overlay.winfo_exists(): + self._overlay.destroy() + self._image_references.clear() + self._root.update_idletasks() + if fade_supported: + self._fade_dashboard_in() + else: + self._finish_transition() + + def _fade_loader_out(self, step: int = 0) -> None: + fade_supported = self._set_root_opacity( + dashboard_transition_opacity(step, fading_in=False) + ) + if not fade_supported or step >= DASHBOARD_TRANSITION_STEPS: + self._swap_for_dashboard(fade_supported=fade_supported) + return + self._root.after( + DASHBOARD_TRANSITION_STEP_MILLISECONDS, + lambda: self._fade_loader_out(step + 1), + ) + + def _begin_transition(self, *, timed_out: bool = False) -> None: + if self._transition_started: + return + self._transition_started = True + self._cancel_after(self._ready_after) + self._cancel_after(self._timeout_after) + self._ready_after = None + self._timeout_after = None + if timed_out: + self._on_timeout(self.ready_components) + self._set_progress_target( + float(len(DASHBOARD_STARTUP_COMPONENTS)), + immediate=True, + ) + self._status.set( + "Opening dashboard with available content…" + if timed_out + else "Opening dashboard…" + ) + self._overlay.update_idletasks() + self._root.after(80, self._fade_loader_out) + + def _start_after_minimum(self) -> None: + self._ready_after = None + self.maybe_start_transition() + + def _timeout(self) -> None: + self._timeout_after = None + self._begin_transition(timed_out=True) diff --git a/ptlinux/ui/components/theme.py b/src/ptlinux/ui/components/theme.py similarity index 98% rename from ptlinux/ui/components/theme.py rename to src/ptlinux/ui/components/theme.py index 7da47d0..5d69fff 100644 --- a/ptlinux/ui/components/theme.py +++ b/src/ptlinux/ui/components/theme.py @@ -399,6 +399,8 @@ def configure_dashboard_style(root: Any, ttk: Any, launcher_ui_scale: float) -> fieldbackground="#211916", background="#211916", foreground="#fff0d2", + selectbackground="#211916", + selectforeground="#fff0d2", arrowcolor="#d8aa61", bordercolor="#4a3528", lightcolor="#4a3528", @@ -422,6 +424,14 @@ def configure_dashboard_style(root: Any, ttk: Any, launcher_ui_scale: float) -> ("disabled", "#887a70"), ("readonly", "#fff0d2"), ], + selectbackground=[ + ("disabled", "#171412"), + ("readonly", "#211916"), + ], + selectforeground=[ + ("disabled", "#887a70"), + ("readonly", "#fff0d2"), + ], arrowcolor=[ ("disabled", "#887a70"), ("active", "#f0d7a2"), diff --git a/ptlinux/ui/components/update_dialog.py b/src/ptlinux/ui/components/update_dialog.py similarity index 99% rename from ptlinux/ui/components/update_dialog.py rename to src/ptlinux/ui/components/update_dialog.py index c63e6a7..919d764 100644 --- a/ptlinux/ui/components/update_dialog.py +++ b/src/ptlinux/ui/components/update_dialog.py @@ -4,12 +4,10 @@ from __future__ import annotations from typing import Any, Callable -from ...paths import AppPaths from ...self_update import ( LauncherRemoteHistory, LauncherUpdateCheck, LauncherUpdateHistory, - inspect_launcher_update_history, ) from ...update_resolver import PRERELEASE_CHANNEL, ReleaseModelError, SemanticVersion from ...tk_chrome import ( @@ -98,13 +96,13 @@ class LauncherUpdateDialog: self, root: Any, *, - paths: AppPaths, launcher_version: str, ui_scale: float, window_icon: Any | None, apply_window_icon: Callable[[Any], None], get_check: Callable[[], LauncherUpdateCheck | None], get_remote_history: Callable[[], LauncherRemoteHistory | None], + get_installed_history: Callable[[], LauncherUpdateHistory], get_selected_channel: Callable[[], str], is_busy: Callable[[], bool], request_check: Callable[[], None], @@ -114,13 +112,13 @@ class LauncherUpdateDialog: append_log: Callable[[str], None], ) -> None: self._root = root - self._paths = paths self._launcher_version = launcher_version self._ui_scale = ui_scale self._window_icon = window_icon self._apply_window_icon = apply_window_icon self._get_check = get_check self._get_remote_history = get_remote_history + self._get_installed_history = get_installed_history self._get_selected_channel = get_selected_channel self._is_busy = is_busy self._request_check = request_check @@ -461,7 +459,7 @@ class LauncherUpdateDialog: assert self._install_button is not None check = self._get_check() try: - self._installed_history = inspect_launcher_update_history(self._paths) + self._installed_history = self._get_installed_history() except RuntimeError as error: self._installed_history = None self._append_log(f"Could not inspect launcher update history: {error}") diff --git a/src/ptlinux/ui/controllers/__init__.py b/src/ptlinux/ui/controllers/__init__.py new file mode 100644 index 0000000..7a3664e --- /dev/null +++ b/src/ptlinux/ui/controllers/__init__.py @@ -0,0 +1 @@ +"""Dashboard workflow controllers.""" diff --git a/src/ptlinux/ui/controllers/content.py b/src/ptlinux/ui/controllers/content.py new file mode 100644 index 0000000..f63e35d --- /dev/null +++ b/src/ptlinux/ui/controllers/content.py @@ -0,0 +1,213 @@ +"""One-shot background refresh for dashboard remote content.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import threading +import time + +from ...changelog import PatchNote, fetch_patch_notes +from ...depot import ( + DepotClient, + DepotError, + DepotSnapshot, + cache_depot_launch_catalog, +) +from ...news import ( + Broadcast, + NewsArticle, + fetch_latest_broadcast, + fetch_news, + fetch_news_image, + fetch_official_logo, + read_cached_news_image, + read_cached_official_logo, +) +from ...paths import AppPaths +from ...server_status import RealmServerStatus, ServerStatusError, fetch_server_status +from .operation import DashboardEventQueue +from ..viewmodels.health import ServerStatusCheck + + +def perform_server_status_check( + *, + fetcher: Callable[[], tuple[RealmServerStatus, ...]] = fetch_server_status, + monotonic: Callable[[], float] = time.monotonic, + wall_clock: Callable[[], float] = time.time, +) -> ServerStatusCheck: + """Measure one official status request without leaking its endpoint.""" + + started_at = monotonic() + try: + statuses = fetcher() + except ServerStatusError as error: + duration_seconds = max(0.0, monotonic() - started_at) + return ServerStatusCheck( + statuses=None, + checked_at=wall_clock(), + duration_seconds=duration_seconds, + error=str(error), + ) + duration_seconds = max(0.0, monotonic() - started_at) + return ServerStatusCheck( + statuses=statuses, + checked_at=wall_clock(), + duration_seconds=duration_seconds, + ) + + +class RemoteContentController: + """Start each remote dashboard request at most once.""" + + def __init__( + self, + paths: AppPaths, + events: DashboardEventQueue, + *, + low_bandwidth_mode: bool, + append_log: Callable[[str], None], + mark_component_ready: Callable[[str, str], None], + refresh_launcher_updates: Callable[[], None], + broadcast_fetcher: Callable[[Path], Broadcast | None] = fetch_latest_broadcast, + news_fetcher: Callable[[Path], tuple[NewsArticle, ...]] = fetch_news, + patches_fetcher: Callable[[Path], tuple[PatchNote, ...]] = fetch_patch_notes, + news_image_fetcher: Callable[[NewsArticle, Path], bytes | None] = fetch_news_image, + cached_news_image_reader: Callable[ + [NewsArticle, Path], bytes | None + ] = read_cached_news_image, + logo_fetcher: Callable[[Path], bytes | None] = fetch_official_logo, + cached_logo_reader: Callable[[Path], bytes | None] = read_cached_official_logo, + server_status_fetcher: Callable[[], ServerStatusCheck] = perform_server_status_check, + depot_client_factory: Callable[[], DepotClient] = DepotClient, + depot_error: type[Exception] = DepotError, + catalog_cache: Callable[[Path, DepotSnapshot], object] = cache_depot_launch_catalog, + thread_factory: Callable[..., threading.Thread] = threading.Thread, + ) -> None: + self._paths = paths + self._events = events + self._low_bandwidth_mode = low_bandwidth_mode + self._append_log = append_log + self._mark_component_ready = mark_component_ready + self._refresh_launcher_updates = refresh_launcher_updates + self._broadcast_fetcher = broadcast_fetcher + self._news_fetcher = news_fetcher + self._patches_fetcher = patches_fetcher + self._news_image_fetcher = news_image_fetcher + self._cached_news_image_reader = cached_news_image_reader + self._logo_fetcher = logo_fetcher + self._cached_logo_reader = cached_logo_reader + self._server_status_fetcher = server_status_fetcher + self._depot_client_factory = depot_client_factory + self._depot_error = depot_error + self._catalog_cache = catalog_cache + self._thread_factory = thread_factory + self._started = False + + @property + def started(self) -> bool: + return self._started + + def start(self) -> bool: + if self._started: + return False + self._started = True + self._start_workers() + return True + + def _start_workers(self) -> None: + def broadcast_worker() -> None: + self._events.put( + ("broadcast", self._broadcast_fetcher(self._paths.broadcast_cache)) + ) + + def news_worker() -> None: + self._events.put(("news", self._news_fetcher(self._paths.news_cache))) + + def patches_worker() -> None: + self._events.put( + ("patches", self._patches_fetcher(self._paths.patches_cache)) + ) + + def server_status_worker() -> None: + self._events.put(("server_status_check", self._server_status_fetcher())) + + def version_worker() -> None: + try: + snapshot = self._depot_client_factory().fetch_snapshot() + except self._depot_error as error: + self._events.put(("version_error", str(error))) + else: + try: + self._catalog_cache( + self._paths.depot_launch_catalog_cache, + snapshot, + ) + except OSError as error: + self._events.put( + ( + "output", + f"Could not cache official realm choices: {error}\n", + ) + ) + self._events.put(("version", snapshot)) + + if self._low_bandwidth_mode: + self._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"), + ): + self._mark_component_ready(component, message) + else: + self._start_thread(broadcast_worker, "ptlinux-broadcast") + self._start_thread(news_worker, "ptlinux-news") + self._start_thread(patches_worker, "ptlinux-patches") + self._start_thread(server_status_worker, "ptlinux-server-status") + self._start_thread(version_worker, "ptlinux-version") + if not self._low_bandwidth_mode: + self._refresh_launcher_updates() + + def start_news_images(self, articles: tuple[NewsArticle, ...]) -> None: + """Load requested article artwork without touching Tk from worker threads.""" + + for article in articles: + if article.image_url is None: + continue + + def worker(news_article: NewsArticle = article) -> None: + image = ( + self._cached_news_image_reader( + news_article, + self._paths.news_image_cache, + ) + if self._low_bandwidth_mode + else self._news_image_fetcher( + news_article, + self._paths.news_image_cache, + ) + ) + self._events.put(("news_image", (news_article.id, image))) + + self._start_thread(worker, "ptlinux-news-image") + + def start_logo(self) -> None: + """Load the cached or remote launcher logo on one background worker.""" + + def worker() -> None: + image = ( + self._cached_logo_reader(self._paths.logo_cache) + if self._low_bandwidth_mode + else self._logo_fetcher(self._paths.logo_cache) + ) + self._events.put(("logo_image", image)) + + self._start_thread(worker, "ptlinux-logo") + + def _start_thread(self, target: Callable[[], None], name: str) -> None: + self._thread_factory(target=target, name=name, daemon=True).start() diff --git a/src/ptlinux/ui/controllers/events.py b/src/ptlinux/ui/controllers/events.py new file mode 100644 index 0000000..81c254d --- /dev/null +++ b/src/ptlinux/ui/controllers/events.py @@ -0,0 +1,51 @@ +"""Bounded Tk-thread routing for dashboard worker events.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +import queue + +from .operation import DashboardEventQueue + + +DashboardEventHandler = Callable[[object], bool | None] + + +@dataclass(frozen=True, slots=True) +class EventDrainResult: + processed: int + keep_polling: bool = True + + +class DashboardEventRouter: + """Drain a bounded event batch through Tk-thread handlers.""" + + def __init__( + self, + events: DashboardEventQueue, + handlers: Mapping[str, DashboardEventHandler], + *, + recover: Callable[[Exception], None], + ) -> None: + self._events = events + self._handlers = dict(handlers) + self._recover = recover + + def drain(self, limit: int) -> EventDrainResult: + if limit <= 0: + raise ValueError("dashboard event batch limit must be positive") + processed = 0 + try: + while processed < limit: + try: + kind, value = self._events.get_nowait() + except queue.Empty: + break + processed += 1 + handler = self._handlers.get(kind) + if handler is not None and handler(value) is False: + return EventDrainResult(processed, keep_polling=False) + except Exception as error: + self._recover(error) + return EventDrainResult(processed) diff --git a/src/ptlinux/ui/controllers/installation.py b/src/ptlinux/ui/controllers/installation.py new file mode 100644 index 0000000..a14d7e1 --- /dev/null +++ b/src/ptlinux/ui/controllers/installation.py @@ -0,0 +1,36 @@ +"""Install, update, repair, import, and health operation planning.""" + +from __future__ import annotations + +from .operation import OperationPlan +from ..viewmodels.installation import game_sync_presentation + + +def game_sync_operation(*, installed: bool, repair: bool = False) -> OperationPlan: + presentation = game_sync_presentation(installed=installed, repair=repair) + arguments = ( + ("update", "--no-import", "--repair") + if repair + else ("update",) + ) + return OperationPlan( + label=presentation.operation_label, + arguments=arguments, + game_files_may_change=True, + file_progress_verb=presentation.file_progress_verb, + ) + + +def import_game_operation(source: str) -> OperationPlan: + return OperationPlan( + label="Importing the existing game into the managed installation…", + arguments=("import-game", source), + game_files_may_change=True, + ) + + +def system_check_operation() -> OperationPlan: + return OperationPlan( + label="Checking the system…", + arguments=("doctor",), + ) diff --git a/src/ptlinux/ui/controllers/launcher_update.py b/src/ptlinux/ui/controllers/launcher_update.py new file mode 100644 index 0000000..0ef5ad2 --- /dev/null +++ b/src/ptlinux/ui/controllers/launcher_update.py @@ -0,0 +1,150 @@ +"""Launcher-update refresh, operation planning, and restart control.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +import os +from pathlib import Path +import sys +import threading + +from ...paths import AppPaths +from ...self_update import ( + LauncherUpdateHistory, + LauncherRemoteHistory, + LauncherUpdateCheck, + cache_launcher_release_history, + cache_launcher_update_check, + check_for_launcher_update, + fetch_launcher_release_history, + inspect_launcher_update_history, +) +from .operation import DashboardEventQueue, OperationPlan +from ..viewmodels.dashboard import DashboardError + + +@dataclass(frozen=True, slots=True) +class LauncherRestartResult: + error: str | None = None + + @property + def started(self) -> bool: + return self.error is None + + +def launcher_restart_spec( + paths: AppPaths, + environment: Mapping[str, str] | None = None, +) -> tuple[list[str], dict[str, str]]: + """Build an isolated restart that imports the newly activated release.""" + + current = paths.launcher_current + if not current.is_symlink(): + raise DashboardError(f"launcher current path is not a symbolic link: {current}") + try: + release = current.resolve(strict=True) + except OSError as error: + raise DashboardError( + f"launcher current release is unavailable: {error}" + ) from error + releases = paths.launcher_releases.resolve(strict=False) + if release.parent != releases or not (release / "ptlinux").is_dir(): + raise DashboardError( + f"launcher current release is outside the release store: {release}" + ) + env = dict(os.environ if environment is None else environment) + env.update( + { + "PTLINUX_INSTALL_DIR": str(paths.data), + "PTLINUX_APP_HOME": str(paths.app_home), + "PYTHONSAFEPATH": "1", + "PYTHONPATH": str(release), + } + ) + return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env + + +def launcher_install_operation(version: str) -> OperationPlan: + return OperationPlan( + label=f"Installing launcher {version}…", + arguments=("launcher-update",), + restart_on_success=True, + ) + + +def launcher_rollback_operation(version: str) -> OperationPlan: + return OperationPlan( + label=f"Rolling back launcher to {version}…", + arguments=("launcher-update", "--rollback", version), + restart_on_success=True, + ) + + +def load_installed_launcher_history(paths: AppPaths) -> LauncherUpdateHistory: + """Read the local launcher history for update-dialog presentation.""" + + return inspect_launcher_update_history(paths) + + +def start_launcher_update_refresh( + paths: AppPaths, + requested_channel: str, + events: DashboardEventQueue, + *, + update_checker: Callable[..., LauncherUpdateCheck] = check_for_launcher_update, + update_cache: Callable[[Path, LauncherUpdateCheck], object] = cache_launcher_update_check, + history_fetcher: Callable[[], LauncherRemoteHistory] = fetch_launcher_release_history, + history_cache: Callable[[Path, LauncherRemoteHistory], object] = cache_launcher_release_history, + thread_factory: Callable[..., threading.Thread] = threading.Thread, +) -> None: + def update_worker() -> None: + try: + check = update_checker(channel=requested_channel) + except RuntimeError as error: + events.put(("launcher_update_error", (requested_channel, str(error)))) + else: + update_cache(paths.launcher_update_check_cache, check) + events.put(("launcher_update_check", (requested_channel, check))) + + def history_worker() -> None: + try: + history = history_fetcher() + except RuntimeError as error: + events.put(("launcher_history_error", str(error))) + else: + history_cache(paths.launcher_release_history_cache, history) + events.put(("launcher_release_history", history)) + + thread_factory( + target=update_worker, + name="ptlinux-self-update", + daemon=True, + ).start() + thread_factory( + target=history_worker, + name="ptlinux-release-history", + daemon=True, + ).start() + + +def restart_dashboard_after_update( + paths: AppPaths, + *, + restart_spec: Callable[ + [AppPaths], + tuple[list[str], Mapping[str, str]], + ] = launcher_restart_spec, + process_factory: Callable[..., object], +) -> LauncherRestartResult: + try: + command, environment = restart_spec(paths) + process_factory( + command, + env=environment, + close_fds=True, + start_new_session=True, + ) + except OSError as error: + return LauncherRestartResult(error=str(error)) + return LauncherRestartResult() diff --git a/src/ptlinux/ui/controllers/operation.py b/src/ptlinux/ui/controllers/operation.py new file mode 100644 index 0000000..7032dff --- /dev/null +++ b/src/ptlinux/ui/controllers/operation.py @@ -0,0 +1,232 @@ +"""Background launcher-operation process lifecycle and output routing.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +import json +import os +import queue +import signal +import subprocess +import sys +import threading + +from ...telemetry import ( + TELEMETRY_OPERATION_PROCESS_ROLE, + TELEMETRY_PROCESS_ROLE_VARIABLE, +) +from ..viewmodels.downloader import ProgressCoalescer + + +DashboardEventQueue = queue.Queue[tuple[str, object]] + + +class GameLaunchSupervisorRegistry: + """Coordinate cancellation requests with game supervisor processes.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._processes: dict[int, subprocess.Popen[str]] = {} + self._cancel_requested: set[int] = set() + + def attach(self, client_id: int, process: subprocess.Popen[str]) -> bool: + """Register a supervisor and report whether cancellation raced its start.""" + + with self._lock: + self._processes[client_id] = process + return client_id in self._cancel_requested + + def request_cancel(self, client_id: int) -> subprocess.Popen[str] | None: + """Record cancellation and return an already-started supervisor, if any.""" + + with self._lock: + self._cancel_requested.add(client_id) + return self._processes.get(client_id) + + def detach(self, client_id: int, process: subprocess.Popen[str]) -> None: + with self._lock: + if self._processes.get(client_id) is process: + self._processes.pop(client_id, None) + + def is_cancel_requested(self, client_id: int) -> bool: + with self._lock: + return client_id in self._cancel_requested + + def finish(self, client_id: int) -> bool: + """Forget one launch and return whether the user cancelled it.""" + + with self._lock: + self._processes.pop(client_id, None) + if client_id not in self._cancel_requested: + return False + self._cancel_requested.remove(client_id) + return True + + +def interrupt_game_supervisor(process: subprocess.Popen[str]) -> bool: + """Ask the play CLI to stop its complete Proton/game process group.""" + + if process.poll() is not None: + return False + try: + process.send_signal(signal.SIGINT) + except OSError: + return False + return True + + +def launcher_child_command(*arguments: str) -> list[str]: + """Build a child command without writing bytecode into the active release.""" + + return [sys.executable, "-B", "-m", "ptlinux", *arguments] + + +def launcher_child_environment( + environment: Mapping[str, str] | None = None, +) -> dict[str, str]: + """Mark an internal CLI process as part of the current dashboard session.""" + + child_environment = dict(os.environ if environment is None else environment) + child_environment[TELEMETRY_PROCESS_ROLE_VARIABLE] = ( + TELEMETRY_OPERATION_PROCESS_ROLE + ) + return child_environment + + +@dataclass(frozen=True, slots=True) +class OperationPlan: + """One dashboard request for the stable launcher CLI.""" + + label: str + arguments: tuple[str, ...] + show_preloader: bool = False + restart_on_success: bool = False + game_files_may_change: bool = False + file_progress_verb: str = "Updating" + + +def start_operation_process( + command: list[str], + *, + events: DashboardEventQueue, + progress_updates: ProgressCoalescer, + game_launch_supervisors: GameLaunchSupervisorRegistry, + child_environment: Callable[[], Mapping[str, str]], + interrupt_supervisor: Callable[[subprocess.Popen[str]], bool], + restart_on_success: bool = False, + game_client_id: int | None = None, + launch_stage_total: int = 4, + process_factory: Callable[..., subprocess.Popen[str]] = subprocess.Popen, + thread_factory: Callable[..., threading.Thread] = threading.Thread, +) -> None: + """Start one CLI child and translate its line protocol into dashboard events.""" + + 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}")) + + def worker() -> None: + 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 = process_factory( + command, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + env=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_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_progress = json.loads(line.removeprefix("PROGRESS ")) + except json.JSONDecodeError: + publish_output(line) + else: + if isinstance(parsed_progress, dict): + progress_updates.publish(parsed_progress) + 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))) + + thread_factory( + target=worker, + name=( + f"ptlinux-game-client-{game_client_id}" + if game_client_id is not None + else "ptlinux-operation" + ), + daemon=True, + ).start() diff --git a/src/ptlinux/ui/controllers/play.py b/src/ptlinux/ui/controllers/play.py new file mode 100644 index 0000000..e8cae53 --- /dev/null +++ b/src/ptlinux/ui/controllers/play.py @@ -0,0 +1,85 @@ +"""Game-launch preparation, operation planning, and cancellation control.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +import subprocess + +from ...dashboard_debug import RuntimeDebugInfo, inspect_runtime_debug +from ...depot import DepotSnapshot, cache_depot_launch_catalog +from ...paths import AppPaths +from ...settings import Settings, load_settings +from .operation import GameLaunchSupervisorRegistry, OperationPlan + + +@dataclass(frozen=True, slots=True) +class GameLaunchPreparation: + settings: Settings | None = None + runtime_debug: RuntimeDebugInfo | None = None + realm_cache_error: str | None = None + settings_error: str | None = None + + +@dataclass(frozen=True, slots=True) +class GameLaunchCancellation: + log_message: str + status_message: str + progress_message: str + + +def prepare_game_launch( + paths: AppPaths, + snapshot: DepotSnapshot | None, + *, + cache_launch_catalog: Callable[[Path, DepotSnapshot], None] = cache_depot_launch_catalog, + settings_loader: Callable[[AppPaths], Settings] = load_settings, + runtime_inspector: Callable[[AppPaths, Settings], RuntimeDebugInfo] = inspect_runtime_debug, +) -> GameLaunchPreparation: + if snapshot is not None: + try: + cache_launch_catalog(paths.depot_launch_catalog_cache, snapshot) + except OSError as error: + return GameLaunchPreparation(realm_cache_error=str(error)) + try: + settings = settings_loader(paths) + except (OSError, ValueError) as error: + return GameLaunchPreparation(settings_error=str(error)) + return GameLaunchPreparation( + settings=settings, + runtime_debug=runtime_inspector(paths, settings), + ) + + +def game_launch_operation(realm: str, client_id: int) -> OperationPlan: + return OperationPlan( + label=f"Starting PristonTale EU {realm} client {client_id}…", + arguments=("play", "--realm", realm), + show_preloader=True, + ) + + +def cancel_game_launch( + registry: GameLaunchSupervisorRegistry, + client_id: int, + *, + interrupt_supervisor: Callable[[subprocess.Popen[str]], bool], +) -> GameLaunchCancellation: + process = registry.request_cancel(client_id) + interrupted = process is not None and interrupt_supervisor(process) + if interrupted: + log_message = ( + f"Cancelling game client {client_id}; stopping its process group." + ) + elif process is None: + log_message = ( + f"Cancelling game client {client_id} before its supervisor starts." + ) + else: + log_message = f"Game client {client_id} was already stopping." + return GameLaunchCancellation( + log_message=log_message, + status_message=f"Cancelling game client {client_id}…", + progress_message="Stopping the initiated game and compatibility processes…", + ) diff --git a/src/ptlinux/ui/dashboard.py b/src/ptlinux/ui/dashboard.py new file mode 100644 index 0000000..2fab61a --- /dev/null +++ b/src/ptlinux/ui/dashboard.py @@ -0,0 +1,1691 @@ +"""Native PristonTale EU dashboard; Wine never hosts the updater UI.""" + +from __future__ import annotations + +import queue +import subprocess +import sys +import threading +import time +from typing import TYPE_CHECKING, Mapping + +if TYPE_CHECKING: + from tkinter import PhotoImage, Toplevel + +from .. import __version__ +from ..changelog import ( + PatchNote, + fetch_patch_notes, + 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, + 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 ( + create_dashboard_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.content_view import DashboardContentView, patch_change_colors +from .components.header import build_dashboard_header +from .components.news_panel import 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.startup_preloader import DashboardStartupPreloader +from .components.theme import configure_dashboard_style +from .components.update_dialog import LauncherUpdateDialog +from .controllers.content import RemoteContentController, perform_server_status_check +from .controllers.events import DashboardEventRouter +from .controllers.installation import ( + game_sync_operation, + import_game_operation, + system_check_operation, +) +from .controllers.launcher_update import ( + launcher_install_operation, + launcher_restart_spec, + launcher_rollback_operation, + load_installed_launcher_history, + restart_dashboard_after_update, + start_launcher_update_refresh, +) +from .controllers.operation import ( + GameLaunchSupervisorRegistry, + OperationPlan, + interrupt_game_supervisor, + launcher_child_command, + launcher_child_environment, + start_operation_process, +) +from .controllers.play import ( + cancel_game_launch, + game_launch_operation, + prepare_game_launch, +) +from .dialogs.activity import build_activity_window +from .dialogs.debug import DashboardDebugDialog +from .dialogs.game_preloader import GamePreloaderDialog +from .viewmodels.dashboard import ( + ACTIVE_POLL_INTERVAL_MILLISECONDS, + BASE_DISPLAY_DPI, + BASE_TK_SCALING, + WINDOW_HEIGHT, + WINDOW_WIDTH, + DashboardError, + GameClientTracker, + dashboard_controls_stacked, + dashboard_poll_interval, + fitted_window_size, + game_client_activity_text, + launcher_update_presentation, + resolve_ui_scale, +) +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.debug import ( + DashboardDebugReportState, + render_dashboard_debug_report, +) +from .viewmodels.health import ( + ServerStatusCheck, + server_status_check_text, +) +from .viewmodels.installation import ( + game_file_progress_status, +) +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 + + +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 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) + 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) + + def finish_dashboard_startup() -> None: + 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 handle_dashboard_startup_timeout(ready_components: frozenset[str]) -> None: + settle_pending_news_artwork() + if "version" not in ready_components: + version.set( + f"Launcher {__version__} · game build check continuing in background" + ) + + startup_preloader = DashboardStartupPreloader( + root, + dashboard_content, + tk=tk, + ttk=ttk, + window_icon=window_icon, + on_timeout=handle_dashboard_startup_timeout, + on_complete=finish_dashboard_startup, + ) + set_startup_component_pending = startup_preloader.set_pending + mark_startup_component_ready = startup_preloader.mark_ready + update_startup_progress = startup_preloader.set_message + + 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 + + broadcast_widgets = build_broadcasts_panel( + frame, + ttk=ttk, + before=news_panel, + ) + + 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() + settings_window: tk.Toplevel | None = None + launcher_update_dialog: LauncherUpdateDialog | 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…" + dashboard_runtime_debug: RuntimeDebugInfo | None = None + dashboard_verification_debug: VerificationDebugInfo | None = None + dashboard_debug_state_error: str | None = None + 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()) + game_preloader.set_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()) + game_preloader.set_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: + game_preloader.close() + return + cancellation = cancel_game_launch( + game_launch_supervisors, + client_id, + interrupt_supervisor=interrupt_game_supervisor, + ) + append_log(cancellation.log_message) + game_preloader.close() + root.deiconify() + root.lift() + status.set(cancellation.status_message) + progress_text.set(cancellation.progress_message) + + game_preloader = GamePreloaderDialog( + root, + tk=tk, + ttk=ttk, + window_icon=window_icon, + apply_window_icon=apply_window_icon, + cancel=cancel_active_game_launch, + launch_stage_total=LAUNCH_STAGE_TOTAL, + ) + + 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: + game_preloader.show() + 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)}") + + start_operation_process( + command, + events=events, + progress_updates=progress_updates, + game_launch_supervisors=game_launch_supervisors, + child_environment=launcher_child_environment, + interrupt_supervisor=interrupt_game_supervisor, + restart_on_success=restart_on_success, + game_client_id=game_client_id, + launch_stage_total=LAUNCH_STAGE_TOTAL, + process_factory=subprocess.Popen, + thread_factory=threading.Thread, + ) + + 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, + ) + + content_view = DashboardContentView( + root, + tk=tk, + ttk=ttk, + news_panel=news_widgets, + broadcasts_panel=broadcast_widgets, + logo=logo, + ui_scale=launcher_ui_scale, + open_article=open_article, + request_news_images=lambda articles: remote_content_controller.start_news_images( + articles + ), + set_component_pending=set_startup_component_pending, + mark_component_ready=mark_startup_component_ready, + ) + show_broadcast = content_view.show_broadcast + show_news = content_view.show_news + show_patch_notes = content_view.show_patch_notes + complete_news_image = content_view.complete_news_image + settle_pending_news_artwork = content_view.settle_pending_news_artwork + install_official_logo = content_view.install_official_logo + + def update_server_status_debug_text() -> None: + value = "" + if debug_dialog.visible: + 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=debug_dialog.visible, + ) + + 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 dashboard_debug_report_state() -> DashboardDebugReportState: + return DashboardDebugReportState( + server_status_check=latest_server_status_check, + server_statuses=latest_server_statuses, + server_status_placeholder=server_status_placeholder, + realm=realm_value.get(), + start_options=available_start_options, + official_snapshot=official_snapshot, + installation_status=latest_installation_status, + client_states=game_clients.debug_states(), + client_runtime_debug=dict(client_runtime_debug), + dashboard_runtime_debug=dashboard_runtime_debug, + verification_debug=dashboard_verification_debug, + state_error=dashboard_debug_state_error, + dxvk_cache=str(paths.dxvk_cache), + active_game_launch_id=active_game_launch_id, + operation_active=operation_active, + operation_stage=operation_stage, + operation_stage_total=operation_stage_total, + operation_message=operation_message, + operation_status=status.get(), + ) + + debug_dialog = DashboardDebugDialog( + root, + tk=tk, + ttk=ttk, + apply_window_icon=apply_window_icon, + refresh_state=refresh_dashboard_debug_disk_state, + render_report=lambda: render_dashboard_debug_report( + dashboard_debug_report_state() + ), + on_visibility_changed=render_server_status, + ) + update_dashboard_debug_view = debug_dialog.refresh + set_dashboard_debug = debug_dialog.set_visible + + 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 + start_launcher_update_refresh( + paths, + requested_channel, + events, + update_checker=check_for_launcher_update, + update_cache=cache_launcher_update_check, + history_fetcher=fetch_launcher_release_history, + history_cache=cache_launcher_release_history, + thread_factory=threading.Thread, + ) + + remote_content_controller = RemoteContentController( + paths, + events, + low_bandwidth_mode=startup_settings.low_bandwidth_mode, + append_log=append_log, + mark_component_ready=mark_startup_component_ready, + refresh_launcher_updates=refresh_launcher_update_check, + broadcast_fetcher=fetch_latest_broadcast, + news_fetcher=fetch_news, + patches_fetcher=fetch_patch_notes, + news_image_fetcher=fetch_news_image, + cached_news_image_reader=read_cached_news_image, + logo_fetcher=fetch_official_logo, + cached_logo_reader=read_cached_official_logo, + server_status_fetcher=perform_server_status_check, + depot_client_factory=DepotClient, + depot_error=DepotError, + catalog_cache=cache_depot_launch_catalog, + thread_factory=threading.Thread, + ) + + def start_remote_content_refresh() -> None: + remote_content_controller.start() + + 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() + game_preloader.close() + 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() + game_preloader.close() + 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 debug_dialog.visible: + 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 handle_output_event(value: object) -> None: + if isinstance(value, str): + append_log(value) + + def handle_done_event(value: object) -> None: + if isinstance(value, int): + finish_operation(value) + + def handle_launcher_update_done_event(value: object) -> bool | None: + if not isinstance(value, int): + return None + finish_operation(value) + if value != 0: + return None + append_log("Launcher update activated; restarting the dashboard.") + restart = restart_dashboard_after_update( + paths, + restart_spec=launcher_restart_spec, + process_factory=subprocess.Popen, + ) + if restart.error is not None: + 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, + ) + return None + root.destroy() + return False + + def handle_operation_status_event(value: object) -> None: + if not isinstance(value, Mapping): + return + 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) + + def handle_game_supervisor_started_event(value: object) -> None: + if ( + 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]) + + def handle_game_operation_status_event(value: object) -> None: + if not ( + 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]) + ): + return + handle_operation_status_event(value[1]) + + def handle_game_started_event(value: object) -> None: + if not ( + isinstance(value, int) + and not game_launch_supervisors.is_cancel_requested(value) + ): + return + 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…") + + def handle_game_window_ready_event(value: object) -> None: + nonlocal game_window_active + if not ( + isinstance(value, int) + and not game_launch_supervisors.is_cancel_requested(value) + ): + return + if game_clients.mark_running(value): + game_window_active = True + release_game_launch_controls(value, ready=True) + + def handle_game_done_event(value: object) -> None: + nonlocal game_window_active + if not ( + isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], int) + and isinstance(value[1], int) + ): + return + 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 debug_dialog.visible: + refresh_dashboard_debug_disk_state() + + def handle_broadcast_event(value: object) -> None: + show_broadcast(value if isinstance(value, Broadcast) else None) + mark_startup_component_ready("broadcast", "Broadcast ready") + + def handle_server_status_event(value: object) -> None: + if not isinstance(value, ServerStatusCheck): + return + 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" + ), + ) + + def handle_news_event(value: object) -> None: + if not isinstance(value, tuple): + return + show_news(value) + mark_startup_component_ready("news", "News cards ready") + + def handle_patches_event(value: object) -> None: + if not ( + isinstance(value, tuple) + and all(isinstance(item, PatchNote) for item in value) + ): + return + show_patch_notes( + value, + on_complete=lambda: mark_startup_component_ready( + "patches", + "Patch notes ready", + ), + ) + + def handle_news_image_event(value: object) -> None: + complete_news_image(value) + + def handle_logo_image_event(value: object) -> None: + install_official_logo(value) + mark_startup_component_ready("logo", "Launcher artwork ready") + + def handle_compatibility_event(value: object) -> None: + nonlocal runtime_compatibility + if not ( + isinstance(value, tuple) + and len(value) == 3 + and isinstance(value[0], int) + and isinstance(value[1], RuntimeCompatibility) + and isinstance(value[2], bool) + ): + return + check_generation, checked, update_activity = value + if check_generation != compatibility_check_generation: + return + runtime_compatibility = checked + if checked.compatible: + append_log( + "Compatibility check passed: " + f"scene={checked.scene_action}, 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", + ) + + def handle_version_event(value: object) -> None: + if not isinstance(value, DepotSnapshot): + return + 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") + + def handle_version_error_event(_value: object) -> None: + version.set(f"Launcher {__version__} · game build check unavailable") + refresh_primary_action() + mark_startup_component_ready("version", "Game version check complete") + + def handle_launcher_update_check_event(value: object) -> None: + if not ( + isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], str) + and isinstance(value[1], LauncherUpdateCheck) + ): + return + requested_channel, update_check = value + if requested_channel != selected_update_channel: + return + show_launcher_update_check(update_check) + mark_startup_component_ready( + "launcher_update", + "Launcher update status ready", + ) + + def handle_launcher_update_error_event(value: object) -> None: + if not ( + isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], str) + and isinstance(value[1], str) + ): + return + requested_channel, update_error = value + if requested_channel != selected_update_channel: + return + 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", + ) + + def handle_launcher_history_event(value: object) -> None: + nonlocal launcher_release_history + if not isinstance(value, LauncherRemoteHistory): + return + launcher_release_history = value + if launcher_update_dialog is not None: + launcher_update_dialog.refresh() + + def handle_launcher_history_error_event(value: object) -> None: + if isinstance(value, str): + append_log(f"Launcher release history unavailable: {value}") + + def recover_event_processing(error: Exception) -> None: + append_log(f"Dashboard event processing recovered from an error: {error}") + + event_router = DashboardEventRouter( + events, + { + "output": handle_output_event, + "done": handle_done_event, + "launcher_update_done": handle_launcher_update_done_event, + "operation_status": handle_operation_status_event, + "game_supervisor_started": handle_game_supervisor_started_event, + "game_operation_status": handle_game_operation_status_event, + "game_started": handle_game_started_event, + "game_window_ready": handle_game_window_ready_event, + "game_done": handle_game_done_event, + "broadcast": handle_broadcast_event, + "server_status_check": handle_server_status_event, + "news": handle_news_event, + "patches": handle_patches_event, + "news_image": handle_news_image_event, + "logo_image": handle_logo_image_event, + "compatibility": handle_compatibility_event, + "version": handle_version_event, + "version_error": handle_version_error_event, + "launcher_update_check": handle_launcher_update_check_event, + "launcher_update_error": handle_launcher_update_error_event, + "launcher_release_history": handle_launcher_history_event, + "launcher_history_error": handle_launcher_history_error_event, + }, + recover=recover_event_processing, + ) + + def poll_events() -> None: + result = event_router.drain(EVENT_BATCH_SIZE) + if not result.keep_polling: + return + 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_preloader.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 run_operation_plan(plan: OperationPlan, *, game_client_id: int | None = None) -> None: + run_operation( + plan.label, + command_for(*plan.arguments), + show_preloader=plan.show_preloader, + restart_on_success=plan.restart_on_success, + game_files_may_change=plan.game_files_may_change, + file_progress_verb=plan.file_progress_verb, + game_client_id=game_client_id, + ) + + def install_launcher_and_restart(version_to_install: str) -> None: + run_operation_plan(launcher_install_operation(version_to_install)) + + def rollback_launcher_and_restart(target_version: str) -> None: + run_operation_plan(launcher_rollback_operation(target_version)) + + launcher_update_dialog = LauncherUpdateDialog( + root, + 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_installed_history=lambda: load_installed_launcher_history(paths), + 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 + preparation = prepare_game_launch( + paths, + official_snapshot, + cache_launch_catalog=cache_depot_launch_catalog, + settings_loader=load_settings, + runtime_inspector=inspect_runtime_debug, + ) + if preparation.realm_cache_error is not None: + show_themed_dialog( + root, + title="Realm selection", + message=( + "The launcher could not save the official realm selection:\n\n" + f"{preparation.realm_cache_error}" + ), + kind="error", + window_icon=window_icon, + ) + return + if preparation.settings_error is not None: + append_log( + "Could not inspect launch runtime settings: " + f"{preparation.settings_error}" + ) + client_id = game_clients.begin() + if preparation.runtime_debug is not None and preparation.settings is not None: + client_runtime_debug[client_id] = preparation.runtime_debug + client_launch_settings[client_id] = preparation.settings + active_game_launch_id = client_id + run_operation_plan( + game_launch_operation(realm_value.get(), client_id), + 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) + run_operation_plan( + game_sync_operation(installed=current.installed, repair=repair) + ) + + 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_plan(system_check_operation())) + + 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 debug_dialog.visible: + 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_plan(import_game_operation(source)) + + 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 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, + ) + content_view.apply_width(width) + + 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" + + 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()) + apply_responsive_layout() + startup_preloader.raise_overlay() + startup_preloader.set_status("Restoring dashboard content…") + startup_preloader.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() + remote_content_controller.start_logo() + update_startup_progress("Loading online dashboard content…") + startup_preloader.update_idletasks() + startup_preloader.schedule_timeout() + startup_preloader.maybe_start_transition() + try: + root.mainloop() + except KeyboardInterrupt: + try: + root.destroy() + except tk.TclError: + pass diff --git a/ptlinux/ui/dialogs/__init__.py b/src/ptlinux/ui/dialogs/__init__.py similarity index 100% rename from ptlinux/ui/dialogs/__init__.py rename to src/ptlinux/ui/dialogs/__init__.py diff --git a/ptlinux/ui/dialogs/activity.py b/src/ptlinux/ui/dialogs/activity.py similarity index 100% rename from ptlinux/ui/dialogs/activity.py rename to src/ptlinux/ui/dialogs/activity.py diff --git a/src/ptlinux/ui/dialogs/debug.py b/src/ptlinux/ui/dialogs/debug.py new file mode 100644 index 0000000..bf5b54d --- /dev/null +++ b/src/ptlinux/ui/dialogs/debug.py @@ -0,0 +1,142 @@ +"""Hidden read-only dashboard debug window.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +DASHBOARD_DEBUG_KEY = "" + + +class DashboardDebugDialog: + """Own the hidden debug window, visibility, and retained report text.""" + + def __init__( + self, + root: Any, + *, + tk: Any, + ttk: Any, + apply_window_icon: Callable[[Any], None], + refresh_state: Callable[[], None], + render_report: Callable[[], str], + on_visibility_changed: Callable[[], None], + ) -> None: + self._root = root + self._tk = tk + self._ttk = ttk + self._apply_window_icon = apply_window_icon + self._refresh_state = refresh_state + self._render_report = render_report + self._on_visibility_changed = on_visibility_changed + self._window: Any | None = None + self._output: Any | None = None + self._last_render = "" + self._visible = False + root.bind_all(DASHBOARD_DEBUG_KEY, self.toggle, add="+") + + @property + def visible(self) -> bool: + return self._visible + + def set_visible(self, enabled: bool) -> None: + self._visible = enabled + self._last_render = "" + if enabled: + self._refresh_state() + self._ensure_window() + assert self._window is not None + self._window.deiconify() + self._window.lift() + self._window.focus_set() + elif self._window is not None and self._window.winfo_exists(): + self._window.withdraw() + self._on_visibility_changed() + self.refresh(force=True) + + def toggle(self, _event: object | None = None) -> str: + self.set_visible(not self._visible) + return "break" + + def refresh(self, *, force: bool = False) -> None: + output = self._output + if not self._visible or output is None or not output.winfo_exists(): + return + rendered = self._render_report() + if not force and rendered == self._last_render: + return + position = output.yview()[0] + output.configure(state="normal") + output.delete("1.0", "end") + output.insert("1.0", rendered) + output.configure(state="disabled") + output.yview_moveto(position) + self._last_render = rendered + + def _ensure_window(self) -> None: + if self._window is not None and self._window.winfo_exists(): + return + window = self._tk.Toplevel(self._root) + self._window = window + window.title("PristonTale EU — Debug View") + window.geometry("900x640") + window.minsize(700, 440) + window.configure(background="#0c0908") + self._apply_window_icon(window) + debug_header = self._tk.Frame( + window, + background="#24170f", + padx=16, + pady=12, + ) + debug_header.pack(fill="x") + self._tk.Label( + debug_header, + text="DASHBOARD DEBUG VIEW", + background="#24170f", + foreground="#f5d18b", + font=("Sans", 10, "bold"), + ).pack(anchor="w") + self._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 = self._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 = self._tk.Text( + body, + background="#100d0c", + foreground="#ded1c4", + insertbackground="#f8fafc", + font=("Monospace", 10), + wrap="none", + state="disabled", + padx=12, + pady=10, + relief="flat", + ) + self._output = output + vertical = self._ttk.Scrollbar( + body, + orient="vertical", + command=output.yview, + ) + horizontal = self._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: self.set_visible(False)) diff --git a/src/ptlinux/ui/dialogs/game_preloader.py b/src/ptlinux/ui/dialogs/game_preloader.py new file mode 100644 index 0000000..4fd3bb1 --- /dev/null +++ b/src/ptlinux/ui/dialogs/game_preloader.py @@ -0,0 +1,166 @@ +"""Modal progress window shown while a game client starts.""" + +from __future__ import annotations + +from typing import Any, Callable + +from ...tk_chrome import centered_window_geometry, create_window_chrome +from ..components.preloader import freeze_preloader_geometry +from ..viewmodels.dashboard import ( + PRELOADER_STATUS_CHARACTERS, + preloader_logo_subsample_factor, + preloader_status_text, +) + + +class GamePreloaderDialog: + """Own the game-launch preloader window and its transient widget state.""" + + def __init__( + self, + root: Any, + *, + tk: Any, + ttk: Any, + window_icon: Any | None, + apply_window_icon: Callable[[Any], None], + cancel: Callable[[], None], + launch_stage_total: int, + ) -> None: + self._root = root + self._tk = tk + self._ttk = ttk + self._window_icon = window_icon + self._apply_window_icon = apply_window_icon + self._cancel = cancel + self._launch_stage_total = launch_stage_total + self._window: Any = None + self._status: Any = None + self._progress: Any = None + self._image_references: list[Any] = [] + + def show(self) -> None: + if self._window is not None and self._window.winfo_exists(): + return + window = self._tk.Toplevel(self._root) + window.withdraw() + self._window = window + window.title("Launching PristonTale EU") + window.configure(background="#0c0908") + window.resizable(False, False) + window.protocol("WM_DELETE_WINDOW", self._cancel) + window.transient(self._root) + self._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(self._cancel) + cancel_button = self._tk.Button( + loader_chrome.content, + text="×", + command=self._cancel, + 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 = self._tk.StringVar(value="Starting launcher process…") + self._status = status + content = self._ttk.Frame( + loader_chrome.content, + style="Root.TFrame", + padding=(34, 26), + ) + content.pack(fill="both", expand=True) + cancel_button.lift() + if self._window_icon is not None: + factor = preloader_logo_subsample_factor( + self._window_icon.width(), + self._window_icon.height(), + ) + modal_logo = self._window_icon.subsample(factor, factor) + self._image_references.clear() + self._image_references.append(modal_logo) + self._tk.Label( + content, + image=modal_logo, + background="#0c0908", + borderwidth=0, + highlightthickness=0, + ).pack(anchor="center", pady=(0, 16)) + self._ttk.Label( + content, + text="Preparing the game window…", + style="PreloaderStatus.TLabel", + anchor="center", + ).pack(fill="x") + self._ttk.Label( + content, + textvariable=status, + style="PreloaderMuted.TLabel", + width=PRELOADER_STATUS_CHARACTERS, + anchor="center", + ).pack(fill="x", pady=(5, 11)) + progress = self._ttk.Progressbar( + content, + style="Depot.Horizontal.TProgressbar", + mode="determinate", + maximum=self._launch_stage_total, + value=1, + ) + self._progress = progress + progress.pack(fill="x") + freeze_preloader_geometry(content) + self._root.update_idletasks() + window.update_idletasks() + window.geometry( + centered_window_geometry( + window.winfo_reqwidth(), + window.winfo_reqheight(), + parent_x=self._root.winfo_rootx(), + parent_y=self._root.winfo_rooty(), + parent_width=self._root.winfo_width(), + parent_height=self._root.winfo_height(), + ) + ) + window.deiconify() + window.lift() + window.grab_set() + window.focus_force() + + def set_status( + self, + value: str, + step: int | None = None, + total: int | None = None, + ) -> None: + if self._status is not None: + self._status.set(preloader_status_text(value, step, total)) + if self._progress is not None and step is not None and total is not None: + self._progress.configure(maximum=total, value=step) + + def close(self) -> None: + if self._window is not None and self._window.winfo_exists(): + try: + self._window.grab_release() + except self._tk.TclError: + pass + self._window.destroy() + self._window = None + self._status = None + self._progress = None + self._image_references.clear() diff --git a/ptlinux/ui/dialogs/settings_dialog.py b/src/ptlinux/ui/dialogs/settings_dialog.py similarity index 100% rename from ptlinux/ui/dialogs/settings_dialog.py rename to src/ptlinux/ui/dialogs/settings_dialog.py diff --git a/ptlinux/ui/viewmodels/__init__.py b/src/ptlinux/ui/viewmodels/__init__.py similarity index 100% rename from ptlinux/ui/viewmodels/__init__.py rename to src/ptlinux/ui/viewmodels/__init__.py diff --git a/ptlinux/ui/viewmodels/dashboard.py b/src/ptlinux/ui/viewmodels/dashboard.py similarity index 69% rename from ptlinux/ui/viewmodels/dashboard.py rename to src/ptlinux/ui/viewmodels/dashboard.py index fcfe2b9..bce58fb 100644 --- a/ptlinux/ui/viewmodels/dashboard.py +++ b/src/ptlinux/ui/viewmodels/dashboard.py @@ -4,24 +4,10 @@ from __future__ import annotations from dataclasses import dataclass import math -import os -import signal -import subprocess -import sys -import threading import time -from typing import TYPE_CHECKING, Callable, Mapping +from typing import Callable, Mapping -from ...paths import AppPaths from ...self_update import LauncherUpdateCheck -from ...telemetry import ( - TELEMETRY_OPERATION_PROCESS_ROLE, - TELEMETRY_PROCESS_ROLE_VARIABLE, -) - - -if TYPE_CHECKING: - from tkinter import Frame WINDOW_WIDTH = 1002 @@ -120,17 +106,23 @@ def fitted_window_size( screen_height: int, margin: int = 64, ) -> tuple[int, int]: - """Scale an initial window without placing it beyond the available display.""" + """Scale a window and clamp it when the display server reports usable bounds.""" if width <= 0 or height <= 0 or scale <= 0: raise ValueError("window dimensions and scale must be positive") - if screen_width <= 0 or screen_height <= 0 or margin < 0: - raise ValueError("screen dimensions must be positive and margin non-negative") - available_width = max(1, screen_width - margin) - available_height = max(1, screen_height - margin) + if margin < 0: + raise ValueError("screen margin must be non-negative") + scaled_width = max(1, round(width * scale)) + scaled_height = max(1, round(height * scale)) + available_width = ( + max(1, screen_width - margin) if screen_width > 0 else scaled_width + ) + available_height = ( + max(1, screen_height - margin) if screen_height > 0 else scaled_height + ) return ( - min(max(1, round(width * scale)), available_width), - min(max(1, round(height * scale)), available_height), + min(scaled_width, available_width), + min(scaled_height, available_height), ) @@ -165,60 +157,6 @@ def setting_value(label: str, choices: Mapping[str, str]) -> str: raise ValueError(f"unsupported setting choice: {label}") from error -class GameLaunchSupervisorRegistry: - """Bridge Tk cancellation requests to game launch worker processes safely.""" - - def __init__(self) -> None: - self._lock = threading.Lock() - self._processes: dict[int, subprocess.Popen[str]] = {} - self._cancel_requested: set[int] = set() - - def attach(self, client_id: int, process: subprocess.Popen[str]) -> bool: - """Register a supervisor and report whether cancellation raced its start.""" - - with self._lock: - self._processes[client_id] = process - return client_id in self._cancel_requested - - def request_cancel(self, client_id: int) -> subprocess.Popen[str] | None: - """Record cancellation and return an already-started supervisor, if any.""" - - with self._lock: - self._cancel_requested.add(client_id) - return self._processes.get(client_id) - - def detach(self, client_id: int, process: subprocess.Popen[str]) -> None: - with self._lock: - if self._processes.get(client_id) is process: - self._processes.pop(client_id, None) - - def is_cancel_requested(self, client_id: int) -> bool: - with self._lock: - return client_id in self._cancel_requested - - def finish(self, client_id: int) -> bool: - """Forget one launch and return whether the user cancelled it.""" - - with self._lock: - self._processes.pop(client_id, None) - if client_id not in self._cancel_requested: - return False - self._cancel_requested.remove(client_id) - return True - - -def interrupt_game_supervisor(process: subprocess.Popen[str]) -> bool: - """Ask the play CLI to stop its complete Proton/game process group.""" - - if process.poll() is not None: - return False - try: - process.send_signal(signal.SIGINT) - except OSError: - return False - return True - - class GameClientTracker: """Track dashboard-launched clients without retaining process objects in Tk.""" @@ -424,63 +362,6 @@ def startup_progress_animation_value(current: float, target: float) -> float: return current + (direction * step) -def freeze_preloader_geometry(frame: Frame) -> tuple[int, int]: - """Lock a measured preloader frame so later status text cannot reflow it.""" - - frame.update_idletasks() - width = max(1, frame.winfo_reqwidth()) - height = max(1, frame.winfo_reqheight()) - frame.configure(width=width, height=height) - frame.pack_propagate(False) - return width, height - - -def launcher_restart_spec( - paths: AppPaths, - environment: Mapping[str, str] | None = None, -) -> tuple[list[str], dict[str, str]]: - """Build an isolated restart that imports the newly activated release.""" - - current = paths.launcher_current - if not current.is_symlink(): - raise DashboardError(f"launcher current path is not a symbolic link: {current}") - try: - release = current.resolve(strict=True) - except OSError as exc: - raise DashboardError(f"launcher current release is unavailable: {exc}") from exc - releases = paths.launcher_releases.resolve(strict=False) - if release.parent != releases or not (release / "ptlinux").is_dir(): - raise DashboardError(f"launcher current release is outside the release store: {release}") - env = dict(os.environ if environment is None else environment) - env.update( - { - "PTLINUX_INSTALL_DIR": str(paths.data), - "PTLINUX_APP_HOME": str(paths.app_home), - "PYTHONSAFEPATH": "1", - "PYTHONPATH": str(release), - } - ) - return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env - - -def launcher_child_command(*arguments: str) -> list[str]: - """Build a child command without writing bytecode into the active release.""" - - return [sys.executable, "-B", "-m", "ptlinux", *arguments] - - -def launcher_child_environment( - environment: Mapping[str, str] | None = None, -) -> dict[str, str]: - """Mark an internal CLI process as part of the current dashboard session.""" - - child_environment = dict(os.environ if environment is None else environment) - child_environment[TELEMETRY_PROCESS_ROLE_VARIABLE] = ( - TELEMETRY_OPERATION_PROCESS_ROLE - ) - return child_environment - - def dashboard_poll_interval( *, operation_active: bool, diff --git a/src/ptlinux/ui/viewmodels/debug.py b/src/ptlinux/ui/viewmodels/debug.py new file mode 100644 index 0000000..1207013 --- /dev/null +++ b/src/ptlinux/ui/viewmodels/debug.py @@ -0,0 +1,232 @@ +"""Pure report state and formatting for the hidden dashboard debug view.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass + +from ...dashboard_debug import RuntimeDebugInfo, VerificationDebugInfo +from ...depot import DepotSnapshot, DepotStartOption +from ...install_status import GameInstallationStatus +from ...server_status import RealmServerStatus +from .dashboard import GameClientDebugState, debug_elapsed_text +from .health import ( + ServerStatusCheck, + server_status_check_text, + server_status_presentation, +) + + +@dataclass(frozen=True) +class DashboardDebugReportState: + """All launcher state needed to render one read-only debug report.""" + + server_status_check: ServerStatusCheck | None + server_statuses: tuple[RealmServerStatus, ...] | None + server_status_placeholder: str + realm: str + start_options: tuple[DepotStartOption, ...] + official_snapshot: DepotSnapshot | None + installation_status: GameInstallationStatus | None + client_states: tuple[GameClientDebugState, ...] + client_runtime_debug: Mapping[int, RuntimeDebugInfo] + dashboard_runtime_debug: RuntimeDebugInfo | None + verification_debug: VerificationDebugInfo | None + state_error: str | None + dxvk_cache: str + active_game_launch_id: int | None + operation_active: bool + operation_stage: int + operation_stage_total: int + operation_message: str + operation_status: str + + +def render_dashboard_debug_report( + state: DashboardDebugReportState, + *, + timestamp: float | None = None, +) -> str: + """Render the complete hidden debug report without reading disk or using Tk.""" + + lines = ["PT-LINUX DASHBOARD DEBUG", ""] + lines.extend(("SERVERS", "-------")) + lines.append( + server_status_check_text(state.server_status_check, timestamp=timestamp) + if state.server_status_check is not None + else "status check pending" + ) + if state.server_statuses: + lines.extend( + server_status_presentation( + server, + show_debug_details=True, + ).text + for server in state.server_statuses + ) + else: + lines.append(state.server_status_placeholder) + + lines.extend(("", "LAUNCH", "------")) + selected_realm = state.realm.casefold() + start_option = next( + ( + option + for option in state.start_options + if option.name.casefold() == selected_realm + ), + None, + ) + lines.append(f"Realm: {state.realm 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 state.official_snapshot is None: + lines.append("Official depot: unavailable") + else: + lines.append( + f"Official depot: build {state.official_snapshot.build} · " + f"version {state.official_snapshot.version}" + ) + if state.installation_status is None: + lines.append("Installed depot: unchecked") + else: + local_build = state.installation_status.local_build + lines.append( + f"Installed depot: {local_build if local_build is not None else 'unknown'}" + ) + + active_runtime = next( + ( + state.client_runtime_debug[client.client_id] + for client in state.client_states + if client.client_id in state.client_runtime_debug + ), + state.dashboard_runtime_debug, + ) + lines.extend(("", "RUNTIME", "-------")) + lines.append( + "Source: active client launch snapshot" + if ( + state.client_states + and active_runtime is not state.dashboard_runtime_debug + ) + else "Source: next-launch selection" + ) + if active_runtime is None: + lines.append(state.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( + "DXVK D3D9 override: " + f"{active_runtime.dxvk_d3d9_override or 'not recorded'}" + ) + lines.append(f"DXVK state cache: {state.dxvk_cache}") + if active_runtime.error: + lines.append(f"Runtime selection error: {active_runtime.error}") + + lines.extend(("", "CLIENTS", "-------")) + starting_count = sum(client.phase == "starting" for client in state.client_states) + running_count = sum(client.phase == "running" for client in state.client_states) + lines.append(f"Starting: {starting_count} · running: {running_count}") + if not state.client_states: + lines.append("No active game clients") + for client in state.client_states: + pid = str(client.supervisor_pid) if client.supervisor_pid is not None else "pending" + lines.append( + f"Client {client.client_id}: {client.phase} · " + f"{debug_elapsed_text(client.elapsed_seconds)} · supervisor PID {pid}" + ) + client_runtime = state.client_runtime_debug.get(client.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 ( + client.client_id == state.active_game_launch_id + and state.operation_stage_total > 0 + ): + lines.append( + f" launch stage {state.operation_stage}/{state.operation_stage_total} · " + f"{state.operation_message or 'waiting'}" + ) + if state.operation_active and state.active_game_launch_id is None: + lines.append( + f"Launcher operation: {state.operation_stage}/{state.operation_stage_total} · " + f"{state.operation_message or state.operation_status}" + ) + + lines.extend(("", "VERIFICATION", "------------")) + verification = state.verification_debug + if verification is None: + lines.append(state.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( + "Import verified: build " + f"{verification.import_build if verification.import_build is not None else 'unknown'} · " + f"{verification.import_verified_at}" + ) + lines.append( + "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( + "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 state.installation_status is not None: + missing = state.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" diff --git a/ptlinux/ui/viewmodels/downloader.py b/src/ptlinux/ui/viewmodels/downloader.py similarity index 100% rename from ptlinux/ui/viewmodels/downloader.py rename to src/ptlinux/ui/viewmodels/downloader.py diff --git a/ptlinux/ui/viewmodels/health.py b/src/ptlinux/ui/viewmodels/health.py similarity index 74% rename from ptlinux/ui/viewmodels/health.py rename to src/ptlinux/ui/viewmodels/health.py index 8680284..7446929 100644 --- a/ptlinux/ui/viewmodels/health.py +++ b/src/ptlinux/ui/viewmodels/health.py @@ -4,9 +4,8 @@ from __future__ import annotations from dataclasses import dataclass import time -from typing import Callable -from ...server_status import RealmServerStatus, ServerStatusError, fetch_server_status +from ...server_status import RealmServerStatus SERVER_STATUS_STALE_SECONDS = 5 * 60 @@ -30,33 +29,6 @@ class ServerStatusCheck: error: str | None = None -def perform_server_status_check( - *, - fetcher: Callable[[], tuple[RealmServerStatus, ...]] = fetch_server_status, - monotonic: Callable[[], float] = time.monotonic, - wall_clock: Callable[[], float] = time.time, -) -> ServerStatusCheck: - """Measure one official status request without leaking its endpoint.""" - - started_at = monotonic() - try: - statuses = fetcher() - except ServerStatusError as error: - duration_seconds = max(0.0, monotonic() - started_at) - return ServerStatusCheck( - statuses=None, - checked_at=wall_clock(), - duration_seconds=duration_seconds, - error=str(error), - ) - duration_seconds = max(0.0, monotonic() - started_at) - return ServerStatusCheck( - statuses=statuses, - checked_at=wall_clock(), - duration_seconds=duration_seconds, - ) - - def server_status_check_text( check: ServerStatusCheck, *, diff --git a/ptlinux/ui/viewmodels/installation.py b/src/ptlinux/ui/viewmodels/installation.py similarity index 100% rename from ptlinux/ui/viewmodels/installation.py rename to src/ptlinux/ui/viewmodels/installation.py diff --git a/ptlinux/umu.py b/src/ptlinux/umu.py similarity index 100% rename from ptlinux/umu.py rename to src/ptlinux/umu.py diff --git a/ptlinux/update_resolver.py b/src/ptlinux/update_resolver.py similarity index 100% rename from ptlinux/update_resolver.py rename to src/ptlinux/update_resolver.py diff --git a/ptlinux/window_diagnostics.py b/src/ptlinux/window_diagnostics.py similarity index 100% rename from ptlinux/window_diagnostics.py rename to src/ptlinux/window_diagnostics.py