Compare commits

..

2 commits

Author SHA1 Message Date
Lukas Brübach
64f58535dd [Client] Settings: match CI clang-format-18 line breaking 2026-10-11 13:03:35 +02:00
Lukas Brübach
c3b839a905 [Client] Settings: extract SettingsView from DlgSettings
Move the search bar, tab bar, stacked pages and search results out of
DlgSettings into a reusable SettingsView widget. The dialog becomes a
thin frame (title, OK button, close validation) delegating to the view.
No visual or behavior change; tr() contexts preserved as DlgSettings.
2026-10-11 11:40:20 +02:00
131 changed files with 797 additions and 4501 deletions

View file

@ -4,6 +4,7 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \ build-essential \
ccache \ ccache \
clang-format \
cmake \ cmake \
file \ file \
g++ \ g++ \

View file

@ -5,6 +5,7 @@ RUN apt-get update && \
build-essential \ build-essential \
ca-certificates \ ca-certificates \
ccache \ ccache \
clang-format \
cmake \ cmake \
file \ file \
g++ \ g++ \

View file

@ -4,6 +4,7 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \ build-essential \
ccache \ ccache \
clang-format \
cmake \ cmake \
file \ file \
g++ \ g++ \

View file

@ -4,6 +4,7 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \ build-essential \
ccache \ ccache \
clang-format \
cmake \ cmake \
file \ file \
g++ \ g++ \

View file

@ -5,10 +5,10 @@ RUN apt-get update && \
build-essential \ build-essential \
ca-certificates \ ca-certificates \
ccache \ ccache \
clang-format \
cmake \ cmake \
file \ file \
g++ \ g++ \
gcovr \
git \ git \
libgl-dev \ libgl-dev \
liblzma-dev \ liblzma-dev \

View file

@ -8,18 +8,14 @@
# --suffix <suffix> renames package with this suffix, requires arg # --suffix <suffix> renames package with this suffix, requires arg
# --server compiles servatrice # --server compiles servatrice
# --test runs tests # --test runs tests
# --sanitize builds with AddressSanitizer and UndefinedBehaviorSanitizer, gcc/clang only
# --coverage instruments the test run for line coverage and prints a gcovr summary, gcc only
# --debug or --release sets the build type ie CMAKE_BUILD_TYPE # --debug or --release sets the build type ie CMAKE_BUILD_TYPE
# --ccache [<size>] uses ccache and shows stats, optionally provide size # --ccache [<size>] uses ccache and shows stats, optionally provide size
# --evict-ccache <age> runs ccache eviction based on given age after build # --evict-ccache <age> runs ccache eviction based on given age after build
# --dir <dir> sets the name of the build dir, default is "build" # --dir <dir> sets the name of the build dir, default is "build"
# --cmake-generator <generator> sets CMAKE_GENERATOR as used by cmake # --cmake-generator <generator> sets CMAKE_GENERATOR as used by cmake
# --target-macos-version <version> sets the min os version - only used for macOS builds # --target-macos-version <version> sets the min os version - only used for macOS builds
# --profile writes an MSBuild performance summary and binary log, Windows only # uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE # (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --ccache <ccache_size> --dir <dir>)
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir> --profile)
# exitcode: 1 for failure, 3 for invalid arguments # exitcode: 1 for failure, 3 for invalid arguments
# Read arguments # Read arguments
@ -61,14 +57,6 @@ while [[ $# != 0 ]]; do
MAKE_TEST=1 MAKE_TEST=1
shift shift
;; ;;
'--sanitize')
MAKE_SANITIZE=1
shift
;;
'--coverage')
MAKE_COVERAGE=1
shift
;;
'--debug') '--debug')
BUILDTYPE="Debug" BUILDTYPE="Debug"
shift shift
@ -116,10 +104,6 @@ while [[ $# != 0 ]]; do
export CMAKE_GENERATOR=$1 export CMAKE_GENERATOR=$1
shift shift
;; ;;
'--profile')
BUILD_PROFILE=1
shift
;;
'--target-macos-version') '--target-macos-version')
shift shift
if [[ $# == 0 ]]; then if [[ $# == 0 ]]; then
@ -147,15 +131,6 @@ if [[ ! $BUILD_DIR ]]; then
BUILD_DIR="build" BUILD_DIR="build"
fi fi
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
if [[ $MAKE_COVERAGE ]]; then
# Resolved while the working directory is still the source root. $0 is relative to the
# directory the script was invoked from, so after the cd below it no longer resolves, and
# under `set -e` the failed subshell would abort the build rather than just yielding an empty
# variable. Only the coverage report needs this, so only coverage pays for it.
SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
fi
cd "$BUILD_DIR" cd "$BUILD_DIR"
# Set minimum CMake Version # Set minimum CMake Version
@ -172,17 +147,6 @@ fi
if [[ $MAKE_TEST ]]; then if [[ $MAKE_TEST ]]; then
flags+=("-DTEST=1") flags+=("-DTEST=1")
fi fi
if [[ $MAKE_SANITIZE ]]; then
flags+=("-DENABLE_SANITIZERS=ON")
else
# The release-package step re-runs cmake in the same build dir, so an
# ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak
# into the shipped binaries (libasan/libubsan Depends in the DEB).
flags+=("-DENABLE_SANITIZERS=OFF")
fi
if [[ $MAKE_COVERAGE ]]; then
flags+=("-DENABLE_COVERAGE=ON")
fi
if [[ $USE_CCACHE ]]; then if [[ $USE_CCACHE ]]; then
flags+=("-DUSE_CCACHE=1") flags+=("-DUSE_CCACHE=1")
# PCH-aware caching is required or ccache refuses to cache any TU that # PCH-aware caching is required or ccache refuses to cache any TU that
@ -214,22 +178,6 @@ function ccachestatsverbose() {
fi fi
} }
function cpuinfo() {
# The number of cores decides how much build parallelism is useful; the hosted
# runners are not all sized alike, so read it off the machine instead of assuming.
case "$RUNNER_OS" in
Windows)
echo "cores: ${NUMBER_OF_PROCESSORS:-unknown}"
;;
macOS)
echo "cores: $(sysctl -n hw.ncpu)"
;;
*)
echo "cores: $(nproc 2>/dev/null || echo unknown)"
;;
esac
}
# Compile # Compile
if [[ $RUNNER_OS == macOS ]]; then if [[ $RUNNER_OS == macOS ]]; then
# QTDIR is needed for macOS since we actually only use the cached thin Qt binaries instead of the install-qt-action, # QTDIR is needed for macOS since we actually only use the cached thin Qt binaries instead of the install-qt-action,
@ -264,14 +212,8 @@ if [[ $RUNNER_OS == macOS ]]; then
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake" triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
fi fi
cp "$triplet_source" "$triplet_file" cp "$triplet_source" "$triplet_file"
# The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that echo "set(VCPKG_CMAKE_SYSTEM_VERSION $TARGET_MACOS_VERSION)" >>"$triplet_file"
# agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $TARGET_MACOS_VERSION)" >>"$triplet_file"
# produce identical binaries when they share the lowest deployment target, so they all
# build against 14 to keep one vcpkg cache scope instead of three.
# Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml.
vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}"
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file"
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file"
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir") flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version") flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version") flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
@ -314,34 +256,9 @@ if [[ $RUNNER_OS == macOS ]]; then
fi fi
elif [[ $RUNNER_OS == Windows ]]; then elif [[ $RUNNER_OS == Windows ]]; then
if [[ $CMAKE_GENERATOR == "Visual Studio"* ]]; then # Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
# cmake only hands MSBuild a job count when one is asked for, and without it # and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt
# MSBuild builds a single node: one project at a time, whatever MTT does inside buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true)
# it. Ask for a node per core.
if [[ ! $BUILD_PARALLEL_LEVEL ]]; then
BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}"
fi
buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
# Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
# and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt
# EnforceProcessCountAcrossBuilds makes the CL_MPCount ceiling apply across all
# the projects the nodes are building, instead of each node running its own
# cl.exe processes and /m and MTT multiplying into an oversubscribed machine.
buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true -p:EnforceProcessCountAcrossBuilds=true -p:CL_MPCount="$BUILD_PARALLEL_LEVEL")
if [[ $BUILD_PROFILE ]]; then
# A performance summary says which projects and tasks cost what, the binary log
# keeps the full timeline; both are far too noisy to always produce. The files
# land next to the solution, i.e. in the build dir the workflow uploads. These
# join the MTT options above, which already carried the "--" separator.
buildflags+=("-flp:PerformanceSummary;v=q;LogFile=msbuild-perf.log" -bl:msbuild.binlog)
fi
else
# Ninja runs the compiler itself, so it neither takes the MSBuild switches
# (it rejects -p: options outright) nor a job count: it sizes its own pool
# for the cores it finds.
echo "ninja $(ninja --version)"
fi
fi fi
if [[ $USE_CCACHE ]]; then if [[ $USE_CCACHE ]]; then
@ -357,19 +274,10 @@ cmake .. "${flags[@]}"
echo "::endgroup::" echo "::endgroup::"
echo "::group::Build project" echo "::group::Build project"
cpuinfo
echo "Running cmake --build with flags: ${buildflags[*]}" echo "Running cmake --build with flags: ${buildflags[*]}"
cmake --build . "${buildflags[@]}" cmake --build . "${buildflags[@]}"
echo "::endgroup::" echo "::endgroup::"
if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then
# Repeat the summary in the job log so the numbers are readable without
# downloading the binary log, which is hundreds of megabytes.
echo "::group::MSBuild performance summary"
cat msbuild-perf.log
echo "::endgroup::"
fi
if [[ $USE_CCACHE ]]; then if [[ $USE_CCACHE ]]; then
if [[ $CCACHE_EVICTION_AGE ]]; then if [[ $CCACHE_EVICTION_AGE ]]; then
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE" echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
@ -398,50 +306,7 @@ fi
if [[ $MAKE_TEST ]]; then if [[ $MAKE_TEST ]]; then
echo "::group::Run tests" echo "::group::Run tests"
if [[ $MAKE_SANITIZE ]]; then ctest -C "$BUILDTYPE" --output-on-failure
# detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and
# LeakSanitizer's default is aggressive enough to bury our real findings under hundreds
# of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for
# and they stay fully on.
# abort_on_error=1: a bad access leaves the process in an unusable state, so continuing
# only produces a second, more confusing report from the wreckage.
# UBSAN halt_on_error=1: without it UBSan prints "runtime error: ..." but the
# process exits 0 (recover mode), so gtest/ctest report a pass and the job
# stays green. Combined with -fno-sanitize-recover=undefined at compile time,
# the first finding aborts the test process with a non-zero exit.
export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:print_stacktrace=1:symbolize=1"
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1:symbolize=1"
fi
# Every TEST() is its own CTest entry, so the suite is process-spawn bound rather than CPU
# bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to
# run concurrently: the only one that claims a fixed resource is single_instance_manager_test,
# which already scopes its local socket name to its own pid so parallel copies cannot collide.
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
echo "::endgroup::"
fi
if [[ $MAKE_COVERAGE ]]; then
# gcovr is not a build dependency and is not available everywhere, so a missing one is reported
# and skipped rather than failing an otherwise green build.
echo "::group::Coverage report"
GCOVR="$(command -v gcovr || true)"
if [[ -z $GCOVR ]]; then
echo "gcovr not found; install it to produce a coverage report"
else
"$GCOVR" --version
# This script configures with `cmake ..`, so the build dir it was started from is the object
# directory and the source root is one level up. Both are passed explicitly: gcovr otherwise
# walks the current directory and trips over in-tree build directories left by other tooling,
# whose stale .gcno files cannot be resolved. --config is named too, so the filters apply no
# matter what directory gcovr happens to start from.
GCOVR_ARGS=("." --root "$SRC_ROOT" --object-directory "." --config "$SRC_ROOT/gcovr.cfg")
"$GCOVR" "${GCOVR_ARGS[@]}" --txt --decisions
"$GCOVR" "${GCOVR_ARGS[@]}" --html-details coverage-details.html --html-title "Cockatrice line coverage"
"$GCOVR" "${GCOVR_ARGS[@]}" --lcov -o coverage.lcov
"$GCOVR" "${GCOVR_ARGS[@]}" --cobertura-pretty -o coverage.xml
"$GCOVR" "${GCOVR_ARGS[@]}" --json-summary-pretty -o coverage-summary.json
echo "Coverage artifacts written to coverage-details.html, coverage.lcov, coverage.xml and coverage-summary.json"
fi
echo "::endgroup::" echo "::endgroup::"
fi fi

View file

@ -13,17 +13,11 @@
# --set-cache <location> sets the location to cache the image or for ccache # --set-cache <location> sets the location to cache the image or for ccache
# #
# requires: docker # requires: docker
# uses env: NAME IMAGE_SUFFIX CACHE BUILD GET SAVE INTERACTIVE # uses env: NAME CACHE BUILD GET SAVE INTERACTIVE
# (correspond to args: <name> --set-cache <cache> --build --get --save --interactive) # (correspond to args: <name> --set-cache <cache> --build --get --save --interactive)
# sets env: RUN CCACHE_DIR IMAGE_NAME RUN_ARGS RUN_OPTS BUILD_SCRIPT # sets env: RUN CCACHE_DIR IMAGE_NAME RUN_ARGS RUN_OPTS BUILD_SCRIPT
# exitcode: 1 for failure, 2 for missing dockerfile, 3 for invalid arguments # exitcode: 1 for failure, 2 for missing dockerfile, 3 for invalid arguments
# #
# IMAGE_SUFFIX is optional and only appended to the image name, never to NAME, so that a matrix
# leg can have its own image and image cache without needing its own directory under .ci. Two legs
# building and tagging the same image concurrently would race on `docker build --tag`.
# It is deliberately not called SUFFIX: compile.sh already uses SUFFIX for output file names, and
# a job-level SUFFIX would be silently overridden by those per-step env blocks.
#
# exported RUN function will run the BUILD_SCRIPT inside of the docker container. # exported RUN function will run the BUILD_SCRIPT inside of the docker container.
# note that the docker container will not inherit any environment variables! # note that the docker container will not inherit any environment variables!
# #
@ -87,7 +81,7 @@ if ! [[ $NAME ]]; then
return 3 return 3
fi fi
export IMAGE_NAME="${project_name,,}_${NAME,,}${IMAGE_SUFFIX:+_${IMAGE_SUFFIX,,}}" # lower case export IMAGE_NAME="${project_name,,}_${NAME,,}" # lower case
docker_dir=".ci/$NAME" docker_dir=".ci/$NAME"
if ! [[ -r $docker_dir/Dockerfile ]]; then if ! [[ -r $docker_dir/Dockerfile ]]; then

View file

