Added more folders and restructured classes inside them.
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "PTEU-Linux-Launcher"
|
name = "PTEU-Linux-Launcher"
|
||||||
version = "0.9.11"
|
version = "0.9.11"
|
||||||
@@ -8,3 +12,27 @@ dependencies = []
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
ptdiag = "ptdiag.cli:main"
|
ptdiag = "ptdiag.cli:main"
|
||||||
pristontale-eu = "ptlinux.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"]
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ __version__ = "0.1.0"
|
|||||||
def __getattr__(name: str) -> Any:
|
def __getattr__(name: str) -> Any:
|
||||||
"""Load diagnostic configuration helpers only when the full harness needs them.
|
"""Load diagnostic configuration helpers only when the full harness needs them.
|
||||||
|
|
||||||
The production launcher ships only :mod:`ptdiag.assbin`, which it uses for
|
The launcher release retains :mod:`ptdiag.assbin` and :mod:`ptdiag.logs` as
|
||||||
verified scene handling. Deferring the larger diagnostic-only imports keeps
|
compatibility façades. Deferring the larger diagnostic-only imports keeps the
|
||||||
the compact package importable without shipping the investigation harness.
|
compact package importable without shipping the investigation harness.
|
||||||
"""
|
"""
|
||||||
if name in {"AppConfig", "ConfigError", "load_config"}:
|
if name in {"AppConfig", "ConfigError", "load_config"}:
|
||||||
from .config import AppConfig, ConfigError, load_config
|
from .config import AppConfig, ConfigError, load_config
|
||||||
22
src/ptdiag/artifacts.py
Normal file
22
src/ptdiag/artifacts.py
Normal file
@@ -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"
|
||||||
72
src/ptdiag/assbin.py
Normal file
72
src/ptdiag/assbin.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
@@ -9,7 +9,8 @@ import re
|
|||||||
import tomllib
|
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):
|
class ConfigError(ValueError):
|
||||||
@@ -53,19 +54,48 @@ class AppConfig:
|
|||||||
|
|
||||||
|
|
||||||
def project_root() -> Path:
|
def project_root() -> Path:
|
||||||
|
"""Return the Python source root used by checkout-only helper commands."""
|
||||||
|
|
||||||
return Path(__file__).resolve().parent.parent
|
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:
|
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 = resolve_config_path(path)
|
||||||
config_path = config_path.expanduser().absolute()
|
|
||||||
try:
|
try:
|
||||||
with config_path.open("rb") as handle:
|
with config_path.open("rb") as handle:
|
||||||
data = tomllib.load(handle)
|
data = tomllib.load(handle)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"Configuration not found: {config_path}. "
|
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
|
) from exc
|
||||||
except tomllib.TOMLDecodeError as exc:
|
except tomllib.TOMLDecodeError as exc:
|
||||||
raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc
|
raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc
|
||||||
775
src/ptdiag/experiment.py
Normal file
775
src/ptdiag/experiment.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
1
src/ptdiag/experiments/__init__.py
Normal file
1
src/ptdiag/experiments/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Controlled diagnostic experiment implementation modules."""
|
||||||
287
src/ptdiag/experiments/cleanup.py
Normal file
287
src/ptdiag/experiments/cleanup.py
Normal file
@@ -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
|
||||||
257
src/ptdiag/experiments/evidence.py
Normal file
257
src/ptdiag/experiments/evidence.py
Normal file
@@ -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)
|
||||||
332
src/ptdiag/experiments/execution.py
Normal file
332
src/ptdiag/experiments/execution.py
Normal file
@@ -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")),
|
||||||
|
},
|
||||||
|
}
|
||||||
212
src/ptdiag/experiments/log_selection.py
Normal file
212
src/ptdiag/experiments/log_selection.py
Normal file
@@ -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", [])
|
||||||
|
)
|
||||||
18
src/ptdiag/experiments/models.py
Normal file
18
src/ptdiag/experiments/models.py
Normal file
@@ -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."""
|
||||||
624
src/ptdiag/experiments/preparation.py
Normal file
624
src/ptdiag/experiments/preparation.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
40
src/ptdiag/logs.py
Normal file
40
src/ptdiag/logs.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
@@ -7,7 +7,12 @@ from pathlib import Path
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
import tomllib
|
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):
|
class TestcaseError(ValueError):
|
||||||
@@ -86,7 +91,15 @@ def resolve_testcase_path(value: str | Path) -> Path:
|
|||||||
path = Path(value)
|
path = Path(value)
|
||||||
if path.suffix or path.parent != Path("."):
|
if path.suffix or path.parent != Path("."):
|
||||||
return path.expanduser().absolute()
|
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:
|
def load_testcase(value: str | Path) -> Testcase:
|
||||||
@@ -94,7 +107,10 @@ def load_testcase(value: str | Path) -> Testcase:
|
|||||||
data, sources = _load_inherited(path, ())
|
data, sources = _load_inherited(path, ())
|
||||||
game = _section(data, "game")
|
game = _section(data, "game")
|
||||||
display = _section(data, "display")
|
display = _section(data, "display")
|
||||||
environment = _section(data, "env")
|
environment = _resolve_environment_paths(
|
||||||
|
_string_mapping(_section(data, "env"), "env"),
|
||||||
|
path.parent,
|
||||||
|
)
|
||||||
watch = _section(data, "watch")
|
watch = _section(data, "watch")
|
||||||
capture = _section(data, "capture")
|
capture = _section(data, "capture")
|
||||||
modification = _optional_section(data, "modification")
|
modification = _optional_section(data, "modification")
|
||||||
@@ -114,7 +130,7 @@ def load_testcase(value: str | Path) -> Testcase:
|
|||||||
virtual_desktop=_boolean(display, "virtual_desktop"),
|
virtual_desktop=_boolean(display, "virtual_desktop"),
|
||||||
size=_string(display, "size"),
|
size=_string(display, "size"),
|
||||||
),
|
),
|
||||||
env=_string_mapping(environment, "env"),
|
env=environment,
|
||||||
watch=TestWatch(
|
watch=TestWatch(
|
||||||
require_new_native_log=_boolean(watch, "require_new_native_log"),
|
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(
|
def _load_inherited(
|
||||||
path: Path,
|
path: Path,
|
||||||
stack: tuple[Path, ...],
|
stack: tuple[Path, ...],
|
||||||
2
src/ptlinux/application/__init__.py
Normal file
2
src/ptlinux/application/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Application workflows shared by the CLI and dashboard entry points."""
|
||||||
|
|
||||||
2
src/ptlinux/application/commands/__init__.py
Normal file
2
src/ptlinux/application/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Feature-oriented command workflows."""
|
||||||
|
|
||||||
190
src/ptlinux/application/commands/health.py
Normal file
190
src/ptlinux/application/commands/health.py
Normal file
@@ -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
|
||||||
479
src/ptlinux/application/commands/installation.py
Normal file
479
src/ptlinux/application/commands/installation.py
Normal file
@@ -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
|
||||||
|
|
||||||
138
src/ptlinux/application/commands/launcher_update.py
Normal file
138
src/ptlinux/application/commands/launcher_update.py
Normal file
@@ -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
|
||||||
938
src/ptlinux/application/commands/play.py
Normal file
938
src/ptlinux/application/commands/play.py
Normal file
@@ -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)
|
||||||
32
src/ptlinux/application/commands/settings.py
Normal file
32
src/ptlinux/application/commands/settings.py
Normal file
@@ -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
|
||||||
245
src/ptlinux/application/common.py
Normal file
245
src/ptlinux/application/common.py
Normal file
@@ -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))
|
||||||
918
src/ptlinux/cli.py
Normal file
918
src/ptlinux/cli.py
Normal file
@@ -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)
|
||||||
@@ -342,15 +342,22 @@ def _finish_stream_relays(
|
|||||||
stdout_thread: threading.Thread,
|
stdout_thread: threading.Thread,
|
||||||
stderr_thread: threading.Thread,
|
stderr_thread: threading.Thread,
|
||||||
) -> None:
|
) -> 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)
|
stdout_thread.join(timeout=2.0)
|
||||||
stderr_thread.join(timeout=2.0)
|
stderr_thread.join(timeout=2.0)
|
||||||
for stream in (process.stdout, process.stderr):
|
for stream, relay_thread in (
|
||||||
if stream is not None:
|
(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:
|
try:
|
||||||
stream.close()
|
stream.close()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
stdout_thread.join(timeout=0.25)
|
|
||||||
stderr_thread.join(timeout=0.25)
|
|
||||||
1270
src/ptlinux/depot.py
Normal file
1270
src/ptlinux/depot.py
Normal file
File diff suppressed because it is too large
Load Diff
1
src/ptlinux/diagnostics/__init__.py
Normal file
1
src/ptlinux/diagnostics/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Launcher-owned diagnostic parsing used by CLI and runtime workflows."""
|
||||||
1
src/ptlinux/game/__init__.py
Normal file
1
src/ptlinux/game/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Launcher-owned game data, synchronization, and compatibility services."""
|
||||||
1
src/ptlinux/game/compatibility/__init__.py
Normal file
1
src/ptlinux/game/compatibility/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Verified compatibility transforms for publisher game data."""
|
||||||
87
src/ptlinux/game/depot/__init__.py
Normal file
87
src/ptlinux/game/depot/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
208
src/ptlinux/game/depot/catalogs.py
Normal file
208
src/ptlinux/game/depot/catalogs.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
338
src/ptlinux/game/depot/filesystem.py
Normal file
338
src/ptlinux/game/depot/filesystem.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
413
src/ptlinux/game/depot/integrity.py
Normal file
413
src/ptlinux/game/depot/integrity.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
279
src/ptlinux/game/depot/manifest.py
Normal file
279
src/ptlinux/game/depot/manifest.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
238
src/ptlinux/game/depot/models.py
Normal file
238
src/ptlinux/game/depot/models.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
82
src/ptlinux/game/depot/realms.py
Normal file
82
src/ptlinux/game/depot/realms.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
913
src/ptlinux/game/depot/transfer.py
Normal file
913
src/ptlinux/game/depot/transfer.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
151
src/ptlinux/game/ptr_compat.py
Normal file
151
src/ptlinux/game/ptr_compat.py
Normal file
@@ -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()
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Portable game runtime selection and Proton/UMU preparation."""
|
"""Portable game runtime selection, preparation, and launch."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass, replace
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -15,22 +15,32 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
from typing import Mapping, Sequence
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
from ..command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||||
from .container_host import container_host_error, inspect_container_host
|
from ..container_host import container_host_error, inspect_container_host
|
||||||
from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
|
from ..depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
|
||||||
from .gamebox import (
|
from ..gamebox import (
|
||||||
GameboxConfigurationError,
|
GameboxConfigurationError,
|
||||||
build_gamebox_command,
|
build_gamebox_command,
|
||||||
find_gamescope,
|
find_gamescope,
|
||||||
read_game_canvas_size,
|
read_game_canvas_size,
|
||||||
)
|
)
|
||||||
from .paths import AppPaths
|
from ..paths import AppPaths
|
||||||
from .settings import Settings
|
from ..settings import Settings
|
||||||
from .system_identity import (
|
from ..system_identity import (
|
||||||
detect_distribution_id as _detect_distribution_id,
|
detect_distribution_id as _detect_distribution_id,
|
||||||
is_running_inside_steam_runtime as _is_running_inside_steam_runtime,
|
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"
|
WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
|
||||||
@@ -68,6 +78,26 @@ class RuntimeSelection:
|
|||||||
steam_root: str | None
|
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(
|
def select_runtime(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
environment: Mapping[str, str] | None = None,
|
environment: Mapping[str, str] | None = None,
|
||||||
@@ -76,6 +106,7 @@ def select_runtime(
|
|||||||
distribution_id: str | None = None,
|
distribution_id: str | None = None,
|
||||||
kernel_release: str | None = None,
|
kernel_release: str | None = None,
|
||||||
) -> RuntimeSelection:
|
) -> RuntimeSelection:
|
||||||
|
settings = runtime_settings_for_realm(settings)
|
||||||
env = os.environ if environment is None else environment
|
env = os.environ if environment is None else environment
|
||||||
umu = find_umu_launcher(paths, env)
|
umu = find_umu_launcher(paths, env)
|
||||||
current_umu_host = prefers_current_umu_host(
|
current_umu_host = prefers_current_umu_host(
|
||||||
@@ -419,10 +450,21 @@ def run_game(
|
|||||||
_require_container_host(selection, runner)
|
_require_container_host(selection, runner)
|
||||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
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(
|
native_executable, windows_executable, game_arguments = _game_start_command(
|
||||||
paths,
|
paths,
|
||||||
start_option,
|
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":
|
if selection.kind == "umu":
|
||||||
game_command = [
|
game_command = [
|
||||||
selection.executable,
|
selection.executable,
|
||||||
@@ -469,6 +511,14 @@ def run_game(
|
|||||||
)
|
)
|
||||||
if extra_environment:
|
if extra_environment:
|
||||||
environment.update(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:
|
if use_gamebox:
|
||||||
gamescope_path = None
|
gamescope_path = None
|
||||||
game_size = None
|
game_size = None
|
||||||
@@ -577,6 +627,19 @@ def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path:
|
|||||||
return cursor
|
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(
|
def run_runtime_prefix_command(
|
||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
selection: RuntimeSelection,
|
selection: RuntimeSelection,
|
||||||
@@ -635,7 +698,7 @@ def _require_container_host(
|
|||||||
|
|
||||||
def _runtime_python_path(existing: str | None) -> str:
|
def _runtime_python_path(existing: str | None) -> str:
|
||||||
"""Make the installed package importable after changing into runtime_view."""
|
"""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:
|
if not existing:
|
||||||
return package_root
|
return package_root
|
||||||
return os.pathsep.join((package_root, existing))
|
return os.pathsep.join((package_root, existing))
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,13 +12,14 @@ import shutil
|
|||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from ptdiag.assbin import (
|
from .compatibility.assbin import (
|
||||||
SceneInspection,
|
SceneInspection,
|
||||||
inspect_wrapped_assbin,
|
inspect_wrapped_assbin,
|
||||||
normalize_wrapped_assbin_textures,
|
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")
|
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
|
||||||
@@ -869,8 +870,13 @@ def _runtime_layout_ready(
|
|||||||
runtime = paths.runtime_view
|
runtime = paths.runtime_view
|
||||||
if runtime.is_symlink() or not runtime.is_dir():
|
if runtime.is_symlink() or not runtime.is_dir():
|
||||||
return False
|
return False
|
||||||
top_level_exceptions = {"game", "game.ini", "save"}
|
ptr_compatibility_files = {
|
||||||
top_level_special = {"game", "game.ini", "save"}
|
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 (
|
for name, binary_source in (
|
||||||
("Game.exe", game_exe_source),
|
("Game.exe", game_exe_source),
|
||||||
("game.dll", game_dll_source),
|
("game.dll", game_dll_source),
|
||||||
@@ -913,6 +919,9 @@ def _runtime_layout_ready(
|
|||||||
actual_links += 1
|
actual_links += 1
|
||||||
if game_dll_source != source / "game.dll":
|
if game_dll_source != source / "game.dll":
|
||||||
actual_links += 1
|
actual_links += 1
|
||||||
|
actual_links += sum(
|
||||||
|
1 for name in ptr_compatibility_files if (source / name).exists()
|
||||||
|
)
|
||||||
return actual_links == expected_links
|
return actual_links == expected_links
|
||||||
|
|
||||||
|
|
||||||
13
src/ptlinux/gamebox_runtime.py
Normal file
13
src/ptlinux/gamebox_runtime.py
Normal file
@@ -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
|
||||||
13
src/ptlinux/gamescope_child_runtime.py
Normal file
13
src/ptlinux/gamescope_child_runtime.py
Normal file
@@ -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
|
||||||
13
src/ptlinux/gamescope_runtime.py
Normal file
13
src/ptlinux/gamescope_runtime.py
Normal file
@@ -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
|
||||||
@@ -10,6 +10,7 @@ import re
|
|||||||
|
|
||||||
from .command import CommandRunner
|
from .command import CommandRunner
|
||||||
from .paths import AppPaths
|
from .paths import AppPaths
|
||||||
|
from .resource_paths import native_resource_path
|
||||||
from .runtime import RuntimeSelection, run_runtime_prefix_command
|
from .runtime import RuntimeSelection, run_runtime_prefix_command
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ class IdentityProbeResult:
|
|||||||
|
|
||||||
|
|
||||||
def packaged_identity_probe() -> Path:
|
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(
|
def probe_client_identity(
|
||||||
140
src/ptlinux/launcher_updates/__init__.py
Normal file
140
src/ptlinux/launcher_updates/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
461
src/ptlinux/launcher_updates/activation.py
Normal file
461
src/ptlinux/launcher_updates/activation.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
302
src/ptlinux/launcher_updates/archive.py
Normal file
302
src/ptlinux/launcher_updates/archive.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
377
src/ptlinux/launcher_updates/discovery.py
Normal file
377
src/ptlinux/launcher_updates/discovery.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
181
src/ptlinux/launcher_updates/models.py
Normal file
181
src/ptlinux/launcher_updates/models.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
527
src/ptlinux/launcher_updates/parsing.py
Normal file
527
src/ptlinux/launcher_updates/parsing.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
476
src/ptlinux/launcher_updates/rollback.py
Normal file
476
src/ptlinux/launcher_updates/rollback.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
1651
src/ptlinux/launcher_updates/service.py
Normal file
1651
src/ptlinux/launcher_updates/service.py
Normal file
File diff suppressed because it is too large
Load Diff
551
src/ptlinux/launcher_updates/staging.py
Normal file
551
src/ptlinux/launcher_updates/staging.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
BIN
src/ptlinux/native/ptr-bcrypt-proxy.dll
Executable file
BIN
src/ptlinux/native/ptr-bcrypt-proxy.dll
Executable file
Binary file not shown.
10
src/ptlinux/overlay.py
Normal file
10
src/ptlinux/overlay.py
Normal file
@@ -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
|
||||||
1
src/ptlinux/presentation/__init__.py
Normal file
1
src/ptlinux/presentation/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Game-window and Gamescope presentation services."""
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,12 +13,12 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Collection, Iterable, Mapping, Sequence
|
from collections.abc import Collection, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from .desktop_identity import (
|
from ..desktop_identity import (
|
||||||
APPLICATION_NAME,
|
APPLICATION_NAME,
|
||||||
APPLICATION_WM_CLASS,
|
APPLICATION_WM_CLASS,
|
||||||
APPLICATION_WM_INSTANCE,
|
APPLICATION_WM_INSTANCE,
|
||||||
)
|
)
|
||||||
from .window_diagnostics import WindowDiagnosticRecorder
|
from ..window_diagnostics import WindowDiagnosticRecorder
|
||||||
|
|
||||||
WINDOW_TITLE = APPLICATION_NAME
|
WINDOW_TITLE = APPLICATION_NAME
|
||||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
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()
|
expected = expected_title.strip().casefold()
|
||||||
if not candidate or not expected:
|
if not candidate or not expected:
|
||||||
return False
|
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} ")
|
return candidate == expected or candidate.startswith(f"{expected} ")
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
from .gamebox_runtime import (
|
from .game_window import (
|
||||||
GAMESCOPE_HOST_FOCUS_STATE_ENV,
|
GAMESCOPE_HOST_FOCUS_STATE_ENV,
|
||||||
GameboxRuntimeError,
|
GameboxRuntimeError,
|
||||||
_X11,
|
_X11,
|
||||||
@@ -23,7 +23,8 @@ from .gamebox_runtime import (
|
|||||||
_sample_window_diagnostic_details,
|
_sample_window_diagnostic_details,
|
||||||
_wm_state_name,
|
_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_TIMEOUT_SECONDS = 10.0
|
||||||
WINDOW_DISCOVERY_POLL_SECONDS = 0.05
|
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:
|
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:
|
def _bundled_gamescope_host_supported() -> bool:
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
41
src/ptlinux/resource_paths.py
Normal file
41
src/ptlinux/resource_paths.py
Normal file
@@ -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}")
|
||||||
10
src/ptlinux/runtime.py
Normal file
10
src/ptlinux/runtime.py
Normal file
@@ -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
|
||||||
10
src/ptlinux/self_update.py
Normal file
10
src/ptlinux/self_update.py
Normal file
@@ -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
|
||||||
@@ -8,6 +8,7 @@ from typing import Callable, Mapping, Protocol
|
|||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from . import __version__
|
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.
|
# 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:
|
if not service_states and players_online is None:
|
||||||
return None
|
return None
|
||||||
online = all(service_states) if service_states else 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(
|
return RealmServerStatus(
|
||||||
name=display_name,
|
name=display_name,
|
||||||
online=online,
|
online=online,
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user