Compare commits

...

31 commits

Author SHA1 Message Date
Lukas Brübach
b7253efefb [Client] Settings: match CI clang-format-18 line breaking 2026-10-16 16:37:25 +02:00
Lukas Brübach
daceb04a2b [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-16 16:37:25 +02:00
BruebachL
456d9d9e70
[Server] Inform client of login/registration rate limit timeout (#7468)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Security] Add per-address rate limiting for auth endpoints

Introduce a thread-safe RateLimiter that tracks attempts per key
(IP address) within a sliding time window, and wire it into the
authentication endpoints:

- Login: failed login attempts from an address are counted; once the
  configured maximum is exceeded within the window, further logins
  from that address are rejected with RespTooManyRequests. A
  successful login clears the failed attempts for that address.
- Registration: implement the previously stubbed
  tooManyRegistrationAttempts, limiting how many accounts can be
  created per address per window.
- Forgot-password: throttle both the email-request and the
  email-challenge paths per address.

New [security] settings with defaults:
  max_login_attempts_per_ip=5 / login_attempt_window_seconds=900
  max_registrations_per_ip=2 / registration_window_seconds=3600
  max_forgot_password_requests_per_ip=3 / forgot_password_window_seconds=3600

Adds unit tests for the RateLimiter (window limit, over-limit
blocking, clearing, per-key independence).

Took 3 minutes

* [Server] Fix rate limiter: disable semantics, draining window, bounded keys, pre-work login gate

- recordAttempt()/isBlocked() return false when maxAttempts <= 0 or windowSeconds <= 0, so the documented 'set to 0 to disable' actually disables instead of blocking every address permanently
- Keys at their limit are rejected without recording, so repeated attempts at the attacker's pace cannot keep sliding the window and holding the lockout open; a block now clears once the recorded attempts age out of windowSeconds
- Attempts map is bounded: an opportunistic prune (every 60s) erases keys whose attempts have fully aged out
- Login is throttled before the work: isLoginRateLimited() (a new virtual on Server, backed by isBlocked()) gates loginUser(), so a locked-out address no longer burns a database round trip and password verification per attempt
- recordAttemptAt()/isBlockedAt() time seams make the sliding-window behaviour deterministically testable; tests rewritten for the real semantics (no reliance on the maxAttempts=0 bug, no sleeps)

* [Server] Inform client of login/registration rate limit timeout

- Add retryAfterSeconds/retryAfterSecondsAt to RateLimiter to compute
  seconds until oldest attempt exits the sliding window
- Expose loginRateLimitRemainingSeconds via Server/Servatrice
- Include denied_end_time (absolute epoch) in RespTooManyRequests for
  login pre-auth and when transitioning to lockout after failed attempt
- Include denied_end_time in registration rate limit response
- Client: show retry time for login RespTooManyRequests
- Client: simplify registration RespTooManyRequests message to show
  retry time when provided
- Add test for retryAfterSecondsAt

* [Client] Restore registration guidance and drop duplicated rate limit reason

The registration RespTooManyRequests case now only shows the retry-after
copy when the server actually sent a denied_end_time; the per-email account
cap path (no end time) falls back to the previous guidance about already
having an account and contacting the server operator.

Drop the canned denied_reason_str from the new login rate limit branches so
the client no longer prints the same sentence twice.

* [Server] Exempt trusted_sources from auth rate limits

Addresses in security/trusted_sources bypass per-address limits, matching
the existing max_users_per_address bypass in initSession. Without this,
Webatrice behind local nginx (all clients as 127.0.0.1) would lock out
every web user after 5 mistyped passwords, and cap the whole server at
2 registrations / 3 forgot-password requests per window.

Adds Servatrice::isRateLimitExempt helper; recordFailedLogin,
isLoginRateLimited, recordRegistrationAttempt and
recordForgotPasswordAttempt all check it.

# Conflicts:
#	servatrice/src/servatrice.cpp

* [Server] Key login rate limit by (address, username)

A successful login cleared the whole per-IP failure history, so 4 bad
guesses plus 1 self-login (or guest UnknownUser login) from the same
address bypassed the limiter. Failures are now bucketed per
(address, lowercase username) and a success clears only its own pair.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 05:47:43 +02:00
BruebachL
213ce691dd
[Completer] Minimum size popup, fall back to compact design if not possible. (#7111)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Completer] Minimum size popup, fall back to compact design if not possible.

Took 15 minutes

Took 6 seconds


Took 5 minutes

* [Completer] Fix popup going offscreen by clamping in screen coordinates

The position clamping in applyPopupWidth() was comparing popup->pos()
(parent coordinates) against screen->availableGeometry() (screen
coordinates).  When the parent widget is not at the screen origin the
clamping computed a position in the wrong coordinate space, placing the
popup offscreen.  Convert to global coordinates first, clamp, then map
back to parent coordinates.

* [Completer] Clamp popup from geometry() in screen coordinates

setMinimumWidth() already widens the popup synchronously, so the old
width() < minWidth guard never ran and the popup grew past the right
screen edge. The popup is also a top-level Qt::Popup window, so move()
takes global coordinates and the mapFromGlobal round-trip moved it to
the top-left of the screen. Clamp x from geometry() after
setMinimumWidth() and move(QPoint(clampedX, popup->y())) directly.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 18:10:07 +02:00
BruebachL
96b0888a73
[Oracle] Don't report a cancelled card database update as successful (#7082)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Oracle] Don't report a cancelled card database update as successful (#3470)

Took 3 minutes

* [Oracle] Only report cancel when nothing was saved yet

Rejecting the wizard after SaveSetsPage or a download page already
wrote the database to disk still replaced cards.xml on disk, but
wasCancelled() forced exit code 1 so Cockatrice skipped the reload.
Track successful saves with OracleWizard::markDatabaseSaved() and only
treat a cancel as a failure when nothing was saved.

* [Oracle] Restore updater stderr in card update error dialog

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 15:21:07 +02:00
BruebachL
bbf0425655
[Style] Confirm broken Windows styles with a dialog offering Fusion (#7507)
* [Style] Confirm broken Windows styles with a dialog offering Fusion

* [Style] Address review nits on broken style dialog

Extract Fusion index lookup into findFusionIndex helper and add all
message box buttons before choosing the default button.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 12:41:01 +02:00
BruebachL
fec693ca2a
[Tests] Scope loader matcher settings to the process to fix parallel-run flakes (#7504)
* [Tests] Scope loader matcher settings to the process to fix parallel-run flakes

* [Tests] Clean up per-process settings sandbox after loader matcher run

* [Tests] Skip loader matcher sandbox cleanup during gtest discovery

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 12:21:40 +02:00
BruebachL
677692500b
[Game] Raise rubber band above count label and collapse multi-selection on click (#7451)
* [Game] Raise rubber band above count label and collapse multi-selection on click

Raise rubber band on start and collapse multi-selection to clicked card when left-clicking an already-selected card with no modifiers.

* [Game] Collapse multi-selection on release instead of press

Collapsing at press time dropped the selection before the drag started,
which broke dragging multiple cards. Keep the selection on press and
collapse it on release when the pointer stayed within the drag
distance.

* [Game] Defer selection collapse until the double-click window passes

A release without drag collapsed the multi-selection immediately, so a
double-click that followed (playSelected acts on scene()->selectedItems())
only ever saw the single clicked card. When the card handles
double-clicks on the selection, arm the collapse on a single-shot timer
for QApplication::doubleClickInterval() instead and cancel it from the
double-click handler; a new press disarms the other cards and a
selection-count guard covers rubber-band/ctrl changes meanwhile.

* [Game] Keep sideboard multi-selection on double-click

DeckViewCard overrides doubleClickActsOnSelection to preserve the
selection, restoring master behavior where double-clicking moves all
selected cards between main and side. DeckView cancels any pending
collapse before reading the selection.

* [Game] Remove duplicated inline selection-collapse declarations

* [Game] Fix DeckView collapse call visibility and multi-selection collapse

Make cancelPendingSelectionCollapse() public so DeckView can call it on
selected DeckViewCards. Always arm the deferred collapse on release
instead of skipping cards whose double-click acts on the selection;
the double-click-interval timer plus the cancel calls in
CardItem::mouseDoubleClickEvent and DeckView::mouseDoubleClickEvent
already preserve the selection for double-clicks.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-15 11:35:03 +02:00
BruebachL
fea12a74e2
[CI] Build Windows releases with Ninja (#7380)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
The A/B run measured Ninja against the Visual Studio generator across
ten builds: Ninja medians sit at ~17 min against ~21 for MSBuild, and
more importantly Ninja already matches the Linux and macOS legs, runs
the compiler itself and parallelizes the whole graph instead of one
MSBuild node at a time. Single runs on shared 4-core hosts swing by
minutes either way (VS and Ninja legs even ran in different Azure
regions inside one matrix), so this switches on medians and
engineering coherence, not on any one timing.

The Ninja leg is now the release leg: it keeps the -Win10 suffix and
the vcpkg scope (named after the vs2026 toolchain, whose cl.exe is
unchanged, so the warm archives keep restoring), uploads PDBs from the
single-config layout and ships installers to releases. The experiment
scaffolding goes away with the generator it measured.
2026-10-14 19:13:52 +02:00
BruebachL
0c10798ef4
[CI] Build the Windows solution on more than one MSBuild node (#7379)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
cmake --build only passes a job count to MSBuild when it is asked for
one, and .ci/compile.sh never asked, so every Visual Studio generator
build ran single-node: projects were built one after another and MTT
only ever parallelized the files of whichever project happened to be
building. Ask for one node per core, overridable with
BUILD_PARALLEL_LEVEL, and fall back to a single node when the core
count is not exposed.

Alongside it set EnforceProcessCountAcrossBuilds and CL_MPCount, the
knobs Microsoft's build throughput write-up pairs with MTT: without
them the node count and MTT multiply into more cl.exe processes than
the runner has cores for.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-14 16:15:01 +02:00
BruebachL
eefe0bd672
[CI] Collect an MSBuild performance summary and binary log on request (#7378)
The Windows job spends about 28 minutes inside one "Build project"
group, with nothing in it that says where the time goes: MSBuild runs
single-node because no --parallel is passed, so projects build one
after another and it is not visible which projects, tasks or phases are
responsible.

Add a --profile flag that makes the Windows build write
-flp:PerformanceSummary and -bl, repeat the summary in the job log so
the numbers need no download, and report the core count, which decides
how much parallelism is worth asking for. The binary log is uploaded as
an artifact only on the runs that asked for it.

The workflow drives it from a build_profile matrix key that is empty by
default, so no build changes behaviour until someone sets it.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-14 14:27:58 +02:00
BruebachL
25c85cf0bb
[CI] Key the vcpkg binary cache per ABI and let pull requests heal it (#7377)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [CI] Key the vcpkg binary cache per ABI and let pull requests heal it

The cache had no prefix, and TAServers/vcpkg-cache restores every entry it
finds under the prefix rather than only the hashes vcpkg asks for. With all
legs sharing the single vcpkg/ namespace, each of the five of them downloads
all 66 archives of all five on every run. Each restore also refreshes the
entry's last access date, so a dead ABI generation stays fresh forever and
GitHub's least recently used eviction can never reach it.

Name the scope in the key after the platform, target and toolchain it belongs
to, so a leg restores only its own archives. macOS 14, 15 and 26 on the same
Xcode build identical binaries when they share the lowest deployment target,
so point all three at 14 and let them share one scope instead of three.

A pull request cannot write the shared cache either. Its entries are scoped to
refs/pull/<n>/merge, so nothing it saves is ever readable again, and letting
it write anyway only burns the repository's quota, which is what the previous
attempt at this did. Keep the build read-only and hand the archives it had to
build itself to a new workflow running in the default branch scope, which
saves them where master can restore them. Forks stay read-only, they are the
one case whose archives must not be trusted.

Because the prefix changes every key, add a job that deletes the entries of
retired scopes, which is the one-time clean up of the current flat namespace,
and the per-run ones pull requests leave behind, which age based eviction
also never reaches.

Also pin the Linux ccache cache to its own subdirectory, since the directory
is shared with the docker build context, and drop the clang-format install
from the build images, which format nothing at runtime.

* [CI] Create the vcpkg cache directory when a scope is still empty

* [CI] Let a pull request write the vcpkg archives it builds

* [CI] Upload the vcpkg handover as one archive instead of loose files

* [CI] Hand the vcpkg cache over from one leg per scope

* [CI] Gate vcpkg cache writes to master pushes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-14 08:14:16 +02:00
BruebachL
cce606764c
[Server] Resolve a zone dump request to an in-range index span (#7401)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Command_DumpZone.number_cards is an unbounded sint32 supplied by the client, and
cmdDumpZone used it as an index without ever putting it in range.

    for (int i = 0; (i < cards.size()) && (i < numberCards || numberCards == -1); ++i) {
        const auto &findId = cmd.is_reversed() ? cards.size() - numberCards + i : i;
        Server_Card *card = cards[findId];

A forward request happened to be safe, because the loop stopped at the zone's own size.
A reversed one was not. number_cards is documented by the client as -1 for "the whole
zone", and cards.size() - numberCards is therefore cardCount + 1, one past the last card.
A request larger than the zone puts the offset at a negative index instead. Both read
out of bounds, then dereference the result as a Server_Card, so either adjacent heap
memory or a crash.

Any player can reach it: the zone only has to be a public one, or the requester's own,
and both number_cards and is_reversed come straight off the wire. A reversed dump of -1
is enough on any public zone with cards in it.

Resolve the request to a half-open span up front. -1 and any positive count are clamped
to what the zone actually holds, any other negative count resolves to nothing, and a
reversed request takes the span from the end rather than computing an offset from a value
that may not be in range. cards.at() then carries the bound as a second line of defence,
and qsizetype keeps the loop and the list's own indexing type aligned.

Also report card_count as the zone's real size. It was
numberCards < cards.size() ? cards.size() : numberCards, which agrees with the zone size
for every request that was in range and reported the client's own oversized number
otherwise. The client uses card_count to draw placeholders when a zone lists no cards, so
overstating it invents cards that were never there.

Put the resolution in its own translation unit. server_abstract_player.cpp refers to the
RNG global that only servatrice's main.cpp defines, so anything linking that object needs
the whole server binary behind it, which is why no test could reach this code before.

server_dump_zone_test covers each request shape and then asserts the span is contained in
[0, cardCount) across the edges of the request domain, INT_MIN and INT_MAX included.
Reverting the arithmetic makes three of them fail, the two out-of-bounds shapes and the
containment sweep.

The client's own copy of this convention at view_zone.cpp:93 already clamps the way this
now does. Leaving that duplication for a follow-up, since the client reaching the same
answer by a different route is not a defect.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-13 11:23:13 +02:00
BruebachL
24874b10cf
[GameSelector] Persist quick-filter toolbar edits and repair filter settings migration (#7500)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [GameSelector] Persist quick-filter toolbar edits and repair filter settings migration

- Save game-filter parameters on toolbar changes, debounced with flush on close
- Repair pre-camelCase gamefilters.ini keys in place on startup
- Map the legacy creator-name filter to hostNameFilter
- Balance begin/endFilterChange in GamesProxyModel::refresh
- Align the hideIgnoredUserGames default between model and settings
- Cover the migration with regression tests

* [Review] Address RickyRister comments on filter persistence

- Rename game-type prefix constants to SCREAMING_SNAKE to match
  MIGRATION_SENTINEL_KEY and project style
- Document why GameSelector destructor flushes a pending debounced
  filter save (toolbar path has no other writer)

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-13 07:45:08 +02:00
BruebachL
a712553c81
[CardDB] Reload after the first run enables all sets without freezing startup (#7470)
* [CardDB] Reload when the first run enables all sets

checkUnknownSets() enabled every set when none was enabled, but the
database had already been parsed under that empty enablement, so the
parsers had stripped every printing. It only notified the models and never
reloaded, leaving the deck editor's card database display empty until the
next launch.

Route the all-sets-enabled case through the same reload the new-sets path
uses, and skip caching a snapshot whose sets are all disabled.

* [CardDB] Own checkUnknownSets after startup and fix the cache skip

The loader's initialLoadComplete guard skipped checkUnknownSets() on the
first successful load of the session, so a download that landed after
startupConfigCheck() and the onboarding wizard had already run (e.g.
skipping the wizard download and fetching later from the menu) left every
set disabled and the deck editor blank until the next launch. The guard
also let the check run on the worker before the queued snapshot swap, and
alongside reloadCardDatabasesAndNotify()'s own check, double-emitting
cardDatabaseNewSetsFound on manual reloads.

Move the check to the callers that load after startup: the post-update,
custom-import, and spoiler paths run it via a QFutureWatcher once the
finished snapshot has been swapped in, and the loader's synchronous
path-change reload runs it directly.

Judge the cache skip by the parsed sets' own enablement, what the parsers
actually saw, instead of getEnabledSetNames(), which reports empty for the
noop controller even though it enables every set. Wait for the first-run
reparse before opening Manage Sets so the dialog cannot bind to the
pre-reload set instances.

* [CardDB] Queue post-reload check and unblock first-run Manage Sets

reloadCardDatabasesAndNotify() no longer emits cardDatabaseEnabledSetsChanged after the swap's Reset already rebuilt the models (second 36k rebuild froze startup), and its follow-up checkUnknownSets() is queued to the GUI thread so it sees swapped-in data. cardDatabaseAllNewSetsEnabled() chains Welcome/Manage Sets off a QFutureWatcher instead of waitForFinished() blocking the event loop during the reparse.

* [CardDB] Batch the first-run enable-all into a single settings flush

checkUnknownSets() enabled every set with ~3 QSettings rewrite cycles per set (sort key, known, enabled), and each setValue re-opens and re-syncs the whole cardDatabase.ini. With a full database (874 sets) that froze the GUI thread for ~9s after the database download (LagMonitor: event loop stalled for 9129 ms, measured offscreen with a real 59MB cards.xml), reading as a total application hang that recovers on its own.

Persist the enable-all through the controller's batched saveSets() in a single flush instead, mirroring what SetsModel::save() already does: CardSetList::enableAllBatched() assigns fresh sort keys and marks every set known+enabled in memory, then writes all records at once. SetSaveData gains the isKnown flag so the batch covers all three options, and SetsModel::save() passes the sets' known state through (unchanged behavior there).

Also drop the cardDatabaseEnabledSetsChanged emission from the all-sets-enabled branch: the reload chained off cardDatabaseAllNewSetsEnabled rebuilds the models from the re-parsed snapshot via cardDatabaseReset, so emitting both rebuilt the 36k-card models twice on the GUI thread.

Adds a cache-invalidation test driving the real checkUnknownSets() first-run path.

* [CardDB] Batch the new-sets-found enable-all the same way

The first-run batching fixed the 874-set case, but the new-sets-found dialog path (enableAllUnknownSets/markAllSetsAsKnown) still wrote per set and per option, each setValue re-opening and re-syncing the whole cardDatabase.ini. Users with hundreds of custom sets hit the same multi-second GUI stall when answering the dialog.

Persist both through the controller's batched saveSets() in a single flush, writing only changed sets (so the common no-op check skips the flush entirely). Removes the now-unused write-through CardSetList methods (enableAll, enableAllUnknown, markAllAsKnown, guessSortKeys) so the batched flush is the only way to persist bulk set changes.

Extends the test controller mock with per-write/batched-flush counters and adds tests asserting both dialog paths persist with exactly one flush and zero individual writes.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-13 06:29:16 +02:00
BruebachL
bac2d285e3
[Versioning] Bump version number for 3.1.2 beta release. (#7501)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-12 21:14:29 +02:00
BruebachL
96b819d7c8
Support tab-separated deck imports (#4222) (#7267)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Third-party deck tools (e.g. Delver Lens) export decks as tab-separated rows
such as "2	Card Name	Set Name". Card names never contain tab characters,
so a leading numeric column marks a tabular record: the first two columns are read
as the quantity and card name, and any further columns are discarded as
printer/set metadata.

- deck_list: parse tab-delimited rows before whitespace collapsing
- tests: cover tab-separated mainboard and sideboard imports
- docs: document the accepted plain-text import format (fills the TODO)

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 21:19:10 +02:00
BruebachL
8968709642
[SMTP] Add reliable email delivery with retry and database-backed queue (#7240)
* [SMTP] Add reliable email delivery with retry and database-backed queue

Rework the SMTP client from fire-and-forget sending into a persistent
queue with exponential backoff and per-message retry budget, backed by
the existing database rows as the durable source of truth.

- Keep pending emails in an in-memory queue with attempts/backoff
  tracking; enqueue without deleting database rows, and only remove the
  row once delivery is confirmed via the mailDelivered signal.
- Add exponential backoff (doubling, capped at 30 min) between delivery
  attempts, bounded by new smtp/maxretries and smtp/retrydelay settings.
- Track connection-layer failures (connect/auth/encrypt) with their own
  backoff; route failures to recordFailed/recordConnectionFailure so
  undeliverable mail eventually drops from the queue instead of
  retrying forever.
- Treat rejected recipients/senders as permanent failures.
- Rebuild the SMTP buffer from the authoritative queue before each send
  via a new QxtSmtp::reset() so reconnects never duplicate deliveries.
- Start the idle timeout after authentication, not on TCP connect, so
  slow SSL/STARTTLS handshakes are not cut short.

* [SMTP] Fix retry accounting, backoff reset, and token dedupe

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 20:59:32 +02:00
BruebachL
1a4d89ff8f
[Game] Don't alert game tab for spectator activity (#1848) (#7263)
Spectator join/leave events were triggering the game tab's attention
indicator (exclamation mark and taskbar alert) for players who often
leave the tab open while waiting for an opponent. This made spectator
activity a false alarm.

Only emit the user event when an actual player joins or leaves, keeping
spectator joins/leaves quiet.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 16:54:03 +02:00
BruebachL
4feb31bd1a
[Game] Show loaded deck messages to players joining a game (#7268)
* Show loaded deck messages to players joining a game (#552)

The "Player X has loaded a deck" message is only broadcast when a deck is
selected, so players and spectators who join a game afterwards never see it.

On game join, replay a deck-select event context privately to the joining
participant for every player that already has a deck loaded, reusing the
existing Event_PlayerPropertiesChanged + Context_DeckSelect mechanism so no
protocol version bump is needed.

- Server_AbstractPlayer: expose the sideboard-locked state
- Server_Game: synthesize deck-select contexts in createGameJoinedEvent

* [Server] Skip deck replay on rejoin and after start

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 16:53:26 +02:00
BruebachL
4c5a5e3e55
[Game] Invalidate view when hiding replaced card menu (#7453)
* [Game] Invalidate view when hiding replaced card menu

Hide any visible card menu before replacing and invalidate viewport.

* [Game] Fix viewport update compile error in setCardMenu

QWidget::update has no QRectF overload, and sceneRect is in scene
coordinates rather than viewport coordinates anyway, so invalidate the
whole viewport instead.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:36:11 +02:00
BruebachL
5d06bb903d
[Chat] Fix card completion after an @ mention (#7469)
An open [[ card link now takes precedence over a space-preceded @
mention, so typing an @ before a card no longer shadows the card
completer.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:35:10 +02:00
BruebachL
57a6a0d9cb
[Client] Warn when data paths point inside the program directory (#7473)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:34:49 +02:00
BruebachL
f83a61979f
[Installer] Uninstaller deletes only the installed payload, never the whole tree (#7472)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:34:49 +02:00
BruebachL
07eb9f0103
[Report] Prompt for a note on Resolve and drop the redundant option (#7445)
- Remove the third close option (Resolve with note...) so closing a
  report is one of two clear actions
- Make Resolve always prompt for the optional resolution note, matching
  Dismiss, and rename the button to Resolve...
- Halve the resolveReport signature now that every caller prompts

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:33:48 +02:00
BruebachL
38acddeede
[Report] Add context menu to report queue rows (#7444)
* [Report] Add report reopen action for closed reports

- New Command_ReportReopen moderator command (extension 1206) with server
  handler that reopens resolved/dismissed reports, resets resolution state
  and notified, and records the reopening note in the audit log
- Reopen... button in the report queue action bar, gated to resolved and
  dismissed reports, with optional note prompt and tooltip
- Success confirmations for Assign, Resolve/Dismiss and Reopen now survive
  the list refresh via a pending status message instead of being clobbered
- Dispatch/auth coverage in server_moderator_role_test

* [Report] Surface resolution audit info in report details

- Add resolved_by_name to ServerInfo_Report and join resolved_by users in
  the report list, my-list and details queries
- Render resolution note and resolved-at/by in the report detail pane
  instead of duplicating the description into the chat log context

* [Report] Add context menu to report queue rows

- Open the standard UserContextMenu on right-click/Menu key for the
  reported user, selecting the row first so the details panel matches
  the menu target
- Carry the reported user's level in ServerInfo_Report so the menu
  shows the same actions as the user list, with an IsUser fallback for
  deleted or unregistered users
- Wire the Private chat action through TabReport to TabSupervisor and
  retranslate the menu on live language switches

* [Server] Fix reported_user_level column indices in report queries

The SELECT lists resolved_by_name at index 10, shifting room_id to 11,
replay_id to 12, reported_user_id to 13 and reported_user_admin to 14.
The code read 12/13, so 'user exists' meant 'a replay exists' and the
admin flags came from the user id (e.g. id 5 -> admin+judge; users
without a replay reported as unregistered, hiding registered-only
actions). Fixed in cmdReportList and cmdReportMyList, plus the column
comment.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:33:47 +02:00
BruebachL
74a6acde91
[Report] Surface resolution audit info in report details (#7443)
- Add resolved_by_name to ServerInfo_Report and join resolved_by users in
  the report list, my-list and details queries
- Render resolution note and resolved-at/by in the report detail pane
  instead of duplicating the description into the chat log context

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:29:09 +02:00
BruebachL
4a010bd17f
[Report] Add report reopen action for closed reports (#7442)
- New Command_ReportReopen moderator command (extension 1206) with server
  handler that reopens resolved/dismissed reports, resets resolution state
  and notified, and records the reopening note in the audit log
- Reopen... button in the report queue action bar, gated to resolved and
  dismissed reports, with optional note prompt and tooltip
- Success confirmations for Assign, Resolve/Dismiss and Reopen now survive
  the list refresh via a pending status message instead of being clobbered
- Dispatch/auth coverage in server_moderator_role_test

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:28:45 +02:00
BruebachL
315cbae345
[Build] Extend PCH to libraries and grow IWYU-driven header sets (#7456)
The executables precompile Qt since #7235, but the libcockatrice_*
static libraries still reparse the full Qt Core stack in every TU.
Measured over the tree after the IWYU pass: 380 MB of header parse in
the libraries, 92% of it reachable from qtcore_pch.h.

- Apply qtcore_pch.h to the 14 Qt Core static library targets
  (skips libcockatrice_rng: its SFMT.c is C and cannot use a C++
  precompiled header).
- Grow qtcore_pch.h with 25 QtCore headers that IWYU shows are
  re-parsed across many TUs and were not already reachable from the
  existing list (JSON/QUuid cluster, QSettings, item models,
  threading, XmlStream, QTemporaryDir, ...).
- Grow qtwidgets_pch.h with 30 QtGui/QtWidgets headers (dialog
  cluster, item views, QImageReader, ...).

Only stable Qt headers go into the PCH, so it rarely invalidates.
Test executables stay PCH-less: 1-3 TUs per target does not amortize
the extra precompile.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:07:30 +02:00
BruebachL
bdda3dedfa
[CI] Publish a gcovr line-coverage report from the test run (#7404)
* [Tests] Register each test case with CTest instead of each test binary

Registering a test cost four edits across a dozen CMakeLists files: add_test,
add_executable, a conditional add_dependencies on gtest, and target_link_libraries.
The conditional branch was the trap -- it is only needed when GTest was fetched and
add_subdirectory'd, and omitting it yields undefined gtest symbols at link time --
so it was easy to leave out and impossible to notice until the link failed.

Fold all four steps into cockatrice_add_test() and migrate every test directory
onto it, with a cockatrice_add_manual_test() wrapper for the three benchmarks that
are built but deliberately kept out of ctest. The calls now read as a declaration
of what a test is rather than a transcript of how to register one.

While rewriting the registrations, replace add_test(NAME x COMMAND x) with
gtest_discover_tests(). Naming the binary as a single CTest entry meant a CI failure
reported only which binary failed, and ctest -R Suite.Case could not select an
individual case. Discovery raises the suite from 35 opaque entries to 469 real ones:
ctest -R ThemeMigrationTest.IsIdempotent now runs exactly that case, and
ctest -L server / -LE bench work off labels applied at registration.

Give every test a 60s TIMEOUT. CTest's implicit default is 1500s, so one hung
socket wait would stall CI for 25 minutes, and single_instance_manager_test already
does real QLocalSocket waits.

Dropping per-case granularity costs wall-clock time, since the suite becomes
process-spawn bound rather than CPU bound: serial ctest went from 3.8s to 30.8s. Run
it with -j in CI to recover it -- 7.6s at -j4, and 3.5s at -j8, both at or below
the old serial time. The binaries are parallel-safe: the only test claiming a fixed
resource scopes its local socket name to its own pid, so concurrent copies cannot
collide.

Also delete dummy_test, which asserted 1 == 1 and only proved the harness was
wired up -- something the other 468 cases already establish.

No test is added, removed, or changed by this commit; the 469 cases are the same
ones the 35 binaries contained.

* [Tests] Make QtTest available to test targets

QtTest was not among the modules test targets could link, so the Qt model and
signal contracts had no way to be verified from a test: QAbstractItemModelTester
and QSignalSpy both live there.

Add Test to _TEST_NEEDED in cmake/FindQtRuntime.cmake. That is the single place the
union of test-only Qt modules is declared, so this is the only file that needs to
change to make the module reachable from every test target.

Nothing uses it yet. This lands the capability ahead of the model-layer tests that
need it, so those can be added as tests alone rather than as test plus build-system
plumbing.

* [Tests] Correct the note on the oracle test compression defines

add_definitions() populates the directory's COMPILE_DEFINITIONS property, which is
consumed at generate time, so it reaches every target in the directory no matter
where it is called relative to add_executable(). The previous note claimed the
flags stayed confined to the benchmark because the find_package block came after
it; oracle_importer_test, created above that block, carries both defines too.

Verified harmless rather than a live bug: HAS_ZLIB and HAS_LZMA gate code only in
oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, and no test target compiles
either. The ordering and the leaked defines are both pre-existing, so this only
corrects the explanation.

* [Tests] Carry one CTest label per test instead of several

gtest_discover_tests() can hand CTest exactly one label. GoogleTestAddTests.cmake
accumulates its generated script with `foreach(arg ${ARGN})`, so a label list reaches
the CTestTestfile as bare `LABELS ci server`, and CTest reads only the first value of a
property and drops the rest without a word.

The `ci` label that is appended to every registered test was therefore absent from every
test that also asked for `server` or `bench`: `ctest -L ci` reported 400 of 469 cases,
and `ctest --print-labels` never listed a second label anywhere in the build.

Reduce each test to exactly one of `ci`, `server`, or `bench`, which is all the
selections need. The three now partition the suite exactly, 400 + 64 + 5 = 469, and
`ctest -LE bench` still means everything but benchmarks. A second label is now a
configure-time FATAL_ERROR rather than a line that quietly means something else.

Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it
composes with the group instead of competing for the single slot.

Also raise DISCOVERY_TIMEOUT off GoogleTestAddTests' 5s default. A plain debug binary
answers --gtest_list_tests in 0.04s, but that budget is thin once several binaries
discover concurrently behind a -j build, and being over it fails at the *link* step with
a bare "Process terminated due to timeout" that points nowhere near the cause.

* [Tests] Link gtest_main on the fetched-gtest path

The downloaded-gtest branch set GTEST_BOTH_LIBRARIES to `gtest` alone, while
FindGTest sets it to both halves. Every test target links that list rather than
picking an entry point, so on this path nothing provides main() and any test
source that does not define its own fails at link with `undefined reference to
main` pointing at crt1.o, naming neither gtest nor the line responsible.

That stayed latent while every test carried its own four-line main(). A test that
omits it now fails, and the error points at the C runtime rather than at gtest,
which is a long way from the cause. Restoring both halves makes the two paths
agree and drops the boilerplate requirement on new test sources.

The 18 sources that still define main() keep linking: gtest_main.cc defines
nothing but main, so the linker leaves its object in the archive unused rather
than reporting a duplicate.

* [Build] Add an ENABLE_SANITIZERS option for ASan and UBSan

Adds -DENABLE_SANITIZERS, off by default, building with AddressSanitizer and
UndefinedBehaviorSanitizer. The bulk of the work is in the three ways the obvious
implementation silently does the wrong thing.

Probe the flags on the link step, not just the compile step. check_cxx_compiler_flag()
only compiles, so every sanitizer flag comes back unsupported, because linking an ASan
object without -fsanitize=address leaves __asan_init undefined. The result is not a
build failure but a build that appears instrumented and is not, which is worse. Use
check_cxx_source_compiles() with CMAKE_REQUIRED_LINK_OPTIONS instead, and treat an
unsupported sanitizer pair as fatal since the cosmetic flags are allowed to degrade.

Opt vendored sources out per source, through a cockatrice_disable_sanitizers_for()
helper rather than a list of set_source_files_properties() calls at the root. The
helper has to exist because CMake resolves a source-file property against the directory
scope its target was declared in: setting the property from the root with absolute paths
is a no-op, silently, which is why the call sites sit next to the add_library and
add_executable that pull the vendored code in. SFMT, minizip, the LZMA SDK and Qxt SMTP
are excluded. oracle/src/qt-json is in the history but not in the tree, removed in
#7214.

Also mark those sources SKIP_PRECOMPILE_HEADERS. The PCH 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, which is a fatal
-Werror=invalid-pch in the servatrice and oracle builds.

-fno-sanitize=vptr is not optional. Qt constructs QObject subclasses into raw storage
and writes the vptr afterwards, so the vptr check reports on essentially every QObject
instantiation, and it has no value here because nothing outside moc-generated code
inherits QObject.

Verified on the resulting build: our own translation units carry ASan and UBSan calls,
SFMT.o carries neither, and the full suite of 464 cases passes clean under
ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 with UBSAN_OPTIONS=halt_on_error=0.

The test suite needs two adjustments to make this option usable at all, both of which
land here because they are consequences of COCKATRICE_SANITIZERS existing rather than
independent changes.

DISCOVERY_TIMEOUT rises from 60s to 300s under sanitizers. An instrumented binary takes
2.8s to answer --gtest_list_tests against 0.04s uninstrumented, and discovery runs
several binaries at once behind a -j build.

One target is excluded via a NO_SANITIZE flag, server_developer_role_test. Reaching
google::protobuf::TextFormat::Printer's destructor aborts on an absl assertion inside
system protobuf 33.4 whenever the consumer translation unit is built without NDEBUG,
which a Debug sanitizer build is. This reproduces with no Cockatrice code at all, just
"TextFormat::Printer p; p.PrintToString(msg, &out);" built with -fsanitize=address, so it
is upstream rather than ours. It is a flag and not a fourth label because "skip this in
the sanitizer leg" has to compose with a test's group rather than compete for the single
label slot, and because a flag excludes by not registering the cases at all, which
cannot rot the way a label silently can. The target still builds and still runs on the
legs without sanitizers.

* [CI] Run the suite once under ASan and UBSan

Adds --sanitize to .ci/compile.sh, mirroring --test, and turns it on for one entry of
the existing Linux Docker matrix: Ubuntu 26.04, which already had a test-enabled leg.
That reuses the image, the ccache layer and the existing step shape, so the CI diff is a
matrix key plus the argument plumbing. Debian 13 and Fedora 44 keep covering the
uninstrumented suite, so nothing is lost by instrumenting one leg.

Sanitizers stay off the release and package legs, which build with --release for
packaging: instrumenting those would change the shipped binaries' optimization profile
and build time for no benefit. macOS and Windows stay uninstrumented too, since ASan is
unreliable against the Qt build there and MSVC has no equivalent without /fsanitize=address.

--sanitize does not imply --test, so an instrumented build can be produced locally
without paying for ctest. When both are given, ctest runs with detect_leaks=0 because Qt
and the sanitizer runtimes both leak at exit by design and LeakSanitizer's default buries
real findings under hundreds of process-teardown reports, and with UBSan halt_on_error=0 so
one run collects many findings instead of stopping at the first. ASan keeps
abort_on_error=1, since a bad access leaves the process unusable and continuing only
produces a second, more confusing report from the wreckage.

* [CI] Publish a gcovr line-coverage report from the test run

Sanitizers tell us what a run did wrong; they say nothing about what it never
reached. This adds the other half: an instrumented build whose report says which
of our own lines no test executes.

Add an ENABLE_COVERAGE option that compiles with --coverage -O0 -g, mirroring the
ENABLE_SANITIZERS opt-in including the per-source carve-out that keeps vendored
trees out of the denominator. -O0 is deliberate: at higher optimisation levels GCC
merges and relocates lines, so the report would describe code that no longer exists
in that form. Only GCC is supported, since Clang's intermediate format needs
llvm-cov; asking for coverage under Clang is a hard error rather than a build that
silently produces unreadable .gcno files.

Keep the filters in a committed gcovr.cfg rather than in the invocation. The tests
themselves are excluded because they are compiled with the same instrumentation
and are only ever executed by the run doing the measuring, so leaving them in would
report a healthier project than we have. The process entry points and the client's
window chrome go too: each needs a display, an interactive terminal or a live
database before it does anything a headless test could observe, so they would
otherwise sit at 0% forever and bury real movement.

Give coverage its own matrix leg rather than a second flag on the sanitizer leg.
The two answer different questions, and combining them slows both down without
telling us more. The new variant key gives the leg its own Docker image and ccache
entry so the two legs cannot race on the same tag.

The leg publishes an HTML report with per-file pages, LCOV, Cobertura XML and a
JSON summary. gcovr is a report-time dependency rather than a build dependency, so
a missing one warns and skips instead of failing an otherwise green build.

* [CI] Resolve the coverage source root before the build dir cd

The previous commit computed SRC_ROOT after `cd "$BUILD_DIR"`, under a comment
claiming it ran before. `$0` is relative to the directory the script was invoked
from, so from inside the build dir `dirname "$0"` no longer resolves and the
subshell's `cd` fails. Under `set -e` that is fatal rather than an empty variable,
which took down every leg in the matrix, coverage or not, including the ones this
change does not otherwise touch.

Move it above the cd and guard it on MAKE_COVERAGE, since the coverage report is
the only consumer. Resolving it unconditionally meant a coverage-only detail was
paid for by every build in the fleet, and gave every one of them a way to fail.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:05:28 +02:00
BruebachL
8822ade49d
[Tests] Register each test case with CTest instead of each test binary (#7399)
* [Tests] Register each test case with CTest instead of each test binary

Registering a test cost four edits across a dozen CMakeLists files: add_test,
add_executable, a conditional add_dependencies on gtest, and target_link_libraries.
The conditional branch was the trap -- it is only needed when GTest was fetched and
add_subdirectory'd, and omitting it yields undefined gtest symbols at link time --
so it was easy to leave out and impossible to notice until the link failed.

Fold all four steps into cockatrice_add_test() and migrate every test directory
onto it, with a cockatrice_add_manual_test() wrapper for the three benchmarks that
are built but deliberately kept out of ctest. The calls now read as a declaration
of what a test is rather than a transcript of how to register one.

While rewriting the registrations, replace add_test(NAME x COMMAND x) with
gtest_discover_tests(). Naming the binary as a single CTest entry meant a CI failure
reported only which binary failed, and ctest -R Suite.Case could not select an
individual case. Discovery raises the suite from 35 opaque entries to 469 real ones:
ctest -R ThemeMigrationTest.IsIdempotent now runs exactly that case, and
ctest -L server / -LE bench work off labels applied at registration.

Give every test a 60s TIMEOUT. CTest's implicit default is 1500s, so one hung
socket wait would stall CI for 25 minutes, and single_instance_manager_test already
does real QLocalSocket waits.

Dropping per-case granularity costs wall-clock time, since the suite becomes
process-spawn bound rather than CPU bound: serial ctest went from 3.8s to 30.8s. Run
it with -j in CI to recover it -- 7.6s at -j4, and 3.5s at -j8, both at or below
the old serial time. The binaries are parallel-safe: the only test claiming a fixed
resource scopes its local socket name to its own pid, so concurrent copies cannot
collide.

Also delete dummy_test, which asserted 1 == 1 and only proved the harness was
wired up -- something the other 468 cases already establish.

No test is added, removed, or changed by this commit; the 469 cases are the same
ones the 35 binaries contained.

* [Tests] Make QtTest available to test targets

QtTest was not among the modules test targets could link, so the Qt model and
signal contracts had no way to be verified from a test: QAbstractItemModelTester
and QSignalSpy both live there.

Add Test to _TEST_NEEDED in cmake/FindQtRuntime.cmake. That is the single place the
union of test-only Qt modules is declared, so this is the only file that needs to
change to make the module reachable from every test target.

Nothing uses it yet. This lands the capability ahead of the model-layer tests that
need it, so those can be added as tests alone rather than as test plus build-system
plumbing.

* [Tests] Correct the note on the oracle test compression defines

add_definitions() populates the directory's COMPILE_DEFINITIONS property, which is
consumed at generate time, so it reaches every target in the directory no matter
where it is called relative to add_executable(). The previous note claimed the
flags stayed confined to the benchmark because the find_package block came after
it; oracle_importer_test, created above that block, carries both defines too.

Verified harmless rather than a live bug: HAS_ZLIB and HAS_LZMA gate code only in
oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, and no test target compiles
either. The ordering and the leaked defines are both pre-existing, so this only
corrects the explanation.

* [Tests] Carry one CTest label per test instead of several

gtest_discover_tests() can hand CTest exactly one label. GoogleTestAddTests.cmake
accumulates its generated script with `foreach(arg ${ARGN})`, so a label list reaches
the CTestTestfile as bare `LABELS ci server`, and CTest reads only the first value of a
property and drops the rest without a word.

The `ci` label that is appended to every registered test was therefore absent from every
test that also asked for `server` or `bench`: `ctest -L ci` reported 400 of 469 cases,
and `ctest --print-labels` never listed a second label anywhere in the build.

Reduce each test to exactly one of `ci`, `server`, or `bench`, which is all the
selections need. The three now partition the suite exactly, 400 + 64 + 5 = 469, and
`ctest -LE bench` still means everything but benchmarks. A second label is now a
configure-time FATAL_ERROR rather than a line that quietly means something else.

Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it
composes with the group instead of competing for the single slot.

Also raise DISCOVERY_TIMEOUT off GoogleTestAddTests' 5s default. A plain debug binary
answers --gtest_list_tests in 0.04s, but that budget is thin once several binaries
discover concurrently behind a -j build, and being over it fails at the *link* step with
a bare "Process terminated due to timeout" that points nowhere near the cause.

* [Tests] Link gtest_main on the fetched-gtest path

The downloaded-gtest branch set GTEST_BOTH_LIBRARIES to `gtest` alone, while
FindGTest sets it to both halves. Every test target links that list rather than
picking an entry point, so on this path nothing provides main() and any test
source that does not define its own fails at link with `undefined reference to
main` pointing at crt1.o, naming neither gtest nor the line responsible.

That stayed latent while every test carried its own four-line main(). A test that
omits it now fails, and the error points at the C runtime rather than at gtest,
which is a long way from the cause. Restoring both halves makes the two paths
agree and drops the boilerplate requirement on new test sources.

The 18 sources that still define main() keep linking: gtest_main.cc defines
nothing but main, so the linker leaves its object in the archive unused rather
than reporting a duplicate.

* [Build] Run the test suite under ASan and UBSan (#7400)

* [Build] Add an ENABLE_SANITIZERS option for ASan and UBSan

Adds -DENABLE_SANITIZERS, off by default, building with AddressSanitizer and
UndefinedBehaviorSanitizer. The bulk of the work is in the three ways the obvious
implementation silently does the wrong thing.

Probe the flags on the link step, not just the compile step. check_cxx_compiler_flag()
only compiles, so every sanitizer flag comes back unsupported, because linking an ASan
object without -fsanitize=address leaves __asan_init undefined. The result is not a
build failure but a build that appears instrumented and is not, which is worse. Use
check_cxx_source_compiles() with CMAKE_REQUIRED_LINK_OPTIONS instead, and treat an
unsupported sanitizer pair as fatal since the cosmetic flags are allowed to degrade.

Opt vendored sources out per source, through a cockatrice_disable_sanitizers_for()
helper rather than a list of set_source_files_properties() calls at the root. The
helper has to exist because CMake resolves a source-file property against the directory
scope its target was declared in: setting the property from the root with absolute paths
is a no-op, silently, which is why the call sites sit next to the add_library and
add_executable that pull the vendored code in. SFMT, minizip, the LZMA SDK and Qxt SMTP
are excluded. oracle/src/qt-json is in the history but not in the tree, removed in
#7214.

Also mark those sources SKIP_PRECOMPILE_HEADERS. The PCH 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, which is a fatal
-Werror=invalid-pch in the servatrice and oracle builds.

-fno-sanitize=vptr is not optional. Qt constructs QObject subclasses into raw storage
and writes the vptr afterwards, so the vptr check reports on essentially every QObject
instantiation, and it has no value here because nothing outside moc-generated code
inherits QObject.

Verified on the resulting build: our own translation units carry ASan and UBSan calls,
SFMT.o carries neither, and the full suite of 464 cases passes clean under
ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 with UBSAN_OPTIONS=halt_on_error=0.

The test suite needs two adjustments to make this option usable at all, both of which
land here because they are consequences of COCKATRICE_SANITIZERS existing rather than
independent changes.

DISCOVERY_TIMEOUT rises from 60s to 300s under sanitizers. An instrumented binary takes
2.8s to answer --gtest_list_tests against 0.04s uninstrumented, and discovery runs
several binaries at once behind a -j build.

One target is excluded via a NO_SANITIZE flag, server_developer_role_test. Reaching
google::protobuf::TextFormat::Printer's destructor aborts on an absl assertion inside
system protobuf 33.4 whenever the consumer translation unit is built without NDEBUG,
which a Debug sanitizer build is. This reproduces with no Cockatrice code at all, just
"TextFormat::Printer p; p.PrintToString(msg, &out);" built with -fsanitize=address, so it
is upstream rather than ours. It is a flag and not a fourth label because "skip this in
the sanitizer leg" has to compose with a test's group rather than compete for the single
label slot, and because a flag excludes by not registering the cases at all, which
cannot rot the way a label silently can. The target still builds and still runs on the
legs without sanitizers.

* [CI] Run the suite once under ASan and UBSan

Adds --sanitize to .ci/compile.sh, mirroring --test, and turns it on for one entry of
the existing Linux Docker matrix: Ubuntu 26.04, which already had a test-enabled leg.
That reuses the image, the ccache layer and the existing step shape, so the CI diff is a
matrix key plus the argument plumbing. Debian 13 and Fedora 44 keep covering the
uninstrumented suite, so nothing is lost by instrumenting one leg.

Sanitizers stay off the release and package legs, which build with --release for
packaging: instrumenting those would change the shipped binaries' optimization profile
and build time for no benefit. macOS and Windows stay uninstrumented too, since ASan is
unreliable against the Qt build there and MSVC has no equivalent without /fsanitize=address.

--sanitize does not imply --test, so an instrumented build can be produced locally
without paying for ctest. When both are given, ctest runs with detect_leaks=0 because Qt
and the sanitizer runtimes both leak at exit by design and LeakSanitizer's default buries
real findings under hundreds of process-teardown reports, and with UBSan halt_on_error=0 so
one run collects many findings instead of stopping at the first. ASan keeps
abort_on_error=1, since a bad access leaves the process unusable and continuing only
produces a second, more confusing report from the wreckage.

* [Build] Fix sanitizer flag leak into release package and make UBSan fail CI

- compile.sh always passes -DENABLE_SANITIZERS explicitly so the
  release-package reconfigure in the same build dir cannot inherit ON
  from the sanitized debug step (which shipped libasan/libubsan DEB deps).
- Sanitizers.cmake resets COCKATRICE_SANITIZERS to OFF when the option is
  off (CACHE INTERNAL otherwise sticks forever, keeping NO_SANITIZE skips
  and the 300s discovery timeout).
- UBSan now fails the job: -fno-sanitize-recover=undefined at compile time
  plus UBSAN_OPTIONS halt_on_error=1 (recover mode exits 0, so the old
  halt_on_error=0 could never fail).

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 14:50:09 +02:00
135 changed files with 5147 additions and 1337 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,14 +8,18 @@
# --suffix <suffix> renames package with this suffix, requires arg
# --server compiles servatrice
# --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
# --ccache [<size>] uses ccache and shows stats, optionally provide size
# --evict-ccache <age> runs ccache eviction based on given age after build
# --dir <dir> sets the name of the build dir, default is "build"
# --cmake-generator <generator> sets CMAKE_GENERATOR as used by cmake
# --target-macos-version <version> sets the min os version - only used for macOS builds
# 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
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --ccache <ccache_size> --dir <dir>)
# --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 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 --sanitize --coverage --ccache <ccache_size> --dir <dir> --profile)
# exitcode: 1 for failure, 3 for invalid arguments
# Read arguments
@ -57,6 +61,14 @@ while [[ $# != 0 ]]; do
MAKE_TEST=1
shift
;;
'--sanitize')
MAKE_SANITIZE=1
shift
;;
'--coverage')
MAKE_COVERAGE=1
shift
;;
'--debug')
BUILDTYPE="Debug"
shift
@ -104,6 +116,10 @@ while [[ $# != 0 ]]; do
export CMAKE_GENERATOR=$1
shift
;;
'--profile')
BUILD_PROFILE=1
shift
;;
'--target-macos-version')
shift
if [[ $# == 0 ]]; then
@ -131,6 +147,15 @@ if [[ ! $BUILD_DIR ]]; then
BUILD_DIR="build"
fi
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"
# Set minimum CMake Version
@ -147,6 +172,17 @@ fi
if [[ $MAKE_TEST ]]; then
flags+=("-DTEST=1")
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
flags+=("-DUSE_CCACHE=1")
# PCH-aware caching is required or ccache refuses to cache any TU that
@ -178,6 +214,22 @@ function ccachestatsverbose() {
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
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,
@ -212,8 +264,14 @@ if [[ $RUNNER_OS == macOS ]]; then
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
fi
cp "$triplet_source" "$triplet_file"
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $TARGET_MACOS_VERSION)" >>"$triplet_file"
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $TARGET_MACOS_VERSION)" >>"$triplet_file"
# The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that
# agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode
# 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_HOST_TRIPLET=$triplet_version")
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
@ -256,9 +314,34 @@ if [[ $RUNNER_OS == macOS ]]; then
fi
elif [[ $RUNNER_OS == Windows ]]; then
# 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
buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true)
if [[ $CMAKE_GENERATOR == "Visual Studio"* ]]; then
# cmake only hands MSBuild a job count when one is asked for, and without it
# MSBuild builds a single node: one project at a time, whatever MTT does inside
# 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
if [[ $USE_CCACHE ]]; then
@ -274,10 +357,19 @@ cmake .. "${flags[@]}"
echo "::endgroup::"
echo "::group::Build project"
cpuinfo
echo "Running cmake --build with flags: ${buildflags[*]}"
cmake --build . "${buildflags[@]}"
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 [[ $CCACHE_EVICTION_AGE ]]; then
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
@ -306,7 +398,50 @@ fi
if [[ $MAKE_TEST ]]; then
echo "::group::Run tests"
ctest -C "$BUILDTYPE" --output-on-failure
if [[ $MAKE_SANITIZE ]]; then
# 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::"
fi

View file

@ -13,11 +13,17 @@
# --set-cache <location> sets the location to cache the image or for ccache
#
# requires: docker
# uses env: NAME CACHE BUILD GET SAVE INTERACTIVE
# uses env: NAME IMAGE_SUFFIX 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
# 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.
# note that the docker container will not inherit any environment variables!
#
@ -81,7 +87,7 @@ if ! [[ $NAME ]]; then
return 3
fi
export IMAGE_NAME="${project_name,,}_${NAME,,}" # lower case
export IMAGE_NAME="${project_name,,}_${NAME,,}${IMAGE_SUFFIX:+_${IMAGE_SUFFIX,,}}" # lower case
docker_dir=".ci/$NAME"
if ! [[ -r $docker_dir/Dockerfile ]]; then

View file

@ -144,17 +144,36 @@ jobs:
package: DEB
name: ${{ matrix.distro }} ${{ matrix.version }}
# The one leg that runs the suite under AddressSanitizer and
# 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
runs-on: ubuntu-latest # https://github.com/actions/runner-images
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
timeout-minutes: 70
env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache
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
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
steps:
- name: "Checkout"
@ -164,8 +183,8 @@ jobs:
id: restore_ccache
uses: actions/cache/restore@v6
with:
key: ccache-${{ matrix.distro }}${{ matrix.version }}
path: ${{ env.CACHE }}
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
path: ${{ env.CACHE }}/.ccache
- name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image"
shell: bash
@ -174,6 +193,9 @@ jobs:
- name: "Build debug and test"
if: matrix.test != 'skip'
shell: bash
env:
sanitize: '${{ matrix.sanitize }}'
coverage: '${{ matrix.coverage }}'
run: |
source .ci/docker.sh
args=()
@ -181,7 +203,33 @@ jobs:
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
RUN --server --debug --test "${args[@]}"
extra=()
[[ $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"
id: build
@ -219,7 +267,7 @@ jobs:
uses: actions/cache/save@v6
with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CACHE }}
path: ${{ env.CACHE }}/.ccache
- name: "Upload artifact"
id: upload_artifact
@ -277,6 +325,8 @@ jobs:
soc: Intel
type: Release
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"
- os: macOS
@ -293,6 +343,9 @@ jobs:
soc: Apple
type: Release
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"
- os: macOS
@ -309,6 +362,8 @@ jobs:
soc: Apple
type: Release
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6"
- os: macOS
@ -322,19 +377,30 @@ jobs:
soc: Apple
type: Debug
use_ccache: 1
vcpkg_deployment_target: 14
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
xcode: "26.6"
- os: Windows
target: 10
runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md
cmake_generator: "Visual Studio 18 2026"
cmake_generator_platform: x64
# Ninja builds the whole dependency graph at once instead of one
# MSBuild node at a time, runs the compiler itself, and matches the
# 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
package_suffix: "-Win10"
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
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' || '' }}
needs: configure
@ -428,12 +494,39 @@ jobs:
shell: bash
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"
id: vcpkg-cache
uses: TAServers/vcpkg-cache@v3
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 }}
# 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
- name: "Build Cockatrice"
id: build
@ -442,7 +535,6 @@ jobs:
BUILDTYPE: '${{ matrix.type }}'
CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }}
CMAKE_GENERATOR: ${{ matrix.cmake_generator }}
CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }}
DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
@ -453,9 +545,19 @@ jobs:
PACKAGE_SUFFIX: '${{ matrix.package_suffix }}'
TARGET_MACOS_VERSION: ${{ matrix.override_target }}
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_DISABLE_METRICS: 1
VCPKG_FEATURE_FLAGS: dependencygraph
VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }}
run: .ci/compile.sh --server --test --vcpkg
# CPack reports a failed makensis run as "Problem running NSIS command"
@ -469,6 +571,63 @@ jobs:
shell: bash
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
- 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
@ -544,6 +703,8 @@ jobs:
if-no-files-found: error
path: ${{ steps.build.outputs.path }}
# Ninja is single-config: binaries and their PDBs land directly in the
# target directories instead of a Release subdirectory.
- name: "[Windows] Upload PDBs (Program Databases)"
if: matrix.os == 'Windows' && github.ref_type != 'tag'
uses: actions/upload-artifact@v7
@ -551,9 +712,9 @@ jobs:
if-no-files-found: error
name: ${{ steps.build.outputs.name }}-PDBs
path: |
build/cockatrice/Release/*.pdb
build/oracle/Release/*.pdb
build/servatrice/Release/*.pdb
build/cockatrice/*.pdb
build/oracle/*.pdb
build/servatrice/*.pdb
- name: "Upload to release"
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
@ -581,3 +742,67 @@ jobs:
BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }}
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"

121
.github/workflows/vcpkg-cache-import.yml vendored Normal file
View file

@ -0,0 +1,121 @@
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,6 +22,10 @@ option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
# Compile tests
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
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
@ -86,7 +90,7 @@ endif()
# A project name is needed for CPack
# Version can be overriden by git tags, see cmake/getversion.cmake
project("Cockatrice" VERSION 3.1.1)
project("Cockatrice" VERSION 3.1.2)
# Set release name if not provided via env/cmake var
if(NOT DEFINED GIT_TAG_RELEASENAME)
@ -211,6 +215,12 @@ else()
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
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
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_definitions("-DSFMT_MEXP=19937")

75
cmake/Coverage.cmake Normal file
View file

@ -0,0 +1,75 @@
# 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,7 +40,9 @@ endif()
if(TEST)
# 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.
set(_TEST_NEEDED Concurrent Network Svg Widgets)
# Test is QtTest: it brings QAbstractItemModelTester and QSignalSpy, which are how the Qt model
# and signal contracts get verified without standing up a GUI.
set(_TEST_NEEDED Concurrent Network Svg Widgets Test)
endif()
set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_COCKATRICE_NEEDED} ${_ORACLE_NEEDED}

View file

@ -304,10 +304,11 @@ SetShellVarContext all
; _?=$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
; own directory, and the process we started exits at once. ExecWait then returns
; before the uninstall has run, and the temp copy's "RMDir /r $INSTDIR" executes
; while the section below is still writing the new payload. _?= disables the copy
; and makes ExecWait block until the uninstall finishes. It must be the last
; argument and must not be quoted, even when the path contains spaces.
; before the uninstall has run, and the temp copy keeps deleting files - from
; 3.1.0 and 3.1.1 on disk a blanket "RMDir /r $INSTDIR" - while the section
; below is still writing the new payload. _?= disables the copy and makes
; ExecWait block until the uninstall finishes. It must be the last argument
; and must not be quoted, even when the path contains spaces.
;
; --- 32-bit uninstall ---
SetRegView 32
@ -1107,9 +1108,14 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
; upgrade portable mode
!insertmacro CockLog "section: portable upgrade purge"
!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\sounds"
RMDir /r "$INSTDIR\themes"
RMDir /r "$INSTDIR\translations"
Delete "$INSTDIR\uninstall.exe"
Delete "$INSTDIR\cockatrice.exe"
@ -1263,16 +1269,32 @@ Section "un.Application" UnSecApplication
SetShellVarContext all
StrCpy $R1 0
; Record whether this was a portable install before the directory (and the
; portable.dat marker inside it) is removed below.
; Record whether this was a portable install before portable.dat is
; removed below.
IfFileExists "$INSTDIR\portable.dat" 0 +2
StrCpy $R1 1
; Remove the entire application directory so any file that is not part of
; the installed payload (e.g. build-tree artifacts such as *.dir folders,
; *_autogen and *.tlog files from a build) cannot survive between an
; uninstall and a fresh reinstall.
RMDir /r "$INSTDIR"
; Remove only what the installer wrote. The two lists below are generated
; by CPack from the staged install tree: one Delete per installed file and
; one non-recursive RMDir per installed directory, children first.
; Anything the user put in $INSTDIR - decks, pictures, portable data
; folders - is not on those lists and survives, and $INSTDIR itself is
; 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"

109
cmake/Sanitizers.cmake Normal file
View file

@ -0,0 +1,109 @@
# 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,22 +3,53 @@
*
* Safe for every target that links Qt Core, including the headless
* 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 <QByteArray>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QItemSelectionModel>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QList>
#include <QLoggingCategory>
#include <QMap>
#include <QMetaEnum>
#include <QMetaObject>
#include <QMutex>
#include <QMutexLocker>
#include <QObject>
#include <QPointer>
#include <QPropertyAnimation>
#include <QRandomGenerator>
#include <QReadWriteLock>
#include <QRegularExpression>
#include <QSet>
#include <QSettings>
#include <QSharedPointer>
#include <QSortFilterProxyModel>
#include <QString>
#include <QStringList>
#include <QTemporaryDir>
#include <QThread>
#include <QTimer>
#include <QUuid>
#include <QVariant>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>

View file

@ -4,27 +4,56 @@
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
* Network layers that virtually every client translation unit re-parses.
* 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 <QAction>
#include <QApplication>
#include <QCheckBox>
#include <QComboBox>
#include <QCompleter>
#include <QDialogButtonBox>
#include <QDockWidget>
#include <QFileDialog>
#include <QFormLayout>
#include <QFrame>
#include <QGraphicsItem>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QGraphicsView>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QImage>
#include <QImageReader>
#include <QInputDialog>
#include <QLabel>
#include <QLayout>
#include <QListWidget>
#include <QMainWindow>
#include <QMenu>
#include <QMessageBox>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QPainter>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QScrollArea>
#include <QSpinBox>
#include <QSplitter>
#include <QStandardItemModel>
#include <QTabWidget>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QToolBar>
#include <QToolButton>
#include <QTreeWidget>
#include <QVBoxLayout>
#include <QWidget>

View file

@ -59,6 +59,8 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_update.cpp
src/interface/widgets/dialogs/dlg_view_log.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/tip_of_the_day.cpp
src/interface/widgets/deck_share/deck_share_utils.cpp
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp

View file

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

View file

@ -10,6 +10,7 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QIODevice>
#include <QList>
#include <QLocale>
@ -177,7 +178,15 @@ bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data)
// Data written, so reload the card database
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written";
const auto reloadOk = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
// Spoilers can introduce sets the config has never seen; the loader never
// 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
// when the database was last updated

View file

@ -133,6 +133,10 @@ SettingsCache::SettingsCache()
// Migrate settings from global.ini to per-class INI files (runs at most once)
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);
cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this);
serversSettings = new ServersSettings(settingsPath, this);

View file

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

View file

@ -12,6 +12,7 @@
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/utility/card_ref.h"
#include <QApplication>
#include <QBrush>
#include <QChar>
#include <QCursor>
@ -28,6 +29,7 @@
#include <QPixmap>
#include <QPointF>
#include <QRectF>
#include <QTimer>
#include <QTransform>
#include <QVariant>
#include <QtGlobal>
@ -42,7 +44,7 @@ class QSizeF;
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
: ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
bgColor(Qt::transparent), isHovered(false), realZValue(0)
bgColor(Qt::transparent), isHovered(false), realZValue(0), pendingSelectionCollapse(false)
{
setCursor(Qt::OpenHandCursor);
setFlag(ItemIsSelectable);
@ -344,6 +346,16 @@ void AbstractCardItem::setFaceDown(bool _facedown)
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) {
emit cardShiftClicked(cardRef.name);
} else if ((event->modifiers() & Qt::ControlModifier)) {
@ -351,6 +363,10 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
} else if (!isSelected()) {
scene()->clearSelection();
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) {
setCursor(Qt::ClosedHandCursor);
@ -362,6 +378,32 @@ void AbstractCardItem::mousePressEvent(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) {
emit deleteCardInfoPopup(cardRef.name);
}
@ -370,6 +412,17 @@ void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
event->accept();
}
void AbstractCardItem::cancelPendingSelectionCollapse()
{
pendingSelectionCollapse = false;
++selectionCollapseGeneration;
}
bool AbstractCardItem::doubleClickActsOnSelection() const
{
return false;
}
void AbstractCardItem::processHoverEvent()
{
emit hovered(this);

View file

@ -41,6 +41,8 @@ protected:
private:
bool isHovered;
qreal realZValue;
bool pendingSelectionCollapse;
int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings
private slots:
void pixmapUpdated();
@ -130,6 +132,8 @@ public:
}
void setFaceDown(bool _facedown);
void processHoverEvent();
/** @brief Drop a pending collapse and disarm any deferred collapse timer. */
void cancelPendingSelectionCollapse();
void deleteCardInfoPopup()
{
emit deleteCardInfoPopup(cardRef.name);
@ -144,6 +148,16 @@ protected:
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override;
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

View file

@ -518,6 +518,9 @@ void CardItem::mouseReleaseEvent(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) &&
(SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
@ -525,6 +528,11 @@ void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
event->accept();
}
bool CardItem::doubleClickActsOnSelection() const
{
return SettingsCache::instance().userInterface().getDoubleClickToPlay();
}
bool CardItem::animationEvent()
{
int rotation = ROTATION_DEGREES_PER_FRAME;

View file

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

View file

@ -182,6 +182,15 @@ void DeckView::mouseDoubleClickEvent(QMouseEvent *event)
QList<MoveCard_ToZone> result;
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++) {
auto *c = static_cast<DeckViewCard *>(sel.at(i));
auto *zone = static_cast<DeckViewCardContainer *>(c->parentItem());

View file

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

View file

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

View file

@ -1,205 +1,41 @@
/**
* @file dlg_settings.cpp
* @brief Implementation of the main settings dialog
* @brief Implementation of the main settings dialog (thin frame around SettingsView)
* @ingroup Dialogs
*/
#include "dlg_settings.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../main.h"
#include "../settings_page/abstract_settings_page.h"
#include "../settings_page/appearance_settings_page.h"
#include "../settings_page/deck_editor_settings_page.h"
#include "../settings_page/general_settings_page.h"
#include "../settings_page/messages_settings_page.h"
#include "../settings_page/settings_search_delegate.h"
#include "../settings_page/settings_search_model.h"
#include "../settings_page/shortcut_settings_page.h"
#include "../settings_page/sound_settings_page.h"
#include "../settings_page/storage_settings_page.h"
#include "../settings_page/user_interface_settings_page.h"
#include "libcockatrice/card/database/card_database.h"
#include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QAbstractAnimation>
#include <QAbstractItemView>
#include <QApplication>
#include <QBrush>
#include <QByteArray>
#include <QCloseEvent>
#include <QColor>
#include <QDebug>
#include <QDir>
#include <QEasingCurve>
#include <QEvent>
#include <QFlags>
#include <QFrame>
#include <QGraphicsOpacityEffect>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QIcon>
#include <QKeyEvent>
#include <QKeySequence>
#include <QLineEdit>
#include <QListView>
#include <QMessageBox>
#include <QMessageLogger>
#include <QModelIndex>
#include <QObject>
#include <QPalette>
#include <QPixmap>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QRect>
#include <QScreen>
#include <QScrollArea>
#include <QScrollBar>
#include <QSequentialAnimationGroup>
#include <QShortcut>
#include <QSize>
#include <QSizePolicy>
#include <QStackedLayout>
#include <QStackedWidget>
#include <QStringLiteral>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
#include <QtGlobal>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <qnamespace.h>
class QModelIndex;
/**
* @brief Wraps a widget in a scroll area for long settings pages
* @param widget The widget to wrap
* @return The scroll area containing the widget
*/
static QScrollArea *makeScrollable(QWidget *widget)
{
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
auto *scrollArea = new QScrollArea;
scrollArea->setWidgetResizable(true);
scrollArea->setContentsMargins(0, 0, 0, 0);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->horizontalScrollBar()->setEnabled(false);
scrollArea->setWidget(widget);
return scrollArea;
}
/**
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
*/
static QStringList pageIconResources()
{
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
}
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
{
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
retranslateUi();
searchEdit->setFocus();
adjustSize();
}
void DlgSettings::setupUi()
{
// Search bar
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
auto *searchLayout = new QHBoxLayout;
searchLayout->addWidget(searchEdit);
// Tab bar (built in setupTabBar)
setupTabBar();
// Pages stacked widget
pagesWidget = new QStackedWidget;
auto *generalPage = new GeneralSettingsPage;
auto *appearancePage = new AppearanceSettingsPage;
auto *userInterfacePage = new UserInterfaceSettingsPage;
auto *deckEditorPage = new DeckEditorSettingsPage;
auto *storagePage = new StorageSettingsPage;
auto *messagesPage = new MessagesSettingsPage;
auto *soundPage = new SoundSettingsPage;
auto *shortcutsPage = new ShortcutSettingsPage;
pages.append(generalPage);
pages.append(appearancePage);
pages.append(userInterfacePage);
pages.append(deckEditorPage);
pages.append(storagePage);
pages.append(messagesPage);
pages.append(soundPage);
pages.append(shortcutsPage);
pagesWidget->addWidget(makeScrollable(generalPage));
pagesWidget->addWidget(makeScrollable(appearancePage));
pagesWidget->addWidget(makeScrollable(userInterfacePage));
pagesWidget->addWidget(makeScrollable(deckEditorPage));
pagesWidget->addWidget(makeScrollable(storagePage));
pagesWidget->addWidget(makeScrollable(messagesPage));
pagesWidget->addWidget(soundPage);
pagesWidget->addWidget(shortcutsPage);
Q_ASSERT(pages.size() == NumPages);
// Search results view (hidden by default)
searchResultsView = new QListView;
searchResultsView->setUniformItemSizes(false);
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
searchResultsView->setVisible(false);
searchResultsView->setStyleSheet(
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
searchModel = new SettingsSearchModel(this);
searchDelegate = new SettingsSearchDelegate(this);
searchResultsView->setModel(searchModel);
searchResultsView->setItemDelegate(searchDelegate);
connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
const QStringList icons = pageIconResources();
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
tabButtons[i]->setIcon(QPixmap(icons[i]));
}
searchDelegate->setPageIcons(icons);
searchResultsView->viewport()->update();
});
// Build search index after pages are created
buildSearchIndex();
// Pages container (stacked widget + search results overlay)
pagesContainer = new QWidget;
auto *containerLayout = new QStackedLayout;
containerLayout->setStackingMode(QStackedLayout::StackAll);
containerLayout->addWidget(pagesWidget);
containerLayout->addWidget(searchResultsView);
pagesContainer->setLayout(containerLayout);
settingsView = new SettingsView(this);
// Bottom buttons
auto *buttonBox = new QHBoxLayout;
@ -211,241 +47,25 @@ void DlgSettings::setupUi()
// Main layout
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(searchLayout);
mainLayout->addWidget(tabBarWidget);
auto *separator = new QFrame;
separator->setFrameShape(QFrame::HLine);
separator->setFrameShadow(QFrame::Sunken);
mainLayout->addWidget(separator);
mainLayout->addWidget(pagesContainer);
mainLayout->addWidget(settingsView);
mainLayout->addSpacing(4);
mainLayout->addLayout(buttonBox);
setLayout(mainLayout);
// Keyboard shortcuts
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
retranslateUi();
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
int next = (currentTabIndex + 1) % tabButtons.size();
setActiveTab(next);
});
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
setActiveTab(prev);
});
// Initialize to first tab
setActiveTab(0);
}
void DlgSettings::setupTabBar()
{
tabBarWidget = new QWidget;
auto *tabLayout = new QHBoxLayout;
tabLayout->setContentsMargins(0, 0, 0, 0);
tabLayout->setSpacing(2);
const QStringList iconResources = pageIconResources();
for (int i = 0; i < iconResources.size(); ++i) {
auto *tabButton = new QToolButton;
tabButton->setCheckable(true);
tabButton->setIcon(QPixmap(iconResources[i]));
tabButton->setIconSize(QSize(48, 48));
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
tabButton->setAutoExclusive(true);
tabButton->setMinimumHeight(85);
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
tabButtons.append(tabButton);
tabLayout->addWidget(tabButton);
}
tabBarWidget->setLayout(tabLayout);
}
void DlgSettings::buildSearchIndex()
{
QList<SettingsSearchEntry> allEntries;
const QStringList pageNames = translatedPageNames();
searchDelegate->setPageNames(pageNames);
searchDelegate->setPageIcons(pageIconResources());
for (int i = 0; i < pages.size(); ++i) {
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
for (auto &entry : pageEntries) {
if (entry.pageIndex == -1) {
entry.pageIndex = i;
}
}
allEntries.append(pageEntries);
}
searchModel->setSourceEntries(allEntries);
}
void DlgSettings::onTabClicked(int index)
{
if (searchActive) {
switchToTabMode();
}
setActiveTab(index);
}
void DlgSettings::setActiveTab(int index)
{
if (index < 0 || index >= tabButtons.size()) {
return;
}
currentTabIndex = index;
pagesWidget->setCurrentIndex(index);
for (int i = 0; i < tabButtons.size(); ++i) {
tabButtons[i]->setChecked(i == index);
}
// Style active tab with a thick accent border + subtle background tint
for (int i = 0; i < tabButtons.size(); ++i) {
if (i == index) {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: palette(window); padding-bottom: 1px; }");
} else {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: transparent; }");
}
}
}
void DlgSettings::flashWidget(QWidget *widget)
{
auto *overlay = new QWidget(widget);
overlay->setGeometry(widget->rect());
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
QPalette pal = overlay->palette();
QColor flashColor = pal.color(QPalette::Highlight);
flashColor.setAlpha(100);
pal.setBrush(QPalette::Window, flashColor);
overlay->setPalette(pal);
overlay->setAutoFillBackground(true);
auto *effect = new QGraphicsOpacityEffect(overlay);
effect->setOpacity(0.0);
overlay->setGraphicsEffect(effect);
overlay->show();
overlay->raise();
auto *flashIn = new QPropertyAnimation(effect, "opacity");
flashIn->setDuration(120);
flashIn->setStartValue(0.0);
flashIn->setEndValue(0.6);
flashIn->setEasingCurve(QEasingCurve::OutCubic);
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
fadeOut->setDuration(900);
fadeOut->setStartValue(0.6);
fadeOut->setEndValue(0.0);
fadeOut->setEasingCurve(QEasingCurve::InCubic);
auto *group = new QSequentialAnimationGroup(overlay);
group->addAnimation(flashIn);
group->addAnimation(fadeOut);
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
group->start(QAbstractAnimation::DeleteWhenStopped);
}
void DlgSettings::onSearchTextChanged(const QString &text)
{
searchModel->setFilterString(text);
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
if (!searchActive) {
switchToSearchMode();
}
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
} else if (searchActive) {
switchToTabMode();
}
}
void DlgSettings::switchToSearchMode()
{
searchActive = true;
tabBarWidget->setVisible(false);
pagesWidget->setVisible(false);
searchResultsView->setVisible(true);
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
}
void DlgSettings::switchToTabMode()
{
searchActive = false;
tabBarWidget->setVisible(true);
pagesWidget->setVisible(true);
searchResultsView->setVisible(false);
searchEdit->blockSignals(true);
searchEdit->clear();
searchEdit->blockSignals(false);
setActiveTab(currentTabIndex);
}
void DlgSettings::onSearchResultClicked(const QModelIndex &index)
{
navigateToSearchResult(index);
}
void DlgSettings::navigateToSearchResult(const QModelIndex &index)
{
SettingsSearchEntry entry = searchModel->entryForIndex(index);
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
return;
}
// Switch to the page
switchToTabMode();
setActiveTab(entry.pageIndex);
// Scroll to the widget, focus it, and flash to highlight it
if (entry.widget) {
QWidget *widget = entry.widget;
while (widget) {
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
scrollArea->ensureWidgetVisible(entry.widget);
break;
}
widget = widget->parentWidget();
}
entry.widget->setFocus();
flashWidget(entry.widget);
}
adjustSize();
}
void DlgSettings::setTab(int index)
{
if (index >= 0 && index < tabButtons.size()) {
setActiveTab(index);
}
settingsView->setTab(index);
}
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
{
return pages.value(static_cast<int>(which));
return settingsView->page(which);
}
void DlgSettings::updateLanguage()
@ -454,49 +74,6 @@ void DlgSettings::updateLanguage()
installNewTranslator();
}
bool DlgSettings::eventFilter(QObject *watched, QEvent *event)
{
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Escape) {
if (searchActive) {
switchToTabMode();
return true;
}
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
if (searchActive) {
if (searchResultsView->currentIndex().isValid()) {
navigateToSearchResult(searchResultsView->currentIndex());
}
return true;
}
} else if (keyEvent->key() == Qt::Key_Down) {
if (searchActive) {
int nextRow = searchResultsView->currentIndex().row() + 1;
if (nextRow >= searchModel->rowCount()) {
nextRow = 0;
}
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
searchResultsView->scrollTo(searchModel->index(nextRow));
return true;
}
} else if (keyEvent->key() == Qt::Key_Up) {
if (searchActive) {
int prevRow = searchResultsView->currentIndex().row() - 1;
if (prevRow < 0) {
prevRow = searchModel->rowCount() - 1;
}
if (prevRow >= 0) {
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
searchResultsView->scrollTo(searchModel->index(prevRow));
}
return true;
}
}
}
return QDialog::eventFilter(watched, event);
}
void DlgSettings::closeEvent(QCloseEvent *event)
{
bool showLoadError = true;
@ -576,26 +153,5 @@ void DlgSettings::closeEvent(QCloseEvent *event)
void DlgSettings::retranslateUi()
{
setWindowTitle(tr("Settings"));
retranslateTabNames();
searchEdit->setPlaceholderText(tr("Search settings..."));
okButton->setText(tr("OK"));
// Rebuild search index for translated text
buildSearchIndex();
}
QStringList DlgSettings::translatedPageNames()
{
return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"),
tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")};
}
void DlgSettings::retranslateTabNames()
{
const QStringList tabLabels = translatedPageNames();
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
tabButtons[i]->setText(tabLabels[i]);
}
}

View file

@ -1,105 +1,59 @@
/**
* @file dlg_settings.h
* @brief Main settings dialog for the Cockatrice client
* @brief Main settings dialog for the Cockatrice client (thin frame around SettingsView)
* @ingroup Dialogs
*/
#ifndef DLG_SETTINGS_H
#define DLG_SETTINGS_H
#include "settings_view.h"
#include <QDialog>
#include <QList>
#include <QLoggingCategory>
#include <QString>
#include <QStringList>
#include <qtmetamacros.h>
class QPushButton;
class QModelIndex;
class QWidget;
class AbstractSettingsPage;
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
class QStackedWidget;
class QToolButton;
class QListView;
class QLineEdit;
class AbstractSettingsPage;
class SettingsSearchModel;
class SettingsSearchDelegate;
/**
* @brief Main application settings dialog with tabbed navigation and search
* @brief Main application settings dialog hosting the shared SettingsView
*
* Provides a modern settings interface organized into tabbed pages. Users can
* either navigate by clicking tabs or search for specific settings using the
* built-in search bar. Search results are filtered and ranked by relevance.
* Thin QDialog frame around SettingsView: only the window title, the OK button
* and the close validation live here. All pages, navigation and search are
* owned by SettingsView so other hosts (e.g. a settings tab) can reuse them.
*/
class DlgSettings : public QDialog
{
Q_OBJECT
public:
/**
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
*
* Use these values instead of raw indices so reordering pages never silently
* breaks external callers like tab_room.cpp.
*/
enum SettingsPage
{
GeneralPage = 0,
AppearancePage,
UserInterfacePage,
DeckEditorPage,
StoragePage,
MessagesPage,
SoundPage,
ShortcutsPage,
NumPages
};
using SettingsPage = SettingsView::SettingsPage;
static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage;
static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage;
static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage;
static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage;
static constexpr SettingsPage StoragePage = SettingsView::StoragePage;
static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage;
static constexpr SettingsPage SoundPage = SettingsView::SoundPage;
static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage;
static constexpr SettingsPage NumPages = SettingsView::NumPages;
explicit DlgSettings(QWidget *parent = nullptr);
void setTab(int index);
AbstractSettingsPage *page(SettingsPage which) const;
private slots:
void onTabClicked(int index);
void onSearchTextChanged(const QString &text);
void onSearchResultClicked(const QModelIndex &index);
void updateLanguage();
private:
// UI elements
QLineEdit *searchEdit; ///< Search bar for filtering settings
QWidget *tabBarWidget; ///< Container widget for the tab buttons
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
QListView *searchResultsView; ///< Search results list view
QWidget *pagesContainer; ///< Container stacking pages and search results
QPushButton *okButton; ///< Button to close the dialog
// Data
QList<AbstractSettingsPage *> pages; ///< All settings page instances
SettingsSearchModel *searchModel; ///< Model for search results
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
int currentTabIndex; ///< Currently active tab index
bool searchActive; ///< Whether search mode is active
void setupUi();
void setupTabBar();
void buildSearchIndex();
void switchToTabMode();
void switchToSearchMode();
void navigateToSearchResult(const QModelIndex &index);
void setActiveTab(int index);
static void flashWidget(QWidget *widget);
static QStringList translatedPageNames();
SettingsView *settingsView; ///< Shared settings content
QPushButton *okButton; ///< Button to close the dialog
void retranslateUi();
void retranslateTabNames();
protected:
void closeEvent(QCloseEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
};
#endif // DLG_SETTINGS_H

View file

@ -0,0 +1,34 @@
#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

@ -0,0 +1,25 @@
#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

@ -0,0 +1,497 @@
/**
* @file settings_view.cpp
* @brief Implementation of the settings content widget
* @ingroup Dialogs
*/
#include "settings_view.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../settings_page/abstract_settings_page.h"
#include "../settings_page/appearance_settings_page.h"
#include "../settings_page/deck_editor_settings_page.h"
#include "../settings_page/general_settings_page.h"
#include "../settings_page/messages_settings_page.h"
#include "../settings_page/settings_search_delegate.h"
#include "../settings_page/settings_search_model.h"
#include "../settings_page/shortcut_settings_page.h"
#include "../settings_page/sound_settings_page.h"
#include "../settings_page/storage_settings_page.h"
#include "../settings_page/user_interface_settings_page.h"
#include <QAbstractAnimation>
#include <QAbstractItemView>
#include <QBrush>
#include <QColor>
#include <QCoreApplication>
#include <QDebug>
#include <QEasingCurve>
#include <QEvent>
#include <QFlags>
#include <QFrame>
#include <QGraphicsOpacityEffect>
#include <QHBoxLayout>
#include <QIcon>
#include <QKeyEvent>
#include <QKeySequence>
#include <QLineEdit>
#include <QListView>
#include <QMessageLogger>
#include <QModelIndex>
#include <QObject>
#include <QPalette>
#include <QPixmap>
#include <QPropertyAnimation>
#include <QRect>
#include <QScreen>
#include <QScrollArea>
#include <QScrollBar>
#include <QSequentialAnimationGroup>
#include <QShortcut>
#include <QSize>
#include <QSizePolicy>
#include <QStackedLayout>
#include <QStackedWidget>
#include <QStringLiteral>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
#include <QtGlobal>
#include <libcockatrice/settings/personal_settings.h>
#include <qnamespace.h>
/**
* @brief Wraps a widget in a scroll area for long settings pages
* @param widget The widget to wrap
* @return The scroll area containing the widget
*/
static QScrollArea *makeScrollable(QWidget *widget)
{
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
auto *scrollArea = new QScrollArea;
scrollArea->setWidgetResizable(true);
scrollArea->setContentsMargins(0, 0, 0, 0);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->horizontalScrollBar()->setEnabled(false);
scrollArea->setWidget(widget);
return scrollArea;
}
/**
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
*/
static QStringList pageIconResources()
{
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
}
SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false)
{
setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi);
retranslateUi();
searchEdit->setFocus();
}
void SettingsView::setupUi()
{
// Search bar
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged);
auto *searchLayout = new QHBoxLayout;
searchLayout->addWidget(searchEdit);
// Tab bar (built in setupTabBar)
setupTabBar();
// Pages stacked widget
pagesWidget = new QStackedWidget;
auto *generalPage = new GeneralSettingsPage;
auto *appearancePage = new AppearanceSettingsPage;
auto *userInterfacePage = new UserInterfaceSettingsPage;
auto *deckEditorPage = new DeckEditorSettingsPage;
auto *storagePage = new StorageSettingsPage;
auto *messagesPage = new MessagesSettingsPage;
auto *soundPage = new SoundSettingsPage;
auto *shortcutsPage = new ShortcutSettingsPage;
pages.append(generalPage);
pages.append(appearancePage);
pages.append(userInterfacePage);
pages.append(deckEditorPage);
pages.append(storagePage);
pages.append(messagesPage);
pages.append(soundPage);
pages.append(shortcutsPage);
pagesWidget->addWidget(makeScrollable(generalPage));
pagesWidget->addWidget(makeScrollable(appearancePage));
pagesWidget->addWidget(makeScrollable(userInterfacePage));
pagesWidget->addWidget(makeScrollable(deckEditorPage));
pagesWidget->addWidget(makeScrollable(storagePage));
pagesWidget->addWidget(makeScrollable(messagesPage));
pagesWidget->addWidget(soundPage);
pagesWidget->addWidget(shortcutsPage);
Q_ASSERT(pages.size() == NumPages);
// Search results view (hidden by default)
searchResultsView = new QListView;
searchResultsView->setUniformItemSizes(false);
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
searchResultsView->setVisible(false);
searchResultsView->setStyleSheet(
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
searchModel = new SettingsSearchModel(this);
searchDelegate = new SettingsSearchDelegate(this);
searchResultsView->setModel(searchModel);
searchResultsView->setItemDelegate(searchDelegate);
connect(searchResultsView, &QListView::clicked, this, &SettingsView::onSearchResultClicked);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
const QStringList icons = pageIconResources();
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
tabButtons[i]->setIcon(QPixmap(icons[i]));
}
searchDelegate->setPageIcons(icons);
searchResultsView->viewport()->update();
});
// Build search index after pages are created
buildSearchIndex();
// Pages container (stacked widget + search results overlay)
pagesContainer = new QWidget;
auto *containerLayout = new QStackedLayout;
containerLayout->setStackingMode(QStackedLayout::StackAll);
containerLayout->addWidget(pagesWidget);
containerLayout->addWidget(searchResultsView);
pagesContainer->setLayout(containerLayout);
// Main layout
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(searchLayout);
mainLayout->addWidget(tabBarWidget);
auto *separator = new QFrame;
separator->setFrameShape(QFrame::HLine);
separator->setFrameShadow(QFrame::Sunken);
mainLayout->addWidget(separator);
mainLayout->addWidget(pagesContainer);
mainLayout->addSpacing(4);
setLayout(mainLayout);
// Keyboard shortcuts
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
int next = (currentTabIndex + 1) % tabButtons.size();
setActiveTab(next);
});
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
setActiveTab(prev);
});
// Initialize to first tab
setActiveTab(0);
}
void SettingsView::setupTabBar()
{
tabBarWidget = new QWidget;
auto *tabLayout = new QHBoxLayout;
tabLayout->setContentsMargins(0, 0, 0, 0);
tabLayout->setSpacing(2);
const QStringList iconResources = pageIconResources();
for (int i = 0; i < iconResources.size(); ++i) {
auto *tabButton = new QToolButton;
tabButton->setCheckable(true);
tabButton->setIcon(QPixmap(iconResources[i]));
tabButton->setIconSize(QSize(48, 48));
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
tabButton->setAutoExclusive(true);
tabButton->setMinimumHeight(85);
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
tabButtons.append(tabButton);
tabLayout->addWidget(tabButton);
}
tabBarWidget->setLayout(tabLayout);
}
void SettingsView::buildSearchIndex()
{
QList<SettingsSearchEntry> allEntries;
const QStringList pageNames = translatedPageNames();
searchDelegate->setPageNames(pageNames);
searchDelegate->setPageIcons(pageIconResources());
for (int i = 0; i < pages.size(); ++i) {
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
for (auto &entry : pageEntries) {
if (entry.pageIndex == -1) {
entry.pageIndex = i;
}
}
allEntries.append(pageEntries);
}
searchModel->setSourceEntries(allEntries);
}
void SettingsView::onTabClicked(int index)
{
if (searchActive) {
switchToTabMode();
}
setActiveTab(index);
}
void SettingsView::setActiveTab(int index)
{
if (index < 0 || index >= tabButtons.size()) {
return;
}
currentTabIndex = index;
pagesWidget->setCurrentIndex(index);
for (int i = 0; i < tabButtons.size(); ++i) {
tabButtons[i]->setChecked(i == index);
}
// Style active tab with a thick accent border + subtle background tint
for (int i = 0; i < tabButtons.size(); ++i) {
if (i == index) {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: palette(window); padding-bottom: 1px; }");
} else {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: transparent; }");
}
}
}
void SettingsView::flashWidget(QWidget *widget)
{
auto *overlay = new QWidget(widget);
overlay->setGeometry(widget->rect());
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
QPalette pal = overlay->palette();
QColor flashColor = pal.color(QPalette::Highlight);
flashColor.setAlpha(100);
pal.setBrush(QPalette::Window, flashColor);
overlay->setPalette(pal);
overlay->setAutoFillBackground(true);
auto *effect = new QGraphicsOpacityEffect(overlay);
effect->setOpacity(0.0);
overlay->setGraphicsEffect(effect);
overlay->show();
overlay->raise();
auto *flashIn = new QPropertyAnimation(effect, "opacity");
flashIn->setDuration(120);
flashIn->setStartValue(0.0);
flashIn->setEndValue(0.6);
flashIn->setEasingCurve(QEasingCurve::OutCubic);
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
fadeOut->setDuration(900);
fadeOut->setStartValue(0.6);
fadeOut->setEndValue(0.0);
fadeOut->setEasingCurve(QEasingCurve::InCubic);
auto *group = new QSequentialAnimationGroup(overlay);
group->addAnimation(flashIn);
group->addAnimation(fadeOut);
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
group->start(QAbstractAnimation::DeleteWhenStopped);
}
void SettingsView::onSearchTextChanged(const QString &text)
{
searchModel->setFilterString(text);
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
if (!searchActive) {
switchToSearchMode();
}
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
} else if (searchActive) {
switchToTabMode();
}
}
void SettingsView::switchToSearchMode()
{
searchActive = true;
tabBarWidget->setVisible(false);
pagesWidget->setVisible(false);
searchResultsView->setVisible(true);
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
}
void SettingsView::switchToTabMode()
{
searchActive = false;
tabBarWidget->setVisible(true);
pagesWidget->setVisible(true);
searchResultsView->setVisible(false);
searchEdit->blockSignals(true);
searchEdit->clear();
searchEdit->blockSignals(false);
setActiveTab(currentTabIndex);
}
void SettingsView::onSearchResultClicked(const QModelIndex &index)
{
navigateToSearchResult(index);
}
void SettingsView::navigateToSearchResult(const QModelIndex &index)
{
SettingsSearchEntry entry = searchModel->entryForIndex(index);
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
return;
}
// Switch to the page
switchToTabMode();
setActiveTab(entry.pageIndex);
// Scroll to the widget, focus it, and flash to highlight it
if (entry.widget) {
QWidget *widget = entry.widget;
while (widget) {
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
scrollArea->ensureWidgetVisible(entry.widget);
break;
}
widget = widget->parentWidget();
}
entry.widget->setFocus();
flashWidget(entry.widget);
}
}
void SettingsView::setTab(int index)
{
if (index >= 0 && index < tabButtons.size()) {
setActiveTab(index);
}
}
AbstractSettingsPage *SettingsView::page(SettingsPage which) const
{
return pages.value(static_cast<int>(which));
}
bool SettingsView::eventFilter(QObject *watched, QEvent *event)
{
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Escape) {
if (searchActive) {
switchToTabMode();
return true;
}
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
if (searchActive) {
if (searchResultsView->currentIndex().isValid()) {
navigateToSearchResult(searchResultsView->currentIndex());
}
return true;
}
} else if (keyEvent->key() == Qt::Key_Down) {
if (searchActive) {
int nextRow = searchResultsView->currentIndex().row() + 1;
if (nextRow >= searchModel->rowCount()) {
nextRow = 0;
}
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
searchResultsView->scrollTo(searchModel->index(nextRow));
return true;
}
} else if (keyEvent->key() == Qt::Key_Up) {
if (searchActive) {
int prevRow = searchResultsView->currentIndex().row() - 1;
if (prevRow < 0) {
prevRow = searchModel->rowCount() - 1;
}
if (prevRow >= 0) {
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
searchResultsView->scrollTo(searchModel->index(prevRow));
}
return true;
}
}
}
return QWidget::eventFilter(watched, event);
}
void SettingsView::retranslateUi()
{
retranslateTabNames();
// NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings
// before the view was extracted, and keeping the context preserves existing translations.
searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings..."));
// Rebuild search index for translated text
buildSearchIndex();
}
QStringList SettingsView::translatedPageNames()
{
// NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi).
return {QCoreApplication::translate("DlgSettings", "General"),
QCoreApplication::translate("DlgSettings", "Appearance"),
QCoreApplication::translate("DlgSettings", "User Interface"),
QCoreApplication::translate("DlgSettings", "Card Sources"),
QCoreApplication::translate("DlgSettings", "Storage"),
QCoreApplication::translate("DlgSettings", "Chat"),
QCoreApplication::translate("DlgSettings", "Sound"),
QCoreApplication::translate("DlgSettings", "Shortcuts")};
}
void SettingsView::retranslateTabNames()
{
const QStringList tabLabels = translatedPageNames();
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
tabButtons[i]->setText(tabLabels[i]);
}
}

