Updated telemetry to support game dumps.

This commit is contained in:
2026-09-15 21:15:39 +02:00
parent c278efa339
commit 67d89b40d5
17 changed files with 805 additions and 99 deletions

View File

@@ -8,10 +8,12 @@ import os
from pathlib import Path from pathlib import Path
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1] if __package__:
if str(PROJECT_ROOT) not in sys.path: from .repository_paths import ensure_project_imports
sys.path.insert(0, str(PROJECT_ROOT)) else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, build_release # noqa: E402 from scripts.release_lib import ReleaseToolError, build_release # noqa: E402

View File

@@ -7,10 +7,12 @@ import argparse
from pathlib import Path from pathlib import Path
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1] if __package__:
if str(PROJECT_ROOT) not in sys.path: from .repository_paths import ensure_project_imports
sys.path.insert(0, str(PROJECT_ROOT)) else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402 from scripts.release_lib import ( # noqa: E402
ReleaseToolError, ReleaseToolError,
generate_manifest, generate_manifest,

View File

@@ -11,10 +11,12 @@ import sys
from typing import Mapping from typing import Mapping
from urllib.parse import urlsplit from urllib.parse import urlsplit
PROJECT_ROOT = Path(__file__).resolve().parents[1] if __package__:
if str(PROJECT_ROOT) not in sys.path: from .repository_paths import PROJECT_ROOT, ensure_project_imports
sys.path.insert(0, str(PROJECT_ROOT)) else:
from repository_paths import PROJECT_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402 from scripts.release_lib import ( # noqa: E402
ReleaseToolError, ReleaseToolError,
_parse_timestamp, _parse_timestamp,

View File

@@ -9,10 +9,12 @@ import os
from pathlib import Path from pathlib import Path
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1] if __package__:
if str(PROJECT_ROOT) not in sys.path: from .repository_paths import ensure_project_imports
sys.path.insert(0, str(PROJECT_ROOT)) else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from ptlinux.self_update import LauncherUpdateError, parse_update_manifest # noqa: E402 from ptlinux.self_update import LauncherUpdateError, parse_update_manifest # noqa: E402
from ptlinux.update_resolver import ( # noqa: E402 from ptlinux.update_resolver import ( # noqa: E402
ReleaseModelError, ReleaseModelError,

View File

@@ -38,7 +38,7 @@ cleanup()
trap cleanup EXIT trap cleanup EXIT
RELEASE_MODEL="$( RELEASE_MODEL="$(
"$PYTHON_BIN" "${SCRIPT_ROOT}/../ptlinux/update_resolver.py" inspect-tag "$TAG" "$PYTHON_BIN" "${SCRIPT_ROOT}/../src/ptlinux/update_resolver.py" inspect-tag "$TAG"
)" )"
if [[ "$(jq -r '.version' <<< "$RELEASE_MODEL")" != "$VERSION" \ if [[ "$(jq -r '.version' <<< "$RELEASE_MODEL")" != "$VERSION" \
|| "$(jq -r '.channel' <<< "$RELEASE_MODEL")" != "$CHANNEL" \ || "$(jq -r '.channel' <<< "$RELEASE_MODEL")" != "$CHANNEL" \

View File

@@ -7,8 +7,8 @@ usage()
cat <<'EOF' cat <<'EOF'
Usage: ./scripts/release.sh CHANNEL VERSION [OPTIONS] Usage: ./scripts/release.sh CHANNEL VERSION [OPTIONS]
Build a verified local release package in dist/vVERSION/. This command does not Build a verified local release package in .artifacts/releases/vVERSION/. This
commit, tag, push, create a Gitea release, or upload files. command does not commit, tag, push, create a Gitea release, or upload files.
Channels: Channels:
stable stable
@@ -22,7 +22,8 @@ Options:
--editor Edit the final notes with ${EDITOR:-nano} --editor Edit the final notes with ${EDITOR:-nano}
--dry-run Build and verify in a temporary directory only --dry-run Build and verify in a temporary directory only
--skip-tests Skip configured local preflight checks --skip-tests Skip configured local preflight checks
--signing-key PATH Use PATH instead of the local release signing key --signing-key PATH Use an external private signing key at PATH
--output-directory PATH Write the version package to PATH
-h, --help Show this help -h, --help Show this help
EOF EOF
} }
@@ -68,6 +69,7 @@ OPEN_EDITOR=0
DRY_RUN=0 DRY_RUN=0
SKIP_TESTS=0 SKIP_TESTS=0
SIGNING_KEY="${PTLINUX_SIGNING_KEY:-}" SIGNING_KEY="${PTLINUX_SIGNING_KEY:-}"
REQUESTED_OUTPUT_DIRECTORY=""
while [[ $# -gt 0 ]] while [[ $# -gt 0 ]]
do do
@@ -107,6 +109,11 @@ do
SIGNING_KEY="$2" SIGNING_KEY="$2"
shift 2 shift 2
;; ;;
--output-directory)
[[ $# -ge 2 ]] || { printf '%s\n' '--output-directory requires a path.' >&2; exit 2; }
REQUESTED_OUTPUT_DIRECTORY="$2"
shift 2
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -145,6 +152,7 @@ then
exit 1 exit 1
fi fi
cd -- "$PROJECT_ROOT" cd -- "$PROJECT_ROOT"
export PYTHONPATH="${PROJECT_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
"$PYTHON_BIN" -m ptlinux.update_resolver validate "$CHANNEL" "$VERSION" "$PYTHON_BIN" -m ptlinux.update_resolver validate "$CHANNEL" "$VERSION"
TAG="v${VERSION}" TAG="v${VERSION}"
@@ -171,6 +179,17 @@ cleanup()
} }
trap cleanup EXIT trap cleanup EXIT
NOTES_PATH="${TEMPORARY_ROOT}/release-notes.md" NOTES_PATH="${TEMPORARY_ROOT}/release-notes.md"
if [[ "$DRY_RUN" -eq 1 && "$CHANGELOG_ONLY" -eq 0 ]]
then
OUTPUT_CANDIDATE="${TEMPORARY_ROOT}/releases/${TAG}"
else
OUTPUT_CANDIDATE="${REQUESTED_OUTPUT_DIRECTORY:-${PROJECT_ROOT}/.artifacts/releases/${TAG}}"
fi
OUTPUT_DIRECTORY="$(
"$PYTHON_BIN" -c \
'import sys; from pathlib import Path; from scripts.release_lib import _safe_output_directory; print(_safe_output_directory(Path(sys.argv[1])))' \
"$OUTPUT_CANDIDATE"
)"
if [[ -z "$DESCRIPTION" && -z "$DESCRIPTION_FILE" && -t 0 && "$CHANGELOG_ONLY" -eq 0 ]] if [[ -z "$DESCRIPTION" && -z "$DESCRIPTION_FILE" && -t 0 && "$CHANGELOG_ONLY" -eq 0 ]]
then then
@@ -237,14 +256,33 @@ printf '%s\n' '--------------------------------------------------'
if [[ "$CHANGELOG_ONLY" -eq 1 ]] if [[ "$CHANGELOG_ONLY" -eq 1 ]]
then then
NOTES_OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}" mkdir -p -- "$OUTPUT_DIRECTORY"
mkdir -p -- "$NOTES_OUTPUT_DIRECTORY" cp -- "$NOTES_PATH" "${OUTPUT_DIRECTORY}/release-notes.md"
cp -- "$NOTES_PATH" "${NOTES_OUTPUT_DIRECTORY}/release-notes.md"
printf 'Release notes written to %s\n' \ printf 'Release notes written to %s\n' \
"${NOTES_OUTPUT_DIRECTORY}/release-notes.md" "${OUTPUT_DIRECTORY}/release-notes.md"
exit 0 exit 0
fi fi
if [[ -z "$SIGNING_KEY" ]]
then
printf '%s\n' 'A release signing key is required.' >&2
printf '%s\n' \
'Use --signing-key PATH or set PTLINUX_SIGNING_KEY to a path outside the repository.' >&2
exit 1
fi
SIGNING_KEY="$(
"$PYTHON_BIN" -c \
'import sys
from pathlib import Path
from scripts.release_lib import ReleaseToolError, _safe_signing_key_path
try:
print(_safe_signing_key_path(Path(sys.argv[1]), output_directory=Path(sys.argv[2])))
except ReleaseToolError as exc:
print(f"Invalid release signing key: {exc}", file=sys.stderr)
raise SystemExit(2)' \
"$SIGNING_KEY" "$OUTPUT_DIRECTORY"
)"
if [[ "$SKIP_TESTS" -ne 1 ]] if [[ "$SKIP_TESTS" -ne 1 ]]
then then
mapfile -t PREFLIGHT_COMMANDS < <( mapfile -t PREFLIGHT_COMMANDS < <(
@@ -261,11 +299,6 @@ then
else else
printf '%s\n' 'Skipping local preflight checks by request.' printf '%s\n' 'Skipping local preflight checks by request.'
fi fi
if [[ -z "$SIGNING_KEY" ]]
then
SIGNING_KEY="${PROJECT_ROOT}/release-keys/launcher-update-private.pem"
fi
if [[ ! -f "$SIGNING_KEY" || -L "$SIGNING_KEY" ]] if [[ ! -f "$SIGNING_KEY" || -L "$SIGNING_KEY" ]]
then then
printf 'Release signing key is missing: %s\n' "$SIGNING_KEY" >&2 printf 'Release signing key is missing: %s\n' "$SIGNING_KEY" >&2
@@ -273,13 +306,6 @@ then
exit 1 exit 1
fi fi
if [[ "$DRY_RUN" -eq 1 ]]
then
OUTPUT_DIRECTORY="${TEMPORARY_ROOT}/dist/${TAG}"
else
OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}"
fi
SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \ SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
RELEASE_COMMIT="$COMMIT" \ RELEASE_COMMIT="$COMMIT" \
RELEASE_TAG="$TAG" \ RELEASE_TAG="$TAG" \
@@ -297,7 +323,7 @@ REQUIRE_RELEASE_SIGNATURE=1 \
if [[ "$DRY_RUN" -eq 1 ]] if [[ "$DRY_RUN" -eq 1 ]]
then then
printf '%s\n' \ printf '%s\n' \
'Dry run complete. The release built and verified in a temporary directory; dist/ was not changed.' 'Dry run complete. The release built and verified in a temporary directory; .artifacts/ was not changed.'
exit 0 exit 0
fi fi

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timezone
import fnmatch import fnmatch
import gzip import gzip
import hashlib import hashlib
import io
import json import json
import os import os
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
@@ -20,10 +21,14 @@ import tempfile
from typing import BinaryIO, Mapping, Sequence from typing import BinaryIO, Mapping, Sequence
from urllib.parse import urlsplit from urllib.parse import urlsplit
from scripts.repository_paths import (
ARTIFACTS_ROOT,
PROJECT_ROOT,
RELEASE_ARTIFACTS_ROOT,
ensure_project_imports,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1] ensure_project_imports()
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
CONFIG_PATH = PROJECT_ROOT / "release/config.json" CONFIG_PATH = PROJECT_ROOT / "release/config.json"
READ_SIZE = 256 * 1024 READ_SIZE = 256 * 1024
MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 MAX_ARCHIVE_BYTES = 32 * 1024 * 1024
@@ -42,6 +47,9 @@ _PROJECT_VERSION_PATTERN = re.compile(
r'^version = "([0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?)"$', r'^version = "([0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?)"$',
re.MULTILINE, re.MULTILINE,
) )
_LEGACY_VERSION_PATTERN = re.compile(
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z"
)
class ReleaseToolError(RuntimeError): class ReleaseToolError(RuntimeError):
@@ -202,8 +210,11 @@ def build_release(
_validate_repository_url(repository) _validate_repository_url(repository)
release_notes = _read_release_notes(release_notes_file) release_notes = _read_release_notes(release_notes_file)
output = _safe_output_directory(output_directory) output = _safe_output_directory(output_directory)
if signing_key is not None and signing_key.expanduser().resolve().is_relative_to(output): if signing_key is not None:
raise ReleaseToolError("the private signing key cannot be stored in release output") signing_key = _safe_signing_key_path(
signing_key,
output_directory=output,
)
_clean_output_directory(output) _clean_output_directory(output)
archive_prefix = _required_string(config, "archive_prefix") archive_prefix = _required_string(config, "archive_prefix")
archive_name = f"{archive_prefix}-{version}.tar.gz" archive_name = f"{archive_prefix}-{version}.tar.gz"
@@ -264,7 +275,10 @@ def build_release(
verify_signature( verify_signature(
manifest, manifest,
signature, signature,
PROJECT_ROOT / "ptlinux/launcher-update-public.pem", _payload_source_for_destination(
config,
PurePosixPath("ptlinux/launcher-update-public.pem"),
),
) )
artifacts.append(signature) artifacts.append(signature)
elif require_signature: elif require_signature:
@@ -272,6 +286,112 @@ def build_release(
return tuple(artifacts) return tuple(artifacts)
def build_legacy_launcher_update(
*,
version: str,
output_directory: Path,
repository_url: str,
minimum_updater_version: str,
signing_key: Path | None,
require_signature: bool = False,
source_date_epoch: int,
) -> tuple[Path, ...]:
"""Build the schema-1 update artifacts consumed by deployed launchers.
The names, wrapped archive layout, minimal ``release.json``, and ``stable.json``
shape are compatibility contracts. Payload selection and archive serialization
live here so the historical ``tools/build_launcher_update.py`` entry point can
remain a forwarding wrapper.
"""
_validate_legacy_version(version, "release version")
_validate_legacy_version(minimum_updater_version, "minimum updater version")
config = load_config()
current_version = source_version(config)
if current_version != version:
raise ReleaseToolError(
f"requested version {version} does not match ptlinux.__version__ "
f"{current_version}"
)
_validate_repository_url(repository_url)
if require_signature and signing_key is None:
raise ReleaseToolError("a signing key is required for a production release")
if source_date_epoch < 0:
raise ReleaseToolError("source date epoch cannot be negative")
output = output_directory.expanduser().resolve()
if signing_key is not None:
signing_key = _safe_signing_key_path(
signing_key,
output_directory=output,
)
output.mkdir(parents=True, exist_ok=True)
archive_root = (
f"{_required_string(config, 'legacy_archive_root_prefix')}{version}"
)
archive = output / f"{archive_root}.tar.gz"
archive_temporary = output / f".{archive.name}.{os.getpid()}.tmp"
archive_temporary.unlink(missing_ok=True)
try:
_write_legacy_launcher_archive(
archive_temporary,
archive_root=archive_root,
version=version,
mtime=source_date_epoch,
payload=collect_payload(config),
)
archive_temporary.chmod(0o644)
archive_temporary.replace(archive)
except BaseException:
archive_temporary.unlink(missing_ok=True)
raise
digest = sha256_file(archive)
checksum = archive.with_suffix(archive.suffix + ".sha256")
_write_atomic(checksum, f"{digest} {archive.name}\n".encode("ascii"))
repository = repository_url.rstrip("/")
manifest = {
"archive_sha256": digest,
"archive_size": archive.stat().st_size,
"archive_url": f"{repository}/releases/download/v{version}/{archive.name}",
"channel": "stable",
"minimum_updater_version": minimum_updater_version,
"notes_url": f"{repository}/releases/tag/v{version}",
"published_at": _timestamp_from_epoch(source_date_epoch),
"schema": 1,
"version": version,
}
manifest_path = output / "stable.json"
manifest_bytes = (
json.dumps(manifest, indent=2, sort_keys=True, separators=(",", ": ")) + "\n"
).encode("utf-8")
_write_atomic(manifest_path, manifest_bytes)
signature_path = output / "stable.json.sig"
artifacts: list[Path] = [archive, checksum, manifest_path]
if signing_key is not None:
sign_file(manifest_path, signature_path, signing_key)
public_key = _payload_source_for_destination(
config,
PurePosixPath("ptlinux/launcher-update-public.pem"),
)
try:
verify_signature(manifest_path, signature_path, public_key)
except ReleaseToolError as exc:
signature_path.unlink(missing_ok=True)
raise ReleaseToolError(
"the signing key does not match ptlinux/launcher-update-public.pem"
) from exc
artifacts.append(signature_path)
else:
signature_path.unlink(missing_ok=True)
print(
"Warning: stable.json was not signed; set PTLINUX_SIGNING_KEY for a "
"publishable release.",
file=sys.stderr,
)
return tuple(artifacts)
def stage_release( def stage_release(
destination: Path, destination: Path,
config: Mapping[str, object], config: Mapping[str, object],
@@ -307,6 +427,38 @@ def stage_release(
payload.append( payload.append(
PayloadFile(PurePosixPath("release.json"), None, release_metadata, 0o644) PayloadFile(PurePosixPath("release.json"), None, release_metadata, 0o644)
) )
_stage_payload_files(destination, payload)
_require_staged_files(destination, config)
def stage_runtime_payload(
destination: Path,
config: Mapping[str, object] | None = None,
*,
source_root: Path | None = None,
) -> None:
"""Stage the runtime files shared by installers and launcher updates.
`release.json` is generated by the caller because bootstrap installers and
signed launcher updates attach different source metadata to that file.
"""
release_config = load_config() if config is None else config
_stage_payload_files(
destination,
collect_payload(release_config, source_root=source_root),
)
_require_staged_files(
destination,
release_config,
allowed_missing={"release.json"},
)
def _stage_payload_files(
destination: Path,
payload: Sequence[PayloadFile],
) -> None:
seen: set[PurePosixPath] = set() seen: set[PurePosixPath] = set()
for item in sorted(payload, key=lambda entry: entry.relative_path.as_posix()): for item in sorted(payload, key=lambda entry: entry.relative_path.as_posix()):
if item.relative_path in seen: if item.relative_path in seen:
@@ -324,12 +476,27 @@ def stage_release(
for directory in (destination, *destination.rglob("*")): for directory in (destination, *destination.rglob("*")):
if directory.is_dir(): if directory.is_dir():
directory.chmod(0o755) directory.chmod(0o755)
def _require_staged_files(
destination: Path,
config: Mapping[str, object],
*,
allowed_missing: set[str] | None = None,
) -> None:
missing_allowed = set() if allowed_missing is None else allowed_missing
for required in _string_list(config.get("required_files"), "required_files"): for required in _string_list(config.get("required_files"), "required_files"):
if required in missing_allowed:
continue
if not (destination / required).is_file(): if not (destination / required).is_file():
raise ReleaseToolError(f"staging tree is missing required file: {required}") raise ReleaseToolError(f"staging tree is missing required file: {required}")
def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]: def collect_payload(
config: Mapping[str, object],
*,
source_root: Path | None = None,
) -> list[PayloadFile]:
payload_config = config.get("payload") payload_config = config.get("payload")
if not isinstance(payload_config, dict): if not isinstance(payload_config, dict):
raise ReleaseToolError("release payload configuration must be an object") raise ReleaseToolError("release payload configuration must be an object")
@@ -341,18 +508,27 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
if not isinstance(tree, dict): if not isinstance(tree, dict):
raise ReleaseToolError("payload python tree must be an object") raise ReleaseToolError("payload python tree must be an object")
source_name = _required_string(tree, "source") source_name = _required_string(tree, "source")
source_root = PROJECT_ROOT / source_name source_relative = _safe_relative_path(source_name)
if not source_root.is_dir() or source_root.is_symlink(): destination_name = tree.get("destination", source_name)
if not isinstance(destination_name, str) or not destination_name:
raise ReleaseToolError("payload python tree destination must be a string")
destination_relative = _safe_relative_path(destination_name)
tree_root = _payload_source_path(
source_relative,
source_root,
packaged_relative=destination_relative,
)
if not tree_root.is_dir() or tree_root.is_symlink():
raise ReleaseToolError(f"payload source tree is invalid: {source_name}") raise ReleaseToolError(f"payload source tree is invalid: {source_name}")
exclusions = set(_string_list(tree.get("exclude", []), "payload exclude")) exclusions = set(_string_list(tree.get("exclude", []), "payload exclude"))
for source in sorted(source_root.rglob("*.py")): for source in sorted(tree_root.rglob("*.py")):
relative_in_tree = source.relative_to(source_root).as_posix() relative_in_tree = source.relative_to(tree_root).as_posix()
if relative_in_tree in exclusions: if relative_in_tree in exclusions:
continue continue
_validate_source_file(source) _validate_source_file(source)
result.append( result.append(
PayloadFile( PayloadFile(
PurePosixPath(source_name) / PurePosixPath(relative_in_tree), destination_relative / PurePosixPath(relative_in_tree),
source, source,
None, None,
0o644, 0o644,
@@ -364,9 +540,27 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
for file_config in configured_files: for file_config in configured_files:
if not isinstance(file_config, dict): if not isinstance(file_config, dict):
raise ReleaseToolError("payload file entry must be an object") raise ReleaseToolError("payload file entry must be an object")
legacy_path = file_config.get("path")
if legacy_path is not None:
if "source" in file_config or "destination" in file_config:
raise ReleaseToolError(
"payload file entry cannot combine path with source or destination"
)
path_text = _required_string(file_config, "path") path_text = _required_string(file_config, "path")
relative = _safe_relative_path(path_text) source_relative = _safe_relative_path(path_text)
source = PROJECT_ROOT.joinpath(*relative.parts) destination_relative = source_relative
else:
source_relative = _safe_relative_path(
_required_string(file_config, "source")
)
destination_relative = _safe_relative_path(
_required_string(file_config, "destination")
)
source = _payload_source_path(
source_relative,
source_root,
packaged_relative=destination_relative,
)
_validate_source_file(source) _validate_source_file(source)
mode_text = _required_string(file_config, "mode") mode_text = _required_string(file_config, "mode")
try: try:
@@ -375,10 +569,29 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
raise ReleaseToolError(f"invalid payload mode {mode_text!r}") from exc raise ReleaseToolError(f"invalid payload mode {mode_text!r}") from exc
if mode not in {0o644, 0o755}: if mode not in {0o644, 0o755}:
raise ReleaseToolError(f"unsupported payload mode: {mode_text}") raise ReleaseToolError(f"unsupported payload mode: {mode_text}")
result.append(PayloadFile(relative, source, None, mode)) result.append(PayloadFile(destination_relative, source, None, mode))
return result return result
def _payload_source_path(
relative: PurePosixPath,
source_root: Path | None,
*,
packaged_relative: PurePosixPath | None = None,
) -> Path:
if source_root is None:
return PROJECT_ROOT.joinpath(*relative.parts)
candidates = [source_root.joinpath(*relative.parts)]
if relative.parts[:1] == ("src",):
candidates.append(source_root.joinpath(*relative.parts[1:]))
if packaged_relative is not None:
candidates.append(source_root.joinpath(*packaged_relative.parts))
for candidate in candidates:
if candidate.exists():
return candidate
return candidates[0]
def generate_manifest( def generate_manifest(
destination: Path, destination: Path,
*, *,
@@ -543,7 +756,7 @@ def verify_release(
if not (extracted / required).is_file(): if not (extracted / required).is_file():
raise ReleaseToolError(f"release archive is missing required file: {required}") raise ReleaseToolError(f"release archive is missing required file: {required}")
packaged_version = _version_from_source_file( packaged_version = _version_from_source_file(
extracted / _required_string(config, "version_source") extracted / _packaged_version_path(config)
) )
if packaged_version != version: if packaged_version != version:
raise ReleaseToolError( raise ReleaseToolError(
@@ -568,16 +781,57 @@ def verify_release(
verify_signature( verify_signature(
manifest_path, manifest_path,
signature, signature,
public_key or PROJECT_ROOT / "ptlinux/launcher-update-public.pem", public_key or _payload_source_for_destination(
config,
PurePosixPath("ptlinux/launcher-update-public.pem"),
),
) )
elif require_signature: elif require_signature:
raise ReleaseToolError("manifest signature is required but missing") raise ReleaseToolError("manifest signature is required but missing")
def _safe_signing_key_path(
private_key: Path,
*,
output_directory: Path | None = None,
) -> Path:
"""Validate that a private key is external, private, and a regular file."""
candidate = private_key.expanduser().absolute()
if candidate.is_symlink():
raise ReleaseToolError(
f"release signing key cannot be a symbolic link: {candidate}"
)
resolved = candidate.resolve()
location_pairs = (
(candidate, PROJECT_ROOT.absolute()),
(resolved, PROJECT_ROOT.resolve()),
(candidate, ARTIFACTS_ROOT.absolute()),
(resolved, ARTIFACTS_ROOT.resolve()),
)
if any(path == root or path.is_relative_to(root) for path, root in location_pairs):
raise ReleaseToolError(
"release signing key must be outside the repository and .artifacts: "
f"{candidate}"
)
if output_directory is not None:
output = output_directory.expanduser().resolve()
if resolved == output or resolved.is_relative_to(output):
raise ReleaseToolError(
f"release signing key must be outside release output: {candidate}"
)
if not resolved.is_file():
raise ReleaseToolError(f"release signing key is missing: {candidate}")
if resolved.stat().st_mode & 0o077:
raise ReleaseToolError(
"release signing key must not be accessible by group or other users: "
f"{candidate}"
)
return resolved
def sign_file(source: Path, signature: Path, private_key: Path) -> None: def sign_file(source: Path, signature: Path, private_key: Path) -> None:
key = private_key.expanduser().resolve() key = _safe_signing_key_path(private_key)
if not key.is_file() or key.is_symlink():
raise ReleaseToolError(f"manifest signing key is missing: {key}")
openssl = shutil.which("openssl") openssl = shutil.which("openssl")
if openssl is None: if openssl is None:
raise ReleaseToolError("OpenSSL is required to sign release manifests") raise ReleaseToolError("OpenSSL is required to sign release manifests")
@@ -730,6 +984,65 @@ def _write_deterministic_archive(
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
def _write_legacy_launcher_archive(
destination: Path,
*,
archive_root: str,
version: str,
mtime: int,
payload: Sequence[PayloadFile],
) -> None:
"""Write the original wrapped launcher archive byte-for-byte deterministically."""
metadata = (
json.dumps({"schema": 1, "version": version}, indent=2, sort_keys=True) + "\n"
).encode("utf-8")
with destination.open("xb") as raw:
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=mtime) as compressed:
with tarfile.open(
fileobj=compressed,
mode="w",
format=tarfile.PAX_FORMAT,
) as bundle:
for directory in (
archive_root,
f"{archive_root}/ptdiag",
f"{archive_root}/ptlinux",
f"{archive_root}/ptlinux/native",
):
information = _tar_info(directory, mtime, 0o755)
information.type = b"5"
bundle.addfile(information)
with io.BytesIO(metadata) as handle:
_add_tar_file(
bundle,
information_name=f"{archive_root}/release.json",
handle=handle,
size=len(metadata),
mtime=mtime,
mode=0o644,
)
for item in sorted(
payload,
key=lambda entry: entry.relative_path.as_posix(),
):
if item.source is None:
raise ReleaseToolError(
f"legacy payload has no source: {item.relative_path}"
)
with item.source.open("rb") as handle:
_add_tar_file(
bundle,
information_name=(
f"{archive_root}/{item.relative_path.as_posix()}"
),
handle=handle,
size=item.source.stat().st_size,
mtime=mtime,
mode=item.mode,
)
def _tar_info(name: str, mtime: int, mode: int) -> tarfile.TarInfo: def _tar_info(name: str, mtime: int, mode: int) -> tarfile.TarInfo:
information = tarfile.TarInfo(name) information = tarfile.TarInfo(name)
information.uid = 0 information.uid = 0
@@ -1005,12 +1318,13 @@ def _safe_output_directory(path: Path) -> Path:
if expanded.is_symlink(): if expanded.is_symlink():
raise ReleaseToolError(f"output directory cannot be a symbolic link: {expanded}") raise ReleaseToolError(f"output directory cannot be a symbolic link: {expanded}")
resolved = expanded.resolve() resolved = expanded.resolve()
project_dist_path = PROJECT_ROOT / "dist" project_release_path = RELEASE_ARTIFACTS_ROOT
if project_dist_path.is_symlink(): if project_release_path.is_symlink():
raise ReleaseToolError( raise ReleaseToolError(
f"repository dist directory cannot be a symbolic link: {project_dist_path}" "repository release-artifact directory cannot be a symbolic link: "
f"{project_release_path}"
) )
project_dist = project_dist_path.resolve() project_releases = project_release_path.resolve()
temporary_roots = ( temporary_roots = (
Path(tempfile.gettempdir()).resolve(), Path(tempfile.gettempdir()).resolve(),
Path("/var/tmp").resolve(), Path("/var/tmp").resolve(),
@@ -1023,12 +1337,16 @@ def _safe_output_directory(path: Path) -> Path:
} }
if resolved in unsafe or resolved in PROJECT_ROOT.resolve().parents: if resolved in unsafe or resolved in PROJECT_ROOT.resolve().parents:
raise ReleaseToolError(f"refusing unsafe output directory: {resolved}") raise ReleaseToolError(f"refusing unsafe output directory: {resolved}")
if resolved == project_dist or resolved.is_relative_to(project_dist): if resolved == project_releases or resolved.is_relative_to(project_releases):
return resolved return resolved
if resolved.is_relative_to(PROJECT_ROOT.resolve()):
raise ReleaseToolError(
"release output inside the repository must be below .artifacts/releases"
)
if any(resolved.is_relative_to(root) for root in temporary_roots): if any(resolved.is_relative_to(root) for root in temporary_roots):
return resolved return resolved
raise ReleaseToolError( raise ReleaseToolError(
"release output must be the repository dist directory or a temporary directory" "release output must be below .artifacts/releases or a temporary directory"
) )
@@ -1111,6 +1429,21 @@ def _validate_repository_url(value: str) -> None:
) )
def _validate_legacy_version(value: str, label: str) -> None:
if _LEGACY_VERSION_PATTERN.fullmatch(value) is None:
raise ReleaseToolError(f"{label} must use X.Y.Z format: {value!r}")
def _payload_source_for_destination(
config: Mapping[str, object],
destination: PurePosixPath,
) -> Path:
for item in collect_payload(config):
if item.relative_path == destination and item.source is not None:
return item.source
raise ReleaseToolError(f"release payload is missing {destination.as_posix()}")
def _read_json_object(path: Path) -> dict[str, object]: def _read_json_object(path: Path) -> dict[str, object]:
try: try:
value = json.loads( value = json.loads(
@@ -1166,7 +1499,7 @@ def _set_staged_source_version(
config: Mapping[str, object], config: Mapping[str, object],
version: str, version: str,
) -> None: ) -> None:
path = staging / _required_string(config, "version_source") path = staging / _packaged_version_path(config)
try: try:
contents = path.read_text(encoding="utf-8") contents = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc: except (OSError, UnicodeError) as exc:
@@ -1212,6 +1545,17 @@ def _write_atomic(path: Path, data: bytes) -> None:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
def _packaged_version_path(config: Mapping[str, object]) -> PurePosixPath:
value = config.get("packaged_version_path")
if value is None:
# Older repository fixtures used one path because source and archive
# layouts were identical.
value = _required_string(config, "version_source")
if not isinstance(value, str) or not value:
raise ReleaseToolError("packaged_version_path must be a non-empty string")
return _safe_relative_path(value)
def _git_output(*arguments: str) -> str: def _git_output(*arguments: str) -> str:
try: try:
result = subprocess.run( result = subprocess.run(

View File

@@ -0,0 +1,25 @@
"""Repository-only paths shared by maintainer scripts and tests."""
from __future__ import annotations
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
ARTIFACTS_ROOT = PROJECT_ROOT / ".artifacts"
RELEASE_ARTIFACTS_ROOT = ARTIFACTS_ROOT / "releases"
def ensure_project_imports() -> None:
"""Put checkout-only import roots ahead of ambient Python installations."""
ordered = [SOURCE_ROOT, PROJECT_ROOT]
for path in ordered:
text = str(path)
while text in sys.path:
sys.path.remove(text)
for path in reversed(ordered):
if path.is_dir():
sys.path.insert(0, str(path))

View File

@@ -9,10 +9,12 @@ from pathlib import Path
import re import re
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1] if __package__:
if str(PROJECT_ROOT) not in sys.path: from .repository_paths import PROJECT_ROOT, ensure_project_imports
sys.path.insert(0, str(PROJECT_ROOT)) else:
from repository_paths import PROJECT_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, load_config # noqa: E402 from scripts.release_lib import ReleaseToolError, load_config # noqa: E402
from ptlinux.update_resolver import SemanticVersion from ptlinux.update_resolver import SemanticVersion

View File

@@ -7,10 +7,12 @@ import argparse
from pathlib import Path from pathlib import Path
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1] if __package__:
if str(PROJECT_ROOT) not in sys.path: from .repository_paths import SOURCE_ROOT, ensure_project_imports
sys.path.insert(0, str(PROJECT_ROOT)) else:
from repository_paths import SOURCE_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402 from scripts.release_lib import ( # noqa: E402
ReleaseToolError, ReleaseToolError,
sign_file, sign_file,
@@ -26,7 +28,7 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--public-key", "--public-key",
type=Path, type=Path,
default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem", default=SOURCE_ROOT / "ptlinux/launcher-update-public.pem",
) )
arguments = parser.parse_args() arguments = parser.parse_args()
try: try:

View File

@@ -7,10 +7,12 @@ import argparse
from pathlib import Path from pathlib import Path
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1] if __package__:
if str(PROJECT_ROOT) not in sys.path: from .repository_paths import SOURCE_ROOT, ensure_project_imports
sys.path.insert(0, str(PROJECT_ROOT)) else:
from repository_paths import SOURCE_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, verify_signature # noqa: E402 from scripts.release_lib import ReleaseToolError, verify_signature # noqa: E402
@@ -21,7 +23,7 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--public-key", "--public-key",
type=Path, type=Path,
default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem", default=SOURCE_ROOT / "ptlinux/launcher-update-public.pem",
) )
arguments = parser.parse_args() arguments = parser.parse_args()
try: try:

View File

@@ -7,10 +7,12 @@ import argparse
from pathlib import Path from pathlib import Path
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1] if __package__:
if str(PROJECT_ROOT) not in sys.path: from .repository_paths import ensure_project_imports
sys.path.insert(0, str(PROJECT_ROOT)) else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, verify_release # noqa: E402 from scripts.release_lib import ReleaseToolError, verify_release # noqa: E402

View File

@@ -15,6 +15,10 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from ...command import CommandOutcome from ...command import CommandOutcome
from ...diagnostics.crash_dumps import (
inspect_minidump,
prepare_minidump_attachment,
)
from ...diagnostics.native_logs import LogFileState from ...diagnostics.native_logs import LogFileState
from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm
from ...launcher import LauncherError from ...launcher import LauncherError
@@ -383,15 +387,12 @@ def play_locked(
runtime_issues["game_window_ready"] = game_window_ready runtime_issues["game_window_ready"] = game_window_ready
if game_readiness_signal is not None: if game_readiness_signal is not None:
runtime_issues["game_readiness_signal"] = game_readiness_signal runtime_issues["game_readiness_signal"] = game_readiness_signal
if ( recommended_comparison = recommended_runtime_comparison(
selection.kind == "proton7-container" selection.kind,
and runtime_issues["host_futex_fatal_count"] runtime_issues,
): )
runtime_issues["recommended_one_variable_comparison"] = "proton7" if recommended_comparison is not None:
elif selection.kind == "proton7" and runtime_issues["gstreamer_missing_libraries"]: runtime_issues["recommended_one_variable_comparison"] = recommended_comparison
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 clean_exit = outcome.returncode == 0 and current_game_crash is None
launch_evidence = game_readiness_signal launch_evidence = game_readiness_signal
if launch_evidence is None and current_game_log is not None: if launch_evidence is None and current_game_log is not None:
@@ -439,6 +440,38 @@ def play_locked(
"realm": start_option.name, "realm": start_option.name,
**api._game_exit_metadata(outcome.returncode, selection.kind), **api._game_exit_metadata(outcome.returncode, selection.kind),
} }
if not clean_exit:
common_game_metadata["failure_reason"] = game_failure_reason(
outcome,
current_game_log,
current_game_crash,
common_game_metadata,
)
failure_evidence: dict[str, object] = {
"native_game_log_present": current_game_log is not None,
}
if current_game_log is not None:
failure_evidence["native_game_log"] = game_log_telemetry_summary(
current_game_log
)
if current_game_crash is not None:
failure_evidence["crash_dump_info"] = crash_dump_telemetry_summary(
current_game_crash
)
crash_dump_attachment: dict[str, object] | None = None
if (
current_game_crash is not None
and settings.game_crash_dump_upload_enabled
and telemetry.enabled
):
crash_path = current_game_crash.get("path")
if isinstance(crash_path, str):
upload_status, crash_dump_attachment = prepare_minidump_attachment(
Path(crash_path)
)
else:
upload_status = "path_unavailable"
failure_evidence["crash_dump_upload_status"] = upload_status
if launch_succeeded: if launch_succeeded:
if not launch_success_reported: if not launch_success_reported:
assert launch_evidence is not None assert launch_evidence is not None
@@ -461,7 +494,7 @@ def play_locked(
"game_launch_failed", "game_launch_failed",
severity="error", severity="error",
message=( message=(
"Game client wrote a crash dump" game_crash_message(current_game_log)
if current_game_crash is not None if current_game_crash is not None
else "Game session timed out" else "Game session timed out"
if outcome.timed_out if outcome.timed_out
@@ -469,23 +502,28 @@ def play_locked(
), ),
metadata={ metadata={
**common_game_metadata, **common_game_metadata,
**failure_evidence,
"runtime_issues": runtime_issues, "runtime_issues": runtime_issues,
"stdout": outcome.stdout, "stdout": outcome.stdout,
"stderr": outcome.stderr, "stderr": outcome.stderr,
}, },
context={"operation": "launch_game", "stage": "game_start"}, context={"operation": "launch_game", "stage": "game_start"},
attachment=crash_dump_attachment,
) )
if launch_succeeded: if launch_succeeded:
exit_message = None exit_message = None
if current_game_crash is not None: if current_game_crash is not None:
exit_message = "Game client wrote a crash dump" exit_message = game_crash_message(current_game_log)
elif outcome.timed_out: elif outcome.timed_out:
exit_message = "Game session timed out" exit_message = "Game session timed out"
elif common_game_metadata.get("signal_inferred") is True: elif common_game_metadata.get("signal_inferred") is True:
signal_wrapper = (
"Proton" if selection.kind == "proton7" else "UMU"
)
exit_message = ( exit_message = (
"Game session ended with status " "Game session ended with status "
f"{common_game_metadata['raw_exit_code']}, consistent with " f"{common_game_metadata['raw_exit_code']}, consistent with "
f"{common_game_metadata['signal_name']} wrapped by UMU" f"{common_game_metadata['signal_name']} wrapped by {signal_wrapper}"
) )
elif "signal" in common_game_metadata: elif "signal" in common_game_metadata:
exit_message = ( exit_message = (
@@ -507,12 +545,14 @@ def play_locked(
if clean_exit if clean_exit
else { else {
**common_game_metadata, **common_game_metadata,
**failure_evidence,
"runtime_issues": runtime_issues, "runtime_issues": runtime_issues,
"stdout": outcome.stdout, "stdout": outcome.stdout,
"stderr": outcome.stderr, "stderr": outcome.stderr,
} }
), ),
context={"operation": "launch_game", "stage": "exited"}, context={"operation": "launch_game", "stage": "exited"},
attachment=crash_dump_attachment,
) )
if as_json or dry_run: if as_json or dry_run:
api._emit(report, as_json) api._emit(report, as_json)
@@ -553,10 +593,15 @@ def game_exit_metadata(
) )
return metadata return metadata
# UMU returns Popen's negative child status from a Python main function. # UMU and the retained Proton 7 Python entry point return a negative child
# SystemExit encodes -SIGKILL as 247 before our Python gamebox sees it. # status from subprocess. SystemExit encodes -SIGKILL as 247 before our
# Python gamebox sees it.
wrapped_sigkill = 256 - int(signal.SIGKILL) wrapped_sigkill = 256 - int(signal.SIGKILL)
if runtime_kind in {"umu", "proton7-container"} and returncode == wrapped_sigkill: if (
runtime_kind in {"umu", "proton7", "proton7-container"}
and returncode == wrapped_sigkill
):
wrapper = "proton" if runtime_kind == "proton7" else "umu"
metadata.update( metadata.update(
{ {
"exit_code": 128 + int(signal.SIGKILL), "exit_code": 128 + int(signal.SIGKILL),
@@ -564,12 +609,65 @@ def game_exit_metadata(
"signal": int(signal.SIGKILL), "signal": int(signal.SIGKILL),
"signal_name": signal.SIGKILL.name, "signal_name": signal.SIGKILL.name,
"signal_inferred": True, "signal_inferred": True,
"signal_inference": "umu_python_system_exit", "signal_inference": f"{wrapper}_python_system_exit",
} }
) )
return metadata return metadata
def recommended_runtime_comparison(
runtime_kind: str,
runtime_issues: dict[str, object],
) -> str | None:
"""Choose a useful one-variable comparison from launch-relevant evidence."""
if (
runtime_kind == "proton7-container"
and runtime_issues.get("host_futex_fatal_count")
):
return "proton7"
if (
runtime_kind == "proton7"
and runtime_issues.get("gstreamer_missing_libraries")
and (
runtime_issues.get("gstreamer_stream_error") is True
or runtime_issues.get("game_window_ready") is not True
)
):
return "proton7-container"
if runtime_issues.get("dbghelp_symdeferred_assertion_count"):
return "umu"
return None
def game_failure_reason(
outcome: CommandOutcome,
current_game_log: dict[str, object] | None,
current_game_crash: dict[str, object] | None,
exit_metadata: dict[str, object],
) -> str:
"""Classify the strongest evidence recorded for a failed game session."""
if current_game_log and current_game_log.get("runtime_out_of_memory") is True:
return "direct3d_resource_exhaustion"
if current_game_crash is not None:
return "client_crash_dump"
if outcome.timed_out:
return "session_timeout"
if "signal" in exit_metadata:
return "process_signal"
return "nonzero_exit"
def game_crash_message(current_game_log: dict[str, object] | None) -> str:
if (
current_game_log is not None
and current_game_log.get("runtime_out_of_memory") is True
):
return "Game client logged Direct3D resource exhaustion and wrote a crash dump"
return "Game client wrote a crash dump"
def signal_name(signal_number: int) -> str: def signal_name(signal_number: int) -> str:
try: try:
return signal.Signals(signal_number).name return signal.Signals(signal_number).name
@@ -654,6 +752,7 @@ def current_game_log(
], ],
"failed_import_scenes": analysis.failed_import_scenes, "failed_import_scenes": analysis.failed_import_scenes,
"stage_without_new_stage": analysis.stage_without_new_stage, "stage_without_new_stage": analysis.stage_without_new_stage,
"tex_reclaim_count": analysis.tex_reclaim_count,
"module_code_sync_error": analysis.module_code_sync_error, "module_code_sync_error": analysis.module_code_sync_error,
"module_code_sync_left": analysis.module_code_sync_left, "module_code_sync_left": analysis.module_code_sync_left,
"module_code_sync_right": analysis.module_code_sync_right, "module_code_sync_right": analysis.module_code_sync_right,
@@ -702,13 +801,59 @@ def current_game_crash(
) )
if state is None: if state is None:
return None return None
return { report: dict[str, object] = {
"path": str(state.path), "path": str(state.path),
"modified_at": datetime.fromtimestamp( "modified_at": datetime.fromtimestamp(
state.mtime_ns / 1_000_000_000 state.mtime_ns / 1_000_000_000
).astimezone().isoformat(), ).astimezone().isoformat(),
"size": state.size, "size": state.size,
} }
minidump = inspect_minidump(state.path)
if minidump is not None:
report["minidump"] = minidump
return report
def game_log_telemetry_summary(report: dict[str, object]) -> dict[str, object]:
"""Keep the native-log evidence useful while bounding telemetry cardinality."""
keys = (
"modified_at",
"size",
"load_texture_to_material_failed",
"import_scene_threw",
"asset_pack_health_failed",
"managed_asset_integrity_failed",
"runtime_out_of_memory",
"out_of_memory_count",
"out_of_memory_effect_paths",
"failed_import_scenes",
"stage_without_new_stage",
"tex_reclaim_count",
"module_code_sync_error",
"module_code_sync_left",
"module_code_sync_right",
"module_code_sync_delta",
"banned_code_1",
"failed_file_find_count",
"failed_file_load_count",
"failed_file_read_count",
"failed_asset_file_count",
"failed_texture_file_count",
"failed_effect_file_count",
"missing_texture_reference_count",
"missing_effect_reference_count",
"missing_image_reference_count",
"missing_pack_image_reference_count",
)
return {key: report[key] for key in keys if key in report}
def crash_dump_telemetry_summary(report: dict[str, object]) -> dict[str, object]:
"""Exclude the local dump path while retaining its bounded signature."""
keys = ("modified_at", "size", "minidump")
return {key: report[key] for key in keys if key in report}
def runtime_issue_summary(stderr: str) -> dict[str, object]: def runtime_issue_summary(stderr: str) -> dict[str, object]:

View File

@@ -53,6 +53,10 @@ WINETRICKS_CODEPAGE_PROBE = (
) )
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"' WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
RUNTIME_DEFAULT_SYNC = "runtime-default" RUNTIME_DEFAULT_SYNC = "runtime-default"
PROTON_DEBUG_ENVIRONMENT_VARIABLE = "PTLINUX_PROTON_DEBUG"
PROTON_DEBUG_WINE_CHANNELS = (
"+timestamp,+pid,+tid,+seh,+unwind,+debugstr,+loaddll,+mscoree"
)
CACHYOS_MARKER = "cachyos" CACHYOS_MARKER = "cachyos"
CURRENT_UMU_DISTRIBUTIONS = frozenset( CURRENT_UMU_DISTRIBUTIONS = frozenset(
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"} {"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
@@ -770,6 +774,7 @@ def _proton_environment(
) -> dict[str, str]: ) -> dict[str, str]:
if not selection.steam_root: if not selection.steam_root:
raise RuntimeErrorPT("Steam root is missing for Proton") raise RuntimeErrorPT("Steam root is missing for Proton")
debug_logging = os.environ.get(PROTON_DEBUG_ENVIRONMENT_VARIABLE) == "1"
environment = { environment = {
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root, "STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data), "STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
@@ -777,10 +782,12 @@ def _proton_environment(
"SteamAppId": "0", "SteamAppId": "0",
"SteamGameId": "0", "SteamGameId": "0",
"WINEPREFIX": str(paths.proton_prefix), "WINEPREFIX": str(paths.proton_prefix),
"WINEDEBUG": "-all", "WINEDEBUG": PROTON_DEBUG_WINE_CHANNELS if debug_logging else "-all",
"PROTON_LOG": "0", "PROTON_LOG": "1" if debug_logging else "0",
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), "DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
} }
if debug_logging:
environment["PROTON_LOG_DIR"] = str(paths.logs)
environment.update(_proton_sync_environment(synchronization)) environment.update(_proton_sync_environment(synchronization))
return environment return environment

View File

@@ -39,6 +39,7 @@ class Settings:
realm: str = "Babel" realm: str = "Babel"
ui_scale: str = "auto" ui_scale: str = "auto"
telemetry_enabled: bool = True telemetry_enabled: bool = True
game_crash_dump_upload_enabled: bool = False
borderless: bool = False borderless: bool = False
suppress_unresponsive_warnings: bool = True suppress_unresponsive_warnings: bool = True
runtime: str = "auto" runtime: str = "auto"
@@ -158,6 +159,11 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
realm=_string(value, "realm", "Babel").strip(), realm=_string(value, "realm", "Babel").strip(),
ui_scale=_string(value, "ui_scale", "auto"), ui_scale=_string(value, "ui_scale", "auto"),
telemetry_enabled=_boolean(value, "telemetry_enabled", True), telemetry_enabled=_boolean(value, "telemetry_enabled", True),
game_crash_dump_upload_enabled=_boolean(
value,
"game_crash_dump_upload_enabled",
False,
),
borderless=_migrated_borderless(value), borderless=_migrated_borderless(value),
allow_window_resizing=_boolean( allow_window_resizing=_boolean(
value, value,

View File

@@ -19,6 +19,11 @@ from types import TracebackType
from typing import Any from typing import Any
from .. import __version__ from .. import __version__
from ..diagnostics.crash_dumps import (
MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS,
MAX_CRASH_DUMP_COMPRESSED_BYTES,
MAX_CRASH_DUMP_UPLOAD_BYTES,
)
from ..paths import AppPaths from ..paths import AppPaths
@@ -41,6 +46,7 @@ MAX_VALUE_DEPTH = 8
QUEUE_CAPACITY = 128 QUEUE_CAPACITY = 128
DELIVERY_TIMEOUT_SECONDS = 3.0 DELIVERY_TIMEOUT_SECONDS = 3.0
SHUTDOWN_WAIT_SECONDS = 0.25 SHUTDOWN_WAIT_SECONDS = 0.25
CRASH_DUMP_SHUTDOWN_WAIT_SECONDS = DELIVERY_TIMEOUT_SECONDS + 0.5
ERROR_SUPPRESSION_WINDOW_SECONDS = 60.0 ERROR_SUPPRESSION_WINDOW_SECONDS = 60.0
MAX_ERROR_FINGERPRINTS = 128 MAX_ERROR_FINGERPRINTS = 128
@@ -63,6 +69,7 @@ _RESOURCE_USAGE_EVENTS = frozenset(
"update_success", "update_success",
} }
) )
_CRASH_DUMP_ATTACHMENT_EVENTS = frozenset({"game_exited", "game_launch_failed"})
VALID_SEVERITIES = frozenset({"debug", "info", "warning", "error", "critical"}) VALID_SEVERITIES = frozenset({"debug", "info", "warning", "error", "critical"})
VALID_EVENTS = frozenset( VALID_EVENTS = frozenset(
{ {
@@ -254,6 +261,7 @@ class TelemetryService:
self._worker: threading.Thread | None = None self._worker: threading.Thread | None = None
self._environment_worker: threading.Thread | None = None self._environment_worker: threading.Thread | None = None
self._state_lock = threading.RLock() self._state_lock = threading.RLock()
self._crash_dump_attachment_enqueued = False
self._repeated_errors: dict[str, _RepeatedErrorState] = {} self._repeated_errors: dict[str, _RepeatedErrorState] = {}
self._previous_sys_excepthook: Any = None self._previous_sys_excepthook: Any = None
self._previous_threading_excepthook: Any = None self._previous_threading_excepthook: Any = None
@@ -372,6 +380,7 @@ class TelemetryService:
message: str | None = None, message: str | None = None,
metadata: Mapping[str, object] | None = None, metadata: Mapping[str, object] | None = None,
context: Mapping[str, object] | None = None, context: Mapping[str, object] | None = None,
attachment: Mapping[str, object] | None = None,
) -> bool: ) -> bool:
"""Sanitize and enqueue one event without blocking on network I/O.""" """Sanitize and enqueue one event without blocking on network I/O."""
@@ -431,10 +440,29 @@ class TelemetryService:
sanitized_metadata, sanitized_metadata,
MAX_METADATA_BYTES, MAX_METADATA_BYTES,
) )
if attachment:
sanitized_attachment = (
self._sanitize_attachment(attachment)
if event in _CRASH_DUMP_ATTACHMENT_EVENTS
else None
)
if sanitized_attachment is not None:
payload["attachment"] = sanitized_attachment
if len(self._facade._json_bytes(payload)) > MAX_REQUEST_BYTES:
payload.pop("attachment", None)
self._set_attachment_status(
payload,
"payload_too_large",
)
else:
self._set_attachment_status(payload, "invalid_attachment")
body = self._facade._encode_payload(payload) body = self._facade._encode_payload(payload)
if body is None: if body is None:
return False return False
self._queue.put_nowait(body) self._queue.put_nowait(body)
if "attachment" in payload:
with self._state_lock:
self._crash_dump_attachment_enqueued = True
if repeated_error is not None: if repeated_error is not None:
evicted = self._remember_repeated_error(repeated_error) evicted = self._remember_repeated_error(repeated_error)
if evicted is not None: if evicted is not None:
@@ -663,7 +691,15 @@ class TelemetryService:
if not self._active or self._closing: if not self._active or self._closing:
return return
self._closing = True self._closing = True
deadline = time.monotonic() + max(0.0, timeout_seconds) crash_dump_attachment_enqueued = self._crash_dump_attachment_enqueued
effective_timeout = max(
0.0,
timeout_seconds,
CRASH_DUMP_SHUTDOWN_WAIT_SECONDS
if crash_dump_attachment_enqueued
else 0.0,
)
deadline = time.monotonic() + effective_timeout
environment_worker = self._environment_worker environment_worker = self._environment_worker
if isinstance(environment_worker, threading.Thread) and ( if isinstance(environment_worker, threading.Thread) and (
environment_worker is not threading.current_thread() environment_worker is not threading.current_thread()
@@ -931,6 +967,79 @@ class TelemetryService:
truncate=self._facade._truncate_characters, truncate=self._facade._truncate_characters,
) )
def _sanitize_attachment(
self,
attachment: Mapping[str, object],
) -> dict[str, object] | None:
"""Accept only the bounded, path-free minidump envelope we generate."""
value = dict(attachment)
if set(value) != {
"schema",
"kind",
"encoding",
"original_size",
"compressed_size",
"sha256",
"data_base64url",
}:
return None
if (
value.get("schema") != 1
or isinstance(value.get("schema"), bool)
or value.get("kind") != "windows_minidump"
or value.get("encoding") != "gzip+base64url"
):
return None
original_size = value.get("original_size")
compressed_size = value.get("compressed_size")
if (
isinstance(original_size, bool)
or not isinstance(original_size, int)
or original_size < 32
or original_size > MAX_CRASH_DUMP_UPLOAD_BYTES
or isinstance(compressed_size, bool)
or not isinstance(compressed_size, int)
or compressed_size < 1
or compressed_size > MAX_CRASH_DUMP_COMPRESSED_BYTES
):
return None
sha256 = value.get("sha256")
data = value.get("data_base64url")
if (
not isinstance(sha256, str)
or re.fullmatch(r"[0-9a-f]{64}", sha256) is None
or not isinstance(data, str)
or len(data) != (compressed_size * 4 + 2) // 3
or len(data) > MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS
or re.fullmatch(r"[A-Za-z0-9_-]+", data) is None
):
return None
metadata = {key: item for key, item in value.items() if key != "data_base64url"}
sanitized = self._sanitize_value(metadata, key="attachment", depth=0)
if not isinstance(sanitized, dict):
return None
sanitized["data_base64url"] = data
return sanitized
def _set_attachment_status(
self,
payload: dict[str, object],
status: str,
) -> None:
current_metadata = payload.get("metadata")
metadata: dict[str, object] = {"crash_dump_upload_status": status}
if isinstance(current_metadata, dict):
metadata.update(
(key, value)
for key, value in current_metadata.items()
if key != "crash_dump_upload_status"
)
payload["metadata"] = self._facade._bound_mapping(
metadata,
MAX_METADATA_BYTES,
)
def _sanitize_text(self, value: str) -> str: def _sanitize_text(self, value: str) -> str:
return self._facade._privacy_sanitize_text( return self._facade._privacy_sanitize_text(
value, value,

View File

@@ -185,6 +185,9 @@ def open_settings_dialog(
value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS) value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS)
) )
telemetry_value = tk.BooleanVar(value=current_settings.telemetry_enabled) telemetry_value = tk.BooleanVar(value=current_settings.telemetry_enabled)
crash_dump_upload_value = tk.BooleanVar(
value=current_settings.game_crash_dump_upload_enabled
)
suppress_unresponsive_value = tk.BooleanVar( suppress_unresponsive_value = tk.BooleanVar(
value=current_settings.suppress_unresponsive_warnings value=current_settings.suppress_unresponsive_warnings
) )
@@ -322,6 +325,7 @@ def open_settings_dialog(
allow_window_resizing=allow_window_resizing_value.get(), allow_window_resizing=allow_window_resizing_value.get(),
ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS), ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS),
telemetry_enabled=telemetry_value.get(), telemetry_enabled=telemetry_value.get(),
game_crash_dump_upload_enabled=crash_dump_upload_value.get(),
suppress_unresponsive_warnings=suppress_unresponsive_value.get(), suppress_unresponsive_warnings=suppress_unresponsive_value.get(),
runtime=setting_value(runtime_value.get(), RUNTIME_LABELS), runtime=setting_value(runtime_value.get(), RUNTIME_LABELS),
proton_sync=setting_value( proton_sync=setting_value(
@@ -590,7 +594,7 @@ def open_settings_dialog(
ttk.Checkbutton( ttk.Checkbutton(
diagnostics_card, diagnostics_card,
name="settings_telemetry_enabled", name="settings_telemetry_enabled",
text="Send anonymous diagnostic and usage information", text="Send launcher diagnostic and usage information",
variable=telemetry_value, variable=telemetry_value,
style="Settings.TCheckbutton", style="Settings.TCheckbutton",
cursor="hand2", cursor="hand2",
@@ -600,14 +604,35 @@ def open_settings_dialog(
diagnostics_card, diagnostics_card,
text=( text=(
"Helps improve PristonTale EU Linux compatibility by sending anonymous " "Helps improve PristonTale EU Linux compatibility by sending anonymous "
"launcher errors, Linux and display versions, graphics/runtime compatibility, " "launcher errors, Linux and display versions, graphics/runtime "
"and installation or update reliability. Game accounts, personal files, " "compatibility, and installation or update reliability. Routine "
"hostnames, and hardware serial numbers are never collected." "telemetry does not collect game accounts, personal files, hostnames, "
"or hardware serial numbers."
), ),
style="SettingsHint.TLabel", style="SettingsHint.TLabel",
wraplength=720, wraplength=720,
) )
diagnostics_hint.grid(row=1, column=0, sticky="w", pady=(7, 0)) diagnostics_hint.grid(row=1, column=0, sticky="w", pady=(7, 0))
ttk.Checkbutton(
diagnostics_card,
name="settings_game_crash_dump_upload_enabled",
text="Include compressed game crash dumps in error reports",
variable=crash_dump_upload_value,
style="Settings.TCheckbutton",
cursor="hand2",
takefocus=True,
).grid(row=2, column=0, sticky="w", pady=(12, 0))
crash_dump_hint = ttk.Label(
diagnostics_card,
text=(
"Off by default and requires telemetry above. A crash dump can contain "
"fragments of account, chat, or other game-process memory. Uploads are "
"compressed, integrity checked, and limited to 2 MiB before compression."
),
style="SettingsHint.TLabel",
wraplength=720,
)
crash_dump_hint.grid(row=3, column=0, sticky="w", pady=(7, 0))
overrides_card = ttk.LabelFrame( overrides_card = ttk.LabelFrame(
content, content,
@@ -705,6 +730,9 @@ def open_settings_dialog(
diagnostics_hint.configure( diagnostics_hint.configure(
wraplength=max(140, viewport_width - round(42 * launcher_ui_scale)) wraplength=max(140, viewport_width - round(42 * launcher_ui_scale))
) )
crash_dump_hint.configure(
wraplength=max(140, viewport_width - round(42 * launcher_ui_scale))
)
footer_note.configure( footer_note.configure(
wraplength=( wraplength=(
max(140, viewport_width - round(42 * launcher_ui_scale)) max(140, viewport_width - round(42 * launcher_ui_scale))