mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
Merge branch 'Cockatrice:master' into token-decrement-fix
This commit is contained in:
commit
7b2da47dcb
32 changed files with 2863 additions and 79 deletions
|
|
@ -3,11 +3,13 @@ FROM debian:12
|
|||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
file \
|
||||
g++ \
|
||||
git \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ RUN apt-get update && \
|
|||
file \
|
||||
g++ \
|
||||
git \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
FROM fedora:43
|
||||
|
||||
RUN dnf install -y \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
gcc-c++ \
|
||||
git \
|
||||
libcurl-devel \
|
||||
mariadb-devel \
|
||||
ninja-build \
|
||||
openssl-devel \
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
FROM fedora:44
|
||||
|
||||
RUN dnf install -y \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
gcc-c++ \
|
||||
git \
|
||||
libcurl-devel \
|
||||
mariadb-devel \
|
||||
ninja-build \
|
||||
openssl-devel \
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ FROM ubuntu:24.04
|
|||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
file \
|
||||
g++ \
|
||||
git \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ RUN apt-get update && \
|
|||
g++ \
|
||||
gcovr \
|
||||
git \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
|
|
|
|||
|
|
@ -200,6 +200,20 @@ if [[ $USE_VCPKG ]]; then
|
|||
flags+=("-DUSE_VCPKG=1")
|
||||
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
|
||||
fi
|
||||
if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then
|
||||
# Release CI opts into the sentry-native SDK (default is OFF so from-source, distro and
|
||||
# offline package builds never fetch it). Debug builds stay SDK-free so crashes from
|
||||
# developer builds never reach Sentry.
|
||||
flags+=("-DWITH_CRASH_REPORTING=ON")
|
||||
fi
|
||||
if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then
|
||||
# Release-only: Debug builds stay DSN-free so crashes from developer builds
|
||||
# never reach Sentry. Empty (local builds, forks without the secret, and non-tag
|
||||
# CI builds, which never receive the secret) keeps today's mock fallback via the
|
||||
# compiled-in default. CI passes the value from the SENTRY_DSN secret on tag
|
||||
# Release builds only, so pre-release test artifacts never report to Sentry.
|
||||
flags+=("-DCOCKATRICE_SENTRY_DSN=$COCKATRICE_SENTRY_DSN")
|
||||
fi
|
||||
|
||||
# Add cmake --build flags
|
||||
buildflags=(--config "$BUILDTYPE")
|
||||
|
|
|
|||
|
|
@ -175,6 +175,12 @@ function RUN ()
|
|||
args+=(--mount "type=bind,source=$GITHUB_OUTPUT,target=/gh_output")
|
||||
args+=(--env "GITHUB_OUTPUT=/gh_output")
|
||||
fi
|
||||
# The container does not inherit host env vars: without this, WITH_CRASH_REPORTING=ON set in
|
||||
# the workflow never reaches compile.sh inside the container, so the Linux packages silently
|
||||
# build with crash reporting OFF while the libcurl packages in the Dockerfiles go unused.
|
||||
if [[ ${WITH_CRASH_REPORTING:-} ]]; then
|
||||
args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}")
|
||||
fi
|
||||
# shellcheck disable=2086
|
||||
docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@"
|
||||
return $?
|
||||
|
|
|
|||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
|
@ -60,8 +60,10 @@ jobs:
|
|||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
g++ \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
|
|
|
|||
225
.github/workflows/desktop-build.yml
vendored
225
.github/workflows/desktop-build.yml
vendored
|
|
@ -46,6 +46,11 @@ jobs:
|
|||
outputs:
|
||||
tag: ${{ steps.configure.outputs.tag }}
|
||||
sha: ${{ steps.configure.outputs.sha }}
|
||||
# MAJ.MIN.PATCH[-beta[.N]] of a tag build; empty otherwise. Mirrors what
|
||||
# cmake/getversion.cmake derives as PROJECT_VERSION, which the client bakes as its
|
||||
# Sentry release (cmake/CrashReporting.cmake) — symbol uploads must use the same
|
||||
# string so stack traces de-symbolicate against this build's binaries.
|
||||
sentry_release: ${{ steps.configure.outputs.sentry_release }}
|
||||
|
||||
steps:
|
||||
- name: "Configure"
|
||||
|
|
@ -56,6 +61,9 @@ jobs:
|
|||
run: |
|
||||
if [[ "$GITHUB_REF_TYPE" == 'tag' ]]; then # release
|
||||
echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
|
||||
# Tag format is YYYY-MM-DD-{Release|Development}-MAJ.MIN.PATCH[-beta[.N]];
|
||||
# PROJECT_VERSION is the tail after the type (see cmake/getversion.cmake).
|
||||
echo "sentry_release=$(sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}-(Release|Development)-//' <<< "$GITHUB_REF_NAME")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
|
@ -239,6 +247,12 @@ jobs:
|
|||
SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}'
|
||||
package: '${{ matrix.package }}'
|
||||
server_only: '${{ matrix.server_only }}'
|
||||
WITH_CRASH_REPORTING: 'ON'
|
||||
# Tag builds only: artifacts from master pushes and PR branches must not report to
|
||||
# Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way.
|
||||
# configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN
|
||||
# and the symbol upload below just like full releases.
|
||||
COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }}
|
||||
run: |
|
||||
source .ci/docker.sh
|
||||
args=()
|
||||
|
|
@ -247,9 +261,58 @@ jobs:
|
|||
args+=(--ccache "$CCACHE_SIZE")
|
||||
args+=(--cmake-generator "$CMAKE_GENERATOR")
|
||||
args+=(--suffix "$SUFFIX")
|
||||
# The container inherits no environment, so the DSN rides in explicitly. -e without
|
||||
# =value passes it through (possibly empty); compile.sh only consumes it for Release
|
||||
# configures, and empty keeps the mock fallback.
|
||||
export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN"
|
||||
|
||||
RUN --server --release --package "$package" "${args[@]}"
|
||||
|
||||
- name: "Install sentry-cli"
|
||||
if: matrix.package != 'skip' && needs.configure.outputs.tag != null
|
||||
# Downloads from Sentry's release registry: a Sentry outage or a broken install under
|
||||
# Git Bash must not take down the release assets below.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_CLI_VERSION: '3.8.0'
|
||||
run: |
|
||||
export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli"
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH"
|
||||
|
||||
- name: "Upload debug symbols to Sentry"
|
||||
# Like the DSN above, this runs for every tag — beta (Development) tags included.
|
||||
if: matrix.package != 'skip' && needs.configure.outputs.tag != null
|
||||
# A Sentry outage or rotated token must not take down the release assets below.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: |
|
||||
paths=()
|
||||
# The binaries only, not the directories: a directory makes sentry-cli walk the
|
||||
# whole build tree, where it finds (and uploads) object files and other fragments
|
||||
# that can never match a stack frame. The packaged copy is split and stripped by
|
||||
# cpack, but keeps this binary's GNU build-id, which is how Sentry matches it.
|
||||
for app in cockatrice oracle servatrice; do
|
||||
binary="build/$app/$app"
|
||||
[[ -f $binary ]] && paths+=("$binary")
|
||||
done
|
||||
if [[ ${#paths[@]} == 0 ]]; then
|
||||
# Fail loudly instead of skipping: see the Windows/macOS leg below — this step
|
||||
# is the only way symbols reach Sentry and `continue-on-error` keeps the job
|
||||
# green either way, so a silent skip looks exactly like a successful upload.
|
||||
echo "::error::No debug symbol files found; nothing would be uploaded"
|
||||
exit 1
|
||||
fi
|
||||
# Project slug on Sentry; must match the project the baked DSN points at.
|
||||
# No --release here: `debug-files upload` has no such option (sentry-cli errors on
|
||||
# the unknown flag and uploads nothing), and debug information files are matched to
|
||||
# events by debug ID via the event's debug_meta, not by release. The release grouping
|
||||
# comes from the client event itself (COCKATRICE_SENTRY_RELEASE).
|
||||
sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
|
||||
|
||||
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
|
||||
- name: "Delete remote compiler cache (ccache)"
|
||||
if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
|
||||
|
|
@ -535,6 +598,11 @@ jobs:
|
|||
BUILDTYPE: '${{ matrix.type }}'
|
||||
CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }}
|
||||
CMAKE_GENERATOR: ${{ matrix.cmake_generator }}
|
||||
# Tag builds only: artifacts from master pushes and PR branches must not report to
|
||||
# Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way.
|
||||
# configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN
|
||||
# and the symbol upload below just like full releases.
|
||||
COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }}
|
||||
DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
|
||||
GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS
|
||||
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
|
||||
|
|
@ -558,8 +626,125 @@ jobs:
|
|||
VCPKG_DISABLE_METRICS: 1
|
||||
VCPKG_FEATURE_FLAGS: dependencygraph
|
||||
VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }}
|
||||
# Release legs compile the sentry-native SDK; Debug legs stay SDK-free.
|
||||
WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }}
|
||||
run: .ci/compile.sh --server --test --vcpkg
|
||||
|
||||
- name: "Install sentry-cli"
|
||||
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
|
||||
# Same as the Linux leg above: the install downloads from Sentry's registry, so a
|
||||
# failure here must not block the release upload below.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_CLI_VERSION: '3.8.0'
|
||||
run: |
|
||||
export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli"
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH"
|
||||
|
||||
- name: "Upload debug symbols to Sentry"
|
||||
# Like the DSN above, this runs for every tag — beta (Development) tags included.
|
||||
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
|
||||
# A Sentry outage or rotated token must not take down the release assets below.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: |
|
||||
paths=()
|
||||
if [[ '${{ matrix.os }}' == 'Windows' ]]; then
|
||||
# Top-level CMakeLists.txt links /DEBUG even in release, so the linker PDBs
|
||||
# land next to the binaries. Ninja is single-config, so that is
|
||||
# build/<target>/ and NOT build/<target>/Release/ — the same globs the symbol
|
||||
# artifact step below uses.
|
||||
#
|
||||
# The executables are uploaded as well, in the same invocation. On 64-bit
|
||||
# Windows the Call Frame Information (.pdata/.xdata) lives in the PE and is
|
||||
# not copied into the PDB, so a PDB on its own leaves Sentry reporting
|
||||
# "Stack Unwinding: Missing" for the image. sentry-cli also needs to see the
|
||||
# PE and its PDB together: only then does it associate the PE's code
|
||||
# identifier with the PDB's debug identifier.
|
||||
shopt -s nullglob
|
||||
for target in cockatrice oracle servatrice; do
|
||||
paths+=("build/$target"/*.exe "build/$target"/*.pdb)
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
# Qt's own runtime DLLs, taken from required-runtime.txt — the manifest
|
||||
# .ci/compile.sh asserts the installer against — so the set uploaded is
|
||||
# the set an install actually carries. The PE is the only carrier of
|
||||
# unwind information for a 64-bit image, and sending it in the same
|
||||
# invocation as a PDB is what lets sentry-cli associate the PE's code
|
||||
# identifier with the PDB's debug identifier. Qt ships no PDBs with the
|
||||
# SDK, so Qt frames resolve through the export table: names, no lines.
|
||||
qt_dir=
|
||||
qt_core_dir=
|
||||
# Existence guards matter: the step runs under `bash -eo pipefail`, so a
|
||||
# redirection-less sed over a missing file would abort the step outright
|
||||
# and continue-on-error would hide it.
|
||||
if [[ -f build/CMakeCache.txt ]]; then
|
||||
qt_core_dir=$(sed -n 's/^Qt6Core_DIR:PATH=//p' build/CMakeCache.txt | head -1)
|
||||
fi
|
||||
if [[ -n $qt_core_dir ]]; then
|
||||
qt_dir=$(dirname "$(dirname "$(dirname "$qt_core_dir")")")
|
||||
fi
|
||||
if [[ -z $qt_dir || ! -d $qt_dir ]]; then
|
||||
# Warnings, not failures: the upload of our own binaries is what this
|
||||
# step is for, and continue-on-error already means an upload failure
|
||||
# cannot be told apart from a successful one.
|
||||
echo "::warning::Qt runtime directory not resolved from build/CMakeCache.txt; Qt images get no unwind information"
|
||||
elif [[ ! -f build/required-runtime.txt ]]; then
|
||||
echo "::warning::build/required-runtime.txt is missing; cannot tell which Qt DLLs ship"
|
||||
else
|
||||
while IFS= read -r runtime; do
|
||||
name=${runtime##*/}
|
||||
if [[ $runtime == */* ]]; then
|
||||
# A Qt plugin: the install root keeps it as plugins/<type>/<name>.
|
||||
# `|| true`: an unmatched glob makes ls exit non-zero, and pipefail
|
||||
# turns that into a step abort.
|
||||
runtime_path=$(ls "$qt_dir/plugins"/*/"$name" 2>/dev/null | head -1) || true
|
||||
else
|
||||
runtime_path="$qt_dir/bin/$name"
|
||||
fi
|
||||
if [[ -f ${runtime_path:-} ]]; then
|
||||
paths+=("$runtime_path")
|
||||
else
|
||||
echo "::warning::shipped Qt runtime $runtime not found under $qt_dir"
|
||||
fi
|
||||
done < <(grep -E '^(Qt6[^/]*\.dll|Plugins/)' build/required-runtime.txt)
|
||||
fi
|
||||
else
|
||||
# Release builds carry DWARF (-g, kept by CrashReporting.cmake); macOS keeps its
|
||||
# debug info in dSYM bundles, so materialize them before uploading.
|
||||
for app in cockatrice oracle servatrice; do
|
||||
binary="build/$app/$app.app/Contents/MacOS/$app"
|
||||
if [[ -f $binary ]]; then
|
||||
if [[ '${{ matrix.os }}' == 'macOS' ]]; then
|
||||
dsymutil "$binary" -o "$binary.dSYM"
|
||||
paths+=("$binary.dSYM")
|
||||
# The dSYM holds the debug information, but the unwind information
|
||||
# Sentry needs to walk a minidump stack lives in the Mach-O itself.
|
||||
paths+=("$binary")
|
||||
else
|
||||
paths+=("$binary")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ ${#paths[@]} == 0 ]]; then
|
||||
# Fail loudly instead of skipping: this step is the only way symbols reach
|
||||
# Sentry, and `continue-on-error` keeps the job green either way, so a
|
||||
# silent skip is indistinguishable from a successful upload. It also hid a
|
||||
# wrong Windows path (build/<target>/Release, which Ninja never creates).
|
||||
echo "::error::No debug symbol files found; nothing would be uploaded"
|
||||
exit 1
|
||||
fi
|
||||
# Project slug on Sentry; must match the project the baked DSN points at.
|
||||
# No --release: see the Linux leg above — the flag does not exist on
|
||||
# `debug-files upload` and DIFs are matched by debug ID, not release.
|
||||
sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
|
||||
|
||||
# CPack reports a failed makensis run as "Problem running NSIS command"
|
||||
# plus a path to NSISOutput.log, and that log lives only on the runner, so
|
||||
# the actual compile error is otherwise nowhere in the workflow output.
|
||||
|
|
@ -703,19 +888,6 @@ jobs:
|
|||
if-no-files-found: error
|
||||
path: ${{ steps.build.outputs.path }}
|
||||
|
||||
# Ninja is single-config: binaries and their PDBs land directly in the
|
||||
# target directories instead of a Release subdirectory.
|
||||
- name: "[Windows] Upload PDBs (Program Databases)"
|
||||
if: matrix.os == 'Windows' && github.ref_type != 'tag'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: ${{ steps.build.outputs.name }}-PDBs
|
||||
path: |
|
||||
build/cockatrice/*.pdb
|
||||
build/oracle/*.pdb
|
||||
build/servatrice/*.pdb
|
||||
|
||||
- name: "Upload to release"
|
||||
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
|
||||
id: upload_release
|
||||
|
|
@ -727,6 +899,33 @@ jobs:
|
|||
tag_name: ${{ needs.configure.outputs.tag }}
|
||||
run: gh release upload "$tag_name" "$asset_path#$asset_name"
|
||||
|
||||
# Ninja is single-config: binaries and their PDBs land directly in the
|
||||
# target directories instead of a Release subdirectory.
|
||||
#
|
||||
# Archived for tag builds only, and ordered after the release upload so
|
||||
# a missing PDB can never block a release from being published. On a tag
|
||||
# build this artifact is the only durable copy of the symbols: the Sentry
|
||||
# upload is continue-on-error, so without it a failed upload would lose
|
||||
# the ability to symbolicate that release permanently. Other builds upload
|
||||
# no symbols at all, so archiving them there would only cost storage.
|
||||
#
|
||||
# The executables travel with their PDBs: re-uploading the pair together is
|
||||
# what lets sentry-cli associate the PE's code identifier with the PDB's
|
||||
# debug identifier, and the PE is what carries the unwind information.
|
||||
- name: "[Windows] Upload debug symbols (exe + pdb)"
|
||||
if: matrix.os == 'Windows' && needs.configure.outputs.tag != null
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: ${{ steps.build.outputs.name }}-Symbols
|
||||
path: |
|
||||
build/cockatrice/*.exe
|
||||
build/cockatrice/*.pdb
|
||||
build/oracle/*.exe
|
||||
build/oracle/*.pdb
|
||||
build/servatrice/*.exe
|
||||
build/servatrice/*.pdb
|
||||
|
||||
- name: "Attest binary provenance"
|
||||
if: steps.upload_release.outcome == 'success'
|
||||
id: attestation
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ endif()
|
|||
# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on.
|
||||
include(Sanitizers)
|
||||
include(Coverage)
|
||||
include(CrashReporting)
|
||||
|
||||
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
|
|
@ -336,6 +337,15 @@ elseif(WIN32)
|
|||
# Configure file with custom definitions for NSIS.
|
||||
configure_file("${COCKATRICE_CMAKE_PATH}/NSIS.definitions.nsh.in" "${PROJECT_BINARY_DIR}/NSIS.definitions.nsh")
|
||||
|
||||
# Installer-log sender for the AbortInstall Sentry hook (values parsed out of COCKATRICE_SENTRY_DSN in
|
||||
# cmake/CrashReporting.cmake). Generated only when a DSN was parsed: cmake/NSIS.template.in embeds it under
|
||||
# the same CPACK_SENTRY_ENABLED check, so a build without reporting never references or carries the helper.
|
||||
if(CPACK_SENTRY_ENABLED)
|
||||
configure_file(
|
||||
"${COCKATRICE_CMAKE_PATH}/SendInstallerLog.ps1.in" "${PROJECT_BINARY_DIR}/SendInstallerLog.ps1" @ONLY
|
||||
)
|
||||
endif()
|
||||
|
||||
# Every runtime DLL a Windows install has to contain, as paths relative to the
|
||||
# install root. .ci/compile.sh reads this and fails the build when the packaged
|
||||
# installer is missing one of them, or carries a runtime it does not know about.
|
||||
|
|
|
|||
212
cmake/CrashReporting.cmake
Normal file
212
cmake/CrashReporting.cmake
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# Crash reporting via sentry-native (Sentry protocol, MIT).
|
||||
# Included once from the root CMakeLists.txt, right after the sanitizer/coverage helpers.
|
||||
#
|
||||
# Inputs:
|
||||
# WITH_CRASH_REPORTING -- build the client with the sentry-native SDK (default OFF, our release
|
||||
# CI passes ON; packagers and from-source builds stay SDK-free until this
|
||||
# ships something real, so offline package builds never fetch the SDK)
|
||||
# COCKATRICE_SENTRY_DSN -- Sentry DSN baked into the build (default empty = SDK init skipped at runtime)
|
||||
# COCKATRICE_SENTRY_TAG -- sentry-native release tag to vendor via FetchContent
|
||||
# ENABLE_SANITIZERS -- forces the sentry backend to "none" (crashpad vs ASan conflict)
|
||||
# Defines: cockatrice_target_enable_crash_reporting(<target>)
|
||||
|
||||
option(WITH_CRASH_REPORTING "Build with crash reporting via sentry-native" OFF)
|
||||
set(COCKATRICE_SENTRY_DSN
|
||||
""
|
||||
CACHE STRING "Sentry DSN baked into the client; empty disables crash-report upload at runtime"
|
||||
)
|
||||
# Pinned stable release; tag verified at https://github.com/getsentry/sentry-native/tags.
|
||||
set(COCKATRICE_SENTRY_TAG
|
||||
"0.17.3"
|
||||
CACHE STRING "sentry-native release tag to vendor via FetchContent"
|
||||
)
|
||||
|
||||
# Wire the SDK (or the no-op fallback) into a client target. Always callable: with the
|
||||
# option OFF the compile definitions below make crash_reporter.cpp compile to no-ops.
|
||||
function(cockatrice_target_enable_crash_reporting _target)
|
||||
if(WITH_CRASH_REPORTING AND TARGET sentry::sentry)
|
||||
target_link_libraries(${_target} PRIVATE sentry::sentry)
|
||||
target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=1)
|
||||
else()
|
||||
target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=0)
|
||||
endif()
|
||||
# Always baked in (possibly empty): main() prefers the COCKATRICE_SENTRY_DSN env var at runtime.
|
||||
target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_DSN=\"${COCKATRICE_SENTRY_DSN}\"")
|
||||
# Sentry release for events from this target: PROJECT_VERSION (getversion.cmake ran before this
|
||||
# file was included). Deliberately not PROJECT_VERSION_FRIENDLY / VERSION_STRING — that carries
|
||||
# a build date, so every build of the same version would land in a different Sentry release.
|
||||
# PROJECT_VERSION matches what the installer reports in its own log and what CPack derives the
|
||||
# package version from, so client and installer events group under one release.
|
||||
target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_RELEASE=\"${PROJECT_VERSION}\"")
|
||||
endfunction()
|
||||
|
||||
if(WITH_CRASH_REPORTING AND WITH_CLIENT)
|
||||
include(FetchContent)
|
||||
|
||||
if(ENABLE_SANITIZERS)
|
||||
# The crash handler cannot run under ASan/UBSan, so there is no backend to capture with.
|
||||
set(_sentry_backend "none")
|
||||
else()
|
||||
# Breakpad on every OS (increment 3 probe, 2026-10-14): the crashpad sources
|
||||
# do vendor friction-free (FetchContent inits all submodules recursively by
|
||||
# default, verified populated in a local build tree), but the crashpad backend
|
||||
# would need its out-of-process crashpad_handler binary installed next to the
|
||||
# exe, into the macOS bundle, and through NSIS, plus handler-path wiring and
|
||||
# notarization care. Breakpad is in-process, needs no extra binary and no
|
||||
# packaging on any OS sentry-native builds, so simplicity wins.
|
||||
set(_sentry_backend "breakpad")
|
||||
endif()
|
||||
# FORCE so toggling ENABLE_SANITIZERS on a reused build dir cannot leave a stale backend behind.
|
||||
set(SENTRY_BACKEND
|
||||
"${_sentry_backend}"
|
||||
CACHE STRING "Crash handler backend for sentry-native" FORCE
|
||||
)
|
||||
unset(_sentry_backend)
|
||||
|
||||
if(NOT MSVC)
|
||||
# Symbol upload needs debug info in Release binaries: the top-level flags build GCC
|
||||
# Release as -s -O2 (stripped, no DWARF) and Clang/Apple Release as plain -O2 (no DWARF),
|
||||
# both of which upload nothing useful. Keep -g and drop the strip flag while the SDK is
|
||||
# on; the OFF default leaves local, distro and Debug builds untouched. MSVC already
|
||||
# links /DEBUG with PDBs in release (see top-level CMakeLists.txt), so it needs nothing.
|
||||
string(REGEX REPLACE "(^| )-s( |$)" "\\1\\2" CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE}")
|
||||
if(NOT CMAKE_CXX_FLAGS_RELEASE MATCHES "(^| )-g( |$)")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# No Qt integration: it runs find_package(Qt6) inside the vendored build, which
|
||||
# cannot see our thin/aqt Qt layouts (macOS CI) under foreign toolchains, and its
|
||||
# breadcrumbs could carry unredacted log lines into native crash events that bypass
|
||||
# before_send. Our attached redacted log preview already carries the log context.
|
||||
set(SENTRY_INTEGRATION_QT
|
||||
OFF
|
||||
CACHE BOOL "Qt log-message breadcrumbs for sentry-native (off: discovery + secret surface)" FORCE
|
||||
)
|
||||
# The transport stays at the SDK default (curl on Apple/Linux, winhttp on Windows).
|
||||
set(SENTRY_BUILD_TESTS
|
||||
OFF
|
||||
CACHE BOOL "Build sentry-native tests" FORCE
|
||||
)
|
||||
set(SENTRY_BUILD_EXAMPLES
|
||||
OFF
|
||||
CACHE BOOL "Build sentry-native examples" FORCE
|
||||
)
|
||||
|
||||
fetchcontent_declare(
|
||||
sentry
|
||||
GIT_REPOSITORY https://github.com/getsentry/sentry-native.git
|
||||
GIT_TAG ${COCKATRICE_SENTRY_TAG}
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
fetchcontent_makeavailable(sentry)
|
||||
|
||||
# Vendored sentry-native (and its breakpad/crashpad trees) warn under our -Wall -Wextra
|
||||
# (e.g. breakpad's -Wmissing-field-initializers), and WARNING_AS_ERROR would fail the build
|
||||
# on code we cannot fix. Those findings are not actionable for us, so opt the whole vendored
|
||||
# tree out of warnings-as-errors while keeping the warnings themselves visible; our own
|
||||
# targets keep -Werror. Interface libraries compile nothing, so they are skipped outright:
|
||||
# an INTERFACE -Wno-error would propagate to our consumers and weaken their build.
|
||||
function(_cockatrice_sentry_opt_out_of_werror _dir)
|
||||
get_property(
|
||||
_sentry_targets
|
||||
DIRECTORY "${_dir}"
|
||||
PROPERTY BUILDSYSTEM_TARGETS
|
||||
)
|
||||
foreach(_sentry_target IN LISTS _sentry_targets)
|
||||
get_target_property(_sentry_type ${_sentry_target} TYPE)
|
||||
if(_sentry_type STREQUAL "INTERFACE_LIBRARY")
|
||||
continue()
|
||||
elseif(_sentry_type MATCHES "LIBRARY|EXECUTABLE")
|
||||
get_target_property(_sentry_imported ${_sentry_target} IMPORTED)
|
||||
if(NOT _sentry_imported)
|
||||
target_compile_options(${_sentry_target} PRIVATE -Wno-error)
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
get_property(
|
||||
_sentry_subdirs
|
||||
DIRECTORY "${_dir}"
|
||||
PROPERTY SUBDIRECTORIES
|
||||
)
|
||||
foreach(_sentry_subdir IN LISTS _sentry_subdirs)
|
||||
_cockatrice_sentry_opt_out_of_werror("${_sentry_subdir}")
|
||||
endforeach()
|
||||
endfunction()
|
||||
if(NOT MSVC)
|
||||
_cockatrice_sentry_opt_out_of_werror("${sentry_SOURCE_DIR}")
|
||||
endif()
|
||||
|
||||
message(STATUS "Crash reporting: sentry-native ${COCKATRICE_SENTRY_TAG} (backend: ${SENTRY_BACKEND})")
|
||||
else()
|
||||
message(STATUS "Crash reporting: SDK not vendored "
|
||||
"(WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}, WITH_CLIENT=${WITH_CLIENT})"
|
||||
)
|
||||
endif()
|
||||
|
||||
# Packaged release binaries must not ship the DWARF that the symbol upload needs: it is
|
||||
# 80-90% of an unstripped binary and nobody downloads a 500 MB installer to get it. The
|
||||
# build tree keeps -g (CI uploads symbols from there); the *packaged copy* is split at
|
||||
# install time by a CPack install script, which extracts the debug info next to the staging
|
||||
# dir and strips the binary. Only ELF (Linux/BSD) needs this: Windows links /DEBUG with
|
||||
# separate PDBs, and macOS keeps its dSYM extracted from the bundle.
|
||||
option(COCKATRICE_SPLIT_DEBUG_SYMBOLS "Strip DWARF out of packaged release binaries" ON)
|
||||
set(COCKATRICE_DEBUG_SYMBOL_DIR
|
||||
"${CMAKE_BINARY_DIR}/debug-symbols"
|
||||
CACHE PATH "Where the packaged binaries' split debug information is written (kept out of the package)"
|
||||
)
|
||||
if(WITH_CRASH_REPORTING
|
||||
AND WITH_CLIENT
|
||||
AND COCKATRICE_SPLIT_DEBUG_SYMBOLS
|
||||
AND CMAKE_BUILD_TYPE STREQUAL "Release"
|
||||
AND UNIX
|
||||
AND NOT APPLE
|
||||
)
|
||||
find_program(COCKATRICE_OBJCOPY NAMES objcopy)
|
||||
find_program(COCKATRICE_STRIP_TOOL NAMES strip)
|
||||
if(COCKATRICE_OBJCOPY AND COCKATRICE_STRIP_TOOL)
|
||||
configure_file(
|
||||
"${CMAKE_CURRENT_LIST_DIR}/SplitDebugSymbols.cmake.in" "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake" @ONLY
|
||||
)
|
||||
# PRE_BUILD runs after CPack stages the install tree and before the package is produced.
|
||||
# CPACK_INSTALL_SCRIPTS would run too early (staging is still empty); the script walks
|
||||
# CPACK_TEMPORARY_DIRECTORY, never CMAKE_INSTALL_PREFIX, and refuses to run otherwise.
|
||||
list(APPEND CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake")
|
||||
message(STATUS "Crash reporting: packaged binaries stripped, debug info split to ${COCKATRICE_DEBUG_SYMBOL_DIR}")
|
||||
else()
|
||||
message(WARNING "COCKATRICE_SPLIT_DEBUG_SYMBOLS is ON but objcopy/strip were not found; "
|
||||
"packaged release binaries will keep their debug information"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Installer failure reporting (increment 4): parse COCKATRICE_SENTRY_DSN into the parts the Windows installer
|
||||
# needs. These are CPACK_* so CPack snapshots them at include(CPack) time and substitutes them into
|
||||
# cmake/NSIS.template.in at package time; on non-Windows they are inert (only the NSIS template consumes them).
|
||||
# Set from COCKATRICE_SENTRY_DSN on every configure, so a changed or removed DSN can never leave a stale value
|
||||
# behind. Plain variables, not CACHE: they only need to be in scope when include(CPack) later snapshots every
|
||||
# CPACK_* variable into CPackConfig.cmake.
|
||||
set(CPACK_SENTRY_ENABLED "0")
|
||||
set(CPACK_SENTRY_KEY "")
|
||||
set(CPACK_SENTRY_ENVELOPE_URL "")
|
||||
set(CPACK_SENTRY_DSN_FULL "")
|
||||
# Deliberately PROJECT_VERSION: that is what the client bakes as its Sentry release and what CI passes to
|
||||
# `sentry-cli debug-files upload`, so installer and client events group under one release. It is not the
|
||||
# CPACK_PACKAGE_VERSION the installer logs in its own log line — on Development tags PROJECT_VERSION carries a
|
||||
# "-<label>" suffix (getversion.cmake) while CPACK_PACKAGE_VERSION is only MAJOR.MINOR.PATCH.
|
||||
set(CPACK_SENTRY_RELEASE "${PROJECT_VERSION}")
|
||||
|
||||
# Canonical <scheme>://<key>@<host>/<project-id>. Deliberately unanchored, so a trailing slash still matches.
|
||||
# A self-hosted DSN with a path prefix (e.g. GlitchTip behind a subpath) does not match and falls through to the
|
||||
# disabled-and-warn branch instead of deriving a wrong endpoint from the tail of the path.
|
||||
if(COCKATRICE_SENTRY_DSN MATCHES "(https?)://([^@]+)@([^/]+)/([0-9]+)")
|
||||
set(CPACK_SENTRY_ENABLED "1")
|
||||
set(CPACK_SENTRY_KEY "${CMAKE_MATCH_2}")
|
||||
# Scheme comes from the DSN, not an assumption: self-hosted (GlitchTip) DSNs may be plain http.
|
||||
set(CPACK_SENTRY_ENVELOPE_URL "${CMAKE_MATCH_1}://${CMAKE_MATCH_3}/api/${CMAKE_MATCH_4}/envelope/")
|
||||
set(CPACK_SENTRY_DSN_FULL "${COCKATRICE_SENTRY_DSN}")
|
||||
elseif(NOT COCKATRICE_SENTRY_DSN STREQUAL "")
|
||||
message(WARNING "COCKATRICE_SENTRY_DSN is set but malformed "
|
||||
"(expected <scheme>://<key>@<host>/<project-id>); installer log reporting disabled"
|
||||
)
|
||||
endif()
|
||||
|
|
@ -37,6 +37,9 @@ Var LogFindHandle
|
|||
Var LogFindName
|
||||
Var LogMatchLabel
|
||||
Var LogMatchPattern
|
||||
; Sentry installer-log hook state (see AbortInstall)
|
||||
Var SentryResult
|
||||
Var SentryOutput
|
||||
|
||||
!include LogicLib.nsh
|
||||
!include FileFunc.nsh
|
||||
|
|
@ -909,6 +912,48 @@ Function AbortInstall
|
|||
${If} $ReinstallMode = 1
|
||||
${OrIfNot} ${Silent}
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath"
|
||||
; Offer to send the installer log to Sentry so aborted installs can be diagnosed. This block already runs
|
||||
; only when someone is at the keyboard (attended install, or the updater's /R upgrade whose launcher has
|
||||
; quit), so a silent or package-manager install never reaches the prompt and its behavior is unchanged.
|
||||
; Only a package built with a baked DSN offers to send, so the prompt can only appear when a send
|
||||
; can actually go out. A preprocessor guard rather than a runtime test: a package built without a
|
||||
; DSN compiles the block out entirely, so it cannot even carry the helper or its baked endpoint.
|
||||
; No /SD here: a /SD would suppress this prompt on the silent /R upgrade, which is exactly the
|
||||
; attended-update case this offer exists for. The runtime gate above (ReinstallMode/Silent) is the
|
||||
; hang protection — a package-manager /S install without /R never reaches this box, matching the
|
||||
; two sibling MessageBox sites in this file.
|
||||
!if "@CPACK_SENTRY_ENABLED@" == "1"
|
||||
MessageBox MB_YESNO|MB_ICONQUESTION "Send the installer log to the developers to help fix this? The log contains file paths from this machine, which may include your Windows user name." IDYES sentry_send
|
||||
!insertmacro CockLog "sentry send: declined by user"
|
||||
Goto sentry_done
|
||||
sentry_send:
|
||||
; The abort reason is already in the log file (CockLog writes "ABORT: ..." as the
|
||||
; first thing AbortInstall does), so the helper reads it from there. Nothing
|
||||
; reason-shaped crosses the command line, which keeps quoting trivial and needs
|
||||
; no NSIS string functions.
|
||||
InitPluginsDir
|
||||
; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both are
|
||||
; configure_file'd into the build root, which is three levels up from project.nsi). An
|
||||
; absolute CMake path here carries forward slashes, which makensis does not treat as
|
||||
; absolute and so fails to find the file at package time.
|
||||
File "/oname=$PLUGINSDIR\send-installer-log.ps1" "..\..\..\SendInstallerLog.ps1"
|
||||
; The send can take up to the /TIMEOUT below, so put something on the details pane
|
||||
; the moment the user says yes rather than leaving the window blank until it returns.
|
||||
DetailPrint "Sending installer log..."
|
||||
nsExec::ExecToStack /TIMEOUT=60000 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\send-installer-log.ps1" -LogPath "$InstallLogPath"'
|
||||
Pop $SentryResult
|
||||
Pop $SentryOutput
|
||||
; $SentryOutput is a single line by construction: the helper flattens its message
|
||||
; and this log file is uploaded verbatim, so a stray newline must not be able to
|
||||
; forge extra lines into the artifact we send.
|
||||
!insertmacro CockLog "sentry send: result=$SentryResult output=$SentryOutput"
|
||||
${If} $SentryResult == "0"
|
||||
MessageBox MB_OK|MB_ICONINFORMATION "The installer log was sent. Thank you for helping improve Cockatrice!"
|
||||
${Else}
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "The installer log could not be sent. You can still share it manually; it was written to:$\r$\n$InstallLogPath"
|
||||
${EndIf}
|
||||
sentry_done:
|
||||
!endif
|
||||
${EndIf}
|
||||
SetErrorLevel 1
|
||||
Abort
|
||||
|
|
@ -1530,11 +1575,22 @@ ${If} $PortableMode = 0
|
|||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
|
||||
; Enable Windows User-Mode Dumps
|
||||
; No Windows Error Reporting LocalDumps registration: crash capture belongs to
|
||||
; the in-app reporter (Sentry/breakpad, MiniDumpNormal-class data only). Opting
|
||||
; machines into OS-level full dumps (DumpType 2) would leave unredacted memory
|
||||
; dumps on user machines that nothing consumes.
|
||||
; https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps
|
||||
WriteRegExpandStr HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe" "DumpFolder" "%LOCALAPPDATA%\CrashDumps\Cockatrice"
|
||||
WriteRegDWORD HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe" "DumpCount" "5"
|
||||
WriteRegDWORD HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe" "DumpType" "2"
|
||||
|
||||
; Older installers did register cockatrice.exe above (and the removed
|
||||
; MiniDumpWithFullMemory writer filled that folder), and that key survives this
|
||||
; install, so WER would keep writing full dumps. Remove it, and clear the
|
||||
; legacy dumps nothing consumes anymore. $LOCALAPPDATA resolves per shell context:
|
||||
; under "all" it points at ProgramData, so switch to "current" for the per-user
|
||||
; dumps folder and back afterwards; the registry delete works in either context.
|
||||
DeleteRegKey HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe"
|
||||
SetShellVarContext current
|
||||
RMDir /r "$LOCALAPPDATA\CrashDumps\Cockatrice"
|
||||
SetShellVarContext all
|
||||
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "DisplayIcon" "$INSTDIR\cockatrice.exe"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "DisplayName" "Cockatrice"
|
||||
|
|
@ -1635,6 +1691,15 @@ Section "un.Application" UnSecApplication
|
|||
|
||||
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice"
|
||||
|
||||
; Leftover WER LocalDumps key from pre-Sentry installers (see the installer note
|
||||
; above): removing it here covers machines that uninstall without reinstalling.
|
||||
; The legacy full-memory dumps go with it; nothing consumes them anymore. Same shell
|
||||
; context note as the installer: the per-user dumps folder needs "current".
|
||||
DeleteRegKey HKLM "Software\Microsoft\Windows\Windows Error Reporting\LocalDumps\cockatrice.exe"
|
||||
SetShellVarContext current
|
||||
RMDir /r "$LOCALAPPDATA\CrashDumps\Cockatrice"
|
||||
SetShellVarContext all
|
||||
|
||||
; Only remove the file/protocol associations if we registered them (i.e. the
|
||||
; install was not portable) and .cod is still owned by Cockatrice, so we don't
|
||||
; clobber a .cod association installed by another application.
|
||||
|
|
|
|||
120
cmake/SendInstallerLog.ps1.in
Normal file
120
cmake/SendInstallerLog.ps1.in
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Sends the NSIS installer log to Sentry as an envelope carrying one error
|
||||
# event plus the log as an attachment.
|
||||
#
|
||||
# Generated at configure time from this template by CMake configure_file: the
|
||||
# CPACK_SENTRY_* placeholders are baked in then, so the real values only ever
|
||||
# exist in the build directory and the packaged installer, never in the repo.
|
||||
# Runs on stock Windows PowerShell 5.1 with no extra modules.
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$LogPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$EnvelopeUrl = '@CPACK_SENTRY_ENVELOPE_URL@'
|
||||
$SentryKey = '@CPACK_SENTRY_KEY@'
|
||||
$Release = '@CPACK_SENTRY_RELEASE@'
|
||||
$Dsn = '@CPACK_SENTRY_DSN_FULL@'
|
||||
|
||||
try {
|
||||
# The envelope endpoint is TLS-only; stock powershell.exe on older systems
|
||||
# still negotiates TLS 1.0 otherwise, so opt into 1.2 explicitly.
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
} catch {
|
||||
}
|
||||
|
||||
# --- Log payload, tail-capped at 128 KiB ---------------------------------
|
||||
$MaxLogBytes = 128 * 1024
|
||||
$TruncNotice = '[log truncated to last 128 KB]'
|
||||
if (Test-Path -LiteralPath $LogPath) {
|
||||
$raw = [System.IO.File]::ReadAllBytes($LogPath)
|
||||
} else {
|
||||
$raw = [System.Text.Encoding]::UTF8.GetBytes('(installer log not found: ' + $LogPath + ')')
|
||||
}
|
||||
if ($raw.Length -gt $MaxLogBytes) {
|
||||
$tail = New-Object byte[] $MaxLogBytes
|
||||
[System.Buffer]::BlockCopy($raw, $raw.Length - $MaxLogBytes, $tail, 0, $MaxLogBytes)
|
||||
$prefix = [System.Text.Encoding]::UTF8.GetBytes($TruncNotice + "`r`n")
|
||||
$logBytes = New-Object byte[] ($prefix.Length + $tail.Length)
|
||||
[System.Buffer]::BlockCopy($prefix, 0, $logBytes, 0, $prefix.Length)
|
||||
[System.Buffer]::BlockCopy($tail, 0, $logBytes, $prefix.Length, $tail.Length)
|
||||
} else {
|
||||
$logBytes = $raw
|
||||
}
|
||||
|
||||
# --- Abort reason, read from the log itself --------------------------------
|
||||
# AbortInstall logs "ABORT: ..." first, so the reason travels inside the file
|
||||
# and nothing reason-shaped crosses the command line (no quoting hazards).
|
||||
# Select-String yields the matching line alone, so the reason is already one
|
||||
# physical line for the message; anything else stays in the attached log.
|
||||
# Non-ASCII paths may garble here; the attachment is byte-exact regardless.
|
||||
$Reason = 'unknown (no ABORT line in log)'
|
||||
if (Test-Path -LiteralPath $LogPath) {
|
||||
$abortLine = Select-String -LiteralPath $LogPath -Pattern '^ABORT: ' | Select-Object -Last 1
|
||||
if ($abortLine) {
|
||||
$Reason = ($abortLine.Line -replace '^ABORT:\s*', '')
|
||||
}
|
||||
}
|
||||
|
||||
# --- Event ---------------------------------------------------------------
|
||||
$eventId = [guid]::NewGuid().ToString('N')
|
||||
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
# $Event is an automatic variable (Microsoft.PowerShell.Utility\Register-ObjectEvent),
|
||||
# so the local is named $sentryEvent to avoid shadowing it.
|
||||
$sentryEvent = [ordered]@{
|
||||
event_id = $eventId
|
||||
timestamp = $now
|
||||
platform = 'other'
|
||||
level = 'error'
|
||||
logger = 'cockatrice-installer'
|
||||
release = $Release
|
||||
environment = 'installer'
|
||||
sdk = @{ name = 'cockatrice.installer'; version = $Release }
|
||||
message = @{ formatted = 'Installer aborted: ' + $Reason }
|
||||
extra = @{
|
||||
reason = $Reason
|
||||
os = [System.Environment]::OSVersion.VersionString
|
||||
}
|
||||
}
|
||||
$eventJson = $sentryEvent | ConvertTo-Json -Compress -Depth 6
|
||||
$eventBytes = [System.Text.Encoding]::UTF8.GetBytes($eventJson)
|
||||
|
||||
# --- Envelope, assembled as bytes (the log is opaque binary) -------------
|
||||
$sentAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
$headerLine = '{"event_id":"' + $eventId + '","sent_at":"' + $sentAt + '","dsn":"' + $Dsn + '"}'
|
||||
$eventHeader = '{"type":"event","length":' + $eventBytes.Length + '}'
|
||||
$attachHeader = '{"type":"attachment","filename":"Cockatrice-installer.log","content_type":"text/plain","length":' + $logBytes.Length + '}'
|
||||
$enc = [System.Text.Encoding]::UTF8
|
||||
$lf = [byte[]]@(10)
|
||||
$body = New-Object System.IO.MemoryStream
|
||||
foreach ($chunk in @($enc.GetBytes($headerLine), $lf, $enc.GetBytes($eventHeader), $lf, $eventBytes, $lf, $enc.GetBytes($attachHeader), $lf, $logBytes)) {
|
||||
$body.Write($chunk, 0, $chunk.Length)
|
||||
}
|
||||
$bodyBytes = $body.ToArray()
|
||||
$body.Close()
|
||||
|
||||
# --- Send ----------------------------------------------------------------
|
||||
# The key travels in the auth header only; stdout below never contains it.
|
||||
$auth = 'Sentry sentry_version=7, sentry_client=cockatrice.installer/' + $Release + ', sentry_key=' + $SentryKey
|
||||
Invoke-RestMethod -Uri $EnvelopeUrl -Method Post -TimeoutSec 45 -ContentType 'application/x-sentry-envelope' -Headers @{ 'X-Sentry-Auth' = $auth } -Body $bodyBytes | Out-Null
|
||||
Write-Output ('SENT ' + $eventId)
|
||||
exit 0
|
||||
} catch {
|
||||
# Scrub the key/DSN out of the failure text in case a transport error echoes them.
|
||||
$msg = $_.Exception.Message
|
||||
if ($msg -and $SentryKey) {
|
||||
$msg = $msg.Replace($SentryKey, '<redacted>')
|
||||
}
|
||||
if ($msg -and $Dsn) {
|
||||
$msg = $msg.Replace($Dsn, '<redacted>')
|
||||
}
|
||||
# Flatten to one line: this text is echoed into the installer log, which is
|
||||
# itself uploaded as an attachment, so a multi-line transport error must not
|
||||
# be able to forge extra lines in the artifact we send.
|
||||
if ($msg) {
|
||||
$msg = ($msg -replace '\s+', ' ').Trim()
|
||||
}
|
||||
Write-Output ('FAILED: ' + $msg)
|
||||
exit 1
|
||||
}
|
||||
80
cmake/SplitDebugSymbols.cmake.in
Normal file
80
cmake/SplitDebugSymbols.cmake.in
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Splits DWARF out of the packaged ELF binaries, then strips them.
|
||||
#
|
||||
# This is a CPack pre-build script (CPACK_PRE_BUILD_SCRIPTS, wired in CrashReporting.cmake):
|
||||
# CPack runs it after staging the install tree and before producing the package, so it only
|
||||
# ever touches the copy that becomes the release artifact. The build tree is left alone: -g
|
||||
# stays on, and CI uploads symbols to Sentry straight from build/.
|
||||
#
|
||||
# For every ELF file under CPACK_TEMPORARY_DIRECTORY the script extracts the debug information
|
||||
# to COCKATRICE_DEBUG_SYMBOL_DIR and removes the debug sections from the packaged binary.
|
||||
# COCKATRICE_DEBUG_SYMBOL_DIR is deliberately outside the staging tree, so sidecars never end
|
||||
# up in the package; CI can archive/upload them if wanted (it normally uploads straight from
|
||||
# the build tree). The only thing that must survive stripping is .eh_frame (allocatable, used
|
||||
# for unwinding), which neither --only-keep-debug nor --strip-debug removes.
|
||||
#
|
||||
# Tool paths, the symbol directory and the tree to walk are baked in / provided at configure /
|
||||
# run time; the guards below refuse to run on anything that is not a CPack staging directory,
|
||||
# so a missing variable can never turn the glob into a filesystem-wide walk.
|
||||
|
||||
set(_objcopy "@COCKATRICE_OBJCOPY@")
|
||||
set(_strip "@COCKATRICE_STRIP_TOOL@")
|
||||
set(_symbolDir "@COCKATRICE_DEBUG_SYMBOL_DIR@")
|
||||
|
||||
if(_objcopy STREQUAL "" OR _strip STREQUAL "")
|
||||
message(WARNING "split debug symbols: objcopy/strip not found at configure time, packaging unstripped binaries")
|
||||
return()
|
||||
endif()
|
||||
if(_symbolDir STREQUAL "")
|
||||
message(FATAL_ERROR "split debug symbols: COCKATRICE_DEBUG_SYMBOL_DIR is empty")
|
||||
endif()
|
||||
|
||||
# CPack sets CPACK_TEMPORARY_DIRECTORY to the staging tree; CMAKE_INSTALL_PREFIX is still the
|
||||
# configured /usr prefix here, so globbing it would walk system directories. Refuse to run
|
||||
# unless the path exists and really is a CPack staging tree.
|
||||
if(NOT CPACK_TEMPORARY_DIRECTORY OR NOT IS_DIRECTORY "${CPACK_TEMPORARY_DIRECTORY}")
|
||||
message(STATUS "split debug symbols: no CPack staging directory, skipping")
|
||||
return()
|
||||
endif()
|
||||
if(NOT CPACK_TEMPORARY_DIRECTORY MATCHES "_CPack_Packages")
|
||||
message(WARNING "split debug symbols: ${CPACK_TEMPORARY_DIRECTORY} is not a CPack staging tree, skipping")
|
||||
return()
|
||||
endif()
|
||||
|
||||
file(MAKE_DIRECTORY "${_symbolDir}")
|
||||
|
||||
file(
|
||||
GLOB_RECURSE _entries
|
||||
LIST_DIRECTORIES true
|
||||
"${CPACK_TEMPORARY_DIRECTORY}/*"
|
||||
)
|
||||
set(_processed 0)
|
||||
foreach(_file IN LISTS _entries)
|
||||
if(IS_DIRECTORY "${_file}" OR IS_SYMLINK "${_file}")
|
||||
continue()
|
||||
endif()
|
||||
# Only ELF: objcopy would fail on the icons, translations and Qt resources next to it.
|
||||
file(
|
||||
READ "${_file}" _magic
|
||||
OFFSET 0
|
||||
LIMIT 4
|
||||
HEX
|
||||
)
|
||||
if(NOT _magic STREQUAL "7f454c46")
|
||||
continue()
|
||||
endif()
|
||||
get_filename_component(_name "${_file}" NAME)
|
||||
set(_debug "${_symbolDir}/${_name}.debug")
|
||||
execute_process(COMMAND "${_objcopy}" --only-keep-debug "${_file}" "${_debug}" RESULT_VARIABLE _rc)
|
||||
if(NOT _rc EQUAL 0)
|
||||
message(WARNING "split debug symbols: objcopy --only-keep-debug failed for ${_file}")
|
||||
continue()
|
||||
endif()
|
||||
execute_process(COMMAND "${_strip}" --strip-debug --strip-unneeded "${_file}" RESULT_VARIABLE _rc)
|
||||
if(NOT _rc EQUAL 0)
|
||||
message(WARNING "split debug symbols: strip failed for ${_file}")
|
||||
continue()
|
||||
endif()
|
||||
math(EXPR _processed "${_processed} + 1")
|
||||
endforeach()
|
||||
|
||||
message(STATUS "Split debug symbols for ${_processed} packaged ELF file(s); sidecars in ${_symbolDir}")
|
||||
|
|
@ -15,6 +15,7 @@ set(cockatrice_SOURCES
|
|||
src/client/network/update/client/client_update_checker.cpp
|
||||
src/client/network/update/client/release_channel.cpp
|
||||
src/client/network/update/card_spoiler/spoiler_background_updater.cpp
|
||||
src/client/crash/crash_reporter.cpp
|
||||
src/client/latency_graph_widget.cpp
|
||||
src/client/latency_status_widget.cpp
|
||||
src/client/lag_monitor.cpp
|
||||
|
|
@ -30,6 +31,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/dialogs/dlg_connect.cpp
|
||||
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
|
||||
src/interface/widgets/dialogs/dlg_create_game.cpp
|
||||
src/interface/widgets/dialogs/dlg_crash_report.cpp
|
||||
src/interface/widgets/dialogs/dlg_default_tags_editor.cpp
|
||||
src/interface/widgets/dialogs/dlg_edit_avatar.cpp
|
||||
src/interface/widgets/dialogs/dlg_edit_password.cpp
|
||||
|
|
@ -578,6 +580,8 @@ target_link_libraries(
|
|||
${COCKATRICE_QT_MODULES}
|
||||
)
|
||||
|
||||
cockatrice_target_enable_crash_reporting(cockatrice)
|
||||
|
||||
if(UNIX)
|
||||
if(APPLE)
|
||||
set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}")
|
||||
|
|
|
|||
616
cockatrice/src/client/crash/crash_reporter.cpp
Normal file
616
cockatrice/src/client/crash/crash_reporter.cpp
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
#include "crash_reporter.h"
|
||||
|
||||
#if COCKATRICE_WITH_CRASH_REPORTING
|
||||
#include <sentry.h>
|
||||
#endif
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLockFile>
|
||||
#include <QLoggingCategory>
|
||||
#include <QRegularExpression>
|
||||
#include <QThread>
|
||||
#include <QUrl>
|
||||
#include <cstring>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <string>
|
||||
|
||||
Q_LOGGING_CATEGORY(CrashReportLog, "crash_report");
|
||||
|
||||
#if COCKATRICE_WITH_CRASH_REPORTING
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr char CockatriceUrlScheme[] = "cockatrice://";
|
||||
|
||||
/**
|
||||
* @brief Qt-free cockatrice:// secret scrubber for the before_send hook.
|
||||
*
|
||||
* before_send does not run in async-signal context on our backends (breakpad invokes it on the
|
||||
* crashing thread outside the signal handler, and crashpad/native crashes bypass the hook
|
||||
* entirely), but it still avoids QString/QRegularExpression so it stays safe to call from the
|
||||
* crash-handler thread. Truncates each URL at the first terminator, keeping scheme://host like
|
||||
* redactCockatriceUrls does. (std::string allocates, so this is not strictly signal-safe.)
|
||||
*/
|
||||
std::string scrubCockatriceUrls(const char *input)
|
||||
{
|
||||
std::string out;
|
||||
const char *pos = input;
|
||||
const char *scheme = CockatriceUrlScheme;
|
||||
const size_t schemeLen = std::strlen(scheme);
|
||||
while (*pos != '\0') {
|
||||
const char *found = std::strstr(pos, scheme);
|
||||
if (found == nullptr) {
|
||||
out.append(pos);
|
||||
break;
|
||||
}
|
||||
out.append(pos, found);
|
||||
const char *hostEnd = found + schemeLen;
|
||||
while (*hostEnd != '\0' && *hostEnd != '/' && *hostEnd != '?' && *hostEnd != '#' && *hostEnd != ' ' &&
|
||||
*hostEnd != '\t' && *hostEnd != '\n' && *hostEnd != '\'' && *hostEnd != '"' && *hostEnd != '<' &&
|
||||
*hostEnd != '>') {
|
||||
++hostEnd;
|
||||
}
|
||||
out.append(found, hostEnd);
|
||||
pos = hostEnd;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void scrubStringValue(sentry_value_t holder, const char *key)
|
||||
{
|
||||
sentry_value_t current = sentry_value_get_by_key(holder, key);
|
||||
if (sentry_value_get_type(current) != SENTRY_VALUE_TYPE_STRING) {
|
||||
return;
|
||||
}
|
||||
const char *raw = sentry_value_as_string(current);
|
||||
if (raw == nullptr || std::strstr(raw, CockatriceUrlScheme) == nullptr) {
|
||||
return;
|
||||
}
|
||||
sentry_value_set_by_key(holder, key, sentry_value_new_string(scrubCockatriceUrls(raw).c_str()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Scrub trivially reachable string values inside a breadcrumb data object.
|
||||
*/
|
||||
int scrubDataEntry(const char *key, sentry_value_t value, void *userdata)
|
||||
{
|
||||
if (sentry_value_get_type(value) != SENTRY_VALUE_TYPE_STRING) {
|
||||
return 0;
|
||||
}
|
||||
const char *raw = sentry_value_as_string(value);
|
||||
if (raw == nullptr || std::strstr(raw, CockatriceUrlScheme) == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
auto *holder = static_cast<sentry_value_t *>(userdata);
|
||||
sentry_value_set_by_key(*holder, key, sentry_value_new_string(scrubCockatriceUrls(raw).c_str()));
|
||||
return 0;
|
||||
}
|
||||
|
||||
void scrubDataObject(sentry_value_t data)
|
||||
{
|
||||
if (sentry_value_get_type(data) != SENTRY_VALUE_TYPE_OBJECT) {
|
||||
return;
|
||||
}
|
||||
sentry_value_foreach_key_value(data, scrubDataEntry, &data);
|
||||
}
|
||||
|
||||
void scrubCrumbList(sentry_value_t crumbs)
|
||||
{
|
||||
const size_t count = sentry_value_get_length(crumbs);
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
sentry_value_t crumb = sentry_value_get_by_index(crumbs, i);
|
||||
if (sentry_value_get_type(crumb) != SENTRY_VALUE_TYPE_OBJECT) {
|
||||
continue;
|
||||
}
|
||||
scrubStringValue(crumb, "message");
|
||||
scrubDataObject(sentry_value_get_by_key(crumb, "data"));
|
||||
}
|
||||
}
|
||||
|
||||
void scrubBreadcrumbs(sentry_value_t event)
|
||||
{
|
||||
// The SDK merges scope breadcrumbs onto the event as a plain list (sentry__value_merge_breadcrumbs
|
||||
// in sentry_scope.c); the { "values": [...] } envelope shape only exists on the wire. Accept both
|
||||
// so the scrubbing runs on the actual merged form.
|
||||
sentry_value_t breadcrumbs = sentry_value_get_by_key(event, "breadcrumbs");
|
||||
if (sentry_value_get_type(breadcrumbs) == SENTRY_VALUE_TYPE_LIST) {
|
||||
scrubCrumbList(breadcrumbs);
|
||||
return;
|
||||
}
|
||||
if (sentry_value_get_type(breadcrumbs) == SENTRY_VALUE_TYPE_OBJECT) {
|
||||
sentry_value_t values = sentry_value_get_by_key(breadcrumbs, "values");
|
||||
if (sentry_value_get_type(values) == SENTRY_VALUE_TYPE_LIST) {
|
||||
scrubCrumbList(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief before_send hook: scrub cockatrice:// secrets from the event.
|
||||
*
|
||||
* Defense in depth — payloads we build are pre-redacted. No breadcrumbs are recorded today (the Qt
|
||||
* integration stays off), so this currently covers the top-level message and logentry.formatted,
|
||||
* plus any breadcrumb messages/data that reach the merged event in the future. Native crash
|
||||
* minidumps bypass this hook regardless of backend: breakpad captures them in-process without
|
||||
* invoking before_send, and fast-fail crashes on Windows skip the local handler, so before_send is
|
||||
* never invoked for those.
|
||||
*/
|
||||
sentry_value_t scrubEventBeforeSend(sentry_value_t event, sentry_hint_t *hint, void *userData)
|
||||
{
|
||||
Q_UNUSED(hint);
|
||||
Q_UNUSED(userData);
|
||||
scrubStringValue(event, "message");
|
||||
sentry_value_t logentry = sentry_value_get_by_key(event, "logentry");
|
||||
if (sentry_value_get_type(logentry) == SENTRY_VALUE_TYPE_OBJECT) {
|
||||
scrubStringValue(logentry, "formatted");
|
||||
}
|
||||
scrubBreadcrumbs(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief DSN matcher shared by isDsnWellFormed() and describeDsn().
|
||||
*
|
||||
* Kept unanchored on purpose, mirroring the MATCHES in cmake/CrashReporting.cmake (CMake searches
|
||||
* rather than anchoring), so the client never rejects a DSN the build itself baked in. Compiled once
|
||||
* because QRegularExpression construction is not free and both helpers are called per pending
|
||||
* envelope.
|
||||
*/
|
||||
const QRegularExpression &dsnPattern()
|
||||
{
|
||||
static const QRegularExpression pattern(QStringLiteral("(https?)://([^@]+)@([^/]+)/([0-9]+)"));
|
||||
return pattern;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool CrashReporter::isDsnWellFormed(const QString &dsn)
|
||||
{
|
||||
return dsnPattern().match(dsn).hasMatch();
|
||||
}
|
||||
|
||||
QString CrashReporter::describeDsn(const QString &dsn)
|
||||
{
|
||||
const QRegularExpressionMatch match = dsnPattern().match(dsn);
|
||||
if (!match.hasMatch()) {
|
||||
return QString();
|
||||
}
|
||||
// Host and project id only: the key in group 2 must never reach a log line.
|
||||
return QStringLiteral("%1 (project %2)").arg(match.captured(3), match.captured(4));
|
||||
}
|
||||
|
||||
QString CrashReporter::describeOriginMismatch(const QString &envelopePath, const QString ¤tDsn)
|
||||
{
|
||||
const QString destination = describeDsn(currentDsn);
|
||||
if (destination.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QFile file(envelopePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return QString();
|
||||
}
|
||||
// The envelope header is the first line and is small; the cap only keeps a corrupt file from
|
||||
// being read in full.
|
||||
const QByteArray headerLine = file.readLine(4096).trimmed();
|
||||
if (headerLine.isEmpty() || !headerLine.startsWith('{')) {
|
||||
return QString();
|
||||
}
|
||||
const QString originDsn = QJsonDocument::fromJson(headerLine).object().value(QStringLiteral("dsn")).toString();
|
||||
const QString origin = describeDsn(originDsn);
|
||||
// Equal destinations are not a mismatch: a different key for the same host and project still
|
||||
// uploads where the crash was captured.
|
||||
if (origin.isEmpty() || origin == destination) {
|
||||
return QString();
|
||||
}
|
||||
return QStringLiteral("was captured for %1 but this session uploads with %2").arg(origin, destination);
|
||||
}
|
||||
|
||||
bool CrashReporter::isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode)
|
||||
{
|
||||
return !dsn.isEmpty() && consentMode != PersonalSettings::CrashReportingMode::NeverSend;
|
||||
}
|
||||
|
||||
void CrashReporter::init(const QString &dsn,
|
||||
const QString &release,
|
||||
const QString &databasePath,
|
||||
PersonalSettings::CrashReportingMode consentMode)
|
||||
{
|
||||
shutdown();
|
||||
sentryDatabasePath = databasePath;
|
||||
if (!isReportingEnabled(dsn, consentMode)) {
|
||||
if (!dsn.isEmpty()) {
|
||||
// A deliberate choice rather than a misconfiguration, so it is not a warning. The
|
||||
// no-DSN case is reported by the caller, which is the only place that knows whether
|
||||
// the environment or the build supplied the DSN.
|
||||
qCInfo(CrashReportLog) << "crash reporting disabled by preference (Never send)";
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Only created when the SDK will actually run: no-DSN launches stay side-effect free.
|
||||
#if COCKATRICE_WITH_CRASH_REPORTING
|
||||
// A crash captured by an earlier run is uploaded by THIS session's transport, which posts every
|
||||
// envelope with the DSN it was armed with (sentry-native keeps the DSN in the transport state,
|
||||
// and envelopes restored from disk are raw, so their own "dsn" header is never read). When the
|
||||
// two disagree the report lands in a project nobody chose, which no other log line reveals.
|
||||
const QStringList pendingEnvelopes = pendingEnvelopePaths(sentryDatabasePath);
|
||||
for (const QString &envelopePath : pendingEnvelopes) {
|
||||
const QString mismatch = describeOriginMismatch(envelopePath, dsn);
|
||||
if (!mismatch.isEmpty()) {
|
||||
qCWarning(CrashReportLog) << "pending crash envelope" << envelopePath << mismatch;
|
||||
}
|
||||
}
|
||||
qCInfo(CrashReportLog) << "crash reporting armed for" << describeDsn(dsn);
|
||||
QDir().mkpath(sentryDatabasePath);
|
||||
if (consentMode != PersonalSettings::CrashReportingMode::AlwaysSend) {
|
||||
// sentry-native persists consent to <database>/user-consent and reloads it in sentry_init
|
||||
// before old runs are processed, so a stale "given" from an earlier AlwaysSend run (or an
|
||||
// earlier Send in Ask mode) would upload the next crash before the dialog asks. Our
|
||||
// CrashReportingMode is the source of truth, so clear the persisted state first; asking
|
||||
// afterwards re-establishes it via giveConsent()/revokeConsent().
|
||||
QFile::remove(sentryDatabasePath + QStringLiteral("/user-consent"));
|
||||
}
|
||||
sentry_options_t *options = sentry_options_new();
|
||||
sentry_options_set_dsn(options, dsn.toUtf8().constData());
|
||||
sentry_options_set_release(options, release.toUtf8().constData());
|
||||
sentry_options_set_database_path(options, sentryDatabasePath.toUtf8().constData());
|
||||
// Consent-gated uploads: crashes are captured into the local database but
|
||||
// nothing leaves the machine until giveConsent(). cache_keep holds the previous run's
|
||||
// crash envelope in <database>/cache instead of dropping it at init, so the Ask dialog
|
||||
// decides its fate: giveConsent() flushes it via the transport retry, revokeConsent()
|
||||
// deletes it below. This also covers the first crash after switching to AlwaysSend,
|
||||
// which giveConsent() (running after init) would otherwise miss. http_retry is required
|
||||
// for the flush: without it the held envelope is written with a plain filename the retry
|
||||
// scanner skips, and giveConsent()'s sentry_transport_retry() sends nothing.
|
||||
sentry_options_set_require_user_consent(options, 1);
|
||||
sentry_options_set_cache_keep(options, SENTRY_CACHE_KEEP_OFFLINE);
|
||||
sentry_options_set_http_retry(options, 1);
|
||||
sentry_options_set_before_send(options, scrubEventBeforeSend, nullptr);
|
||||
ready = (sentry_init(options) == 0);
|
||||
#else
|
||||
Q_UNUSED(release);
|
||||
// Not a misconfiguration like an empty DSN: this build cannot report whatever the DSN says, so
|
||||
// a tester forcing a crash needs to know the difference.
|
||||
qCInfo(CrashReportLog) << "crash reporting unavailable: built without Sentry support";
|
||||
ready = false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool CrashReporter::isReady() const
|
||||
{
|
||||
return ready;
|
||||
}
|
||||
|
||||
void CrashReporter::shutdown()
|
||||
{
|
||||
#if COCKATRICE_WITH_CRASH_REPORTING
|
||||
if (ready) {
|
||||
sentry_close();
|
||||
}
|
||||
#endif
|
||||
ready = false;
|
||||
}
|
||||
|
||||
CrashReporter::~CrashReporter() = default;
|
||||
CrashReporter::CrashReporter() = default;
|
||||
|
||||
bool CrashReporter::beginSession(const QString &dir)
|
||||
{
|
||||
// Restartable: releasing first keeps begin->end->begin round-trips clean.
|
||||
endSession();
|
||||
|
||||
QDir().mkpath(dir);
|
||||
const QDir sessionDir(dir);
|
||||
const QString selfName = QStringLiteral("session-%1").arg(QCoreApplication::applicationPid());
|
||||
|
||||
bool unclean = false;
|
||||
const QStringList leftovers = sessionDir.entryList({QStringLiteral("session-*")}, QDir::Files);
|
||||
for (const QString &name : leftovers) {
|
||||
if (name == selfName) {
|
||||
continue;
|
||||
}
|
||||
const QString path = sessionDir.filePath(name);
|
||||
QLockFile probe(path);
|
||||
// Long-lived session lock: stale means "owner dead", regardless of file age, so a
|
||||
// crash followed by an immediate relaunch (younger than the 30 s default) is still
|
||||
// detected. tryLock() only deletes the file when it finds it stale, so a live
|
||||
// concurrent instance's lock is never touched here. (The static
|
||||
// QLockFile::removeStaleLockFile(path) form does not exist in Qt5/Qt6, and the
|
||||
// member removeStaleLockFile() force-removes without a staleness check, so neither
|
||||
// is usable for this scan.)
|
||||
probe.setStaleLockTime(0);
|
||||
if (probe.tryLock(0)) {
|
||||
unclean = true;
|
||||
probe.unlock(); // deletes the stale file
|
||||
continue;
|
||||
}
|
||||
// tryLock failed: either a live concurrent instance owns the file, or it is
|
||||
// unreadable (garbage/empty) and therefore has no live owner. Only the latter is
|
||||
// cleaned up; a live instance is left alone. On Unix QLockFile creates the file
|
||||
// before writing the pid, so a second instance starting at the same moment can
|
||||
// briefly observe an empty (or half-written) file for a live owner: re-read once
|
||||
// after a beat so a live owner's fresh lock is never mistaken for garbage and deleted.
|
||||
qint64 pid = 0;
|
||||
QString hostname, appname;
|
||||
if (!probe.getLockInfo(&pid, &hostname, &appname)) {
|
||||
QThread::msleep(100);
|
||||
if (!probe.getLockInfo(&pid, &hostname, &appname)) {
|
||||
QFile::remove(path);
|
||||
unclean = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionLockPath = sessionDir.filePath(selfName);
|
||||
sessionLock = std::make_unique<QLockFile>(sessionLockPath);
|
||||
sessionLock->setStaleLockTime(0);
|
||||
if (!sessionLock->tryLock(0)) {
|
||||
qCWarning(CrashReportLog) << "Failed to claim session lock at" << sessionLockPath;
|
||||
}
|
||||
return unclean;
|
||||
}
|
||||
|
||||
void CrashReporter::endSession()
|
||||
{
|
||||
if (sessionLock) {
|
||||
// unlock() removes the lock file; nothing left to clean up.
|
||||
sessionLock->unlock();
|
||||
sessionLock.reset();
|
||||
} else if (!sessionLockPath.isEmpty()) {
|
||||
// The lock was never claimed (a live concurrent instance owns its file): only our
|
||||
// own unclaimed file, if tryLock left one behind, is removed.
|
||||
QFile::remove(sessionLockPath);
|
||||
}
|
||||
sessionLockPath.clear();
|
||||
}
|
||||
|
||||
void CrashReporter::giveConsent()
|
||||
{
|
||||
#if COCKATRICE_WITH_CRASH_REPORTING
|
||||
if (ready) {
|
||||
sentry_user_consent_give();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void CrashReporter::revokeConsent()
|
||||
{
|
||||
#if COCKATRICE_WITH_CRASH_REPORTING
|
||||
// Revoke (not reset): an explicit refusal blocks uploads; reset would
|
||||
// return to unknown, which blocks uploads too but loses the decision.
|
||||
if (ready) {
|
||||
sentry_user_consent_revoke();
|
||||
// Drop the held crash envelope: without this it would sit in <database>/cache and
|
||||
// upload on the next give (e.g. after switching to AlwaysSend), after the user
|
||||
// explicitly refused it. The SDK recreates the directory as needed.
|
||||
QDir(sentryDatabasePath + QStringLiteral("/cache")).removeRecursively();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool CrashReporter::sendReport(const QString &redactedPreview, const QString &userNote)
|
||||
{
|
||||
#if COCKATRICE_WITH_CRASH_REPORTING
|
||||
if (!ready) {
|
||||
return false;
|
||||
}
|
||||
// Local scope (not the global one): the log preview rides only on this event. A global
|
||||
// sentry_attach_bytes would linger and ride along on every later event in the session,
|
||||
// including a real native crash later on — and with the crashpad backend byte attachments
|
||||
// are written to disk for exactly that. In-memory bytes also mean no report files land in
|
||||
// the Sentry DB dir, so there is no disk growth and no same-second filename collision.
|
||||
sentry_scope_t *scope = sentry_scope_new();
|
||||
if (scope == nullptr) {
|
||||
qCWarning(CrashReportLog) << "Could not create scope for crash report";
|
||||
return false;
|
||||
}
|
||||
const QByteArray previewBytes = redactedPreview.toUtf8();
|
||||
const sentry_uuid_t attachmentId = sentry_scope_attach_bytes(
|
||||
scope, previewBytes.constData(), static_cast<size_t>(previewBytes.size()), "crash-log.txt");
|
||||
const bool attachOk = sentry_uuid_is_nil(&attachmentId) == 0;
|
||||
if (!attachOk) {
|
||||
qCWarning(CrashReportLog) << "Could not attach crash log to crash report";
|
||||
}
|
||||
bool noteAttachOk = true;
|
||||
if (!userNote.isEmpty()) {
|
||||
const QByteArray noteBytes = userNote.toUtf8();
|
||||
const sentry_uuid_t noteId = sentry_scope_attach_bytes(scope, noteBytes.constData(),
|
||||
static_cast<size_t>(noteBytes.size()), "user-note.txt");
|
||||
noteAttachOk = sentry_uuid_is_nil(¬eId) == 0;
|
||||
if (!noteAttachOk) {
|
||||
qCWarning(CrashReportLog) << "Could not attach user note to crash report";
|
||||
}
|
||||
}
|
||||
|
||||
// Release is attached automatically from the init options. Deliberately no
|
||||
// sentry_set_user call: the report must carry no PII. The dialog collects
|
||||
// no contact info, so the message event + attachments are the whole payload
|
||||
// (sentry_value_new_feedback/sentry_capture_feedback exist but stay unused).
|
||||
sentry_value_t event = sentry_value_new_message_event(SENTRY_LEVEL_ERROR, "crash-report", "Crash report");
|
||||
const sentry_uuid_t eventId = sentry_scope_capture_event(scope, event, nullptr);
|
||||
sentry_scope_free(scope);
|
||||
if (sentry_uuid_is_nil(&eventId)) {
|
||||
qCWarning(CrashReportLog) << "Failed to capture crash report event";
|
||||
return false;
|
||||
}
|
||||
char eventIdStr[37] = {};
|
||||
sentry_uuid_as_string(&eventId, eventIdStr);
|
||||
// Capture only queues the envelope to the background transport: a network failure stays
|
||||
// invisible here, so true means "queued", not "delivered". The Save fallback is offered only
|
||||
// when the local capture itself fails.
|
||||
qCInfo(CrashReportLog) << "Crash report queued, event id:" << eventIdStr;
|
||||
// A failed attachment still captures the event above, but reports failure
|
||||
// so callers offer the local-save fallback instead of silent success.
|
||||
return attachOk && noteAttachOk;
|
||||
#else
|
||||
Q_UNUSED(redactedPreview);
|
||||
Q_UNUSED(userNote);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool CrashReporter::appendAttachmentToEnvelope(const QString &envelopePath,
|
||||
const QString &filename,
|
||||
const QByteArray &payload)
|
||||
{
|
||||
// QIODevice::ReadWrite creates a missing file; appending to a file that was never
|
||||
// written by the SDK would fabricate an envelope, so require it to exist.
|
||||
if (!QFile::exists(envelopePath)) {
|
||||
return false;
|
||||
}
|
||||
QFile file(envelopePath);
|
||||
if (!file.open(QIODevice::ReadWrite)) {
|
||||
return false;
|
||||
}
|
||||
// Item boundaries are single newlines: make sure the header starts on a fresh line.
|
||||
if (file.size() > 0) {
|
||||
if (!file.seek(file.size() - 1)) {
|
||||
return false;
|
||||
}
|
||||
char lastByte = 0;
|
||||
if (file.read(&lastByte, 1) != 1) {
|
||||
return false;
|
||||
}
|
||||
if (lastByte != '\n' && file.write("\n", 1) != 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Item shape mirrors sentry__envelope_add_attachment in the SDK. `length` is what the
|
||||
// SDK's envelope re-parser uses to frame the payload (a missing length ends the item at
|
||||
// the next newline, which would truncate a multi-line log and make the rest of the file
|
||||
// fail to parse); `attachment_length` is the logical attachment size the SDK also emits.
|
||||
// The filename is a compile-time ASCII constant from the callers, so no JSON escaping is
|
||||
// needed.
|
||||
const QByteArray header = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":") +
|
||||
QByteArray::number(payload.size()) + QByteArrayLiteral(",\"attachment_length\":") +
|
||||
QByteArray::number(payload.size()) + QByteArrayLiteral(",\"filename\":\"") +
|
||||
filename.toUtf8() + QByteArrayLiteral("\",\"content_type\":\"text/plain\"}") + '\n';
|
||||
if (file.write(header) != header.size()) {
|
||||
return false;
|
||||
}
|
||||
return file.write(payload) == payload.size();
|
||||
}
|
||||
|
||||
QStringList CrashReporter::pendingEnvelopePaths(const QString &databasePath)
|
||||
{
|
||||
static const auto isUuid = [](const QString &id) { return id.size() == 36 && id.count(QLatin1Char('-')) == 4; };
|
||||
static const auto isEnvelopeName = [](const QString &name) {
|
||||
if (name == QStringLiteral("__sentry-crash.envelope")) {
|
||||
return false;
|
||||
}
|
||||
const QString stem = name.left(name.size() - 9); // strip ".envelope"
|
||||
if (isUuid(stem)) {
|
||||
return true;
|
||||
}
|
||||
// Retry cache filename: "<ts>-<count>-<uuid>".
|
||||
const qsizetype firstSep = stem.indexOf(QLatin1Char('-'));
|
||||
const qsizetype secondSep = firstSep > 0 ? stem.indexOf(QLatin1Char('-'), firstSep + 1) : -1;
|
||||
return firstSep > 0 && secondSep > 0 && isUuid(stem.mid(secondSep + 1)) && stem.left(firstSep).toLongLong() > 0;
|
||||
};
|
||||
QStringList result;
|
||||
const QDir dbDir(databasePath);
|
||||
if (!dbDir.exists()) {
|
||||
return result;
|
||||
}
|
||||
// Consent-held envelopes (Ask mode: the SDK cached them at crash time instead
|
||||
// of sending, see sentry__submit_envelope). Bare "<uuid>.envelope" with
|
||||
// cache_keep only, "<ts>-<count>-<uuid>.envelope" with http_retry.
|
||||
const QDir cacheDir(databasePath + QStringLiteral("/cache"));
|
||||
const QStringList cacheNames = cacheDir.entryList({QStringLiteral("*.envelope")}, QDir::Files, QDir::Name);
|
||||
for (const QString &name : cacheNames) {
|
||||
if (isEnvelopeName(name)) {
|
||||
result.append(cacheDir.filePath(name));
|
||||
}
|
||||
}
|
||||
// In-flight envelopes dumped to the crashed run's own directory (AlwaysSend
|
||||
// mode: consent was already given, so the transport dump wrote them there and
|
||||
// sentry_init's process_old_runs replays them at startup — attach before init).
|
||||
const QStringList runDirs =
|
||||
dbDir.entryList({QStringLiteral("*.run")}, QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||
for (const QString &runDir : runDirs) {
|
||||
const QDir dir(dbDir.filePath(runDir));
|
||||
const QStringList names = dir.entryList({QStringLiteral("*.envelope")}, QDir::Files, QDir::Name);
|
||||
for (const QString &name : names) {
|
||||
if (isEnvelopeName(name)) {
|
||||
result.append(dir.filePath(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int CrashReporter::attachToPendingCrashEnvelopes(const QString &redactedLogText, const QString &userNoteText)
|
||||
{
|
||||
return attachToPendingCrashEnvelopes(sentryDatabasePath, redactedLogText, userNoteText);
|
||||
}
|
||||
|
||||
int CrashReporter::attachToPendingCrashEnvelopes(const QString &databasePath,
|
||||
const QString &redactedLogText,
|
||||
const QString &userNoteText)
|
||||
{
|
||||
if (databasePath.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
const QByteArray logBytes = redactedLogText.toUtf8();
|
||||
const QByteArray noteBytes = userNoteText.toUtf8();
|
||||
int attached = 0;
|
||||
const QStringList envelopes = pendingEnvelopePaths(databasePath);
|
||||
for (const QString &path : envelopes) {
|
||||
bool ok = appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), logBytes);
|
||||
if (ok && !noteBytes.isEmpty()) {
|
||||
ok = appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), noteBytes);
|
||||
}
|
||||
if (ok) {
|
||||
++attached;
|
||||
} else {
|
||||
qCWarning(CrashReportLog) << "Failed to attach crash log to pending envelope" << path;
|
||||
}
|
||||
}
|
||||
if (attached > 0) {
|
||||
qCInfo(CrashReportLog) << "Attached crash log and user note to" << attached << "pending crash envelope(s)";
|
||||
}
|
||||
return attached;
|
||||
}
|
||||
|
||||
QString CrashReporter::sanitizeLogLine(const QString &line)
|
||||
{
|
||||
static const auto colorEscapeCodePattern = QRegularExpression(QStringLiteral("\033\\[[0-9;]*m"));
|
||||
|
||||
QString sanitized = line;
|
||||
sanitized.replace(colorEscapeCodePattern, "");
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
QString CrashReporter::redactCockatriceUrls(const QString &line)
|
||||
{
|
||||
static auto urlPattern = QRegularExpression(QStringLiteral("cockatrice://\\S+"));
|
||||
|
||||
// Single pass over the original line: rescanning the replaced text would match a redacted
|
||||
// secret-free URL again and loop forever.
|
||||
QString redacted;
|
||||
redacted.reserve(line.size());
|
||||
qsizetype pos = 0;
|
||||
QRegularExpressionMatchIterator it = urlPattern.globalMatch(line);
|
||||
while (it.hasNext()) {
|
||||
QRegularExpressionMatch match = it.next();
|
||||
redacted.append(line.mid(pos, match.capturedStart() - pos));
|
||||
// The pattern only matches cockatrice:// URLs, so the check is folded into the parse:
|
||||
// scheme://host, secrets dropped.
|
||||
const QUrl parsed(match.captured(0));
|
||||
redacted.append(parsed.scheme() + QStringLiteral("://") + parsed.host());
|
||||
pos = match.capturedEnd();
|
||||
}
|
||||
redacted.append(line.mid(pos));
|
||||
return redacted;
|
||||
}
|
||||
177
cockatrice/src/client/crash/crash_reporter.h
Normal file
177
cockatrice/src/client/crash/crash_reporter.h
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* @file crash_reporter.h
|
||||
* @ingroup Core
|
||||
*/
|
||||
|
||||
#ifndef CRASH_REPORTER_H
|
||||
#define CRASH_REPORTER_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QLoggingCategory>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <memory>
|
||||
|
||||
#ifndef COCKATRICE_WITH_CRASH_REPORTING
|
||||
#define COCKATRICE_WITH_CRASH_REPORTING 0
|
||||
#endif
|
||||
|
||||
#ifndef COCKATRICE_SENTRY_DSN
|
||||
#define COCKATRICE_SENTRY_DSN ""
|
||||
#endif
|
||||
|
||||
#ifndef COCKATRICE_SENTRY_RELEASE
|
||||
#define COCKATRICE_SENTRY_RELEASE ""
|
||||
#endif
|
||||
|
||||
class QLockFile;
|
||||
|
||||
Q_DECLARE_LOGGING_CATEGORY(CrashReportLog);
|
||||
|
||||
/**
|
||||
* @brief Sentry-backed crash-report facade, owned by main().
|
||||
*
|
||||
* An empty DSN (or Never consent) skips the SDK entirely, so the mock dialog
|
||||
* fallback behaves exactly as before. All SDK calls are guarded so OFF builds
|
||||
* compile to no-ops.
|
||||
*
|
||||
* @par Consent gating
|
||||
* init() arms require-user-consent and before_send scrubbing. Stale persisted
|
||||
* consent from a previous AlwaysSend run is cleared before init when the mode is
|
||||
* not AlwaysSend, and cached crash envelopes are held for the dialog instead of
|
||||
* being dropped. giveConsent()/revokeConsent() gate uploads.
|
||||
*
|
||||
* @par Envelope and log helpers
|
||||
* attachToPendingCrashEnvelopes() folds the redacted log and the optional user
|
||||
* note into the pending native crash event(s) before consent releases them, so
|
||||
* dump, stack, log and note arrive as ONE Sentry event. sendReport() is the
|
||||
* fallback for unclean exits that produced no native crash event, capturing a
|
||||
* redacted message event with the log preview attached. The log redaction
|
||||
* helpers are static and SDK-independent.
|
||||
*
|
||||
* @par Session tracking
|
||||
* beginSession()/endSession() are SDK-independent: a QLockFile per process in
|
||||
* the database dir detects unclean exits cross-platform, replacing the legacy
|
||||
* Windows-only .dmp scan. The held lock makes the reporter non-copyable and
|
||||
* non-movable.
|
||||
*/
|
||||
class CrashReporter
|
||||
{
|
||||
public:
|
||||
CrashReporter();
|
||||
CrashReporter(const CrashReporter &) = delete;
|
||||
CrashReporter &operator=(const CrashReporter &) = delete;
|
||||
CrashReporter(CrashReporter &&) = delete;
|
||||
CrashReporter &operator=(CrashReporter &&) = delete;
|
||||
~CrashReporter();
|
||||
|
||||
void init(const QString &dsn,
|
||||
const QString &release,
|
||||
const QString &databasePath,
|
||||
PersonalSettings::CrashReportingMode consentMode);
|
||||
[[nodiscard]] bool isReady() const;
|
||||
void shutdown();
|
||||
void giveConsent();
|
||||
void revokeConsent();
|
||||
bool sendReport(const QString &redactedPreview, const QString &userNote = QString());
|
||||
|
||||
/**
|
||||
* @brief Append the redacted log (and optional user note) to every pending
|
||||
* native crash envelope in the local database, so the crashed event uploads
|
||||
* with dump, stack, log and note together instead of the log arriving as a
|
||||
* separate event that has to be correlated by hand. Both storage locations
|
||||
* are covered: consent-held envelopes in <database>/cache (Ask mode) and
|
||||
* in-flight envelopes dumped into <database>/<run>.run (AlwaysSend mode).
|
||||
*
|
||||
* For the AlwaysSend variant run this BEFORE init() (static overload): the
|
||||
* startup replay of *.run envelopes happens inside sentry_init. SDK-independent
|
||||
* plain-file surgery on the Sentry envelope format, so it stays unit-testable
|
||||
* in OFF builds.
|
||||
*
|
||||
* @return the number of envelopes that received the attachments.
|
||||
*/
|
||||
int attachToPendingCrashEnvelopes(const QString &redactedLogText, const QString &userNoteText);
|
||||
static int attachToPendingCrashEnvelopes(const QString &databasePath,
|
||||
const QString &redactedLogText,
|
||||
const QString &userNoteText);
|
||||
|
||||
/**
|
||||
* @brief Append one attachment item to a cached Sentry envelope file.
|
||||
*
|
||||
* An envelope is a newline-separated stream of item-header-line + payload
|
||||
* pairs. The retry transport replays cached envelopes byte-for-byte
|
||||
* (sentry-native 0.17.3 reads them raw and serializes raw envelopes back
|
||||
* verbatim), so appending well-formed items associates them with the event
|
||||
* already in the file; the first line (event id header) is never touched.
|
||||
*
|
||||
* @param filename ASCII-only item filename, embedded verbatim in the header.
|
||||
*/
|
||||
static bool
|
||||
appendAttachmentToEnvelope(const QString &envelopePath, const QString &filename, const QByteArray &payload);
|
||||
|
||||
/**
|
||||
* @brief Pending crash envelopes in <database>: the consent-held files in
|
||||
* <database>/cache and the in-flight files in <database>/<run>.run.
|
||||
*
|
||||
* Matches the cache filename conventions sentry-native accepts (see
|
||||
* sentry__parse_cache_filename): bare "<uuid>.envelope" and retry
|
||||
* "<ts>-<count>-<uuid>.envelope"; run dirs accept plain "<uuid>.envelope"
|
||||
* minus the SDK's own "__sentry-crash.envelope" marker.
|
||||
*/
|
||||
static QStringList pendingEnvelopePaths(const QString &databasePath);
|
||||
|
||||
/**
|
||||
* @brief SDK-independent gating decision, unit-testable in OFF builds.
|
||||
*/
|
||||
static bool isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode);
|
||||
|
||||
/**
|
||||
* @brief Whether a DSN has the shape the build and the SDK require:
|
||||
* <scheme>://<key>@<host>/<project-id>.
|
||||
*
|
||||
* Deliberately the same unanchored pattern cmake/CrashReporting.cmake matches (CMake's MATCHES
|
||||
* searches rather than anchoring), so a DSN this build accepted is never rejected here. The SDK
|
||||
* refuses to even build a request for a DSN it considers invalid, so an unchecked typo disables
|
||||
* uploading with no error anywhere.
|
||||
*/
|
||||
static bool isDsnWellFormed(const QString &dsn);
|
||||
|
||||
/**
|
||||
* @brief Human-readable destination of a DSN, e.g. "o0.ingest.sentry.io (project 1234567)".
|
||||
*
|
||||
* Safe to log: the public key is never part of the result. Empty for a malformed DSN.
|
||||
*/
|
||||
static QString describeDsn(const QString &dsn);
|
||||
|
||||
/**
|
||||
* @brief Describe the captured-for DSN of a pending envelope when it points at a different
|
||||
* destination than the DSN this session would upload it with; empty when they agree.
|
||||
*
|
||||
* sentry-native posts every envelope with the DSN held in the transport state of the session
|
||||
* doing the sending (envelopes restored from disk are raw and their own "dsn" header is never
|
||||
* read), so a cached crash envelope captured for one project can silently be uploaded to
|
||||
* another - the exact way a tester's report lands in the wrong place. Compares destinations
|
||||
* (host and project id), not raw strings, so a different key for the same project stays quiet.
|
||||
*/
|
||||
static QString describeOriginMismatch(const QString &envelopePath, const QString ¤tDsn);
|
||||
|
||||
/**
|
||||
* @brief Claim this process's session lock, reporting a previous unclean exit.
|
||||
* @return true if a leftover session lock from a dead process (or an unreadable
|
||||
* garbage file) was found and cleaned; live concurrent instances are ignored.
|
||||
*/
|
||||
bool beginSession(const QString &dir);
|
||||
void endSession();
|
||||
|
||||
static QString sanitizeLogLine(const QString &line);
|
||||
static QString redactCockatriceUrls(const QString &line);
|
||||
|
||||
private:
|
||||
bool ready = false;
|
||||
QString sentryDatabasePath;
|
||||
QString sessionLockPath;
|
||||
std::unique_ptr<QLockFile> sessionLock;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QFlags>
|
||||
#include <QIODevice>
|
||||
#include <QLocale>
|
||||
|
|
@ -18,8 +20,11 @@
|
|||
|
||||
#define LOGGER_MAX_ENTRIES 128
|
||||
#define LOGGER_FILENAME "qdebug.txt"
|
||||
#define LOGGER_SESSION_MARKER "Log session started at"
|
||||
#define LOGGER_PERSISTENT_MAX_BYTES (512 * 1024)
|
||||
#define LOGGER_PERSISTENT_KEEP_LINES 2000
|
||||
|
||||
Logger::Logger() : logToFileEnabled(false)
|
||||
Logger::Logger() : logToFileEnabled(false), persistentLogEnabled(false)
|
||||
{
|
||||
logBuffer.append(getClientVersion());
|
||||
logBuffer.append(getSystemArchitecture());
|
||||
|
|
@ -35,6 +40,7 @@ Logger::Logger() : logToFileEnabled(false)
|
|||
Logger::~Logger()
|
||||
{
|
||||
closeLogfileSession();
|
||||
closePersistentLogSession();
|
||||
logBuffer.clear();
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +88,137 @@ void Logger::closeLogfileSession()
|
|||
fileHandle.close();
|
||||
}
|
||||
|
||||
void Logger::setPersistentLogPath(const QString &path)
|
||||
{
|
||||
QMutexLocker locker(&mutex);
|
||||
if (path.isEmpty() || (persistentLogEnabled && persistentLogPath == path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
persistentLogPath = path;
|
||||
openPersistentLog(path);
|
||||
}
|
||||
|
||||
void Logger::prunePersistentLog(const QString &path)
|
||||
{
|
||||
QFileInfo info(path);
|
||||
if (!info.exists() || info.size() <= LOGGER_PERSISTENT_MAX_BYTES) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return;
|
||||
}
|
||||
QList<QString> allLines;
|
||||
QTextStream in(&file);
|
||||
while (!in.atEnd()) {
|
||||
allLines.append(in.readLine());
|
||||
}
|
||||
file.close();
|
||||
|
||||
const int start =
|
||||
allLines.size() > LOGGER_PERSISTENT_KEEP_LINES ? allLines.size() - LOGGER_PERSISTENT_KEEP_LINES : 0;
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||
return;
|
||||
}
|
||||
QTextStream out(&file);
|
||||
for (int i = start; i < allLines.size(); ++i) {
|
||||
out << allLines.at(i) << Qt::endl;
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
|
||||
void Logger::openPersistentLog(const QString &path)
|
||||
{
|
||||
prunePersistentLog(path);
|
||||
|
||||
persistentFileHandle.setFileName(path);
|
||||
if (!persistentFileHandle.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
||||
qWarning() << "Logger failed to open persistent log" << path << "for writing";
|
||||
return;
|
||||
}
|
||||
persistentFileStream.setDevice(&persistentFileHandle);
|
||||
persistentFileStream << LOGGER_SESSION_MARKER << " " << QDateTime::currentDateTime().toString() << Qt::endl;
|
||||
persistentFileStream << getClientVersion() << Qt::endl;
|
||||
persistentFileStream << getSystemArchitecture() << Qt::endl;
|
||||
persistentFileStream << getClientInstallInfo() << Qt::endl;
|
||||
persistentFileStream.flush();
|
||||
persistentLogEnabled = true;
|
||||
}
|
||||
|
||||
void Logger::closePersistentLogSession()
|
||||
{
|
||||
if (!persistentLogEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
persistentLogEnabled = false;
|
||||
persistentFileStream << "Log session closed at " << QDateTime::currentDateTime().toString() << Qt::endl;
|
||||
persistentFileStream.flush();
|
||||
persistentFileHandle.close();
|
||||
}
|
||||
|
||||
QList<QString> Logger::getPreviousSessionTail(int maxLines) const
|
||||
{
|
||||
QString path;
|
||||
{
|
||||
QMutexLocker locker(&mutex);
|
||||
path = persistentLogPath;
|
||||
}
|
||||
if (path.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
return extractPreviousSessionTail(path, maxLines);
|
||||
}
|
||||
|
||||
QList<QString> Logger::extractPreviousSessionTail(const QString &filePath, int maxLines)
|
||||
{
|
||||
QList<QString> result;
|
||||
if (maxLines <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return result;
|
||||
}
|
||||
QList<QString> allLines;
|
||||
QTextStream in(&file);
|
||||
while (!in.atEnd()) {
|
||||
allLines.append(in.readLine());
|
||||
}
|
||||
file.close();
|
||||
|
||||
QList<int> markerIndexes;
|
||||
for (int i = 0; i < allLines.size(); ++i) {
|
||||
if (allLines.at(i).contains(QLatin1String(LOGGER_SESSION_MARKER))) {
|
||||
markerIndexes.append(i);
|
||||
}
|
||||
}
|
||||
|
||||
QList<QString> tail;
|
||||
if (markerIndexes.isEmpty()) {
|
||||
tail = allLines;
|
||||
} else if (markerIndexes.size() == 1) {
|
||||
// Only the current session has been recorded; there is no previous session yet.
|
||||
return result;
|
||||
} else {
|
||||
const int previousStart = markerIndexes.at(markerIndexes.size() - 2);
|
||||
const int currentStart = markerIndexes.last();
|
||||
for (int i = previousStart; i < currentStart; ++i) {
|
||||
tail.append(allLines.at(i));
|
||||
}
|
||||
}
|
||||
|
||||
const int start = tail.size() > maxLines ? tail.size() - maxLines : 0;
|
||||
for (int i = start; i < tail.size(); ++i) {
|
||||
result.append(tail.at(i));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void Logger::log(QtMsgType /* type */, const QMessageLogContext & /* ctx */, const QString &message)
|
||||
{
|
||||
QMetaObject::invokeMethod(this, "internalLog", Qt::QueuedConnection, Q_ARG(const QString &, message));
|
||||
|
|
@ -102,6 +239,11 @@ void Logger::internalLog(const QString &message)
|
|||
if (logToFileEnabled) {
|
||||
fileStream << message << Qt::endl; // Print to fileStream
|
||||
}
|
||||
|
||||
if (persistentLogEnabled) {
|
||||
persistentFileStream << message << Qt::endl;
|
||||
persistentFileStream.flush();
|
||||
}
|
||||
}
|
||||
|
||||
QString Logger::getSystemArchitecture()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ public:
|
|||
{
|
||||
return logBuffer;
|
||||
}
|
||||
void setPersistentLogPath(const QString &path);
|
||||
QList<QString> getPreviousSessionTail(int maxLines = 60) const;
|
||||
static QList<QString> extractPreviousSessionTail(const QString &filePath, int maxLines = 60);
|
||||
|
||||
private:
|
||||
Logger();
|
||||
|
|
@ -61,11 +64,18 @@ private:
|
|||
QTextStream fileStream;
|
||||
QFile fileHandle;
|
||||
QList<QString> logBuffer;
|
||||
QMutex mutex;
|
||||
mutable QMutex mutex;
|
||||
QString persistentLogPath;
|
||||
bool persistentLogEnabled;
|
||||
QTextStream persistentFileStream;
|
||||
QFile persistentFileHandle;
|
||||
|
||||
protected:
|
||||
void openLogfileSession();
|
||||
void closeLogfileSession();
|
||||
void openPersistentLog(const QString &path);
|
||||
void prunePersistentLog(const QString &path);
|
||||
void closePersistentLogSession();
|
||||
|
||||
protected slots:
|
||||
void internalLog(const QString &message);
|
||||
|
|
|
|||
245
cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp
Normal file
245
cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
#include "dlg_crash_report.h"
|
||||
|
||||
#include "../../../client/crash/crash_reporter.h"
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../logger.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QEvent>
|
||||
#include <QFile>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QList>
|
||||
#include <QMessageBox>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
|
||||
DlgCrashReport::DlgCrashReport(QWidget *parent, bool _realSending, CrashReporter *_reporter)
|
||||
: QDialog(parent), realSending(_realSending), reporter(_reporter)
|
||||
{
|
||||
infoLabel = new QLabel(this);
|
||||
infoLabel->setWordWrap(true);
|
||||
|
||||
accountLabel = new QLabel(this);
|
||||
accountLabel->setWordWrap(true);
|
||||
|
||||
// Constructed before previewArea: Qt derives the default Tab chain from construction
|
||||
// order, and note sits visually above the log excerpt, so this keeps Tab moving downward.
|
||||
noteArea = new QPlainTextEdit(this);
|
||||
// Optional free text ("what I was doing when it crashed") — a few lines are
|
||||
// enough for a description, leave the rest of the space to the log excerpt.
|
||||
noteArea->setMaximumHeight(70);
|
||||
|
||||
previewArea = new QPlainTextEdit(this);
|
||||
previewArea->setReadOnly(true);
|
||||
previewArea->setPlainText(buildRedactedPreview());
|
||||
|
||||
rememberBox = new QCheckBox(this);
|
||||
|
||||
sendButton = new QPushButton(this);
|
||||
sendButton->setDefault(true);
|
||||
connect(sendButton, &QPushButton::clicked, this, &DlgCrashReport::actSend);
|
||||
|
||||
dontSendButton = new QPushButton(this);
|
||||
dontSendButton->setAutoDefault(false);
|
||||
connect(dontSendButton, &QPushButton::clicked, this, &DlgCrashReport::actDontSend);
|
||||
|
||||
auto *buttonLayout = new QHBoxLayout;
|
||||
buttonLayout->addWidget(rememberBox);
|
||||
buttonLayout->addStretch();
|
||||
buttonLayout->addWidget(dontSendButton);
|
||||
buttonLayout->addWidget(sendButton);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout;
|
||||
mainLayout->addWidget(infoLabel);
|
||||
mainLayout->addWidget(accountLabel);
|
||||
mainLayout->addWidget(noteArea);
|
||||
mainLayout->addWidget(previewArea);
|
||||
mainLayout->addLayout(buttonLayout);
|
||||
setLayout(mainLayout);
|
||||
|
||||
resize(700, 500);
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void DlgCrashReport::changeEvent(QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::LanguageChange) {
|
||||
retranslateUi();
|
||||
}
|
||||
QDialog::changeEvent(event);
|
||||
}
|
||||
|
||||
void DlgCrashReport::retranslateUi()
|
||||
{
|
||||
setWindowTitle(tr("Send crash report"));
|
||||
if (realSending) {
|
||||
// The preview below prefers the crashed session's persisted log tail
|
||||
// (Logger::getPreviousSessionTail), falling back to this restart's in-memory buffer only
|
||||
// when no previous session was saved. On send it is attached as crash-log.txt to the
|
||||
// pending native crash event (dump + stack + log + note in one report); when no native
|
||||
// crash event exists, it rides the fallback message event instead.
|
||||
infoLabel->setText(tr("Oops, we crashed — and now we need your help to make Cockatrice better!\n\n"
|
||||
"The good thing is, helping is easy and convenient. Sending uploads what the "
|
||||
"previous run left behind when it crashed: the crash dump with the stack trace, "
|
||||
"the app version and operating system info, the redacted log excerpt shown below, "
|
||||
"and anything you write in the note box. If no log from the crashed session was "
|
||||
"saved, this restart's early logs are shown instead.\n\n"
|
||||
"Reports are what let us fix crashes like this one. Check \u201cRemember my choice\u201d "
|
||||
"and Send, and future crash reports are sent automatically without interruption."));
|
||||
} else {
|
||||
infoLabel->setText(tr("(Mock preview — nothing will be uploaded.)\n\n"
|
||||
"Cockatrice closed unexpectedly. A real report would contain what the previous run "
|
||||
"left behind when it crashed: the crash dump with the stack trace, the app version "
|
||||
"and operating system info, the redacted log excerpt shown below, and anything you "
|
||||
"write in the note box. If no log from the crashed session was saved, this restart's "
|
||||
"early logs are shown instead.\n\n"
|
||||
"A mock file is written locally for inspection."));
|
||||
}
|
||||
accountLabel->setText(tr("No account needed, and no contact info is collected. Login links "
|
||||
"(cockatrice://...) are scrubbed before sending."));
|
||||
noteArea->setPlaceholderText(tr("(Optional) Describe what you were doing when it crashed"));
|
||||
// Direction-neutral on purpose: the same box also remembers a "Don't send" decision
|
||||
// (reject() persists NeverSend when it is checked), so it must not promise sending.
|
||||
rememberBox->setText(tr("Remember my choice"));
|
||||
sendButton->setText(tr("Send"));
|
||||
dontSendButton->setText(tr("Don't send"));
|
||||
}
|
||||
|
||||
QString DlgCrashReport::buildRedactedPreview()
|
||||
{
|
||||
Logger &logger = Logger::getInstance();
|
||||
|
||||
QStringList header;
|
||||
header.append(logger.getClientVersion());
|
||||
header.append(logger.getSystemArchitecture());
|
||||
header.append(logger.getSystemLocale());
|
||||
header.append(logger.getClientInstallInfo());
|
||||
header.append(QStringLiteral("---"));
|
||||
|
||||
QList<QString> logLines = logger.getPreviousSessionTail();
|
||||
if (logLines.isEmpty()) {
|
||||
logLines = logger.getLogBuffer();
|
||||
}
|
||||
const int previewLines = 60;
|
||||
int start = logLines.size() > previewLines ? logLines.size() - previewLines : 0;
|
||||
|
||||
QStringList lines = header;
|
||||
for (int i = start; i < logLines.size(); ++i) {
|
||||
lines.append(CrashReporter::redactCockatriceUrls(CrashReporter::sanitizeLogLine(logLines.at(i))));
|
||||
}
|
||||
return lines.join(QStringLiteral("\n"));
|
||||
}
|
||||
|
||||
QString DlgCrashReport::writeMockReport(const QString &previewText, const QString &userNote)
|
||||
{
|
||||
Logger &logger = Logger::getInstance();
|
||||
|
||||
QJsonObject report;
|
||||
report[QStringLiteral("mock")] = true;
|
||||
report[QStringLiteral("timestamp")] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
report[QStringLiteral("clientVersion")] = logger.getClientVersion();
|
||||
report[QStringLiteral("systemArchitecture")] = logger.getSystemArchitecture();
|
||||
report[QStringLiteral("systemLocale")] = logger.getSystemLocale();
|
||||
report[QStringLiteral("installInfo")] = logger.getClientInstallInfo();
|
||||
report[QStringLiteral("logPreview")] = previewText;
|
||||
report[QStringLiteral("userNote")] = userNote;
|
||||
|
||||
QString fileName = QStringLiteral("crash-report-mock-%1.json")
|
||||
.arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-HHmmss")));
|
||||
QString filePath = QDir::tempPath() + QStringLiteral("/") + fileName;
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
return QString();
|
||||
}
|
||||
file.write(QJsonDocument(report).toJson());
|
||||
file.close();
|
||||
return filePath;
|
||||
}
|
||||
|
||||
void DlgCrashReport::actSend()
|
||||
{
|
||||
if (rememberBox->isChecked()) {
|
||||
SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::AlwaysSend);
|
||||
}
|
||||
const QString preview = previewArea->toPlainText();
|
||||
const QString userNote = noteArea->toPlainText().trimmed();
|
||||
if (realSending && reporter != nullptr) {
|
||||
// Fold log and note into the pending native crash event(s) BEFORE consent:
|
||||
// giveConsent() releases the held envelopes for upload, so dump, stack,
|
||||
// log and note arrive as one Sentry event. Without a pending crash event
|
||||
// (unclean exit that produced no native crash) this returns 0 and
|
||||
// sendReport() falls back to a standalone message event with the same
|
||||
// attachments.
|
||||
const int attached = reporter->attachToPendingCrashEnvelopes(preview, userNote);
|
||||
reporter->giveConsent();
|
||||
if (attached > 0 || reporter->sendReport(preview, userNote)) {
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
qCWarning(CrashReportLog) << "Failed to send crash report";
|
||||
// Outcome mapping for the failure box: Save = store locally and close
|
||||
// as accepted (the follow-up info box shows the path, so the user
|
||||
// knows it was NOT sent); Discard = revoke consent and close as
|
||||
// rejected (dropped); Cancel/X = back to the dialog, neither sent nor
|
||||
// dropped. The Remember checkbox governs only the original
|
||||
// Send/Don't-send decision, so the Discard path below revokes and
|
||||
// closes via QDialog::reject() without touching the setting (calling
|
||||
// our reject() override here would wrongly persist NeverSend over the
|
||||
// AlwaysSend stored above when Remember was checked).
|
||||
QMessageBox failureBox(QMessageBox::Warning, tr("Send crash report"),
|
||||
tr("Sending the crash report failed. Save the report locally instead?"),
|
||||
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, this);
|
||||
failureBox.setDefaultButton(QMessageBox::Save);
|
||||
failureBox.setEscapeButton(QMessageBox::Cancel);
|
||||
const QMessageBox::StandardButton choice = static_cast<QMessageBox::StandardButton>(failureBox.exec());
|
||||
if (choice == QMessageBox::Save) {
|
||||
const QString savedPath = writeMockReport(preview, userNote);
|
||||
if (savedPath.isEmpty()) {
|
||||
QMessageBox::warning(this, tr("Send crash report"), tr("Could not save the crash report locally."));
|
||||
} else {
|
||||
QMessageBox::information(this, tr("Send crash report"),
|
||||
tr("Crash report saved locally to:\n%1").arg(savedPath));
|
||||
}
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
if (choice == QMessageBox::Discard) {
|
||||
reporter->revokeConsent();
|
||||
QDialog::reject();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const QString savedPath = writeMockReport(preview, userNote);
|
||||
if (savedPath.isEmpty()) {
|
||||
qCWarning(CrashReportLog) << "Could not save the mock crash report locally";
|
||||
} else {
|
||||
qCInfo(CrashReportLog) << "Mock crash report saved locally to" << savedPath;
|
||||
}
|
||||
accept();
|
||||
}
|
||||
|
||||
void DlgCrashReport::actDontSend()
|
||||
{
|
||||
reject();
|
||||
}
|
||||
|
||||
void DlgCrashReport::reject()
|
||||
{
|
||||
if (rememberBox->isChecked()) {
|
||||
SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::NeverSend);
|
||||
}
|
||||
if (realSending && reporter != nullptr) {
|
||||
reporter->revokeConsent();
|
||||
}
|
||||
QDialog::reject();
|
||||
}
|
||||
63
cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h
Normal file
63
cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* @file dlg_crash_report.h
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
|
||||
#ifndef DLG_CRASH_REPORT_H
|
||||
#define DLG_CRASH_REPORT_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QString>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class QCheckBox;
|
||||
class QLabel;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
|
||||
class CrashReporter;
|
||||
|
||||
/**
|
||||
* @brief Crash-report dialog: mock fallback or real Sentry send.
|
||||
*
|
||||
* Mock mode (default): the Send path writes a redacted JSON file to the temp
|
||||
* dir instead of uploading anything. Real mode (realSending, with a live
|
||||
* CrashReporter): Send first folds the redacted log and the optional user note
|
||||
* into the pending native crash event(s), then gives consent so dump, stack,
|
||||
* log and note upload as one Sentry event; when no pending crash event exists
|
||||
* it falls back to a standalone message event with the same attachments.
|
||||
* Failure offers a local save instead.
|
||||
*/
|
||||
class DlgCrashReport : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DlgCrashReport(QWidget *parent = nullptr, bool realSending = false, CrashReporter *_reporter = nullptr);
|
||||
|
||||
static QString buildRedactedPreview();
|
||||
static QString writeMockReport(const QString &previewText, const QString &userNote = QString());
|
||||
|
||||
void reject() override;
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
QLabel *infoLabel;
|
||||
QLabel *accountLabel;
|
||||
QPlainTextEdit *previewArea;
|
||||
QPlainTextEdit *noteArea;
|
||||
QCheckBox *rememberBox;
|
||||
QPushButton *sendButton;
|
||||
QPushButton *dontSendButton;
|
||||
bool realSending = false;
|
||||
CrashReporter *reporter = nullptr;
|
||||
|
||||
void retranslateUi();
|
||||
|
||||
private slots:
|
||||
void actSend();
|
||||
void actDontSend();
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
@ -133,6 +133,27 @@ GeneralSettingsPage::GeneralSettingsPage()
|
|||
versionGroupBox = new QGroupBox;
|
||||
versionGroupBox->setLayout(versionGrid);
|
||||
|
||||
// crash reporting consent (stored in personal.ini, not debug.ini)
|
||||
crashReportingBox.addItem(""); // texts set in retranslateUi
|
||||
crashReportingBox.addItem("");
|
||||
crashReportingBox.addItem("");
|
||||
crashReportingBox.setCurrentIndex(static_cast<int>(settings.personal().getCrashReportingMode()));
|
||||
|
||||
connect(&crashReportingBox, qOverload<int>(&QComboBox::currentIndexChanged), this, [](int index) {
|
||||
if (index < 0 || index > static_cast<int>(PersonalSettings::CrashReportingMode::NeverSend)) {
|
||||
return;
|
||||
}
|
||||
SettingsCache::instance().personal().setCrashReportingMode(
|
||||
static_cast<PersonalSettings::CrashReportingMode>(index));
|
||||
});
|
||||
|
||||
auto *crashReportingGrid = new QGridLayout;
|
||||
crashReportingGrid->addWidget(&crashReportingLabel, 0, 0);
|
||||
crashReportingGrid->addWidget(&crashReportingBox, 0, 1);
|
||||
|
||||
crashReportingGroupBox = new QGroupBox;
|
||||
crashReportingGroupBox->setLayout(crashReportingGrid);
|
||||
|
||||
// card database settings
|
||||
startupCardUpdateCheckBehaviorSelector.addItem(""); // these will be set in retranslateUI
|
||||
startupCardUpdateCheckBehaviorSelector.addItem("");
|
||||
|
|
@ -336,6 +357,7 @@ GeneralSettingsPage::GeneralSettingsPage()
|
|||
auto *mainLayout = new QVBoxLayout;
|
||||
mainLayout->addWidget(languageGroupBox);
|
||||
mainLayout->addWidget(versionGroupBox);
|
||||
mainLayout->addWidget(crashReportingGroupBox);
|
||||
mainLayout->addWidget(cardDatabaseGroupBox);
|
||||
mainLayout->addWidget(startupGroupBox);
|
||||
mainLayout->addWidget(pathsGroupBox);
|
||||
|
|
@ -569,13 +591,19 @@ void GeneralSettingsPage::retranslateUi()
|
|||
tr("English and selected card language"));
|
||||
advertiseTranslationPageLabel.setText(
|
||||
QString("<a href='%1'>%2</a>").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));
|
||||
|
||||
versionGroupBox->setTitle(tr("Version settings"));
|
||||
updateReleaseChannelLabel.setText(tr("Update channel"));
|
||||
startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup"));
|
||||
updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client"));
|
||||
newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice"));
|
||||
|
||||
crashReportingGroupBox->setTitle(tr("Crash reporting"));
|
||||
crashReportingLabel.setText(tr("Send crash reports:"));
|
||||
crashReportingBox.setItemText(static_cast<int>(PersonalSettings::CrashReportingMode::Ask), tr("Ask every time"));
|
||||
crashReportingBox.setItemText(static_cast<int>(PersonalSettings::CrashReportingMode::AlwaysSend),
|
||||
tr("Always send"));
|
||||
crashReportingBox.setItemText(static_cast<int>(PersonalSettings::CrashReportingMode::NeverSend), tr("Never send"));
|
||||
|
||||
// We can't change the strings after they're put into the QComboBox, so this is our workaround
|
||||
int oldIndex = updateReleaseChannelBox.currentIndex();
|
||||
updateReleaseChannelBox.clear();
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ private slots:
|
|||
private:
|
||||
QGroupBox *languageGroupBox;
|
||||
QGroupBox *versionGroupBox;
|
||||
QGroupBox *crashReportingGroupBox;
|
||||
QGroupBox *cardDatabaseGroupBox;
|
||||
QGroupBox *startupGroupBox;
|
||||
QGroupBox *pathsGroupBox;
|
||||
|
|
@ -70,6 +71,9 @@ private:
|
|||
QCheckBox updateNotificationCheckBox;
|
||||
QCheckBox newVersionOracleCheckBox;
|
||||
|
||||
QLabel crashReportingLabel;
|
||||
QComboBox crashReportingBox;
|
||||
|
||||
QLabel startupCardUpdateCheckBehaviorLabel;
|
||||
QComboBox startupCardUpdateCheckBehaviorSelector;
|
||||
QLabel cardUpdateCheckIntervalLabel;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
#include "main.h"
|
||||
|
||||
#include "client/crash/crash_reporter.h"
|
||||
#include "client/network/update/card_spoiler/spoiler_background_updater.h"
|
||||
#include "client/settings/cache_settings.h"
|
||||
#include "client/sound_engine.h"
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
#include "interface/logger.h"
|
||||
#include "interface/pixel_map_generator.h"
|
||||
#include "interface/theme_manager.h"
|
||||
#include "interface/widgets/dialogs/dlg_crash_report.h"
|
||||
#include "interface/window_main.h"
|
||||
#include "libcockatrice/card/database/card_database.h"
|
||||
#include "libcockatrice/rng/rng_abstract.h"
|
||||
|
|
@ -103,60 +105,6 @@ static void CockatriceLogger(QtMsgType type, const QMessageLogContext &ctx, cons
|
|||
Logger::getInstance().log(type, ctx, logMessage);
|
||||
}
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
// clang-format off
|
||||
#include <Windows.h>
|
||||
#include <DbgHelp.h>
|
||||
#include <ShlObj.h>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
|
||||
#pragma comment(lib, "DbgHelp.lib") // Link the DbgHelp library
|
||||
// clang-format on
|
||||
|
||||
LONG WINAPI CockatriceUnhandledExceptionFilter(EXCEPTION_POINTERS *exceptionPointers)
|
||||
{
|
||||
std::filesystem::path path;
|
||||
|
||||
// Find %LOCALAPPDATA% (or cheat at finding it)
|
||||
wchar_t *localAppDataFolder;
|
||||
if (SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_CREATE, NULL, &localAppDataFolder) != S_OK) {
|
||||
path = std::filesystem::temp_directory_path().parent_path().parent_path();
|
||||
} else {
|
||||
path = std::filesystem::path(localAppDataFolder);
|
||||
}
|
||||
|
||||
// Plan on writing crash files into %LOCALAPPDATA%/CrashDumps/Cockatrice/cockatrice.crash.*.dmp
|
||||
path /= "CrashDumps";
|
||||
path /= "Cockatrice";
|
||||
if (!std::filesystem::exists(path)) {
|
||||
std::filesystem::create_directories(path);
|
||||
}
|
||||
path /= "cockatrice.crash." + std::to_string(std::time(0)) + ".dmp";
|
||||
|
||||
// Create and write crash files
|
||||
#ifdef UNICODE
|
||||
HANDLE hDumpFile =
|
||||
CreateFile(path.wstring().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
#else
|
||||
HANDLE hDumpFile =
|
||||
CreateFile(path.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
#endif
|
||||
|
||||
MINIDUMP_EXCEPTION_INFORMATION mei;
|
||||
mei.ExceptionPointers = exceptionPointers;
|
||||
mei.ThreadId = GetCurrentThreadId();
|
||||
mei.ClientPointers = 1;
|
||||
|
||||
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hDumpFile, MiniDumpWithFullMemory, &mei, nullptr,
|
||||
nullptr);
|
||||
|
||||
CloseHandle(hDumpFile);
|
||||
|
||||
return EXCEPTION_EXECUTE_HANDLER;
|
||||
}
|
||||
#endif
|
||||
|
||||
void installNewTranslator()
|
||||
{
|
||||
QString lang = SettingsCache::instance().personal().getLang();
|
||||
|
|
@ -197,11 +145,52 @@ static QString redactActivationUrl(const QString &url)
|
|||
return parsed.scheme() + "://" + parsed.host();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Run the startup crash-report flow for an unclean exit (or --simulate-crash).
|
||||
*
|
||||
* With a configured DSN the SDK owns capture and upload (consent-gated); with an
|
||||
* empty DSN the flow falls back to a local mock file. AlwaysSend uploads silently
|
||||
* (the log was folded into the pending native envelope(s) at startup); Ask offers
|
||||
* the dialog; NeverSend skips silently.
|
||||
*
|
||||
* @param attachedEnvelopes number of pending native envelopes that already carry the
|
||||
* redacted log, so an AlwaysSend run only sends a standalone
|
||||
* log event when no crash event existed.
|
||||
*/
|
||||
static void runCrashReportFlow(CrashReporter &crashReporter, QWidget *parent, int attachedEnvelopes)
|
||||
{
|
||||
const PersonalSettings::CrashReportingMode crashMode = SettingsCache::instance().personal().getCrashReportingMode();
|
||||
const bool crashReady = crashReporter.isReady();
|
||||
if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) {
|
||||
if (crashReady) {
|
||||
// Silent path: the redacted log was folded into the pending native crash
|
||||
// envelope(s) at startup and consent already released them. Only when no
|
||||
// pending crash event existed (e.g. --simulate-crash) does a standalone
|
||||
// log event still need to go out here.
|
||||
if (attachedEnvelopes == 0) {
|
||||
const QString crashPreview = DlgCrashReport::buildRedactedPreview();
|
||||
if (!crashReporter.sendReport(crashPreview)) {
|
||||
const QString savedPath = DlgCrashReport::writeMockReport(crashPreview);
|
||||
qCWarning(MainLog) << "Failed to send crash report, saved locally to" << savedPath;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview());
|
||||
}
|
||||
} else if (crashMode == PersonalSettings::CrashReportingMode::Ask) {
|
||||
// Real mode: Send gives consent + captures the event, Don't send revokes
|
||||
// consent; the dialog owns both via the reporter.
|
||||
DlgCrashReport crashDialog(parent, crashReady, crashReady ? &crashReporter : nullptr);
|
||||
crashDialog.exec();
|
||||
}
|
||||
// NeverSend: skip silently (no dialog, nothing written or sent).
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
#ifdef Q_OS_WIN
|
||||
SetUnhandledExceptionFilter(CockatriceUnhandledExceptionFilter);
|
||||
#endif
|
||||
// No custom unhandled-exception filter: sentry owns native crash capture on all
|
||||
// platforms. (The legacy MiniDumpWithFullMemory writer was removed as a privacy
|
||||
// liability; unclean exits are detected via CrashReporter::beginSession below.)
|
||||
|
||||
// Logging setup
|
||||
#ifdef Q_OS_APPLE
|
||||
|
|
@ -259,6 +248,82 @@ int main(int argc, char *argv[])
|
|||
qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true);
|
||||
#endif
|
||||
|
||||
// Persistent session log: keeps this run's logs on disk so the next-startup crash dialog can
|
||||
// attach the crashed session's tail (the in-memory buffer is lost on crash). Opened in Append
|
||||
// mode with a per-launch session marker; read back via Logger::getPreviousSessionTail().
|
||||
// Declared before crash-reporter init: the startup attach below needs the log tail while the
|
||||
// file still holds the crashed session's lines.
|
||||
const QString persistentLogPath =
|
||||
SettingsCache::instance().getSettingsPath() + QStringLiteral("logs/cockatrice.log");
|
||||
Logger::getInstance().setPersistentLogPath(persistentLogPath);
|
||||
|
||||
// Crash reporting: init the SDK early so a crash anywhere below is captured into the
|
||||
// local database. Uploads stay consent-gated (require_user_consent, armed in CrashReporter::init):
|
||||
// AlwaysSend gives consent here so cached envelopes flush at startup; any other mode starts
|
||||
// unconsented and the dialog below drives give/revoke. An empty DSN skips the SDK entirely and
|
||||
// the mock fallback further below behaves exactly as before.
|
||||
CrashReporter crashReporter;
|
||||
const QString crashDatabasePath = SettingsCache::instance().getSettingsPath() + QStringLiteral("sentry");
|
||||
// Envelopes that received the redacted log at startup (AlwaysSend path); 0 means the log was
|
||||
// NOT folded into a pending crash event and a standalone log event is still needed below.
|
||||
int crashLogAttachedEnvelopes = 0;
|
||||
{
|
||||
QString crashDsn = QString::fromUtf8(qgetenv("COCKATRICE_SENTRY_DSN"));
|
||||
const bool dsnFromEnvironment = !crashDsn.isEmpty();
|
||||
if (!dsnFromEnvironment) {
|
||||
crashDsn = QString::fromUtf8(COCKATRICE_SENTRY_DSN);
|
||||
}
|
||||
if (crashDsn.isEmpty()) {
|
||||
// Nothing is armed without a DSN, so no crash is ever captured and nothing is ever sent.
|
||||
// It is the one disabled configuration a tester cannot see from the UI, so say it here.
|
||||
qCWarning(CrashReportLog) << "crash reporting disabled: no DSN configured (COCKATRICE_SENTRY_DSN is "
|
||||
"not set and this build has no DSN baked in)";
|
||||
} else if (!CrashReporter::isDsnWellFormed(crashDsn)) {
|
||||
// Same rule the installer applies (cmake/CrashReporting.cmake): a malformed DSN disables
|
||||
// reporting rather than silently falling back to the project baked into the build, which
|
||||
// would upload where the tester never chose. The SDK would otherwise refuse to build a
|
||||
// request at all and report nothing about it.
|
||||
qCWarning(CrashReportLog) << "crash reporting disabled:"
|
||||
<< (dsnFromEnvironment ? "COCKATRICE_SENTRY_DSN is set but malformed"
|
||||
: "the DSN baked into this build is malformed")
|
||||
<< "(expected <scheme>://<key>@<host>/<project-id>)";
|
||||
crashDsn.clear();
|
||||
} else if (dsnFromEnvironment) {
|
||||
// Destination and mode are logged by CrashReporter::init() once it resolves them.
|
||||
qCInfo(CrashReportLog) << "using COCKATRICE_SENTRY_DSN from the environment";
|
||||
}
|
||||
const PersonalSettings::CrashReportingMode crashMode =
|
||||
SettingsCache::instance().personal().getCrashReportingMode();
|
||||
// AlwaysSend folds the crashed session's redacted log into the pending crash
|
||||
// envelope(s) BEFORE init: sentry_init replays the crashed run's *.run
|
||||
// envelopes immediately when consent is already persisted, so attaching any
|
||||
// later would be too late. Consent given below then releases them, and dump,
|
||||
// stack and log upload as a single Sentry event. Ask mode attaches at the
|
||||
// dialog instead — its cache-held envelopes stay untouched until giveConsent().
|
||||
if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend &&
|
||||
CrashReporter::isReportingEnabled(crashDsn, crashMode)) {
|
||||
crashLogAttachedEnvelopes = CrashReporter::attachToPendingCrashEnvelopes(
|
||||
crashDatabasePath, DlgCrashReport::buildRedactedPreview(), QString());
|
||||
}
|
||||
crashReporter.init(crashDsn, QString::fromUtf8(COCKATRICE_SENTRY_RELEASE), crashDatabasePath, crashMode);
|
||||
if (crashReporter.isReady() && crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) {
|
||||
// Silent opt-in: cached native crash envelopes flush automatically once consent is given.
|
||||
crashReporter.giveConsent();
|
||||
}
|
||||
// Mid-session mode changes must reach the SDK: without this, switching from AlwaysSend to
|
||||
// NeverSend (or Ask) leaves consent given until restart, so a later crash in this session
|
||||
// would still upload after the user opted out. crashReporter outlives main(), so capturing
|
||||
// it here is safe.
|
||||
QObject::connect(&SettingsCache::instance().personal(), &PersonalSettings::crashReportingModeChanged,
|
||||
[&crashReporter](PersonalSettings::CrashReportingMode mode) {
|
||||
if (mode == PersonalSettings::CrashReportingMode::AlwaysSend) {
|
||||
crashReporter.giveConsent();
|
||||
} else {
|
||||
crashReporter.revokeConsent();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Translations
|
||||
#ifdef Q_OS_MAC
|
||||
translationPath = qApp->applicationDirPath() + "/../Resources/translations";
|
||||
|
|
@ -276,7 +341,10 @@ int main(int argc, char *argv[])
|
|||
|
||||
parser.addOptions(
|
||||
{{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"},
|
||||
{{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}});
|
||||
{{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")},
|
||||
{"simulate-crash",
|
||||
QCoreApplication::translate("main",
|
||||
"Show the crash-report dialog (saves a mock file when no DSN is configured)")}});
|
||||
|
||||
parser.process(app);
|
||||
|
||||
|
|
@ -320,6 +388,16 @@ int main(int argc, char *argv[])
|
|||
}
|
||||
}
|
||||
|
||||
// Unclean-exit detection (cross-platform QLockFile session tracking, replacing the
|
||||
// legacy Windows-only .dmp scan): a leftover session lock means the previous run died
|
||||
// without reaching endSession(). Our own lock is held until the event loop finishes.
|
||||
// Deliberately placed after parser.process() (--help/--version exit inside it and must
|
||||
// not leak a lock) and after the instance hand-off (a handed-off secondary exits
|
||||
// immediately and must never take a lock). Note this counts any death without
|
||||
// endSession() as a crash, including SIGTERM at logout, Task Manager kills and
|
||||
// session-end shutdowns; in AlwaysSend those send a report too.
|
||||
const bool uncleanExit = crashReporter.beginSession(crashDatabasePath);
|
||||
|
||||
rng = new RNG_SFMT(CryptoUtil::randomUInt64());
|
||||
themeManager = new ThemeManager;
|
||||
soundEngine = new SoundEngine;
|
||||
|
|
@ -413,6 +491,13 @@ int main(int argc, char *argv[])
|
|||
ui.show();
|
||||
qCInfo(MainLog) << "ui.show() finished";
|
||||
|
||||
// Crash-report flow: the trigger is --simulate-crash or an unclean previous exit
|
||||
// (leftover session lock; the old %LOCALAPPDATA%/CrashDumps .dmp scan is gone with
|
||||
// the exception filter). See runCrashReportFlow() for the per-mode handling.
|
||||
if (parser.isSet("simulate-crash") || uncleanExit) {
|
||||
runCrashReportFlow(crashReporter, &ui, crashLogAttachedEnvelopes);
|
||||
}
|
||||
|
||||
// force shortcuts to be shown/hidden in right-click menus, regardless of system defaults
|
||||
qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus,
|
||||
!SettingsCache::instance().userInterface().getShowShortcuts());
|
||||
|
|
@ -444,5 +529,10 @@ int main(int argc, char *argv[])
|
|||
UserLevelPixmapGenerator::clear();
|
||||
ManaSymbolPixmapGenerator::clear();
|
||||
|
||||
// Released last: crashes anywhere in the teardown above (a common place for Qt apps to
|
||||
// crash) are still captured by the SDK and detected as unclean exits on the next launch.
|
||||
crashReporter.shutdown();
|
||||
crashReporter.endSession();
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,4 +34,4 @@ int DebugSettings::getLocalGamePlayerCount() const
|
|||
QString DebugSettings::getDeckPathForPlayer(const QString &playerName) const
|
||||
{
|
||||
return getValue(playerName, "localgame", "deck").toString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,3 +51,24 @@ void PersonalSettings::setSeenTips(const QList<int> &_seenTips)
|
|||
}
|
||||
setValue(QVariant::fromValue(storedTipList), "seenTips", "tipOfDay");
|
||||
}
|
||||
|
||||
PersonalSettings::CrashReportingMode PersonalSettings::getCrashReportingMode() const
|
||||
{
|
||||
const int raw = getValue("crashReportingMode", QString(), QString(), 0).toInt();
|
||||
switch (raw) {
|
||||
case 1:
|
||||
return CrashReportingMode::AlwaysSend;
|
||||
case 2:
|
||||
return CrashReportingMode::NeverSend;
|
||||
case 0:
|
||||
default:
|
||||
// Clamp corrupt ini values back to Ask; the QSettings key string is unchanged (no migration).
|
||||
return CrashReportingMode::Ask;
|
||||
}
|
||||
}
|
||||
|
||||
void PersonalSettings::setCrashReportingMode(CrashReportingMode _crashReportingMode)
|
||||
{
|
||||
setValue(static_cast<int>(_crashReportingMode), "crashReportingMode");
|
||||
emit crashReportingModeChanged(_crashReportingMode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,19 @@ public:
|
|||
void setShowTipsOnStartup(bool _showTipsOnStartup);
|
||||
void setSeenTips(const QList<int> &_seenTips);
|
||||
|
||||
enum class CrashReportingMode
|
||||
{
|
||||
Ask = 0, ///< Show the crash-report dialog after an unclean exit.
|
||||
AlwaysSend = 1, ///< Upload crash reports without asking.
|
||||
NeverSend = 2 ///< Never upload; stay silent.
|
||||
};
|
||||
|
||||
[[nodiscard]] CrashReportingMode getCrashReportingMode() const;
|
||||
void setCrashReportingMode(CrashReportingMode _crashReportingMode);
|
||||
|
||||
signals:
|
||||
void langChanged();
|
||||
void crashReportingModeChanged(CrashReportingMode mode);
|
||||
|
||||
public:
|
||||
explicit PersonalSettings(const QString &settingPath, QObject *parent = nullptr);
|
||||
|
|
|
|||
186
publish_beta.sh
Executable file
186
publish_beta.sh
Executable file
|
|
@ -0,0 +1,186 @@
|
|||
#!/bin/bash
|
||||
# Publish a new Development beta tag.
|
||||
# Wraps the manual flow:
|
||||
# git checkout master && git remote update -p && git pull
|
||||
# git tag <date>-Development-<version>-beta[.N]
|
||||
# git push upstream <tag>
|
||||
#
|
||||
# Tag convention (see cmake/getversion.cmake):
|
||||
# first beta for a version: YYYY-MM-DD-Development-X.Y.Z-beta
|
||||
# subsequent betas: YYYY-MM-DD-Development-X.Y.Z-beta.2, .3, ...
|
||||
# Version defaults to the PROJECT VERSION in CMakeLists.txt.
|
||||
#
|
||||
# Usage:
|
||||
# ./publish_beta.sh [--dry-run] [--yes] [--version X.Y.Z] [--remote upstream] [--branch master]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="upstream"
|
||||
BRANCH="master"
|
||||
VERSION=""
|
||||
DRY_RUN=0
|
||||
ASSUME_YES=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Options:
|
||||
--version X.Y.Z Version to tag (default: parsed from CMakeLists.txt)
|
||||
--remote NAME Remote to update and push the tag to (default: upstream)
|
||||
--branch NAME Branch to tag from (default: master)
|
||||
--dry-run Print what would be done without creating/pushing the tag
|
||||
--yes Skip the confirmation prompt
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version)
|
||||
VERSION="${2:?--version needs an argument like 3.1.1}"
|
||||
shift 2
|
||||
;;
|
||||
--version=*)
|
||||
VERSION="${1#--version=}"
|
||||
shift
|
||||
;;
|
||||
--remote)
|
||||
REMOTE="${2:?--remote needs an argument}"
|
||||
shift 2
|
||||
;;
|
||||
--remote=*)
|
||||
REMOTE="${1#--remote=}"
|
||||
shift
|
||||
;;
|
||||
--branch)
|
||||
BRANCH="${2:?--branch needs an argument}"
|
||||
shift 2
|
||||
;;
|
||||
--branch=*)
|
||||
BRANCH="${1#--branch=}"
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
ASSUME_YES=1
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" || exit 2
|
||||
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "Error: not inside a git repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION="$(grep -m1 -oP 'project\(\s*"Cockatrice"\s+VERSION\s+\K[0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt || true)"
|
||||
fi
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: could not determine version (got '$VERSION'). Pass --version X.Y.Z." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Warning: working tree has uncommitted changes. They will not be included in the tag." >&2
|
||||
git status --short >&2
|
||||
fi
|
||||
|
||||
echo "==> git checkout $BRANCH"
|
||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||
git checkout "$BRANCH"
|
||||
else
|
||||
echo "(dry-run) skipped"
|
||||
fi
|
||||
|
||||
echo "==> git remote update -p"
|
||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||
git remote update -p
|
||||
else
|
||||
echo "(dry-run) skipped"
|
||||
fi
|
||||
|
||||
echo "==> git pull --ff-only"
|
||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||
git pull --ff-only
|
||||
git fetch --tags "$REMOTE"
|
||||
else
|
||||
echo "(dry-run) skipped"
|
||||
fi
|
||||
|
||||
DATE="$(date +%F)"
|
||||
PATTERN="*-Development-${VERSION}-beta*"
|
||||
|
||||
# Highest beta number used so far for this version.
|
||||
# A bare "-beta" (no suffix) counts as 1; "-beta.N" counts as N.
|
||||
MAX_BETA=0
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] || continue
|
||||
if [[ "$tag" =~ -beta\.([0-9]+)$ ]]; then
|
||||
NUM="${BASH_REMATCH[1]}"
|
||||
elif [[ "$tag" =~ -beta$ ]]; then
|
||||
NUM=1
|
||||
else
|
||||
continue
|
||||
fi
|
||||
if [[ "$NUM" -gt "$MAX_BETA" ]]; then
|
||||
MAX_BETA="$NUM"
|
||||
fi
|
||||
done < <(git tag --list "$PATTERN")
|
||||
|
||||
NEXT_BETA=$((MAX_BETA + 1))
|
||||
if [[ "$NEXT_BETA" -le 1 ]]; then
|
||||
NEW_TAG="${DATE}-Development-${VERSION}-beta"
|
||||
else
|
||||
NEW_TAG="${DATE}-Development-${VERSION}-beta.${NEXT_BETA}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Version: $VERSION"
|
||||
echo "Latest beta: $MAX_BETA (for $VERSION)"
|
||||
echo "New tag: $NEW_TAG"
|
||||
echo "Head: $(git rev-parse --short HEAD) $(git log -1 --oneline | head -c 120)"
|
||||
echo ""
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${NEW_TAG}" >/dev/null; then
|
||||
echo "Error: tag $NEW_TAG already exists locally." >&2
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --tags "$REMOTE" | grep -q "refs/tags/${NEW_TAG}$"; then
|
||||
echo "Error: tag $NEW_TAG already exists on $REMOTE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
echo "(dry-run) would run: git tag $NEW_TAG"
|
||||
echo "(dry-run) would run: git push $REMOTE $NEW_TAG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$ASSUME_YES" -eq 0 ]]; then
|
||||
read -r -p "Create tag $NEW_TAG and push to $REMOTE? [y/N] " answer
|
||||
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
git tag "$NEW_TAG"
|
||||
git push "$REMOTE" "$NEW_TAG"
|
||||
|
||||
echo "Done: $NEW_TAG"
|
||||
|
|
@ -422,6 +422,21 @@ if(WITH_CLIENT)
|
|||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
${CMAKE_SOURCE_DIR}/libcockatrice_utility
|
||||
)
|
||||
# CrashReporter is client code; DEFINITIONS keeps the test hermetic by compiling the
|
||||
# no-op path (no sentry-native headers needed) regardless of WITH_CRASH_REPORTING.
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
crash_reporter_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/crash/crash_reporter.cpp
|
||||
crash_reporter_test.cpp
|
||||
LIBS
|
||||
libcockatrice_settings
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
DEFINITIONS
|
||||
COCKATRICE_WITH_CRASH_REPORTING=0
|
||||
)
|
||||
endif()
|
||||
|
||||
add_subdirectory(card_zone_algorithms)
|
||||
|
|
|
|||
427
tests/crash_reporter_test.cpp
Normal file
427
tests/crash_reporter_test.cpp
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
#include "client/crash/crash_reporter.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QLockFile>
|
||||
#include <QString>
|
||||
#include <QSysInfo>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTextStream>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
TEST(CrashReporterTest, SanitizeLogLineStripsAnsiColorCodes)
|
||||
{
|
||||
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[32mhello\033[0m")), QStringLiteral("hello"));
|
||||
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[1;31mfatal\033[0m")), QStringLiteral("fatal"));
|
||||
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("plain line")), QStringLiteral("plain line"));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, RedactCockatriceUrlsKeepsSchemeAndHostOnly)
|
||||
{
|
||||
ASSERT_EQ(
|
||||
CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck?token=secret&user=me now")),
|
||||
QStringLiteral("open cockatrice://opendeck now"));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, RedactCockatriceUrlsIsIdempotent)
|
||||
{
|
||||
// A secret-free URL must not match again after redaction (regression: this hung forever).
|
||||
ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck now")),
|
||||
QStringLiteral("open cockatrice://opendeck now"));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, RedactCockatriceUrlsLeavesOtherTextAlone)
|
||||
{
|
||||
ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("https://cockatrice.github.io/?token=secret")),
|
||||
QStringLiteral("https://cockatrice.github.io/?token=secret"));
|
||||
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("")), QStringLiteral(""));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, InitSkippedWithoutDsn)
|
||||
{
|
||||
CrashReporter reporter;
|
||||
reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
|
||||
PersonalSettings::CrashReportingMode::Ask);
|
||||
ASSERT_FALSE(reporter.isReady());
|
||||
reporter.shutdown();
|
||||
ASSERT_FALSE(reporter.isReady());
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, InitSkippedWhenNeverSend)
|
||||
{
|
||||
CrashReporter reporter;
|
||||
reporter.init(QStringLiteral("https://key@sentry.io/1"), QStringLiteral("3.1.2"),
|
||||
QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
|
||||
PersonalSettings::CrashReportingMode::NeverSend);
|
||||
ASSERT_FALSE(reporter.isReady());
|
||||
reporter.shutdown();
|
||||
}
|
||||
|
||||
// The init() tests above compile to no-ops when the SDK is off, so they pass
|
||||
// regardless of the gating; the gating decision itself is covered here, in both
|
||||
// build modes, through the SDK-independent helper init() consults.
|
||||
TEST(CrashReporterTest, ReportingEnabledGating)
|
||||
{
|
||||
using Mode = PersonalSettings::CrashReportingMode;
|
||||
const QString dsn = QStringLiteral("https://key@sentry.io/1");
|
||||
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::Ask));
|
||||
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::AlwaysSend));
|
||||
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::NeverSend));
|
||||
ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::Ask));
|
||||
ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::AlwaysSend));
|
||||
ASSERT_FALSE(CrashReporter::isReportingEnabled(dsn, Mode::NeverSend));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, IsDsnWellFormedAcceptsWhatTheBuildAcceptsAndRejectsUiLinks)
|
||||
{
|
||||
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://abc123@o0.ingest.sentry.io/1234567")));
|
||||
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("http://key@localhost:9000/42")));
|
||||
// Unanchored like cmake/CrashReporting.cmake's MATCHES, so a DSN the build baked is never
|
||||
// rejected here: a trailing path still matches.
|
||||
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/7/extra")));
|
||||
// The project-settings URL is the mistake a tester actually makes.
|
||||
ASSERT_FALSE(CrashReporter::isDsnWellFormed(
|
||||
QStringLiteral("https://sentry.io/settings/cockatrice/projects/cockatrice-desktop/keys/")));
|
||||
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/not-a-number")));
|
||||
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("o0.ingest.sentry.io/1234567")));
|
||||
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QString()));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, DescribeDsnNamesHostAndProjectWithoutTheKey)
|
||||
{
|
||||
const QString described =
|
||||
CrashReporter::describeDsn(QStringLiteral("https://supersecretkey@o0.ingest.sentry.io/1234567"));
|
||||
ASSERT_EQ(described, QStringLiteral("o0.ingest.sentry.io (project 1234567)"));
|
||||
ASSERT_FALSE(described.contains(QStringLiteral("supersecretkey")));
|
||||
ASSERT_TRUE(CrashReporter::describeDsn(QStringLiteral("not a dsn")).isEmpty());
|
||||
ASSERT_TRUE(CrashReporter::describeDsn(QString()).isEmpty());
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, DescribeOriginMismatchReportsOnlyRealDestinationChanges)
|
||||
{
|
||||
const QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString envelopePath = dir.filePath(QStringLiteral("crash.envelope"));
|
||||
QFile file(envelopePath);
|
||||
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
|
||||
file.write(QByteArrayLiteral("{\"event_id\":\"x\",\"dsn\":\"https://keyA@hostA.example/111\"}\n"));
|
||||
file.write(QByteArrayLiteral("{\"type\":\"event\",\"length\":2}\n{}\n"));
|
||||
file.close();
|
||||
|
||||
const QString described =
|
||||
CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://keyB@hostB.example/222"));
|
||||
ASSERT_FALSE(described.isEmpty());
|
||||
ASSERT_TRUE(described.contains(QStringLiteral("hostA.example")));
|
||||
ASSERT_TRUE(described.contains(QStringLiteral("hostB.example")));
|
||||
ASSERT_FALSE(described.contains(QStringLiteral("keyA")));
|
||||
ASSERT_FALSE(described.contains(QStringLiteral("keyB")));
|
||||
|
||||
// A different key for the SAME host and project is not a routing change and must stay quiet.
|
||||
ASSERT_TRUE(
|
||||
CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://otherkey@hostA.example/111"))
|
||||
.isEmpty());
|
||||
// An unreadable envelope, a header without a dsn, or no current dsn are all silent.
|
||||
ASSERT_TRUE(CrashReporter::describeOriginMismatch(dir.filePath(QStringLiteral("absent.envelope")),
|
||||
QStringLiteral("https://keyB@hostB.example/222"))
|
||||
.isEmpty());
|
||||
ASSERT_TRUE(CrashReporter::describeOriginMismatch(envelopePath, QString()).isEmpty());
|
||||
const QString headerlessPath = dir.filePath(QStringLiteral("no-dsn.envelope"));
|
||||
QFile headerless(headerlessPath);
|
||||
ASSERT_TRUE(headerless.open(QIODevice::WriteOnly));
|
||||
headerless.write(QByteArrayLiteral("{\"event_id\":\"y\"}\n"));
|
||||
headerless.close();
|
||||
ASSERT_TRUE(CrashReporter::describeOriginMismatch(headerlessPath, QStringLiteral("https://keyB@hostB.example/222"))
|
||||
.isEmpty());
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, ShutdownWithoutInitIsSafe)
|
||||
{
|
||||
CrashReporter reporter;
|
||||
ASSERT_FALSE(reporter.isReady());
|
||||
reporter.shutdown();
|
||||
ASSERT_FALSE(reporter.isReady());
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, ConsentAndSendWithoutDsnAreSafeNoOps)
|
||||
{
|
||||
CrashReporter reporter;
|
||||
reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
|
||||
PersonalSettings::CrashReportingMode::Ask);
|
||||
ASSERT_FALSE(reporter.isReady());
|
||||
reporter.giveConsent();
|
||||
reporter.revokeConsent();
|
||||
ASSERT_FALSE(reporter.isReady());
|
||||
ASSERT_FALSE(reporter.sendReport(QStringLiteral("preview")));
|
||||
ASSERT_FALSE(reporter.sendReport(QString()));
|
||||
reporter.shutdown();
|
||||
ASSERT_FALSE(reporter.isReady());
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
QByteArray readFileBytes(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return {};
|
||||
}
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
bool writeFileBytes(const QString &path, const QByteArray &content)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
return false;
|
||||
}
|
||||
return file.write(content) == content.size();
|
||||
}
|
||||
|
||||
QString uuidOf(char marker)
|
||||
{
|
||||
// 36 chars, 4 hyphens — structurally a UUID, content irrelevant for these tests.
|
||||
return QStringLiteral("00000000-0000-4000-8000-00000000000%1").arg(marker);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(CrashReporterTest, AppendAttachmentAppendsWellFormedItemAfterExistingContent)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath(QStringLiteral("event.envelope"));
|
||||
// Existing envelope content deliberately WITHOUT a trailing newline.
|
||||
const QByteArray original = QByteArrayLiteral("{\"event_id\":\"x\"}\n{\"type\":\"event\",\"length\":2}\n{}");
|
||||
ASSERT_TRUE(writeFileBytes(path, original));
|
||||
|
||||
ASSERT_TRUE(
|
||||
CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), QByteArrayLiteral("hello")));
|
||||
const QByteArray expected = original + '\n' +
|
||||
QByteArrayLiteral("{\"type\":\"attachment\",\"length\":5,\"attachment_length\":5,"
|
||||
"\"filename\":\"crash-log.txt\",\"content_type\":\"text/plain\"}\n") +
|
||||
QByteArrayLiteral("hello");
|
||||
ASSERT_EQ(readFileBytes(path), expected);
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, AppendAttachmentOnEmptyFileHasNoLeadingNewline)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath(QStringLiteral("event.envelope"));
|
||||
ASSERT_TRUE(writeFileBytes(path, QByteArray()));
|
||||
|
||||
ASSERT_TRUE(
|
||||
CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), QByteArrayLiteral("note")));
|
||||
const QByteArray expected = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":4,\"attachment_length\":4,"
|
||||
"\"filename\":\"user-note.txt\",\"content_type\":\"text/plain\"}\n") +
|
||||
QByteArrayLiteral("note");
|
||||
ASSERT_EQ(readFileBytes(path), expected);
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, AppendAttachmentRejectsMissingFile)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
ASSERT_FALSE(CrashReporter::appendAttachmentToEnvelope(dir.filePath(QStringLiteral("missing.envelope")),
|
||||
QStringLiteral("crash-log.txt"), QByteArrayLiteral("x")));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, AppendAttachmentLengthIsByteAccurate)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath(QStringLiteral("event.envelope"));
|
||||
ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}")));
|
||||
|
||||
const QByteArray utf8Payload = QString::fromUtf8("héllo").toUtf8();
|
||||
ASSERT_EQ(utf8Payload.size(), 6); // 5 characters, "é" is 2 bytes
|
||||
ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), utf8Payload));
|
||||
const QByteArray content = readFileBytes(path);
|
||||
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"length\":6")));
|
||||
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"attachment_length\":6")));
|
||||
ASSERT_TRUE(content.endsWith(utf8Payload));
|
||||
}
|
||||
|
||||
// The SDK's envelope re-parser frames each item's payload by its `length` header; a
|
||||
// missing or wrong length ends the payload at the first newline, so the remaining log
|
||||
// lines get parsed as item headers and the whole envelope is rejected (and deleted).
|
||||
TEST(CrashReporterTest, AppendAttachmentKeepsMultiLinePayloadIntact)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath(QStringLiteral("event.envelope"));
|
||||
ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}")));
|
||||
|
||||
const QByteArray payload = QByteArrayLiteral("first line\nsecond line\nthird line");
|
||||
ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), payload));
|
||||
const QByteArray content = readFileBytes(path);
|
||||
ASSERT_TRUE(content.contains("\"length\":" + QByteArray::number(payload.size())));
|
||||
ASSERT_TRUE(content.contains("\"attachment_length\":" + QByteArray::number(payload.size())));
|
||||
ASSERT_TRUE(content.endsWith(payload));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, PendingEnvelopePathsMatchesSdkNamingInCacheAndRunDirs)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString dbPath = dir.path();
|
||||
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache")));
|
||||
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/aaaa.run")));
|
||||
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/other")));
|
||||
|
||||
const QString retryName = QStringLiteral("1700000000000-0-") + uuidOf('a') + QStringLiteral(".envelope");
|
||||
const QString bareName = uuidOf('b') + QStringLiteral(".envelope");
|
||||
// Valid: retry-style cache file + bare run-dir file.
|
||||
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + retryName, QByteArrayLiteral("{}")));
|
||||
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + bareName, QByteArrayLiteral("{}")));
|
||||
// Rejected: junk names and the SDK's own marker file.
|
||||
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + uuidOf('c') + QStringLiteral(".envelope.bak"),
|
||||
QByteArrayLiteral("{}")));
|
||||
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + QStringLiteral("random.envelope"),
|
||||
QByteArrayLiteral("{}")));
|
||||
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + QStringLiteral("__sentry-crash.envelope"),
|
||||
QByteArrayLiteral("{}")));
|
||||
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/other/") + bareName, QByteArrayLiteral("{}")));
|
||||
|
||||
const QStringList found = CrashReporter::pendingEnvelopePaths(dbPath);
|
||||
ASSERT_EQ(found.size(), 2);
|
||||
ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/cache/") + retryName));
|
||||
ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/aaaa.run/") + bareName));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, PendingEnvelopePathsWithoutDatabaseIsEmpty)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(dir.filePath(QStringLiteral("no-such-db"))).isEmpty());
|
||||
ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(QString()).isEmpty());
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, AttachFoldsLogAndNoteIntoPendingEnvelopes)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString dbPath = dir.path();
|
||||
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache")));
|
||||
const QString envelopePath = dbPath + QStringLiteral("/cache/") + uuidOf('d') + QStringLiteral(".envelope");
|
||||
ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n")));
|
||||
|
||||
CrashReporter reporter;
|
||||
// Empty DSN: no SDK, but init() still records the database path the dialog uses.
|
||||
reporter.init(QString(), QStringLiteral("3.1.2"), dbPath, PersonalSettings::CrashReportingMode::Ask);
|
||||
ASSERT_EQ(reporter.attachToPendingCrashEnvelopes(QStringLiteral("log line"), QStringLiteral("my note")), 1);
|
||||
|
||||
const QByteArray content = readFileBytes(envelopePath);
|
||||
ASSERT_TRUE(content.startsWith(QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n")));
|
||||
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\"")));
|
||||
ASSERT_TRUE(content.contains(QByteArrayLiteral("\nlog line")));
|
||||
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"user-note.txt\"")));
|
||||
ASSERT_TRUE(content.endsWith(QByteArrayLiteral("\nmy note")));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, AttachSkipsNoteWhenEmptyAndFindsRunDirEnvelopes)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString dbPath = dir.path();
|
||||
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/bbbb.run")));
|
||||
const QString envelopePath = dbPath + QStringLiteral("/bbbb.run/") + uuidOf('e') + QStringLiteral(".envelope");
|
||||
ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{}")));
|
||||
|
||||
// Static overload (the pre-init AlwaysSend path) with an empty note.
|
||||
ASSERT_EQ(CrashReporter::attachToPendingCrashEnvelopes(dbPath, QStringLiteral("log only"), QString()), 1);
|
||||
const QByteArray content = readFileBytes(envelopePath);
|
||||
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\"")));
|
||||
ASSERT_FALSE(content.contains(QByteArrayLiteral("user-note.txt")));
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, BeginSessionOnEmptyDirReportsClean)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
CrashReporter reporter;
|
||||
ASSERT_FALSE(reporter.beginSession(dir.path()));
|
||||
reporter.endSession();
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, BeginSessionIgnoresLiveConcurrentInstance)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
// A second QLockFile held in-process stands in for a live concurrent instance: same
|
||||
// pid, alive, so the scan must leave it alone and report a clean start.
|
||||
QLockFile liveInstance(dir.filePath(QStringLiteral("session-424242")));
|
||||
ASSERT_TRUE(liveInstance.tryLock(0));
|
||||
CrashReporter reporter;
|
||||
ASSERT_FALSE(reporter.beginSession(dir.path()));
|
||||
ASSERT_TRUE(QFile::exists(liveInstance.fileName()));
|
||||
reporter.endSession();
|
||||
liveInstance.unlock();
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, BeginSessionDetectsStaleDeadOwnerLock)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
// A well-formed lock whose owner is dead: our own pid (so the host matches) but a
|
||||
// process name that cannot match, which is how QLockFile spots pid reuse. Must be
|
||||
// reported unclean and cleaned up, even though the file is brand new (this is why
|
||||
// the scan probes with staleLockTime 0 rather than the 30 s default).
|
||||
const QString stalePath = dir.filePath(QStringLiteral("session-stale-owner"));
|
||||
QFile stale(stalePath);
|
||||
ASSERT_TRUE(stale.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
QTextStream out(&stale);
|
||||
out << QCoreApplication::applicationPid() << '\n'
|
||||
<< QStringLiteral("definitely-not-the-test-binary") << '\n'
|
||||
<< QSysInfo::machineHostName() << '\n';
|
||||
stale.close();
|
||||
|
||||
CrashReporter reporter;
|
||||
ASSERT_TRUE(reporter.beginSession(dir.path()));
|
||||
ASSERT_FALSE(QFile::exists(stalePath));
|
||||
reporter.endSession();
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, BeginSessionCleansGarbageFileAndReportsUnclean)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
// An unreadable lock file has no live owner by definition: report unclean and remove it.
|
||||
const QString garbagePath = dir.filePath(QStringLiteral("session-garbage"));
|
||||
QFile garbage(garbagePath);
|
||||
ASSERT_TRUE(garbage.open(QIODevice::WriteOnly));
|
||||
garbage.write("not-a-lock-file\x00\xff", 17);
|
||||
garbage.close();
|
||||
|
||||
CrashReporter reporter;
|
||||
ASSERT_TRUE(reporter.beginSession(dir.path()));
|
||||
ASSERT_FALSE(QFile::exists(garbagePath));
|
||||
reporter.endSession();
|
||||
}
|
||||
|
||||
TEST(CrashReporterTest, BeginSessionEndSessionRoundTripReportsClean)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
CrashReporter reporter;
|
||||
ASSERT_FALSE(reporter.beginSession(dir.path()));
|
||||
reporter.endSession();
|
||||
ASSERT_FALSE(reporter.beginSession(dir.path()));
|
||||
reporter.endSession();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
#include <QDir>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QSettings>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTemporaryDir>
|
||||
|
|
@ -367,6 +368,32 @@ TEST_F(SettingsDefaultsTest, Personal_ShowTipsOnStartup_Default)
|
|||
ASSERT_EQ(s.getShowTipsOnStartup(), true);
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_Default)
|
||||
{
|
||||
PersonalSettings s(settingsPath, nullptr);
|
||||
ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask);
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_SetAndGet)
|
||||
{
|
||||
PersonalSettings s(settingsPath, nullptr);
|
||||
s.setCrashReportingMode(PersonalSettings::CrashReportingMode::AlwaysSend);
|
||||
ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::AlwaysSend);
|
||||
s.setCrashReportingMode(PersonalSettings::CrashReportingMode::NeverSend);
|
||||
ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::NeverSend);
|
||||
s.setCrashReportingMode(PersonalSettings::CrashReportingMode::Ask);
|
||||
ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask);
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_ClampsCorruptValue)
|
||||
{
|
||||
QSettings raw(settingsPath + "personal.ini", QSettings::IniFormat);
|
||||
raw.setValue("personal/crashReportingMode", 99);
|
||||
raw.sync();
|
||||
PersonalSettings s(settingsPath, nullptr);
|
||||
ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask);
|
||||
}
|
||||
|
||||
// --- DownloadSettings ---
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Download_PicDownload_Default)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue