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]
|
||||
name = "PTEU-Linux-Launcher"
|
||||
version = "0.9.11"
|
||||
@@ -8,3 +12,27 @@ dependencies = []
|
||||
[project.scripts]
|
||||
ptdiag = "ptdiag.cli:main"
|
||||
pristontale-eu = "ptlinux.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
ptlinux = ["launcher-update-public.pem", "native/*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
extend-exclude = [
|
||||
".artifacts",
|
||||
".venv",
|
||||
"candidates",
|
||||
"dist",
|
||||
"results",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F"]
|
||||
|
||||
@@ -13,9 +13,9 @@ __version__ = "0.1.0"
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Load diagnostic configuration helpers only when the full harness needs them.
|
||||
|
||||
The production launcher ships only :mod:`ptdiag.assbin`, which it uses for
|
||||
verified scene handling. Deferring the larger diagnostic-only imports keeps
|
||||
the compact package importable without shipping the investigation harness.
|
||||
The launcher release retains :mod:`ptdiag.assbin` and :mod:`ptdiag.logs` as
|
||||
compatibility façades. Deferring the larger diagnostic-only imports keeps the
|
||||
compact package importable without shipping the investigation harness.
|
||||
"""
|
||||
if name in {"AppConfig", "ConfigError", "load_config"}:
|
||||
from .config import AppConfig, ConfigError, load_config
|
||||
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
|
||||
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path("config/local.toml")
|
||||
DEFAULT_CONFIG_PATH = Path("diagnostics/config/local.toml")
|
||||
LEGACY_CONFIG_PATH = Path("config/local.toml")
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
@@ -53,19 +54,48 @@ class AppConfig:
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
"""Return the Python source root used by checkout-only helper commands."""
|
||||
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def repository_root() -> Path:
|
||||
"""Return the repository root while retaining flat-install compatibility."""
|
||||
|
||||
source_root = project_root()
|
||||
checkout_root = source_root.parent
|
||||
if source_root.name == "src" and (checkout_root / "pyproject.toml").is_file():
|
||||
return checkout_root
|
||||
return source_root
|
||||
|
||||
|
||||
def diagnostics_root() -> Path:
|
||||
return repository_root() / "diagnostics"
|
||||
|
||||
|
||||
def resolve_config_path(path: str | Path | None = None) -> Path:
|
||||
if path is not None:
|
||||
return Path(path).expanduser().absolute()
|
||||
root = repository_root()
|
||||
preferred = root / DEFAULT_CONFIG_PATH
|
||||
legacy = root / LEGACY_CONFIG_PATH
|
||||
if preferred.is_file():
|
||||
return preferred.absolute()
|
||||
if legacy.is_file():
|
||||
return legacy.absolute()
|
||||
return preferred.absolute()
|
||||
|
||||
|
||||
def load_config(path: str | Path | None = None) -> AppConfig:
|
||||
config_path = Path(path) if path is not None else project_root() / DEFAULT_CONFIG_PATH
|
||||
config_path = config_path.expanduser().absolute()
|
||||
config_path = resolve_config_path(path)
|
||||
try:
|
||||
with config_path.open("rb") as handle:
|
||||
data = tomllib.load(handle)
|
||||
except FileNotFoundError as exc:
|
||||
raise ConfigError(
|
||||
f"Configuration not found: {config_path}. "
|
||||
"Copy config/local.example.toml to config/local.toml."
|
||||
"Copy diagnostics/config/local.example.toml to "
|
||||
"diagnostics/config/local.toml. Legacy config/local.toml is also recognized."
|
||||
) from exc
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc
|
||||
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
|
||||
import tomllib
|
||||
|
||||
from .config import project_root
|
||||
from .config import repository_root
|
||||
|
||||
|
||||
DEFAULT_TESTCASE_DIRECTORY = Path("diagnostics/testcases")
|
||||
LEGACY_TESTCASE_DIRECTORY = Path("testcases")
|
||||
_PATH_ENVIRONMENT_KEYS = frozenset({"DXVK_CONFIG_FILE"})
|
||||
|
||||
|
||||
class TestcaseError(ValueError):
|
||||
@@ -86,7 +91,15 @@ def resolve_testcase_path(value: str | Path) -> Path:
|
||||
path = Path(value)
|
||||
if path.suffix or path.parent != Path("."):
|
||||
return path.expanduser().absolute()
|
||||
return (project_root() / "testcases" / f"{path.name}.toml").absolute()
|
||||
root = repository_root()
|
||||
filename = f"{path.name}.toml"
|
||||
preferred = root / DEFAULT_TESTCASE_DIRECTORY / filename
|
||||
legacy = root / LEGACY_TESTCASE_DIRECTORY / filename
|
||||
if preferred.is_file():
|
||||
return preferred.absolute()
|
||||
if legacy.is_file():
|
||||
return legacy.absolute()
|
||||
return preferred.absolute()
|
||||
|
||||
|
||||
def load_testcase(value: str | Path) -> Testcase:
|
||||
@@ -94,7 +107,10 @@ def load_testcase(value: str | Path) -> Testcase:
|
||||
data, sources = _load_inherited(path, ())
|
||||
game = _section(data, "game")
|
||||
display = _section(data, "display")
|
||||
environment = _section(data, "env")
|
||||
environment = _resolve_environment_paths(
|
||||
_string_mapping(_section(data, "env"), "env"),
|
||||
path.parent,
|
||||
)
|
||||
watch = _section(data, "watch")
|
||||
capture = _section(data, "capture")
|
||||
modification = _optional_section(data, "modification")
|
||||
@@ -114,7 +130,7 @@ def load_testcase(value: str | Path) -> Testcase:
|
||||
virtual_desktop=_boolean(display, "virtual_desktop"),
|
||||
size=_string(display, "size"),
|
||||
),
|
||||
env=_string_mapping(environment, "env"),
|
||||
env=environment,
|
||||
watch=TestWatch(
|
||||
require_new_native_log=_boolean(watch, "require_new_native_log"),
|
||||
),
|
||||
@@ -149,6 +165,25 @@ def load_testcase(value: str | Path) -> Testcase:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_environment_paths(
|
||||
environment: Mapping[str, str],
|
||||
testcase_directory: Path,
|
||||
) -> dict[str, str]:
|
||||
result = dict(environment)
|
||||
for key in _PATH_ENVIRONMENT_KEYS:
|
||||
value = result.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
path = Path(value).expanduser()
|
||||
if path.is_absolute():
|
||||
result[key] = str(path)
|
||||
continue
|
||||
candidate = (testcase_directory / path).resolve(strict=False)
|
||||
if candidate.is_file():
|
||||
result[key] = str(candidate)
|
||||
return result
|
||||
|
||||
|
||||
def _load_inherited(
|
||||
path: Path,
|
||||
stack: tuple[Path, ...],
|
||||
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,
|
||||
stderr_thread: threading.Thread,
|
||||
) -> None:
|
||||
"""Bound relay cleanup so a second interrupt can never be required."""
|
||||
"""Bound relay cleanup even when an orphan inherits the output pipes."""
|
||||
|
||||
stdout_thread.join(timeout=2.0)
|
||||
stderr_thread.join(timeout=2.0)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
if stream is not None:
|
||||
for stream, relay_thread in (
|
||||
(process.stdout, stdout_thread),
|
||||
(process.stderr, stderr_thread),
|
||||
):
|
||||
# TextIOWrapper.close() waits for an active reader's internal lock. Wine
|
||||
# and Gamescope helpers can outlive their direct parent while retaining a
|
||||
# standard-output descriptor, leaving readline() blocked even though the
|
||||
# supervised process has exited. The play command itself is short-lived,
|
||||
# so let its daemon relay leave with the process instead of blocking the
|
||||
# game-completion event indefinitely.
|
||||
if stream is not None and not relay_thread.is_alive():
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
stdout_thread.join(timeout=0.25)
|
||||
stderr_thread.join(timeout=0.25)
|
||||
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 dataclasses import asdict, dataclass
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
@@ -15,22 +15,32 @@ import shutil
|
||||
import sys
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||
from .container_host import container_host_error, inspect_container_host
|
||||
from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
|
||||
from .gamebox import (
|
||||
from ..command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||
from ..container_host import container_host_error, inspect_container_host
|
||||
from ..depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
|
||||
from ..gamebox import (
|
||||
GameboxConfigurationError,
|
||||
build_gamebox_command,
|
||||
find_gamescope,
|
||||
read_game_canvas_size,
|
||||
)
|
||||
from .paths import AppPaths
|
||||
from .settings import Settings
|
||||
from .system_identity import (
|
||||
from ..paths import AppPaths
|
||||
from ..settings import Settings
|
||||
from ..system_identity import (
|
||||
detect_distribution_id as _detect_distribution_id,
|
||||
is_running_inside_steam_runtime as _is_running_inside_steam_runtime,
|
||||
)
|
||||
from .umu import find_umu_launcher
|
||||
from ..umu import find_umu_launcher
|
||||
from .depot.realms import (
|
||||
PTR_BABEL_REALM_NAME,
|
||||
launcher_realm_name,
|
||||
required_start_option_files,
|
||||
)
|
||||
from .ptr_compat import (
|
||||
PtrCompatibilityError,
|
||||
ptr_bcrypt_dll_overrides,
|
||||
stage_ptr_bcrypt_compatibility,
|
||||
)
|
||||
|
||||
|
||||
WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
|
||||
@@ -68,6 +78,26 @@ class RuntimeSelection:
|
||||
steam_root: str | None
|
||||
|
||||
|
||||
def runtime_settings_for_realm(
|
||||
settings: Settings,
|
||||
realm: str | None = None,
|
||||
) -> Settings:
|
||||
"""Apply compatibility requirements for the selected game realm."""
|
||||
|
||||
selected_realm = launcher_realm_name(realm or settings.realm)
|
||||
if selected_realm.casefold() == PTR_BABEL_REALM_NAME.casefold():
|
||||
# PTR V21.78.89 is stable on the retained Proton 7 runtime once its CNG
|
||||
# SHA-256 pseudo-handle call is supplied by the scoped BCrypt proxy.
|
||||
return replace(
|
||||
settings,
|
||||
realm=PTR_BABEL_REALM_NAME,
|
||||
runtime="proton7",
|
||||
)
|
||||
if selected_realm != settings.realm:
|
||||
return replace(settings, realm=selected_realm)
|
||||
return settings
|
||||
|
||||
|
||||
def select_runtime(
|
||||
settings: Settings,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
@@ -76,6 +106,7 @@ def select_runtime(
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> RuntimeSelection:
|
||||
settings = runtime_settings_for_realm(settings)
|
||||
env = os.environ if environment is None else environment
|
||||
umu = find_umu_launcher(paths, env)
|
||||
current_umu_host = prefers_current_umu_host(
|
||||
@@ -419,10 +450,21 @@ def run_game(
|
||||
_require_container_host(selection, runner)
|
||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
||||
ptr_babel = bool(required_start_option_files(start_option))
|
||||
native_executable, windows_executable, game_arguments = _game_start_command(
|
||||
paths,
|
||||
start_option,
|
||||
)
|
||||
if not runner.dry_run:
|
||||
_validate_start_option_runtime_files(paths.runtime_view, start_option)
|
||||
if ptr_babel:
|
||||
try:
|
||||
stage_ptr_bcrypt_compatibility(
|
||||
paths.runtime_view,
|
||||
selection.proton_path,
|
||||
)
|
||||
except PtrCompatibilityError as exc:
|
||||
raise RuntimeErrorPT(str(exc)) from exc
|
||||
if selection.kind == "umu":
|
||||
game_command = [
|
||||
selection.executable,
|
||||
@@ -469,6 +511,14 @@ def run_game(
|
||||
)
|
||||
if extra_environment:
|
||||
environment.update(extra_environment)
|
||||
if ptr_babel:
|
||||
existing_overrides = environment.get(
|
||||
"WINEDLLOVERRIDES",
|
||||
os.environ.get("WINEDLLOVERRIDES"),
|
||||
)
|
||||
environment["WINEDLLOVERRIDES"] = ptr_bcrypt_dll_overrides(
|
||||
existing_overrides
|
||||
)
|
||||
if use_gamebox:
|
||||
gamescope_path = None
|
||||
game_size = None
|
||||
@@ -577,6 +627,19 @@ def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path:
|
||||
return cursor
|
||||
|
||||
|
||||
def _validate_start_option_runtime_files(
|
||||
runtime_view: Path,
|
||||
option: DepotStartOption,
|
||||
) -> None:
|
||||
for relative in required_start_option_files(option):
|
||||
path = _resolve_runtime_path_case(runtime_view, relative)
|
||||
if not path.is_file():
|
||||
raise RuntimeErrorPT(
|
||||
f"realm {option.name} is unavailable; runtime view is missing "
|
||||
f"{relative.as_posix()}"
|
||||
)
|
||||
|
||||
|
||||
def run_runtime_prefix_command(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
@@ -635,7 +698,7 @@ def _require_container_host(
|
||||
|
||||
def _runtime_python_path(existing: str | None) -> str:
|
||||
"""Make the installed package importable after changing into runtime_view."""
|
||||
package_root = str(Path(__file__).resolve().parent.parent)
|
||||
package_root = str(Path(__file__).resolve().parents[2])
|
||||
if not existing:
|
||||
return package_root
|
||||
return os.pathsep.join((package_root, existing))
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Updater-safe runtime view with one verified normalized scene cache."""
|
||||
"""Updater-safe linked game runtime view and compatibility cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,13 +12,14 @@ import shutil
|
||||
from typing import Mapping
|
||||
import uuid
|
||||
|
||||
from ptdiag.assbin import (
|
||||
from .compatibility.assbin import (
|
||||
SceneInspection,
|
||||
inspect_wrapped_assbin,
|
||||
normalize_wrapped_assbin_textures,
|
||||
)
|
||||
from .ptr_compat import PTR_BCRYPT_RUNTIME_FILENAMES
|
||||
|
||||
from .paths import AppPaths
|
||||
from ..paths import AppPaths
|
||||
|
||||
|
||||
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
|
||||
@@ -869,8 +870,13 @@ def _runtime_layout_ready(
|
||||
runtime = paths.runtime_view
|
||||
if runtime.is_symlink() or not runtime.is_dir():
|
||||
return False
|
||||
top_level_exceptions = {"game", "game.ini", "save"}
|
||||
top_level_special = {"game", "game.ini", "save"}
|
||||
ptr_compatibility_files = {
|
||||
name
|
||||
for name in PTR_BCRYPT_RUNTIME_FILENAMES
|
||||
if not (runtime / name).is_symlink() and (runtime / name).is_file()
|
||||
}
|
||||
top_level_exceptions = {"game", "game.ini", "save", *ptr_compatibility_files}
|
||||
top_level_special = {"game", "game.ini", "save", *ptr_compatibility_files}
|
||||
for name, binary_source in (
|
||||
("Game.exe", game_exe_source),
|
||||
("game.dll", game_dll_source),
|
||||
@@ -913,6 +919,9 @@ def _runtime_layout_ready(
|
||||
actual_links += 1
|
||||
if game_dll_source != source / "game.dll":
|
||||
actual_links += 1
|
||||
actual_links += sum(
|
||||
1 for name in ptr_compatibility_files if (source / name).exists()
|
||||
)
|
||||
return actual_links == expected_links
|
||||
|
||||
|
||||
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 .paths import AppPaths
|
||||
from .resource_paths import native_resource_path
|
||||
from .runtime import RuntimeSelection, run_runtime_prefix_command
|
||||
|
||||
|
||||
@@ -54,7 +55,7 @@ class IdentityProbeResult:
|
||||
|
||||
|
||||
def packaged_identity_probe() -> Path:
|
||||
return Path(__file__).resolve().parent / "native" / "pt-identity-probe.exe"
|
||||
return native_resource_path("pt-identity-probe.exe")
|
||||
|
||||
|
||||
def probe_client_identity(
|
||||
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
|
||||
|
||||
@@ -13,12 +13,12 @@ import sys
|
||||
import time
|
||||
from collections.abc import Collection, Iterable, Mapping, Sequence
|
||||
|
||||
from .desktop_identity import (
|
||||
from ..desktop_identity import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_WM_CLASS,
|
||||
APPLICATION_WM_INSTANCE,
|
||||
)
|
||||
from .window_diagnostics import WindowDiagnosticRecorder
|
||||
from ..window_diagnostics import WindowDiagnosticRecorder
|
||||
|
||||
WINDOW_TITLE = APPLICATION_NAME
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
||||
@@ -89,6 +89,12 @@ def _matches_game_window_title(title: str, expected_title: str) -> bool:
|
||||
expected = expected_title.strip().casefold()
|
||||
if not candidate or not expected:
|
||||
return False
|
||||
# The publisher prefixes PTR client titles with ``PTR -`` while retaining
|
||||
# the regular game title and version suffix. Treat that publisher qualifier
|
||||
# as part of the game title so launch readiness and later focus repair see
|
||||
# the same client window.
|
||||
if candidate.startswith("ptr - "):
|
||||
candidate = candidate.removeprefix("ptr - ").lstrip()
|
||||
return candidate == expected or candidate.startswith(f"{expected} ")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Launch Gamescope with low-overhead host-window resize behavior."""
|
||||
"""Launch and supervise Gamescope's resizable host presentation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +14,7 @@ import tempfile
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from .gamebox_runtime import (
|
||||
from .game_window import (
|
||||
GAMESCOPE_HOST_FOCUS_STATE_ENV,
|
||||
GameboxRuntimeError,
|
||||
_X11,
|
||||
@@ -23,7 +23,8 @@ from .gamebox_runtime import (
|
||||
_sample_window_diagnostic_details,
|
||||
_wm_state_name,
|
||||
)
|
||||
from .window_diagnostics import WindowDiagnosticRecorder
|
||||
from ..resource_paths import native_resource_path
|
||||
from ..window_diagnostics import WindowDiagnosticRecorder
|
||||
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 10.0
|
||||
WINDOW_DISCOVERY_POLL_SECONDS = 0.05
|
||||
@@ -382,7 +383,7 @@ def _read_gamescope_version(executable: str) -> tuple[int, int, int] | None:
|
||||
|
||||
|
||||
def _bundled_gamescope_path() -> Path:
|
||||
return Path(__file__).resolve().parent / "native" / BUNDLED_GAMESCOPE_FILENAME
|
||||
return native_resource_path(BUNDLED_GAMESCOPE_FILENAME)
|
||||
|
||||
|
||||
def _bundled_gamescope_host_supported() -> bool:
|
||||
@@ -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
|
||||
|
||||
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 . import __version__
|
||||
from .game.depot.realms import launcher_realm_name
|
||||
|
||||
|
||||
# This is the same publisher endpoint embedded in Windows launcher 1.0.0.64.
|
||||
@@ -123,7 +124,7 @@ def _parse_realm(
|
||||
if not service_states and players_online is None:
|
||||
return None
|
||||
online = all(service_states) if service_states else None
|
||||
display_name = name[0].upper() + name[1:]
|
||||
display_name = launcher_realm_name(name[0].upper() + name[1:])
|
||||
return RealmServerStatus(
|
||||
name=display_name,
|
||||
online=online,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user