@ -144,36 +144,17 @@ jobs:
package: DEB package: DEB
# The one leg that runs the suite under AddressSanitizer and name: ${{ matrix.distro }} ${{ matrix.version }}
# UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the
# uninstrumented suite, so nothing is lost by instrumenting this one.
sanitize: yes
- distro: Ubuntu
version: 26.04
variant: cover # separate image and ccache, same distro
# The one leg that runs instrumented for line coverage and publishes the report as
# a workflow artifact. It is a separate leg rather than a second flag on the
# sanitizer leg because the two instruments answer different questions: the
# sanitizer leg reports what the run did wrong, the coverage leg reports what the
# run never touched at all. Combining them would also slow both down. Packaging is
# skipped because the leg above already publishes the DEB for this distro.
coverage: yes
package: skip
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure needs: configure
runs-on: ubuntu-latest # https://github.com/actions/runner-images runs-on: ubuntu-latest # https://github.com/actions/runner-images
continue-on-error: ${{ matrix.allow-failure == 'yes' }} continue-on-error: ${{ matrix.allow-failure == 'yes' }}
timeout-minutes: 70 timeout-minutes: 70
env: env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d CCACHE_EVICTION_AGE: 7d
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
CMAKE_GENERATOR: 'Ninja' CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }} NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
steps: steps:
- name: "Checkout" - name: "Checkout"
@ -183,8 +164,8 @@ jobs:
id: restore_ccache id: restore_ccache
uses: actions/cache/restore@v6 uses: actions/cache/restore@v6
with: with:
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} key: ccache-${{ matrix.distro }}${{ matrix.version }}
path: ${{ env.CACHE }}/.ccache path: ${{ env.CACHE }}
- name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image" - name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image"
shell: bash shell: bash
@ -193,9 +174,6 @@ jobs:
- name: "Build debug and test" - name: "Build debug and test"
if: matrix.test != 'skip' if: matrix.test != 'skip'
shell: bash shell: bash
env:
sanitize: '${{ matrix.sanitize }}'
coverage: '${{ matrix.coverage }}'
run: | run: |
source .ci/docker.sh source .ci/docker.sh
args=() args=()
@ -203,33 +181,7 @@ jobs:
args+=(--ccache "$CCACHE_SIZE") args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR") args+=(--cmake-generator "$CMAKE_GENERATOR")
extra=() RUN --server --debug --test "${args[@]}"
[[ $sanitize == yes ]] && extra+=(--sanitize)
[[ $coverage == yes ]] && extra+=(--coverage)
RUN --server --debug --test "${extra[@]}" "${args[@]}"
# RUN bind-mounts the workspace into the container, so the report the build wrote into
# build/ is already on the host and no second container is needed to collect it.
- name: "Collect coverage report"
if: matrix.coverage == 'yes'
shell: bash
run: |
mkdir -p coverage
# --html-details writes one page per source file plus a stylesheet and script, and the
# index links to all of them by relative name. Copying the index alone would publish a
# report where every link 404s, so the whole family has to travel together.
cp build/coverage-details* coverage/
cp build/coverage.lcov build/coverage.xml build/coverage-summary.json coverage/
ls -la coverage/
- name: "Upload coverage artifact"
if: matrix.coverage == 'yes'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
if-no-files-found: warn
- name: "Build release package" - name: "Build release package"
id: build id: build
@ -267,7 +219,7 @@ jobs:
uses: actions/cache/save@v6 uses: actions/cache/save@v6
with: with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }} key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CACHE }}/.ccache path: ${{ env.CACHE }}
- name: "Upload artifact" - name: "Upload artifact"
id: upload_artifact id: upload_artifact
@ -325,8 +277,6 @@ jobs:
soc: Intel soc: Intel
type: Release type: Release
use_ccache: 1 use_ccache: 1
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
xcode: "26.3" xcode: "26.3"
- os: macOS - os: macOS
@ -343,9 +293,6 @@ jobs:
soc: Apple soc: Apple
type: Release type: Release
use_ccache: 1 use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
xcode: "26.6" xcode: "26.6"
- os: macOS - os: macOS
@ -362,8 +309,6 @@ jobs:
soc: Apple soc: Apple
type: Release type: Release
use_ccache: 1 use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6" xcode: "26.6"
- os: macOS - os: macOS
@ -377,30 +322,19 @@ jobs:
soc: Apple soc: Apple
type: Debug type: Debug
use_ccache: 1 use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6" xcode: "26.6"
- os: Windows - os: Windows
target: 10 target: 10
runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md
# Ninja builds the whole dependency graph at once instead of one cmake_generator: "Visual Studio 18 2026"
# MSBuild node at a time, runs the compiler itself, and matches the cmake_generator_platform: x64
# Linux and macOS legs. Its median build phase measured ~17 min
# against ~21 for the Visual Studio generator (shared 4-core hosts
# swing either by minutes, so read medians, not single runs).
cmake_generator: Ninja
make_package: 1 make_package: 1
package_suffix: "-Win10" package_suffix: "-Win10"
qt_version: 6.11.* qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
type: Release type: Release
# Scope is named after the vs2026 toolchain, not the generator: the
# ABI hash covers cl.exe, which is identical, so this leg keeps
# restoring the warm archives. Renaming would orphan them.
vcpkg_scope: 'vcpkg/windows10-x64-vs2026/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: configure needs: configure
@ -494,39 +428,12 @@ jobs:
shell: bash shell: bash
run: choco install nsis run: choco install nsis
# Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
- name: "[Windows] Set up MSVC environment"
if: matrix.os == 'Windows'
uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9 # v4.1.0
with:
arch: x64
- name: "Setup vcpkg cache" - name: "Setup vcpkg cache"
id: vcpkg-cache id: vcpkg-cache
uses: TAServers/vcpkg-cache@v3 uses: TAServers/vcpkg-cache@v3
with: with:
# Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform,
# target and toolchain. The action restores every entry it finds under the
# prefix, not just the hashes vcpkg asks for, so with one shared vcpkg/
# namespace every leg downloads all 66 archives of all 5 legs on every run.
# Each of those restores also refreshes the entry's last access date, which
# is why a dead ABI generation is never evicted by age. Naming the scope is
# what makes those generations identifiable, and prune-caches below able to
# delete them.
prefix: ${{ matrix.vcpkg_scope }}
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
# The action only creates its directory when the prefix matched at least one
# entry, and it reads that directory unguarded when it saves at the end of the
# job. A scope nobody has written to yet therefore leaves the directory missing
# and fails the job, which is exactly the state every scope starts in. Make the
# directory up front so an empty scope is a cache miss instead of an error.
- name: "Create vcpkg cache directory"
shell: bash
env:
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
run: mkdir -p "$CACHE_DIR"
# Uses environment variables, see compile.sh for more details # Uses environment variables, see compile.sh for more details
- name: "Build Cockatrice" - name: "Build Cockatrice"
id: build id: build
@ -535,6 +442,7 @@ jobs:
BUILDTYPE: '${{ matrix.type }}' BUILDTYPE: '${{ matrix.type }}'
CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }} CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }}
CMAKE_GENERATOR: ${{ matrix.cmake_generator }} CMAKE_GENERATOR: ${{ matrix.cmake_generator }}
CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }}
DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
@ -545,19 +453,9 @@ jobs:
PACKAGE_SUFFIX: '${{ matrix.package_suffix }}' PACKAGE_SUFFIX: '${{ matrix.package_suffix }}'
TARGET_MACOS_VERSION: ${{ matrix.override_target }} TARGET_MACOS_VERSION: ${{ matrix.override_target }}
USE_CCACHE: ${{ matrix.use_ccache }} USE_CCACHE: ${{ matrix.use_ccache }}
# Only pushes to the default branch may write the shared binary cache: the
# archives a run compiles are only as trustworthy as the code they were
# built from, and only merged code may populate the scope that master and
# release builds restore and link. Pull requests (same-repository ones
# included) restore the shared cache but never write it; with the source
# left at read the collect step below finds nothing on PRs, so no
# pull-request-scoped entry is ever saved that no later run could restore.
# Forks pushing to their own default branch resolve github.ref against
# their own repository, so they keep a working cache of their own there.
VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}" VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}"
VCPKG_DISABLE_METRICS: 1 VCPKG_DISABLE_METRICS: 1
VCPKG_FEATURE_FLAGS: dependencygraph VCPKG_FEATURE_FLAGS: dependencygraph
VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }}
run: .ci/compile.sh --server --test --vcpkg run: .ci/compile.sh --server --test --vcpkg
# CPack reports a failed makensis run as "Problem running NSIS command" # CPack reports a failed makensis run as "Problem running NSIS command"
@ -571,63 +469,6 @@ jobs:
shell: bash shell: bash
run: find . -name NSISOutput.log -exec cat {} + run: find . -name NSISOutput.log -exec cat {} +
# A master run hands over the archives it had to build itself so that
# vcpkg-cache-import.yml, which runs in the default branch scope, can save them
# where later runs can restore them. Pull requests hand over nothing: their cache
# entries would be scoped to refs/pull/<n>/merge and stay unreadable for every
# other run, so writing them only burns the repository's quota.
# vcpkg_upload keeps this to one leg per scope: macOS 14, 15 and 26 share a scope
# by design, and all three rebuild the same archives, so uploading from each of them
# would publish three identical 269 MB artifacts under one artifact name and make
# the importer spawn a save job per copy.
- name: "Collect new vcpkg entries"
id: vcpkg_new
if: github.ref == 'refs/heads/master' && matrix.vcpkg_upload == 1
shell: bash
env:
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
GH_TOKEN: ${{ github.token }}
VCPKG_SCOPE: '${{ matrix.vcpkg_scope }}'
run: |
# The archive name is the ABI hash, so a missing key is exactly what vcpkg had
# to compile from source in this job.
gh api --paginate \
"repos/$GITHUB_REPOSITORY/actions/caches?ref=refs/heads/master&key=$VCPKG_SCOPE&per_page=100" \
--jq '.actions_caches[].key' | sort -u >"$RUNNER_TEMP/master-keys.txt"
find "$CACHE_DIR" -type f -name '*.zip' -exec basename {} .zip \; |
sed "s|^|$VCPKG_SCOPE|" | sort -u >"$RUNNER_TEMP/local-keys.txt"
comm -23 "$RUNNER_TEMP/local-keys.txt" "$RUNNER_TEMP/master-keys.txt" >"$RUNNER_TEMP/new-keys.txt"
mkdir -p "$RUNNER_TEMP/vcpkg-new"
: >"$RUNNER_TEMP/vcpkg-new/keys.txt"
while read -r key; do
hash="${key##*/}"
archive="$(find "$CACHE_DIR" -type f -name "$hash.zip" -print -quit)"
dir="$RUNNER_TEMP/vcpkg-new/${hash:0:2}" # vcpkg's own two character fanout
mkdir -p "$dir"
cp "$archive" "$dir/"
echo "$key ${hash:0:2}/$hash.zip" >>"$RUNNER_TEMP/vcpkg-new/keys.txt"
done <"$RUNNER_TEMP/new-keys.txt"
count="$(wc -l <"$RUNNER_TEMP/new-keys.txt")"
echo "vcpkg scope $VCPKG_SCOPE: $count entries to import"
echo "count=$count" >>"$GITHUB_OUTPUT"
echo "slug=$(sed -e 's|^vcpkg/||' -e 's|/$||' -e 's|/|-|g' <<<"$VCPKG_SCOPE")" >>"$GITHUB_OUTPUT"
# archive: false accepts a single file and nothing else, and a scope hands over one
# archive per ABI hash vcpkg had to rebuild, which is 23 files on a cold runner.
# Zipping the directory keeps the fanout at the artifact root, which is where
# vcpkg-cache-import.yml looks for keys.txt once download-artifact unpacks it.
- name: "Upload new vcpkg entries"
# An empty count means the step above did not run at all, on master and on forks
if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0'
uses: actions/upload-artifact@v7
with:
if-no-files-found: error # a count above zero with nothing staged is a bug
name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }}
path: ${{ runner.temp }}/vcpkg-new
retention-days: 1
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "[macOS] Delete remote compiler cache (ccache)" - name: "[macOS] Delete remote compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
@ -703,8 +544,6 @@ jobs:
if-no-files-found: error if-no-files-found: error
path: ${{ steps.build.outputs.path }} 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)" - name: "[Windows] Upload PDBs (Program Databases)"
if: matrix.os == 'Windows' && github.ref_type != 'tag' if: matrix.os == 'Windows' && github.ref_type != 'tag'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@ -712,9 +551,9 @@ jobs:
if-no-files-found: error if-no-files-found: error
name: ${{ steps.build.outputs.name }}-PDBs name: ${{ steps.build.outputs.name }}-PDBs
path: | path: |
build/cockatrice/*.pdb build/cockatrice/Release/*.pdb
build/oracle/*.pdb build/oracle/Release/*.pdb
build/servatrice/*.pdb build/servatrice/Release/*.pdb
- name: "Upload to release" - name: "Upload to release"
if: needs.configure.outputs.tag != null && matrix.make_package == '1' if: needs.configure.outputs.tag != null && matrix.make_package == '1'
@ -742,67 +581,3 @@ jobs:
BUILD_PATH: ${{ steps.build.outputs.path }} BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
prune-caches:
name: Prune caches
# Only master writes caches at all, so only master may clean them up too
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
continue-on-error: true # freeing space must never fail a build
steps:
# The repository holds 10Gi of cache against entries that want more, and GitHub
# evicts the least recently used entry whenever that is exceeded. The vcpkg
# entries are the reason that never happens: the restore above touches every
# entry under the prefix on every run, so a dead ABI generation keeps its access
# date fresh forever and age based eviction can never reach it. Deleting by age
# alone cannot fix that, only knowing which scope an entry belongs to can.
- name: "Delete unusable cache entries"
shell: bash
env:
GH_TOKEN: ${{ github.token }}
# Must list every vcpkg_scope of the build-vcpkg matrix above, or the entries of
# a scope that is still in use get deleted on the next run.
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
run: |
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
--jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
live=" ${LIVE_VCPKG_SCOPES} "
deleted=0
while IFS=$'\t' read -r id ref key accessed; do
[[ -n $id ]] || continue
# Only ever touch the key shapes this workflow creates, so that an entry of
# some other workflow can never be deleted by accident
case "$key" in
vcpkg/* | ccache-* | Qt*) ;;
*) continue ;;
esac
reason=""
if [[ $ref != refs/heads/master ]]; then
reason="scoped to $ref"
elif [[ $key == vcpkg/* ]]; then
# "<scope>/<abi hash>", cut the hash off the end to get the scope
scope="vcpkg/${key#vcpkg/}"
scope="${scope%/*}/"
if [[ $live != *" $scope "* ]]; then
reason="no matrix leg uses $scope"
fi
fi
if [[ -z $reason ]]; then
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"
if [[ $stamp -ne 0 && $stamp -lt $cutoff ]]; then
reason="unused since $accessed"
fi
fi
if [[ -n $reason ]]; then
echo "::notice::deleting $key, $reason"
gh api -X DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id"
deleted=$((deleted + 1))
fi
done <"$RUNNER_TEMP/caches.tsv"
echo "::notice::deleted $deleted of $(wc -l <"$RUNNER_TEMP/caches.tsv") cache entries"

View file

@ -1,121 +0,0 @@
name: Import vcpkg cache
# After a runner image update changes every port's ABI hash, the first master run
# rebuilds protobuf, openssl and abseil from source and saves them into the shared
# cache, where every later run can restore them.
#
# Only master pushes are imported here: the collect step, keys.txt and the zip
# contents all come from the workflow run being imported, so importing from an
# unmerged pull request would let anyone who can push a branch — or a bot actor
# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into
# the scope that master and tag builds restore and link into signed releases.
# Forks build untrusted code and must never reach that scope either.
#
# repository_dispatch is not used on purpose: it would also resolve to the default branch,
# but it counts as a low-trust trigger and may only write there when it declares a
# write-capable cache-mode, which puts it in the same position as workflow_run.
permissions:
actions: write # needed to save entries into the default branch's cache scope
contents: read
on:
workflow_run:
workflows: ["Build Desktop"]
types: [completed]
# Every import writes the same scopes, so let them queue instead of racing each other
concurrency:
group: vcpkg-cache-import
cancel-in-progress: false
jobs:
list:
name: List new vcpkg entries
# Only a successful push to the default branch has archives worth importing.
# Only code that landed on master may populate the scope master runs restore.
# Bot actors are excluded on top: their runs must never promote binaries into
# the shared scope even if they somehow target the default branch.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!endsWith(github.event.workflow_run.actor.login, '[bot]') &&
!endsWith(github.event.workflow_run.triggering_actor.login, '[bot]')
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
# workflow_run is a low-trust trigger, so cache access is read-only by default even
# though this run resolves to the default branch. Assert below that this took effect,
# because a save under read-only access only warns and would import nothing silently.
cache-mode: write
outputs:
entries: ${{ steps.scan.outputs.entries }}
steps:
- name: "Download new vcpkg entries"
uses: actions/download-artifact@v8
with:
pattern: vcpkg-new-*
path: incoming
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: "Assert write access"
shell: bash
run: |
if [[ $ACTIONS_CACHE_MODE != *write* ]]; then
echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'"
exit 1
fi
- name: "List cache entries"
id: scan
shell: bash
working-directory: incoming
run: |
entries="$(find . -name keys.txt -type f -exec sh -c '
for manifest; do
artifact="$(basename "$(dirname "$manifest")")"
while read -r key path; do
printf "{\"artifact\": \"%s\", \"key\": \"%s\", \"path\": \"%s\"}\n" \
"$artifact" "$key" "$path"
done <"$manifest"
done
' _ {} +)"
[[ -n $entries ]] || exit 0
# Compact, because a multi line value cannot be written to GITHUB_OUTPUT
entries="$(jq -cs '.' <<<"$entries")"
echo "entries=$entries" >>"$GITHUB_OUTPUT"
echo "::notice::importing $(jq length <<<"$entries") vcpkg entries"
save:
name: "Save ${{ matrix.entry.key }}"
needs: list
if: needs.list.outputs.entries
runs-on: ubuntu-slim
cache-mode: write
strategy:
# One job per archive, they run in parallel and each only uploads a single file
fail-fast: false
max-parallel: 20
matrix:
entry: ${{ fromJSON(needs.list.outputs.entries) }}
steps:
- name: "Download new vcpkg entries"
uses: actions/download-artifact@v8
with:
pattern: ${{ matrix.entry.artifact }}
path: incoming
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: "Save vcpkg entry"
uses: actions/cache/save@v6
with:
# The payload is a Windows or macOS archive while the upload runs on Linux, and
# without this the entry would be stamped as a Linux cache that neither of them
# is allowed to restore.
enableCrossOsArchive: true
key: ${{ matrix.entry.key }}
path: incoming/${{ matrix.entry.path }}

View file

@ -22,10 +22,6 @@ option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
# Compile tests # Compile tests
option(TEST "Build tests" OFF) option(TEST "Build tests" OFF)
# Build with AddressSanitizer and UndefinedBehaviorSanitizer
option(ENABLE_SANITIZERS "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF)
# Build instrumented for GCC line coverage, reported with gcovr
option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF)
# Use vcpkg regardless of OS # Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF) option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
@ -90,7 +86,7 @@ endif()
# A project name is needed for CPack # A project name is needed for CPack
# Version can be overriden by git tags, see cmake/getversion.cmake # Version can be overriden by git tags, see cmake/getversion.cmake
project("Cockatrice" VERSION 3.1.2) project("Cockatrice" VERSION 3.1.1)
# Set release name if not provided via env/cmake var # Set release name if not provided via env/cmake var
if(NOT DEFINED GIT_TAG_RELEASENAME) if(NOT DEFINED GIT_TAG_RELEASENAME)
@ -215,12 +211,6 @@ else()
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
endif() endif()
# Always included, because they define cockatrice_disable_sanitizers_for() and
# cockatrice_disable_coverage_for() for the vendored trees. They only touch the global flags
# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on.
include(Sanitizers)
include(Coverage)
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_definitions("-DSFMT_MEXP=19937") add_definitions("-DSFMT_MEXP=19937")

View file

@ -1,75 +0,0 @@
# Line-coverage instrumentation opt-in, plus per-source opt-out
# Input: ENABLE_COVERAGE
# Defines: cockatrice_disable_coverage_for
#
# Included once from the root CMakeLists.txt, immediately after the per-compiler flag
# block, so add_compile_options() reaches every library and executable defined below.
#
# Note the name deliberately shadows CMake's own built-in Modules/Coverage.cmake. It resolves
# to this file only because the root CMakeLists.txt puts cmake/ at the front of
# CMAKE_MODULE_PATH; if that ever moves, include(Coverage) would silently pick up the built-in
# module, which only defines a `COVERAGE` custom target and touches no global flags, so
# instrumentation would quietly stop happening rather than erroring.
include(CheckCXXSourceCompiles)
# Opt a set of vendored sources out of instrumentation.
#
# cockatrice_disable_coverage_for(<source> ...)
#
# Third-party code drags the total down with lines no test can ever reach, and a total that
# cannot move is not a useful signal. This is the same carve-out the sanitizer build uses.
#
# The sources must be named relative to the directory that compiles them. CMake resolves a
# source-file property against the directory scope the target was declared in, so calling
# this from the root with absolute paths silently does nothing. That is why the call sites
# live next to the add_library/add_executable that pull the vendored code in.
#
# SKIP_PRECOMPILE_HEADERS mirrors the sanitizer helper. The precompiled header carries the
# instrumentation flags, and mixing an instrumented PCH with an uninstrumented translation
# unit makes GCC reject it.
function(cockatrice_disable_coverage_for)
if(NOT ENABLE_COVERAGE)
return()
endif()
# There is no -fno-coverage: --coverage is shorthand for -fprofile-arcs -ftest-coverage, so both
# halves have to be negated. CC rejects "-fno-coverage" outright, which is how the vendored C
# files below hit it first.
set_source_files_properties(
${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-profile-arcs;-fno-test-coverage" SKIP_PRECOMPILE_HEADERS ON
)
endfunction()
if(ENABLE_COVERAGE)
# Line coverage needs the compiler's own toolchain, so only GCC is supported. Clang's
# equivalent is llvm-cov, which reads a different intermediate format and would need a
# separate report path; rejecting it is better than emitting .gcno files that gcovr cannot
# read.
if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
message(FATAL_ERROR "ENABLE_COVERAGE requires GCC, but the compiler is ${CMAKE_CXX_COMPILER_ID}. "
"Clang coverage needs llvm-cov and is not wired up."
)
endif()
# Probe the way the flag will actually be used: compile AND link. A .gcno file comes from the
# compile step, but the runtime that writes .gcda files comes from the link step, so probing
# only the compile step would pass on a toolchain that cannot actually produce coverage.
set(_cockatrice_saved_required_flags "${CMAKE_REQUIRED_FLAGS}")
set(_cockatrice_saved_required_link_options "${CMAKE_REQUIRED_LINK_OPTIONS}")
set(CMAKE_REQUIRED_FLAGS "-O0 -g")
set(CMAKE_REQUIRED_LINK_OPTIONS -fprofile-arcs -ftest-coverage)
check_cxx_source_compiles("int main() { return 0; }" COCKATRICE_HAVE_COVERAGE_FLAGS)
set(CMAKE_REQUIRED_FLAGS "${_cockatrice_saved_required_flags}")
set(CMAKE_REQUIRED_LINK_OPTIONS "${_cockatrice_saved_required_link_options}")
if(NOT COCKATRICE_HAVE_COVERAGE_FLAGS)
message(FATAL_ERROR "ENABLE_COVERAGE is on, but this toolchain rejected the coverage flags.")
endif()
# -O0 keeps the line mapping honest: at higher optimisation levels the compiler merges and
# relocates lines, and the report describes code that no longer exists in that form.
add_compile_options(--coverage -O0 -g)
add_link_options(--coverage)
message(STATUS "Coverage instrumentation enabled (--coverage). Report with gcovr over the build dir.")
endif()

View file

@ -40,9 +40,7 @@ endif()
if(TEST) if(TEST)
# Union of Qt modules required across all test targets (independent of application targets). # Union of Qt modules required across all test targets (independent of application targets).
# When adding a new test that needs additional Qt modules, add them here rather than in the test's CMakeLists.txt. # When adding a new test that needs additional Qt modules, add them here rather than in the test's CMakeLists.txt.
# Test is QtTest: it brings QAbstractItemModelTester and QSignalSpy, which are how the Qt model set(_TEST_NEEDED Concurrent Network Svg Widgets)
# and signal contracts get verified without standing up a GUI.
set(_TEST_NEEDED Concurrent Network Svg Widgets Test)
endif() endif()
set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_COCKATRICE_NEEDED} ${_ORACLE_NEEDED} set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_COCKATRICE_NEEDED} ${_ORACLE_NEEDED}

View file

@ -304,11 +304,10 @@ SetShellVarContext all
; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller ; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller
; normally copies itself to %TEMP% and runs from there so that it can delete its ; normally copies itself to %TEMP% and runs from there so that it can delete its
; own directory, and the process we started exits at once. ExecWait then returns ; own directory, and the process we started exits at once. ExecWait then returns
; before the uninstall has run, and the temp copy keeps deleting files - from ; before the uninstall has run, and the temp copy's "RMDir /r $INSTDIR" executes
; 3.1.0 and 3.1.1 on disk a blanket "RMDir /r $INSTDIR" - while the section ; while the section below is still writing the new payload. _?= disables the copy
; below is still writing the new payload. _?= disables the copy and makes ; and makes ExecWait block until the uninstall finishes. It must be the last
; ExecWait block until the uninstall finishes. It must be the last argument ; argument and must not be quoted, even when the path contains spaces.
; and must not be quoted, even when the path contains spaces.
; ;
; --- 32-bit uninstall --- ; --- 32-bit uninstall ---
SetRegView 32 SetRegView 32
@ -1108,14 +1107,9 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
; upgrade portable mode ; upgrade portable mode
!insertmacro CockLog "section: portable upgrade purge" !insertmacro CockLog "section: portable upgrade purge"
!insertmacro CockLogListing "before-portable-purge" "*.dll" !insertmacro CockLogListing "before-portable-purge" "*.dll"
; plugins and translations are payload and are rewritten by the payload
; below, so removing them here is safe. sounds and themes are payload
; too - the default packs are rewritten below as well - but a portable
; user's custom sounds and themes live in exactly those folders, so a
; recursive purge here would delete user content along with the defaults.
; Stale default files from an older release are overwritten or replaced
; by the payload, never by a purge.
RMDir /r "$INSTDIR\plugins" RMDir /r "$INSTDIR\plugins"
RMDir /r "$INSTDIR\sounds"
RMDir /r "$INSTDIR\themes"
RMDir /r "$INSTDIR\translations" RMDir /r "$INSTDIR\translations"
Delete "$INSTDIR\uninstall.exe" Delete "$INSTDIR\uninstall.exe"
Delete "$INSTDIR\cockatrice.exe" Delete "$INSTDIR\cockatrice.exe"
@ -1269,32 +1263,16 @@ Section "un.Application" UnSecApplication
SetShellVarContext all SetShellVarContext all
StrCpy $R1 0 StrCpy $R1 0
; Record whether this was a portable install before portable.dat is ; Record whether this was a portable install before the directory (and the
; removed below. ; portable.dat marker inside it) is removed below.
IfFileExists "$INSTDIR\portable.dat" 0 +2 IfFileExists "$INSTDIR\portable.dat" 0 +2
StrCpy $R1 1 StrCpy $R1 1
; Remove only what the installer wrote. The two lists below are generated ; Remove the entire application directory so any file that is not part of
; by CPack from the staged install tree: one Delete per installed file and ; the installed payload (e.g. build-tree artifacts such as *.dir folders,
; one non-recursive RMDir per installed directory, children first. ; *_autogen and *.tlog files from a build) cannot survive between an
; Anything the user put in $INSTDIR - decks, pictures, portable data ; uninstall and a fresh reinstall.
; folders - is not on those lists and survives, and $INSTDIR itself is RMDir /r "$INSTDIR"
; only removed when nothing else is left in it. An earlier version of this
; section ran a blanket "RMDir /r $INSTDIR" to keep build-tree artifacts
; out of a fresh reinstall; that also deleted user decks whenever a user
; had pointed their data folders into the install directory, so payload
; purity may never be bought at that price again.
@CPACK_NSIS_DELETE_FILES@
@CPACK_NSIS_DELETE_DIRECTORIES@
; Written by the installer itself, so absent from the staged payload
; above; qdebug.txt is app-generated log output next to the executables.
Delete "$INSTDIR\uninstall.exe"
Delete "$INSTDIR\portable.dat"
Delete "$INSTDIR\qdebug.txt"
; Succeeds only when no foreign files remain.
RMDir "$INSTDIR"
RMDir /r "$SMPROGRAMS\Cockatrice" RMDir /r "$SMPROGRAMS\Cockatrice"

View file

@ -1,109 +0,0 @@
# AddressSanitizer + UndefinedBehaviorSanitizer opt-in, plus per-source opt-out
# Input: ENABLE_SANITIZERS
# Defines: cockatrice_disable_sanitizers_for
#
# Included once from the root CMakeLists.txt, immediately after the per-compiler flag
# block, so add_compile_options() reaches every library and executable defined below.
include(CheckCXXSourceCompiles)
# Opt a set of vendored sources out of instrumentation.
#
# cockatrice_disable_sanitizers_for(<source> ...)
#
# Sanitizer findings inside vendored code are not actionable for us, and letting them
# through buries our own bugs in noise until everyone learns to skip the job.
#
# The sources must be named relative to the directory that compiles them. CMake resolves a
# source-file property against the directory scope the target was declared in, so calling
# this from the root with absolute paths silently does nothing. That is why the call sites
# live next to the add_library/add_executable that pull the vendored code in.
#
# SKIP_PRECOMPILE_HEADERS is not optional. The precompiled header is built with the
# sanitizer flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with
# -fno-sanitize=all does not, and GCC then refuses the PCH with
# "not used because __SANITIZE_ADDRESS__ not defined", which is a fatal -Werror=invalid-pch
# in the servatrice and oracle builds.
function(cockatrice_disable_sanitizers_for)
if(NOT ENABLE_SANITIZERS)
return()
endif()
set_source_files_properties(${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-sanitize=all" SKIP_PRECOMPILE_HEADERS ON)
endfunction()
if(ENABLE_SANITIZERS)
# Probe a flag the way it will actually be used: on the compile step AND the link step.
# check_cxx_compiler_flag() only probes the compile step, which makes every sanitizer
# flag look unsupported because linking an ASan object without -fsanitize=address leaves
# __asan_init undefined. That is a silent misconfiguration rather than an error, so the
# probe has to link.
function(cockatrice_probe_flag FLAG VARNAME)
set(CMAKE_REQUIRED_FLAGS "${FLAG}")
set(CMAKE_REQUIRED_LINK_OPTIONS "${FLAG}")
check_cxx_source_compiles("int main() { return 0; }" ${VARNAME})
unset(CMAKE_REQUIRED_FLAGS)
unset(CMAKE_REQUIRED_LINK_OPTIONS)
endfunction()
set(SANITIZER_FLAGS
-fsanitize=address,undefined
-fno-omit-frame-pointer
# UBSan defaults to recover mode: it prints "runtime error: ..." but the
# process exits 0, so gtest/ctest report a pass and CI stays green.
# -fno-sanitize-recover makes the first finding abort instead. This is
# probed like the other cosmetic flags, but CI also sets
# UBSAN_OPTIONS=halt_on_error=1 as the runtime-side guarantee.
-fno-sanitize-recover=undefined
# Qt builds QObject subclasses into raw storage and writes the vptr afterwards, which
# the vptr check reports on essentially every QObject instantiation. The check has no
# value for us because nothing outside moc-generated code inherits QObject, so it is
# off outright rather than suppressed piecemeal.
-fno-sanitize=vptr
)
# MSVC has no ASan/UBSan equivalent, and clang-cl would need /fsanitize=address plus a
# matching runtime link, which is a separate piece of work.
if(MSVC)
message(FATAL_ERROR "ENABLE_SANITIZERS is not supported with MSVC")
endif()
# Probe rather than assume, so an older toolchain gets the sanitizers it can support
# instead of failing the build on a flag it does not recognize.
set(SANITIZER_SUPPORTED_FLAGS)
foreach(FLAG ${SANITIZER_FLAGS})
string(MAKE_C_IDENTIFIER "COCKATRICE_HAS${FLAG}" _FLAG_VAR)
cockatrice_probe_flag("${FLAG}" ${_FLAG_VAR})
if(${_FLAG_VAR})
list(APPEND SANITIZER_SUPPORTED_FLAGS ${FLAG})
else()
message(STATUS "Sanitizers: ${FLAG} unsupported by ${CMAKE_CXX_COMPILER_ID}, skipping")
endif()
endforeach()
# The whole point of the option is the sanitizers, so an unsupported pair is fatal even
# though the cosmetic flags above are allowed to degrade.
if(NOT "-fsanitize=address,undefined" IN_LIST SANITIZER_SUPPORTED_FLAGS)
message(FATAL_ERROR "ENABLE_SANITIZERS requires -fsanitize=address,undefined, which "
"${CMAKE_CXX_COMPILER_ID} does not accept"
)
endif()
add_compile_options(${SANITIZER_SUPPORTED_FLAGS})
add_link_options(-fsanitize=address,undefined)
# Recorded so the test tree can label its entries and a plain `make` at the same time
# cannot quietly disagree with what CI ran.
set(COCKATRICE_SANITIZERS
ON
CACHE INTERNAL "AddressSanitizer and UBSan are enabled"
)
else()
# ENABLE_SANITIZERS is OFF, but COCKATRICE_SANITIZERS is CACHE INTERNAL, so a
# reused build dir (e.g. CI's release-package step reconfiguring the same dir
# after the sanitized debug step) would otherwise keep skipping NO_SANITIZE
# tests and keep the 300s discovery timeout. Reset it explicitly.
set(COCKATRICE_SANITIZERS
OFF
CACHE INTERNAL "AddressSanitizer and UBSan are enabled" FORCE
)
endif()

View file

@ -3,53 +3,22 @@
* *
* Safe for every target that links Qt Core, including the headless * Safe for every target that links Qt Core, including the headless
* Servatrice binary. Keep this header free of any widget/gui types. * Servatrice binary. Keep this header free of any widget/gui types.
*
* Candidates are chosen from IWYU include statistics: a header earns a
* spot when it is not already reachable from the rest of this list and
* enough translation units re-parse it. Qt headers are stable across
* builds, so the precompiled header rarely invalidates.
*/ */
#include <QBasicTimer> #include <QBasicTimer>
#include <QByteArray> #include <QByteArray>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime> #include <QDateTime>
#include <QDebug> #include <QDebug>
#include <QDir>
#include <QElapsedTimer>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QHash> #include <QHash>
#include <QItemSelectionModel>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QList> #include <QList>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QMap> #include <QMap>
#include <QMetaEnum>
#include <QMetaObject> #include <QMetaObject>
#include <QMutex>
#include <QMutexLocker>
#include <QObject> #include <QObject>
#include <QPointer>
#include <QPropertyAnimation>
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QReadWriteLock>
#include <QRegularExpression>
#include <QSet>
#include <QSettings>
#include <QSharedPointer> #include <QSharedPointer>
#include <QSortFilterProxyModel>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QTemporaryDir>
#include <QThread>
#include <QTimer> #include <QTimer>
#include <QUuid>
#include <QVariant> #include <QVariant>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>

