Updated telemetry to support game dumps.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
25
scripts/repository_paths.py
Normal file
25
scripts/repository_paths.py
Normal 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))
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user