Merge branch 'Cockatrice:master' into token-decrement-fix

This commit is contained in:
Jurriaan 2026-10-19 16:11:33 +02:00 • committed by GitHub
commit 7b2da47dcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2863 additions and 79 deletions

View file

@ -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 \

View file

@ -9,6 +9,7 @@ RUN apt-get update && \
file \
g++ \
git \
libcurl4-openssl-dev \
libgl-dev \
liblzma-dev \
libmariadb-dev-compat \

View file

@ -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 \

View file

@ -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 \

View file

@ -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 \

View file

@ -10,6 +10,7 @@ RUN apt-get update && \
g++ \
gcovr \
git \
libcurl4-openssl-dev \
libgl-dev \
liblzma-dev \
libmariadb-dev-compat \

View file

@ -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")

View file

@ -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 $?

View file

@ -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 \

View file

@ -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

View file

@ -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
View 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()

View file

@ -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.

View 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
}

View 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}")

View file

@ -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}")

View 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 &currentDsn)
{
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(&noteId) == 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;
}

View 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 &currentDsn);
/**
* @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

View file

@ -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()

View file

@ -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);

View 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();
}

View 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

View file

@ -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();

View file

@ -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;

View file

@ -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;
}

View file

@ -34,4 +34,4 @@ int DebugSettings::getLocalGamePlayerCount() const
QString DebugSettings::getDeckPathForPlayer(const QString &playerName) const
{
return getValue(playerName, "localgame", "deck").toString();
}
}

View file

@ -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);
}

View file

@ -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
View 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"

View file

@ -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)

View 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();
}

View file

@ -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)