3 Commits

Author SHA1 Message Date
06469a0ad3 Updated to version: 1.0.5 2026-09-15 21:29:05 +02:00
f7ceba2b9b Added crash dumps class. 2026-09-15 21:23:10 +02:00
67d89b40d5 Updated telemetry to support game dumps. 2026-09-15 21:15:39 +02:00
23 changed files with 1137 additions and 135 deletions

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux # PristonTale EU for Linux
Version 0.9.11 installs, updates, repairs, and launches PristonTale EU through a Version 1.0.5 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not native Linux dashboard. Game files come directly from the publisher and are not
included in this archive. included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text ```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \ openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v0-9-11.tar.gz.sig \ -signature pristontale-eu-linux-installer-v1-0-5.tar.gz.sig \
pristontale-eu-linux-installer-v0-9-11.tar.gz pristontale-eu-linux-installer-v1-0-5.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-9-11.tar.gz.sha256 sha256sum -c pristontale-eu-linux-installer-v1-0-5.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-9-11.tar.gz tar -xzf pristontale-eu-linux-installer-v1-0-5.tar.gz
cd pristontale-eu-linux-installer-v0-9-11 cd pristontale-eu-linux-installer-v1-0-5
./install.sh ./install.sh
``` ```

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "PTEU-Linux-Launcher" name = "PTEU-Linux-Launcher"
version = "0.9.11" version = "1.0.5"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness" description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"] __all__ = ["AppPaths", "__version__"]
__version__ = "0.9.11" __version__ = "1.0.5"

View File

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

View File

@@ -0,0 +1,291 @@
"""Bounded, dependency-free crash signatures for Windows minidumps."""
from __future__ import annotations
import base64
from dataclasses import dataclass
import gzip
import hashlib
from pathlib import Path, PureWindowsPath
import struct
_MINIDUMP_SIGNATURE = b"MDMP"
_HEADER_SIZE = 32
_DIRECTORY_ENTRY_SIZE = 12
_MODULE_ENTRY_SIZE = 108
_THREAD_ENTRY_SIZE = 48
_EXCEPTION_STREAM_TYPE = 6
_MODULE_LIST_STREAM_TYPE = 4
_SYSTEM_INFO_STREAM_TYPE = 7
_THREAD_LIST_STREAM_TYPE = 3
_MAX_DUMP_BYTES = 32 * 1024 * 1024
_MAX_STREAMS = 256
_MAX_MODULES = 4096
_MAX_THREADS = 4096
_MAX_STACK_BYTES = 64 * 1024
_MAX_STACK_SIGNATURES = 12
MAX_CRASH_DUMP_UPLOAD_BYTES = 2 * 1024 * 1024
MAX_CRASH_DUMP_COMPRESSED_BYTES = 30 * 1024
MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS = 40 * 1024
_ARCHITECTURE_NAMES = {
0: "x86",
5: "arm",
6: "ia64",
9: "x86_64",
12: "arm64",
}
_EXCEPTION_NAMES = {
0x80000003: "breakpoint",
0xC0000005: "access_violation",
0xC0000017: "no_memory",
0xC0000094: "integer_divide_by_zero",
0xC00000FD: "stack_overflow",
0xC0000409: "stack_buffer_overrun",
0xE06D7363: "msvc_cpp_exception",
}
@dataclass(frozen=True)
class _Module:
base: int
size: int
name: str
system: bool
def inspect_minidump(path: Path) -> dict[str, object] | None:
"""Return a privacy-safe crash signature, or ``None`` for an unusable dump.
The signature keeps stable exception and module offsets without absolute paths
or memory addresses. Full dump attachment is handled by the separate opt-in path.
"""
try:
with path.open("rb") as handle:
data = handle.read(_MAX_DUMP_BYTES + 1)
except OSError:
return None
if len(data) < _HEADER_SIZE or len(data) > _MAX_DUMP_BYTES:
return None
try:
return _inspect_minidump_bytes(data)
except (OverflowError, struct.error, UnicodeError, ValueError):
return None
def prepare_minidump_attachment(
path: Path,
) -> tuple[str, dict[str, object] | None]:
"""Compress one explicitly approved dump for bounded telemetry delivery."""
try:
with path.open("rb") as handle:
data = handle.read(MAX_CRASH_DUMP_UPLOAD_BYTES + 1)
except OSError:
return "unreadable", None
if len(data) > MAX_CRASH_DUMP_UPLOAD_BYTES:
return "source_too_large", None
try:
if _inspect_minidump_bytes(data) is None:
return "invalid_minidump", None
except (OverflowError, struct.error, UnicodeError, ValueError):
return "invalid_minidump", None
compressed = gzip.compress(data, compresslevel=9, mtime=0)
if len(compressed) > MAX_CRASH_DUMP_COMPRESSED_BYTES:
return "compressed_too_large", None
encoded = base64.urlsafe_b64encode(compressed).rstrip(b"=").decode("ascii")
if len(encoded) > MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS:
return "encoded_too_large", None
return (
"included",
{
"schema": 1,
"kind": "windows_minidump",
"encoding": "gzip+base64url",
"original_size": len(data),
"compressed_size": len(compressed),
"sha256": hashlib.sha256(data).hexdigest(),
"data_base64url": encoded,
},
)
def _inspect_minidump_bytes(data: bytes) -> dict[str, object] | None:
if len(data) < _HEADER_SIZE or data[:4] != _MINIDUMP_SIGNATURE:
return None
_stream_count, directory_rva = struct.unpack_from("<II", data, 8)
if _stream_count > _MAX_STREAMS:
return None
_require_range(data, directory_rva, _stream_count * _DIRECTORY_ENTRY_SIZE)
streams: dict[int, tuple[int, int]] = {}
for index in range(_stream_count):
offset = directory_rva + index * _DIRECTORY_ENTRY_SIZE
stream_type, size, rva = struct.unpack_from("<III", data, offset)
_require_range(data, rva, size)
streams.setdefault(stream_type, (rva, size))
exception_stream = streams.get(_EXCEPTION_STREAM_TYPE)
if exception_stream is None or exception_stream[1] < 40:
return None
exception_rva, _exception_size = exception_stream
thread_id = _read_u32(data, exception_rva)
exception_code = _read_u32(data, exception_rva + 8)
exception_flags = _read_u32(data, exception_rva + 12)
exception_address = _read_u64(data, exception_rva + 24)
modules = _read_modules(data, streams.get(_MODULE_LIST_STREAM_TYPE))
exception_module = _module_for_address(modules, exception_address)
architecture = _read_architecture(data, streams.get(_SYSTEM_INFO_STREAM_TYPE))
result: dict[str, object] = {
"format": "minidump",
"architecture": architecture,
"exception_code": f"0x{exception_code:08x}",
"exception_kind": _EXCEPTION_NAMES.get(exception_code, "unknown"),
"exception_flags": f"0x{exception_flags:08x}",
"module_count": len(modules),
}
if exception_module is not None:
result.update(
{
"exception_module": exception_module.name,
"exception_module_offset": (
f"0x{exception_address - exception_module.base:x}"
),
}
)
stack = _read_exception_stack(
data,
streams.get(_THREAD_LIST_STREAM_TYPE),
thread_id,
)
if stack is not None:
pointer_size = 8 if architecture in {"x86_64", "arm64", "ia64"} else 4
signatures = _application_stack_signatures(stack, pointer_size, modules)
if signatures:
result["application_stack"] = signatures
return result
def _read_modules(
data: bytes,
stream: tuple[int, int] | None,
) -> tuple[_Module, ...]:
if stream is None or stream[1] < 4:
return ()
rva, size = stream
count = _read_u32(data, rva)
if count > _MAX_MODULES or 4 + count * _MODULE_ENTRY_SIZE > size:
raise ValueError("invalid minidump module list")
modules: list[_Module] = []
for index in range(count):
offset = rva + 4 + index * _MODULE_ENTRY_SIZE
base = _read_u64(data, offset)
image_size = _read_u32(data, offset + 8)
name_rva = _read_u32(data, offset + 20)
module_path = _read_utf16_string(data, name_rva)
name = PureWindowsPath(module_path).name or "unknown"
folded_path = module_path.replace("/", "\\").casefold()
modules.append(
_Module(
base=base,
size=image_size,
name=name,
system="\\windows\\" in folded_path,
)
)
return tuple(modules)
def _read_architecture(
data: bytes,
stream: tuple[int, int] | None,
) -> str:
if stream is None or stream[1] < 2:
return "unknown"
architecture = struct.unpack_from("<H", data, stream[0])[0]
return _ARCHITECTURE_NAMES.get(architecture, f"architecture_{architecture}")
def _read_exception_stack(
data: bytes,
stream: tuple[int, int] | None,
thread_id: int,
) -> bytes | None:
if stream is None or stream[1] < 4:
return None
rva, size = stream
count = _read_u32(data, rva)
if count > _MAX_THREADS or 4 + count * _THREAD_ENTRY_SIZE > size:
raise ValueError("invalid minidump thread list")
for index in range(count):
offset = rva + 4 + index * _THREAD_ENTRY_SIZE
if _read_u32(data, offset) != thread_id:
continue
stack_size = min(_read_u32(data, offset + 32), _MAX_STACK_BYTES)
stack_rva = _read_u32(data, offset + 36)
_require_range(data, stack_rva, stack_size)
return data[stack_rva : stack_rva + stack_size]
return None
def _application_stack_signatures(
stack: bytes,
pointer_size: int,
modules: tuple[_Module, ...],
) -> list[dict[str, str]]:
result: list[dict[str, str]] = []
seen: set[tuple[str, int]] = set()
format_code = "<Q" if pointer_size == 8 else "<I"
for offset in range(0, len(stack) - pointer_size + 1, pointer_size):
address = struct.unpack_from(format_code, stack, offset)[0]
module = _module_for_address(modules, address)
if module is None or module.system:
continue
module_offset = address - module.base
key = (module.name.casefold(), module_offset)
if key in seen:
continue
seen.add(key)
result.append(
{
"module": module.name,
"offset": f"0x{module_offset:x}",
}
)
if len(result) >= _MAX_STACK_SIGNATURES:
break
return result
def _module_for_address(
modules: tuple[_Module, ...],
address: int,
) -> _Module | None:
for module in modules:
if module.base <= address < module.base + module.size:
return module
return None
def _read_utf16_string(data: bytes, rva: int) -> str:
byte_length = _read_u32(data, rva)
if byte_length > 64 * 1024 or byte_length % 2:
raise ValueError("invalid minidump string")
_require_range(data, rva + 4, byte_length)
return data[rva + 4 : rva + 4 + byte_length].decode("utf-16-le")
def _read_u32(data: bytes, offset: int) -> int:
_require_range(data, offset, 4)
return struct.unpack_from("<I", data, offset)[0]
def _read_u64(data: bytes, offset: int) -> int:
_require_range(data, offset, 8)
return struct.unpack_from("<Q", data, offset)[0]
def _require_range(data: bytes, offset: int, size: int) -> None:
if offset < 0 or size < 0 or offset > len(data) or size > len(data) - offset:
raise ValueError("minidump range is outside the file")

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,16 +2,16 @@
"application": "PristonTale-EU", "application": "PristonTale-EU",
"archive": { "archive": {
"layout": "flat", "layout": "flat",
"name": "PristonTale-EU-1.0.2.tar.gz", "name": "PristonTale-EU-1.0.5.tar.gz",
"sha256": "1ade5b0c864e5644023ad30cb9fd14f46c85c55d68ed81d38e344e19023342b0", "sha256": "31414eefc52325a532ded83166d083c4d8d4a6699bfa92d619197b879e6d6578",
"size": 1539982, "size": 1544157,
"url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.2/PristonTale-EU-1.0.2.tar.gz?sha256=1ade5b0c864e5644023ad30cb9fd14f46c85c55d68ed81d38e344e19023342b0" "url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.5/PristonTale-EU-1.0.5.tar.gz?sha256=31414eefc52325a532ded83166d083c4d8d4a6699bfa92d619197b879e6d6578"
}, },
"archive_sha256": "1ade5b0c864e5644023ad30cb9fd14f46c85c55d68ed81d38e344e19023342b0", "archive_sha256": "31414eefc52325a532ded83166d083c4d8d4a6699bfa92d619197b879e6d6578",
"archive_size": 1539982, "archive_size": 1544157,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.2/PristonTale-EU-1.0.2.tar.gz?sha256=1ade5b0c864e5644023ad30cb9fd14f46c85c55d68ed81d38e344e19023342b0", "archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.5/PristonTale-EU-1.0.5.tar.gz?sha256=31414eefc52325a532ded83166d083c4d8d4a6699bfa92d619197b879e6d6578",
"channel": "stable", "channel": "stable",
"commit": "17e3f21e87ea14ff346a3150be066b6eab7f3978", "commit": "67d89b40d54f1bf6d1886cd2cdbd35b0fa8b4324",
"compatibility": { "compatibility": {
"config_schema": 1, "config_schema": 1,
"manifest_schema": 1, "manifest_schema": 1,
@@ -36,7 +36,7 @@
}, },
{ {
"path": "ptlinux/__init__.py", "path": "ptlinux/__init__.py",
"sha256": "76fee8162979631fb5a34824ea0ff965c6710bc8389d4b4b2b87ac16ed40c9fb", "sha256": "012bb02392f8b6441d2f7f53b71b5736d70495409ee01d52ff1677dd68931cea",
"size": 151 "size": 151
}, },
{ {
@@ -71,8 +71,8 @@
}, },
{ {
"path": "ptlinux/application/commands/play.py", "path": "ptlinux/application/commands/play.py",
"sha256": "18c5c3f67ce8f769402993af28158c518abad447bb6e20be553d551545faac74", "sha256": "7591103e801972398c2d1bd683518b9149c0903b3f6bd3de79c185f66c0b42f6",
"size": 30733 "size": 34941
}, },
{ {
"path": "ptlinux/application/commands/settings.py", "path": "ptlinux/application/commands/settings.py",
@@ -124,6 +124,11 @@
"sha256": "ba51d92dde5c2c1b544100e71d03a0f4381162ce30731c97e7b3d2d26910c4b6", "sha256": "ba51d92dde5c2c1b544100e71d03a0f4381162ce30731c97e7b3d2d26910c4b6",
"size": 75 "size": 75
}, },
{
"path": "ptlinux/diagnostics/crash_dumps.py",
"sha256": "fba6ce4110128d2e1d551e2ad720319e9e5f2b90740707b553a293c00c1d1ff6",
"size": 8611
},
{ {
"path": "ptlinux/diagnostics/native_logs.py", "path": "ptlinux/diagnostics/native_logs.py",
"sha256": "3c07a45e89f69b2607288d7fd98f20e0679ee35599de03a3d4e3838ef93e91ba", "sha256": "3c07a45e89f69b2607288d7fd98f20e0679ee35599de03a3d4e3838ef93e91ba",
@@ -201,8 +206,8 @@
}, },
{ {
"path": "ptlinux/game/runtime.py", "path": "ptlinux/game/runtime.py",
"sha256": "082613fa2af0bd7dad892f02c9b3a162fcc09725bc31f039db27d155dccd30a8", "sha256": "461c2c5d1774fd3d6f998dfc981ba7b6df59a95414dd66f26e1a74b67f5af8b0",
"size": 31787 "size": 32162
}, },
{ {
"path": "ptlinux/game/runtime_view.py", "path": "ptlinux/game/runtime_view.py",
@@ -431,8 +436,8 @@
}, },
{ {
"path": "ptlinux/settings.py", "path": "ptlinux/settings.py",
"sha256": "ecfc88c666669607fcc21c269515fb90034748bcf3be81ccfea4463f2a3e9f28", "sha256": "af7b1de17c69d499a3a91e22f2f2e2e6035ea79131cfc04800656468b762c870",
"size": 10212 "size": 10363
}, },
{ {
"path": "ptlinux/system_identity.py", "path": "ptlinux/system_identity.py",
@@ -496,8 +501,8 @@
}, },
{ {
"path": "ptlinux/telemetry/service.py", "path": "ptlinux/telemetry/service.py",
"sha256": "8a2eff8c3199ff96b89d9f839e9e5286c78bad2c7eab5ef3690e1cd0b548a072", "sha256": "32404f39a5a85aab87794c17baa193a17b204ffe168080af1e5a7e6b9601777e",
"size": 29855 "size": 33173
}, },
{ {
"path": "ptlinux/telemetry/transport.py", "path": "ptlinux/telemetry/transport.py",
@@ -641,8 +646,8 @@
}, },
{ {
"path": "ptlinux/ui/dialogs/settings_dialog.py", "path": "ptlinux/ui/dialogs/settings_dialog.py",
"sha256": "3a78c155a388f38c3d2e358a648057f6d76161fd7f6690d10e711d5d7ca96d6e", "sha256": "700e514977c584cdb8b499491700a0689e766b28fb3f3d1b0a5b7c719051ca0e",
"size": 23733 "size": 24739
}, },
{ {
"path": "ptlinux/ui/viewmodels/__init__.py", "path": "ptlinux/ui/viewmodels/__init__.py",
@@ -691,21 +696,21 @@
}, },
{ {
"path": "release.json", "path": "release.json",
"sha256": "3fa3f47ce6291b441be5aebc7025b7e33f738fe37747e95007a6dc9c64a2e40c", "sha256": "3cf9be35e4a0db075d79956b11f8d21c9ae4c8be5169690300bae335df6e7d01",
"size": 218 "size": 218
} }
], ],
"minimum_updater_version": "0.9.9", "minimum_updater_version": "0.9.9",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.2", "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.5",
"published_at": "2026-09-12T09:32:46Z", "published_at": "2026-09-15T19:15:39Z",
"release": { "release": {
"notes_markdown": "# PristonTale EU Launcher 1.0.2\n\n## Changes\n\n- Reworked some game window and gamescope methods for Cinnamon. (`17e3f21`)\n- Added more folders and restructured classes inside them. (`2009afe`)\n- Reworked the project structure. (`775c987`)\n- Add channel-aware release tooling and updater recovery (`0a89323`)\n- Publish signed launcher release history (`cc48bf4`)\n- Upload files to \"updates\" (`84f8de5`)\n- Delete updates/stable.json.sig (`1329485`)\n- Delete updates/stable.json (`8f1655b`)\n- Fix v0.9.11 archive download cache (`be71d31`)\n- Upload files to \"updates\" (`46e11ae`)\n- Delete updates/stable.json.sig (`eeb993e`)\n- Delete updates/stable.json (`a90bd19`)\n\n---\n\nVersion: `1.0.2` \nChannel: `stable` \nTag: `v1.0.2` \nSource commit: `17e3f21e87ea14ff346a3150be066b6eab7f3978`\n", "notes_markdown": "# PristonTale EU Launcher 1.0.5\n\nAdds opt-in, bounded game crash-dump attachments for telemetry, improves game-exit diagnostics and Proton 7 crash classification, and packages the current launcher, runtime compatibility, and native helper updates.\n\n## Changes\n\n- Updated telemetry to support game dumps. (`67d89b4`)\n- Released version 1.0.2 (`c278efa`)\n- Reworked some game window and gamescope methods for Cinnamon. (`f3059c3`)\n- Added more folders and restructured classes inside them. (`2cbb931`)\n- Reworked the project structure. (`be7a478`)\n- Delete RELEASE_PIPELINE_WORKPLAN.md (`15741f5`)\n- Add channel-aware release tooling and updater recovery (`0a89323`)\n- Publish signed launcher release history (`cc48bf4`)\n- Upload files to \"updates\" (`84f8de5`)\n- Delete updates/stable.json.sig (`1329485`)\n- Delete updates/stable.json (`8f1655b`)\n- Fix v0.9.11 archive download cache (`be71d31`)\n- Upload files to \"updates\" (`46e11ae`)\n- Delete updates/stable.json.sig (`eeb993e`)\n- Delete updates/stable.json (`a90bd19`)\n\n---\n\nVersion: `1.0.5` \nChannel: `stable` \nTag: `v1.0.5` \nSource commit: `67d89b40d54f1bf6d1886cd2cdbd35b0fa8b4324`\n",
"release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.2", "release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.5",
"summary": "Reworked some game window and gamescope methods for Cinnamon. (`17e3f21`)", "summary": "Adds opt-in, bounded game crash-dump attachments for telemetry, improves game-exit diagnostics and Proton 7 crash classification, and packages the current launcher, runtime compatibility, and native helper updates.",
"title": "PristonTale EU Launcher 1.0.2" "title": "PristonTale EU Launcher 1.0.5"
}, },
"schema": 1, "schema": 1,
"status": "active", "status": "active",
"tag": "v1.0.2", "tag": "v1.0.5",
"version": "1.0.2" "version": "1.0.5"
} }

Binary file not shown.