View file

@ -0,0 +1,98 @@
/**
* @file settings_view.h
* @brief Settings content widget hosted by the settings dialog (and, later, the settings tab)
* @ingroup Dialogs
*/
#ifndef SETTINGS_VIEW_H
#define SETTINGS_VIEW_H
#include <QList>
#include <QString>
#include <QStringList>
#include <QWidget>
#include <qtmetamacros.h>
class QModelIndex;
class QObject;
class QEvent;
class QStackedWidget;
class QToolButton;
class QListView;
class QLineEdit;
class AbstractSettingsPage;
class SettingsSearchModel;
class SettingsSearchDelegate;
/**
* @brief Settings pages with tabbed navigation and search, without any dialog chrome
*
* Owns the search bar, the tab bar, the stacked settings pages and the search
* results view. Dialog/tab hosts only add their own frame (buttons, titles).
*/
class SettingsView : public QWidget
{
Q_OBJECT
public:
/**
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
*
* Use these values instead of raw indices so reordering pages never silently
* breaks external callers like tab_room.cpp.
*/
enum SettingsPage
{
GeneralPage = 0,
AppearancePage,
UserInterfacePage,
DeckEditorPage,
StoragePage,
MessagesPage,
SoundPage,
ShortcutsPage,
NumPages
};
explicit SettingsView(QWidget *parent = nullptr);
void setTab(int index);
AbstractSettingsPage *page(SettingsPage which) const;
private slots:
void onTabClicked(int index);
void onSearchTextChanged(const QString &text);
void onSearchResultClicked(const QModelIndex &index);
private:
// UI elements
QLineEdit *searchEdit; ///< Search bar for filtering settings
QWidget *tabBarWidget; ///< Container widget for the tab buttons
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
QListView *searchResultsView; ///< Search results list view
QWidget *pagesContainer; ///< Container stacking pages and search results
// Data
QList<AbstractSettingsPage *> pages; ///< All settings page instances
SettingsSearchModel *searchModel; ///< Model for search results
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
int currentTabIndex; ///< Currently active tab index
bool searchActive; ///< Whether search mode is active
void setupUi();
void setupTabBar();
void buildSearchIndex();
void switchToTabMode();
void switchToSearchMode();
void navigateToSearchResult(const QModelIndex &index);
void setActiveTab(int index);
static void flashWidget(QWidget *widget);
static QStringList translatedPageNames();
void retranslateUi();
void retranslateTabNames();
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
};
#endif // SETTINGS_VIEW_H