View file

@ -4,56 +4,27 @@
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and * Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
* Network layers that virtually every client translation unit re-parses. * Network layers that virtually every client translation unit re-parses.
* Do not use on Servatrice (headless, QT_DONT_USE_QTGUI). * Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
*
* Candidates are chosen from IWYU include statistics: a header earns a
* spot when it is not already reachable from the rest of this list and
* enough translation units re-parse it. Qt headers are stable across
* builds, so the precompiled header rarely invalidates.
*/ */
#include "qtcore_pch.h" #include "qtcore_pch.h"
#include <QAction> #include <QAction>
#include <QApplication> #include <QApplication>
#include <QCheckBox>
#include <QComboBox>
#include <QCompleter>
#include <QDialogButtonBox>
#include <QDockWidget>
#include <QFileDialog>
#include <QFormLayout>
#include <QFrame> #include <QFrame>
#include <QGraphicsItem> #include <QGraphicsItem>
#include <QGraphicsScene> #include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QGraphicsView> #include <QGraphicsView>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QImage> #include <QImage>
#include <QImageReader>
#include <QInputDialog>
#include <QLabel> #include <QLabel>
#include <QLayout> #include <QLayout>
#include <QListWidget>
#include <QMainWindow> #include <QMainWindow>
#include <QMenu> #include <QMenu>
#include <QMessageBox>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QPainter> #include <QPainter>
#include <QPlainTextEdit>
#include <QPushButton> #include <QPushButton>
#include <QScrollArea> #include <QScrollArea>
#include <QSpinBox>
#include <QSplitter>
#include <QStandardItemModel>
#include <QTabWidget> #include <QTabWidget>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QToolBar> #include <QToolBar>
#include <QToolButton>
#include <QTreeWidget> #include <QTreeWidget>
#include <QVBoxLayout>
#include <QWidget> #include <QWidget>

View file

@ -59,7 +59,6 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_update.cpp src/interface/widgets/dialogs/dlg_update.cpp
src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/override_printing_warning.cpp
src/interface/widgets/dialogs/path_safety_warning.cpp
src/interface/widgets/dialogs/settings_view.cpp src/interface/widgets/dialogs/settings_view.cpp
src/interface/widgets/dialogs/tip_of_the_day.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp
src/interface/widgets/deck_share/deck_share_utils.cpp src/interface/widgets/deck_share/deck_share_utils.cpp

View file

@ -239,17 +239,6 @@ void ConnectionController::onLoginError(int r,
break; break;
} }
case Response::RespTooManyRequests: {
QString bannedStr = endTime ? tr("Too many login attempts. Try again after %1.")
.arg(QDateTime::fromSecsSinceEpoch(endTime).toString())
: tr("Too many login attempts. Please try again later.");
if (!reasonStr.isEmpty()) {
bannedStr.append("\n\n" + reasonStr);
}
QMessageBox::critical(dialogParent, tr("Error"), bannedStr);
break;
}
case Response::RespWrongPassword: { case Response::RespWrongPassword: {
QMessageBox::critical(dialogParent, tr("Error"), QMessageBox::critical(dialogParent, tr("Error"),
tr("Incorrect username or password. " tr("Incorrect username or password. "
@ -376,15 +365,12 @@ void ConnectionController::onRegisterError(int r, QString reasonStr, quint32 end
break; break;
} }
case Response::RespTooManyRequests: { case Response::RespTooManyRequests: {
QString deniedStr = QMessageBox::critical(dialogParent, tr("Registration denied"),
endTime ? tr("Too many registration attempts. Try again after %1.") tr("It appears you are attempting to register a new account on this server yet you "
.arg(QDateTime::fromSecsSinceEpoch(endTime).toString()) "already have an account registered with the email provided. This server "
: tr("It appears you are attempting to register a new account on this server yet you " "restricts the number of accounts a user can register per address. Please "
"already have an account registered with the email provided. This server " "contact the server operator for further assistance or to obtain your "
"restricts the number of accounts a user can register per address. Please " "credential information."));
"contact the server operator for further assistance or to obtain your "
"credential information.");
QMessageBox::critical(dialogParent, tr("Registration denied"), deniedStr);
break; break;
} }
case Response::RespPasswordTooShort: { case Response::RespPasswordTooShort: {

View file

@ -10,7 +10,6 @@
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QFutureWatcher>
#include <QIODevice> #include <QIODevice>
#include <QList> #include <QList>
#include <QLocale> #include <QLocale>
@ -178,15 +177,7 @@ bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data)
// Data written, so reload the card database // Data written, so reload the card database
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written"; qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written";
// Spoilers can introduce sets the config has never seen; the loader never const auto reloadOk = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
// runs checkUnknownSets() itself, so run it once the finished snapshot has
// been swapped in.
auto *watcher = new QFutureWatcher<void>(this);
connect(watcher, &QFutureWatcher<void>::finished, this, [watcher] {
watcher->deleteLater();
CardDatabaseManager::getInstance()->checkUnknownSets();
});
watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
// If the user has notifications enabled, let them know // If the user has notifications enabled, let them know
// when the database was last updated // when the database was last updated

View file

@ -133,10 +133,6 @@ SettingsCache::SettingsCache()
// Migrate settings from global.ini to per-class INI files (runs at most once) // Migrate settings from global.ini to per-class INI files (runs at most once)
SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); SettingsMigration::migrateSettingsFromGlobalIni(settingsPath);
// Repair gamefilters.ini keys renamed to camelCase by #7084 for profiles that migrated
// before that rename (idempotent, safe to run on every startup, portable builds included).
SettingsMigration::migrateGameFiltersToCamelCase(settingsPath);
shortcutsSettings = new ShortcutsSettings(settingsPath, this); shortcutsSettings = new ShortcutsSettings(settingsPath, this);
cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this); cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this);
serversSettings = new ServersSettings(settingsPath, this); serversSettings = new ServersSettings(settingsPath, this);

View file

@ -277,6 +277,8 @@ void GameEventHandler::eventSpectatorLeave(const Event_Leave &event,
emit spectatorLeft(eventPlayerId); emit spectatorLeft(eventPlayerId);
game->getPlayerManager()->removeSpectator(eventPlayerId); game->getPlayerManager()->removeSpectator(eventPlayerId);
emitUserEvent();
} }
void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event, void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event,
@ -463,9 +465,9 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/,
PlayerLogic *newPlayer = game->getPlayerManager()->addPlayer(playerId, playerInfo.user_info()); PlayerLogic *newPlayer = game->getPlayerManager()->addPlayer(playerId, playerInfo.user_info());
emit logJoinPlayer(newPlayer); emit logJoinPlayer(newPlayer);
emit playerJoined(playerInfo); emit playerJoined(playerInfo);
emitUserEvent();
} }
emitUserEvent();
} }
QString GameEventHandler::getLeaveReason(Event_Leave::LeaveReason reason) QString GameEventHandler::getLeaveReason(Event_Leave::LeaveReason reason)

View file

@ -12,7 +12,6 @@
#include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/utility/card_ref.h" #include "libcockatrice/utility/card_ref.h"
#include <QApplication>
#include <QBrush> #include <QBrush>
#include <QChar> #include <QChar>
#include <QCursor> #include <QCursor>
@ -29,7 +28,6 @@
#include <QPixmap> #include <QPixmap>
#include <QPointF> #include <QPointF>
#include <QRectF> #include <QRectF>
#include <QTimer>
#include <QTransform> #include <QTransform>
#include <QVariant> #include <QVariant>
#include <QtGlobal> #include <QtGlobal>
@ -44,7 +42,7 @@ class QSizeF;
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
: ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0), : ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
bgColor(Qt::transparent), isHovered(false), realZValue(0), pendingSelectionCollapse(false) bgColor(Qt::transparent), isHovered(false), realZValue(0)
{ {
setCursor(Qt::OpenHandCursor); setCursor(Qt::OpenHandCursor);
setFlag(ItemIsSelectable); setFlag(ItemIsSelectable);
@ -346,16 +344,6 @@ void AbstractCardItem::setFaceDown(bool _facedown)
void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{ {
cancelPendingSelectionCollapse();
if (scene() != nullptr) {
// A new press starts a new gesture: no card may collapse the selection out from under it afterwards.
for (QGraphicsItem *selectedItem : scene()->selectedItems()) {
auto *selectedCard = qgraphicsitem_cast<AbstractCardItem *>(selectedItem);
if (selectedCard != nullptr && selectedCard != this) {
selectedCard->cancelPendingSelectionCollapse();
}
}
}
if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) { if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) {
emit cardShiftClicked(cardRef.name); emit cardShiftClicked(cardRef.name);
} else if ((event->modifiers() & Qt::ControlModifier)) { } else if ((event->modifiers() & Qt::ControlModifier)) {
@ -363,10 +351,6 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
} else if (!isSelected()) { } else if (!isSelected()) {
scene()->clearSelection(); scene()->clearSelection();
setSelected(true); setSelected(true);
} else if (event->button() == Qt::LeftButton && scene()->selectedItems().count() > 1) {
// Part of a multi-selection; collapse only if this turns out to be a click rather than a drag,
// so that dragging the whole selection keeps it intact.
pendingSelectionCollapse = true;
} }
if (event->button() == Qt::LeftButton) { if (event->button() == Qt::LeftButton) {
setCursor(Qt::ClosedHandCursor); setCursor(Qt::ClosedHandCursor);
@ -378,32 +362,6 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{ {
if (pendingSelectionCollapse) {
pendingSelectionCollapse = false;
bool dragged = (event->screenPos() - event->buttonDownScreenPos(event->button())).manhattanLength() >=
2 * QApplication::startDragDistance();
if (!dragged) {
if (doubleClickActsOnSelection()) {
// A double-click may still arrive and must act on the whole multi-selection, so collapse only
// once the double-click window has passed without one. The generation guard (and the receiver
// context) drops the collapse if the item is gone, deselected, or a double-click cancelled it;
// the count guard drops it if the selection changed meanwhile (rubber band, ctrl-toggle).
const int generation = ++selectionCollapseGeneration;
const int armedSelectionCount = scene()->selectedItems().count();
QTimer::singleShot(QApplication::doubleClickInterval(), this, [this, generation, armedSelectionCount] {
if (generation == selectionCollapseGeneration && scene() != nullptr && isSelected() &&
scene()->selectedItems().count() == armedSelectionCount && armedSelectionCount > 1) {
scene()->clearSelection();
setSelected(true);
}
});
} else {
scene()->clearSelection();
setSelected(true);
}
}
}
if (event->button() == Qt::MiddleButton) { if (event->button() == Qt::MiddleButton) {
emit deleteCardInfoPopup(cardRef.name); emit deleteCardInfoPopup(cardRef.name);
} }
@ -412,17 +370,6 @@ void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
event->accept(); event->accept();
} }
void AbstractCardItem::cancelPendingSelectionCollapse()
{
pendingSelectionCollapse = false;
++selectionCollapseGeneration;
}
bool AbstractCardItem::doubleClickActsOnSelection() const
{
return false;
}
void AbstractCardItem::processHoverEvent() void AbstractCardItem::processHoverEvent()
{ {
emit hovered(this); emit hovered(this);

View file

@ -41,8 +41,6 @@ protected:
private: private:
bool isHovered; bool isHovered;
qreal realZValue; qreal realZValue;
bool pendingSelectionCollapse;
int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings
private slots: private slots:
void pixmapUpdated(); void pixmapUpdated();
@ -132,8 +130,6 @@ public:
} }
void setFaceDown(bool _facedown); void setFaceDown(bool _facedown);
void processHoverEvent(); void processHoverEvent();
/** @brief Drop a pending collapse and disarm any deferred collapse timer. */
void cancelPendingSelectionCollapse();
void deleteCardInfoPopup() void deleteCardInfoPopup()
{ {
emit deleteCardInfoPopup(cardRef.name); emit deleteCardInfoPopup(cardRef.name);
@ -148,16 +144,6 @@ protected:
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override; QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override;
void cacheBgColor(); void cacheBgColor();
/**
* @brief Whether a double-click on this item acts on the whole selection.
*
* When true, a release without drag defers the multi-selection collapse
* until the double-click window passes, so a following double-click still
* sees every selected card. Subclasses that handle double-clicks on the
* selection must return true here and call
* cancelPendingSelectionCollapse() from their double-click handler.
*/
virtual bool doubleClickActsOnSelection() const;
}; };
#endif #endif

View file

@ -518,9 +518,6 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
{ {
// The preceding release deferred its multi-selection collapse for exactly this: the double-click plays
// every selected card, so the collapse must not run.
cancelPendingSelectionCollapse();
if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) && if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) &&
(SettingsCache::instance().userInterface().getDoubleClickToPlay())) { (SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier)); handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
@ -528,11 +525,6 @@ void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
event->accept(); event->accept();
} }
bool CardItem::doubleClickActsOnSelection() const
{
return SettingsCache::instance().userInterface().getDoubleClickToPlay();
}
bool CardItem::animationEvent() bool CardItem::animationEvent()
{ {
int rotation = ROTATION_DEGREES_PER_FRAME; int rotation = ROTATION_DEGREES_PER_FRAME;

View file

@ -175,7 +175,6 @@ protected:
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
bool doubleClickActsOnSelection() const override;
}; };
#endif #endif

View file

@ -182,15 +182,6 @@ void DeckView::mouseDoubleClickEvent(QMouseEvent *event)
QList<MoveCard_ToZone> result; QList<MoveCard_ToZone> result;
QList<QGraphicsItem *> sel = scene()->selectedItems(); QList<QGraphicsItem *> sel = scene()->selectedItems();
// The double-click goes to the view, not the card, so cancel any pending
// selection collapse on the cards before reading the selection.
for (auto *item : sel) {
if (auto *c = dynamic_cast<DeckViewCard *>(item)) {
c->cancelPendingSelectionCollapse();
}
}
sel = scene()->selectedItems();
for (int i = 0; i < sel.size(); i++) { for (int i = 0; i < sel.size(); i++) {
auto *c = static_cast<DeckViewCard *>(sel.at(i)); auto *c = static_cast<DeckViewCard *>(sel.at(i));
auto *zone = static_cast<DeckViewCardContainer *>(c->parentItem()); auto *zone = static_cast<DeckViewCardContainer *>(c->parentItem());

View file

@ -49,10 +49,6 @@ public:
{ {
return originZone; return originZone;
} }
bool doubleClickActsOnSelection() const override
{
return true;
}
protected: protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;

View file

@ -127,7 +127,6 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin)
selectionOrigin = _selectionOrigin; selectionOrigin = _selectionOrigin;
previousBandRect = QRect(); previousBandRect = QRect();
rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0))); rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0)));
rubberBand->raise();
rubberBand->show(); rubberBand->show();
} }

View file

