diff --git a/scripts/build_release.py b/scripts/build_release.py index 4315f9a..0016eb0 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -8,10 +8,12 @@ import os from pathlib import Path import sys -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +if __package__: + from .repository_paths import ensure_project_imports +else: + from repository_paths import ensure_project_imports +ensure_project_imports() from scripts.release_lib import ReleaseToolError, build_release # noqa: E402 diff --git a/scripts/generate-manifest.py b/scripts/generate-manifest.py index e42add9..ec72b5b 100755 --- a/scripts/generate-manifest.py +++ b/scripts/generate-manifest.py @@ -7,10 +7,12 @@ import argparse from pathlib import Path import sys -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +if __package__: + from .repository_paths import ensure_project_imports +else: + from repository_paths import ensure_project_imports +ensure_project_imports() from scripts.release_lib import ( # noqa: E402 ReleaseToolError, generate_manifest, diff --git a/scripts/generate_history.py b/scripts/generate_history.py index aaa1d4b..beb8e1b 100755 --- a/scripts/generate_history.py +++ b/scripts/generate_history.py @@ -11,10 +11,12 @@ import sys from typing import Mapping from urllib.parse import urlsplit -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +if __package__: + from .repository_paths import PROJECT_ROOT, ensure_project_imports +else: + from repository_paths import PROJECT_ROOT, ensure_project_imports +ensure_project_imports() from scripts.release_lib import ( # noqa: E402 ReleaseToolError, _parse_timestamp, diff --git a/scripts/prepare_channel_metadata.py b/scripts/prepare_channel_metadata.py index 489a95c..c421380 100644 --- a/scripts/prepare_channel_metadata.py +++ b/scripts/prepare_channel_metadata.py @@ -9,10 +9,12 @@ import os from pathlib import Path import sys -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +if __package__: + from .repository_paths import ensure_project_imports +else: + from repository_paths import ensure_project_imports +ensure_project_imports() from ptlinux.self_update import LauncherUpdateError, parse_update_manifest # noqa: E402 from ptlinux.update_resolver import ( # noqa: E402 ReleaseModelError, diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh index 5cf9a5e..f1e834a 100755 --- a/scripts/publish-gitea-release.sh +++ b/scripts/publish-gitea-release.sh @@ -38,7 +38,7 @@ cleanup() trap cleanup EXIT 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" \ || "$(jq -r '.channel' <<< "$RELEASE_MODEL")" != "$CHANNEL" \ diff --git a/scripts/release.sh b/scripts/release.sh index fd7b3b5..f3b3905 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -7,8 +7,8 @@ usage() cat <<'EOF' Usage: ./scripts/release.sh CHANNEL VERSION [OPTIONS] -Build a verified local release package in dist/vVERSION/. This command does not -commit, tag, push, create a Gitea release, or upload files. +Build a verified local release package in .artifacts/releases/vVERSION/. This +command does not commit, tag, push, create a Gitea release, or upload files. Channels: stable @@ -22,7 +22,8 @@ Options: --editor Edit the final notes with ${EDITOR:-nano} --dry-run Build and verify in a temporary directory only --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 EOF } @@ -68,6 +69,7 @@ OPEN_EDITOR=0 DRY_RUN=0 SKIP_TESTS=0 SIGNING_KEY="${PTLINUX_SIGNING_KEY:-}" +REQUESTED_OUTPUT_DIRECTORY="" while [[ $# -gt 0 ]] do @@ -107,6 +109,11 @@ do SIGNING_KEY="$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) usage exit 0 @@ -145,6 +152,7 @@ then exit 1 fi cd -- "$PROJECT_ROOT" +export PYTHONPATH="${PROJECT_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}" "$PYTHON_BIN" -m ptlinux.update_resolver validate "$CHANNEL" "$VERSION" TAG="v${VERSION}" @@ -171,6 +179,17 @@ cleanup() } trap cleanup EXIT 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 ]] then @@ -237,14 +256,33 @@ printf '%s\n' '--------------------------------------------------' if [[ "$CHANGELOG_ONLY" -eq 1 ]] then - NOTES_OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}" - mkdir -p -- "$NOTES_OUTPUT_DIRECTORY" - cp -- "$NOTES_PATH" "${NOTES_OUTPUT_DIRECTORY}/release-notes.md" + mkdir -p -- "$OUTPUT_DIRECTORY" + cp -- "$NOTES_PATH" "${OUTPUT_DIRECTORY}/release-notes.md" printf 'Release notes written to %s\n' \ - "${NOTES_OUTPUT_DIRECTORY}/release-notes.md" + "${OUTPUT_DIRECTORY}/release-notes.md" exit 0 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 ]] then mapfile -t PREFLIGHT_COMMANDS < <( @@ -261,11 +299,6 @@ then else printf '%s\n' 'Skipping local preflight checks by request.' fi - -if [[ -z "$SIGNING_KEY" ]] -then - SIGNING_KEY="${PROJECT_ROOT}/release-keys/launcher-update-private.pem" -fi if [[ ! -f "$SIGNING_KEY" || -L "$SIGNING_KEY" ]] then printf 'Release signing key is missing: %s\n' "$SIGNING_KEY" >&2 @@ -273,13 +306,6 @@ then exit 1 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" \ RELEASE_COMMIT="$COMMIT" \ RELEASE_TAG="$TAG" \ @@ -297,7 +323,7 @@ REQUIRE_RELEASE_SIGNATURE=1 \ if [[ "$DRY_RUN" -eq 1 ]] then 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 fi diff --git a/scripts/release_lib.py b/scripts/release_lib.py index 35e73b7..6c937ba 100644 --- a/scripts/release_lib.py +++ b/scripts/release_lib.py @@ -7,6 +7,7 @@ from datetime import datetime, timezone import fnmatch import gzip import hashlib +import io import json import os from pathlib import Path, PurePosixPath @@ -20,10 +21,14 @@ import tempfile from typing import BinaryIO, Mapping, Sequence 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] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +ensure_project_imports() CONFIG_PATH = PROJECT_ROOT / "release/config.json" READ_SIZE = 256 * 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]+)?)"$', 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): @@ -202,8 +210,11 @@ def build_release( _validate_repository_url(repository) release_notes = _read_release_notes(release_notes_file) output = _safe_output_directory(output_directory) - if signing_key is not None and signing_key.expanduser().resolve().is_relative_to(output): - raise ReleaseToolError("the private signing key cannot be stored in release output") + if signing_key is not None: + signing_key = _safe_signing_key_path( + signing_key, + output_directory=output, + ) _clean_output_directory(output) archive_prefix = _required_string(config, "archive_prefix") archive_name = f"{archive_prefix}-{version}.tar.gz" @@ -264,7 +275,10 @@ def build_release( verify_signature( manifest, signature, - PROJECT_ROOT / "ptlinux/launcher-update-public.pem", + _payload_source_for_destination( + config, + PurePosixPath("ptlinux/launcher-update-public.pem"), + ), ) artifacts.append(signature) elif require_signature: @@ -272,6 +286,112 @@ def build_release( 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( destination: Path, config: Mapping[str, object], @@ -307,6 +427,38 @@ def stage_release( payload.append( 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() for item in sorted(payload, key=lambda entry: entry.relative_path.as_posix()): if item.relative_path in seen: @@ -324,12 +476,27 @@ def stage_release( for directory in (destination, *destination.rglob("*")): if directory.is_dir(): 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"): + if required in missing_allowed: + continue if not (destination / required).is_file(): 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") if not isinstance(payload_config, dict): 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): raise ReleaseToolError("payload python tree must be an object") source_name = _required_string(tree, "source") - source_root = PROJECT_ROOT / source_name - if not source_root.is_dir() or source_root.is_symlink(): + source_relative = _safe_relative_path(source_name) + 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}") exclusions = set(_string_list(tree.get("exclude", []), "payload exclude")) - for source in sorted(source_root.rglob("*.py")): - relative_in_tree = source.relative_to(source_root).as_posix() + for source in sorted(tree_root.rglob("*.py")): + relative_in_tree = source.relative_to(tree_root).as_posix() if relative_in_tree in exclusions: continue _validate_source_file(source) result.append( PayloadFile( - PurePosixPath(source_name) / PurePosixPath(relative_in_tree), + destination_relative / PurePosixPath(relative_in_tree), source, None, 0o644, @@ -364,9 +540,27 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]: for file_config in configured_files: if not isinstance(file_config, dict): raise ReleaseToolError("payload file entry must be an object") - path_text = _required_string(file_config, "path") - relative = _safe_relative_path(path_text) - source = PROJECT_ROOT.joinpath(*relative.parts) + 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") + source_relative = _safe_relative_path(path_text) + 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) mode_text = _required_string(file_config, "mode") 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 if mode not in {0o644, 0o755}: 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 +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( destination: Path, *, @@ -543,7 +756,7 @@ def verify_release( if not (extracted / required).is_file(): raise ReleaseToolError(f"release archive is missing required file: {required}") packaged_version = _version_from_source_file( - extracted / _required_string(config, "version_source") + extracted / _packaged_version_path(config) ) if packaged_version != version: raise ReleaseToolError( @@ -568,16 +781,57 @@ def verify_release( verify_signature( manifest_path, 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: 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: - key = private_key.expanduser().resolve() - if not key.is_file() or key.is_symlink(): - raise ReleaseToolError(f"manifest signing key is missing: {key}") + key = _safe_signing_key_path(private_key) openssl = shutil.which("openssl") if openssl is None: raise ReleaseToolError("OpenSSL is required to sign release manifests") @@ -730,6 +984,65 @@ def _write_deterministic_archive( 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: information = tarfile.TarInfo(name) information.uid = 0 @@ -1005,12 +1318,13 @@ def _safe_output_directory(path: Path) -> Path: if expanded.is_symlink(): raise ReleaseToolError(f"output directory cannot be a symbolic link: {expanded}") resolved = expanded.resolve() - project_dist_path = PROJECT_ROOT / "dist" - if project_dist_path.is_symlink(): + project_release_path = RELEASE_ARTIFACTS_ROOT + if project_release_path.is_symlink(): 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 = ( Path(tempfile.gettempdir()).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: 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 + 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): return resolved 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]: try: value = json.loads( @@ -1166,7 +1499,7 @@ def _set_staged_source_version( config: Mapping[str, object], version: str, ) -> None: - path = staging / _required_string(config, "version_source") + path = staging / _packaged_version_path(config) try: contents = path.read_text(encoding="utf-8") except (OSError, UnicodeError) as exc: @@ -1212,6 +1545,17 @@ def _write_atomic(path: Path, data: bytes) -> None: 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: try: result = subprocess.run( diff --git a/scripts/repository_paths.py b/scripts/repository_paths.py new file mode 100644 index 0000000..af634dd --- /dev/null +++ b/scripts/repository_paths.py @@ -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)) diff --git a/scripts/set_version.py b/scripts/set_version.py index b697a23..1209a88 100755 --- a/scripts/set_version.py +++ b/scripts/set_version.py @@ -9,10 +9,12 @@ from pathlib import Path import re import sys -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +if __package__: + from .repository_paths import PROJECT_ROOT, ensure_project_imports +else: + from repository_paths import PROJECT_ROOT, ensure_project_imports +ensure_project_imports() from scripts.release_lib import ReleaseToolError, load_config # noqa: E402 from ptlinux.update_resolver import SemanticVersion diff --git a/scripts/sign_metadata.py b/scripts/sign_metadata.py index 6c4685e..89e9b3e 100755 --- a/scripts/sign_metadata.py +++ b/scripts/sign_metadata.py @@ -7,10 +7,12 @@ import argparse from pathlib import Path import sys -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +if __package__: + from .repository_paths import SOURCE_ROOT, ensure_project_imports +else: + from repository_paths import SOURCE_ROOT, ensure_project_imports +ensure_project_imports() from scripts.release_lib import ( # noqa: E402 ReleaseToolError, sign_file, @@ -26,7 +28,7 @@ def main() -> int: parser.add_argument( "--public-key", type=Path, - default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem", + default=SOURCE_ROOT / "ptlinux/launcher-update-public.pem", ) arguments = parser.parse_args() try: diff --git a/scripts/verify_metadata.py b/scripts/verify_metadata.py index 1a8b950..6ea597b 100644 --- a/scripts/verify_metadata.py +++ b/scripts/verify_metadata.py @@ -7,10 +7,12 @@ import argparse from pathlib import Path import sys -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +if __package__: + from .repository_paths import SOURCE_ROOT, ensure_project_imports +else: + from repository_paths import SOURCE_ROOT, ensure_project_imports +ensure_project_imports() from scripts.release_lib import ReleaseToolError, verify_signature # noqa: E402 @@ -21,7 +23,7 @@ def main() -> int: parser.add_argument( "--public-key", type=Path, - default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem", + default=SOURCE_ROOT / "ptlinux/launcher-update-public.pem", ) arguments = parser.parse_args() try: diff --git a/scripts/verify_release.py b/scripts/verify_release.py index b193e1f..060c378 100755 --- a/scripts/verify_release.py +++ b/scripts/verify_release.py @@ -7,10 +7,12 @@ import argparse from pathlib import Path import sys -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) +if __package__: + from .repository_paths import ensure_project_imports +else: + from repository_paths import ensure_project_imports +ensure_project_imports() from scripts.release_lib import ReleaseToolError, verify_release # noqa: E402 diff --git a/src/ptlinux/application/commands/play.py b/src/ptlinux/application/commands/play.py index 2683593..10bdd27 100644 --- a/src/ptlinux/application/commands/play.py +++ b/src/ptlinux/application/commands/play.py @@ -15,6 +15,10 @@ from typing import Any from uuid import uuid4 from ...command import CommandOutcome +from ...diagnostics.crash_dumps import ( + inspect_minidump, + prepare_minidump_attachment, +) from ...diagnostics.native_logs import LogFileState from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm from ...launcher import LauncherError @@ -383,15 +387,12 @@ def play_locked( 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" + recommended_comparison = recommended_runtime_comparison( + selection.kind, + runtime_issues, + ) + if recommended_comparison is not None: + runtime_issues["recommended_one_variable_comparison"] = recommended_comparison 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: @@ -439,6 +440,38 @@ def play_locked( "realm": start_option.name, **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 not launch_success_reported: assert launch_evidence is not None @@ -461,7 +494,7 @@ def play_locked( "game_launch_failed", severity="error", message=( - "Game client wrote a crash dump" + game_crash_message(current_game_log) if current_game_crash is not None else "Game session timed out" if outcome.timed_out @@ -469,23 +502,28 @@ def play_locked( ), metadata={ **common_game_metadata, + **failure_evidence, "runtime_issues": runtime_issues, "stdout": outcome.stdout, "stderr": outcome.stderr, }, context={"operation": "launch_game", "stage": "game_start"}, + attachment=crash_dump_attachment, ) if launch_succeeded: exit_message = 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: exit_message = "Game session timed out" elif common_game_metadata.get("signal_inferred") is True: + signal_wrapper = ( + "Proton" if selection.kind == "proton7" else "UMU" + ) 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" + f"{common_game_metadata['signal_name']} wrapped by {signal_wrapper}" ) elif "signal" in common_game_metadata: exit_message = ( @@ -507,12 +545,14 @@ def play_locked( if clean_exit else { **common_game_metadata, + **failure_evidence, "runtime_issues": runtime_issues, "stdout": outcome.stdout, "stderr": outcome.stderr, } ), context={"operation": "launch_game", "stage": "exited"}, + attachment=crash_dump_attachment, ) if as_json or dry_run: api._emit(report, as_json) @@ -553,10 +593,15 @@ def game_exit_metadata( ) 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. + # UMU and the retained Proton 7 Python entry point return a negative child + # status from subprocess. 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: + if ( + runtime_kind in {"umu", "proton7", "proton7-container"} + and returncode == wrapped_sigkill + ): + wrapper = "proton" if runtime_kind == "proton7" else "umu" metadata.update( { "exit_code": 128 + int(signal.SIGKILL), @@ -564,12 +609,65 @@ def game_exit_metadata( "signal": int(signal.SIGKILL), "signal_name": signal.SIGKILL.name, "signal_inferred": True, - "signal_inference": "umu_python_system_exit", + "signal_inference": f"{wrapper}_python_system_exit", } ) 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: try: return signal.Signals(signal_number).name @@ -654,6 +752,7 @@ def current_game_log( ], "failed_import_scenes": analysis.failed_import_scenes, "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_left": analysis.module_code_sync_left, "module_code_sync_right": analysis.module_code_sync_right, @@ -702,13 +801,59 @@ def current_game_crash( ) if state is None: return None - return { + report: dict[str, object] = { "path": str(state.path), "modified_at": datetime.fromtimestamp( state.mtime_ns / 1_000_000_000 ).astimezone().isoformat(), "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]: diff --git a/src/ptlinux/game/runtime.py b/src/ptlinux/game/runtime.py index 68ecb64..5f45f0b 100644 --- a/src/ptlinux/game/runtime.py +++ b/src/ptlinux/game/runtime.py @@ -53,6 +53,10 @@ WINETRICKS_CODEPAGE_PROBE = ( ) WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"' 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" CURRENT_UMU_DISTRIBUTIONS = frozenset( {"arch", "cachyos", "endeavouros", "garuda", "manjaro"} @@ -770,6 +774,7 @@ def _proton_environment( ) -> dict[str, str]: if not selection.steam_root: raise RuntimeErrorPT("Steam root is missing for Proton") + debug_logging = os.environ.get(PROTON_DEBUG_ENVIRONMENT_VARIABLE) == "1" environment = { "STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root, "STEAM_COMPAT_DATA_PATH": str(paths.compat_data), @@ -777,10 +782,12 @@ def _proton_environment( "SteamAppId": "0", "SteamGameId": "0", "WINEPREFIX": str(paths.proton_prefix), - "WINEDEBUG": "-all", - "PROTON_LOG": "0", + "WINEDEBUG": PROTON_DEBUG_WINE_CHANNELS if debug_logging else "-all", + "PROTON_LOG": "1" if debug_logging else "0", "DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), } + if debug_logging: + environment["PROTON_LOG_DIR"] = str(paths.logs) environment.update(_proton_sync_environment(synchronization)) return environment diff --git a/src/ptlinux/settings.py b/src/ptlinux/settings.py index ec62aa0..8bae34f 100644 --- a/src/ptlinux/settings.py +++ b/src/ptlinux/settings.py @@ -39,6 +39,7 @@ class Settings: realm: str = "Babel" ui_scale: str = "auto" telemetry_enabled: bool = True + game_crash_dump_upload_enabled: bool = False borderless: bool = False suppress_unresponsive_warnings: bool = True runtime: str = "auto" @@ -158,6 +159,11 @@ def _settings_from_object(value: dict[str, object]) -> Settings: realm=_string(value, "realm", "Babel").strip(), ui_scale=_string(value, "ui_scale", "auto"), telemetry_enabled=_boolean(value, "telemetry_enabled", True), + game_crash_dump_upload_enabled=_boolean( + value, + "game_crash_dump_upload_enabled", + False, + ), borderless=_migrated_borderless(value), allow_window_resizing=_boolean( value, diff --git a/src/ptlinux/telemetry/service.py b/src/ptlinux/telemetry/service.py index c839627..54378a6 100644 --- a/src/ptlinux/telemetry/service.py +++ b/src/ptlinux/telemetry/service.py @@ -19,6 +19,11 @@ from types import TracebackType from typing import Any 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 @@ -41,6 +46,7 @@ MAX_VALUE_DEPTH = 8 QUEUE_CAPACITY = 128 DELIVERY_TIMEOUT_SECONDS = 3.0 SHUTDOWN_WAIT_SECONDS = 0.25 +CRASH_DUMP_SHUTDOWN_WAIT_SECONDS = DELIVERY_TIMEOUT_SECONDS + 0.5 ERROR_SUPPRESSION_WINDOW_SECONDS = 60.0 MAX_ERROR_FINGERPRINTS = 128 @@ -63,6 +69,7 @@ _RESOURCE_USAGE_EVENTS = frozenset( "update_success", } ) +_CRASH_DUMP_ATTACHMENT_EVENTS = frozenset({"game_exited", "game_launch_failed"}) VALID_SEVERITIES = frozenset({"debug", "info", "warning", "error", "critical"}) VALID_EVENTS = frozenset( { @@ -254,6 +261,7 @@ class TelemetryService: self._worker: threading.Thread | None = None self._environment_worker: threading.Thread | None = None self._state_lock = threading.RLock() + self._crash_dump_attachment_enqueued = False self._repeated_errors: dict[str, _RepeatedErrorState] = {} self._previous_sys_excepthook: Any = None self._previous_threading_excepthook: Any = None @@ -372,6 +380,7 @@ class TelemetryService: message: str | None = None, metadata: Mapping[str, object] | None = None, context: Mapping[str, object] | None = None, + attachment: Mapping[str, object] | None = None, ) -> bool: """Sanitize and enqueue one event without blocking on network I/O.""" @@ -431,10 +440,29 @@ class TelemetryService: sanitized_metadata, 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) if body is None: return False 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: evicted = self._remember_repeated_error(repeated_error) if evicted is not None: @@ -663,7 +691,15 @@ class TelemetryService: if not self._active or self._closing: return 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 if isinstance(environment_worker, threading.Thread) and ( environment_worker is not threading.current_thread() @@ -931,6 +967,79 @@ class TelemetryService: 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: return self._facade._privacy_sanitize_text( value, diff --git a/src/ptlinux/ui/dialogs/settings_dialog.py b/src/ptlinux/ui/dialogs/settings_dialog.py index 7558142..7db0637 100644 --- a/src/ptlinux/ui/dialogs/settings_dialog.py +++ b/src/ptlinux/ui/dialogs/settings_dialog.py @@ -185,6 +185,9 @@ def open_settings_dialog( value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS) ) 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( value=current_settings.suppress_unresponsive_warnings ) @@ -322,6 +325,7 @@ def open_settings_dialog( allow_window_resizing=allow_window_resizing_value.get(), ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS), telemetry_enabled=telemetry_value.get(), + game_crash_dump_upload_enabled=crash_dump_upload_value.get(), suppress_unresponsive_warnings=suppress_unresponsive_value.get(), runtime=setting_value(runtime_value.get(), RUNTIME_LABELS), proton_sync=setting_value( @@ -590,7 +594,7 @@ def open_settings_dialog( ttk.Checkbutton( diagnostics_card, name="settings_telemetry_enabled", - text="Send anonymous diagnostic and usage information", + text="Send launcher diagnostic and usage information", variable=telemetry_value, style="Settings.TCheckbutton", cursor="hand2", @@ -600,14 +604,35 @@ def open_settings_dialog( diagnostics_card, text=( "Helps improve PristonTale EU Linux compatibility by sending anonymous " - "launcher errors, Linux and display versions, graphics/runtime compatibility, " - "and installation or update reliability. Game accounts, personal files, " - "hostnames, and hardware serial numbers are never collected." + "launcher errors, Linux and display versions, graphics/runtime " + "compatibility, and installation or update reliability. Routine " + "telemetry does not collect game accounts, personal files, hostnames, " + "or hardware serial numbers." ), style="SettingsHint.TLabel", wraplength=720, ) 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( content, @@ -705,6 +730,9 @@ def open_settings_dialog( diagnostics_hint.configure( 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( wraplength=( max(140, viewport_width - round(42 * launcher_ui_scale))