[CrashReporting] Mock dialog, sentry skeleton and CI prerequisites

Mock: DlgCrashReport preview dialog (no account needed, redacted log
preview), CrashReportingMode Ask/Always/Never setting in DebugSettings,
--simulate-crash flag and Help-menu preview entry. Send writes a local
mock JSON only.

Vendor sentry-native 0.17.3 via FetchContent behind
WITH_CRASH_REPORTING (breakpad everywhere, backend none under
sanitizers) plus a CrashReporter facade with early init and post-loop
shutdown. DSN is a placeholder: empty CMake default with
COCKATRICE_SENTRY_DSN env override, so SDK init is skipped and mock
behavior is unchanged until a real DSN is configured.

CI prerequisites riding here so every layer below builds green:
libcurl dev plus CA bundles in the Linux images and CodeQL env, sentry
Qt integration off (its own find_package cannot see the macOS thin Qt
layout), and the StrFunc Using construction makensis requires.

Tests: ctest green, format clean, code plus UI gates PASS.
This commit is contained in:
Lukas Brübach 2026-10-14 08:53:41 +02:00 • committed by GitHub
parent ea6af0048c
commit 6411abfb57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 962 additions and 5 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,12 @@ 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
# Add cmake --build flags
buildflags=(--config "$BUILDTYPE")

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

@ -239,6 +239,7 @@ jobs:
SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}'
package: '${{ matrix.package }}'
server_only: '${{ matrix.server_only }}'
WITH_CRASH_REPORTING: 'ON'
run: |
source .ci/docker.sh
args=()
@ -558,6 +559,8 @@ 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
# CPack reports a failed makensis run as "Problem running NSIS command"

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

124
cmake/CrashReporting.cmake Normal file
View file

@ -0,0 +1,124 @@
# 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}\"")
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")
elseif(WIN32 OR APPLE)
# Out-of-process handler; shipping the handler binary is a later increment, which unifies
# every OS on breakpad (in-process, no extra binary to ship).
set(_sentry_backend "crashpad")
else()
# In-process, no extra binary to ship.
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)
# 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}
)
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()

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,116 @@
#include "crash_reporter.h"
#if COCKATRICE_WITH_CRASH_REPORTING
#include <sentry.h>
#endif
#include <QDir>
#include <QFile>
#include <QRegularExpression>
#include <QUrl>
#include <libcockatrice/settings/personal_settings.h>
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)) {
return;
}
// Only created when the SDK will actually run: no-DSN launches stay side-effect free.
#if COCKATRICE_WITH_CRASH_REPORTING
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().
sentry_options_set_require_user_consent(options, 1);
ready = (sentry_init(options) == 0);
#else
Q_UNUSED(release);
ready = false;
#endif
}
bool CrashReporter::isReady() const
{
return ready;
}
void CrashReporter::shutdown()
{
#if COCKATRICE_WITH_CRASH_REPORTING
if (ready) {
sentry_close();
}
#endif
ready = false;
}
void CrashReporter::giveConsent()
{
#if COCKATRICE_WITH_CRASH_REPORTING
if (ready) {
sentry_user_consent_give();
}
#endif
}
void CrashReporter::revokeConsent()
{
#if COCKATRICE_WITH_CRASH_REPORTING
if (ready) {
sentry_user_consent_revoke();
}
#endif
}
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,63 @@
/**
* @file crash_reporter.h
* @ingroup Core
*/
#ifndef CRASH_REPORTER_H
#define CRASH_REPORTER_H
#include <QString>
#include <libcockatrice/settings/personal_settings.h>
#ifndef COCKATRICE_WITH_CRASH_REPORTING
#define COCKATRICE_WITH_CRASH_REPORTING 0
#endif
#ifndef COCKATRICE_SENTRY_DSN
#define COCKATRICE_SENTRY_DSN ""
#endif
/**
* @brief Sentry-backed crash-report facade, owned by main().
*
* Single responsibility: init/shutdown the sentry-native SDK behind consent
* gating, plus the shared log redaction helpers. An empty DSN (or Never
* consent) skips the SDK entirely, so the mock dialog fallback behaves exactly
* as before. init() arms require-user-consent so cached crash envelopes never
* upload before the user is asked; giveConsent()/revokeConsent() (driven by
* the reporting-mode setting and the crash dialog) gate uploads, and any stale
* persisted consent from a previous AlwaysSend run is cleared before init when
* the mode is not AlwaysSend. The real send path is a later increment. All SDK
* calls are guarded so OFF builds compile to no-ops.
*/
class CrashReporter
{
public:
CrashReporter() = default;
CrashReporter(const CrashReporter &) = delete;
CrashReporter &operator=(const CrashReporter &) = delete;
~CrashReporter() = default;
void init(const QString &dsn,
const QString &release,
const QString &databasePath,
PersonalSettings::CrashReportingMode consentMode);
[[nodiscard]] bool isReady() const;
void shutdown();
void giveConsent();
void revokeConsent();
/**
* @brief SDK-independent gating decision, unit-testable in OFF builds.
*/
static bool isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode);
static QString sanitizeLogLine(const QString &line);
static QString redactCockatriceUrls(const QString &line);
private:
bool ready = false;
QString sentryDatabasePath;
};
#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,152 @@
#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 <QPlainTextEdit>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/personal_settings.h>
DlgCrashReport::DlgCrashReport(QWidget *parent) : QDialog(parent)
{
infoLabel = new QLabel(this);
infoLabel->setWordWrap(true);
accountLabel = new QLabel(this);
accountLabel->setWordWrap(true);
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(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"));
infoLabel->setText(
tr("Cockatrice closed unexpectedly. The preview below shows what a crash report would contain (mock)."));
accountLabel->setText(tr("No account needed to send a crash report."));
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)
{
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;
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 savedPath = writeMockReport(previewArea->toPlainText());
if (savedPath.isEmpty()) {
qWarning() << "Could not save the mock crash report locally";
} else {
qInfo() << "Mock crash report saved locally to" << savedPath;
}
accept();
}
void DlgCrashReport::actDontSend()
{
if (rememberBox->isChecked()) {
SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::NeverSend);
}
reject();
}