@ -1,34 +0,0 @@
#include "path_safety_warning.h"
#include "../../client/settings/cache_settings.h"
#include <QCoreApplication>
#include <QMessageBox>
#include <QObject>
#include <QString>
#include <libcockatrice/settings/paths_settings.h>
class QWidget;
bool PathSafetyWarning::confirmOutsideProgramDir(QWidget *parent, const QString &path)
{
if (SettingsCache::instance().getIsPortableBuild()) {
return true;
}
if (!PathsSettings::isInsideDir(path, QCoreApplication::applicationDirPath())) {
return true;
}
QMessageBox box(QMessageBox::Warning, QObject::tr("Path inside the program directory"),
QObject::tr("'%1' is inside the Cockatrice program directory.\n\n"
"Files stored there can be deleted by an update or by uninstalling "
"Cockatrice, so this change was not applied. Choose a location outside "
"the program directory.")
.arg(path),
QMessageBox::Ok, parent);
box.setTextFormat(Qt::PlainText);
box.exec();
return false;
}

View file

@ -1,25 +0,0 @@
#ifndef COCKATRICE_PATH_SAFETY_WARNING_H
#define COCKATRICE_PATH_SAFETY_WARNING_H
class QString;
class QWidget;
namespace PathSafetyWarning
{
/**
* @brief Warns when the user tries to configure a data path inside the program directory.
*
* Shows a message box explaining that files kept there can be deleted by an
* update or by uninstalling Cockatrice. Portable builds are always exempt:
* their data lives beside the executables by design.
*
* @param parent The parent widget
* @param path The path the user is trying to configure
* @return true when the path is outside the program directory and safe to apply
*/
bool confirmOutsideProgramDir(QWidget *parent, const QString &path);
} // namespace PathSafetyWarning
#endif // COCKATRICE_PATH_SAFETY_WARNING_H

View file

@ -17,9 +17,7 @@ struct GameFilterConfigs
static constexpr int DEFAULT_MAX_PLAYERS_MAX = 99; static constexpr int DEFAULT_MAX_PLAYERS_MAX = 99;
bool hideBuddiesOnlyGames = false; bool hideBuddiesOnlyGames = false;
// Defaults to true to match GameFiltersSettings::isHideIgnoredUserGames(), so freshly loaded bool hideIgnoredUserGames = false;
// filters compare equal to a default-constructed config.
bool hideIgnoredUserGames = true;
bool hideFullGames = false; bool hideFullGames = false;
bool hideGamesThatStarted = false; bool hideGamesThatStarted = false;
bool hidePasswordProtectedGames = false; bool hidePasswordProtectedGames = false;

View file