View file

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

View file

@ -113,6 +113,17 @@ GameSelector::GameSelector(AbstractClient *_client,
quickFilterToolBar->setVisible(
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 {
quickFilterToolBar = nullptr;
}
@ -187,6 +198,18 @@ GameSelector::GameSelector(AbstractClient *_client,
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> &)
{
gameListProxyModel->refresh();

View file

@ -4,6 +4,7 @@
#include "game_type_map.h"
#include <QGroupBox>
#include <QTimer>
#include <qtmetamacros.h>
// IWYU pragma: keep
// ServerInfo_User appears by value and inside QList in slot signatures, so the
@ -148,6 +149,9 @@ private:
const bool showFilters; /**< Determines whether filter buttons are displayed. */
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.
*
@ -211,6 +215,7 @@ public:
const bool restoresettings,
const bool _showfilters,
QWidget *parent = nullptr);
~GameSelector() override;
/**
* @brief Updates UI text for translation/localization.

View file

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

View file

@ -23,6 +23,7 @@
#include <QGroupBox>
#include <QMessageBox>
#include <QObject>
#include <QPushButton>
#include <QSharedPointer>
#include <QString>
#include <QStringList>
@ -84,10 +85,9 @@ AppearanceSettingsPage::AppearanceSettingsPage()
const QString currentStyle = cfg.styleName;
const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0);
previousStyle = styleCombo.currentData().toString();
connect(&styleCombo, &QComboBox::currentIndexChanged, this,
[this] { themeManager->setStyleName(styleCombo.currentData().toString()); });
connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::updateStyleWarning);
connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::styleComboChanged);
connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] {
const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
@ -104,6 +104,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0);
styleCombo.blockSignals(false);
previousStyle = styleCombo.currentData().toString();
updateStyleWarning();
});
@ -467,12 +468,88 @@ void AppearanceSettingsPage::updateHomeTabSettingsVisibility()
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()
{
const QString style = styleCombo.currentData().toString();
const bool broken =
style.compare("windows11", Qt::CaseInsensitive) == 0 || style.compare("windowsvista", Qt::CaseInsensitive) == 0;
styleWarningLabel.setVisible(broken);
styleWarningLabel.setVisible(isBrokenStyle(styleCombo.currentData().toString()));
}
void AppearanceSettingsPage::styleComboChanged(int index)
{
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)
@ -538,9 +615,10 @@ void AppearanceSettingsPage::retranslateUi()
styleComboLabel.setText(tr("Active theme style:"));
styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)"));
styleWarningLabel.setText(
tr("Warning: the Windows 11 and Windows Vista styles are buggy and can render controls such as combo boxes "
"unreadable, especially in dark mode. Fusion is the recommended style on every platform: it is Qt's "
"cross-platform style and the one Cockatrice is developed against."));
tr("Warning: the Windows 11 and Windows Vista styles are broken and can break the game view (dragging "
"cards can shrink the board to a tiny grey window that requires rejoining to fix), and can render "
"controls such as combo boxes unreadable, especially in dark mode. Fusion is the recommended style on "
"every platform: it is Qt's cross-platform style and the one Cockatrice is developed against."));
updateStyleWarning();
editPaletteButton.setText(tr("Edit theme palette"));

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
#include "tab_report.h"
#include "../server/user/user_context_menu.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h"
@ -33,11 +34,13 @@
#include <QVariant>
#include <QWidget>
#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_report_add_comment.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_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_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
@ -120,6 +123,10 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents);
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;
descriptionEdit = new QTextEdit;
@ -231,14 +238,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
assignButton = new QPushButton;
resolveButton = new QPushButton;
resolveWithNoteButton = new QPushButton;
dismissButton = new QPushButton;
reopenButton = new QPushButton;
viewReplayButton = new QPushButton;
joinGameButton = new QPushButton;
connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport);
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); });
connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); });
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); });
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false); });
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true); });
connect(reopenButton, &QPushButton::clicked, this, &TabReport::reopenReport);
connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay);
connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame);
@ -247,8 +254,8 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
auto *actionBar = new QHBoxLayout;
actionBar->addWidget(assignButton);
actionBar->addWidget(resolveButton);
actionBar->addWidget(resolveWithNoteButton);
actionBar->addWidget(dismissButton);
actionBar->addWidget(reopenButton);
actionBar->addSpacing(20);
actionBar->addWidget(viewReplayButton);
actionBar->addWidget(joinGameButton);
@ -272,6 +279,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
void TabReport::retranslateUi()
{
userContextMenu->retranslateUi();
searchEdit->setPlaceholderText(tr("Search by username, category..."));
statusFilter->clear();
statusFilter->addItem(tr("All Statuses"), "");
@ -312,9 +320,10 @@ void TabReport::retranslateUi()
statsGroup->setTitle(tr("Report Statistics"));
assignButton->setText(tr("Assign to Me"));
resolveButton->setText(tr("Resolve"));
resolveWithNoteButton->setText(tr("Resolve with note..."));
resolveButton->setText(tr("Resolve..."));
dismissButton->setText(tr("Dismiss..."));
reopenButton->setText(tr("Reopen..."));
reopenButton->setToolTip(tr("Available for resolved or dismissed reports."));
viewReplayButton->setText(tr("View Replay"));
joinGameButton->setText(tr("Join Game"));
}
@ -346,6 +355,7 @@ void TabReport::reportListResponse(const Response &response)
refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) {
pendingStatusMessage.clear();
statusLabel->setText(tr("Failed to load reports."));
return;
}
@ -359,6 +369,11 @@ void TabReport::reportListResponse(const Response &response)
applyFilters();
updateStats();
if (!pendingStatusMessage.isEmpty()) {
statusLabel->setText(pendingStatusMessage);
pendingStatusMessage.clear();
}
if (selectedReportIdBeforeRefresh >= 0) {
bool found = false;
for (int row = 0; row < table->rowCount(); ++row) {
@ -494,6 +509,36 @@ void TabReport::onSelectionChanged()
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)
{
detailsRequestedReportId = reportId;
@ -577,8 +622,8 @@ void TabReport::updateActionStates()
const QString status = QString::fromStdString(report.status());
assignButton->setEnabled(status == "open");
resolveButton->setEnabled(status == "open" || status == "assigned");
resolveWithNoteButton->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 hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0;
@ -590,8 +635,8 @@ void TabReport::setActionsEnabled(bool enabled)
{
assignButton->setEnabled(enabled);
resolveButton->setEnabled(enabled);
resolveWithNoteButton->setEnabled(enabled);
dismissButton->setEnabled(enabled);
reopenButton->setEnabled(enabled);
viewReplayButton->setEnabled(false);
joinGameButton->setEnabled(false);
commentButton->setEnabled(enabled);
@ -645,7 +690,7 @@ void TabReport::assignReport()
void TabReport::assignResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Assigned."));
pendingStatusMessage = tr("Assigned.");
refreshList();
} else {
statusLabel->setText(tr("Assignment failed."));
@ -653,22 +698,19 @@ void TabReport::assignResponse(const Response &response)
}
}
void TabReport::resolveReport(bool dismissed, bool promptNote)
void TabReport::resolveReport(bool dismissed)
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
QString note;
if (promptNote) {
bool ok;
note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
QLineEdit::Normal, QString(), &ok);
if (!ok) {
return;
}
bool ok;
QString note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
QLineEdit::Normal, QString(), &ok);
if (!ok) {
return;
}
setActionsEnabled(false);
@ -689,7 +731,46 @@ void TabReport::resolveReport(bool dismissed, bool promptNote)
void TabReport::resolveResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Done."));
pendingStatusMessage = 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();
} else {
statusLabel->setText(tr("Action failed."));

View file

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

View file

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

View file

@ -46,6 +46,22 @@ template <class T> class QSharedPointer;
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
{
QColor fill;
@ -125,7 +141,7 @@ QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const
}
// Fixed wide rows so the popup has room for name, type line, set and mana
return {480, CardRowHeight};
return {PopupRowWidth, CardRowHeight};
}
// ---------------------------------------------------------------------------
@ -176,7 +192,12 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt
// ---------------------------------------------------------------------------
int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const
int CardCompleterDelegate::drawManaCost(QPainter *p,
const QRect &row,
const QString &manaCost,
int radius,
int spacing,
int rightPad) const
{
if (manaCost.isEmpty()) {
return row.right();
@ -205,11 +226,9 @@ int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QSt
totalW += PartGap;
}
totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * SymbolSpacing;
totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * spacing;
}
const int rightPad = 14;
int x = row.right() - rightPad - totalW + radius;
const int cy = row.center().y();
@ -219,11 +238,11 @@ int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QSt
for (const QString &sym : symbols) {
drawManaSymbol(p, {x, cy}, sym, radius);
x += diam + SymbolSpacing;
x += diam + spacing;
}
if (i < parts.size() - 1) {
x += PartGap - SymbolSpacing;
x += PartGap - spacing;
}
}
@ -290,6 +309,14 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
const QString typeLine = card->getCardType();
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 base = pal.color(QPalette::Base);
@ -338,7 +365,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
// Right mana zone
// -----------------------------------------------------------------------
const QRectF manaZone(cardRect.right() - 110, cardRect.top(), 110, cardRect.height());
const QRectF manaZone(cardRect.right() - manaZoneWidth, cardRect.top(), manaZoneWidth, cardRect.height());
QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft());
@ -380,22 +407,27 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
// Mana cost
// -----------------------------------------------------------------------
const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, SymbolRadius);
const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, symbolRadius, symbolSpacing, rightPad);
// -----------------------------------------------------------------------
// Card name + type line + set code
// -----------------------------------------------------------------------
const int textLeft = cardRect.left() + AccentBarWidth + 12;
const int textRight = costLeft - 8;
const int textLeft = cardRect.left() + AccentBarWidth + TextLeftPad;
const int textRight = costLeft - TextRightGap;
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)
// -----------------------------------------------------------------------
{
const QRect nameRect(textLeft, rect.top() + 2, textWidth, 20);
const QRect nameRect = showInfoBand ? QRect(textLeft, rect.top() + 2, textWidth, 20)
: QRect(textLeft, rect.top() + 2, textWidth, rect.height() - 4);
QFont f = option.font;
f.setPixelSize(13);
@ -416,7 +448,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
// Type line + set code (bottom band)
// -----------------------------------------------------------------------
{
if (showInfoBand) {
const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24);
QFont f = option.font;

View file

@ -36,6 +36,9 @@ public:
void paint(QPainter *painter, 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:
// Set short codes, resolved once per card name and cached
mutable QCache<QString, QString> setCodeCache;
@ -48,7 +51,8 @@ private:
// 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
int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const;
int
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
QString setCodeForCard(const QSharedPointer<CardInfo> &card) const;

View file

@ -20,6 +20,7 @@
#include <QPropertyAnimation>
#include <QRect>
#include <QScreen>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSize>
#include <QString>
@ -103,6 +104,7 @@ bool CardCompleterStyler::eventFilter(QObject *obj, QEvent *ev)
case QEvent::Show:
case QEvent::Move:
case QEvent::Resize:
applyPopupWidth();
updateOrientation();
reposition();
break;
@ -159,6 +161,42 @@ 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()
{
above = isPopupAboveWidget();

View file

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

View file

@ -130,10 +130,19 @@ void LineEditCompleter::keyPressEvent(QKeyEvent *event)
CompleterInfo *active = nullptr;
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) {
bool triggered = false;
switch (info.trigger) {
case CompleterTrigger::Mention: {
if (insideCardLink) {
break;
}
int triggerPos = textValue.lastIndexOf("@", cursorPos - 1);
if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) {
triggered = true;

View file

@ -100,10 +100,27 @@ void renderReportDetails(QTextEdit *chatLogEdit,
const QString &moderatorPrefix,
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()) {
chatLogEdit->setPlainText(QString::fromStdString(report.chat_log()));
const QString chatLog = QString::fromStdString(report.chat_log());
chatLogEdit->setPlainText(
details.isEmpty() ? chatLog : details + "\n\n" + QObject::tr("=== Chat Log ===") + "\n" + chatLog);
} else {
chatLogEdit->clear();
chatLogEdit->setPlainText(details);
}
commentsEdit->clear();

View file

@ -58,7 +58,9 @@
#include <QAction>
#include <QApplication>
#include <QButtonGroup>
#include <QCheckBox>
#include <QCloseEvent>
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDesktopServices>
@ -68,6 +70,7 @@
#include <QFileDialog>
#include <QFileInfo>
#include <QFlags>
#include <QFutureWatcher>
#include <QIODevice>
#include <QLocalSocket>
#include <QMenu>
@ -603,14 +606,45 @@ MainWindow::MainWindow(QWidget *parent)
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()
{
warnAboutDataPathsInProgramDir();
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
// checkUnknownSets() is intentionally deferred from the card database load
// (which runs in main() before MainWindow exists) so that
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live
// receivers when emitted.
// The loader never runs checkUnknownSets() itself (see main.cpp): the
// front-loaded parse runs before MainWindow exists, so
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no
// receivers. MainWindow owns the check after startup.
// 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
// Manage Sets dialog don't appear first.
@ -1104,18 +1138,35 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow
void MainWindow::cardDatabaseAllNewSetsEnabled()
{
if (firstRunWizardActive || CardDatabaseManager::getInstance()->getCardList().isEmpty()) {
// The onboarding wizard owns the first-run messaging on a clean install,
// and with no card data there are no sets to have enabled.
auto *cardDatabase = CardDatabaseManager::getInstance();
// With no card data there is nothing to have enabled and nothing to reparse.
if (cardDatabase->getCardList().isEmpty()) {
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."));
actManageSets();
// checkUnknownSets() enabled every set after the database had already been
// parsed under the (empty) first-run enablement, so the parser dropped every
// printing of every set. Reparse now that the sets are enabled, otherwise the
// deck editor's card database display stays empty until the next launch.
auto *watcher = new QFutureWatcher<void>(this);
connect(watcher, &QFutureWatcher<void>::finished, this, [this, watcher] {
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 */
@ -1202,7 +1253,7 @@ void MainWindow::createCardUpdateProcess(bool background)
<< dir.absoluteFilePath(binaryName);
QMessageBox::warning(this, tr("Error"),
tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName));
exitCardDatabaseUpdate();
exitCardDatabaseUpdate(false);
emit cardDatabaseUpdateFinished(false);
return;
}
@ -1279,7 +1330,7 @@ void MainWindow::cardUpdateStandardError()
}
}
void MainWindow::exitCardDatabaseUpdate()
void MainWindow::exitCardDatabaseUpdate(bool doReload)
{
if (!cardUpdateProcess) {
return;
@ -1288,7 +1339,21 @@ void MainWindow::exitCardDatabaseUpdate()
cardUpdateProcess = nullptr;
statusBar()->clearMessage();
const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
if (doReload) {
// 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)
@ -1324,7 +1389,7 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err)
}
qCWarning(CardDatabaseUpdateLog) << "The card database updater exited with an error:" << error;
exitCardDatabaseUpdate();
exitCardDatabaseUpdate(false);
QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error));
emit cardDatabaseUpdateFinished(false);
}
@ -1337,12 +1402,14 @@ void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatu
cardUpdateProgressOutput(); // drain any progress lines not yet parsed
const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0);
if (exitStatus == QProcess::NormalExit) {
if (success) {
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"
<< static_cast<int>(exitStatus) << "success:" << success;
exitCardDatabaseUpdate();
exitCardDatabaseUpdate(success);
notifyCardUpdateFinishedFromTray(success);
emit cardDatabaseUpdateFinished(success);
}
@ -1490,7 +1557,8 @@ void MainWindow::actAddCustomSet()
QMessageBox::information(
this, tr("Load sets/cards"),
tr("The new sets/cards have been added successfully.\nCockatrice will now reload the card database."));
const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
// Imported files can introduce sets the config has never seen.
reloadCardDatabaseAndCheckSets();
} else {
QMessageBox::warning(this, tr("Load sets/cards"), tr("Sets/cards failed to import."));
}

