Added more folders and restructured classes inside them.

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""Controlled diagnostic experiment implementation modules."""

View 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

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

View 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")),
},
}

View 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", [])
)

View 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."""

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

View File

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

View File

@@ -0,0 +1,2 @@
"""Application workflows shared by the CLI and dashboard entry points."""

View File

@@ -0,0 +1,2 @@
"""Feature-oriented command workflows."""

View 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

View 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

View 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

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

View 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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
"""Launcher-owned diagnostic parsing used by CLI and runtime workflows."""

View File

@@ -0,0 +1 @@
"""Launcher-owned game data, synchronization, and compatibility services."""

View File

@@ -0,0 +1 @@
"""Verified compatibility transforms for publisher game data."""

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View 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

View 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

View 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

View File

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

10
src/ptlinux/overlay.py Normal file
View 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

View File

@@ -0,0 +1 @@
"""Game-window and Gamescope presentation services."""

View File

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

View File

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

View File

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

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

View 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

View File

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