@ -113,17 +113,6 @@ GameSelector::GameSelector(AbstractClient *_client,
quickFilterToolBar->setVisible( quickFilterToolBar->setVisible(
SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar()); SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar());
}); });
// The quick-filter toolbar only updates the in-memory model, so persist every change.
// Otherwise each new GameSelector (room switch, reconnect, restart) would revert to stale
// saved values and e.g. hide full/started games the user had unchecked. The save is
// debounced so typing in the search field does not rewrite the settings file on every
// keystroke; a pending save is flushed in the destructor.
filterSaveDebounceTimer.setSingleShot(true);
filterSaveDebounceTimer.setInterval(500);
connect(&filterSaveDebounceTimer, &QTimer::timeout, this,
[this] { gameListProxyModel->saveFilterParameters(gameTypeMap); });
connect(gameListProxyModel, &GamesProxyModel::filtersChanged, this,
[this] { filterSaveDebounceTimer.start(); });
} else { } else {
quickFilterToolBar = nullptr; quickFilterToolBar = nullptr;
} }
@ -198,18 +187,6 @@ GameSelector::GameSelector(AbstractClient *_client,
connect(client, &AbstractClient::removeFromListEventReceived, this, &GameSelector::processRemoveFromListEvent); connect(client, &AbstractClient::removeFromListEventReceived, this, &GameSelector::processRemoveFromListEvent);
} }
GameSelector::~GameSelector()
{
// Nothing else persists quick-filter toolbar edits: the toolbar only updates the in-memory model,
// and the debounced timer above is the sole writer for that path (actSetFilter/actClearFilter save
// synchronously, but the toolbar does not). Flush a pending debounced save so edits made within
// the debounce interval before destruction are not lost on room switch, reconnect, or close.
if (filterSaveDebounceTimer.isActive()) {
filterSaveDebounceTimer.stop();
gameListProxyModel->saveFilterParameters(gameTypeMap);
}
}
void GameSelector::ignoreListReceived(const QList<ServerInfo_User> &) void GameSelector::ignoreListReceived(const QList<ServerInfo_User> &)
{ {
gameListProxyModel->refresh(); gameListProxyModel->refresh();

View file

@ -4,7 +4,6 @@
#include "game_type_map.h" #include "game_type_map.h"
#include <QGroupBox> #include <QGroupBox>
#include <QTimer>
#include <qtmetamacros.h> #include <qtmetamacros.h>
// IWYU pragma: keep // IWYU pragma: keep
// ServerInfo_User appears by value and inside QList in slot signatures, so the // ServerInfo_User appears by value and inside QList in slot signatures, so the
@ -149,9 +148,6 @@ private:
const bool showFilters; /**< Determines whether filter buttons are displayed. */ const bool showFilters; /**< Determines whether filter buttons are displayed. */
GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */ GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */
QTimer filterSaveDebounceTimer; /**< Delays persisting quick-filter edits so typing does not rewrite the settings
file on every keystroke. */
/** /**
* @brief Updates the widget title to reflect the current number of displayed games. * @brief Updates the widget title to reflect the current number of displayed games.
* *
@ -215,7 +211,6 @@ public:
const bool restoresettings, const bool restoresettings,
const bool _showfilters, const bool _showfilters,
QWidget *parent = nullptr); QWidget *parent = nullptr);
~GameSelector() override;
/** /**
* @brief Updates UI text for translation/localization. * @brief Updates UI text for translation/localization.

View file

@ -523,9 +523,6 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const
void GamesProxyModel::refresh() void GamesProxyModel::refresh()
{ {
#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
beginFilterChange();
#endif
#if (QT_VERSION >= QT_VERSION_CHECK(6, 10, 0)) #if (QT_VERSION >= QT_VERSION_CHECK(6, 10, 0))
endFilterChange(QSortFilterProxyModel::Direction::Rows); endFilterChange(QSortFilterProxyModel::Direction::Rows);
#else #else

View file

@ -23,7 +23,6 @@
#include <QGroupBox> #include <QGroupBox>
#include <QMessageBox> #include <QMessageBox>
#include <QObject> #include <QObject>
#include <QPushButton>
#include <QSharedPointer> #include <QSharedPointer>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
@ -85,9 +84,10 @@ AppearanceSettingsPage::AppearanceSettingsPage()
const QString currentStyle = cfg.styleName; const QString currentStyle = cfg.styleName;
const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0); styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0);
previousStyle = styleCombo.currentData().toString();
connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::styleComboChanged); connect(&styleCombo, &QComboBox::currentIndexChanged, this,
[this] { themeManager->setStyleName(styleCombo.currentData().toString()); });
connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::updateStyleWarning);
connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] { connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] {
const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
@ -104,7 +104,6 @@ AppearanceSettingsPage::AppearanceSettingsPage()
const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0); styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0);
styleCombo.blockSignals(false); styleCombo.blockSignals(false);
previousStyle = styleCombo.currentData().toString();
updateStyleWarning(); updateStyleWarning();
}); });
@ -468,88 +467,12 @@ void AppearanceSettingsPage::updateHomeTabSettingsVisibility()
homeTabDisplayCardNameCheckBox.setVisible(visible); homeTabDisplayCardNameCheckBox.setVisible(visible);
} }
static bool isBrokenStyle(const QString &style)
{
return style.compare("windows11", Qt::CaseInsensitive) == 0 ||
style.compare("windowsvista", Qt::CaseInsensitive) == 0;
}
static int findFusionIndex(const QComboBox &combo)
{
int fusionIdx = combo.findData(QStringLiteral("Fusion"));
if (fusionIdx >= 0) {
return fusionIdx;
}
for (int i = 0; i < combo.count(); ++i) {
if (combo.itemData(i).toString().compare(QStringLiteral("Fusion"), Qt::CaseInsensitive) == 0) {
return i;
}
}
return -1;
}
void AppearanceSettingsPage::updateStyleWarning() void AppearanceSettingsPage::updateStyleWarning()
{ {
styleWarningLabel.setVisible(isBrokenStyle(styleCombo.currentData().toString())); const QString style = styleCombo.currentData().toString();
} const bool broken =
style.compare("windows11", Qt::CaseInsensitive) == 0 || style.compare("windowsvista", Qt::CaseInsensitive) == 0;
void AppearanceSettingsPage::styleComboChanged(int index) styleWarningLabel.setVisible(broken);
{
if (index < 0) {
return;
}
const QString newStyle = styleCombo.itemData(index).toString();
if (!isBrokenStyle(newStyle)) {
themeManager->setStyleName(newStyle);
previousStyle = newStyle;
updateStyleWarning();
return;
}
QMessageBox msgBox(this);
msgBox.setWindowTitle(tr("Broken style"));
msgBox.setIcon(QMessageBox::Warning);
msgBox.setText(tr("The \"%1\" style is broken and can break the game view: dragging cards across zones can "
"shrink the board to a tiny grey window that can only be fixed by rejoining the game.")
.arg(newStyle));
msgBox.setInformativeText(
tr("It can also render controls such as combo boxes unreadable, especially in dark mode.\n\n"
"Fusion is the recommended style on every platform. Switch to Fusion instead?"));
int fusionIdx = findFusionIndex(styleCombo);
QPushButton *fusionButton = nullptr;
if (fusionIdx >= 0) {
fusionButton = msgBox.addButton(tr("Use Fusion instead"), QMessageBox::AcceptRole);
}
QPushButton *keepButton = msgBox.addButton(tr("Use %1 anyway").arg(newStyle), QMessageBox::DestructiveRole);
msgBox.addButton(QMessageBox::Cancel);
if (fusionButton != nullptr) {
msgBox.setDefaultButton(fusionButton);
} else {
msgBox.setDefaultButton(keepButton);
}
msgBox.exec();
const QAbstractButton *clicked = msgBox.clickedButton();
if (fusionButton != nullptr && clicked == fusionButton) {
styleCombo.setCurrentIndex(fusionIdx);
return;
}
if (clicked == keepButton) {
themeManager->setStyleName(newStyle);
previousStyle = newStyle;
updateStyleWarning();
return;
}
// Cancel: revert to the previously applied style without re-prompting
styleCombo.blockSignals(true);
const int previousIdx = styleCombo.findData(previousStyle);
styleCombo.setCurrentIndex(previousIdx >= 0 ? previousIdx : 0);
styleCombo.blockSignals(false);
updateStyleWarning();
} }
void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value) void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value)
@ -615,10 +538,9 @@ void AppearanceSettingsPage::retranslateUi()
styleComboLabel.setText(tr("Active theme style:")); styleComboLabel.setText(tr("Active theme style:"));
styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)")); styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)"));
styleWarningLabel.setText( styleWarningLabel.setText(
tr("Warning: the Windows 11 and Windows Vista styles are broken and can break the game view (dragging " tr("Warning: the Windows 11 and Windows Vista styles are buggy and can render controls such as combo boxes "
"cards can shrink the board to a tiny grey window that requires rejoining to fix), and can render " "unreadable, especially in dark mode. Fusion is the recommended style on every platform: it is Qt's "
"controls such as combo boxes unreadable, especially in dark mode. Fusion is the recommended style on " "cross-platform style and the one Cockatrice is developed against."));
"every platform: it is Qt's cross-platform style and the one Cockatrice is developed against."));
updateStyleWarning(); updateStyleWarning();
editPaletteButton.setText(tr("Edit theme palette")); editPaletteButton.setText(tr("Edit theme palette"));

View file

@ -23,7 +23,6 @@ private slots:
void editPalette(); void editPalette();
void updateHomeTabSettingsVisibility(); void updateHomeTabSettingsVisibility();
void updateStyleWarning(); void updateStyleWarning();
void styleComboChanged(int index);
void showShortcutsChanged(QT_STATE_CHANGED_T enabled); void showShortcutsChanged(QT_STATE_CHANGED_T enabled);
void overrideAllCardArtWithPersonalPreferenceToggled(QT_STATE_CHANGED_T enabled); void overrideAllCardArtWithPersonalPreferenceToggled(QT_STATE_CHANGED_T enabled);
@ -41,7 +40,6 @@ private:
QComboBox styleCombo; QComboBox styleCombo;
QLabel styleWarningLabel; QLabel styleWarningLabel;
QPushButton editPaletteButton; QPushButton editPaletteButton;
QString previousStyle;
QLabel homeTabBackgroundSourceLabel; QLabel homeTabBackgroundSourceLabel;
QComboBox homeTabBackgroundSourceBox; QComboBox homeTabBackgroundSourceBox;

View file

@ -2,7 +2,6 @@
#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h" #include "../../pixel_map_generator.h"
#include "../dialogs/path_safety_warning.h"
#include "update/card_spoiler/spoiler_background_updater.h" #include "update/card_spoiler/spoiler_background_updater.h"
#include <QAbstractItemModel> #include <QAbstractItemModel>
@ -364,9 +363,6 @@ void DeckEditorSettingsPage::spoilerPathButtonClicked()
if (lsPath.isEmpty()) { if (lsPath.isEmpty()) {
return; return;
} }
if (!PathSafetyWarning::confirmOutsideProgramDir(this, lsPath + "/spoiler.xml")) {
return;
}
mpSpoilerSavePathLineEdit->setText(lsPath + "/spoiler.xml"); mpSpoilerSavePathLineEdit->setText(lsPath + "/spoiler.xml");
SettingsCache::instance().paths().setSpoilerDatabasePath(lsPath + "/spoiler.xml"); SettingsCache::instance().paths().setSpoilerDatabasePath(lsPath + "/spoiler.xml");

View file

@ -1,7 +1,6 @@
#include "general_settings_page.h" #include "general_settings_page.h"
#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/cache_settings.h"
#include "../dialogs/path_safety_warning.h"
#include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/card_picture_loader/card_picture_loader.h"
#include "../main.h" #include "../main.h"
#include "../server/user/user_info_connection.h" #include "../server/user/user_info_connection.h"
@ -379,9 +378,6 @@ void GeneralSettingsPage::deckPathButtonClicked()
if (path.isEmpty()) { if (path.isEmpty()) {
return; return;
} }
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
deckPathEdit->setText(path); deckPathEdit->setText(path);
SettingsCache::instance().paths().setDeckPath(path); SettingsCache::instance().paths().setDeckPath(path);
@ -393,9 +389,6 @@ void GeneralSettingsPage::filtersPathButtonClicked()
if (path.isEmpty()) { if (path.isEmpty()) {
return; return;
} }
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
filtersPathEdit->setText(path); filtersPathEdit->setText(path);
SettingsCache::instance().paths().setFiltersPath(path); SettingsCache::instance().paths().setFiltersPath(path);
@ -407,9 +400,6 @@ void GeneralSettingsPage::replaysPathButtonClicked()
if (path.isEmpty()) { if (path.isEmpty()) {
return; return;
} }
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
replaysPathEdit->setText(path); replaysPathEdit->setText(path);
SettingsCache::instance().paths().setReplaysPath(path); SettingsCache::instance().paths().setReplaysPath(path);
@ -421,9 +411,6 @@ void GeneralSettingsPage::picsPathButtonClicked()
if (path.isEmpty()) { if (path.isEmpty()) {
return; return;
} }
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
picsPathEdit->setText(path); picsPathEdit->setText(path);
SettingsCache::instance().paths().setPicsPath(path); SettingsCache::instance().paths().setPicsPath(path);
@ -435,9 +422,6 @@ void GeneralSettingsPage::cardDatabasePathButtonClicked()
if (path.isEmpty()) { if (path.isEmpty()) {
return; return;
} }
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
cardDatabasePathEdit->setText(path); cardDatabasePathEdit->setText(path);
SettingsCache::instance().paths().setCardDatabasePath(path); SettingsCache::instance().paths().setCardDatabasePath(path);
@ -449,9 +433,6 @@ void GeneralSettingsPage::customCardDatabaseButtonClicked()
if (path.isEmpty()) { if (path.isEmpty()) {
return; return;
} }
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
customCardDatabasePathEdit->setText(path); customCardDatabasePathEdit->setText(path);
SettingsCache::instance().paths().setCustomCardDatabasePath(path); SettingsCache::instance().paths().setCustomCardDatabasePath(path);
@ -463,9 +444,6 @@ void GeneralSettingsPage::tokenDatabasePathButtonClicked()
if (path.isEmpty()) { if (path.isEmpty()) {
return; return;
} }
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
tokenDatabasePathEdit->setText(path); tokenDatabasePathEdit->setText(path);
SettingsCache::instance().paths().setTokenDatabasePath(path); SettingsCache::instance().paths().setTokenDatabasePath(path);

View file

@ -1060,12 +1060,6 @@ void TabGame::setCardMenu(CardMenu *menu)
return; return;
} }
if (aCardMenu->menu() && aCardMenu->menu()->isVisible()) {
aCardMenu->menu()->hide();
if (gameView && gameView->viewport()) {
gameView->viewport()->update();
}
}
if (menu) { if (menu) {
aCardMenu->setMenu(menu); aCardMenu->setMenu(menu);
} else { } else {

View file

@ -1,6 +1,5 @@
#include "tab_report.h" #include "tab_report.h"
#include "../server/user/user_context_menu.h"
#include "../utility/report_utils.h" #include "../utility/report_utils.h"
#include "abstract_client.h" #include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response.pb.h"
@ -34,13 +33,11 @@
#include <QVariant> #include <QVariant>
#include <QWidget> #include <QWidget>
#include <QtGlobal> #include <QtGlobal>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h> #include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h> #include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
#include <libcockatrice/protocol/pb/command_report_assign.pb.h> #include <libcockatrice/protocol/pb/command_report_assign.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h> #include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_list.pb.h> #include <libcockatrice/protocol/pb/command_report_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_reopen.pb.h>
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h> #include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
#include <libcockatrice/protocol/pb/command_report_stats.pb.h> #include <libcockatrice/protocol/pb/command_report_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h> #include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
@ -123,10 +120,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents); table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents);
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
table->setContextMenuPolicy(Qt::CustomContextMenu);
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
userContextMenu = new UserContextMenu(tabSupervisor, this);
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog);
descGroup = new QGroupBox; descGroup = new QGroupBox;
descriptionEdit = new QTextEdit; descriptionEdit = new QTextEdit;
@ -238,14 +231,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
assignButton = new QPushButton; assignButton = new QPushButton;
resolveButton = new QPushButton; resolveButton = new QPushButton;
resolveWithNoteButton = new QPushButton;
dismissButton = new QPushButton; dismissButton = new QPushButton;
reopenButton = new QPushButton;
viewReplayButton = new QPushButton; viewReplayButton = new QPushButton;
joinGameButton = new QPushButton; joinGameButton = new QPushButton;
connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport); connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport);
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false); }); connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); });
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true); }); connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); });
connect(reopenButton, &QPushButton::clicked, this, &TabReport::reopenReport); connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); });
connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay); connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay);
connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame); connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame);
@ -254,8 +247,8 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
auto *actionBar = new QHBoxLayout; auto *actionBar = new QHBoxLayout;
actionBar->addWidget(assignButton); actionBar->addWidget(assignButton);
actionBar->addWidget(resolveButton); actionBar->addWidget(resolveButton);
actionBar->addWidget(resolveWithNoteButton);
actionBar->addWidget(dismissButton); actionBar->addWidget(dismissButton);
actionBar->addWidget(reopenButton);
actionBar->addSpacing(20); actionBar->addSpacing(20);
actionBar->addWidget(viewReplayButton); actionBar->addWidget(viewReplayButton);
actionBar->addWidget(joinGameButton); actionBar->addWidget(joinGameButton);
@ -279,7 +272,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
void TabReport::retranslateUi() void TabReport::retranslateUi()
{ {
userContextMenu->retranslateUi();
searchEdit->setPlaceholderText(tr("Search by username, category...")); searchEdit->setPlaceholderText(tr("Search by username, category..."));
statusFilter->clear(); statusFilter->clear();
statusFilter->addItem(tr("All Statuses"), ""); statusFilter->addItem(tr("All Statuses"), "");
@ -320,10 +312,9 @@ void TabReport::retranslateUi()
statsGroup->setTitle(tr("Report Statistics")); statsGroup->setTitle(tr("Report Statistics"));
assignButton->setText(tr("Assign to Me")); assignButton->setText(tr("Assign to Me"));
resolveButton->setText(tr("Resolve...")); resolveButton->setText(tr("Resolve"));
resolveWithNoteButton->setText(tr("Resolve with note..."));
dismissButton->setText(tr("Dismiss...")); dismissButton->setText(tr("Dismiss..."));
reopenButton->setText(tr("Reopen..."));
reopenButton->setToolTip(tr("Available for resolved or dismissed reports."));
viewReplayButton->setText(tr("View Replay")); viewReplayButton->setText(tr("View Replay"));
joinGameButton->setText(tr("Join Game")); joinGameButton->setText(tr("Join Game"));
} }
@ -355,7 +346,6 @@ void TabReport::reportListResponse(const Response &response)
refreshButton->setEnabled(true); refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) { if (response.response_code() != Response::RespOk) {
pendingStatusMessage.clear();
statusLabel->setText(tr("Failed to load reports.")); statusLabel->setText(tr("Failed to load reports."));
return; return;
} }
@ -369,11 +359,6 @@ void TabReport::reportListResponse(const Response &response)
applyFilters(); applyFilters();
updateStats(); updateStats();
if (!pendingStatusMessage.isEmpty()) {
statusLabel->setText(pendingStatusMessage);
pendingStatusMessage.clear();
}
if (selectedReportIdBeforeRefresh >= 0) { if (selectedReportIdBeforeRefresh >= 0) {
bool found = false; bool found = false;
for (int row = 0; row < table->rowCount(); ++row) { for (int row = 0; row < table->rowCount(); ++row) {
@ -509,36 +494,6 @@ void TabReport::onSelectionChanged()
updateActionStates(); updateActionStates();
} }
void TabReport::showTableContextMenu(const QPoint &pos)
{
QTableWidgetItem *clickedItem = table->itemAt(pos);
if (!clickedItem || clickedItem->row() < 0) {
return;
}
const int row = clickedItem->row();
const QTableWidgetItem *reportedItem = table->item(row, COL_REPORTED);
const QTableWidgetItem *idItem = table->item(row, COL_ID);
if (!reportedItem || !idItem || reportedItem->text().isEmpty()) {
return;
}
int userLevel = ServerInfo_User::IsUser;
const int reportId = idItem->data(Qt::UserRole).toInt();
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) {
if (r.reported_user_level() != 0) {
userLevel = r.reported_user_level();
}
break;
}
}
table->selectRow(row);
userContextMenu->showContextMenu(table->viewport()->mapToGlobal(pos), reportedItem->text(),
UserLevelFlags(userLevel));
}
void TabReport::loadReportDetails(int reportId) void TabReport::loadReportDetails(int reportId)
{ {
detailsRequestedReportId = reportId; detailsRequestedReportId = reportId;
@ -622,8 +577,8 @@ void TabReport::updateActionStates()
const QString status = QString::fromStdString(report.status()); const QString status = QString::fromStdString(report.status());
assignButton->setEnabled(status == "open"); assignButton->setEnabled(status == "open");
resolveButton->setEnabled(status == "open" || status == "assigned"); resolveButton->setEnabled(status == "open" || status == "assigned");
resolveWithNoteButton->setEnabled(status == "open" || status == "assigned");
dismissButton->setEnabled(status == "open" || status == "assigned"); dismissButton->setEnabled(status == "open" || status == "assigned");
reopenButton->setEnabled(status == "resolved" || status == "dismissed");
const bool hasGameId = report.game_id() > 0; const bool hasGameId = report.game_id() > 0;
const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0; const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0;
@ -635,8 +590,8 @@ void TabReport::setActionsEnabled(bool enabled)
{ {
assignButton->setEnabled(enabled); assignButton->setEnabled(enabled);
resolveButton->setEnabled(enabled); resolveButton->setEnabled(enabled);
resolveWithNoteButton->setEnabled(enabled);
dismissButton->setEnabled(enabled); dismissButton->setEnabled(enabled);
reopenButton->setEnabled(enabled);
viewReplayButton->setEnabled(false); viewReplayButton->setEnabled(false);
joinGameButton->setEnabled(false); joinGameButton->setEnabled(false);
commentButton->setEnabled(enabled); commentButton->setEnabled(enabled);
@ -690,7 +645,7 @@ void TabReport::assignReport()
void TabReport::assignResponse(const Response &response) void TabReport::assignResponse(const Response &response)
{ {
if (response.response_code() == Response::RespOk) { if (response.response_code() == Response::RespOk) {
pendingStatusMessage = tr("Assigned."); statusLabel->setText(tr("Assigned."));
refreshList(); refreshList();
} else { } else {
statusLabel->setText(tr("Assignment failed.")); statusLabel->setText(tr("Assignment failed."));
@ -698,19 +653,22 @@ void TabReport::assignResponse(const Response &response)
} }
} }
void TabReport::resolveReport(bool dismissed) void TabReport::resolveReport(bool dismissed, bool promptNote)
{ {
const int reportId = selectedReportId(); const int reportId = selectedReportId();
if (reportId < 0) { if (reportId < 0) {
return; return;
} }
bool ok; QString note;
QString note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"), if (promptNote) {
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"), bool ok;
QLineEdit::Normal, QString(), &ok); note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
if (!ok) { dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
return; QLineEdit::Normal, QString(), &ok);
if (!ok) {
return;
}
} }
setActionsEnabled(false); setActionsEnabled(false);
@ -731,46 +689,7 @@ void TabReport::resolveReport(bool dismissed)
void TabReport::resolveResponse(const Response &response) void TabReport::resolveResponse(const Response &response)
{ {
if (response.response_code() == Response::RespOk) { if (response.response_code() == Response::RespOk) {
pendingStatusMessage = tr("Done."); statusLabel->setText(tr("Done."));
refreshList();
} else {
statusLabel->setText(tr("Action failed."));
updateActionStates();
}
}
void TabReport::reopenReport()
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
bool ok;
QString note = QInputDialog::getText(this, tr("Reopen Report"), tr("Reopening note (optional):"), QLineEdit::Normal,
QString(), &ok);
if (!ok) {
return;
}
setActionsEnabled(false);
statusLabel->setText(tr("Reopening..."));
Command_ReportReopen cmd;
cmd.set_report_id(reportId);
if (!note.isEmpty()) {
cmd.set_reopen_note(note.toStdString());
}
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::reopenResponse);
client->sendCommand(pend);
}
void TabReport::reopenResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
pendingStatusMessage = tr("Reopened.");
refreshList(); refreshList();
} else { } else {
statusLabel->setText(tr("Action failed.")); statusLabel->setText(tr("Action failed."));

View file

@ -4,7 +4,6 @@
#include "tab.h" #include "tab.h"
#include <QList> #include <QList>
#include <QPoint>
#include <QString> #include <QString>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h> #include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
@ -23,7 +22,6 @@ class QTimer;
class GameReplay; class GameReplay;
class Response; class Response;
class TabSupervisor; class TabSupervisor;
class UserContextMenu;
class TabReport : public Tab class TabReport : public Tab
{ {
@ -39,17 +37,15 @@ public:
signals: signals:
void openReplay(GameReplay *replay); void openReplay(GameReplay *replay);
void requestJoinGame(int gameId, int roomId); void requestJoinGame(int gameId, int roomId);
void openMessageDialog(const QString &userName, bool focus);
private slots: private slots:
void refreshList(); void refreshList();
void reportListResponse(const Response &response); void reportListResponse(const Response &response);
void assignReport(); void assignReport();
void assignResponse(const Response &response); void assignResponse(const Response &response);
void resolveReport(bool dismissed); void resolveReport(bool dismissed, bool promptNote);
void resolveResponse(const Response &response); void resolveResponse(const Response &response);
void onSelectionChanged(); void onSelectionChanged();
void showTableContextMenu(const QPoint &pos);
void viewReplay(); void viewReplay();
void viewReplayResponse(const Response &response); void viewReplayResponse(const Response &response);
void joinGame(); void joinGame();
@ -60,8 +56,6 @@ private slots:
void reportDetailsResponse(const Response &response); void reportDetailsResponse(const Response &response);
void requestStats(); void requestStats();
void statsResponse(const Response &response); void statsResponse(const Response &response);
void reopenReport();
void reopenResponse(const Response &response);
private: private:
int selectedReportId() const; int selectedReportId() const;
@ -91,13 +85,12 @@ private:
QPushButton *commentButton; QPushButton *commentButton;
QPushButton *assignButton; QPushButton *assignButton;
QPushButton *resolveButton; QPushButton *resolveButton;
QPushButton *resolveWithNoteButton;
QPushButton *dismissButton; QPushButton *dismissButton;
QPushButton *reopenButton;
QPushButton *viewReplayButton; QPushButton *viewReplayButton;
QPushButton *joinGameButton; QPushButton *joinGameButton;
QLabel *statusLabel; QLabel *statusLabel;
QTimer *refreshTimer; QTimer *refreshTimer;
UserContextMenu *userContextMenu;
QGroupBox *userContextGroup; QGroupBox *userContextGroup;
QLabel *userContextName; QLabel *userContextName;
@ -115,7 +108,6 @@ private:
QLabel *userContextNotesLabel; QLabel *userContextNotesLabel;
QLabel *userContextRecentReportsLabel; QLabel *userContextRecentReportsLabel;
QString lastRequestedUser; QString lastRequestedUser;
QString pendingStatusMessage;
QGroupBox *statsGroup; QGroupBox *statsGroup;
QLabel *statsTotalLabel; QLabel *statsTotalLabel;
QLabel *statsTrendLabel; QLabel *statsTrendLabel;

View file

@ -919,7 +919,6 @@ void TabSupervisor::openTabReport()
myAddTab(tabReport, aTabReport); myAddTab(tabReport, aTabReport);
connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay); connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay);
connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame); connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame);
connect(tabReport, &TabReport::openMessageDialog, this, &TabSupervisor::addMessageTab);
connect(tabReport, &QObject::destroyed, this, [this] { connect(tabReport, &QObject::destroyed, this, [this] {
tabReport = nullptr; tabReport = nullptr;
aTabReport->setChecked(false); aTabReport->setChecked(false);

View file

@ -46,22 +46,6 @@ template <class T> class QSharedPointer;
namespace namespace
{ {
// Below this row width the delegate switches to a compact layout so the card
// name stays readable when the popup cannot be as wide as PopupRowWidth.
constexpr int CompactRowWidth = 380;
constexpr int CompactSymbolRadius = 6;
constexpr int CompactSymbolSpacing = 1;
constexpr int CompactRightPad = 8;
constexpr int ManaZoneWidth = 110;
constexpr int CompactManaZoneWidth = 70;
constexpr int RightPad = 14;
constexpr int TextLeftPad = 12;
constexpr int TextRightGap = 8;
// Below this text width the type-line band is dropped so the name can use the
// whole row height instead of being elided to a single readable character.
constexpr int MinNameWidth = 120;
struct ManaColor struct ManaColor
{ {
QColor fill; QColor fill;
@ -141,7 +125,7 @@ QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const
} }
// Fixed wide rows so the popup has room for name, type line, set and mana // Fixed wide rows so the popup has room for name, type line, set and mana
return {PopupRowWidth, CardRowHeight}; return {480, CardRowHeight};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -192,12 +176,7 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
int CardCompleterDelegate::drawManaCost(QPainter *p, int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const
const QRect &row,
const QString &manaCost,
int radius,
int spacing,
int rightPad) const
{ {
if (manaCost.isEmpty()) { if (manaCost.isEmpty()) {
return row.right(); return row.right();
@ -226,9 +205,11 @@ int CardCompleterDelegate::drawManaCost(QPainter *p,
totalW += PartGap; totalW += PartGap;
} }
totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * spacing; totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * SymbolSpacing;
} }
const int rightPad = 14;
int x = row.right() - rightPad - totalW + radius; int x = row.right() - rightPad - totalW + radius;
const int cy = row.center().y(); const int cy = row.center().y();
@ -238,11 +219,11 @@ int CardCompleterDelegate::drawManaCost(QPainter *p,
for (const QString &sym : symbols) { for (const QString &sym : symbols) {
drawManaSymbol(p, {x, cy}, sym, radius); drawManaSymbol(p, {x, cy}, sym, radius);
x += diam + spacing; x += diam + SymbolSpacing;
} }
if (i < parts.size() - 1) { if (i < parts.size() - 1) {
x += PartGap - spacing; x += PartGap - SymbolSpacing;
} }
} }
@ -309,14 +290,6 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
const QString typeLine = card->getCardType(); const QString typeLine = card->getCardType();
const QString setCode = setCodeForCard(card); const QString setCode = setCodeForCard(card);
// When the row is too narrow for the full layout, use smaller mana pips and
// a slimmer mana zone so the card name keeps as much room as possible.
const bool compact = rect.width() < CompactRowWidth;
const int symbolRadius = compact ? CompactSymbolRadius : SymbolRadius;
const int symbolSpacing = compact ? CompactSymbolSpacing : SymbolSpacing;
const int rightPad = compact ? CompactRightPad : RightPad;
const int manaZoneWidth = compact ? CompactManaZoneWidth : ManaZoneWidth;
const QColor accent = accentForColors(card->getColors()); const QColor accent = accentForColors(card->getColors());
const QColor base = pal.color(QPalette::Base); const QColor base = pal.color(QPalette::Base);
@ -365,7 +338,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
// Right mana zone // Right mana zone
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
const QRectF manaZone(cardRect.right() - manaZoneWidth, cardRect.top(), manaZoneWidth, cardRect.height()); const QRectF manaZone(cardRect.right() - 110, cardRect.top(), 110, cardRect.height());
QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft()); QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft());
@ -407,27 +380,22 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
// Mana cost // Mana cost
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, symbolRadius, symbolSpacing, rightPad); const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, SymbolRadius);
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Card name + type line + set code // Card name + type line + set code
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
const int textLeft = cardRect.left() + AccentBarWidth + TextLeftPad; const int textLeft = cardRect.left() + AccentBarWidth + 12;
const int textRight = costLeft - TextRightGap; const int textRight = costLeft - 8;
const int textWidth = qMax(0, textRight - textLeft); const int textWidth = qMax(0, textRight - textLeft);
// If even the compact layout leaves too little room for a readable name,
// drop the type-line band and let the name use the full row height.
const bool showInfoBand = textWidth >= MinNameWidth;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Card name (top band) // Card name (top band)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
{ {
const QRect nameRect = showInfoBand ? QRect(textLeft, rect.top() + 2, textWidth, 20) const QRect nameRect(textLeft, rect.top() + 2, textWidth, 20);
: QRect(textLeft, rect.top() + 2, textWidth, rect.height() - 4);
QFont f = option.font; QFont f = option.font;
f.setPixelSize(13); f.setPixelSize(13);
@ -448,7 +416,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
// Type line + set code (bottom band) // Type line + set code (bottom band)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
if (showInfoBand) { {
const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24); const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24);
QFont f = option.font; QFont f = option.font;

View file

@ -36,9 +36,6 @@ public:
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
// Content width of one popup row; the completer popup is sized to fit it.
static constexpr int PopupRowWidth = 480;
private: private:
// Set short codes, resolved once per card name and cached // Set short codes, resolved once per card name and cached
mutable QCache<QString, QString> setCodeCache; mutable QCache<QString, QString> setCodeCache;
@ -51,8 +48,7 @@ private:
// Draw all mana pips for a cost string like "2RG" or "{2}{R}{G}"; split and // Draw all mana pips for a cost string like "2RG" or "{2}{R}{G}"; split and
// adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used // adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used
int int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const;
drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius, int spacing, int rightPad) const;
// Resolve the preferred printing's set short code for a card // Resolve the preferred printing's set short code for a card
QString setCodeForCard(const QSharedPointer<CardInfo> &card) const; QString setCodeForCard(const QSharedPointer<CardInfo> &card) const;

View file

@ -20,7 +20,6 @@
#include <QPropertyAnimation> #include <QPropertyAnimation>
#include <QRect> #include <QRect>
#include <QScreen> #include <QScreen>
#include <QScrollBar>
#include <QSharedPointer> #include <QSharedPointer>
#include <QSize> #include <QSize>
#include <QString> #include <QString>
@ -104,7 +103,6 @@ bool CardCompleterStyler::eventFilter(QObject *obj, QEvent *ev)
case QEvent::Show: case QEvent::Show:
case QEvent::Move: case QEvent::Move:
case QEvent::Resize: case QEvent::Resize:
applyPopupWidth();
updateOrientation(); updateOrientation();
reposition(); reposition();
break; break;
@ -161,42 +159,6 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void CardCompleterStyler::applyPopupWidth()
{
auto *popup = completer->popup();
// QCompleter sizes its popup to the line edit, so a narrow search field
// would crush the rows. Ask for the width the rows are designed for (plus
// the frame and scrollbar) and let QWidget's geometry clamping keep the
// popup that wide, clamped to the screen it is shown on.
int contentWidth = CardCompleterDelegate::PopupRowWidth + popup->frameWidth() * 2;
if (auto *vbar = popup->verticalScrollBar(); vbar != nullptr) {
contentWidth += vbar->sizeHint().width();
}
const QScreen *screen = popup->screen();
const QRect available = screen ? screen->availableGeometry() : QRect();
const int minWidth = available.isEmpty() ? contentWidth : qMin(contentWidth, available.width());
popup->setMinimumWidth(minWidth);
// setMinimumWidth() already widened the popup if it was narrower, but
// QCompleter positioned it for the line-edit width, so the widened popup
// can now extend past the right screen edge. The popup is a top-level
// Qt::Popup window, so geometry()/move() are already in screen
// coordinates: clamp the x from geometry() and move directly.
if (!available.isEmpty()) {
const QRect geom = popup->geometry();
const int clampedX =
qBound(available.left(), geom.x(), qMax(available.left(), available.right() - geom.width() + 1));
if (clampedX != geom.x()) {
popup->move(QPoint(clampedX, popup->y()));
}
}
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::updateOrientation() void CardCompleterStyler::updateOrientation()
{ {
above = isPopupAboveWidget(); above = isPopupAboveWidget();

View file

@ -52,7 +52,6 @@ private:
void stopPreviewFade(); void stopPreviewFade();
void reposition(); void reposition();
void applyPopupWidth();
void updateOrientation(); void updateOrientation();
void ensureClosestSelected(); void ensureClosestSelected();

View file

@ -130,19 +130,10 @@ void LineEditCompleter::keyPressEvent(QKeyEvent *event)
CompleterInfo *active = nullptr; CompleterInfo *active = nullptr;
QString prefix; QString prefix;
// An open "[[" card link takes precedence over a mention: while the cursor is
// inside it, an earlier "@" must not shadow the card completer.
const int openCardPos = textValue.lastIndexOf("[[", cursorPos - 1);
const int closeCardPos = openCardPos == -1 ? -1 : textValue.indexOf("]]", openCardPos + 2);
const bool insideCardLink = openCardPos != -1 && (closeCardPos == -1 || closeCardPos >= cursorPos);
for (auto &info : completers) { for (auto &info : completers) {
bool triggered = false; bool triggered = false;
switch (info.trigger) { switch (info.trigger) {
case CompleterTrigger::Mention: { case CompleterTrigger::Mention: {
if (insideCardLink) {
break;
}
int triggerPos = textValue.lastIndexOf("@", cursorPos - 1); int triggerPos = textValue.lastIndexOf("@", cursorPos - 1);
if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) { if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) {
triggered = true; triggered = true;

View file

@ -100,27 +100,10 @@ void renderReportDetails(QTextEdit *chatLogEdit,
const QString &moderatorPrefix, const QString &moderatorPrefix,
const QString &nonModeratorPrefix) const QString &nonModeratorPrefix)
{ {
QString details;
if (report.has_resolution_note() && !report.resolution_note().empty()) {
details = QObject::tr("Resolution note: %1").arg(QString::fromStdString(report.resolution_note()));
}
if (report.has_resolution_time()) {
QString resolvedBy = QObject::tr("unknown");
if (report.has_resolved_by_name() && !report.resolved_by_name().empty()) {
resolvedBy = QString::fromStdString(report.resolved_by_name());
}
if (!details.isEmpty()) {
details += "\n";
}
details += QObject::tr("Resolved at %1 by %2").arg(formatReportTime(report.resolution_time()), resolvedBy);
}
if (report.has_chat_log() && !report.chat_log().empty()) { if (report.has_chat_log() && !report.chat_log().empty()) {
const QString chatLog = QString::fromStdString(report.chat_log()); chatLogEdit->setPlainText(QString::fromStdString(report.chat_log()));
chatLogEdit->setPlainText(
details.isEmpty() ? chatLog : details + "\n\n" + QObject::tr("=== Chat Log ===") + "\n" + chatLog);
} else { } else {
chatLogEdit->setPlainText(details); chatLogEdit->clear();
} }
commentsEdit->clear(); commentsEdit->clear();

View file

@ -58,9 +58,7 @@
#include <QAction> #include <QAction>
#include <QApplication> #include <QApplication>
#include <QButtonGroup> #include <QButtonGroup>
#include <QCheckBox>
#include <QCloseEvent> #include <QCloseEvent>
#include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
#include <QDebug> #include <QDebug>
#include <QDesktopServices> #include <QDesktopServices>
@ -70,7 +68,6 @@
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QFlags> #include <QFlags>
#include <QFutureWatcher>
#include <QIODevice> #include <QIODevice>
#include <QLocalSocket> #include <QLocalSocket>
#include <QMenu> #include <QMenu>
@ -606,45 +603,14 @@ MainWindow::MainWindow(QWidget *parent)
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
} }
void MainWindow::warnAboutDataPathsInProgramDir()
{
auto &paths = SettingsCache::instance().paths();
if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) {
return;
}
const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath());
if (offenders.isEmpty()) {
return;
}
QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"),
tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
"Cockatrice keeps its own data outside the program directory by default. Files "
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
"Change them in the Settings dialog - most are on the General page - to keep "
"them safe.")
.arg(offenders.join(QLatin1Char('\n'))),
QMessageBox::Ok, this);
box.setTextFormat(Qt::PlainText);
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
box.setCheckBox(dontShowAgain);
box.exec();
if (dontShowAgain->isChecked()) {
paths.setAppDirWarningAcknowledged(true);
}
}
void MainWindow::startupConfigCheck() void MainWindow::startupConfigCheck()
{ {
warnAboutDataPathsInProgramDir();
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
// The loader never runs checkUnknownSets() itself (see main.cpp): the // checkUnknownSets() is intentionally deferred from the card database load
// front-loaded parse runs before MainWindow exists, so // (which runs in main() before MainWindow exists) so that
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live
// receivers. MainWindow owns the check after startup. // receivers when emitted.
// On a clean install the onboarding wizard owns the first-run experience; // On a clean install the onboarding wizard owns the first-run experience;
// wait until it closes so the legacy "all sets enabled" welcome and the // wait until it closes so the legacy "all sets enabled" welcome and the
// Manage Sets dialog don't appear first. // Manage Sets dialog don't appear first.
@ -1138,35 +1104,18 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow
void MainWindow::cardDatabaseAllNewSetsEnabled() void MainWindow::cardDatabaseAllNewSetsEnabled()
{ {
auto *cardDatabase = CardDatabaseManager::getInstance(); if (firstRunWizardActive || CardDatabaseManager::getInstance()->getCardList().isEmpty()) {
// The onboarding wizard owns the first-run messaging on a clean install,
// With no card data there is nothing to have enabled and nothing to reparse. // and with no card data there are no sets to have enabled.
if (cardDatabase->getCardList().isEmpty()) {
return; return;
} }
// checkUnknownSets() enabled every set after the database had already been QMessageBox::information(
// parsed under the (empty) first-run enablement, so the parser dropped every this, tr("Welcome"),
// printing of every set. Reparse now that the sets are enabled, otherwise the tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the card "
// deck editor's card database display stays empty until the next launch. "database have been enabled.\nRead more about changing the set order or disabling specific sets and "
auto *watcher = new QFutureWatcher<void>(this); "consequent effects in the \"Manage Sets\" dialog."));
connect(watcher, &QFutureWatcher<void>::finished, this, [this, watcher] { actManageSets();
watcher->deleteLater();
if (firstRunWizardActive) {
// The onboarding wizard owns the first-run messaging on a clean install.
return;
}
QMessageBox::information(
this, tr("Welcome"),
tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the "
"card "
"database have been enabled.\nRead more about changing the set order or disabling specific sets and "
"consequent effects in the \"Manage Sets\" dialog."));
// The reload above replaces the set instances, so Manage Sets must open
// only after the finished snapshot has been swapped in.
actManageSets();
});
watcher->setFuture(QtConcurrent::run([cardDatabase] { cardDatabase->reloadCardDatabasesAndNotify(); }));
} }
/* CARD UPDATER */ /* CARD UPDATER */
@ -1253,7 +1202,7 @@ void MainWindow::createCardUpdateProcess(bool background)
<< dir.absoluteFilePath(binaryName); << dir.absoluteFilePath(binaryName);
QMessageBox::warning(this, tr("Error"), QMessageBox::warning(this, tr("Error"),
tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName)); tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName));
exitCardDatabaseUpdate(false); exitCardDatabaseUpdate();
emit cardDatabaseUpdateFinished(false); emit cardDatabaseUpdateFinished(false);
return; return;
} }
@ -1330,7 +1279,7 @@ void MainWindow::cardUpdateStandardError()
} }
} }
void MainWindow::exitCardDatabaseUpdate(bool doReload) void MainWindow::exitCardDatabaseUpdate()
{ {
if (!cardUpdateProcess) { if (!cardUpdateProcess) {
return; return;
@ -1339,21 +1288,7 @@ void MainWindow::exitCardDatabaseUpdate(bool doReload)
cardUpdateProcess = nullptr; cardUpdateProcess = nullptr;
statusBar()->clearMessage(); statusBar()->clearMessage();
if (doReload) { const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
// This can be the first successful load of the session (e.g. the onboarding
// download), so it must own the set check for that load.
reloadCardDatabaseAndCheckSets();
}
}
void MainWindow::reloadCardDatabaseAndCheckSets()
{
auto *watcher = new QFutureWatcher<void>(this);
connect(watcher, &QFutureWatcher<void>::finished, this, [watcher] {
watcher->deleteLater();
CardDatabaseManager::getInstance()->checkUnknownSets();
});
watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
} }
void MainWindow::cardUpdateError(QProcess::ProcessError err) void MainWindow::cardUpdateError(QProcess::ProcessError err)
@ -1389,7 +1324,7 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err)
} }
qCWarning(CardDatabaseUpdateLog) << "The card database updater exited with an error:" << error; qCWarning(CardDatabaseUpdateLog) << "The card database updater exited with an error:" << error;
exitCardDatabaseUpdate(false); exitCardDatabaseUpdate();
QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error)); QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error));
emit cardDatabaseUpdateFinished(false); emit cardDatabaseUpdateFinished(false);
} }
@ -1402,14 +1337,12 @@ void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatu
cardUpdateProgressOutput(); // drain any progress lines not yet parsed cardUpdateProgressOutput(); // drain any progress lines not yet parsed
const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0);
if (success) { if (exitStatus == QProcess::NormalExit) {
SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date());
} else if (exitStatus == QProcess::NormalExit) {
statusBar()->showMessage(tr("Card database update cancelled."), 10000);
} }
qCInfo(CardDatabaseUpdateLog) << "The card database updater finished with exit code" << exitCode << "exit status" qCInfo(CardDatabaseUpdateLog) << "The card database updater finished with exit code" << exitCode << "exit status"
<< static_cast<int>(exitStatus) << "success:" << success; << static_cast<int>(exitStatus) << "success:" << success;
exitCardDatabaseUpdate(success); exitCardDatabaseUpdate();
notifyCardUpdateFinishedFromTray(success); notifyCardUpdateFinishedFromTray(success);
emit cardDatabaseUpdateFinished(success); emit cardDatabaseUpdateFinished(success);
} }
@ -1557,8 +1490,7 @@ void MainWindow::actAddCustomSet()
QMessageBox::information( QMessageBox::information(
this, tr("Load sets/cards"), this, tr("Load sets/cards"),
tr("The new sets/cards have been added successfully.\nCockatrice will now reload the card database.")); tr("The new sets/cards have been added successfully.\nCockatrice will now reload the card database."));
// Imported files can introduce sets the config has never seen. const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
reloadCardDatabaseAndCheckSets();
} else { } else {
QMessageBox::warning(this, tr("Load sets/cards"), tr("Sets/cards failed to import.")); QMessageBox::warning(this, tr("Load sets/cards"), tr("Sets/cards failed to import."));
} }