View file

@ -149,12 +149,23 @@ private:
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()
{
return "oracle";
}
void createCardUpdateProcess(bool background = false);
void exitCardDatabaseUpdate();
void exitCardDatabaseUpdate(bool doReload);
/**
* @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.
* @return true when the updater acknowledged the request

View file

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

View file

@ -38,7 +38,18 @@ 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:
TODO
`[quantity] Card Name [options]`
- `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
two blocks of cards will be considered as the divider between mainboard and sideboard.

63
gcovr.cfg Normal file
View file

@ -0,0 +1,63 @@
# 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,3 +60,5 @@ target_link_libraries(
PUBLIC libcockatrice_utility
PUBLIC ${QT_CORE_MODULE}
)
target_precompile_headers(libcockatrice_card PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -13,6 +13,7 @@
#include <QDebug>
#include <QList>
#include <QMessageLogger>
#include <QMetaObject>
#include <QMetaType>
#include <QMutexLocker>
#include <QSharedPointer>
@ -80,15 +81,17 @@ void CardDatabase::reloadCardDatabasesAndNotify()
{
// 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.
// 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);
if (loadStatus == Ok) {
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);
QMetaObject::invokeMethod(this, &CardDatabase::checkUnknownSets, Qt::AutoConnection);
}
}
@ -207,15 +210,19 @@ void CardDatabase::checkUnknownSets()
if (numUnknownSets > 0) {
emit cardDatabaseNewSetsFound(numUnknownSets, unknownSetNames);
} else {
_sets.markAllAsKnown();
_sets.markAllAsKnownBatched(setPriorityController);
}
} else {
// No set enabled. Probably this is the first time running trice
_sets.guessSortKeys();
_sets.enableAllBatched(setPriorityController);
_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();
}
}
@ -223,13 +230,13 @@ void CardDatabase::checkUnknownSets()
void CardDatabase::enableAllUnknownSets()
{
auto _sets = getSetList();
_sets.enableAllUnknown();
_sets.enableAllUnknownBatched(setPriorityController);
}
void CardDatabase::markAllSetsAsKnown()
{
auto _sets = getSetList();
_sets.markAllAsKnown();
_sets.markAllAsKnownBatched(setPriorityController);
}
void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets)

View file

@ -44,7 +44,13 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent,
// the finished snapshot is swapped into the live database on the GUI thread.
// when SettingsCache's path changes, trigger reloads
connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { loadCardDatabases(); });
connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] {
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()
@ -136,7 +142,23 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
loadCardDatabase(p, data);
}
if (!saveToCache(data, sourceHash)) {
// A snapshot parsed while no set is enabled has had every printing
// 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();
}
}
@ -148,18 +170,15 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
database->refreshCachedReverseRelatedCards(data.cards);
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 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 {
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed";
emit loadingFailed(); // bring up the settings dialog

View file

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

View file

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

View file

@ -1,9 +1,11 @@
#include "card_set_list.h"
#include "libcockatrice/card/set/card_set.h"
#include "libcockatrice/interfaces/interface_card_set_priority_controller.h"
#include <QDate>
#include <QString>
#include <QVector>
#include <algorithm>
#include <compare>
@ -62,60 +64,87 @@ QStringList CardSetList::getUnknownSetsNames()
return sets;
}
void CardSetList::enableAllUnknown()
void CardSetList::enableAllUnknownBatched(ICardSetPriorityController *controller)
{
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) {
set->setIsKnown(true);
set->setEnabled(true);
} else if (set && set->getIsKnownIgnored() && !set->getEnabled()) {
set->setEnabled(true);
}
}
}
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();
QVector<ICardSetPriorityController::SetSaveData> saveData;
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set.isNull()) {
// qCWarning(CardInfoLog) << "guessSortKeys set is null";
continue;
}
set->setSortKey(i);
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)
{
defaultSort();
QVector<ICardSetPriorityController::SetSaveData> saveData;
saveData.reserve(size());
for (int i = 0; i < size(); ++i) {
CardSetPtr set = at(i);
if (set.isNull()) {
continue;
}
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,36 +40,37 @@ public:
void sortByKey();
/**
* @brief Reassigns sort keys based on the current order.
* @brief Enables every set with fresh sort keys, persisting in one flush.
*
* Calls defaultSort() and then assigns sequential sort keys
* to all sets according to their resulting positions, replacing
* any existing sort keys to ensure consistent ordering.
* Assigns sequential sort keys in default-sorted order and marks every set
* known and enabled, writing all records through the controller's batched
* saveSets() in a single settings flush instead of ~3 QSettings rewrite
* 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 guessSortKeys();
void enableAllBatched(ICardSetPriorityController *controller);
/**
* @brief Enables all sets that are unknown or ignored.
* @brief Enables all sets that are unknown or ignored, persisting in one flush.
*
* 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.
* 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 enableAllUnknown();
void enableAllUnknownBatched(ICardSetPriorityController *controller);
/**
* @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.
* @brief Marks all sets as known and adjusts their enabled state, persisting in one flush.
*
* Unknown, non-ignored sets become known and 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 markAllAsKnown();
void markAllAsKnownBatched(ICardSetPriorityController *controller);
/**
* @brief Counts the number of sets that are currently enabled.

View file

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

View file

@ -33,6 +33,7 @@ bool parse(QTextStream &in,
// Regex for advanced card parsing
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
static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))");
@ -110,8 +111,34 @@ bool parse(QTextStream &in,
continue;
}
QString cardName = match.captured().simplified();
const QString rawLine = match.captured();
QString cardName = rawLine.simplified();
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
if (sBStart < 0) {
@ -156,11 +183,12 @@ bool parse(QTextStream &in,
}
// check if a specific amount is mentioned
int amount = 1;
match = reMultiplier.match(cardName);
if (match.hasMatch()) {
amount = match.captured(1).toInt();
cardName = match.captured(2);
if (!hasTabQuantity) {
match = reMultiplier.match(cardName);
if (match.hasMatch()) {
amount = match.captured(1).toInt();
cardName = match.captured(2);
}
}
// Normalize the card name

View file

@ -18,3 +18,5 @@ add_dependencies(libcockatrice_filters libcockatrice_card)
target_include_directories(libcockatrice_filters PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
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,3 +31,5 @@ add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES})
target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
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,6 +12,7 @@ public:
QString shortName;
unsigned int sortKey;
bool enabled;
bool isKnown = false;
};
/**
@ -57,6 +58,14 @@ public:
*/
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;
};