View file

@ -0,0 +1,51 @@
/**
* @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;
/**
* @brief MOCK crash-report dialog (UI + settings only, no network).
*
* The Send path writes a redacted JSON file to the temp dir instead of
* uploading anything. Real Sentry upload comes in a later phase.
*/
class DlgCrashReport : public QDialog
{
Q_OBJECT
public:
explicit DlgCrashReport(QWidget *parent = nullptr);
static QString buildRedactedPreview();
static QString writeMockReport(const QString &previewText);
protected:
void changeEvent(QEvent *event) override;
private:
QLabel *infoLabel;
QLabel *accountLabel;
QPlainTextEdit *previewArea;
QCheckBox *rememberBox;
QPushButton *sendButton;
QPushButton *dontSendButton;
void retranslateUi();
private slots:
void actSend();
void actDontSend();
};
#endif

View file

@ -133,6 +133,28 @@ GeneralSettingsPage::GeneralSettingsPage()
versionGroupBox = new QGroupBox;
versionGroupBox->setLayout(versionGrid);
// crash reporting consent (lives in personal.ini, not debug.ini): Ask shows the crash dialog
// after an unclean exit, AlwaysSend uploads without asking, NeverSend stays silent.
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 +358,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 +592,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"
@ -44,6 +46,9 @@
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDebug>
#include <QDialog>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QIcon>
@ -259,6 +264,34 @@ int main(int argc, char *argv[])
qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true);
#endif
// Crash reporting (increment 1): 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");
{
QString crashDsn = QString::fromUtf8(qgetenv("COCKATRICE_SENTRY_DSN"));
if (crashDsn.isEmpty()) {
crashDsn = QString::fromLatin1(COCKATRICE_SENTRY_DSN);
}
crashReporter.init(crashDsn, QString::fromLatin1(VERSION_STRING), crashDatabasePath,
SettingsCache::instance().personal().getCrashReportingMode());
if (crashReporter.isReady() && SettingsCache::instance().personal().getCrashReportingMode() ==
PersonalSettings::CrashReportingMode::AlwaysSend) {
// Silent opt-in: cached crash envelopes flush automatically once consent is given.
crashReporter.giveConsent();
}
}
// 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().
const QString persistentLogPath =
SettingsCache::instance().getSettingsPath() + QStringLiteral("logs/cockatrice.log");
Logger::getInstance().setPersistentLogPath(persistentLogPath);
// Translations
#ifdef Q_OS_MAC
translationPath = qApp->applicationDirPath() + "/../Resources/translations";
@ -276,7 +309,9 @@ 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 mock crash-report dialog (nothing is sent)")}});
parser.process(app);
@ -413,6 +448,45 @@ int main(int argc, char *argv[])
ui.show();
qCInfo(MainLog) << "ui.show() finished";
// MOCK crash-report flow (UI + settings only): no sentry-native upload, no network.
// Triggered by --simulate-crash or by leftover Windows crash dumps. Handled dumps are deleted
// after Send / Don't send (or a silent NeverSend skip) so one crash never prompts twice; on
// non-Windows nothing writes cockatrice.crash.*.dmp yet, so --simulate-crash is the trigger there.
{
QString crashDumpDir;
#ifdef Q_OS_WIN
// Mirrors the writer's location: %LOCALAPPDATA%/CrashDumps/Cockatrice, with the same
// temp-path fallback the writer uses when the known-folder lookup fails.
crashDumpDir = QString::fromLocal8Bit(qgetenv("LOCALAPPDATA")) + "/CrashDumps/Cockatrice";
if (qgetenv("LOCALAPPDATA").isEmpty()) {
crashDumpDir = QDir::tempPath();
}
#else
crashDumpDir = QDir::tempPath();
#endif
const QStringList crashDumps =
QDir(crashDumpDir).entryList({QStringLiteral("cockatrice.crash.*.dmp")}, QDir::Files);
if (parser.isSet("simulate-crash") || !crashDumps.isEmpty()) {
const PersonalSettings::CrashReportingMode crashMode =
SettingsCache::instance().personal().getCrashReportingMode();
if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) {
crashReporter.giveConsent();
DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview());
} else if (crashMode == PersonalSettings::CrashReportingMode::Ask) {
DlgCrashReport crashDialog(&ui);
if (crashDialog.exec() == QDialog::Accepted) {
crashReporter.giveConsent();
} else {
crashReporter.revokeConsent();
}
}
// NeverSend: skip silently (no dialog, nothing written or sent).
for (const QString &dump : crashDumps) {
QFile::remove(crashDumpDir + QStringLiteral("/") + dump);
}
}
}
// force shortcuts to be shown/hidden in right-click menus, regardless of system defaults
qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus,
!SettingsCache::instance().userInterface().getShowShortcuts());
@ -437,6 +511,8 @@ int main(int argc, char *argv[])
int ret = app.exec();
crashReporter.shutdown();
qCInfo(MainLog) << "Event loop finished, terminating...";
delete rng;
PingPixmapGenerator::clear();

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

@ -18,7 +18,10 @@ class DebugSettings : public SettingsManager
Q_OBJECT
friend class SettingsCache;
public:
explicit DebugSettings(const QString &settingPath, QObject *parent = nullptr);
private:
DebugSettings(const DebugSettings & /*other*/);
public:

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,
AlwaysSend = 1,
NeverSend = 2
};
[[nodiscard]] CrashReportingMode getCrashReportingMode() const;
void setCrashReportingMode(CrashReportingMode _crashReportingMode);
signals:
void langChanged();
void crashReportingModeChanged(CrashReportingMode mode);
public:
explicit PersonalSettings(const QString &settingPath, QObject *parent = nullptr);

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,87 @@
#include "client/crash/crash_reporter.h"
#include <QString>
#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"), QStringLiteral("/tmp/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"),
QStringLiteral("/tmp/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, ShutdownWithoutInitIsSafe)
{
CrashReporter reporter;
ASSERT_FALSE(reporter.isReady());
reporter.shutdown();
ASSERT_FALSE(reporter.isReady());
}
} // 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)