View file

@ -149,23 +149,12 @@ private:
void runFirstRunWizard(bool firstRun = false); void runFirstRunWizard(bool firstRun = false);
/** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */
void warnAboutDataPathsInProgramDir();
inline QString getCardUpdaterBinaryName() inline QString getCardUpdaterBinaryName()
{ {
return "oracle"; return "oracle";
} }
void createCardUpdateProcess(bool background = false); void createCardUpdateProcess(bool background = false);
void exitCardDatabaseUpdate(bool doReload); void exitCardDatabaseUpdate();
/**
* @brief Reloads the card database on a worker thread and runs checkUnknownSets() once the finished
* snapshot has been swapped in.
*
* The loader never runs checkUnknownSets() itself (see main.cpp); post-update loads that can introduce
* new sets must own the check, and it has to run after the queued snapshot swap, not on the worker.
*/
void reloadCardDatabaseAndCheckSets();
/** /**
* @brief Tries to bring the open card database updater window to the front. * @brief Tries to bring the open card database updater window to the front.
* @return true when the updater acknowledged the request * @return true when the updater acknowledged the request

View file

@ -343,12 +343,11 @@ int main(int argc, char *argv[])
// contention that happens when the load runs alongside window construction. // contention that happens when the load runs alongside window construction.
// The CardDatabaseModel populates from the already-loaded data in its // The CardDatabaseModel populates from the already-loaded data in its
// constructor, so the window appears fully populated with no startup lag. // constructor, so the window appears fully populated with no startup lag.
// Note: checkUnknownSets() is deliberately not run for this front-loaded // Note: checkUnknownSets() is deferred from the initial load to
// parse. It would run before MainWindow exists, so // MainWindow::startupConfigCheck() so that
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live
// receivers, and enabling the sets up-front would suppress the first-run // receivers when emitted. Subsequent reloads (e.g. path changes) call
// experience MainWindow::startupConfigCheck() and the onboarding wizard // checkUnknownSets() directly from the loader after the first load.
// provide. Callers that load after startup own the check.
CardDatabaseManager::getInstance()->loadCardDatabases(); CardDatabaseManager::getInstance()->loadCardDatabases();
MainWindow ui; MainWindow ui;

View file

@ -38,18 +38,7 @@ Selecting this action will open a new text editor dialog with the contents of yo
The import dialog expects each line to be a card with the following format: The import dialog expects each line to be a card with the following format:
`[quantity] Card Name [options]` TODO
- `quantity` is the number of copies and can be written as `2`, `2x` or `2X`. If omitted, a single copy is imported.
- `options` are appended after the card name and are understood on a best-effort basis:
- a set code and collector number, either as `Card Name (SET) 123` or `Card Name (SET) 123-1`
- a foil marker, `Card Name *F*`
- Lines can also carry a `SB:` prefix or a set code prefix (`[SET]`) — Cockatrice tries to stay compatible with the most
common deck formats.
Tab-separated exports from third-party apps (such as Delver Lens) are also accepted: any line whose first column is a
plain quantity treats the following columns as the card name and printer/set metadata (which is discarded), for example
`2\tCard Name\tSet Name`.
Each card should be on a separate line and there should be no empty lines between cards. The first empty line between Each card should be on a separate line and there should be no empty lines between cards. The first empty line between
two blocks of cards will be considered as the divider between mainboard and sideboard. two blocks of cards will be considered as the divider between mainboard and sideboard.

View file

@ -1,63 +0,0 @@
# gcovr configuration for Cockatrice line coverage.
#
# Used by .ci/compile.sh --coverage and by hand:
#
# cmake -S . -B build/cover -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=1 -DTEST=1 -DENABLE_COVERAGE=ON
# cmake --build build/cover -j 24
# ctest --test-dir build/cover -LE bench -j 24
# gcovr build/cover --root . --object-directory build/cover --txt --decisions
#
# The point of most of this file is subtraction. A raw report is dominated by code no test can
# reach: vendored third-party trees, build directories left over from earlier experiments, and
# the client's main() and window classes, which have no headless test. Those all dilute the one
# number worth watching, so they are filtered out here rather than in every invocation.
# root is deliberately not set here. This file is normally invoked from the build directory
# (compile.sh runs there, because it configures with `cmake ..`), so a `root` of `.` would
# resolve to the build tree and every filter below would exclude everything. compile.sh passes
# --root explicitly instead.
# Our own code only. This keeps generated Qt MOC output, protobuf output and anything CMake
# built under the source tree out of the report.
filter = ^cockatrice/
filter = ^libcockatrice_[^/]+/
filter = ^servatrice/
filter = ^oracle/
# The tests are the vehicle, not the subject. They are compiled with the same instrumentation,
# so leaving them in would add their near-perfect coverage to the numerator and quietly report a
# healthier project than we have: test code is only ever executed by the run that is measuring
# it. Helpers under tests/ are excluded on the same grounds.
exclude = ^tests/
# Vendored third-party code is not ours to cover and is never going to move the number.
exclude = ^vcpkg/
exclude = ^doc/doxygen/theme/
exclude = ^libcockatrice_rng/libcockatrice/rng/sfmt/
exclude = ^libcockatrice_utility/libcockatrice/utility/peglib\.h$
exclude = ^oracle/src/lzma/
exclude = ^oracle/src/qt-json/
exclude = ^oracle/src/zip/
exclude = ^servatrice/src/smtp/
# Qt's own moc/pch/uic output is generated into the build tree, not the source tree, so the
# filters above already keep it out. These cover CLion's directories, which sit in-tree and are
# picked up by the object-directory walk.
exclude = .*cmake-build-.*
exclude = .*\.cache/
# Process entry points and the client's window chrome. Each of these needs a display, an
# interactive terminal, or a live database before it does anything a test could observe, so no
# headless run will ever move them. Left in, they contribute thousands of lines at 0% and drag
# the total down by a fixed amount that hides movement in the code we can actually test. The
# window header is excluded with its .cpp because gcovr attributes its inline template code
# there, which is where most of its line count lives.
exclude = ^cockatrice/src/main\.cpp$
exclude = ^cockatrice/src/interface/window_main\.
exclude = ^oracle/src/main\.cpp$
exclude = ^servatrice/src/main\.cpp$
exclude-throw-branches = yes
exclude-function-lines = yes
print-summary = yes

View file

@ -60,5 +60,3 @@ target_link_libraries(
PUBLIC libcockatrice_utility PUBLIC libcockatrice_utility
PUBLIC ${QT_CORE_MODULE} PUBLIC ${QT_CORE_MODULE}
) )
target_precompile_headers(libcockatrice_card PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -13,7 +13,6 @@
#include <QDebug> #include <QDebug>
#include <QList> #include <QList>
#include <QMessageLogger> #include <QMessageLogger>
#include <QMetaObject>
#include <QMetaType> #include <QMetaType>
#include <QMutexLocker> #include <QMutexLocker>
#include <QSharedPointer> #include <QSharedPointer>
@ -81,17 +80,15 @@ void CardDatabase::reloadCardDatabasesAndNotify()
{ {
// An explicit reload bypasses the binary cache so the files on disk are // An explicit reload bypasses the binary cache so the files on disk are
// always re-read, even when their size/mtime hash still matches the cache. // always re-read, even when their size/mtime hash still matches the cache.
// Models refresh from the swap's cardDatabaseReset in a single batch, so
// no cardDatabaseEnabledSetsChanged is emitted here: that rebuilt the
// 36k-card models a second time on the GUI thread, which froze startup
// after enabling new/custom sets. The follow-up unknown-sets check is
// queued to the GUI thread so it sees the swapped-in data (this may run
// on a worker via QtConcurrent::run); it is silent unless new sets
// appeared mid-reload.
loadStatus = loader->loadCardDatabases(/* ignoreCache */ true); loadStatus = loader->loadCardDatabases(/* ignoreCache */ true);
if (loadStatus == Ok) { if (loadStatus == Ok) {
QMetaObject::invokeMethod(this, &CardDatabase::checkUnknownSets, Qt::AutoConnection); checkUnknownSets();
// A reload reconstructs the exact same enabled-set state, so the cached
// set-name / alt-name data computed during construction is still valid.
// Skipping the per-card refresh avoids a full 36k-card recompute that
// would otherwise duplicate the work already done while building cards.
notifyEnabledSetsChanged(false);
} }
} }
@ -210,19 +207,15 @@ void CardDatabase::checkUnknownSets()
if (numUnknownSets > 0) { if (numUnknownSets > 0) {
emit cardDatabaseNewSetsFound(numUnknownSets, unknownSetNames); emit cardDatabaseNewSetsFound(numUnknownSets, unknownSetNames);
} else { } else {
_sets.markAllAsKnownBatched(setPriorityController); _sets.markAllAsKnown();
} }
} else { } else {
// No set enabled. Probably this is the first time running trice // No set enabled. Probably this is the first time running trice
_sets.enableAllBatched(setPriorityController); _sets.guessSortKeys();
_sets.sortByKey(); _sets.sortByKey();
_sets.enableAll();
notifyEnabledSetsChanged();
// No cardDatabaseEnabledSetsChanged here: the reload chained off
// cardDatabaseAllNewSetsEnabled re-parses with the sets enabled and its
// swap's cardDatabaseReset rebuilds the models in one batch. Emitting
// both back-to-back rebuilt the 36k-card models twice on the GUI thread,
// stalling startup. (When the card list is empty the slot returns early
// without reloading, but there is nothing to display either.)
emit cardDatabaseAllNewSetsEnabled(); emit cardDatabaseAllNewSetsEnabled();
} }
} }
@ -230,13 +223,13 @@ void CardDatabase::checkUnknownSets()
void CardDatabase::enableAllUnknownSets() void CardDatabase::enableAllUnknownSets()
{ {
auto _sets = getSetList(); auto _sets = getSetList();
_sets.enableAllUnknownBatched(setPriorityController); _sets.enableAllUnknown();
} }
void CardDatabase::markAllSetsAsKnown() void CardDatabase::markAllSetsAsKnown()
{ {
auto _sets = getSetList(); auto _sets = getSetList();
_sets.markAllAsKnownBatched(setPriorityController); _sets.markAllAsKnown();
} }
void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets) void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets)

View file

@ -44,13 +44,7 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent,
// the finished snapshot is swapped into the live database on the GUI thread. // the finished snapshot is swapped into the live database on the GUI thread.
// when SettingsCache's path changes, trigger reloads // when SettingsCache's path changes, trigger reloads
connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { loadCardDatabases(); });
loadCardDatabases();
// This reload runs synchronously on the emitting (GUI) thread, so the
// finished snapshot is already swapped in when it returns: enable any
// sets the newly pointed-at database introduced.
database->checkUnknownSets();
});
} }
CardDatabaseLoader::~CardDatabaseLoader() CardDatabaseLoader::~CardDatabaseLoader()
@ -142,23 +136,7 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
loadCardDatabase(p, data); loadCardDatabase(p, data);
} }
// A snapshot parsed while no set is enabled has had every printing if (!saveToCache(data, sourceHash)) {
// stripped by the parsers, and the cache reader re-derives enablement
// from the priority controller, so such an entry can never be reused
// meaningfully. Skip writing it so a first-run (blank config) load
// cannot poison the cache with an empty snapshot. The check mirrors
// what the parsers saw: any set still reporting enabled means the
// snapshot is complete for the current enablement.
bool anySetEnabled = false;
for (const CardSetPtr &set : data.sets) {
if (set && set->getEnabled()) {
anySetEnabled = true;
break;
}
}
if (!data.sets.isEmpty() && !anySetEnabled) {
qCInfo(CardDatabaseLoadingLog) << "Skipping binary cache: no set is enabled yet, snapshot was stripped";
} else if (!saveToCache(data, sourceHash)) {
qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath(); qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath();
} }
} }
@ -170,15 +148,18 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
database->refreshCachedReverseRelatedCards(data.cards); database->refreshCachedReverseRelatedCards(data.cards);
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success"; qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success";
// NOTE: checkUnknownSets() is intentionally NOT called here. During the
// front-loaded parse in main() MainWindow does not exist yet, so
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would be
// emitted with no receivers, and enabling the sets up-front would
// suppress the first-run experience MainWindow::startupConfigCheck()
// and the onboarding wizard are designed around. Callers that load
// after startup own the check (and any reload it triggers).
emit databaseDataReady(std::move(data)); emit databaseDataReady(std::move(data));
emit loadingFinished(); emit loadingFinished();
// During the front-loaded parse in main() this runs before MainWindow
// exists, so cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled
// would be emitted with no receivers. Skip the check on the first load;
// MainWindow::startupConfigCheck() calls checkUnknownSets() once its
// signal connections are live. On subsequent reloads (e.g. path change)
// MainWindow exists and signals have live receivers.
if (initialLoadComplete) {
database->checkUnknownSets();
}
initialLoadComplete = true;
} else { } else {
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed"; qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed";
emit loadingFailed(); // bring up the settings dialog emit loadingFailed(); // bring up the settings dialog

View file

@ -179,6 +179,7 @@ private:
QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */
QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */
bool initialLoadComplete = false; /**< Set after the first successful load. */
}; };
#endif // COCKATRICE_CARD_DATABASE_LOADER_H #endif // COCKATRICE_CARD_DATABASE_LOADER_H

View file