View file

@ -29,3 +29,5 @@ add_library(
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_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]);
sets[i]->setSortKeyInMemory(sortKey);
sets[i]->setEnabledInMemory(enabled);
saveData.append({sets[i]->getShortName(), sortKey, enabled});
saveData.append({sets[i]->getShortName(), sortKey, enabled, sets[i]->getIsKnown()});
}
db->getPriorityController()->saveSets(saveData);

View file

@ -16,3 +16,5 @@ target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT
target_link_libraries(
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,3 +19,5 @@ target_link_libraries(
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol
libcockatrice_network_server_remote
)
target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

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

View file

@ -19,3 +19,5 @@ target_link_libraries(
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
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,3 +15,5 @@ add_dependencies(libcockatrice_network_server_local libcockatrice_protocol)
target_include_directories(libcockatrice_network_server_local PUBLIC .)
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,6 +3,7 @@ set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
game/dump_zone_range.h
game/server_abstract_participant.h
game/server_abstract_player.h
game/server_arrow.h
@ -33,6 +34,7 @@ qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
add_library(
libcockatrice_network_server_remote STATIC
${MOC_SOURCES}
game/dump_zone_range.cpp
game/server_abstract_participant.cpp
game/server_abstract_player.cpp
game/server_arrow.cpp
@ -62,3 +64,5 @@ target_link_libraries(
libcockatrice_network_server_remote PUBLIC libcockatrice_protocol libcockatrice_utility libcockatrice_rng
libcockatrice_deck_list ${COCKATRICE_QT_MODULES}
)
target_precompile_headers(libcockatrice_network_server_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -0,0 +1,17 @@
#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

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

View file

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

View file

@ -61,6 +61,8 @@
#include <QTimer>
#include <google/protobuf/descriptor.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/event_delete_arrow.pb.h>
#include <libcockatrice/protocol/pb/event_game_closed.pb.h>
@ -825,6 +827,48 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
}
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,

View file

@ -187,6 +187,39 @@ public:
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;
int getNextLocalGameId()

View file

@ -592,6 +592,17 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
QString reasonStr;
int banSecondsLeft = 0;
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,
clientId, clientVersion, connectionType);
switch (res) {
@ -605,10 +616,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
return Response::RespUserIsBanned;
}
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;
case WouldOverwriteOldSession:
return Response::RespWouldOverwriteOldSession;
case UsernameInvalid: {
server->recordFailedLogin(getAddress(), userName);
auto *re = new Response_Login;
re->set_denied_reason_str(reasonStr.toStdString());
rc.setResponseExtension(re);
@ -619,10 +640,12 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
case ClientIdRequired:
return Response::RespClientIdRequired;
case UserIsInactive:
server->recordFailedLogin(getAddress(), userName);
return Response::RespAccountNotActivated;
case PasswordChangeRequired:
return Response::RespPasswordChangeRequired;
default:
server->clearFailedLogins(getAddress(), userName);
authState = res;
usingRealPassword = needsHash;
}

View file

@ -24,3 +24,5 @@ target_include_directories(
libcockatrice_protocol PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
${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,6 +55,7 @@ set(PROTO_FILES
command_report_details.proto
command_report_list.proto
command_report_my_list.proto
command_report_reopen.proto
command_report_resolve.proto
command_report_stats.proto
command_report_user_info.proto

View file

@ -0,0 +1,10 @@
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,6 +26,7 @@ message ModeratorCommand {
REPLAY_DOWNLOAD_BY_GAME_ID = 1203;
REPORT_USER_INFO = 1204;
REPORT_STATS = 1205;
REPORT_REOPEN = 1206;
}
extensions 100 to max;
}

View file

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

View file

@ -11,6 +11,10 @@ add_library(
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_link_libraries(libcockatrice_rng PUBLIC ${QT_CORE_MODULE})

View file

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

View file

@ -2,6 +2,8 @@
#include "libcockatrice/settings/settings_manager.h"
#include <QDir>
#include <QStringList>
#include <QVariant>
class QObject;
@ -121,3 +123,52 @@ void PathsSettings::setSpoilerDatabasePath(const QString &_spoilerDatabasePath)
setValue(_spoilerDatabasePath, "spoilerDatabase");
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,6 +4,7 @@
#include "settings_manager.h"
#include <QString>
#include <QStringList>
#include <libcockatrice/interfaces/interface_paths_settings_provider.h>
#include <qtmetamacros.h>
@ -27,6 +28,24 @@ public:
[[nodiscard]] QString getSpoilerCardDatabasePath() 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 setFiltersPath(const QString &_filtersPath);
void setReplaysPath(const QString &_replaysPath);
@ -37,14 +56,17 @@ public:
void setCustomCardDatabasePath(const QString &_customCardDatabasePath);
void setTokenDatabasePath(const QString &_tokenDatabasePath);
void setSpoilerDatabasePath(const QString &_spoilerDatabasePath);
void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged);
signals:
void cardDatabasePathChanged();
void picsPathChanged();
void themeChanged();
private:
public:
explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr);
private:
PathsSettings(const PathsSettings & /*other*/);
};

View file

@ -642,6 +642,34 @@ static void migrateLegacyMessages(const QString &settingsPath)
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)
{
QSettings legacySetting;
@ -652,24 +680,7 @@ static void migrateLegacyGameFilters(const QString &settingsPath)
return;
}
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"},
};
const QMap<QString, QString> &filterKeyMap = renamedGameFilterKeys();
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) {
@ -686,6 +697,68 @@ static void migrateLegacyGameFilters(const QString &settingsPath)
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)
{
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
@ -744,6 +817,10 @@ bool SettingsMigration::isMigrationPending(const QString &settingsPath)
return true;
}
if (gameFiltersCamelCaseMigrationPending(settingsPath)) {
return true;
}
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
if (personalIni.value("migration/legacy_complete", false).toBool()) {
return false;

View file

@ -9,6 +9,15 @@ public:
static bool migrateSettingsFromGlobalIni(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
* backup of the settings directory is worth taking before they run.

View file

@ -49,3 +49,5 @@ target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Cr
set(ORACLE_LIBS)
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,6 +79,9 @@ if(ZLIB_FOUND)
include_directories(${ZLIB_INCLUDE_DIRS})
add_definitions("-DHAS_ZLIB")
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()
message(STATUS "Oracle: zlib not found; ZIP support disabled")
endif()
@ -89,6 +92,9 @@ if(LIBLZMA_FOUND)
include_directories(${LIBLZMA_INCLUDE_DIRS})
add_definitions("-DHAS_LZMA")
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()
message(STATUS "Oracle: LibLZMA not found; xz support disabled")
endif()

View file

@ -85,5 +85,8 @@ int main(int argc, char *argv[])
QTimer::singleShot(0, &wizard, [&wizard]() { wizard.runInBackground(); });
}
return app.exec();
const int execResult = 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,12 +128,20 @@ void OracleWizard::accept()
void OracleWizard::reject()
{
cancelled = true;
// 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
// destroyed, so the worker thread never touches freed memory.
if (auto *active = dynamic_cast<OracleWizardPage *>(currentPage())) {
active->cancelWork();
}
const auto replies = nam->findChildren<QNetworkReply *>();
for (QNetworkReply *reply : replies) {
reply->abort();
}
QWizard::reject();
}

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