@ -227,10 +227,6 @@ public:
* @param _isknown True if known, false if unknown. * @param _isknown True if known, false if unknown.
*/ */
void setIsKnown(bool _isknown); void setIsKnown(bool _isknown);
void setIsKnownInMemory(bool _isknown)
{
isknown = _isknown;
}
/** /**
* @brief Determines whether the set has incomplete metadata and should be ignored. * @brief Determines whether the set has incomplete metadata and should be ignored.

View file

@ -1,11 +1,9 @@
#include "card_set_list.h" #include "card_set_list.h"
#include "libcockatrice/card/set/card_set.h" #include "libcockatrice/card/set/card_set.h"
#include "libcockatrice/interfaces/interface_card_set_priority_controller.h"
#include <QDate> #include <QDate>
#include <QString> #include <QString>
#include <QVector>
#include <algorithm> #include <algorithm>
#include <compare> #include <compare>
@ -64,87 +62,60 @@ QStringList CardSetList::getUnknownSetsNames()
return sets; return sets;
} }
void CardSetList::enableAllUnknownBatched(ICardSetPriorityController *controller) void CardSetList::enableAllUnknown()
{ {
QVector<ICardSetPriorityController::SetSaveData> saveData;
for (int i = 0; i < size(); ++i) { for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i); CardSetPtr set = at(i);
if (set.isNull()) { if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) {
continue; set->setIsKnown(true);
set->setEnabled(true);
} else if (set && set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabled(true);
} }
bool changed = false;
if (!set->getIsKnown() && !set->getIsKnownIgnored()) {
set->setIsKnownInMemory(true);
set->setEnabledInMemory(true);
changed = true;
} else if (set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabledInMemory(true);
changed = true;
}
if (changed) {
saveData.append(
{set->getShortName(), static_cast<unsigned int>(set->getSortKey()), true, set->getIsKnown()});
}
}
if (!saveData.isEmpty()) {
controller->saveSets(saveData);
} }
} }
void CardSetList::enableAllBatched(ICardSetPriorityController *controller) void CardSetList::enableAll()
{
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set == nullptr) {
// qCWarning(CardInfoLog) << "enabledAll has null";
continue;
}
if (!set->getIsKnownIgnored()) {
set->setIsKnown(true);
}
set->setEnabled(true);
}
}
void CardSetList::markAllAsKnown()
{
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) {
set->setIsKnown(true);
set->setEnabled(false);
} else if (set && set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabled(true);
}
}
}
void CardSetList::guessSortKeys()
{ {
defaultSort(); defaultSort();
QVector<ICardSetPriorityController::SetSaveData> saveData;
saveData.reserve(size());
for (int i = 0; i < size(); ++i) { for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i); CardSetPtr set = at(i);
if (set.isNull()) { if (set.isNull()) {
// qCWarning(CardInfoLog) << "guessSortKeys set is null";
continue; continue;
} }
set->setSortKey(i);
const auto sortKey = static_cast<unsigned int>(i);
set->setSortKeyInMemory(sortKey);
if (!set->getIsKnownIgnored()) {
set->setIsKnownInMemory(true);
}
set->setEnabledInMemory(true);
saveData.append({set->getShortName(), sortKey, true, set->getIsKnown()});
}
if (!saveData.isEmpty()) {
controller->saveSets(saveData);
}
}
void CardSetList::markAllAsKnownBatched(ICardSetPriorityController *controller)
{
QVector<ICardSetPriorityController::SetSaveData> saveData;
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set.isNull()) {
continue;
}
bool changed = false;
if (!set->getIsKnown() && !set->getIsKnownIgnored()) {
set->setIsKnownInMemory(true);
set->setEnabledInMemory(false);
changed = true;
} else if (set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabledInMemory(true);
changed = true;
}
if (changed) {
saveData.append({set->getShortName(), static_cast<unsigned int>(set->getSortKey()), set->getEnabled(),
set->getIsKnown()});
}
}
if (!saveData.isEmpty()) {
controller->saveSets(saveData);
} }
} }

View file

@ -40,37 +40,36 @@ public:
void sortByKey(); void sortByKey();
/** /**
* @brief Enables every set with fresh sort keys, persisting in one flush. * @brief Reassigns sort keys based on the current order.
* *
* Assigns sequential sort keys in default-sorted order and marks every set * Calls defaultSort() and then assigns sequential sort keys
* known and enabled, writing all records through the controller's batched * to all sets according to their resulting positions, replacing
* saveSets() in a single settings flush instead of ~3 QSettings rewrite * any existing sort keys to ensure consistent ordering.
* cycles per set, which froze the GUI thread for seconds on a first run
* with a full database.
* @param controller The set priority controller to persist the records with.
*/ */
void enableAllBatched(ICardSetPriorityController *controller); void guessSortKeys();
/** /**
* @brief Enables all sets that are unknown or ignored, persisting in one flush. * @brief Enables all sets that are unknown or ignored.
* *
* Sets that are not marked as known and not ignored are marked as known * Sets that are not marked as known and not ignored are marked as known
* and enabled. Ignored-known sets are also enabled, but remain ignored. * and enabled. Ignored-known sets are also enabled, but remain ignored.
* Only changed sets are written, so the common case of a few new sets costs
* a single settings flush no matter how large the database is.
* @param controller The set priority controller to persist the records with.
*/ */
void enableAllUnknownBatched(ICardSetPriorityController *controller); void enableAllUnknown();
/** /**
* @brief Marks all sets as known and adjusts their enabled state, persisting in one flush. * @brief Enables all sets in the list.
*
* Equivalent to calling `setEnabled(true)` on each entry.
*/
void enableAll();
/**
* @brief Marks all sets as known and adjusts their enabled state.
* *
* Unknown, non-ignored sets become known and disabled. * Unknown, non-ignored sets become known and disabled.
* Ignored-known sets are enabled if they were previously disabled. * Ignored-known sets are enabled if they were previously disabled.
* Only changed sets are written; when nothing is unknown no flush happens at all.
* @param controller The set priority controller to persist the records with.
*/ */
void markAllAsKnownBatched(ICardSetPriorityController *controller); void markAllAsKnown();
/** /**
* @brief Counts the number of sets that are currently enabled. * @brief Counts the number of sets that are currently enabled.

View file

@ -41,5 +41,3 @@ target_link_libraries(
libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility
${QT_CORE_MODULE} ${QT_CORE_MODULE}
) )
target_precompile_headers(libcockatrice_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -33,7 +33,6 @@ bool parse(QTextStream &in,
// Regex for advanced card parsing // Regex for advanced card parsing
static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))");
static const QRegularExpression reTabQuantity(R"(^\d+[xX]?$)");
// Regex for extracting set code and collector number with attached symbols // Regex for extracting set code and collector number with attached symbols
static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))");
@ -111,34 +110,8 @@ bool parse(QTextStream &in,
continue; continue;
} }
const QString rawLine = match.captured(); QString cardName = match.captured().simplified();
QString cardName = rawLine.simplified();
bool sideboard = false; bool sideboard = false;
int amount = 1;
// Card names never contain tab characters, so a leading numeric field
// marks a tabular record emitted by third-party deck tools (e.g. Delver
// Lens "2\tCard Name\tSet Name"). The first two columns are the quantity
// and the card name; any further columns are printer/set metadata and
// are discarded.
bool hasTabQuantity = false;
const auto tabFields = rawLine.split('\t');
int firstField = 0;
while (firstField < tabFields.size() && tabFields.at(firstField).trimmed().isEmpty()) {
++firstField;
}
if (firstField + 1 < tabFields.size()) {
const QString quantity = tabFields.at(firstField).trimmed();
if (reTabQuantity.match(quantity).hasMatch()) {
hasTabQuantity = true;
QString digits = quantity;
if (digits.endsWith('x') || digits.endsWith('X')) {
digits.chop(1);
}
amount = digits.toInt();
cardName = tabFields.at(firstField + 1).simplified();
}
}
// Sideboard detection // Sideboard detection
if (sBStart < 0) { if (sBStart < 0) {
@ -183,12 +156,11 @@ bool parse(QTextStream &in,
} }
// check if a specific amount is mentioned // check if a specific amount is mentioned
if (!hasTabQuantity) { int amount = 1;
match = reMultiplier.match(cardName); match = reMultiplier.match(cardName);
if (match.hasMatch()) { if (match.hasMatch()) {
amount = match.captured(1).toInt(); amount = match.captured(1).toInt();
cardName = match.captured(2); cardName = match.captured(2);
}
} }
// Normalize the card name // Normalize the card name

View file

@ -18,5 +18,3 @@ add_dependencies(libcockatrice_filters libcockatrice_card)
target_include_directories(libcockatrice_filters PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(libcockatrice_filters PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_filters PUBLIC libcockatrice_card ${QT_CORE_MODULE}) target_link_libraries(libcockatrice_filters PUBLIC libcockatrice_card ${QT_CORE_MODULE})
target_precompile_headers(libcockatrice_filters PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -31,5 +31,3 @@ add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES})
target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE}) target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE})
target_precompile_headers(libcockatrice_interfaces PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -12,7 +12,6 @@ public:
QString shortName; QString shortName;
unsigned int sortKey; unsigned int sortKey;
bool enabled; bool enabled;
bool isKnown = false;
}; };
/** /**
@ -58,14 +57,6 @@ public:
*/ */
virtual QStringList getEnabledSetNames() const = 0; virtual QStringList getEnabledSetNames() const = 0;
/**
* @brief Persists per-set options for every entry in a single settings flush.
*
* Each individual setSortKey/setEnabled/setIsKnown call re-opens and re-syncs
* the whole .ini when backed by QSettings, so persisting hundreds of sets
* one option at a time freezes the GUI thread for seconds. Callers that touch
* more than a handful of sets must bundle the records and call this once.
*/
virtual void saveSets(const QVector<SetSaveData> &data) = 0; virtual void saveSets(const QVector<SetSaveData> &data) = 0;
}; };

View file

@ -29,5 +29,3 @@ add_library(
target_include_directories(libcockatrice_models_database PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(libcockatrice_models_database PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_models_database PUBLIC libcockatrice_card libcockatrice_filters ${QT_CORE_MODULE}) target_link_libraries(libcockatrice_models_database PUBLIC libcockatrice_card libcockatrice_filters ${QT_CORE_MODULE})
target_precompile_headers(libcockatrice_models_database PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -266,7 +266,7 @@ void SetsModel::save(CardDatabase *db)
const bool enabled = enabledSets.contains(sets[i]); const bool enabled = enabledSets.contains(sets[i]);
sets[i]->setSortKeyInMemory(sortKey); sets[i]->setSortKeyInMemory(sortKey);
sets[i]->setEnabledInMemory(enabled); sets[i]->setEnabledInMemory(enabled);
saveData.append({sets[i]->getShortName(), sortKey, enabled, sets[i]->getIsKnown()}); saveData.append({sets[i]->getShortName(), sortKey, enabled});
} }
db->getPriorityController()->saveSets(saveData); db->getPriorityController()->saveSets(saveData);

View file

@ -16,5 +16,3 @@ target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT
target_link_libraries( target_link_libraries(
libcockatrice_models_deck_list PUBLIC libcockatrice_card libcockatrice_deck_list ${QT_CORE_MODULE} libcockatrice_models_deck_list PUBLIC libcockatrice_card libcockatrice_deck_list ${QT_CORE_MODULE}
) )
target_precompile_headers(libcockatrice_models_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -19,5 +19,3 @@ target_link_libraries(
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol
libcockatrice_network_server_remote libcockatrice_network_server_remote
) )
target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -19,5 +19,3 @@ target_link_libraries(
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
libcockatrice_network_client_abstract libcockatrice_network_client_abstract
) )
target_precompile_headers(libcockatrice_network_client_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -19,5 +19,3 @@ target_link_libraries(
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
libcockatrice_network_client_abstract libcockatrice_interfaces libcockatrice_utility libcockatrice_protocol libcockatrice_network_client_abstract libcockatrice_interfaces libcockatrice_utility libcockatrice_protocol
) )
target_precompile_headers(libcockatrice_network_client_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -15,5 +15,3 @@ add_dependencies(libcockatrice_network_server_local libcockatrice_protocol)
target_include_directories(libcockatrice_network_server_local PUBLIC .) target_include_directories(libcockatrice_network_server_local PUBLIC .)
target_link_libraries(libcockatrice_network_server_local PUBLIC ${COCKATRICE_QT_MODULES} libcockatrice_protocol) target_link_libraries(libcockatrice_network_server_local PUBLIC ${COCKATRICE_QT_MODULES} libcockatrice_protocol)
target_precompile_headers(libcockatrice_network_server_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -3,7 +3,6 @@ set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
set(HEADERS set(HEADERS
game/dump_zone_range.h
game/server_abstract_participant.h game/server_abstract_participant.h
game/server_abstract_player.h game/server_abstract_player.h
game/server_arrow.h game/server_arrow.h
@ -34,7 +33,6 @@ qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
add_library( add_library(
libcockatrice_network_server_remote STATIC libcockatrice_network_server_remote STATIC
${MOC_SOURCES} ${MOC_SOURCES}
game/dump_zone_range.cpp
game/server_abstract_participant.cpp game/server_abstract_participant.cpp
game/server_abstract_player.cpp game/server_abstract_player.cpp
game/server_arrow.cpp game/server_arrow.cpp
@ -64,5 +62,3 @@ target_link_libraries(
libcockatrice_network_server_remote PUBLIC libcockatrice_protocol libcockatrice_utility libcockatrice_rng libcockatrice_network_server_remote PUBLIC libcockatrice_protocol libcockatrice_utility libcockatrice_rng
libcockatrice_deck_list ${COCKATRICE_QT_MODULES} libcockatrice_deck_list ${COCKATRICE_QT_MODULES}
) )
target_precompile_headers(libcockatrice_network_server_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -1,17 +0,0 @@
#include "dump_zone_range.h"
#include <algorithm>
std::pair<qsizetype, qsizetype> dumpZoneCardRange(qsizetype cardCount, int numberCards, bool isReversed)
{
qsizetype count = 0;
if (numberCards == -1) {
count = cardCount;
} else if (numberCards > 0) {
count = std::min<qsizetype>(numberCards, cardCount);
}
count = std::max<qsizetype>(count, 0);
const qsizetype begin = isReversed ? cardCount - count : 0;
return {begin, begin + count};
}

View file

@ -1,29 +0,0 @@
#ifndef DUMP_ZONE_RANGE_H
#define DUMP_ZONE_RANGE_H
#include <QtGlobal>
#include <utility>
/**
* @brief Resolve a client's zone dump request against a zone's real card count.
*
* @c number_cards arrives from the client unvalidated. -1 means "the whole zone", which
* is the convention the client uses when sizing its own view of one. Every other negative
* value, and every value larger than the zone, has to be brought into range rather than
* used as an index: with a reversed request the zone offset is
* @c cardCount - @c numberCards, which runs off the front of the list when the request
* exceeds the zone and off the back when @c numberCards is -1.
*
* Kept in its own translation unit so it can be exercised without the rest of
* server_abstract_player.cpp, which refers to the RNG global that only the servatrice
* binary defines.
*
* @param cardCount number of cards the zone actually holds
* @param numberCards the requested count, -1 for the whole zone
* @param isReversed take the trailing cards instead of the leading ones
* @return the half-open index range [begin, end) into the zone's card list, always
* contained in [0, cardCount)
*/
std::pair<qsizetype, qsizetype> dumpZoneCardRange(qsizetype cardCount, int numberCards, bool isReversed);
#endif // DUMP_ZONE_RANGE_H

View file

@ -1,6 +1,5 @@
#include "server_abstract_player.h" #include "server_abstract_player.h"
#include "dump_zone_range.h"
#include "game/server_abstract_participant.h" #include "game/server_abstract_participant.h"
#include "game/server_arrowtarget.h" #include "game/server_arrowtarget.h"
#include "libcockatrice/protocol/pb/card_attributes.pb.h" #include "libcockatrice/protocol/pb/card_attributes.pb.h"
@ -1436,23 +1435,23 @@ Server_AbstractPlayer::cmdDumpZone(const Command_DumpZone &cmd, ResponseContaine
int numberCards = cmd.number_cards(); int numberCards = cmd.number_cards();
const QList<Server_Card *> &cards = zone->getCards(); const QList<Server_Card *> &cards = zone->getCards();
const auto cardRange = dumpZoneCardRange(cards.size(), numberCards, cmd.is_reversed());
auto *re = new Response_DumpZone; auto *re = new Response_DumpZone;
ServerInfo_Zone *zoneInfo = re->mutable_zone_info(); ServerInfo_Zone *zoneInfo = re->mutable_zone_info();
zoneInfo->set_name(zone->getName().toStdString()); zoneInfo->set_name(zone->getName().toStdString());
zoneInfo->set_type(zone->getType()); zoneInfo->set_type(zone->getType());
zoneInfo->set_with_coords(zone->hasCoords()); zoneInfo->set_with_coords(zone->hasCoords());
zoneInfo->set_card_count(cards.size()); zoneInfo->set_card_count(numberCards < cards.size() ? cards.size() : numberCards);
for (qsizetype findId = cardRange.first; findId < cardRange.second; ++findId) { for (int i = 0; (i < cards.size()) && (i < numberCards || numberCards == -1); ++i) {
Server_Card *card = cards.at(findId); const auto &findId = cmd.is_reversed() ? cards.size() - numberCards + i : i;
Server_Card *card = cards[findId];
QString displayedName = card->getFaceDown() ? QString() : card->getName(); QString displayedName = card->getFaceDown() ? QString() : card->getName();
ServerInfo_Card *cardInfo = zoneInfo->add_card_list(); ServerInfo_Card *cardInfo = zoneInfo->add_card_list();
cardInfo->set_provider_id(card->getProviderId().toStdString()); cardInfo->set_provider_id(card->getProviderId().toStdString());
cardInfo->set_name(displayedName.toStdString()); cardInfo->set_name(displayedName.toStdString());
if (zone->getType() == ServerInfo_Zone::HiddenZone) { if (zone->getType() == ServerInfo_Zone::HiddenZone) {
cardInfo->set_id(static_cast<int>(findId)); cardInfo->set_id(findId);
} else { } else {
cardInfo->set_id(card->getId()); cardInfo->set_id(card->getId());
cardInfo->set_x(card->getX()); cardInfo->set_x(card->getX());

View file

@ -56,10 +56,6 @@ public:
{ {
return deck; return deck;
} }
bool getSideboardLocked() const
{
return sideboardLocked;
}
bool getReadyStart() const bool getReadyStart() const
{ {
return readyStart; return readyStart;

View file

@ -61,8 +61,6 @@
#include <QTimer> #include <QTimer>
#include <google/protobuf/descriptor.h> #include <google/protobuf/descriptor.h>
#include <google/protobuf/message.h> #include <google/protobuf/message.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/context_deck_select.pb.h>
#include <libcockatrice/protocol/pb/context_ping_changed.pb.h> #include <libcockatrice/protocol/pb/context_ping_changed.pb.h>
#include <libcockatrice/protocol/pb/event_delete_arrow.pb.h> #include <libcockatrice/protocol/pb/event_delete_arrow.pb.h>
#include <libcockatrice/protocol/pb/event_game_closed.pb.h> #include <libcockatrice/protocol/pb/event_game_closed.pb.h>
@ -827,48 +825,6 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
} }
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, prepareGameEvent(event2, -1)); rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, prepareGameEvent(event2, -1));
// A newly joined player never saw the "Player X has loaded a deck" messages
// (they are only broadcast when a deck is selected), so replay a deck-select
// context for every player that already has a deck loaded. It is delivered
// privately to the joining participant, whose client renders it as a log
// entry in the same way as a fresh deck selection. Skipped on reconnect
// (resuming) — the player already saw these — and once the game has started,
// when the message is no longer useful and would only spam the log.
if (!resuming && !gameStarted) {
for (auto *participant : participants.values()) {
if (participant == joiningParticipant) {
continue;
}
auto *deckOwner = qobject_cast<Server_Player *>(participant);
if (deckOwner == nullptr) {
continue;
}
const DeckList *deckList = deckOwner->getDeckList();
if (deckList == nullptr) {
continue;
}
Event_PlayerPropertiesChanged event;
event.mutable_player_properties()->set_sideboard_locked(deckOwner->getSideboardLocked());
event.mutable_player_properties()->set_deck_hash(deckList->getDeckHash().toStdString());
Context_DeckSelect deckSelect;
deckSelect.set_deck_hash(deckList->getDeckHash().toStdString());
deckSelect.set_sideboard_size(deckList->getSideboardSize());
if (getShareDecklistsOnLoad()) {
deckSelect.set_deck_list(deckList->writeToString_Native().toStdString());
}
GameEventContext context;
context.GetReflection()
->MutableMessage(&context, deckSelect.GetDescriptor()->FindExtensionByName("ext"))
->CopyFrom(deckSelect);
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER,
prepareGameEvent(event, deckOwner->getPlayerId(), &context));
}
}
} }
void Server_Game::sendGameEventContainer(GameEventContainer *cont, void Server_Game::sendGameEventContainer(GameEventContainer *cont,

View file

@ -187,39 +187,6 @@ public:
virtual void observeGameStartDurationMs(qint64 /* elapsedMs */) virtual void observeGameStartDurationMs(qint64 /* elapsedMs */)
{ {
} }
/**
* @brief Record a failed login attempt for the (address, username) pair; returns true if now locked out.
*
* Keyed by pair so a successful login as one user cannot wipe out another
* user's failure history from the same address (e.g. a guest login or the
* attacker's own account clearing the way for further guessing).
*/
virtual bool recordFailedLogin(const QString & /*ipAddress*/, const QString & /*userName*/)
{
return false;
}
/**
* @brief True if the (address, username) pair is already locked out of logging in.
*
* Consulted before any authentication work (database round trip, password
* verification) so a blocked pair cannot burn server CPU per attempt.
*/
virtual bool isLoginRateLimited(const QString & /*ipAddress*/, const QString & /*userName*/)
{
return false;
}
/**
* @brief Seconds remaining before the login rate limit for the (address, username) pair lifts.
* Returns 0 if not limited.
*/
virtual int loginRateLimitRemainingSeconds(const QString & /*ipAddress*/, const QString & /*userName*/)
{
return 0;
}
/** @brief Clear any failed-login lockout for the (address, username) pair, e.g. after a successful login. */
virtual void clearFailedLogins(const QString & /*ipAddress*/, const QString & /*userName*/)
{
}
Server_DatabaseInterface *getDatabaseInterface() const; Server_DatabaseInterface *getDatabaseInterface() const;
int getNextLocalGameId() int getNextLocalGameId()

View file

@ -592,17 +592,6 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
QString reasonStr; QString reasonStr;
int banSecondsLeft = 0; int banSecondsLeft = 0;
QString connectionType = getConnectionType(); QString connectionType = getConnectionType();
// Throttle before doing any work: a locked-out address must not get a full
// database round trip and password verification on every attempt.
if (server->isLoginRateLimited(getAddress(), userName)) {
int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName);
if (remaining > 0) {
auto *re = new Response_Login;
re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch());
rc.setResponseExtension(re);
}
return Response::RespTooManyRequests;
}
AuthenticationResult res = server->loginUser(this, userName, password, needsHash, reasonStr, banSecondsLeft, AuthenticationResult res = server->loginUser(this, userName, password, needsHash, reasonStr, banSecondsLeft,
clientId, clientVersion, connectionType); clientId, clientVersion, connectionType);
switch (res) { switch (res) {
@ -616,20 +605,10 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
return Response::RespUserIsBanned; return Response::RespUserIsBanned;
} }
case NotLoggedIn: case NotLoggedIn:
if (server->recordFailedLogin(getAddress(), userName)) {
int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName);
if (remaining > 0) {
auto *re = new Response_Login;
re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch());
rc.setResponseExtension(re);
}
return Response::RespTooManyRequests;
}
return Response::RespWrongPassword; return Response::RespWrongPassword;
case WouldOverwriteOldSession: case WouldOverwriteOldSession:
return Response::RespWouldOverwriteOldSession; return Response::RespWouldOverwriteOldSession;
case UsernameInvalid: { case UsernameInvalid: {
server->recordFailedLogin(getAddress(), userName);
auto *re = new Response_Login; auto *re = new Response_Login;
re->set_denied_reason_str(reasonStr.toStdString()); re->set_denied_reason_str(reasonStr.toStdString());
rc.setResponseExtension(re); rc.setResponseExtension(re);
@ -640,12 +619,10 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
case ClientIdRequired: case ClientIdRequired:
return Response::RespClientIdRequired; return Response::RespClientIdRequired;
case UserIsInactive: case UserIsInactive:
server->recordFailedLogin(getAddress(), userName);
return Response::RespAccountNotActivated; return Response::RespAccountNotActivated;
case PasswordChangeRequired: case PasswordChangeRequired:
return Response::RespPasswordChangeRequired; return Response::RespPasswordChangeRequired;
default: default:
server->clearFailedLogins(getAddress(), userName);
authState = res; authState = res;
usingRealPassword = needsHash; usingRealPassword = needsHash;
} }

View file

@ -24,5 +24,3 @@ target_include_directories(
libcockatrice_protocol PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} libcockatrice_protocol PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR} # points to the generated headers ${CMAKE_CURRENT_BINARY_DIR} # points to the generated headers
) )
target_precompile_headers(libcockatrice_protocol PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -55,7 +55,6 @@ set(PROTO_FILES
command_report_details.proto command_report_details.proto
command_report_list.proto command_report_list.proto
command_report_my_list.proto command_report_my_list.proto
command_report_reopen.proto
command_report_resolve.proto command_report_resolve.proto
command_report_stats.proto command_report_stats.proto
command_report_user_info.proto command_report_user_info.proto

View file

@ -1,10 +0,0 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportReopen {
extend ModeratorCommand {
optional Command_ReportReopen ext = 1206;
}
required int32 report_id = 1;
optional string reopen_note = 2;
}

View file

@ -26,7 +26,6 @@ message ModeratorCommand {
REPLAY_DOWNLOAD_BY_GAME_ID = 1203; REPLAY_DOWNLOAD_BY_GAME_ID = 1203;
REPORT_USER_INFO = 1204; REPORT_USER_INFO = 1204;
REPORT_STATS = 1205; REPORT_STATS = 1205;
REPORT_REOPEN = 1206;
} }
extensions 100 to max; extensions 100 to max;
} }

View file

@ -33,7 +33,4 @@ message ServerInfo_Report {
optional string chat_log = 14; optional string chat_log = 14;
optional string resolution_note = 15; optional string resolution_note = 15;
optional string resolved_by_name = 16;
optional int32 reported_user_level = 17;
} }

View file

@ -11,10 +11,6 @@ add_library(
libcockatrice/rng/sfmt/SFMT.c libcockatrice/rng/sfmt/SFMT.c
) )
# SFMT is vendored and its own headers trip UBSan's alignment and vptr checks.
cockatrice_disable_sanitizers_for(libcockatrice/rng/sfmt/SFMT.c)
cockatrice_disable_coverage_for(libcockatrice/rng/sfmt/SFMT.c)
target_include_directories(libcockatrice_rng PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(libcockatrice_rng PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_rng PUBLIC ${QT_CORE_MODULE}) target_link_libraries(libcockatrice_rng PUBLIC ${QT_CORE_MODULE})

View file

@ -71,5 +71,3 @@ target_include_directories(
) )
target_link_libraries(libcockatrice_settings PUBLIC libcockatrice_card libcockatrice_utility ${QT_CORE_MODULE}) target_link_libraries(libcockatrice_settings PUBLIC libcockatrice_card libcockatrice_utility ${QT_CORE_MODULE})
target_precompile_headers(libcockatrice_settings PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -110,7 +110,6 @@ void CardDatabaseSettings::saveSets(const QVector<ICardSetPriorityController::Se
s.beginGroup(entry.shortName); s.beginGroup(entry.shortName);
s.setValue("sortKey", entry.sortKey); s.setValue("sortKey", entry.sortKey);
s.setValue("enabled", entry.enabled); s.setValue("enabled", entry.enabled);
s.setValue("isKnown", entry.isKnown);
s.endGroup(); s.endGroup();
} }
s.endGroup(); s.endGroup();
@ -122,6 +121,5 @@ void CardDatabaseSettings::saveSets(const QVector<ICardSetPriorityController::Se
SetOptions &o = setOptionsCache[entry.shortName]; SetOptions &o = setOptionsCache[entry.shortName];
o.sortKey = entry.sortKey; o.sortKey = entry.sortKey;
o.enabled = entry.enabled; o.enabled = entry.enabled;
o.isKnown = entry.isKnown;
} }
} }

View file

@ -2,8 +2,6 @@
#include "libcockatrice/settings/settings_manager.h" #include "libcockatrice/settings/settings_manager.h"
#include <QDir>
#include <QStringList>
#include <QVariant> #include <QVariant>
class QObject; class QObject;
@ -123,52 +121,3 @@ void PathsSettings::setSpoilerDatabasePath(const QString &_spoilerDatabasePath)
setValue(_spoilerDatabasePath, "spoilerDatabase"); setValue(_spoilerDatabasePath, "spoilerDatabase");
emit cardDatabasePathChanged(); emit cardDatabasePathChanged();
} }
QStringList PathsSettings::pathsInsideDir(const QString &baseDir) const
{
QStringList offenders;
const auto check = [&offenders, &baseDir](const QString &path) {
if (!path.isEmpty() && isInsideDir(path, baseDir)) {
offenders.append(QDir::cleanPath(path));
}
};
check(getDeckPath());
check(getFiltersPath());
check(getReplaysPath());
check(getPicsPath());
check(getCustomPicsPath());
check(getThemesPath());
check(getCardDatabasePath());
check(getCustomCardDatabasePath());
check(getTokenDatabasePath());
check(getSpoilerCardDatabasePath());
check(getRedirectCachePath());
offenders.removeDuplicates();
return offenders;
}
bool PathsSettings::isInsideDir(const QString &path, const QString &baseDir)
{
#if defined(Q_OS_WIN) || defined(Q_OS_MACOS)
// Windows and default macOS volumes are case-insensitive filesystems.
constexpr Qt::CaseSensitivity caseSensitivity = Qt::CaseInsensitive;
#else
constexpr Qt::CaseSensitivity caseSensitivity = Qt::CaseSensitive;
#endif
const QString base = QDir::cleanPath(baseDir);
const QString cleaned = QDir::cleanPath(path);
const QString prefix = base.endsWith(QLatin1Char('/')) ? base : base + QLatin1Char('/');
return cleaned == base || cleaned.startsWith(prefix, caseSensitivity);
}
bool PathsSettings::getAppDirWarningAcknowledged() const
{
return getValue("appDirWarningAcknowledged").toBool();
}
void PathsSettings::setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged)
{
setValue(_appDirWarningAcknowledged, "appDirWarningAcknowledged");
}

View file

@ -4,7 +4,6 @@
#include "settings_manager.h" #include "settings_manager.h"
#include <QString> #include <QString>
#include <QStringList>
#include <libcockatrice/interfaces/interface_paths_settings_provider.h> #include <libcockatrice/interfaces/interface_paths_settings_provider.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
@ -28,24 +27,6 @@ public:
[[nodiscard]] QString getSpoilerCardDatabasePath() const override; [[nodiscard]] QString getSpoilerCardDatabasePath() const override;
[[nodiscard]] QString getRedirectCachePath() const override; [[nodiscard]] QString getRedirectCachePath() const override;
/**
* @brief Returns the configured paths that resolve inside baseDir.
*
* Comparison is lexical (QDir::cleanPath); symlinks are not resolved, so a
* path that only reaches baseDir through a symlink is not reported.
*/
[[nodiscard]] QStringList pathsInsideDir(const QString &baseDir) const;
/**
* @brief Whether path resolves inside baseDir, as a single-path check.
*
* A path counts as inside when it equals baseDir or is nested below it;
* a sibling sharing the name prefix (baseDir + "2") does not.
*/
[[nodiscard]] static bool isInsideDir(const QString &path, const QString &baseDir);
[[nodiscard]] bool getAppDirWarningAcknowledged() const;
void setDeckPath(const QString &_deckPath); void setDeckPath(const QString &_deckPath);
void setFiltersPath(const QString &_filtersPath); void setFiltersPath(const QString &_filtersPath);
void setReplaysPath(const QString &_replaysPath); void setReplaysPath(const QString &_replaysPath);
@ -56,17 +37,14 @@ public:
void setCustomCardDatabasePath(const QString &_customCardDatabasePath); void setCustomCardDatabasePath(const QString &_customCardDatabasePath);
void setTokenDatabasePath(const QString &_tokenDatabasePath); void setTokenDatabasePath(const QString &_tokenDatabasePath);
void setSpoilerDatabasePath(const QString &_spoilerDatabasePath); void setSpoilerDatabasePath(const QString &_spoilerDatabasePath);
void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged);
signals: signals:
void cardDatabasePathChanged(); void cardDatabasePathChanged();
void picsPathChanged(); void picsPathChanged();
void themeChanged(); void themeChanged();
public:
explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr);
private: private:
explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr);
PathsSettings(const PathsSettings & /*other*/); PathsSettings(const PathsSettings & /*other*/);
}; };

View file

@ -642,34 +642,6 @@ static void migrateLegacyMessages(const QString &settingsPath)
legacySetting.endGroup(); legacySetting.endGroup();
} }
/**
* gamefilters.ini keys renamed to camelCase by #7084. Profiles that migrated from the legacy
* native store before that rename still hold the old snake_case keys, which nothing reads
* anymore, so their saved filters silently stopped loading.
*/
static const QMap<QString, QString> &renamedGameFilterKeys()
{
static const QMap<QString, QString> keyMap = {
{"hide_buddies_only_games", "hideBuddiesOnlyGames"},
{"hide_full_games", "hideFullGames"},
{"hide_games_that_started", "hideGamesThatStarted"},
{"hide_password_protected_games", "hidePasswordProtectedGames"},
{"hide_ignored_user_games", "hideIgnoredUserGames"},
{"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"},
{"hide_open_decklist_games", "hideOpenDecklistGames"},
{"game_name_filter", "gameNameFilter"},
{"creator_name_filter", "hostNameFilter"},
{"min_players", "minPlayers"},
{"max_players", "maxPlayers"},
{"max_game_age_time", "maxGameAgeTime"},
{"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"},
{"show_spectator_password_protected", "showSpectatorPasswordProtected"},
{"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"},
{"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"},
};
return keyMap;
}
static void migrateLegacyGameFilters(const QString &settingsPath) static void migrateLegacyGameFilters(const QString &settingsPath)
{ {
QSettings legacySetting; QSettings legacySetting;
@ -680,7 +652,24 @@ static void migrateLegacyGameFilters(const QString &settingsPath)
return; return;
} }
const QMap<QString, QString> &filterKeyMap = renamedGameFilterKeys(); const QMap<QString, QString> filterKeyMap = {
{"hide_buddies_only_games", "hideBuddiesOnlyGames"},
{"hide_full_games", "hideFullGames"},
{"hide_games_that_started", "hideGamesThatStarted"},
{"hide_password_protected_games", "hidePasswordProtectedGames"},
{"hide_ignored_user_games", "hideIgnoredUserGames"},
{"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"},
{"hide_open_decklist_games", "hideOpenDecklistGames"},
{"game_name_filter", "gameNameFilter"},
{"creator_name_filter", "creatorNameFilter"},
{"min_players", "minPlayers"},
{"max_players", "maxPlayers"},
{"max_game_age_time", "maxGameAgeTime"},
{"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"},
{"show_spectator_password_protected", "showSpectatorPasswordProtected"},
{"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"},
{"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"},
};
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) { for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) {
@ -697,68 +686,6 @@ static void migrateLegacyGameFilters(const QString &settingsPath)
legacySetting.endGroup(); legacySetting.endGroup();
} }
static const QString LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/game_type/");
static const QString GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/gameType/");
static bool gameFiltersCamelCaseMigrationPending(const QString &settingsPath)
{
if (!QFile::exists(settingsPath + "gamefilters.ini")) {
return false;
}
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
const QMap<QString, QString> &keyMap = renamedGameFilterKeys();
for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) {
if (filtersIni.contains("filter_games/" + it.key()) && !filtersIni.contains("filter_games/" + it.value())) {
return true;
}
}
// Orphaned by #7084, which migrated the legacy host-name filter to a key nothing reads.
if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) {
return true;
}
for (const auto &key : filtersIni.allKeys()) {
if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX) &&
!filtersIni.contains(GAME_FILTERS_GAME_TYPE_PREFIX +
key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size()))) {
return true;
}
}
return false;
}
bool SettingsMigration::migrateGameFiltersToCamelCase(const QString &settingsPath)
{
if (!gameFiltersCamelCaseMigrationPending(settingsPath)) {
return false;
}
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
const QMap<QString, QString> &keyMap = renamedGameFilterKeys();
for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) {
const QString oldKey = "filter_games/" + it.key();
const QString newKey = "filter_games/" + it.value();
if (filtersIni.contains(oldKey) && !filtersIni.contains(newKey)) {
filtersIni.setValue(newKey, filtersIni.value(oldKey));
}
}
if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) {
filtersIni.setValue("filter_games/hostNameFilter", filtersIni.value("filter_games/creatorNameFilter"));
}
for (const auto &key : filtersIni.allKeys()) {
if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX)) {
const QString newKey = GAME_FILTERS_GAME_TYPE_PREFIX + key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size());
if (!filtersIni.contains(newKey)) {
filtersIni.setValue(newKey, filtersIni.value(key));
}
}
}
// The old keys are deliberately kept: pre-#7084 builds still read them, so removing them
// would break downgrades, and nothing in the current code reads them anymore.
filtersIni.sync();
return true;
}
bool SettingsMigration::migrateLegacySettings(const QString &settingsPath) bool SettingsMigration::migrateLegacySettings(const QString &settingsPath)
{ {
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
@ -817,10 +744,6 @@ bool SettingsMigration::isMigrationPending(const QString &settingsPath)
return true; return true;
} }
if (gameFiltersCamelCaseMigrationPending(settingsPath)) {
return true;
}
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
if (personalIni.value("migration/legacy_complete", false).toBool()) { if (personalIni.value("migration/legacy_complete", false).toBool()) {
return false; return false;

View file

@ -9,15 +9,6 @@ public:
static bool migrateSettingsFromGlobalIni(const QString &settingsPath); static bool migrateSettingsFromGlobalIni(const QString &settingsPath);
static bool migrateLegacySettings(const QString &settingsPath); static bool migrateLegacySettings(const QString &settingsPath);
/**
* Moves pre-camelCase gamefilters.ini keys (snake_case, game_type/<hash>) to the camelCase
* keys used since #7084, for profiles that already migrated before that rename and therefore
* never pass through migrateLegacySettings again. Copies values only where the new key is
* absent, never deletes the old keys, and returns true when anything was copied. Idempotent,
* so it runs on every startup.
*/
static bool migrateGameFiltersToCamelCase(const QString &settingsPath);
/** /**
* Returns true when at least one of the two migrations would still do work, i.e. when a * Returns true when at least one of the two migrations would still do work, i.e. when a
* backup of the settings directory is worth taking before they run. * backup of the settings directory is worth taking before they run.

View file

@ -49,5 +49,3 @@ target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Cr
set(ORACLE_LIBS) set(ORACLE_LIBS)
include_directories(${${COCKATRICE_QT_VERSION_NAME}Core_INCLUDE_DIRS}) include_directories(${${COCKATRICE_QT_VERSION_NAME}Core_INCLUDE_DIRS})
target_precompile_headers(libcockatrice_utility PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -79,9 +79,6 @@ if(ZLIB_FOUND)
include_directories(${ZLIB_INCLUDE_DIRS}) include_directories(${ZLIB_INCLUDE_DIRS})
add_definitions("-DHAS_ZLIB") add_definitions("-DHAS_ZLIB")
list(APPEND oracle_SOURCES src/zip/unzip.cpp src/zip/zipglobal.cpp) list(APPEND oracle_SOURCES src/zip/unzip.cpp src/zip/zipglobal.cpp)
# minizip and zlib are vendored.
cockatrice_disable_sanitizers_for(src/zip/unzip.cpp src/zip/zipglobal.cpp)
cockatrice_disable_coverage_for(src/zip/unzip.cpp src/zip/zipglobal.cpp)
else() else()
message(STATUS "Oracle: zlib not found; ZIP support disabled") message(STATUS "Oracle: zlib not found; ZIP support disabled")
endif() endif()
@ -92,9 +89,6 @@ if(LIBLZMA_FOUND)
include_directories(${LIBLZMA_INCLUDE_DIRS}) include_directories(${LIBLZMA_INCLUDE_DIRS})
add_definitions("-DHAS_LZMA") add_definitions("-DHAS_LZMA")
list(APPEND oracle_SOURCES src/lzma/decompress.cpp) list(APPEND oracle_SOURCES src/lzma/decompress.cpp)
# The LZMA SDK is vendored.
cockatrice_disable_sanitizers_for(src/lzma/decompress.cpp)
cockatrice_disable_coverage_for(src/lzma/decompress.cpp)
else() else()
message(STATUS "Oracle: LibLZMA not found; xz support disabled") message(STATUS "Oracle: LibLZMA not found; xz support disabled")
endif() endif()

View file

@ -85,8 +85,5 @@ int main(int argc, char *argv[])
QTimer::singleShot(0, &wizard, [&wizard]() { wizard.runInBackground(); }); QTimer::singleShot(0, &wizard, [&wizard]() { wizard.runInBackground(); });
} }
const int execResult = app.exec(); return app.exec();
// A cancel only counts when nothing was written yet: closing the wizard
// after the card database was already saved on disk is still an update.
return (wizard.wasCancelled() && !wizard.hasSavedDatabase()) ? 1 : execResult;
} }

View file

@ -128,20 +128,12 @@ void OracleWizard::accept()
void OracleWizard::reject() void OracleWizard::reject()
{ {
cancelled = true;
// The wizard is being closed while a page may still run a worker on the // The wizard is being closed while a page may still run a worker on the
// importer. Ask it to stop before the wizard (and the importer child) is // importer. Ask it to stop before the wizard (and the importer child) is
// destroyed, so the worker thread never touches freed memory. // destroyed, so the worker thread never touches freed memory.
if (auto *active = dynamic_cast<OracleWizardPage *>(currentPage())) { if (auto *active = dynamic_cast<OracleWizardPage *>(currentPage())) {
active->cancelWork(); active->cancelWork();
} }
const auto replies = nam->findChildren<QNetworkReply *>();
for (QNetworkReply *reply : replies) {
reply->abort();
}
QWizard::reject(); QWizard::reject();
} }

View file

@ -20,19 +20,6 @@ public:
explicit OracleWizard(QWidget *parent = nullptr); explicit OracleWizard(QWidget *parent = nullptr);
void accept() override; void accept() override;
void reject() override; void reject() override;
bool wasCancelled()
{
return cancelled;
}
/** @brief Records that the card database was written to disk, so a later cancel still counts as an update. */
void markDatabaseSaved()
{
databaseSaved = true;
}
bool hasSavedDatabase()
{
return databaseSaved;
}
void enableButtons(); void enableButtons();
void disableButtons(); void disableButtons();
void retranslateUi(); void retranslateUi();
@ -80,8 +67,6 @@ private:
QByteArray tokensData; QByteArray tokensData;
QString cardSourceUrl; QString cardSourceUrl;
QString cardSourceVersion; QString cardSourceVersion;
bool cancelled = false;
bool databaseSaved = false;
void migrateOracleSettings(); void migrateOracleSettings();
/** /**

View file

@ -484,10 +484,6 @@ void LoadSetsPage::actDownloadFinishedSetsFile()
{ {
// check for a reply // check for a reply
auto *reply = dynamic_cast<QNetworkReply *>(sender()); auto *reply = dynamic_cast<QNetworkReply *>(sender());
if (wizard()->wasCancelled()) {
reply->deleteLater();
return;
}
auto errorCode = reply->error(); auto errorCode = reply->error();
if (errorCode != QNetworkReply::NoError) { if (errorCode != QNetworkReply::NoError) {
QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString())); QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString()));
@ -899,7 +895,6 @@ bool SaveSetsPage::validatePage()
return false; return false;
} }
wizard()->markDatabaseSaved();
return true; return true;
} }

View file

@ -178,10 +178,6 @@ void SimpleDownloadFilePage::actDownloadFinished()
{ {
// check for a reply // check for a reply
auto *reply = dynamic_cast<QNetworkReply *>(sender()); auto *reply = dynamic_cast<QNetworkReply *>(sender());
if (wizard()->wasCancelled()) {
reply->deleteLater();
return;
}
QNetworkReply::NetworkError errorCode = reply->error(); QNetworkReply::NetworkError errorCode = reply->error();
if (errorCode != QNetworkReply::NoError) { if (errorCode != QNetworkReply::NoError) {
QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString())); QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString()));
@ -246,7 +242,6 @@ bool SimpleDownloadFilePage::saveToFile()
// clean saved downloadData // clean saved downloadData
downloadData = QByteArray(); downloadData = QByteArray();
wizard()->markDatabaseSaved();
return true; return true;
} }

View file

@ -9,7 +9,6 @@ set(servatrice_SOURCES
src/event_loop_watchdog.cpp src/event_loop_watchdog.cpp
src/main.cpp src/main.cpp
src/metrics_registry.cpp src/metrics_registry.cpp
src/ratelimiter.cpp
src/servatrice.cpp src/servatrice.cpp
src/servatrice_connection_pool.cpp src/servatrice_connection_pool.cpp
src/servatrice_database_interface.cpp src/servatrice_database_interface.cpp
@ -28,14 +27,6 @@ set(servatrice_SOURCES
set(servatrice_RESOURCES servatrice.qrc) set(servatrice_RESOURCES servatrice.qrc)
# Qxt SMTP is vendored.
cockatrice_disable_sanitizers_for(
src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp
)
cockatrice_disable_coverage_for(
src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp
)
if(WIN32) if(WIN32)
set(servatrice_SOURCES ${servatrice_SOURCES} servatrice.rc) set(servatrice_SOURCES ${servatrice_SOURCES} servatrice.rc)
endif(WIN32) endif(WIN32)

Some files were not shown because too many files have changed in this diff Show more